diff --git a/scripts/bcn_sim.py b/scripts/bcn_sim.py new file mode 100755 index 0000000000..61f567a01d --- /dev/null +++ b/scripts/bcn_sim.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +"""bcn_sim.py — bridge-provider 本地调试台(扮演 BCS 一侧)。 + +一个终端跑 `serve` 启动 bridge(mock 或真 cfuse 引擎均经 engine-tee.sh 采集), +另一个终端用 `send` 模拟 BCS 下行 chat.send,实时逐帧打印转换后的 SSE; +引擎原始事件(cfuse cc stream-json / codex JSONL)由 engine-tee.sh 落盘, +`logs` 随时查看,与 SSE 帧一一对照。 + +子命令: + serve 构建+启动 bridge(默认 mock_cc.sh 引擎) + send TEXT 模拟 BCS chat.send,流式打印 SSE 帧(交互请求时提示决策) + resolve IID 对挂起的 interaction 发 interaction.resolve + abort 对会话的活跃 run 发 chat.abort + inject TEXT 发 chat.inject(上下文注入,不触发引擎) + ping 发 bot.ping + logs [TARGET] 看引擎原始 stdout / bridge→引擎 stdin / stderr / runs.log + +常用: + python3 scripts/bcn_sim.py serve # mock cc 引擎 + python3 scripts/bcn_sim.py send "讲个笑话" + python3 scripts/bcn_sim.py logs stdout -f # 原始引擎事件 + python3 scripts/bcn_sim.py logs converted -f # bridge 转换事件 + python3 scripts/bcn_sim.py logs sse -f # 最终 SSE frame + python3 scripts/bcn_sim.py serve --mock mock_cc_approval.sh # HITL 审批流 + python3 scripts/bcn_sim.py send "部署" --auto-allow + python3 scripts/bcn_sim.py serve --engine cfuse-codex + python3 scripts/bcn_sim.py serve --real [--cfuse /path/to/cfuse] +""" + +import argparse +import http.client +import json +import os +import subprocess +import sys +import time +import uuid + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BCS = os.path.join(REPO, "src", "bcs") +FIXTURES = os.path.join(BCS, "crates", "adapters", "bridge-provider", "tests", "fixtures") +BIN = os.path.join(BCS, "target", "debug", "bridge-provider") +TEE = os.path.join(REPO, "scripts", "engine-tee.sh") + +DEV = os.environ.get("BRIDGE_DEV_DIR", "/tmp/bridge-dev") +LISTEN = os.environ.get("BRIDGE_LISTEN", "127.0.0.1:21100") +TOKEN = os.environ.get("BRIDGE_TOKEN", "tok-b2p") +PROVIDER_ID = os.environ.get("BRIDGE_PROVIDER_ID", "bridge-1") +BOT_REF = os.environ.get("BRIDGE_BOT_REF", "worker-1") + +LOG_FILES = { + "stdout": "engine.stdout.ndjson", # 引擎原始事件(一行一个) + "stdin": "engine.stdin.jsonl", # bridge 写给引擎的行(user 消息/control_response) + "stderr": "engine.stderr.log", + "stderr-json": "engine.stderr.ndjson", # 无 ANSI 的结构化 stderr + "raw": "engine.raw.ndjson", # bridge 读取到的原始 stdout + "converted": "bridge.converted.ndjson", # StreamEvent 转换结果 + "sse": "bridge.sse.ndjson", # 发给 BCS 的最终 SSE frame + "runs": "runs.log", +} + +TTY = sys.stdout.isatty() + + +def c(code: int, s: str) -> str: + return f"\x1b[{code}m{s}\x1b[0m" if TTY else s + + +def dim(s): return c(2, s) +def green(s): return c(32, s) +def red(s): return c(31, s) +def blue(s): return c(34, s) +def yellow(s): return c(33, s) +def bold(s): return c(1, s) + + +def _hostport(listen=None): + host, _, port = (listen or LISTEN).rpartition(":") + return host or "127.0.0.1", int(port) + + +def post(payload: dict, headers: dict | None = None, timeout: int = 30, listen=None): + """POST /webhook,返回 (conn, response)。body 用 utf-8 bytes(中文安全)。""" + host, port = _hostport(listen) + conn = http.client.HTTPConnection(host, port, timeout=timeout) + h = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"} + if headers: + h.update(headers) + conn.request("POST", "/webhook", body=json.dumps(payload, ensure_ascii=False).encode("utf-8"), headers=h) + return conn, conn.getresponse() + + +def req_shell(method: str, run: str, session: str | None, message=None, from_=None, params=None) -> dict: + r = {"type": "req", "id": run, "method": method, + "to_bot": {"provider_id": PROVIDER_ID, "provider_bot_ref": BOT_REF}} + if session is not None: + r["session_id"] = session + if message is not None: + r["message"] = message + if from_ is not None: + r["from"] = from_ + if params is not None: + r["params"] = params + return r + + +def msg(text: str) -> dict: + return {"role": "user", "content": [{"type": "text", "text": text}]} + + +# ---------------------------------------------------------------- serve + +def cmd_serve(args): + os.makedirs(DEV, exist_ok=True) + ws = args.cwd + os.makedirs(ws, exist_ok=True) + + engine_kind = args.engine + if args.real: + real = args.cfuse or "cfuse" + else: + fixture = args.mock or ("mock_cc.sh" if engine_kind == "cfuse-cc" else "mock_codex.sh") + real = os.path.join(FIXTURES, fixture) + if not os.path.exists(real): + sys.exit(f"mock fixture 不存在: {real}") + os.chmod(real, 0o755) + os.chmod(TEE, 0o755) + if not os.path.exists(TEE): + sys.exit(f"缺少 engine-tee.sh: {TEE}") + + cfg = ( + f'provider_id = "{PROVIDER_ID}"\n' + f'listen = "{args.listen}"\n' + f'bcs_to_provider_token = "{TOKEN}"\n\n' + f'trace_dir = "{DEV}"\n\n' + f'[[bot]]\n' + f'provider_bot_ref = "{BOT_REF}"\n' + f'engine = "{engine_kind}"\n' + f'cwd = "{ws}"\n' + f'cfuse_bin = "{TEE}"\n' + ) + cfg_path = os.path.join(DEV, "bridge.toml") + with open(cfg_path, "w") as f: + f.write(cfg) + + print(bold(f">> bridge 配置 ({cfg_path})")) + print(dim(" " + cfg.replace("\n", "\n "))) + print(bold(f">> 引擎: {engine_kind} REAL_ENGINE={real}")) + print(bold(f">> 引擎原始事件将落盘到 {DEV}/engine.stdout.ndjson")) + print(dim(f">> bridge trace: {DEV}/engine.raw.ndjson / bridge.converted.ndjson / bridge.sse.ndjson")) + print(dim(f">> 试发一条: python3 {sys.argv[0]} send \"讲个笑话\"")) + sys.stdout.flush() + + subprocess.run(["cargo", "build", "--manifest-path", os.path.join(BCS, "Cargo.toml"), "-p", "bridge-provider"], check=True) + if not os.path.exists(BIN): + sys.exit(f"构建产物缺失: {BIN}") + + env = os.environ.copy() + env.update({"BRIDGE_CONFIG": cfg_path, "REAL_ENGINE": real, "ENGINE_LOG_DIR": DEV, + "RUST_LOG": args.log}) + os.execve(BIN, [BIN], env) + + +# ---------------------------------------------------------------- SSE 帧渲染 + +EVENT_STYLE = { + "chat": green, + "agent": blue, + "interaction": yellow, + "ping": dim, +} + + +def render_frame(idx: int, event: str, fid: str, data: str, pretty: bool) -> dict | None: + """打印一帧,返回解析后的 dict(解析失败返回 None)。""" + style = EVENT_STYLE.get(event, str) + label = f" #{idx:<3} {style(event or 'data')}" + if fid: + label += dim(f" id={fid}") + obj = None + try: + obj = json.loads(data) + except (json.JSONDecodeError, TypeError): + pass + print(label, end="", flush=True) + if pretty and obj is not None: + print() + print(dim(" " + json.dumps(obj, ensure_ascii=False, indent=2).replace("\n", "\n "))) + elif data: + print(" " + data) + else: + print() + return obj + + +def summarize_terminal(obj: dict) -> str: + state = obj.get("state") + if state == "final": + content = (obj.get("message") or {}).get("content") or [] + texts = [p.get("text", "") for p in content if isinstance(p, dict)] + return green("✔ final") + dim(f" text={(''.join(texts))[:120]!r} stopReason={obj.get('stopReason')}") + if state == "error": + return red(f"✖ error {obj.get('errorMessage')} kind={obj.get('errorKind')}") + if state == "aborted": + return yellow(f"⊘ aborted stopReason={obj.get('stopReason')}") + return "" + + +def fmt_interaction_detail(obj: dict) -> str: + out = [] + if obj.get("kind") == "exec": + out.append(dim(f" command: {obj.get('command')!r}")) + for opt in obj.get("options") or []: + out.append(dim(f" option: {opt.get('decision'):<10} {opt.get('label')}")) + for q in obj.get("questions") or []: + out.append(dim(f" Q: {q.get('question')} options={[o.get('label') for o in q.get('options') or []]}")) + return "\n ".join(out) + + +# ---------------------------------------------------------------- send + +def resolve_interaction(run: str, session: str | None, iid: str, kind: str, decision: str) -> str: + payload = req_shell("interaction.resolve", f"resolve-{uuid.uuid4().hex[:6]}", session, + params={"bcsRunId": run, "runId": run, "interactionId": iid, "kind": kind, + "idempotencyKey": f"key-{iid}-{int(time.time())}", "decision": decision}) + conn, resp = post(payload, timeout=30) + body = resp.read().decode("utf-8", "replace") + ok = '"ok":true' in body or '"ok": true' in body + print(yellow(f" ↳ interaction.resolve[{iid}] decision={decision} → HTTP {resp.status} {body.strip()}") + if ok else red(f" ↳ interaction.resolve[{iid}] 失败: HTTP {resp.status} {body.strip()}")) + conn.close() + return body + + +def cmd_send(args): + run = args.run or f"run-{uuid.uuid4().hex[:8]}" + payload = req_shell("chat.send", run, args.session, message=msg(args.text)) + print(bold(f">> BCN chat.send run={run} session={args.session}")) + print(dim(f">> to_bot={PROVIDER_ID}/{BOT_REF} text={args.text!r}")) + sys.stdout.flush() + + conn, resp = post(payload, headers={"X-BCN-Protocol-Version": "2.0", "Accept": "text/event-stream"}, + timeout=args.timeout) + ctype = resp.getheader("Content-Type") or "" + if resp.status != 200 or "text/event-stream" not in ctype: + body = resp.read().decode("utf-8", "replace") + print(red(f"<< HTTP {resp.status} {ctype}\n{body}")) + conn.close() + return 1 + + idx = 0 + counts: dict[str, int] = {} + terminal = "" + cur: dict[str, str] = {} + started = time.time() + try: + for raw in resp: + line = raw.decode("utf-8", "replace").rstrip("\r\n") + if line.startswith(":"): # SSE comment = heartbeat + print(dim(" · heartbeat"), flush=True) + continue + if not line: + if cur: + idx += 1 + counts[cur.get("event", "?")] = counts.get(cur.get("event", "?"), 0) + 1 + obj = render_frame(idx, cur.get("event", ""), cur.get("id", ""), cur.get("data", ""), args.pretty) + # interaction/requested → 发起 HITL 决策(模拟 BCS 路由给 Human) + if cur.get("event") == "interaction" and isinstance(obj, dict) and obj.get("phase") == "requested": + print(yellow(bold(f" ⏸ 交互等待 Human 决策 (interactionId={obj.get('interactionId')} kind={obj.get('kind')})"))) + detail = fmt_interaction_detail(obj) + if detail: + print(detail) + decision = _pick_decision(args) + resolve_interaction(run, args.session, obj["interactionId"], obj.get("kind") or "exec", decision) + if cur.get("event") == "chat" and isinstance(obj, dict): + term = summarize_terminal(obj) + if term: + terminal = term + cur = {} + else: + key, _, val = line.partition(":") + val = val.lstrip(" ") + if key == "event": + cur["event"] = val + elif key == "id": + cur["id"] = val + elif key == "data": + cur["data"] = cur.get("data", "") + val + except (TimeoutError, OSError) as e: + print(red(f"\n<< 流中断: {e}")) + finally: + conn.close() + + elapsed = time.time() - started + stats = " ".join(f"{k}×{v}" for k, v in counts.items()) + print(bold("――――――――――――――――――――――――――――――")) + print(f"{terminal or dim('(未读到终态帧)')} {dim(f'帧数={idx} {stats} {elapsed:.1f}s')}") + print(dim(f"run={run} session={args.session}")) + print(dim(f"引擎原始事件: {DEV}/engine.stdout.ndjson (tail -f 对照)")) + print(dim(f"bridge→引擎: {DEV}/engine.stdin.jsonl")) + return 0 if terminal else 2 + + +def _pick_decision(args) -> str: + if args.auto_allow: + return "allow_once" + if args.auto_deny: + return "deny" + while True: + answer = input(c(33, " Human 决策? [allow_once/deny] ")).strip().lower() + if answer in ("allow_once", "allow", "deny"): + return "allow_once" if answer == "allow" else answer + print(dim(" 请输入 allow_once 或 deny")) + + +# ---------------------------------------------------------------- 其余子命令 + +def _simple_json_call(payload: dict, label: str, headers=None): + conn, resp = post(payload, headers=headers) + body = resp.read().decode("utf-8", "replace") + conn.close() + try: + pretty = json.dumps(json.loads(body), ensure_ascii=False, indent=2) + except json.JSONDecodeError: + pretty = body + print(f">> {label} id={payload['id']}") + print(f"<< HTTP {resp.status}\n{pretty}") + return 0 if resp.status == 200 else 1 + + +def cmd_resolve(args): + payload = req_shell("interaction.resolve", f"resolve-{uuid.uuid4().hex[:6]}", args.session, + params={"bcsRunId": args.run, "runId": args.run, "interactionId": args.iid, + "kind": args.kind, "idempotencyKey": f"key-{args.iid}-{int(time.time())}", + "decision": args.decision}) + return _simple_json_call(payload, "interaction.resolve") + + +def cmd_abort(args): + payload = req_shell("chat.abort", f"abort-{uuid.uuid4().hex[:6]}", args.session) + return _simple_json_call(payload, "chat.abort") + + +def cmd_inject(args): + payload = req_shell("chat.inject", f"inj-{uuid.uuid4().hex[:6]}", args.session, + message=msg(args.text), + from_={"kind": "bot", "name": args.from_name}) + return _simple_json_call(payload, "chat.inject") + + +def cmd_ping(_args): + payload = req_shell("bot.ping", f"ping-{uuid.uuid4().hex[:6]}", None) + return _simple_json_call(payload, "bot.ping") + + +def cmd_logs(args): + name = LOG_FILES[args.target] + path = os.path.join(DEV, name) + print(dim(f">> {args.target}: {path}")) + if not os.path.exists(path): + print(dim(f" (尚未生成 — 先 serve + send 一次)")) + return 0 + cmd = ["tail", f"-n", str(args.lines)] + if args.follow: + cmd.append("-f") + cmd.append(path) + os.execvp("tail", cmd) + + +# ---------------------------------------------------------------- main + +def main(): + ap = argparse.ArgumentParser(description="bridge-provider 本地调试台(模拟 BCS)") + sub = ap.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("serve", help="构建并启动 bridge(前台, Ctrl-C 优雅退出)") + p.add_argument("--engine", default="cfuse-cc", choices=["cfuse-cc", "cfuse-codex"]) + p.add_argument("--mock", default=None, help="mock 引擎 fixture 名(默认按 engine 选)") + p.add_argument("--real", action="store_true", help="用真 cfuse(--engine 选 cc/codex 模式)") + p.add_argument("--cfuse", default=None, help="真实 cfuse 二进制路径(默认 PATH 里的 cfuse)") + p.add_argument("--listen", default=LISTEN) + p.add_argument("--cwd", default=os.path.join(DEV, "workspace")) + p.add_argument("--log", default=os.environ.get("RUST_LOG", "info")) + p.set_defaults(func=cmd_serve) + + p = sub.add_parser("send", help="模拟 BCS chat.send 并逐帧打印 SSE") + p.add_argument("text") + p.add_argument("--session", default="s-1") + p.add_argument("--run", default=None) + p.add_argument("--timeout", type=int, default=600) + p.add_argument("--auto-allow", action="store_true", help="interaction/requested 自动 allow_once") + p.add_argument("--auto-deny", action="store_true", help="interaction/requested 自动 deny") + p.add_argument("--pretty", action="store_true", help="data JSON 缩进展开") + p.set_defaults(func=cmd_send) + + p = sub.add_parser("resolve", help="对挂起 interaction 发 interaction.resolve") + p.add_argument("iid") + p.add_argument("--decision", default="allow_once", choices=["allow_once", "deny"]) + p.add_argument("--session", default="s-1") + p.add_argument("--run", default="run-1") + p.add_argument("--kind", default="exec") + p.set_defaults(func=cmd_resolve) + + p = sub.add_parser("abort", help="chat.abort 当前会话活跃 run") + p.add_argument("--session", default="s-1") + p.set_defaults(func=cmd_abort) + + p = sub.add_parser("inject", help="chat.inject 上下文(不触发引擎)") + p.add_argument("text") + p.add_argument("--session", default="s-1") + p.add_argument("--from-name", default="observer") + p.set_defaults(func=cmd_inject) + + p = sub.add_parser("ping", help="bot.ping") + p.set_defaults(func=cmd_ping) + + p = sub.add_parser("logs", help="查看引擎原始输入/输出日志") + p.add_argument("target", nargs="?", default="stdout", choices=list(LOG_FILES)) + p.add_argument("-f", "--follow", action="store_true") + p.add_argument("--lines", type=int, default=60) + p.set_defaults(func=cmd_logs) + + args = ap.parse_args() + sys.exit(args.func(args)) + + +if __name__ == "__main__": + main() diff --git a/scripts/engine-tee.sh b/scripts/engine-tee.sh new file mode 100755 index 0000000000..369b5c2a96 --- /dev/null +++ b/scripts/engine-tee.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# engine-tee.sh — cfuse 引擎采集包装器(开发调试用,不进生产) +# +# bridge 配置里的 cfuse_bin 指向本脚本。本脚本把三方数据全部落盘后再转给 +# 真正的引擎(REAL_ENGINE 指定,mock fixture 或真 cfuse),逐行无缓冲透传: +# +# bridge→引擎 stdin $ENGINE_LOG_DIR/engine.stdin.jsonl (bridge 写给引擎的行) +# 引擎→bridge stdout $ENGINE_LOG_DIR/engine.stdout.ndjson (cfuse 原始事件,一行一个) +# 引擎 stderr $ENGINE_LOG_DIR/engine.stderr.log +# 每次引擎启动 $ENGINE_LOG_DIR/runs.log (时间/pid/args) +# +# 用法(bind 会通过环境变量自带): +# REAL_ENGINE=/path/to/mock_cc.sh ENGINE_LOG_DIR=/tmp/bridge-dev \ +# ./scripts/engine-tee.sh --cc --output-format stream-json ... +set -o pipefail + +DIR="${ENGINE_LOG_DIR:-/tmp/bridge-dev}" +REAL="${REAL_ENGINE:?REAL_ENGINE not set — point it at the engine binary/script}" +mkdir -p "$DIR" + +printf '[%s] engine pid=%s args=%s\n' "$(date '+%F %T')" "$$" "$*" >> "$DIR/runs.log" + +tee -a "$DIR/engine.stdin.jsonl" \ + | python3 -c ' +import fcntl +import os +import sys + +for size in (1024 * 1024, 64 * 1024, 16 * 1024, 8 * 1024): + try: + actual = fcntl.fcntl(1, fcntl.F_SETPIPE_SZ, size) + except (AttributeError, OSError): + continue + if actual >= 8 * 1024: + break + +os.execvp(sys.argv[1], sys.argv[1:]) +' "$REAL" "$@" 2> >(tee -a "$DIR/engine.stderr.log" >&2) \ + | tee -a "$DIR/engine.stdout.ndjson" +exit $? diff --git a/src/bcs/Cargo.lock b/src/bcs/Cargo.lock index d547cf9ab5..358d84fc45 100644 --- a/src/bcs/Cargo.lock +++ b/src/bcs/Cargo.lock @@ -1975,6 +1975,30 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bridge-provider" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "bcs-protocol", + "futures", + "libc", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "toml", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "bstr" version = "1.13.1" diff --git a/src/bcs/Cargo.toml b/src/bcs/Cargo.toml index 2e7e818cc9..ac59470b4e 100644 --- a/src/bcs/Cargo.toml +++ b/src/bcs/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/adapters/http/bcs-http", "crates/adapters/http/bcs-provider-http", "crates/adapters/ws/bcs-ws", + "crates/adapters/bridge-provider", # service-api "crates/service-api/bcs-config-api", "crates/service-api/bcs-service-api", @@ -124,6 +125,7 @@ version = "0.1.0" # Async runtime tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } +tokio-stream = "0.1" tokio-tungstenite = { version = "0.26", features = ["native-tls"] } # HTTP server diff --git a/src/bcs/crates/adapters/bridge-provider/Cargo.toml b/src/bcs/crates/adapters/bridge-provider/Cargo.toml new file mode 100644 index 0000000000..a1cf483af6 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "bridge-provider" +description = "BCN Provider 2.0 bridge to local coding engines (cfuse cc/codex)" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +axum = { workspace = true } +bcs-protocol = { workspace = true } +libc = "0.2" +futures = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-stream = { workspace = true } +tokio-util = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +uuid = { workspace = true } + +[dev-dependencies] +reqwest = { workspace = true } +tempfile = { workspace = true } diff --git a/src/bcs/crates/adapters/bridge-provider/src/config.rs b/src/bcs/crates/adapters/bridge-provider/src/config.rs new file mode 100644 index 0000000000..b35e350cd2 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/config.rs @@ -0,0 +1,98 @@ +use std::{net::SocketAddr, path::{Path, PathBuf}}; +use serde::Deserialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum EngineKind { CfuseCc, CfuseCodex } + +#[derive(Debug, Clone, Deserialize)] +pub struct BotConfig { + pub provider_bot_ref: String, + pub engine: EngineKind, + pub model: Option, + pub cwd: PathBuf, + pub permission_mode: Option, + pub cfuse_bin: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ProviderConfig { + pub provider_id: String, + pub listen: SocketAddr, + pub bcs_to_provider_token: String, + pub bot_runtime_token: Option, + #[serde(default)] + pub trace_dir: Option, + #[serde(rename = "bot")] + pub bots: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("read config: {0}")] + Read(#[from] std::io::Error), + #[error("parse config: {0}")] + Parse(#[from] toml::de::Error), +} + +impl ProviderConfig { + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path)?; + Ok(toml::from_str(&text)?) + } + pub fn bot(&self, provider_bot_ref: &str) -> Option<&BotConfig> { + self.bots.iter().find(|b| b.provider_bot_ref == provider_bot_ref) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loads_provider_config_and_finds_bot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bridge.toml"); + std::fs::write( + &path, + r#" +provider_id = "bridge-1" +listen = "127.0.0.1:21100" +bcs_to_provider_token = "tok-b2p" + +[[bot]] +provider_bot_ref = "cc-worker" +engine = "cfuse-cc" +model = "sonnet" +cwd = "/tmp" +"#, + ) + .unwrap(); + let cfg = ProviderConfig::load(&path).unwrap(); + assert_eq!(cfg.provider_id, "bridge-1"); + let bot = cfg.bot("cc-worker").unwrap(); + assert_eq!(bot.engine, EngineKind::CfuseCc); + assert_eq!(bot.model.as_deref(), Some("sonnet")); + assert!(cfg.bot("nope").is_none()); + } + + #[test] + fn rejects_unknown_engine_kind() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bridge.toml"); + std::fs::write( + &path, + r#" +provider_id = "bridge-1" +listen = "127.0.0.1:21100" +bcs_to_provider_token = "t" +[[bot]] +provider_bot_ref = "x" +engine = "bogus" +cwd = "/tmp" +"#, + ) + .unwrap(); + assert!(ProviderConfig::load(&path).is_err()); + } +} diff --git a/src/bcs/crates/adapters/bridge-provider/src/engine/cfuse_cc.rs b/src/bcs/crates/adapters/bridge-provider/src/engine/cfuse_cc.rs new file mode 100644 index 0000000000..5ab2dceb51 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/engine/cfuse_cc.rs @@ -0,0 +1,974 @@ +//! `CfuseCc` driver: maps claude `stream-json` NDJSON lines to engine-neutral +//! [`StreamEvent`]s and drives one downstream turn over a [`CliSession`]. +//! +//! 调用形态(对齐 aix-relay `codefuse_direct_args`,spec §4.2): +//! +//! ```text +//! cfuse --cc --output-format stream-json --verbose --input-format stream-json +//! --include-partial-messages +//! [--permission-mode ] [--resume ] [--model ] +//! ``` +//! +//! 启动后立刻向 stdin 写一条 user 消息(claude stream-json 输入格式)。 +//! +//! 事件映射表(cc stream-json → StreamEvent): +//! +//! | cc 事件 | 映射 | +//! | --- | --- | +//! | `{"type":"system","subtype":"init","session_id":…}` | `CcMap::SessionId` | +//! | `{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":…}}}` | `chat_delta` | +//! | `{"type":"assistant","message":{"content":[{"type":"tool_use","id","name","input"}]}}` | `agent_tool(Start, name, toolCallId=id, args=input)` | +//! | `{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id","content"}]}}` | `agent_tool(Result, toolCallId=tool_use_id, result=…)` | +//! | `{"type":"result","subtype":"success","result":…}` | `CcMap::Final(text)` | +//! | `{"type":"result","subtype":_}` 非成功 | `CcMap::Failed(note)` → `TurnError::EngineExited` | +//! | `{"type":"control_request","request":{"subtype":"can_use_tool",…}}` | `CcMap::ControlRequest { request_id, tool_name, input }` → 驱动接线 HITL 交互(register→requested→await resolution→control_response→resolved) | + +use std::path::PathBuf; + +use bcs_protocol::stream::{InteractionKind, InteractionPhase, StreamEvent, ToolData, ToolPhase}; +use serde_json::{json, Value}; + +use crate::engine::cli::CliSession; +use crate::engine::{ + is_valid_engine_session_id, Engine, EngineKind, TurnError, TurnOutcome, TurnRequest, +}; +use crate::sse; + +/// `cfuse --cc` (claude stream-json) 引擎驱动。 +pub struct CfuseCc { + bin: PathBuf, +} + +impl CfuseCc { + pub fn new(bin: PathBuf) -> Self { + Self { bin } + } +} + +/// 单行 claude `stream-json` NDJSON 的映射结果。 +/// +/// `SessionId` 携带 `system/init` 的引擎内 session id; +/// `Final` 携带成功 `result` 的最终助手文本; +/// `Failed` 携带非成功 `result` 的经净化退出原因(调用方上抛为 +/// `TurnError::EngineExited`);`Events` 是该行产出的 [`StreamEvent`]; +/// `Ignore` 标记“JSON 合法但未识别”;`Malformed` 标记“非法 JSON”; +/// `ControlRequest` 携带 `can_use_tool` 的 request_id/tool_name/input,由 +/// `run_turn` 接线为 HITL 交互(Task 12)。 +#[derive(Debug)] +pub(crate) enum CcMap { + Events(Vec), + SessionId(String), + Final(String), + Failed(String), + Ignore, + Malformed, + /// Engine permission request (`can_use_tool`). Fields surfaced to the driver + /// for the interaction roundtrip; `request_id` is engine-native and stays + /// inside the driver (never sent to BCS as an interactionId). + ControlRequest { + request_id: String, + tool_name: String, + input: Value, + }, +} + +/// 把一行 claude `stream-json` NDJSON 映射为引擎中立的 [`CcMap`]。 +/// +/// 纯函数(无 IO),便于用录制 fixture 做单元测试;按上方映射表逐类分派。 +pub(crate) fn map_cc_line(line: &str, run_id: &str) -> CcMap { + let value: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => return CcMap::Malformed, + }; + let ty = match value.get("type").and_then(|v| v.as_str()) { + Some(t) => t, + None => return CcMap::Ignore, + }; + match ty { + "system" => map_system(&value), + "stream_event" => map_stream_event(&value, run_id), + "assistant" => map_assistant(&value, run_id), + "user" => map_user(&value, run_id), + "result" => map_result(&value), + "control_request" => map_control_request(&value, run_id), + _ => CcMap::Ignore, + } +} + +fn map_system(v: &Value) -> CcMap { + let subtype = v.get("subtype").and_then(|x| x.as_str()).unwrap_or(""); + if subtype != "init" { + return CcMap::Ignore; + } + match v.get("session_id").and_then(|x| x.as_str()) { + Some(s) if !s.is_empty() => CcMap::SessionId(s.to_string()), + _ => CcMap::Ignore, + } +} + +fn map_stream_event(v: &Value, run_id: &str) -> CcMap { + let event = match v.get("event") { + Some(e) => e, + None => return CcMap::Ignore, + }; + let etype = event.get("type").and_then(|x| x.as_str()).unwrap_or(""); + if etype != "content_block_delta" { + return CcMap::Ignore; + } + let delta = match event.get("delta") { + Some(d) => d, + None => return CcMap::Ignore, + }; + let dtype = delta.get("type").and_then(|x| x.as_str()).unwrap_or(""); + match dtype { + "text_delta" => match delta.get("text").and_then(|x| x.as_str()) { + Some(text) => CcMap::Events(vec![sse::chat_delta(run_id, text)]), + None => CcMap::Ignore, + }, + "thinking_delta" => match delta.get("thinking").and_then(|x| x.as_str()) { + Some(text) if !text.is_empty() => CcMap::Events(vec![sse::agent_thinking( + run_id, + Some(text.to_string()), + None, + )]), + _ => CcMap::Ignore, + }, + _ => CcMap::Ignore, + } +} + +fn map_assistant(v: &Value, run_id: &str) -> CcMap { + let content = match v + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + { + Some(arr) => arr, + None => return CcMap::Ignore, + }; + let mut events = Vec::new(); + for item in content { + let itype = item.get("type").and_then(|x| x.as_str()).unwrap_or(""); + if itype != "tool_use" { + continue; + } + let id = item.get("id").and_then(|x| x.as_str()).unwrap_or(""); + let name = item.get("name").and_then(|x| x.as_str()).unwrap_or(""); + let input = item.get("input").cloned().unwrap_or(Value::Null); + events.push(sse::agent_tool( + run_id, + ToolData { + phase: ToolPhase::Start, + name: Some(name.to_string()), + tool_call_id: Some(id.to_string()), + is_error: None, + exit_code: None, + duration_ms: None, + cwd: None, + args: Some(input), + result: None, + partial_result: None, + }, + )); + } + if events.is_empty() { + CcMap::Ignore + } else { + CcMap::Events(events) + } +} + +fn map_user(v: &Value, run_id: &str) -> CcMap { + let content = match v + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + { + Some(arr) => arr, + None => return CcMap::Ignore, + }; + let mut events = Vec::new(); + for item in content { + let itype = item.get("type").and_then(|x| x.as_str()).unwrap_or(""); + if itype != "tool_result" { + continue; + } + let tool_use_id = item.get("tool_use_id").and_then(|x| x.as_str()).unwrap_or(""); + let content_val = item.get("content").cloned().unwrap_or(Value::Null); + events.push(sse::agent_tool( + run_id, + ToolData { + phase: ToolPhase::Result, + name: None, + tool_call_id: Some(tool_use_id.to_string()), + is_error: None, + exit_code: None, + duration_ms: None, + cwd: None, + args: None, + result: Some(content_val), + partial_result: None, + }, + )); + } + if events.is_empty() { + CcMap::Ignore + } else { + CcMap::Events(events) + } +} + +fn map_result(v: &Value) -> CcMap { + let subtype = v.get("subtype").and_then(|x| x.as_str()).unwrap_or(""); + if subtype == "success" { + let text = v.get("result").and_then(|x| x.as_str()).unwrap_or("").to_string(); + return CcMap::Final(text); + } + // 非成功 result:上抛为引擎退出。净化——只保留引擎错误类别标识符, + // 绝不把原始 payload(可能含敏感细节)灌进错误消息。 + CcMap::Failed(sanitize_result_note(subtype)) +} + +/// 只保留引擎错误类别(有限标识符:字母数字/下划线/连字符),剥掉其它。 +fn sanitize_result_note(subtype: &str) -> String { + let clean: String = subtype + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') + .collect(); + if clean.is_empty() { + "engine returned non-success result".to_string() + } else { + format!("engine result subtype: {clean}") + } +} + +/// `control_request` 映射:`can_use_tool` 提取为 [`CcMap::ControlRequest`], +/// 由 `run_turn` 接线 HITL 交互(register→requested→await→control_response +/// →resolved)。`can_use_tool` 以外的子类型忽略;绝不发 `stream:"approval"` +/// (spec §2 禁止旧 approval 用于新接入)。 +fn map_control_request(v: &Value, _run_id: &str) -> CcMap { + let req = match v.get("request") { + Some(r) => r, + None => return CcMap::Ignore, + }; + let subtype = req.get("subtype").and_then(|x| x.as_str()).unwrap_or(""); + if subtype != "can_use_tool" { + return CcMap::Ignore; + } + let request_id = req.get("request_id").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let tool_name = req.get("tool_name").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let input = req.get("input").cloned().unwrap_or(Value::Null); + CcMap::ControlRequest { request_id, tool_name, input } +} + +/// Build the kind-specific `extra` payload for the `interaction/requested` SSE +/// event (per spec §5.2). +/// +/// - exec (`tool_name != "AskUserQuestion"`): `{title, options}` plus either +/// `command` (when `input.command` is a non-empty string) or `description` +/// (otherwise). BCS validates that a present `command` must be a non-empty +/// string — a present-but-null/empty command drops the interaction and parks +/// the run forever, so a missing/non-string command MUST NOT emit the key; +/// we synthesize a human-readable `description` instead. The fixed options +/// `[allow_once, deny]` are always present. +/// - ask_user (`AskUserQuestion`): `{questions}` from `input.questions[]` — +/// questionId = `header` fallback `question_N`, options `label → {label, +/// value=label}` (cc has no separate value; baas-fallback parity). +fn build_requested_extra(tool_name: &str, input: &Value) -> Value { + if tool_name == "AskUserQuestion" { + json!({ "questions": map_ask_user_questions(input) }) + } else { + // Only emit `command` when it is a non-empty string; otherwise omit the + // key (BCS rejects present-but-null/empty command) and synthesize a + // human-readable `description` so the exec interaction still carries + // context for the approver. + let command = input + .get("command") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + let mut extra = json!({ + "title": tool_name, + "options": [ + { "decision": "allow_once", "label": "Allow once" }, + { "decision": "deny", "label": "Deny" }, + ], + }); + match command { + Some(cmd) => extra["command"] = json!(cmd), + None => extra["description"] = json!(synthesize_exec_description(input)), + } + extra + } +} + +/// Synthesize a human-readable `description` for an exec interaction whose tool +/// input carries no usable `command` string. Prefer `path`/`file_path` (the +/// common Read/Edit/Grep shapes); fall back to a compact JSON rendering of the +/// input, truncated to 200 chars on a UTF-8 character boundary (never byte-slice +/// — the input may contain multi-byte text). `serde_json::to_string` on a +/// `Value` cannot fail, but `unwrap_or_default` keeps this panic-free. +fn synthesize_exec_description(input: &Value) -> String { + if let Some(p) = input + .get("path") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + return p.to_string(); + } + if let Some(p) = input + .get("file_path") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + return p.to_string(); + } + const MAX_LEN: usize = 200; + let compact = serde_json::to_string(input).unwrap_or_default(); + match compact.char_indices().nth(MAX_LEN) { + Some((idx, _)) => compact[..idx].to_string(), + None => compact, + } +} + +/// Map cc `AskUserQuestion` `input.questions[]` to BCN questions. See +/// [`build_requested_extra`]: `questionId` 取 `header`,缺省回落到 +/// `question_N`(1-based 索引);`options[].value` 以 `label` 回填(cc 无独立 +/// value,对齐 baas fallback 策略);`multiSelect` 透传。**注意**:含 +/// `secret`/`isSecret` 标记的问题在本函数之前已被 [`ask_user_has_secret`] +/// 拦截并应答 deny,因此不会走到本映射。 +fn map_ask_user_questions(input: &Value) -> Value { + let Some(questions) = input.get("questions").and_then(|q| q.as_array()) else { + return json!([]); + }; + let out: Vec = questions + .iter() + .enumerate() + .map(|(i, q)| { + let question_id = q + .get("header") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("question_{}", i + 1)); + let question = q.get("question").and_then(|v| v.as_str()).unwrap_or(""); + let options = q + .get("options") + .and_then(|o| o.as_array()) + .map(|arr| { + arr.iter() + .map(|o| { + let label = o.get("label").and_then(|v| v.as_str()).unwrap_or(""); + json!({ "label": label, "value": label }) + }) + .collect::>() + }) + .unwrap_or_default(); + let mut obj = json!({ + "questionId": question_id, + "question": question, + "options": options, + }); + if let Some(ms) = q.get("multiSelect").and_then(|v| v.as_bool()) { + obj["multiSelect"] = json!(ms); + } + obj + }) + .collect(); + json!(out) +} + +/// Refuse conversion when any `AskUserQuestion` question carries a `secret` +/// (spec §5.2): BCS interaction cannot ferry secret answers — the driver +/// answers the engine `deny` directly and emits no interaction. Checks both +/// the question-level `secret`/`isSecret` boolean and (defensively) a +/// whole-call `isSecret` flag. +fn ask_user_has_secret(input: &Value) -> bool { + if input.get("secret").and_then(|v| v.as_bool()) == Some(true) + || input.get("isSecret").and_then(|v| v.as_bool()) == Some(true) + { + return true; + } + let Some(questions) = input.get("questions").and_then(|q| q.as_array()) else { + return false; + }; + questions.iter().any(|q| { + q.get("secret").and_then(|v| v.as_bool()) == Some(true) + || q.get("isSecret").and_then(|v| v.as_bool()) == Some(true) + }) +} + +/// Map a BCS resolution value to the cc control_response `behavior` +/// (`"allow"`/`"deny"`). +/// +/// Conservative mapping (final-review hardening): for exec, an explicit +/// `decision` allows ONLY when it is one of the known allow-values +/// (`allow_once`/`allow_session`/`allow_persistent`/`allow_always`); anything +/// else — including `deny`, an unrecognized/garbage value, or a bare `allow` +/// not in the allowlist — maps to `deny`. We never infer allow from an unknown +/// decision. v1 limitation (spec §5.2): cc has no answers channel, so an +/// ask_user resolution collapses to allow/deny — `action:"answer"` allows only +/// when `answers` is a non-empty array; `cancel` and missing/empty answers map +/// to `deny`; the answers payload itself is dropped (cc v1 cannot consume it). +fn resolution_to_behavior(resolution: &Value) -> &'static str { + if let Some(d) = resolution["decision"].as_str() { + return match d { + "allow_once" | "allow_session" | "allow_persistent" | "allow_always" => "allow", + _ => "deny", + }; + } + match resolution["action"].as_str() { + Some("answer") => { + let has_answers = resolution["answers"] + .as_array() + .map_or(false, |a| !a.is_empty()); + if has_answers { "allow" } else { "deny" } + } + _ => "deny", + } +} + +/// Drive one `can_use_tool` control request as a HITL interaction (Task 12): +/// +/// 1. (ask_user only) refuse secret-marked questions — answer the engine `deny` +/// immediately and log; no interaction is emitted (spec §5.2). +/// 2. register a pending interaction with the registry → mint interactionId. +/// 3. emit `interaction/requested` (exec: title/command/options; ask_user: +/// questions). +/// 4. await the BCS resolution (or abort → deny fallback). +/// 5. write the cc `control_response` back to the engine's control channel +/// (`behavior` allow/deny; `updatedInput` only on allow). +/// 6. emit `interaction/resolved`. +/// +/// Returns `Ok(())` so the `run_turn` loop continues to the next line; an IO +/// failure writing the control_response is returned as `TurnError::Io`, and a +/// failed `requested` send (BCS disconnect) aborts the engine. +async fn handle_control_request( + cli: &mut CliSession, + events: &tokio::sync::mpsc::Sender, + abort: &tokio_util::sync::CancellationToken, + req: &TurnRequest, + request_id: String, + tool_name: String, + input: Value, +) -> Result<(), TurnError> { + let kind = if tool_name == "AskUserQuestion" { + InteractionKind::AskUser + } else { + InteractionKind::Exec + }; + // 1. Secret-marked ask_user questions are refused (spec §5.2): the engine + // gets deny and no interaction is emitted to BCS. + if kind == InteractionKind::AskUser && ask_user_has_secret(&input) { + tracing::warn!( + target: "bridge_provider", + request_id = %request_id, tool = %tool_name, + "AskUserQuestion with secret-marked question refused; answering deny" + ); + let deny = json!({ + "type": "control_response", + "response": { "request_id": request_id, + "response": { "behavior": "deny", "updatedInput": null } } + }); + cli.write_line(&deny.to_string()).await.map_err(TurnError::Io)?; + return Ok(()); + } + // 2-3. Register + emit requested. + let (iid, resolution_rx) = req.interactions.register(&req.run_id, kind, request_id.clone()); + let requested = build_requested_extra(&tool_name, &input); + if events + .send(sse::interaction_event( + &req.run_id, + InteractionPhase::Requested, + kind, + &iid, + requested, + )) + .await + .is_err() + { + cli.kill().await; + return Err(TurnError::Aborted); + } + // 4. Await the BCS resolution; abort or a dropped sender recovers to deny + // so the driver never blocks on a dead interaction. + let resolution = tokio::select! { + _ = abort.cancelled() => json!({ "decision": "deny" }), + r = resolution_rx => r.unwrap_or_else(|_| json!({ "decision": "deny" })), + }; + // 5. Write the control_response with the mapped behavior. + let behavior = resolution_to_behavior(&resolution); + let updated_input = if behavior == "allow" { Some(input.clone()) } else { None }; + let response = json!({ + "type": "control_response", + "response": { "request_id": request_id, + "response": { "behavior": behavior, + "updatedInput": updated_input } } + }); + cli.write_line(&response.to_string()).await.map_err(TurnError::Io)?; + // 6. Emit resolved (best-effort — BCS may have disconnected post-request). + // v1 limitation: ask_user resolutions carry no `decision` key, so the + // resolved event's `decision` is null for ask_user (consistent with the + // no-answers-channel collapse above). + let _ = events + .send(sse::interaction_event( + &req.run_id, + InteractionPhase::Resolved, + kind, + &iid, + json!({ "decision": resolution["decision"].clone() }), + )) + .await; + Ok(()) +} + +#[async_trait::async_trait] +impl Engine for CfuseCc { + fn kind(&self) -> EngineKind { + EngineKind::CfuseCc + } + + async fn run_turn( + &self, + req: TurnRequest, + events: tokio::sync::mpsc::Sender, + abort: tokio_util::sync::CancellationToken, + ) -> Result { + let mut args: Vec = vec![ + "--cc".into(), + "--output-format".into(), + "stream-json".into(), + "--verbose".into(), + "--input-format".into(), + "stream-json".into(), + "--include-partial-messages".into(), + ]; + if let Some(mode) = &req.permission_mode { + args.push("--permission-mode".into()); + args.push(mode.clone()); + } + if let Some(sid) = &req.engine_session_id { + args.push("--resume".into()); + args.push(sid.clone()); + } + if let Some(model) = &req.model { + args.push("--model".into()); + args.push(model.clone()); + } + + let mut cli = CliSession::spawn(&self.bin, &args, &req.cwd, &[], req.trace.clone()) + .await + .map_err(TurnError::Spawn)?; + + // 启动后立刻向 stdin 写一条 user 消息(claude stream-json 输入格式)。 + let user_msg = serde_json::json!({ + "type": "user", + "message": { + "role": "user", + "content": [{ "type": "text", "text": req.prompt }] + } + }); + let user_line = serde_json::to_string(&user_msg) + .map_err(|e| TurnError::Protocol(format!("encode user message: {e}")))?; + cli.write_line(&user_line).await.map_err(TurnError::Io)?; + + let mut engine_session_id = req.engine_session_id.clone(); + loop { + tokio::select! { + _ = abort.cancelled() => { + cli.kill().await; + return Err(TurnError::Aborted); + } + line = cli.next_line() => { + let Some(line) = line.map_err(TurnError::Io)? else { + return Err(TurnError::EngineExited("stdout EOF before result".into())); + }; + match map_cc_line(&line, &req.run_id) { + CcMap::SessionId(s) => { + // Validate before adopting: an engine-supplied id is + // later used as a transcript path component and a + // `--resume` argv argument, so it must be safe. An + // invalid id is logged and treated as no session + // (not persisted, not resumed, transcript sink skipped). + if is_valid_engine_session_id(&s) { + engine_session_id = Some(s); + } else { + tracing::warn!( + target: "bridge_provider", + session_id = %s, + "cc system/init supplied invalid session id; \ + ignoring (not persisted/resumed)" + ); + } + } + CcMap::Events(evs) => for ev in evs { + if events.send(ev).await.is_err() { + cli.kill().await; + return Err(TurnError::Aborted); + } + }, + CcMap::Final(text) => { + return Ok(TurnOutcome { engine_session_id, final_text: Some(text) }); + } + CcMap::Failed(note) => { + cli.kill().await; + return Err(TurnError::EngineExited(note)); + } + CcMap::ControlRequest { request_id, tool_name, input } => { + handle_control_request( + &mut cli, + &events, + &abort, + &req, + request_id, + tool_name, + input, + ) + .await?; + } + CcMap::Ignore | CcMap::Malformed => {} + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bcs_protocol::stream::{ChatState, StreamEvent}; + + #[test] + fn maps_cc_ndjson_turn() { + let lines: Vec = std::fs::read_to_string( + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/cc_turn.ndjson")) + .unwrap().lines().map(str::to_string).collect(); + let mut session_id = None; + let mut deltas = String::new(); + let mut tools = 0; + let mut final_text = None; + for line in &lines { + match map_cc_line(line, "r-1") { + CcMap::SessionId(s) => session_id = Some(s), + CcMap::Events(events) => for ev in events { + match ev { + StreamEvent::Chat(c) if c.state == ChatState::Delta => + deltas.push_str(&c.delta_text.unwrap()), + StreamEvent::Agent(_) => tools += 1, + _ => {} + } + }, + CcMap::Final(text) => final_text = Some(text), + CcMap::Failed(_) => {} + CcMap::ControlRequest { .. } => {} + CcMap::Ignore | CcMap::Malformed => {} + } + } + assert_eq!(session_id.as_deref(), Some("cc-sess-1")); + assert_eq!(deltas, "正在分析"); + assert_eq!(tools, 2); + assert_eq!(final_text.as_deref(), Some("完成了")); + } + + #[test] + fn malformed_json_is_malformed() { + assert!(matches!(map_cc_line("not json", "r-1"), CcMap::Malformed)); + assert!(matches!(map_cc_line("{", "r-1"), CcMap::Malformed)); + } + + #[test] + fn unrecognized_type_is_ignore() { + let line = r#"{"type":"mystery","payload":42}"#; + assert!(matches!(map_cc_line(line, "r-1"), CcMap::Ignore)); + } + + #[test] + fn json_without_type_is_ignore() { + let line = r#"{"hello":"world"}"#; + assert!(matches!(map_cc_line(line, "r-1"), CcMap::Ignore)); + } + + #[test] + fn non_success_result_is_failed_with_sanitized_note() { + // error subtype 标识符被保留(引擎错误类别,非用户数据)。 + let line = r#"{"type":"result","subtype":"error_max_cycles","result":"secret detail"}"#; + match map_cc_line(line, "r-1") { + CcMap::Failed(note) => { + assert!(note.contains("error_max_cycles"), "note: {note}"); + // 不携带原始 result payload。 + assert!(!note.contains("secret detail"), "note leaked payload: {note}"); + } + other => panic!("expected Failed, got {other:?}"), + } + } + + #[test] + fn empty_result_text_success_maps_to_empty_final() { + let line = r#"{"type":"result","subtype":"success","result":""}"#; + match map_cc_line(line, "r-1") { + CcMap::Final(text) => assert_eq!(text, ""), + other => panic!("expected Final, got {other:?}"), + } + } + + #[test] + fn control_request_can_use_tool_maps_to_control_request() { + // 不再发占位 thinking(Task 9)——Task 12 接线为 ControlRequest 载体, + // 由 run_turn 驱动 HITL 交互;绝不发 `stream:"approval"`(spec §2)。 + let line = r#"{"type":"control_request","request":{"subtype":"can_use_tool","request_id":"req-7","tool_name":"Bash","input":{"command":"rm -rf /"}}}"#; + match map_cc_line(line, "r-1") { + CcMap::ControlRequest { request_id, tool_name, input } => { + assert_eq!(request_id, "req-7"); + assert_eq!(tool_name, "Bash"); + assert_eq!(input["command"], json!("rm -rf /")); + } + other => panic!("expected ControlRequest, got {other:?}"), + } + } + + #[test] + fn build_requested_extra_exec_carries_title_command_and_fixed_options() { + let extra = build_requested_extra("Bash", &json!({"command":"ls -la"})); + assert_eq!(extra["title"], json!("Bash")); + assert_eq!(extra["command"], json!("ls -la")); + assert_eq!(extra["options"][0]["decision"], json!("allow_once")); + assert_eq!(extra["options"][0]["label"], json!("Allow once")); + assert_eq!(extra["options"][1]["decision"], json!("deny")); + assert_eq!(extra["options"][1]["label"], json!("Deny")); + } + + #[test] + fn build_requested_extra_exec_omits_command_when_missing() { + // BCS rejects a present-but-null `command` (interaction dropped → run + // parks forever), so when input has no `command` string we MUST NOT emit + // the key; we synthesize a `description` from `path`/`file_path` instead. + let extra = build_requested_extra("Read", &json!({"path":"/a"})); + assert_eq!(extra["title"], json!("Read")); + assert!( + extra.get("command").is_none(), + "command key must be absent when input has no command string" + ); + assert_eq!(extra["description"], json!("/a"), "description synthesized from path"); + } + + #[test] + fn build_requested_extra_exec_emits_bcs_valid_shape() { + // BCS-side validation rule for what we emit on an exec `requested` extra: + // if the `command` key is present it must be a non-empty string, and + // `options` must be a non-empty array where each option has a string + // `decision` and `label`. Holds for every shape we build. + fn assert_bcs_exec_valid(extra: &Value) { + if let Some(cmd) = extra.get("command") { + assert!(cmd.is_string(), "command must be a string when present: {extra}"); + let s = cmd.as_str().unwrap(); + assert!(!s.is_empty(), "command must be non-empty when present: {extra}"); + } + let options = extra + .get("options") + .and_then(|o| o.as_array()) + .expect("options must be a non-empty array"); + assert!(!options.is_empty(), "options must be non-empty: {extra}"); + for o in options { + assert!( + o.get("decision").and_then(|v| v.as_str()).is_some(), + "each option needs a string decision: {extra}" + ); + assert!( + o.get("label").and_then(|v| v.as_str()).is_some(), + "each option needs a string label: {extra}" + ); + } + } + + // Non-empty string command → command key present. + assert_bcs_exec_valid(&build_requested_extra("Bash", &json!({"command":"ls -la"}))); + // Missing command → description shape (no command key). + assert_bcs_exec_valid(&build_requested_extra("Read", &json!({"path":"/a"}))); + // Empty-string command → treated as no command (description shape). + assert_bcs_exec_valid(&build_requested_extra("Bash", &json!({"command":""}))); + // Null command → treated as no command (description shape). + assert_bcs_exec_valid(&build_requested_extra("Bash", &json!({"command":null}))); + // No path/file_path either → description falls back to compact JSON. + let extra = build_requested_extra("Bash", &json!({"foo":"bar"})); + assert!(extra.get("command").is_none(), "no command key when command absent"); + assert_bcs_exec_valid(&extra); + assert!(extra["description"].as_str().unwrap().contains("bar")); + } + + #[test] + fn synthesize_exec_description_prefers_path_then_falls_back() { + // path wins over file_path and JSON fallback. + assert_eq!(synthesize_exec_description(&json!({"path":"/a/b","file_path":"/c"})), "/a/b"); + // file_path used when path absent. + assert_eq!(synthesize_exec_description(&json!({"file_path":"/c"})), "/c"); + // empty path string falls through to file_path, then JSON. + assert_eq!(synthesize_exec_description(&json!({"path":"","file_path":"/c"})), "/c"); + // empty path + no file_path → JSON fallback keeps the empty `path` key. + assert_eq!(synthesize_exec_description(&json!({"path":""})), r#"{"path":""}"#); + assert_eq!(synthesize_exec_description(&json!({})), "{}"); + // UTF-8 safe truncation at 200 chars on multibyte text (no mid-char slice). + let big = json!({ "k": "你".repeat(300) }); + let compact = serde_json::to_string(&big).unwrap(); + let desc = synthesize_exec_description(&big); + assert!(desc.chars().count() <= 200, "desc must be at most 200 chars: {}", desc.chars().count()); + // desc is a char-boundary prefix of the compact JSON — a multi-byte char + // is never split (Rust str invariant + `char_indices` truncation). + assert!(compact.starts_with(&desc), "desc must be a char-boundary prefix: {desc:?}"); + } + + #[test] + fn build_requested_extra_ask_user_maps_questions_label_to_value() { + let input = json!({ + "questions": [ + { "header": "lang", "question": "Pick a language", "multiSelect": false, + "options": [ {"label":"Rust"}, {"label":"Go"} ] }, + { "question": "Free text?", "options": [ {"label":"yes"} ] }, + ] + }); + let extra = build_requested_extra("AskUserQuestion", &input); + let qs = &extra["questions"]; + assert_eq!(qs[0]["questionId"], json!("lang"), "header → questionId"); + assert_eq!(qs[0]["question"], json!("Pick a language")); + assert_eq!(qs[0]["multiSelect"], json!(false)); + assert_eq!(qs[0]["options"][0], json!({"label":"Rust","value":"Rust"})); + // 缺 header → question_N(1-based) + assert_eq!(qs[1]["questionId"], json!("question_2")); + assert_eq!(qs[1]["options"][0], json!({"label":"yes","value":"yes"})); + } + + #[test] + fn ask_user_question_with_secret_at_question_is_refused() { + let input = json!({"questions":[{"secret":true,"question":"pwd"}]}); + assert!(ask_user_has_secret(&input)); + } + + #[test] + fn ask_user_question_with_is_secret_at_call_level_is_refused() { + let input = json!({"isSecret":true,"questions":[{"question":"pwd"}]}); + assert!(ask_user_has_secret(&input)); + } + + #[test] + fn ask_user_question_without_secret_is_not_refused() { + let input = json!({"questions":[{"question":"name","options":[{"label":"a"}]}]}); + assert!(!ask_user_has_secret(&input)); + } + + #[test] + fn resolution_to_behavior_maps_exec_decisions_and_ask_user_actions() { + // exec: only explicit allow_* decisions → allow; everything else (deny, + // unrecognized/garbage, a bare `allow` not in the allowlist) → deny. + // Conservative: never infer allow from an unknown decision. + assert_eq!(resolution_to_behavior(&json!({"decision":"allow_once"})), "allow"); + assert_eq!(resolution_to_behavior(&json!({"decision":"allow_session"})), "allow"); + assert_eq!(resolution_to_behavior(&json!({"decision":"allow_persistent"})), "allow"); + assert_eq!(resolution_to_behavior(&json!({"decision":"allow_always"})), "allow"); + assert_eq!(resolution_to_behavior(&json!({"decision":"deny"})), "deny"); + assert_eq!( + resolution_to_behavior(&json!({"decision":"yes"})), + "deny", + "unknown decision → deny" + ); + assert_eq!( + resolution_to_behavior(&json!({"decision":"allow"})), + "deny", + "bare 'allow' (not in allowlist) → deny" + ); + assert_eq!(resolution_to_behavior(&json!({"decision":"garbage"})), "deny"); + // ask_user: answer + 非空 answers → allow;cancel → deny;缺 answers → deny + assert_eq!( + resolution_to_behavior(&json!({"action":"answer","answers":[{"q":"a"}]})), + "allow" + ); + assert_eq!(resolution_to_behavior(&json!({"action":"answer"})), "deny", "empty answers → deny"); + assert_eq!(resolution_to_behavior(&json!({"action":"cancel"})), "deny"); + assert_eq!(resolution_to_behavior(&json!(null)), "deny", "unknown shape → deny"); + } + + #[test] + fn non_can_use_tool_control_request_is_ignore() { + let line = r#"{"type":"control_request","request":{"subtype":"other"}}"#; + assert!(matches!(map_cc_line(line, "r-1"), CcMap::Ignore)); + } + + #[test] + fn assistant_tool_use_carries_id_name_and_input() { + let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_9","name":"Read","input":{"path":"/a"}}]}}"#; + match map_cc_line(line, "r-9") { + CcMap::Events(evs) => { + assert_eq!(evs.len(), 1); + match &evs[0] { + StreamEvent::Agent(a) => match &a.data { + bcs_protocol::stream::AgentData::Tool(t) => { + assert_eq!(t.phase, ToolPhase::Start); + assert_eq!(t.name.as_deref(), Some("Read")); + assert_eq!(t.tool_call_id.as_deref(), Some("toolu_9")); + assert_eq!(t.args.as_ref().unwrap()["path"], serde_json::json!("/a")); + } + other => panic!("expected Tool, got {other:?}"), + }, + other => panic!("expected Agent(Tool), got {other:?}"), + } + } + other => panic!("expected Events, got {other:?}"), + } + } + + #[test] + fn user_tool_result_carries_tool_use_id_and_content_zero_loss() { + let line = r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_9","content":[{"type":"text","text":"ok"}]}]}}"#; + match map_cc_line(line, "r-9") { + CcMap::Events(evs) => { + assert_eq!(evs.len(), 1); + match &evs[0] { + StreamEvent::Agent(a) => match &a.data { + bcs_protocol::stream::AgentData::Tool(t) => { + assert_eq!(t.phase, ToolPhase::Result); + assert_eq!(t.tool_call_id.as_deref(), Some("toolu_9")); + assert_eq!(t.result.as_ref().unwrap()[0]["text"], serde_json::json!("ok")); + } + other => panic!("expected Tool, got {other:?}"), + }, + other => panic!("expected Agent(Tool), got {other:?}"), + } + } + other => panic!("expected Events, got {other:?}"), + } + } + + #[test] + fn assistant_without_tool_use_is_ignore() { + let line = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}"#; + // 文本块由 stream_event 的 deltas 承载;assistant 非工具块不重复映射。 + assert!(matches!(map_cc_line(line, "r-1"), CcMap::Ignore)); + } + + #[test] + fn system_non_init_is_ignore() { + let line = r#"{"type":"system","subtype":"other","session_id":"s"}"#; + assert!(matches!(map_cc_line(line, "r-1"), CcMap::Ignore)); + } + + #[test] + fn stream_event_non_text_delta_is_ignore() { + let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{"}}}"#; + assert!(matches!(map_cc_line(line, "r-1"), CcMap::Ignore)); + } + + #[test] + fn stream_event_thinking_delta_emits_thinking_event() { + let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"分析中"}}}"#; + match map_cc_line(line, "r-1") { + CcMap::Events(events) => match &events[0] { + StreamEvent::Agent(agent) => match &agent.data { + bcs_protocol::stream::AgentData::Thinking(thinking) => { + assert_eq!(thinking.delta.as_deref(), Some("分析中")); + } + other => panic!("expected Thinking, got {other:?}"), + }, + other => panic!("expected Agent, got {other:?}"), + }, + other => panic!("expected Events, got {other:?}"), + } + } +} diff --git a/src/bcs/crates/adapters/bridge-provider/src/engine/cfuse_codex.rs b/src/bcs/crates/adapters/bridge-provider/src/engine/cfuse_codex.rs new file mode 100644 index 0000000000..1e2b98ca88 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/engine/cfuse_codex.rs @@ -0,0 +1,480 @@ +//! `CfuseCodex` driver: maps codex `exec --json` JSONL lines (one JSON object per +//! line) to engine-neutral [`StreamEvent`]s and drives one downstream turn over +//! a [`crate::engine::cli::CliSession`]. +//! +//! 调用形态(对齐 aix-relay probe 实测:`cfuse --codex` 透传到 `codex exec`): +//! +//! ```text +//! // 首轮(无 engine_session_id): +//! cfuse --codex exec --json --skip-git-repo-check -C [-m ] +//! // 续轮(resume 已捕获的 codex thread): +//! cfuse --codex exec resume --json --skip-git-repo-check +//! [-m ] +//! ``` +//! +//! `thread.started` 携带 `thread_id`——引擎内会话 id,续轮经 `exec resume ` +//! 恢复。`CliSession` 已用 `current_dir(cwd)` 推进到工作目录;`-C ` 与首轮 +//! probe 形一致。prompt 作为 argv 位置参数传入;spawn 后立即 `close_stdin()` +//! (`codex exec` 会把 piped stdin 当额外输入读,必须立刻 EOF)。 +//! +//! 事件映射表(codex JSONL → [`CodexMap`] / [`StreamEvent`]): +//! +//! | codex 行 | 映射 | +//! | --- | --- | +//! | `{"type":"thread.started","thread_id":…}` | `CodexMap::SessionId(thread_id)` | +//! | `{"type":"turn.started"}` | `CodexMap::Ignore` | +//! | `{"type":"item.completed","item":{"type":"agent_message","text":…}}` | `chat_delta`(并供 run_turn 累计终态文本) | +//! | `{"type":"item.completed","item":{"type":"reasoning","text":…}}` | `agent_thinking`(delta = item.text) | +//! | `{"type":"item.completed","item":{"type":其它}}` | `CodexMap::Ignore` | +//! | `{"type":"turn.completed"[,"text":…]}` | `CodexMap::Final(累计文本)`(text 缺失 → run_turn 用累计 deltas 兜底) | +//! | `{"type":"turn.failed"[,"error":{"message":…}]}` / `{"type":"error",…}` | `CodexMap::Failed(脱敏 message)` | +//! | 其余(含非 JSON 行、未知 type) | `CodexMap::Ignore` | + +use std::path::PathBuf; + +use bcs_protocol::stream::StreamEvent; +use serde_json::Value; + +use crate::engine::cli::CliSession; +use crate::engine::{ + is_valid_engine_session_id, Engine, EngineKind, TurnError, TurnOutcome, TurnRequest, +}; +use crate::sse; + +/// `cfuse --codex` (codex `exec --json` JSONL) 引擎驱动。 +pub struct CfuseCodex { + bin: PathBuf, +} + +impl CfuseCodex { + pub fn new(bin: PathBuf) -> Self { + Self { bin } + } +} + +/// 单行 codex `exec --json` JSONL 的映射结果。 +/// +/// `SessionId` 携带 `thread.started` 的 `thread_id`(续轮用于 `exec resume`); +/// `Events` 是该行产出的 [`StreamEvent`];`Final` 携带 `turn.completed` 的文本 +/// (事件缺文本则空串,`run_turn` 用累计 deltas 兜底);`Failed` 携带 +/// `turn.failed`/`error` 经脱敏的退出原因(调用方上抛为 [`TurnError::EngineExited`]); +/// `Ignore` 标记“未识别或无产出”。 +#[derive(Debug)] +pub(crate) enum CodexMap { + Events(Vec), + SessionId(String), + Final(String), + Failed(String), + Ignore, +} + +/// 把一行 codex `exec --json` JSONL 映射为引擎中立的 [`CodexMap`]。 +/// +/// 纯函数(无 IO),便于用录制 fixture 做单元测试;按上方映射表逐类分派。 +pub(crate) fn map_codex_line(line: &str, run_id: &str) -> CodexMap { + let value: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => return CodexMap::Ignore, + }; + let ty = match value.get("type").and_then(|v| v.as_str()) { + Some(t) => t, + None => return CodexMap::Ignore, + }; + match ty { + "thread.started" => map_thread_started(&value), + "turn.started" => CodexMap::Ignore, + "item.completed" => map_item_completed(&value, run_id), + "turn.completed" => CodexMap::Final(extract_turn_completed_text(&value)), + "turn.failed" | "error" => map_failed(&value), + _ => CodexMap::Ignore, + } +} + +fn map_thread_started(v: &Value) -> CodexMap { + match v.get("thread_id").and_then(|x| x.as_str()) { + Some(s) if !s.is_empty() => CodexMap::SessionId(s.to_string()), + _ => CodexMap::Ignore, + } +} + +fn map_item_completed(v: &Value, run_id: &str) -> CodexMap { + let item = match v.get("item") { + Some(i) => i, + None => return CodexMap::Ignore, + }; + let itype = item.get("type").and_then(|x| x.as_str()).unwrap_or(""); + match itype { + "agent_message" => match item.get("text").and_then(|x| x.as_str()) { + Some(text) => CodexMap::Events(vec![sse::chat_delta(run_id, text)]), + None => CodexMap::Ignore, + }, + "reasoning" => match item.get("text").and_then(|x| x.as_str()) { + Some(text) => CodexMap::Events(vec![sse::agent_thinking( + run_id, + Some(text.to_string()), + None, + )]), + None => CodexMap::Ignore, + }, + // Task 12 接线工具/approval;本任务仅映射 agent_message/reasoning。 + _ => CodexMap::Ignore, + } +} + +fn extract_turn_completed_text(v: &Value) -> String { + // `turn.completed` 实测不带 text(probed: {"type":"turn.completed","usage":{...}}); + // 防御性抽取 `text`/`output` 字符串,缺失则空串,由 run_turn 用累计 deltas 兜底。 + v.get("text") + .and_then(|x| x.as_str()) + .or_else(|| v.get("output").and_then(|x| x.as_str())) + .unwrap_or("") + .to_string() +} + +fn map_failed(v: &Value) -> CodexMap { + // 优先 `error.message`,其次顶层 `message`;均缺失则固定脱敏回退(不回退 + // 原始 JSON 行——可能携带敏感细节,仅截断剥控制字符不足以彻底净化)。 + let msg = v + .get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .or_else(|| v.get("message").and_then(|m| m.as_str())) + .map(str::to_string) + .unwrap_or_else(|| "engine turn failed".to_string()); + CodexMap::Failed(sanitize_message(&msg)) +} + +/// 脱敏:剥控制字符(防日志注入),按字符截断到上限(UTF-8 安全,使用 +/// `char_indices` 定位边界),不携带其它字段。message 本身是引擎错误描述, +/// 保留以保证下游可读;其它字段(code/raw payload)可能含敏感细节,不输出。 +fn sanitize_message(msg: &str) -> String { + const MAX_LEN: usize = 256; + let clean: String = msg.chars().filter(|c| !c.is_control()).collect(); + match clean.char_indices().nth(MAX_LEN) { + Some((idx, _)) => clean[..idx].to_string(), + None => clean, + } +} + +#[async_trait::async_trait] +impl Engine for CfuseCodex { + fn kind(&self) -> EngineKind { + EngineKind::CfuseCodex + } + + async fn run_turn( + &self, + req: TurnRequest, + events: tokio::sync::mpsc::Sender, + abort: tokio_util::sync::CancellationToken, + ) -> Result { + // `cfuse --codex` 透传到 `codex exec`。首轮用 `exec` + cwd/git flags; + // 续轮用 `exec resume ` 恢复 codex thread(thread_id 由上轮 + // `thread.started` 捕获)。`--json` 与 `--skip-git-repo-check` 两者都 + // 加;`CliSession::spawn` 已用 `current_dir(req.cwd)` 保证续轮也在相同 + // 工作目录中运行。`-m` 经核实属 `codex exec` 通用 flag; + // `--permission-mode` codex exec 不接受,故不拼。 + let mut args: Vec = vec!["--codex".into(), "exec".into()]; + if let Some(sid) = &req.engine_session_id { + args.push("resume".into()); + args.push(sid.clone()); + } + args.push("--json".into()); + args.push("--skip-git-repo-check".into()); + if req.engine_session_id.is_none() { + args.push("-C".into()); + args.push(req.cwd.to_string_lossy().into_owned()); + } + if let Some(model) = &req.model { + args.push("-m".into()); + args.push(model.clone()); + } + args.push(req.prompt.clone()); + + let mut cli = CliSession::spawn(&self.bin, &args, &req.cwd, &[], req.trace.clone()) + .await + .map_err(TurnError::Spawn)?; + // prompt 已在 argv;codex exec 会把 piped stdin 当额外输入读, + // 故立即关闭 stdin 让子进程拿到 EOF,只用 argv prompt。 + cli.close_stdin(); + + let mut engine_session_id = req.engine_session_id.clone(); + let mut deltas = String::new(); + loop { + tokio::select! { + _ = abort.cancelled() => { + cli.kill().await; + return Err(TurnError::Aborted); + } + line = cli.next_line() => { + let Some(line) = line.map_err(TurnError::Io)? else { + return Err(TurnError::EngineExited("stdout EOF before result".into())); + }; + match map_codex_line(&line, &req.run_id) { + CodexMap::SessionId(sid) => { + // Validate before adopting: an engine-supplied id is + // later used as a transcript path component and an + // `exec resume ` argv argument, so it must be + // safe. An invalid id is logged and treated as no + // session (not persisted, not resumed, transcript + // sink skipped). + if is_valid_engine_session_id(&sid) { + engine_session_id = Some(sid); + } else { + tracing::warn!( + target: "bridge_provider", + session_id = %sid, + "codex thread.started supplied invalid session id; \ + ignoring (not persisted/resumed)" + ); + } + } + CodexMap::Events(evs) => { + for ev in evs { + if let StreamEvent::Chat(c) = &ev { + if let Some(t) = &c.delta_text { + deltas.push_str(t); + } + } + if events.send(ev).await.is_err() { + cli.kill().await; + return Err(TurnError::Aborted); + } + } + } + CodexMap::Final(text) => { + // 累计 deltas 形成最终文本;turn.completed 若携带 + // text 则优先,否则用 deltas 兜底(agent_message 增量)。 + let final_text = if text.is_empty() { deltas } else { text }; + return Ok(TurnOutcome { + engine_session_id, + final_text: Some(final_text), + }); + } + CodexMap::Failed(note) => { + cli.kill().await; + return Err(TurnError::EngineExited(note)); + } + CodexMap::Ignore => {} + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bcs_protocol::stream::{AgentData, StreamEvent}; + + #[test] + fn maps_codex_jsonl_turn() { + let text = std::fs::read_to_string( + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/codex_turn.jsonl")).unwrap(); + let mut session_id = None; + let mut deltas = String::new(); + let mut thinking = 0; + let mut final_text = None; + for line in text.lines().filter(|l| !l.trim().is_empty()) { + match map_codex_line(line, "r-1") { + CodexMap::SessionId(s) => session_id = Some(s), + CodexMap::Events(evs) => for ev in evs { + match ev { + StreamEvent::Chat(c) => deltas.push_str(&c.delta_text.unwrap_or_default()), + StreamEvent::Agent(a) => match a.data { + AgentData::Thinking(t) => { + thinking += 1; + assert!(t.delta.is_some(), "reasoning thinking must carry delta"); + } + _ => {} + }, + _ => {} + } + }, + CodexMap::Final(t) => final_text = Some(t), + CodexMap::Failed(_) | CodexMap::Ignore => {} + } + } + assert_eq!(session_id.as_deref(), Some("codex-thread-1")); + assert_eq!(deltas, "正在排查"); + assert_eq!(thinking, 1); + // Fixture turn.completed 无 text → Final("");run_turn 兜底 deltas。 + assert_eq!(final_text.as_deref(), Some("")); + } + + #[test] + fn maps_codex_failure_turn_failed() { + match map_codex_line(r#"{"type":"turn.failed","error":{"message":"boom"}}"#, "r-1") { + CodexMap::Failed(msg) => assert!(msg.contains("boom")), + other => panic!("expected Failed, got {other:?}"), + } + } + + #[test] + fn maps_codex_failure_top_level_error() { + match map_codex_line(r#"{"type":"error","error":{"message":"upstream busy"}}"#, "r-1") { + CodexMap::Failed(msg) => assert!(msg.contains("upstream busy")), + other => panic!("expected Failed, got {other:?}"), + } + } + + #[test] + fn thread_started_captures_thread_id() { + match map_codex_line(r#"{"type":"thread.started","thread_id":"t-9"}"#, "r-1") { + CodexMap::SessionId(s) => assert_eq!(s, "t-9"), + other => panic!("expected SessionId, got {other:?}"), + } + } + + #[test] + fn thread_started_without_thread_id_is_ignore() { + assert!(matches!( + map_codex_line(r#"{"type":"thread.started"}"#, "r-1"), + CodexMap::Ignore + )); + } + + #[test] + fn turn_started_is_ignore() { + assert!(matches!(map_codex_line(r#"{"type":"turn.started"}"#, "r-1"), CodexMap::Ignore)); + } + + #[test] + fn item_completed_agent_message_emits_chat_delta() { + let line = r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"hi"}}"#; + match map_codex_line(line, "r-9") { + CodexMap::Events(evs) => { + assert_eq!(evs.len(), 1); + match &evs[0] { + StreamEvent::Chat(c) => { + assert_eq!(c.delta_text.as_deref(), Some("hi")); + assert_eq!(c.run_id, "r-9"); + } + other => panic!("expected Chat, got {other:?}"), + } + } + other => panic!("expected Events, got {other:?}"), + } + } + + #[test] + fn item_completed_reasoning_emits_thinking() { + let line = r#"{"type":"item.completed","item":{"id":"item_2","type":"reasoning","text":"pondering"}}"#; + match map_codex_line(line, "r-9") { + CodexMap::Events(evs) => { + assert_eq!(evs.len(), 1); + match &evs[0] { + StreamEvent::Agent(a) => match &a.data { + AgentData::Thinking(t) => assert_eq!(t.delta.as_deref(), Some("pondering")), + other => panic!("expected Thinking, got {other:?}"), + }, + other => panic!("expected Agent, got {other:?}"), + } + } + other => panic!("expected Events, got {other:?}"), + } + } + + #[test] + fn item_completed_other_item_type_is_ignore() { + // 工具/其它 item 类型:Task 12 接线;本任务仅 agent_message/reasoning 映射,余者 Ignore。 + for line in [ + r#"{"type":"item.completed","item":{"id":"i","type":"tool_call","text":"rm -rf /"}}"#, + r#"{"type":"item.completed","item":{"id":"i","type":"file_edit"}}"#, + r#"{"type":"item.completed"}"#, + ] { + assert!(matches!(map_codex_line(line, "r-1"), CodexMap::Ignore), "line: {line}"); + } + } + + #[test] + fn item_completed_without_text_is_ignore() { + assert!(matches!( + map_codex_line( + r#"{"type":"item.completed","item":{"id":"i","type":"agent_message"}}"#, + "r-1" + ), + CodexMap::Ignore + )); + } + + #[test] + fn turn_completed_without_text_is_empty_final() { + match map_codex_line(r#"{"type":"turn.completed","usage":{"input_tokens":1}}"#, "r-1") { + CodexMap::Final(text) => assert_eq!(text, ""), + other => panic!("expected Final, got {other:?}"), + } + } + + #[test] + fn turn_completed_with_text_field_carries_it() { + // 防御性:若 codex 在 turn.completed 里带 text,则 Final 携带它。 + match map_codex_line(r#"{"type":"turn.completed","text":"done"}"#, "r-1") { + CodexMap::Final(text) => assert_eq!(text, "done"), + other => panic!("expected Final, got {other:?}"), + } + } + + #[test] + fn unknown_type_is_ignore() { + assert!(matches!(map_codex_line(r#"{"type":"response.created"}"#, "r-1"), CodexMap::Ignore)); + assert!(matches!(map_codex_line(r#"{"type":"foo"}"#, "r-1"), CodexMap::Ignore)); + } + + #[test] + fn json_without_type_is_ignore() { + assert!(matches!(map_codex_line(r#"{"hello":"world"}"#, "r-1"), CodexMap::Ignore)); + } + + #[test] + fn malformed_json_is_ignore() { + assert!(matches!(map_codex_line("not json", "r-1"), CodexMap::Ignore)); + assert!(matches!(map_codex_line("{", "r-1"), CodexMap::Ignore)); + } + + #[test] + fn turn_failed_without_message_uses_fixed_fallback() { + // 无 message 字段时不回退原始 JSON 行(避免泄露),给固定脱敏回退。 + match map_codex_line(r#"{"type":"turn.failed"}"#, "r-1") { + CodexMap::Failed(msg) => assert!(msg.contains("failed"), "msg: {msg}"), + other => panic!("expected Failed, got {other:?}"), + } + } + + #[test] + fn failed_with_control_chars_and_long_message_is_sanitized_and_bounded() { + // 脱敏:剥控制字符,截断到 256 字符上限(UTF-8 安全)。 + let long = format!("{}{}", "boom", "\n".repeat(10)); + let data = format!( + r#"{{"type":"turn.failed","error":{{"message":{}}}}}"#, + serde_json::to_string(&long).unwrap() + ); + match map_codex_line(&data, "r-1") { + CodexMap::Failed(msg) => { + assert!(msg.contains("boom")); + assert!(!msg.contains('\n'), "control chars must be stripped: {msg:?}"); + } + other => panic!("expected Failed, got {other:?}"), + } + + let huge = "x".repeat(1024); + let data = format!( + r#"{{"type":"turn.failed","error":{{"message":{}}}}}"#, + serde_json::to_string(&huge).unwrap() + ); + match map_codex_line(&data, "r-1") { + CodexMap::Failed(msg) => assert_eq!(msg.chars().count(), 256), + other => panic!("expected Failed, got {other:?}"), + } + } + + #[test] + fn sanitize_message_truncation_is_utf8_safe_on_multibyte() { + // UTF-8 安全:按字符边界截断,多字节字符不被切断。 + let s = "你".repeat(300); + let out = sanitize_message(&s); + assert_eq!(out.chars().count(), 256); + assert!(out.chars().all(|c| c == '你')); + } +} diff --git a/src/bcs/crates/adapters/bridge-provider/src/engine/cfuse_codex_app_server.rs b/src/bcs/crates/adapters/bridge-provider/src/engine/cfuse_codex_app_server.rs new file mode 100644 index 0000000000..f21db530e9 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/engine/cfuse_codex_app_server.rs @@ -0,0 +1,682 @@ +//! `CfuseCodexAppServer` driver: runs `cfuse --codex app-server` over the +//! Codex JSON-RPC stdio protocol. +//! +//! This follows the production Codex path used by aix-engine: +//! +//! ```text +//! cfuse --codex app-server --listen stdio:// +//! initialize +//! thread/start | thread/resume +//! turn/start +//! item/agentMessage/delta ... +//! turn/completed +//! ``` +//! +//! Unlike `codex exec --json`, app-server emits assistant text as small delta +//! notifications. The driver forwards those notifications immediately as +//! engine-neutral `chat_delta` events and uses `turn/completed` only as the +//! terminal boundary. + +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; + +use bcs_protocol::stream::{StreamEvent, ToolData, ToolPhase}; +use serde_json::{Value, json}; +use tokio::sync::mpsc::Sender; +use tokio_util::sync::CancellationToken; + +use crate::engine::cli::CliSession; +use crate::engine::{Engine, EngineKind, TurnError, TurnOutcome, TurnRequest}; +use crate::sse; + +/// Codex app-server JSON-RPC driver. +pub struct CfuseCodexAppServer { + bin: PathBuf, +} + +impl CfuseCodexAppServer { + pub fn new(bin: PathBuf) -> Self { + Self { bin } + } +} + +#[async_trait::async_trait] +impl Engine for CfuseCodexAppServer { + fn kind(&self) -> EngineKind { + EngineKind::CfuseCodex + } + + async fn run_turn( + &self, + req: TurnRequest, + events: Sender, + abort: CancellationToken, + ) -> Result { + let args = vec![ + "--codex".to_string(), + "app-server".to_string(), + "--listen".to_string(), + "stdio://".to_string(), + ]; + let mut cli = CliSession::spawn(&self.bin, &args, &req.cwd, &[], req.trace.clone()) + .await + .map_err(TurnError::Spawn)?; + + let mut next_id = 1_u64; + let mut backlog = VecDeque::new(); + + rpc_call( + &mut cli, + &mut next_id, + &mut backlog, + "initialize", + json!({ + "clientInfo": { + "name": "bridge-provider", + "version": env!("CARGO_PKG_VERSION"), + }, + "capabilities": null, + }), + &abort, + ) + .await?; + send_notification(&mut cli, "initialized", Value::Null).await?; + + let thread = if let Some(session_id) = &req.engine_session_id { + rpc_call( + &mut cli, + &mut next_id, + &mut backlog, + "thread/resume", + thread_resume_params(&req, session_id), + &abort, + ) + .await? + } else { + rpc_call( + &mut cli, + &mut next_id, + &mut backlog, + "thread/start", + thread_start_params(&req), + &abort, + ) + .await? + }; + let thread_id = extract_thread_id(&thread).ok_or_else(|| { + TurnError::Protocol(format!("app-server thread response missing thread.id: {thread}")) + })?; + + let turn = rpc_call( + &mut cli, + &mut next_id, + &mut backlog, + "turn/start", + turn_start_params(&req, &thread_id), + &abort, + ) + .await?; + let turn_id = extract_turn_id(&turn).ok_or_else(|| { + TurnError::Protocol(format!("app-server turn response missing turn.id: {turn}")) + })?; + + let mut deltas = String::new(); + let mut thinking = String::new(); + loop { + let value = tokio::select! { + _ = abort.cancelled() => { + cli.kill().await; + return Err(TurnError::Aborted); + } + value = next_message(&mut cli, &mut backlog) => value?, + }; + + if is_server_request(&value) { + respond_server_request(&mut cli, &value).await?; + continue; + } + if !matches_turn(&value, &thread_id, &turn_id) { + continue; + } + + match value.get("method").and_then(Value::as_str).unwrap_or_default() { + "item/agentMessage/delta" | "agent/output_chunk" => { + if let Some(delta) = extract_delta(&value) { + deltas.push_str(&delta); + if send_event(&events, sse::chat_delta(&req.run_id, &delta)).await { + cli.kill().await; + return Err(TurnError::Aborted); + } + } + } + "item/reasoning/textDelta" | "item/reasoning/summaryTextDelta" => { + if let Some(delta) = extract_delta(&value) { + thinking.push_str(&delta); + if send_event( + &events, + sse::agent_thinking( + &req.run_id, + Some(delta), + Some(thinking.clone()), + ), + ) + .await + { + cli.kill().await; + return Err(TurnError::Aborted); + } + } + } + "item/started" => { + if let Some(event) = map_item_started(&value, &req.run_id, &req.cwd) { + if send_event(&events, event).await { + return Err(TurnError::Aborted); + } + } + } + "item/completed" => { + if let Some(event) = map_item_completed(&value, &req.run_id, &req.cwd) { + if send_event(&events, event).await { + return Err(TurnError::Aborted); + } + } + } + "item/commandExecution/outputDelta" | "item/fileChange/outputDelta" => { + if let Some(event) = map_tool_output_delta(&value, &req.run_id) { + if send_event(&events, event).await { + return Err(TurnError::Aborted); + } + } + } + "item/mcpToolCall/progress" => { + if let Some(event) = map_mcp_progress(&value, &req.run_id) { + if send_event(&events, event).await { + return Err(TurnError::Aborted); + } + } + } + "turn/completed" | "agent/turn_completed" => { + if let Some(message) = completed_failure_message(&value) { + return Err(TurnError::EngineExited(message)); + } + let final_text = if deltas.is_empty() { + completed_text(&value).unwrap_or_default() + } else { + deltas + }; + cli.close_stdin(); + return Ok(TurnOutcome { + engine_session_id: Some(thread_id), + final_text: Some(final_text), + }); + } + "agent/turn_failed" => { + return Err(TurnError::EngineExited( + turn_failure_message(&value), + )); + } + _ => {} + } + } + } +} + +/// Send one converted event and return `true` when the downstream BCS sender +/// has gone away. The caller kills the engine and ends the turn in that case. +async fn send_event( + events: &Sender, + event: StreamEvent, +) -> bool { + events.send(event).await.is_err() +} + +fn map_item_started(value: &Value, run_id: &str, cwd: &Path) -> Option { + let item = value.get("params")?.get("item")?; + let item_type = item.get("type").and_then(Value::as_str)?; + let tool_call_id = item.get("id").and_then(Value::as_str)?.to_string(); + let name = tool_item_name(item, item_type)?; + Some(sse::agent_tool( + run_id, + ToolData { + phase: ToolPhase::Start, + name: Some(name), + tool_call_id: Some(tool_call_id), + is_error: None, + exit_code: None, + duration_ms: None, + cwd: Some(item_string(item, "cwd").unwrap_or_else(|| cwd.to_string_lossy().into_owned())), + args: Some(tool_item_args(item, item_type, cwd)), + result: None, + partial_result: None, + }, + )) +} + +fn map_item_completed(value: &Value, run_id: &str, cwd: &Path) -> Option { + let item = value.get("params")?.get("item")?; + let item_type = item.get("type").and_then(Value::as_str)?; + let tool_call_id = item.get("id").and_then(Value::as_str)?.to_string(); + let status = item.get("status").and_then(Value::as_str); + let is_error = status.is_some_and(|value| value != "completed") + || item + .get("exitCode") + .and_then(Value::as_i64) + .is_some_and(|value| value != 0) + || item.get("success").and_then(Value::as_bool) == Some(false); + let name = tool_item_name(item, item_type)?; + let result = if item_type == "commandExecution" { + item.get("aggregatedOutput") + .cloned() + .unwrap_or(Value::String(String::new())) + } else { + item.get("result") + .cloned() + .or_else(|| item.get("contentItems").cloned()) + .or_else(|| item.get("error").cloned()) + .unwrap_or(Value::Null) + }; + Some(sse::agent_tool( + run_id, + ToolData { + phase: ToolPhase::Result, + name: Some(name), + tool_call_id: Some(tool_call_id), + is_error: Some(is_error), + exit_code: item.get("exitCode").and_then(Value::as_i64), + duration_ms: item.get("durationMs").and_then(Value::as_u64), + cwd: item_string(item, "cwd"), + args: Some(tool_item_args(item, item_type, cwd)), + result: Some(result), + partial_result: None, + }, + )) +} + +fn map_tool_output_delta(value: &Value, run_id: &str) -> Option { + let params = value.get("params")?; + let delta = params.get("delta").and_then(Value::as_str)?; + Some(sse::agent_tool( + run_id, + ToolData { + phase: ToolPhase::Update, + name: None, + tool_call_id: params + .get("itemId") + .and_then(Value::as_str) + .map(str::to_string), + is_error: Some(false), + exit_code: None, + duration_ms: None, + cwd: None, + args: None, + result: None, + partial_result: Some(Value::String(delta.to_string())), + }, + )) +} + +fn map_mcp_progress(value: &Value, run_id: &str) -> Option { + let params = value.get("params")?; + let partial = params + .get("message") + .cloned() + .or_else(|| params.get("delta").cloned()) + .unwrap_or_else(|| params.clone()); + Some(sse::agent_tool( + run_id, + ToolData { + phase: ToolPhase::Update, + name: None, + tool_call_id: params + .get("itemId") + .and_then(Value::as_str) + .map(str::to_string), + is_error: Some(false), + exit_code: None, + duration_ms: None, + cwd: None, + args: None, + result: None, + partial_result: Some(partial), + }, + )) +} + +fn item_string(item: &Value, key: &str) -> Option { + item.get(key).and_then(Value::as_str).map(str::to_string) +} + +fn mcp_tool_name(item: &Value) -> String { + let server = item_string(item, "server").unwrap_or_else(|| "mcp".into()); + let tool = item_string(item, "tool").unwrap_or_else(|| "tool".into()); + format!("mcp__{server}__{tool}") +} + +fn tool_item_name(item: &Value, item_type: &str) -> Option { + match item_type { + "commandExecution" => Some("Bash".into()), + "mcpToolCall" => Some(mcp_tool_name(item)), + "dynamicToolCall" => Some("dynamicTool".into()), + "fileChange" => Some("FileChange".into()), + "webSearch" => Some("WebSearch".into()), + _ => None, + } +} + +fn tool_item_args(item: &Value, item_type: &str, cwd: &Path) -> Value { + match item_type { + "commandExecution" => json!({ + "command": item.get("command").cloned().unwrap_or(Value::Null), + "cwd": item.get("cwd").cloned().unwrap_or_else(|| json!(cwd)), + }), + "mcpToolCall" | "dynamicToolCall" => item + .get("arguments") + .cloned() + .unwrap_or(Value::Null), + _ => item.clone(), + } +} + +async fn rpc_call( + cli: &mut CliSession, + next_id: &mut u64, + backlog: &mut VecDeque, + method: &str, + params: Value, + abort: &CancellationToken, +) -> Result { + let id = *next_id; + *next_id = next_id.saturating_add(1); + let request = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }); + send_line(cli, &request).await?; + + // Notifications may arrive before the response we are waiting for (the + // real Codex app-server emits config/status notifications around + // thread/start). Look for the response in the backlog without letting an + // unrelated notification at the front prevent us from reading stdout. + if let Some(index) = backlog + .iter() + .position(|value| value.get("id").and_then(Value::as_u64) == Some(id)) + { + let Some(value) = backlog.remove(index) else { + return Err(TurnError::Protocol( + "app-server response backlog changed while resolving RPC response".into(), + )); + }; + return decode_rpc_response(value, method); + } + + loop { + // Read the live stdout stream directly here. `next_message` consumes + // the notification backlog first, which is correct while driving the + // turn but would repeatedly return the same unrelated notification + // while this RPC call is waiting for a later response. + let value = tokio::select! { + _ = abort.cancelled() => return Err(TurnError::Aborted), + value = next_live_message(cli) => value?, + }; + if value.get("id").and_then(Value::as_u64) == Some(id) { + return decode_rpc_response(value, method); + } + if value.get("method").is_some() { + backlog.push_back(value); + } + } +} + +fn decode_rpc_response(value: Value, method: &str) -> Result { + if let Some(error) = value.get("error") { + let message = error + .get("message") + .and_then(Value::as_str) + .unwrap_or("app-server RPC failed"); + return Err(TurnError::EngineExited(format!( + "{method}: {}", + sanitize_message(message) + ))); + } + Ok(value.get("result").cloned().unwrap_or(Value::Null)) +} + +async fn send_notification( + cli: &mut CliSession, + method: &str, + params: Value, +) -> Result<(), TurnError> { + send_line( + cli, + &json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + }), + ) + .await +} + +async fn send_line(cli: &mut CliSession, value: &Value) -> Result<(), TurnError> { + let line = serde_json::to_string(value) + .map_err(|error| TurnError::Protocol(format!("encode app-server request: {error}")))?; + cli.write_line(&line).await.map_err(TurnError::Io) +} + +async fn next_message( + cli: &mut CliSession, + backlog: &mut VecDeque, +) -> Result { + if let Some(value) = backlog.pop_front() { + return Ok(value); + } + next_live_message(cli).await +} + +async fn next_live_message(cli: &mut CliSession) -> Result { + let Some(line) = cli.next_line().await.map_err(TurnError::Io)? else { + return Err(TurnError::EngineExited( + "app-server stdout EOF before result".into(), + )); + }; + serde_json::from_str(&line) + .map_err(|error| TurnError::Protocol(format!("parse app-server JSON: {error}"))) +} + +async fn respond_server_request(cli: &mut CliSession, request: &Value) -> Result<(), TurnError> { + let id = request.get("id").cloned().unwrap_or(Value::Null); + send_line( + cli, + &json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32000, + "message": "bridge-provider does not support app-server server requests", + }, + }), + ) + .await +} + +fn thread_start_params(req: &TurnRequest) -> Value { + let mut params = json!({ + "cwd": req.cwd, + "approvalPolicy": "never", + "approvalsReviewer": "user", + "sandbox": "read-only", + "ephemeral": false, + "threadSource": "bridge-provider", + }); + if let Some(model) = req.model.as_deref().filter(|model| !model.is_empty()) { + params["model"] = json!(model); + } + params +} + +fn thread_resume_params(req: &TurnRequest, thread_id: &str) -> Value { + let mut params = json!({ + "threadId": thread_id, + "cwd": req.cwd, + "approvalPolicy": "never", + "approvalsReviewer": "user", + "sandbox": "read-only", + }); + if let Some(model) = req.model.as_deref().filter(|model| !model.is_empty()) { + params["model"] = json!(model); + } + params +} + +fn turn_start_params(req: &TurnRequest, thread_id: &str) -> Value { + json!({ + "threadId": thread_id, + "clientUserMessageId": req.run_id, + "input": [{ + "type": "text", + "text": req.prompt, + "text_elements": [], + }], + "cwd": req.cwd, + "approvalPolicy": "never", + "approvalsReviewer": "user", + "sandboxPolicy": { + "type": "readOnly", + "networkAccess": false, + }, + }) +} + +fn extract_thread_id(value: &Value) -> Option { + value + .pointer("/thread/id") + .and_then(Value::as_str) + .or_else(|| value.get("threadId").and_then(Value::as_str)) + .map(str::to_string) +} + +fn extract_turn_id(value: &Value) -> Option { + value + .pointer("/turn/id") + .and_then(Value::as_str) + .or_else(|| value.get("turnId").and_then(Value::as_str)) + .map(str::to_string) +} + +fn matches_turn(value: &Value, thread_id: &str, turn_id: &str) -> bool { + let Some(params) = value.get("params") else { + return false; + }; + let thread_matches = params + .get("threadId") + .and_then(Value::as_str) + .is_none_or(|value| value == thread_id); + let turn_matches = params + .get("turnId") + .and_then(Value::as_str) + .is_none_or(|value| value == turn_id); + thread_matches && turn_matches +} + +fn is_server_request(value: &Value) -> bool { + value.get("id").is_some() + && value.get("method").is_some() + && value.get("result").is_none() + && value.get("error").is_none() +} + +fn extract_delta(value: &Value) -> Option { + let params = value.get("params")?; + params + .get("delta") + .and_then(Value::as_str) + .or_else(|| params.get("text").and_then(Value::as_str)) + .or_else(|| { + params + .get("delta") + .and_then(|delta| delta.get("text")) + .and_then(Value::as_str) + }) + .map(str::to_string) +} + +fn completed_text(value: &Value) -> Option { + let params = value.get("params")?; + params + .get("text") + .and_then(Value::as_str) + .or_else(|| params.get("finalText").and_then(Value::as_str)) + .or_else(|| params.get("final_text").and_then(Value::as_str)) + .map(str::to_string) +} + +fn completed_failure_message(value: &Value) -> Option { + let turn = value.pointer("/params/turn")?; + if turn.get("status").and_then(Value::as_str) != Some("failed") { + return None; + } + let message = turn + .pointer("/error/message") + .and_then(Value::as_str) + .unwrap_or("app-server turn failed"); + Some(sanitize_message(message)) +} + +fn turn_failure_message(value: &Value) -> String { + let message = value + .pointer("/params/error/message") + .and_then(Value::as_str) + .or_else(|| value.pointer("/params/message").and_then(Value::as_str)) + .unwrap_or("app-server turn failed"); + sanitize_message(message) +} + +fn sanitize_message(message: &str) -> String { + const MAX_LEN: usize = 256; + let clean: String = message.chars().filter(|c| !c.is_control()).collect(); + match clean.char_indices().nth(MAX_LEN) { + Some((index, _)) => clean[..index].to_string(), + None => clean, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_app_server_ids() { + assert_eq!( + extract_thread_id(&json!({"thread": {"id": "t-1"}})).as_deref(), + Some("t-1") + ); + assert_eq!( + extract_turn_id(&json!({"turn": {"id": "turn-1"}})).as_deref(), + Some("turn-1") + ); + } + + #[test] + fn extracts_streaming_delta_shapes() { + assert_eq!( + extract_delta(&json!({"params": {"delta": "hello"}})).as_deref(), + Some("hello") + ); + assert_eq!( + extract_delta(&json!({"params": {"text": "world"}})).as_deref(), + Some("world") + ); + } + + #[test] + fn matches_only_the_active_turn() { + let value = json!({ + "params": {"threadId": "t-1", "turnId": "turn-1"} + }); + assert!(matches_turn(&value, "t-1", "turn-1")); + assert!(!matches_turn(&value, "t-2", "turn-1")); + assert!(!matches_turn(&value, "t-1", "turn-2")); + } +} diff --git a/src/bcs/crates/adapters/bridge-provider/src/engine/cli.rs b/src/bcs/crates/adapters/bridge-provider/src/engine/cli.rs new file mode 100644 index 0000000000..405e3fc06b --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/engine/cli.rs @@ -0,0 +1,250 @@ +use std::path::Path; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, Command}; + +use super::trace::TraceContext; + +pub struct CliSession { + child: Child, + stdin: Option, + stdout: BufReader, + trace: Option, +} + +impl CliSession { + pub async fn spawn( + bin: &Path, + args: &[String], + cwd: &Path, + env: &[(String, String)], + trace: Option, + ) -> std::io::Result { + let mut cmd = Command::new(bin); + cmd.args(args) + .current_dir(cwd) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + for (k, v) in env { + cmd.env(k, v); + } + let mut child = cmd.spawn()?; + let stdin = child.stdin.take().ok_or_else(|| io_err("stdin not piped"))?; + let stdout = child.stdout.take().ok_or_else(|| io_err("stdout not piped"))?; + #[cfg(target_os = "linux")] + enlarge_stdout_pipe(&stdout); + let mut stderr = child + .stderr + .take() + .ok_or_else(|| io_err("stderr not piped"))?; + let stderr_trace = trace.clone(); + tokio::spawn(async move { + let mut reader = BufReader::new(&mut stderr); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => break, + Ok(_) => { + let line = line.trim_end(); + if let Some(trace) = &stderr_trace { + trace.record_stderr(line); + } + tracing::debug!( + target: "bridge_provider::engine", + stderr = super::trace::strip_ansi(line) + ); + } + } + } + }); + Ok(Self { + child, + stdin: Some(stdin), + stdout: BufReader::new(stdout), + trace, + }) + } + + pub async fn write_line(&mut self, line: &str) -> std::io::Result<()> { + let stdin = self + .stdin + .as_mut() + .ok_or_else(|| io_err("stdin already closed"))?; + stdin.write_all(line.as_bytes()).await?; + stdin.write_all(b"\n").await?; + stdin.flush().await + } + + /// Close the child's stdin pipe by dropping the handle. + /// + /// `codex exec` reads a piped stdin as additional input; when the prompt is + /// passed as an argv positional (the codex path), stdin must be closed + /// immediately after spawn so the child sees EOF and proceeds with the argv + /// prompt only — instead of blocking on, or consuming, stdin. + pub fn close_stdin(&mut self) { + self.stdin = None; + } + + pub async fn next_line(&mut self) -> std::io::Result> { + let mut line = String::new(); + let n = self.stdout.read_line(&mut line).await?; + if n == 0 { + return Ok(None); + } + if let Some(trace) = &self.trace { + trace.record_raw(line.trim_end_matches(['\n', '\r'])); + } + Ok(Some(line.trim_end_matches(['\n', '\r']).to_string())) + } + + pub async fn kill(&mut self) { + if let Err(e) = self.child.start_kill() { + tracing::debug!(target: "bridge_provider::engine", "kill failed: {e}"); + } + let _ = self.child.wait().await; + } +} + +/// Give the engine stdout pipe enough room for a complete JSONL event. +/// +/// `codex exec --json` emits an assistant answer as one `item.completed` line, +/// rather than as smaller text deltas. Some cfuse/codex builds write that line +/// through a non-blocking stdout descriptor. On hosts whose default pipe is +/// only 4 KiB, a long answer can therefore hit `EAGAIN` before the line reaches +/// the bridge. Grow the pipe after spawn, before the engine starts producing +/// its final event. The requested size is best-effort because Linux may limit +/// it by the caller's pipe-page quota; smaller fallbacks still cover ordinary +/// long responses, while failure leaves the default pipe behavior unchanged. +#[cfg(target_os = "linux")] +#[allow(unsafe_code)] +fn enlarge_stdout_pipe(stdout: &tokio::process::ChildStdout) { + use std::os::fd::AsRawFd; + + const REQUESTED_SIZES: [libc::c_int; 4] = [ + 1024 * 1024, + 64 * 1024, + 16 * 1024, + 8 * 1024, + ]; + let fd = stdout.as_raw_fd(); + for requested in REQUESTED_SIZES { + // SAFETY: `fd` is borrowed from a live ChildStdout and F_SETPIPE_SZ + // only adjusts the kernel pipe capacity associated with that fd. + let actual = unsafe { libc::fcntl(fd, libc::F_SETPIPE_SZ, requested) }; + if actual >= 0 { + tracing::debug!( + target: "bridge_provider::engine", + requested, + actual, + "engine stdout pipe capacity configured" + ); + return; + } + tracing::debug!( + target: "bridge_provider::engine", + requested, + error = %std::io::Error::last_os_error(), + "engine stdout pipe capacity request rejected" + ); + } + tracing::warn!( + target: "bridge_provider::engine", + "unable to enlarge engine stdout pipe; long JSONL events may be truncated by the engine" + ); +} + +fn io_err(msg: &'static str) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::BrokenPipe, msg) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn cli_session_echo_and_kill() { + let script = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/mock_engine.sh"); + let mut cli = CliSession::spawn( + Path::new("bash"), + &[script.to_string()], + Path::new("."), + &[], + None, + ) + .await + .unwrap(); + cli.write_line("hello").await.unwrap(); + let line = cli.next_line().await.unwrap().unwrap(); + assert_eq!(line, "ack:hello"); + cli.kill().await; + } + + #[tokio::test] + async fn cli_session_close_stdin_blocks_further_writes() { + let script = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/mock_engine.sh"); + let mut cli = CliSession::spawn( + Path::new("bash"), + &[script.to_string()], + Path::new("."), + &[], + None, + ) + .await + .unwrap(); + cli.close_stdin(); + let err = cli.write_line("late").await.expect_err("write after close must fail"); + assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe); + cli.kill().await; + } + + #[cfg(target_os = "linux")] + #[tokio::test] + #[allow(unsafe_code)] + async fn cli_session_enlarges_stdout_pipe_for_long_jsonl_events() { + use std::os::fd::AsRawFd; + + let script = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/mock_engine.sh"); + let cli = CliSession::spawn( + Path::new("bash"), + &[script.to_string()], + Path::new("."), + &[], + None, + ) + .await + .unwrap(); + let capacity = unsafe { libc::fcntl(cli.stdout.get_ref().as_raw_fd(), libc::F_GETPIPE_SZ) }; + assert!(capacity >= 8 * 1024, "engine stdout pipe remained too small: {capacity}"); + // `kill_on_drop` reaps the fixture without waiting here; the existing + // kill-path tests cover the explicit async cleanup method. + drop(cli); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn cli_session_reads_nonblocking_long_jsonl_line_without_truncation() { + let script = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/mock_engine_nonblocking_long_line.sh" + ); + let mut cli = CliSession::spawn( + Path::new("bash"), + &[script.to_string()], + Path::new("."), + &[], + None, + ) + .await + .unwrap(); + let line = cli + .next_line() + .await + .unwrap() + .expect("non-blocking fixture should emit a complete line"); + assert!(line.contains("\"type\":\"item.completed\"")); + assert_eq!(line.len(), 7068, "long JSONL event was truncated"); + drop(cli); + } +} diff --git a/src/bcs/crates/adapters/bridge-provider/src/engine/mod.rs b/src/bcs/crates/adapters/bridge-provider/src/engine/mod.rs new file mode 100644 index 0000000000..c654b3c5f2 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/engine/mod.rs @@ -0,0 +1,194 @@ +pub mod cfuse_cc; +pub mod cfuse_codex; +pub mod cfuse_codex_app_server; +pub mod cli; +pub mod trace; +pub mod transcript; + +use std::path::PathBuf; +use std::sync::Arc; + +use bcs_protocol::stream::StreamEvent; + +pub use crate::config::EngineKind; +use crate::config::BotConfig; +use crate::interaction::InteractionRegistry; + +/// One BCS downstream turn request. +/// +/// `run_id` is the BCS downstream body id; frames use it as `runId`. +/// `engine_session_id` carries the engine-native session id on follow-up turns. +/// `cfuse_bin` is the resolved engine binary path. +/// `interactions` is the run's HITL interaction registry: the cc driver +/// registers pending `can_use_tool`/`AskUserQuestion` requests with it, the +/// webhook `interaction.resolve` handler delivers decisions to it. +pub struct TurnRequest { + pub run_id: String, + pub prompt: String, + pub engine_session_id: Option, + pub cwd: PathBuf, + pub model: Option, + pub cfuse_bin: PathBuf, + pub permission_mode: Option, + pub interactions: InteractionRegistry, + pub trace: Option, +} + +/// Outcome of an engine turn: the engine-internal session id (if one was +/// established/resumed) and the final assistant text, if the turn completed. +#[derive(Debug)] +pub struct TurnOutcome { + pub engine_session_id: Option, + pub final_text: Option, +} + +/// Engine turn errors. `EngineExited` carries the engine's own exit reason; +/// `Aborted` is returned when the run is cancelled via the abort token. +/// `Io` is raised on stdout/stdin IO failures (e.g. broken pipe) and converts +/// from [`std::io::Error`] via `?` for the driver's read/write paths. +#[derive(Debug, thiserror::Error)] +pub enum TurnError { + #[error("spawn engine: {0}")] + Spawn(std::io::Error), + #[error("engine exited: {0}")] + EngineExited(String), + #[error("engine turn aborted")] + Aborted, + #[error("engine protocol error: {0}")] + Protocol(String), + #[error("engine io: {0}")] + Io(#[from] std::io::Error), +} + +#[async_trait::async_trait] +pub trait Engine: Send + Sync { + fn kind(&self) -> EngineKind; + async fn run_turn( + &self, + req: TurnRequest, + events: tokio::sync::mpsc::Sender, + abort: tokio_util::sync::CancellationToken, + ) -> Result; +} + +/// Build an [`Engine`] for `bot`. `CfuseCc` uses the Claude stream-json driver; +/// `CfuseCodex` uses the Codex app-server JSON-RPC driver. The legacy +/// [`cfuse_codex::CfuseCodex`] mapping module remains available for protocol +/// fixtures while new runtime turns use app-server for streaming/resume. +pub fn build_engine(bot: &BotConfig) -> Arc { + match bot.engine { + EngineKind::CfuseCc => Arc::new(cfuse_cc::CfuseCc::new( + bot.cfuse_bin.clone().unwrap_or_else(|| PathBuf::from("cfuse")), + )), + EngineKind::CfuseCodex => Arc::new(cfuse_codex_app_server::CfuseCodexAppServer::new( + bot.cfuse_bin.clone().unwrap_or_else(|| PathBuf::from("cfuse")), + )), + } +} + +/// Validate an engine-native session id before it is used as a transcript path +/// component (`.jsonl`) or a `--resume`/`exec resume` argv +/// argument. An engine must never be a trusted source for these — a buggy or +/// hostile engine could supply `../../evil` (path traversal) or `--evil` +/// (argv option injection). Rules: non-empty; no leading dash (argv option +/// guard); no path separators or parent refs; only ascii alphanumeric plus +/// `-`/`_`/`.`. The two engine drivers call this at their capture sites (cc +/// `system/init`, codex `thread.started`); an invalid id is logged and treated +/// as no session (not persisted, not resumed, transcript sink skipped). +pub(crate) fn is_valid_engine_session_id(id: &str) -> bool { + !id.is_empty() + && !id.starts_with('-') + && !id.contains(['/', '\\']) + && !id.contains("..") + && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) +} + +#[cfg(test)] +mod tests { + use super::*; + use bcs_protocol::stream::StreamEvent; + + struct FakeEngine; + #[async_trait::async_trait] + impl Engine for FakeEngine { + fn kind(&self) -> EngineKind { EngineKind::CfuseCc } + async fn run_turn(&self, req: TurnRequest, + events: tokio::sync::mpsc::Sender, + _abort: tokio_util::sync::CancellationToken) + -> Result { + let _ = events.send(crate::sse::chat_delta(&req.run_id, "fake")).await; + Ok(TurnOutcome { engine_session_id: Some("e-1".into()), final_text: Some("done".into()) }) + } + } + + #[tokio::test] + async fn fake_engine_emits_delta() { + let (tx, mut rx) = tokio::sync::mpsc::channel(8); + let engine = FakeEngine; + let req = TurnRequest { + run_id: "r-1".into(), prompt: "hi".into(), engine_session_id: None, + cwd: ".".into(), model: None, cfuse_bin: "cfuse".into(), permission_mode: None, + interactions: InteractionRegistry::new(), + trace: None, + }; + let outcome = engine.run_turn(req, tx, tokio_util::sync::CancellationToken::new()).await.unwrap(); + assert_eq!(outcome.engine_session_id.as_deref(), Some("e-1")); + assert!(rx.recv().await.is_some()); + } + + #[tokio::test] + async fn build_engine_wires_cfuse_codex_driver() { + // 不 spawn:只确认 build_engine 对 CfuseCodex 返回真实驱动(而非 stub)。 + let bot = BotConfig { + provider_bot_ref: "codex-worker".into(), + engine: EngineKind::CfuseCodex, + model: None, + cwd: "/tmp".into(), + permission_mode: None, + cfuse_bin: Some(PathBuf::from("/usr/local/bin/cfuse")), + }; + let engine = build_engine(&bot); + assert_eq!(engine.kind(), EngineKind::CfuseCodex); + } + + #[tokio::test] + async fn build_engine_wires_cfuse_cc_driver() { + // 不 spawn:只确认 build_engine 对 CfuseCc 返回真实驱动(而非 stub)。 + let bot = BotConfig { + provider_bot_ref: "cc-worker".into(), + engine: EngineKind::CfuseCc, + model: None, + cwd: "/tmp".into(), + permission_mode: None, + cfuse_bin: Some(PathBuf::from("/usr/local/bin/cfuse")), + }; + let engine = build_engine(&bot); + assert_eq!(engine.kind(), EngineKind::CfuseCc); + } + + #[test] + fn is_valid_engine_session_id_accepts_safe_ids() { + assert!(is_valid_engine_session_id("cc-sess-1")); + assert!(is_valid_engine_session_id("01a058a5-5bb3-7702-bc4c-7d26b3bfa32d")); + // underscores and dots are in the allowed set; a single dot is fine. + assert!(is_valid_engine_session_id("thread_42")); + assert!(is_valid_engine_session_id("sess.1")); + } + + #[test] + fn is_valid_engine_session_id_rejects_unsafe_ids() { + // empty + assert!(!is_valid_engine_session_id(""), "empty rejected"); + // path separators (path traversal) + assert!(!is_valid_engine_session_id("a/b"), "forward slash rejected"); + assert!(!is_valid_engine_session_id("a\\b"), "backslash rejected"); + assert!(!is_valid_engine_session_id("../x"), "parent ref rejected"); + assert!(!is_valid_engine_session_id("a..b"), "embedded parent ref rejected"); + // leading dash (argv option injection) + assert!(!is_valid_engine_session_id("--evil"), "leading dash rejected"); + // whitespace / other disallowed chars + assert!(!is_valid_engine_session_id("a b"), "space rejected"); + assert!(!is_valid_engine_session_id("a:b"), "colon rejected"); + assert!(!is_valid_engine_session_id("café"), "non-ascii rejected"); + } +} diff --git a/src/bcs/crates/adapters/bridge-provider/src/engine/trace.rs b/src/bcs/crates/adapters/bridge-provider/src/engine/trace.rs new file mode 100644 index 0000000000..a889696879 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/engine/trace.rs @@ -0,0 +1,270 @@ +//! File-backed engine observation for local bridge debugging. +//! +//! The trace is deliberately separate from the protocol path. It records the +//! exact engine stdout line before parsing, a cleaned copy of engine stderr, +//! the normalized event produced by a driver, and the final SSE frame emitted +//! by the run loop. Trace failures are diagnostic-only: they are logged but do +//! not change engine behavior. + +use std::fs::{File, OpenOptions, create_dir_all}; +use std::io::{self, Write}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use bcs_protocol::stream::{AgentData, StreamEvent}; +use serde_json::{Value, json}; + +pub struct TraceStore { + raw: Mutex, + stderr: Mutex, + converted: Mutex, + sse: Mutex, +} + +#[derive(Clone)] +pub struct TraceContext { + store: Arc, + engine: String, + run_id: String, +} + +impl TraceStore { + pub fn open(dir: &Path) -> io::Result> { + create_dir_all(dir)?; + Ok(Arc::new(Self { + raw: Mutex::new(open_append(dir.join("engine.raw.ndjson"))?), + stderr: Mutex::new(open_append(dir.join("engine.stderr.ndjson"))?), + converted: Mutex::new(open_append(dir.join("bridge.converted.ndjson"))?), + sse: Mutex::new(open_append(dir.join("bridge.sse.ndjson"))?), + })) + } + + fn append(&self, stream: &str, file: &Mutex, value: Value) { + let line = match serde_json::to_string(&value) { + Ok(line) => line, + Err(error) => { + tracing::warn!( + target: "bridge_provider::trace", + stream, + error = %error, + "failed to encode trace record" + ); + return; + } + }; + let mut guard = match file.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + if let Err(error) = writeln!(guard, "{line}") { + tracing::warn!( + target: "bridge_provider::trace", + stream, + error = %error, + "failed to append trace record" + ); + } + } + + fn raw(&self, context: &TraceContext, line: &str) { + self.append( + "raw", + &self.raw, + json!({ + "direction": "engine_to_bridge", + "engine": context.engine, + "runId": context.run_id, + "line": line, + "json": serde_json::from_str::(line).ok(), + }), + ); + } + + fn stderr(&self, context: &TraceContext, line: &str) { + self.append( + "stderr", + &self.stderr, + json!({ + "direction": "engine_stderr", + "engine": context.engine, + "runId": context.run_id, + "text": strip_ansi(line), + "rawText": line, + }), + ); + } + + fn converted(&self, context: &TraceContext, event: &StreamEvent, seq: u64) { + self.append( + "converted", + &self.converted, + json!({ + "direction": "bridge_converted", + "engine": context.engine, + "runId": context.run_id, + "seq": seq, + "event": stream_event_value(event), + }), + ); + } + + fn sse(&self, context: &TraceContext, seq: u64, frame: &str) { + self.append( + "sse", + &self.sse, + json!({ + "direction": "bridge_to_bcs", + "engine": context.engine, + "runId": context.run_id, + "seq": seq, + "frame": frame, + "data": sse_data(frame), + }), + ); + } +} + +impl TraceContext { + pub fn new(store: Arc, engine: impl Into, run_id: impl Into) -> Self { + Self { store, engine: engine.into(), run_id: run_id.into() } + } + + pub fn record_raw(&self, line: &str) { + self.store.raw(self, line); + } + + pub fn record_stderr(&self, line: &str) { + self.store.stderr(self, line); + } + + pub fn record_converted(&self, event: &StreamEvent, seq: u64) { + self.store.converted(self, event, seq); + } + + pub fn record_sse(&self, seq: u64, frame: &str) { + self.store.sse(self, seq, frame); + } +} + +fn open_append(path: impl AsRef) -> io::Result { + OpenOptions::new().create(true).append(true).open(path) +} + +fn stream_event_value(event: &StreamEvent) -> Value { + match event { + StreamEvent::Chat(chat) => json!({ + "kind": "chat", + "state": chat.state, + "runId": chat.run_id, + "deltaText": chat.delta_text, + "stopReason": chat.stop_reason, + "errorMessage": chat.error_message, + "errorKind": chat.error_kind, + "message": chat.message, + "raw": chat.raw, + }), + StreamEvent::Agent(agent) => match &agent.data { + AgentData::Tool(tool) => json!({ + "kind": "agent", + "stream": "tool", + "runId": agent.run_id, + "data": serde_json::to_value(tool).unwrap_or(Value::Null), + "raw": agent.raw, + }), + AgentData::Thinking(thinking) => json!({ + "kind": "agent", + "stream": "thinking", + "runId": agent.run_id, + "data": serde_json::to_value(thinking).unwrap_or(Value::Null), + "raw": agent.raw, + }), + AgentData::Lifecycle(lifecycle) => json!({ + "kind": "agent", + "stream": "lifecycle", + "runId": agent.run_id, + "data": serde_json::to_value(lifecycle).unwrap_or(Value::Null), + "raw": agent.raw, + }), + AgentData::Approval(_) => json!({ + "kind": "agent", + "stream": "approval", + "runId": agent.run_id, + "raw": agent.raw, + }), + AgentData::Phase(_) => json!({ + "kind": "agent", + "stream": "phase", + "runId": agent.run_id, + "raw": agent.raw, + }), + AgentData::Unknown { stream, raw } => json!({ + "kind": "agent", + "stream": stream, + "runId": agent.run_id, + "raw": raw, + }), + }, + StreamEvent::Interaction(interaction) => json!({ + "kind": "interaction", + "runId": interaction.run_id, + "phase": interaction.phase, + "interactionId": interaction.interaction_id, + "interactionKind": interaction.kind, + "raw": interaction.raw, + }), + StreamEvent::Ping { ts } => json!({ "kind": "ping", "ts": ts }), + StreamEvent::Unknown { event, raw } => json!({ + "kind": "unknown", + "event": event, + "raw": raw, + }), + } +} + +fn sse_data(frame: &str) -> Value { + let data = frame + .lines() + .filter_map(|line| line.strip_prefix("data: ")) + .collect::(); + serde_json::from_str(&data).unwrap_or(Value::String(data)) +} + +/// Remove CSI-style ANSI sequences from a child diagnostic line while keeping +/// the original text in the trace record's `rawText` field. +pub fn strip_ansi(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars(); + while let Some(ch) = chars.next() { + if ch != '\u{1b}' { + output.push(ch); + continue; + } + match chars.next() { + Some('[') => { + for next in chars.by_ref() { + if ('@'..='~').contains(&next) { + break; + } + } + } + Some(_) | None => {} + } + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_ansi_color_sequences() { + assert_eq!(strip_ansi("\u{1b}[2mINFO\u{1b}[0m hello"), "INFO hello"); + } + + #[test] + fn preserves_sse_data_as_json() { + let frame = "event: chat\ndata: {\"state\":\"delta\"}\n\n"; + assert_eq!(sse_data(frame)["state"], "delta"); + } +} diff --git a/src/bcs/crates/adapters/bridge-provider/src/engine/transcript.rs b/src/bcs/crates/adapters/bridge-provider/src/engine/transcript.rs new file mode 100644 index 0000000000..d8a37a69bb --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/engine/transcript.rs @@ -0,0 +1,267 @@ +//! Transcript sink for engine-native session files. +//! +//! A [`TranscriptSink`] writes an inject message into the engine's own +//! per-session transcript file so the message lives in the engine's history +//! and is visible to future turns without us driving a turn (spec §5.1: +//! inject never triggers an engine run). The CC engine's transcript lives at +//! `~/.claude/projects//.jsonl` (cwd `/`→`-`), +//! and is read back at `cfuse --cc --resume ` — so a sunk +//! `user` entry fortifies the conversation with the inject text. +//! +//! [`ClaudeJsonlSink`] is the CC sink. Codex is sunk as `None` (no sink): its +//! injects stay in `pending_injects` and are prepended to the next chat.send +//! prompt as `[from:{name}] {text}` (see `run::assemble_prompt`). +//! +//! Idempotency: each appended entry carries `bridgeInjectId = inject run_id`. +//! Before appending, we scan the file's existing content for a line bearing +//! the same `bridgeInjectId` and skip if present — a retry with the same id +//! does not duplicate the entry. +//! +//! Chain link: a new entry's `parentUuid` is set to the last existing line's +//! `uuid` if one is present (best-effort single-line lookback), matching the +//! cc JSONL convention; if the file is missing or no predecessor is parseable +//! the field is omitted. + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; + +use crate::session::InjectedMessage; + +/// Append an inject message into an engine-native per-session transcript file. +/// Implementations are idempotent on the inject's `run_id`: a second call with +/// the same `run_id` MUST be a no-op. +pub trait TranscriptSink: Send + Sync { + fn append_user_message( + &self, + cwd: &Path, + engine_session_id: &str, + msg: &InjectedMessage, + ) -> Result<(), TranscriptError>; +} + +#[derive(Debug, thiserror::Error)] +pub enum TranscriptError { + #[error("transcript io: {0}")] + Io(#[from] std::io::Error), + #[error("transcript serialize: {0}")] + Serialize(#[from] serde_json::Error), +} + +/// CC transcript sink: writes user entries into +/// `//.jsonl`. Production +/// resolves `` from `$HOME/.claude/projects`; tests inject a +/// tempdir root via [`ClaudeJsonlSink::with_projects_root`]. +pub struct ClaudeJsonlSink { + projects_root: PathBuf, +} + +impl ClaudeJsonlSink { + /// Test/dev constructor: place transcripts under `root`. + pub fn with_projects_root(root: PathBuf) -> Self { + Self { projects_root: root } + } + + /// Production constructor: `$HOME/.claude/projects`. Returns `None` when + /// `$HOME` is unset (the caller MUST then fall back to pending injects — + /// there is no transcript file we can locate). + pub fn default_home() -> Option { + std::env::var_os("HOME").map(|h| Self { + projects_root: PathBuf::from(h).join(".claude").join("projects"), + }) + } + + /// Per-session transcript path: `//.jsonl`. + fn session_file(&self, cwd: &Path, engine_session_id: &str) -> PathBuf { + let encoded = encode_cwd(cwd); + self.projects_root + .join(encoded) + .join(format!("{engine_session_id}.jsonl")) + } +} + +impl TranscriptSink for ClaudeJsonlSink { + fn append_user_message( + &self, + cwd: &Path, + engine_session_id: &str, + msg: &InjectedMessage, + ) -> Result<(), TranscriptError> { + let file = self.session_file(cwd, engine_session_id); + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent)?; + } + // Read existing content; missing file is treated as empty (new session). + let existing = match std::fs::read_to_string(&file) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(TranscriptError::Io(e)), + }; + // Idempotency: skip if an entry with the same `bridgeInjectId` (= run_id) + // already lives in the file. Substring scan is safe — run_ids are unique + // and the literal `"bridgeInjectId":""` shape is fixed by us. + let needle = format!("\"bridgeInjectId\":\"{}\"", msg.run_id); + if existing.contains(&needle) { + return Ok(()); + } + // Best-effort `parentUuid`: last non-empty line's `uuid` if present. + let parent_uuid = existing + .lines() + .rev() + .find(|l| !l.trim().is_empty()) + .and_then(|l| serde_json::from_str::(l).ok()) + .and_then(|v| v.get("uuid").and_then(|x| x.as_str()).map(str::to_string)); + + // Entry text: `[from:{name}] {text}` (or bare `{text}` when no name). + let text = match &msg.from_name { + Some(name) => format!("[from:{name}] {}", msg.text), + None => msg.text.clone(), + }; + let mut entry = json!({ + "type": "user", + "uuid": uuid::Uuid::new_v4().to_string(), + "sessionId": engine_session_id, + "bridgeInjectId": msg.run_id, + "timestamp": bcs_protocol::now_ms(), + "message": { + "role": "user", + "content": [{ "type": "text", "text": text }] + } + }); + if let Some(p) = parent_uuid { + entry["parentUuid"] = Value::String(p); + } + let line = serde_json::to_string(&entry)? + "\n"; + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&file)?; + f.write_all(line.as_bytes())?; + Ok(()) + } +} + +/// Encode a cwd path for the projects-dir layout: every `/` becomes `-`. +/// `/tmp/work` → `-tmp-work`; the leading slash also maps to `-`. +fn encode_cwd(cwd: &Path) -> String { + cwd.to_string_lossy().replace('/', "-") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session::InjectedMessage; + use std::path::Path; + + #[test] + fn claude_jsonl_sink_appends_idempotently() { + let dir = tempfile::tempdir().unwrap(); + // Claude project layout: //.jsonl; + // encoded-cwd = path '/'->'-' + let projects = dir.path().join("projects"); + let sess_dir = projects.join("-tmp-work"); + std::fs::create_dir_all(&sess_dir).unwrap(); + let sess_file = sess_dir.join("sess-1.jsonl"); + std::fs::write(&sess_file, "{\"type\":\"assistant\",\"uuid\":\"u1\",\"message\":{}}\n").unwrap(); + + let sink = ClaudeJsonlSink::with_projects_root(projects.clone()); + let msg = InjectedMessage { run_id: "inj-1".into(), from_name: Some("张三".into()), text: "观察".into() }; + sink.append_user_message(Path::new("/tmp/work"), "sess-1", &msg).unwrap(); + sink.append_user_message(Path::new("/tmp/work"), "sess-1", &msg).unwrap(); // idempotent + + let content = std::fs::read_to_string(&sess_file).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 2); // only one new line appended + let appended: Value = serde_json::from_str(lines[1]).unwrap(); + assert_eq!(appended["type"], serde_json::json!("user")); + assert_eq!(appended["parentUuid"], serde_json::json!("u1")); + assert_eq!(appended["bridgeInjectId"], serde_json::json!("inj-1")); + assert_eq!(appended["message"]["content"][0]["text"], serde_json::json!("[from:张三] 观察")); + } + + #[test] + fn encode_cwd_replaces_slashes_with_dashes() { + assert_eq!(encode_cwd(Path::new("/tmp/work")), "-tmp-work"); + assert_eq!(encode_cwd(Path::new("/")), "-"); + assert_eq!(encode_cwd(Path::new("relative/nested")), "relative-nested"); + } + + #[test] + fn sink_creates_missing_session_dir_and_file() { + let dir = tempfile::tempdir().unwrap(); + let projects = dir.path().join("projects"); + let sink = ClaudeJsonlSink::with_projects_root(projects.clone()); + let msg = InjectedMessage { run_id: "inj-2".into(), from_name: None, text: "bare text".into() }; + // No pre-existing dir/file; sink must create both. + sink.append_user_message(Path::new("/tmp/other"), "sess-new", &msg).unwrap(); + let file = projects.join("-tmp-other").join("sess-new.jsonl"); + let content = std::fs::read_to_string(&file).unwrap(); + let appended: Value = serde_json::from_str(content.trim()).unwrap(); + assert_eq!(appended["type"], serde_json::json!("user")); + assert_eq!(appended["message"]["content"][0]["text"], serde_json::json!("bare text")); + assert_eq!(appended["bridgeInjectId"], serde_json::json!("inj-2")); + assert!(appended.get("parentUuid").is_none(), "no parent for a fresh file"); + } + + #[test] + fn sink_prepends_from_name_when_present() { + let dir = tempfile::tempdir().unwrap(); + let projects = dir.path().join("projects"); + let sink = ClaudeJsonlSink::with_projects_root(projects.clone()); + let msg = InjectedMessage { + run_id: "inj-3".into(), + from_name: Some("李四".into()), + text: "hello".into(), + }; + sink.append_user_message(Path::new("/tmp/x"), "sess-x", &msg).unwrap(); + let file = projects.join("-tmp-x").join("sess-x.jsonl"); + let content = std::fs::read_to_string(&file).unwrap(); + let appended: Value = serde_json::from_str(content.trim()).unwrap(); + assert_eq!( + appended["message"]["content"][0]["text"], + serde_json::json!("[from:李四] hello") + ); + } + + #[test] + fn sink_chain_links_parent_uuid_to_last_line() { + let dir = tempfile::tempdir().unwrap(); + let projects = dir.path().join("projects"); + let sess_dir = projects.join("-tmp-chain"); + std::fs::create_dir_all(&sess_dir).unwrap(); + let sess_file = sess_dir.join("c.jsonl"); + // Seed two prior lines; the sink must pick up the LAST line's uuid. + std::fs::write( + &sess_file, + "{\"type\":\"user\",\"uuid\":\"a1\",\"message\":{}}\n\ + {\"type\":\"assistant\",\"uuid\":\"a2\",\"message\":{}}\n", + ) + .unwrap(); + let sink = ClaudeJsonlSink::with_projects_root(projects.clone()); + let msg = InjectedMessage { run_id: "inj-c".into(), from_name: None, text: "c".into() }; + sink.append_user_message(Path::new("/tmp/chain"), "c", &msg).unwrap(); + let content = std::fs::read_to_string(&sess_file).unwrap(); + let appended: Value = + serde_json::from_str(content.lines().last().unwrap()).unwrap(); + assert_eq!(appended["parentUuid"], serde_json::json!("a2")); + } + + #[test] + fn sink_distinct_run_ids_append_distinct_lines() { + let dir = tempfile::tempdir().unwrap(); + let projects = dir.path().join("projects"); + let sink = ClaudeJsonlSink::with_projects_root(projects.clone()); + let m1 = InjectedMessage { run_id: "inj-a".into(), from_name: None, text: "one".into() }; + let m2 = InjectedMessage { run_id: "inj-b".into(), from_name: None, text: "two".into() }; + sink.append_user_message(Path::new("/tmp/d"), "s", &m1).unwrap(); + sink.append_user_message(Path::new("/tmp/d"), "s", &m2).unwrap(); + let file = projects.join("-tmp-d").join("s.jsonl"); + let content = std::fs::read_to_string(&file).unwrap(); + assert_eq!(content.lines().count(), 2); + // Re-appending the FIRST run_id still no-ops (idempotency is per-run_id). + sink.append_user_message(Path::new("/tmp/d"), "s", &m1).unwrap(); + let content2 = std::fs::read_to_string(&file).unwrap(); + assert_eq!(content2.lines().count(), 2, "idempotent per-run_id: re-append no-ops"); + } +} diff --git a/src/bcs/crates/adapters/bridge-provider/src/error.rs b/src/bcs/crates/adapters/bridge-provider/src/error.rs new file mode 100644 index 0000000000..03a6e85f1a --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/error.rs @@ -0,0 +1,49 @@ +use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; +use serde_json::json; + +#[derive(Debug)] +pub struct BridgeError { + pub status: StatusCode, + pub code: &'static str, + pub message: String, + pub retryable: bool, +} + +impl BridgeError { + fn new(status: StatusCode, code: &'static str, message: impl Into, retryable: bool) -> Self { + Self { status, code, message: message.into(), retryable } + } + pub fn invalid_request(m: impl Into) -> Self { Self::new(StatusCode::BAD_REQUEST, "invalid_request", m, false) } + pub fn unauthorized() -> Self { Self::new(StatusCode::UNAUTHORIZED, "unauthorized", "invalid token", false) } + pub fn provider_id_mismatch() -> Self { Self::new(StatusCode::FORBIDDEN, "provider_id_mismatch", "provider_id does not match this bridge", false) } + pub fn bot_not_found(r: &str) -> Self { Self::new(StatusCode::NOT_FOUND, "bot_not_found", format!("bot {r} is not registered on this bridge"), false) } + pub fn conflict() -> Self { Self::new(StatusCode::CONFLICT, "conflict", "same idempotency key with different body", false) } + pub fn rate_limited() -> Self { Self::new(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "a run is already active for this session", true) } + pub fn unsupported_method(m: &str) -> Self { Self::new(StatusCode::NOT_IMPLEMENTED, "unsupported_method", format!("method {m} is not supported"), false) } + pub fn unavailable(m: impl Into) -> Self { Self::new(StatusCode::SERVICE_UNAVAILABLE, "unavailable", m, true) } + pub fn timeout() -> Self { Self::new(StatusCode::GATEWAY_TIMEOUT, "timeout", "dependency timed out", true) } + pub fn run_terminated() -> Self { Self::new(StatusCode::GONE, "run_terminated", "run is already terminal", false) } +} + +impl IntoResponse for BridgeError { + fn into_response(self) -> Response { + let (status, body) = self.into_parts(); + (status, Json(body)).into_response() + } +} + +impl BridgeError { + /// Render this error as `(StatusCode, body Value)` — the same shape + /// [`IntoResponse`] produces, but split out so a caller can (a) store the + /// body in the idempotency ledger via `complete_with_status` and (b) return + /// the exact same status+body as the original response on a same-id retry. + /// Used by `chat.abort`'s 410 `run_terminated` path so ledger replay returns + /// 410 (not the default in-flight 200 ack). + pub fn into_parts(self) -> (StatusCode, serde_json::Value) { + let body = json!({ + "ok": false, + "error": { "code": self.code, "message": self.message, "retryable": self.retryable } + }); + (self.status, body) + } +} diff --git a/src/bcs/crates/adapters/bridge-provider/src/idempotency.rs b/src/bcs/crates/adapters/bridge-provider/src/idempotency.rs new file mode 100644 index 0000000000..cd9ac892cd --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/idempotency.rs @@ -0,0 +1,126 @@ +use std::{collections::HashMap, sync::Mutex}; + +use axum::http::StatusCode; + +/// Idempotency decision returned by [`IdempotencyLedger::begin`]. +/// +/// `Replay` carries the `status` + `body` of the originally-completed response +/// (or the default in-flight ack `200 {"ok":true}` for an `InProgress` entry), +/// so a caller that wants to surface non-200 responses (e.g. `chat.abort`'s 410 +/// `run_terminated`) can be replayed with the exact same status on retry. +pub enum IdemDecision { + Proceed, + Replay { status: StatusCode, body: serde_json::Value }, + Conflict, +} + +enum Entry { + InProgress { fingerprint: String }, + Completed { fingerprint: String, status: StatusCode, response: serde_json::Value }, +} + +#[derive(Default)] +pub struct IdempotencyLedger { map: Mutex> } + +impl IdempotencyLedger { + pub fn new() -> Self { Self::default() } + + /// Look up `id` and decide between Proceed (new entry — caller drives the + /// operation), Replay (matching fingerprint — replay the prior response, + /// or the default in-flight ack `200 {"ok":true}` while still in flight), or + /// Conflict (same id with a different fingerprint). The default in-flight + /// ack preserves the historical inject behavior: a same-id retry of an + /// already-running request receives `200 {"ok":true}` immediately. + pub fn begin(&self, id: &str, fingerprint: &str) -> IdemDecision { + let mut map = self.map.lock().unwrap_or_else(|p| p.into_inner()); + match map.get(id) { + Some(Entry::InProgress { fingerprint: f }) if f == fingerprint => + IdemDecision::Replay { status: StatusCode::OK, body: serde_json::json!({"ok": true}) }, + Some(Entry::Completed { fingerprint: f, status, response }) if f == fingerprint => + IdemDecision::Replay { status: *status, body: response.clone() }, + Some(_) => IdemDecision::Conflict, + None => { + map.insert(id.to_string(), Entry::InProgress { fingerprint: fingerprint.to_string() }); + IdemDecision::Proceed + } + } + } + + /// Complete an in-progress entry in the default 200 OK shape (the original + /// inject path — kept for callers that only ever respond 200). + pub fn complete(&self, id: &str, response: serde_json::Value) { + self.complete_with_status(id, StatusCode::OK, response); + } + + /// Complete an in-progress entry, recording the response's `status` and + /// `body`. A subsequent same-id, same-fingerprint retry replays this exact + /// `(status, body)` pair via [`IdemDecision::Replay`]. Used by `chat.abort` + /// to make a 410 `run_terminated` retry replay as 410 (not the default + /// in-flight ack). Only an in-progress entry is advanced — an + /// already-completed entry is left untouched (no overwrite). + pub fn complete_with_status(&self, id: &str, status: StatusCode, response: serde_json::Value) { + let mut map = self.map.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(Entry::InProgress { fingerprint }) = map.get(id) { + let fingerprint = fingerprint.clone(); + map.insert(id.to_string(), Entry::Completed { fingerprint, status, response }); + } + } +} + +pub fn fingerprint(parts: &[&str]) -> String { + // 稳定拼接;调用方传入已选定的关键字段,避免引入哈希依赖 + parts.join("\u{1f}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedupes_same_id_same_body_and_conflicts_different_body() { + let ledger = IdempotencyLedger::new(); + assert!(matches!(ledger.begin("id-1", "fp-a"), IdemDecision::Proceed)); + ledger.complete("id-1", serde_json::json!({"ok": true})); + match ledger.begin("id-1", "fp-a") { + IdemDecision::Replay { status, body } => { + assert_eq!(status, StatusCode::OK); + assert_eq!(body["ok"], serde_json::json!(true)); + } + _ => panic!("expected replay"), + } + assert!(matches!(ledger.begin("id-1", "fp-b"), IdemDecision::Conflict)); + } + + #[test] + fn in_progress_same_body_replays_ok_ack() { + let ledger = IdempotencyLedger::new(); + assert!(matches!(ledger.begin("id-2", "fp-a"), IdemDecision::Proceed)); + match ledger.begin("id-2", "fp-a") { + IdemDecision::Replay { status, body } => { + assert_eq!(status, StatusCode::OK); + assert_eq!(body["ok"], serde_json::json!(true)); + } + _ => panic!("expected replay"), + } + } + + #[test] + fn complete_with_status_replays_non_200_status_on_retry() { + // chat.abort's 410 run_terminated must replay as 410 (not the default + // in-flight 200 ack) so a same-id retry stably returns the same status. + let ledger = IdempotencyLedger::new(); + assert!(matches!(ledger.begin("id-3", "fp-a"), IdemDecision::Proceed)); + let body = serde_json::json!({ + "ok": false, + "error": { "code": "run_terminated", "message": "run is already terminal", "retryable": false } + }); + ledger.complete_with_status("id-3", StatusCode::GONE, body.clone()); + match ledger.begin("id-3", "fp-a") { + IdemDecision::Replay { status, body: b } => { + assert_eq!(status, StatusCode::GONE); + assert_eq!(b["error"]["code"], serde_json::json!("run_terminated")); + } + _ => panic!("expected replay"), + } + } +} diff --git a/src/bcs/crates/adapters/bridge-provider/src/interaction.rs b/src/bcs/crates/adapters/bridge-provider/src/interaction.rs new file mode 100644 index 0000000000..ded8cc6ca0 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/interaction.rs @@ -0,0 +1,202 @@ +//! InteractionRegistry: tracks pending HITL interactions per run, mints +//! interaction_ids, and routes BCS `interaction.resolve` decisions to the +//! parked driver task via a oneshot. +//! +//! Per spec §5/§6.3: the cc driver registers a pending interaction when the +//! engine emits a `can_use_tool`/`AskUserQuestion` control request; BCS +//! resolves it over the webhook; on abort/deadline the run loop invalidates +//! all of a run's pending interactions with a safe fallback (deny) so the +//! driver never blocks on a dead receiver. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use bcs_protocol::stream::InteractionKind; +use serde_json::Value; +use tokio::sync::oneshot; + +/// Mint a fresh interaction_id: `int-<32hex>` (UUIDv4 simple form). +fn mint_interaction_id() -> String { + format!("int-{}", uuid::Uuid::new_v4().simple()) +} + +/// One pending interaction parked in the registry, awaiting a BCS resolve. +pub struct PendingInteraction { + pub run_id: String, + pub kind: InteractionKind, + /// Engine-native request id (cc's `request_id`); never surfaced to BCS. + pub engine_request_id: String, + /// Idempotency key of the resolve that delivered (None until delivered). + pub idempotency_key: Option, + /// Sender the driver awaits; `None` once delivered or invalidated. + resolver: Option>, +} + +/// Outcome of [`InteractionRegistry::resolve`]. +#[derive(Debug, PartialEq, Eq)] +pub enum ResolveOutcome { + /// Resolution delivered to the parked driver (first resolve). + Delivered, + /// Same interaction already resolved — replay ack, no re-delivery. + Duplicate, + /// `interactionId` is not registered. + Unknown, +} + +#[derive(Default)] +struct RegistryInner { + map: HashMap, +} + +/// Cloneable (`Arc>`) registry of pending HITL interactions, keyed by +/// interaction_id. Cheap to clone so it can live on [`crate::webhook::AppState`] +/// and be passed into each [`crate::engine::TurnRequest`]. +#[derive(Clone, Default)] +pub struct InteractionRegistry { + inner: Arc>, +} + +impl InteractionRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Register a pending interaction for `run_id`; returns the minted + /// `interaction_id` and the receiver the driver awaits for the BCS + /// resolution. + pub fn register( + &self, + run_id: &str, + kind: InteractionKind, + engine_request_id: String, + ) -> (String, oneshot::Receiver) { + let interaction_id = mint_interaction_id(); + let (tx, rx) = oneshot::channel::(); + let entry = PendingInteraction { + run_id: run_id.to_string(), + kind, + engine_request_id, + idempotency_key: None, + resolver: Some(tx), + }; + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + inner.map.insert(interaction_id.clone(), entry); + (interaction_id, rx) + } + + /// Deliver `resolution` to the parked driver for `interaction_id`. The + /// first delivery returns [`ResolveOutcome::Delivered`]; any subsequent + /// resolve of an already-delivered interaction returns + /// [`ResolveOutcome::Duplicate`] with no re-delivery (idempotent replay, + /// spec §5.1); an unknown id returns [`ResolveOutcome::Unknown`]. + /// + /// Mutex poison is recovered (consistent with the rest of the crate) so a + /// panicking holder never wedges the registry. + pub fn resolve( + &self, + interaction_id: &str, + idempotency_key: &str, + resolution: Value, + ) -> ResolveOutcome { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + let Some(entry) = inner.map.get_mut(interaction_id) else { + return ResolveOutcome::Unknown; + }; + let Some(resolver) = entry.resolver.take() else { + // Already delivered (by a prior resolve or invalidate_run): replay. + return ResolveOutcome::Duplicate; + }; + // Send the resolution to the parked driver. The receiver is only + // dropped if the driver task already exited (e.g. engine crash) — in + // that case the value is simply dropped and the interaction is closed. + let _ = resolver.send(resolution); + entry.idempotency_key = Some(idempotency_key.to_string()); + ResolveOutcome::Delivered + } + + /// Release every pending interaction of `run_id` with `fallback` (deny on + /// abort/deadline per spec §6.3) so the driver's `resolution_rx` never + /// blocks on a dead receiver. Entries are retained (resolver cleared) so + /// a late BCS resolve resolves to [`ResolveOutcome::Duplicate`] rather + /// than surfacing as `Unknown`. + pub fn invalidate_run(&self, run_id: &str, fallback: Value) { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + for entry in inner.map.values_mut() { + if entry.run_id == run_id { + if let Some(resolver) = entry.resolver.take() { + let _ = resolver.send(fallback.clone()); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bcs_protocol::stream::InteractionKind; + use serde_json::json; + + #[tokio::test] + async fn resolve_delivers_and_duplicate_key_replays() { + let reg = InteractionRegistry::new(); + let (iid, rx) = reg.register("run-1", InteractionKind::Exec, "engine-req-1".into()); + assert!(matches!( + reg.resolve(&iid, "key-1", json!({"decision":"allow_once"})), + ResolveOutcome::Delivered + )); + assert_eq!(rx.await.unwrap()["decision"], json!("allow_once")); + // 同 key 重复 → Duplicate(不再投递) + assert!(matches!( + reg.resolve(&iid, "key-1", json!({"decision":"allow_once"})), + ResolveOutcome::Duplicate + )); + // 未知 id → Unknown + assert!(matches!( + reg.resolve("int-nope", "key-2", json!({"decision":"deny"})), + ResolveOutcome::Unknown + )); + } + + #[tokio::test] + async fn invalidate_run_releases_parked_with_fallback_and_late_resolve_is_duplicate() { + let reg = InteractionRegistry::new(); + let (iid, rx) = reg.register("run-x", InteractionKind::Exec, "e-1".into()); + reg.invalidate_run("run-x", json!({"decision": "deny"})); + // Driver receives the fallback, not a recv error. + assert_eq!(rx.await.unwrap()["decision"], json!("deny")); + // Late BCS resolve → Duplicate (not Unknown) — the run was abandoned, + // not the interaction id. + assert!(matches!( + reg.resolve(&iid, "k", json!({"decision":"allow_once"})), + ResolveOutcome::Duplicate + )); + } + + #[test] + fn invalidate_run_is_scoped_to_run_id() { + let reg = InteractionRegistry::new(); + let (_i_a, _r_a) = reg.register("run-a", InteractionKind::Exec, "e-a".into()); + let (_i_b, mut r_b) = reg.register("run-b", InteractionKind::Exec, "e-b".into()); + reg.invalidate_run("run-a", json!({"decision":"deny"})); + // run-b is untouched: its resolver is still held, so a first resolve + // delivers normally. + assert!(matches!( + reg.resolve(&_i_b, "k", json!({"decision":"allow_once"})), + ResolveOutcome::Delivered + )); + assert_eq!(r_b.try_recv().unwrap()["decision"], json!("allow_once")); + } + + #[test] + fn minted_ids_are_prefixed_int_and_unique() { + let a = mint_interaction_id(); + let b = mint_interaction_id(); + assert!(a.starts_with("int-"), "a = {a}"); + assert!(b.starts_with("int-"), "b = {b}"); + assert_ne!(a, b, "ids must be unique"); + // simple() form is 32 lowercase hex chars, no hyphens. + assert_eq!(a.len(), "int-".len() + 32); + assert!(!a["int-".len()..].contains('-')); + } +} diff --git a/src/bcs/crates/adapters/bridge-provider/src/lib.rs b/src/bcs/crates/adapters/bridge-provider/src/lib.rs new file mode 100644 index 0000000000..a3cfd29b68 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/lib.rs @@ -0,0 +1,11 @@ +pub mod config; +pub mod engine; +pub mod error; +pub mod idempotency; +pub mod interaction; +pub mod run; +pub mod session; +pub mod sse; +pub mod webhook; + +pub use webhook::AppState; diff --git a/src/bcs/crates/adapters/bridge-provider/src/main.rs b/src/bcs/crates/adapters/bridge-provider/src/main.rs new file mode 100644 index 0000000000..6f63695b41 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/main.rs @@ -0,0 +1,109 @@ +//! `bridge-provider` binary entrypoint. +//! +//! Loads config from `BRIDGE_CONFIG` (default `bridge.toml`), initializes tracing +//! with an [`EnvFilter`] from `RUST_LOG` (falls back to the `info` level when the +//! variable is unset), and serves the webhook router on `config.listen` via axum. +//! +//! # Graceful shutdown +//! +//! On SIGINT (ctrl_c) or — on Unix — SIGTERM, axum stops accepting new +//! connections, then [`RunRegistry::abort_all`] cancels every in-flight run +//! (`aborted` terminal state); each run loop finalizes, emits the terminal SSE +//! frame, reaps its engine subprocess, and the in-flight HTTP connections drain. +//! The process then exits 0. No `unwrap`/`expect`/`panic` lives in this file. +//! +//! # HTTP/2 (h2c) — manual verification, NOT in CI +//! +//! Production BCN Provider 2.0 speaks HTTP/2 cleartext (h2c). `axum::serve` +//! drives hyper-util's auto connection builder, which detects the h2 connection +//! preface and upgrades to h2c — so a `--http2-prior-knowledge` client is served +//! as HTTP/2 without TLS. Verify locally against a running binary: +//! +//! ```text +//! # write a throwaway config bound to 127.0.0.1:21999 +//! cat > /tmp/bridge-h2c.toml <<'EOF' +//! provider_id = "bridge-1" +//! listen = "127.0.0.1:21999" +//! bcs_to_provider_token = "tok-b2p" +//! [[bot]] +//! provider_bot_ref = "worker-1" +//! engine = "cfuse-cc" +//! cwd = "/tmp" +//! EOF +//! BRIDGE_CONFIG=/tmp/bridge-h2c.toml cargo run -p bridge-provider & +//! # send a bot.ping over h2c prior-knowledge (no TLS): +//! curl --http2-prior-knowledge -sS -v \ +//! -H 'Authorization: Bearer tok-b2p' \ +//! -H 'Content-Type: application/json' \ +//! --data '{"type":"req","id":"p1","method":"bot.ping","to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}}' \ +//! http://127.0.0.1:21999/webhook +//! # expect: * Using HTTP2 prior knowledge +//! # < HTTP/2 200 +//! # {"ok":true} +//! # SIGTERM the binary; it should exit 0. +//! kill -TERM %1 +//! ``` + +use std::{path::PathBuf, sync::Arc}; + +use bridge_provider::{config::ProviderConfig, webhook, AppState}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let config_path: PathBuf = std::env::var("BRIDGE_CONFIG") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("bridge.toml")); + let config = ProviderConfig::load(&config_path)?; + let listen = config.listen; + let state = Arc::new(AppState::new(config)); + let app = webhook::router(state.clone()); + + let listener = tokio::net::TcpListener::bind(listen).await?; + tracing::info!(%listen, "bridge-provider listening"); + axum::serve(listener, app) + .with_graceful_shutdown(async move { + shutdown_signal().await; + // axum stops taking new connections; cancel every in-flight run so + // the run loops finalize (aborted terminal) and their engine + // subprocesses are reaped before the process exits. + state.runs.abort_all("shutdown").await; + }) + .await?; + Ok(()) +} + +/// Wait for SIGINT (ctrl_c) or — on Unix — SIGTERM, whichever arrives first. +/// +/// Split by `#[cfg(unix)]` so non-Unix builds still compile against `ctrl_c`. +/// Installing the SIGTERM handler never panics; on the (effectively unreachable +/// for Linux) failure path it falls back to ctrl_c-only before returning. +#[cfg(unix)] +async fn shutdown_signal() { + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = match signal(SignalKind::terminate()) { + Ok(s) => s, + Err(e) => { + tracing::warn!(error = %e, "failed to install SIGTERM handler; falling back to ctrl_c"); + let _ = tokio::signal::ctrl_c().await; + tracing::info!(signal = "SIGINT", "graceful shutdown initiated"); + return; + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => tracing::info!(signal = "SIGINT", "graceful shutdown initiated"), + _ = sigterm.recv() => tracing::info!(signal = "SIGTERM", "graceful shutdown initiated"), + } +} + +#[cfg(not(unix))] +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; + tracing::info!(signal = "SIGINT", "graceful shutdown initiated"); +} diff --git a/src/bcs/crates/adapters/bridge-provider/src/run.rs b/src/bcs/crates/adapters/bridge-provider/src/run.rs new file mode 100644 index 0000000000..3b9ea751e3 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/run.rs @@ -0,0 +1,906 @@ +//! RunRegistry + run loop: drives one downstream turn end-to-end and exposes a +//! self-managed SSE frame stream to the webhook handler. +//! +//! Per spec §6.2/§6.4: the run loop selects over an engine-event channel, a 20s +//! heartbeat, and a deadline timer; each engine event is stamped with a monotonic +//! `seq`, encoded to a Provider 2.0 frame via [`crate::sse::event_to_frame`], then +//! appended to the run buffer and broadcast. A BCS disconnect is detected when a +//! broadcast send returns `Err` (no live subscribers); the engine is then aborted +//! and the run closed (spec amendment: kill on write failure, no grace window). +//! +//! Frames are self-managed `String`s (already-formatted SSE frames) — the single +//! testable path; the handler wraps them with [`axum::body::Body::from_stream`]. +//! Heartbeats push the raw [`crate::sse::HEARTBEAT`] comment frame and carry no +//! `seq` (excluded from the monotonic sequence). +//! +//! Re-attach semantics: the same id with the same body, while active, replays the +//! buffered frames then follows the broadcast; the same id already terminal +//! replays the buffered terminal frames as a fresh one-shot stream; the same id +//! with a different body is a 409 conflict (see [`RunRegistry::begin`]). + +use std::collections::HashMap; +use std::convert::Infallible; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use axum::body::{Body, Bytes}; +use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE}; +use axum::http::HeaderValue; +use axum::response::Response; +use bcs_protocol::now_ms; +use bcs_protocol::stream::{ChatState, StreamEvent}; +use futures::stream::{StreamExt, Stream}; +use serde_json::json; +use tokio::sync::{broadcast, mpsc}; +use tokio_util::sync::CancellationToken; + +use crate::engine::{build_engine, TurnError, TurnOutcome, TurnRequest}; +use crate::engine::trace::TraceContext; +use crate::sse::{self, event_to_frame, FrameError, HEARTBEAT}; +use crate::webhook::{AppState, DownstreamRequest}; +use crate::config::BotConfig; + +/// Grace TTL for a terminal run's buffered frames before lazy sweep removes the +/// entry (lets a late re-send replay the terminal state). +const TERMINAL_GRACE: Duration = Duration::from_secs(300); + +/// Forward-loop poll interval: after the driver marks a run terminal, the +/// forwarder drains remaining broadcast messages and exits within this window. +/// Kept tiny so end-of-run latency is negligible; robust against lost wake-ups +/// because the drain is by `try_recv`, not by a one-shot Notify. +const TERMINAL_POLL: Duration = Duration::from_millis(25); + +/// Result of attempting to push one frame into the run's buffer+broadcast. +enum PushOutcome { + /// Frame accepted; loop continues. + Ok, + /// Broadcast had no subscribers: BCS disconnected → abort + close. + Disconnect, + /// Run must terminate now (oversize frame caught → terminal error emitted, + /// or encoder rejected the event). + Terminate, +} + +/// One active or terminal run's shared state: abort token, broadcast sender, +/// replay buffer, terminal flag, and the idempotency fingerprint. +/// +/// `buffer` is a `std::sync::Mutex` (not `tokio::RwLock`) so that a push and a +/// forwarder's `(subscribe, snapshot)` can be made mutually atomic without +/// holding the lock across an `.await` — neither path awaits while holding it. +/// This is what keeps the replay-buffer-then-follow-broadcast forward path free +/// of duplicate or lost frames. +/// +/// `abort_requested` distinguishes an explicit abort (chat.abort or graceful +/// shutdown) from a passive BCS disconnect. The run loop emits a terminal +/// `chat_aborted` frame only when it is set, so a disconnect closes the stream +/// silently while chat.abort surfaces a final `state=aborted` frame to the BCS +/// SSE consumer (spec §5.3). +/// +/// All fields are `Arc`/clone-cheap so a [`RunHandle`] is cheaply cloneable for +/// the driver task and each re-attach forwarder. +#[derive(Clone)] +pub struct RunHandle { + pub abort: CancellationToken, + pub tx: broadcast::Sender, + pub buffer: Arc>>, + pub terminal: Arc, + abort_requested: Arc, + /// `stopReason` to surface in the terminal `chat_aborted` SSE frame. Set by + /// the abort requester via [`Self::request_abort`]; stays at the default + /// `"user_cancelled"` until then. `std::sync::Mutex` (short critical section, + /// never held across an `.await`) so a poison is recoverable. + abort_reason: Arc>, + fp: Arc, +} + +impl RunHandle { + /// Idempotency fingerprint match (same id + same body). + pub fn matches(&self, fp: &str) -> bool { + self.fp.as_ref() == fp + } + + /// Whether the run has reached a terminal state. + pub fn is_terminal(&self) -> bool { + self.terminal.load(Ordering::SeqCst) + } + + /// True iff an explicit abort has been requested via [`Self::request_abort`] + /// (chat.abort or graceful shutdown). The run loop emits a terminal + /// `chat_aborted` frame only when this is set; a passive BCS disconnect + /// (broadcast send returns no subscribers) does not set it, so its run + /// closes silently — the stream just ends. + pub fn is_abort_requested(&self) -> bool { + self.abort_requested.load(Ordering::SeqCst) + } + + /// The `stopReason` to surface in the terminal `chat_aborted` SSE frame — + /// whatever the most recent [`Self::request_abort`] caller set, defaulting + /// to `"user_cancelled"` until then. Mutex poison is recovered (consistent + /// with the rest of this crate) so a panicking holder never wedges the run. + pub fn abort_stop_reason(&self) -> String { + self.abort_reason.lock().unwrap_or_else(|p| p.into_inner()).clone() + } + + /// Mark this run explicitly aborted: set the requested flag (so the run + /// loop emits a `chat_aborted` terminal frame when the engine returns + /// `TurnError::Aborted`), record `reason` as the SSE frame's `stopReason`, + /// and cancel the token (the driver's `select!` arm fires, killing the + /// engine). Idempotent — safe to call repeatedly (a chat.abort racing a + /// graceful shutdown, or a duplicate abort, all collapse to one abort). + pub fn request_abort(&self, reason: &str) { + self.abort_requested.store(true, Ordering::SeqCst); + *self.abort_reason.lock().unwrap_or_else(|p| p.into_inner()) = reason.to_string(); + self.abort.cancel(); + } +} + +struct RunEntry { + handle: RunHandle, + finished_at: Option, +} + +/// Inner state guarded by the registry's single mutex: the forward run map +/// plus the `run_session` reverse index (run_id → (provider_bot_ref, +/// bcs_session_id)). Both are mutated under one lock so the lazy grace-TTL +/// sweep reclaims them atomically — `chat.abort`'s `find_terminal_run` never +/// observes a run_session entry whose run was already swept (and vice versa). +#[derive(Default)] +struct RunRegistryInner { + map: HashMap, + run_session: HashMap, +} + +/// Registry of in-flight and recently-terminal runs, keyed by downstream body id. +/// +/// `begin` is the create-or-get entry point: it atomically inserts a new run or +/// returns the existing handle for the same id (`is_new == false`). `get` reads +/// an existing handle without inserting. `finish` marks a run terminal and stamps +/// `finished_at` so the lazy sweep can reclaim it after [`TERMINAL_GRACE`]. +/// +/// `chat.abort` (Task 14) drives off two lookup paths: +/// - [`Self::get`] returns the active run's handle (so the abort handler can +/// cancel its token + invalidate its interactions). +/// - [`Self::find_terminal_run`] reverse-looks-up via the `run_session` index: +/// given `(provider_bot_ref, session_id)` it answers "is there a terminal run +/// recorded for this pair?" — the second leg of the abort response matrix +/// (terminal run → 410 `run_terminated`; no record → 200 `{"aborted": false}`). +#[derive(Default)] +pub struct RunRegistry { + inner: Mutex, +} + +impl RunRegistry { + pub fn new() -> Self { + Self::default() + } + + fn sweep_locked(inner: &mut RunRegistryInner) { + let now = Instant::now(); + // Retain terminal entries within grace; always retain active. + inner.map.retain(|_, e| { + match e.finished_at { + Some(t) => now.saturating_duration_since(t) < TERMINAL_GRACE, + None => true, + } + }); + // Drop run_session entries whose runs were swept (the run no longer + // lives in the map — the (bot, session) pair is no longer resolvable + // by run id, so `find_terminal_run` must stop reporting it). + inner.run_session.retain(|run_id, _| inner.map.contains_key(run_id)); + } + + /// Returns the existing handle for `run_id` (active or terminal), if any. + /// Performs a lazy grace-TTL sweep of terminal entries. + pub fn get(&self, run_id: &str) -> Option { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + Self::sweep_locked(&mut inner); + inner.map.get(run_id).map(|e| e.handle.clone()) + } + + /// Create a new run, or — if `run_id` already exists — return the existing + /// handle. The second return is `true` iff a fresh run was created; `false` + /// marks "same id already present" (re-attach / terminal-replay / conflict + /// decision belongs to the caller, which compares [`RunHandle::matches`]). + pub fn begin(&self, run_id: &str, fingerprint: String) -> (RunHandle, bool) { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + Self::sweep_locked(&mut inner); + if let Some(entry) = inner.map.get(run_id) { + return (entry.handle.clone(), false); + } + let (tx, _rx) = broadcast::channel::(256); + let handle = RunHandle { + abort: CancellationToken::new(), + tx, + buffer: Arc::new(Mutex::new(Vec::new())), + terminal: Arc::new(AtomicBool::new(false)), + abort_requested: Arc::new(AtomicBool::new(false)), + abort_reason: Arc::new(Mutex::new("user_cancelled".to_string())), + fp: Arc::new(fingerprint), + }; + inner.map.insert( + run_id.to_string(), + RunEntry { handle: handle.clone(), finished_at: None }, + ); + (handle, true) + } + + /// Mark `run_id` terminal. Buffered frames are retained for [`TERMINAL_GRACE`] + /// so a late re-send can replay the terminal state; lazy sweep reclaims them. + pub fn finish(&self, run_id: &str) { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(entry) = inner.map.get_mut(run_id) { + entry.handle.terminal.store(true, Ordering::SeqCst); + entry.finished_at = Some(Instant::now()); + } + } + + /// Delete a run entry (and its `run_session` association), bypassing the + /// grace-TTL retention [`Self::finish`] relies on. Rollback-only: the + /// chat.send handler creates a placeholder entry via [`Self::begin`] + /// before claiming the session slot (`try_start_run`), so it can roll + /// back the placeholder on 429 (the session slot is held by a different + /// run_id) instead of leaving a dangling never-spawned entry pinned in + /// the registry for [`TERMINAL_GRACE`]. Removes from the main `map` then + /// `run_session.retain` (one upsert), so a stale session association is + /// never left pointing at a gone run_id. + pub fn remove(&self, run_id: &str) { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + inner.map.remove(run_id); + inner.run_session.retain(|rid, _| rid != run_id); + } + + /// Record the `(provider_bot_ref, session_id)` association for `run_id`, + /// enabling `chat.abort`'s [`Self::find_terminal_run`] reverse lookup. + /// Called by `handle_chat_send` for a freshly-created run (right after + /// [`Self::begin`] returns `is_new == true`). Idempotent on the same + /// run_id — overwrites any stale association; stale entries are pruned + /// by the grace-TTL sweep ([`Self::sweep_locked`]) once the run itself is + /// swept. + pub fn record_session(&self, run_id: &str, bot: &str, session: &str) { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + inner + .run_session + .insert(run_id.to_string(), (bot.to_string(), session.to_string())); + } + + /// Find a terminal run for `(bot, session)`, returning its run_id. Used by + /// `chat.abort` to distinguish "no active run, but a terminal run was + /// recorded for this session" (return 410 `run_terminated`) from "no record + /// at all" (return 200 `{"aborted": false}`). Iterates the run_session + /// reverse index and checks each candidate's `terminal` flag in the run + /// map. There is at most one terminal run per session in practice: a fresh + /// run cannot start while another is active (the session slot's 429 guard + /// excludes it), so successive terminal runs for one session never overlap. + pub fn find_terminal_run(&self, bot: &str, session: &str) -> Option { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + Self::sweep_locked(&mut inner); + inner + .run_session + .iter() + .find(|(run_id, (b, s))| { + b == bot && s == session + && inner.map.get(*run_id).map_or(false, |e| e.handle.is_terminal()) + }) + .map(|(run_id, _)| run_id.clone()) + } + + /// Abort every still-active run by calling [`RunHandle::request_abort`] with + /// `reason` on each. Used by the provider's graceful shutdown path (Task 15): + /// iterating in-flight runs and cancelling them lets each run loop finalize + /// (engine killed, interactions invalidated, session slot released) instead + /// of leaving orphaned drivers when the process exits. Terminal runs are + /// skipped — they are already closing. The mutex is released before calling + /// `request_abort` so the per-run cancellation (which writes the engine's + /// abort token, not this registry) proceeds without holding the registry + /// lock; cancellation itself is non-blocking. + pub async fn abort_all(&self, reason: &str) { + let handles: Vec = { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + Self::sweep_locked(&mut inner); + inner + .map + .values() + .filter(|e| !e.handle.is_terminal()) + .map(|e| e.handle.clone()) + .collect() + }; + for handle in handles { + handle.request_abort(reason); + } + } +} + +/// Idempotency fingerprint for a chat.send body: `message` + `session_id` + +/// `to_bot.provider_bot_ref`, joined by the ledger's unit separator. `message` +/// is serialized via `serde_json` so structurally-equal JSON compares equal +/// regardless of key ordering; `to_string` failing degrades to an empty string +/// (the body is deserialized upstream, so failure is not expected in practice). +pub fn body_fingerprint(req: &DownstreamRequest, session_id: &str) -> String { + let msg = serde_json::to_string(&req.message) + .unwrap_or_else(|_| String::new()); + crate::idempotency::fingerprint(&[&msg, session_id, &req.to_bot.provider_bot_ref]) +} + +/// Prompt assembly for a turn: pending injects (drained FIFO) prepend each as +/// `[from:{name}] {text}` (or bare `{text}` when `from_name` is `None`), then a +/// blank separator, then the user message text — `message.content[].text` +/// joined with `\n`. Two injects + body thus render as: +/// +/// ```text +/// [from:张三] 注入的消息一 +/// 注入的消息二 +/// +/// <本次 message 文本> +/// ``` +/// +/// The blank line marks where the inject block ends and the current request +/// begins — visible separation that downstream prompts read as context vs ask. +/// The pending-inject prepend is the codex fallback (no transcript sink): an +/// inject that could not be sunk to the engine transcript lives in +/// `pending_injects` and is drained here on the next chat.send. UTF-8 safe — +/// no byte slicing. +async fn assemble_prompt( + state: &AppState, + bot: &BotConfig, + session_id: &str, + req: &DownstreamRequest, +) -> String { + let injects = state + .sessions + .take_pending_injects(&bot.provider_bot_ref, session_id) + .await; + let mut prefix = String::new(); + for inj in &injects { + if !prefix.is_empty() { + prefix.push('\n'); + } + match &inj.from_name { + Some(name) => prefix.push_str(&format!("[from:{name}] {}", inj.text)), + None => prefix.push_str(&inj.text), + } + } + let body = extract_message_text(req.message.as_ref()); + if prefix.is_empty() { + body + } else { + format!("{prefix}\n\n{body}") + } +} + +/// Extract `message.content[].text` and join multiple parts with `\n`. Missing +/// fields yield an empty string (validated upstream). Reused by the chat.inject +/// handler to flatten the inject body into the [`crate::session::InjectedMessage`] +/// text field, so the pending-prepend and transcript-sink paths see one string. +pub(crate) fn extract_message_text(message: Option<&serde_json::Value>) -> String { + let Some(msg) = message else { return String::new() }; + let Some(content) = msg.get("content").and_then(|c| c.as_array()) else { + return String::new(); + }; + let texts: Vec<&str> = content + .iter() + .filter_map(|item| item.get("text").and_then(|t| t.as_str())) + .collect(); + texts.join("\n") +} + +/// Drive the engine turn and push frames into the run's buffer+broadcast. Runs +/// until a terminal condition (final / engine-EOF / deadline / disconnect), +/// then marks the run terminal, releases the session slot, and notifies the +/// registry. The forward stream to the client is consumed separately via +/// [`forward_stream`]. +async fn run_driver( + state: Arc, + handle: RunHandle, + req: DownstreamRequest, + bot: BotConfig, + session_id: String, +) { + let run_id = req.id.clone(); + let timeout_ms = req.timeout_ms.unwrap_or(3_600_000); + + // Resume an established engine-internal session if one was recorded. + let engine_session_id = state + .sessions + .mapping(&bot.provider_bot_ref, &session_id) + .await + .engine_session_id; + + let prompt = assemble_prompt(&state, &bot, &session_id, &req).await; + + let turn_req = TurnRequest { + run_id: run_id.clone(), + prompt, + engine_session_id, + cwd: bot.cwd.clone(), + model: bot.model.clone(), + cfuse_bin: bot.cfuse_bin.clone().unwrap_or_else(|| PathBuf::from("cfuse")), + permission_mode: bot.permission_mode.clone(), + interactions: state.interactions.clone(), + trace: state.trace.as_ref().map(|store| { + TraceContext::new( + store.clone(), + match bot.engine { + crate::config::EngineKind::CfuseCc => "cfuse-cc", + crate::config::EngineKind::CfuseCodex => "cfuse-codex", + }, + run_id.clone(), + ) + }), + }; + + let (ev_tx, mut ev_rx) = mpsc::channel::(64); + let trace = turn_req.trace.clone(); + let abort_token = handle.abort.clone(); + let engine = build_engine(&bot); + let mut engine_handle: Option>> = + Some(tokio::spawn(async move { + engine.run_turn(turn_req, ev_tx, abort_token).await + })); + + let mut seq: u64 = 0; + let mut heartbeat = tokio::time::interval(Duration::from_secs(20)); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Self-terminate ~30s ahead of the hard deadline so a terminal chat_error can + // still flush before the client times out. + let deadline_ms = timeout_ms.saturating_sub(30_000); + let deadline = tokio::time::sleep(Duration::from_millis(deadline_ms)); + tokio::pin!(deadline); + + loop { + tokio::select! { + _ = &mut deadline => { + // Deadline: emit terminal chat_error(deadline) and close. + let _ = push_frame(&handle, &mut seq, &run_id, + &sse::chat_error(&run_id, "run deadline exceeded", Some("deadline")), + trace.as_ref()); + break; + } + _ = heartbeat.tick() => { + // Heartbeat is a raw comment frame; no seq, no encode. + if !push_raw(&handle, HEARTBEAT) { + break; + } + } + ev = ev_rx.recv() => { + match ev { + Some(StreamEvent::Chat(c)) if c.state == ChatState::Final => { + let _ = push_frame( + &handle, + &mut seq, + &run_id, + &StreamEvent::Chat(c), + trace.as_ref(), + ); + break; + } + Some(event) => { + match push_frame(&handle, &mut seq, &run_id, &event, trace.as_ref()) { + PushOutcome::Ok => {} + PushOutcome::Disconnect | PushOutcome::Terminate => break, + } + } + None => { + // Engine task ended without emitting a Chat(Final) event + // (the cc/codex drivers return the final text via + // TurnOutcome). Resolve the outcome and emit the terminal + // frame here. `Aborted` is silent (handled by the + // post-loop cancel). + let outcome: Result = match engine_handle.take() { + Some(h) => match h.await { + Ok(r) => r, + Err(join_err) => Err(TurnError::EngineExited(format!("task join: {join_err}"))), + }, + None => Err(TurnError::EngineExited("engine task missing".into())), + }; + match outcome { + Ok(o) => { + if let Some(sid) = o.engine_session_id { + state.sessions + .set_engine_session_id(&bot.provider_bot_ref, &session_id, &sid) + .await; + } + match o.final_text { + Some(text) => { + let _ = push_frame(&handle, &mut seq, &run_id, + &sse::chat_final(&run_id, text), + trace.as_ref()); + } + None => { + let _ = push_frame(&handle, &mut seq, &run_id, + &sse::chat_error(&run_id, + "engine exited without final text", + Some("runtime_error")), + trace.as_ref()); + } + } + } + Err(TurnError::Aborted) => { + // Explicit abort (chat.abort or graceful + // shutdown) → emit a terminal `chat_aborted` + // frame so the BCS SSE consumer sees the final + // `state=aborted` (spec §5.3). A passive BCS + // disconnect (push_raw's broadcast send returns + // no subscribers) does NOT set the abort flag — + // its run closes silently, the stream just ends. + if handle.is_abort_requested() { + let _ = push_frame( + &handle, + &mut seq, + &run_id, + &sse::chat_aborted(&run_id, &handle.abort_stop_reason()), + trace.as_ref(), + ); + } + } + Err(e) => { + let _ = push_frame(&handle, &mut seq, &run_id, + &sse::chat_error(&run_id, &e.to_string(), + Some("runtime_error")), + trace.as_ref()); + } + } + break; + } + } + } + } + } + + // Finalize: release any parked HITL interactions with a deny fallback + // (spec §6.3: deadline → safe fallback; abort → deny) so the driver's + // resolution_rx never blocks on a dead receiver. Done BEFORE cancelling + // the engine so the fallback is delivered through the resolution channel + // rather than lost to a dropped receiver; entries are retained (marked + // resolved) so a late BCS resolve replays as Duplicate instead of Unknown. + state + .interactions + .invalidate_run(&run_id, json!({ "decision": "deny" })); + + // Cancel the engine (idempotent), await its task if we did not already, + // then mark terminal, release the session slot, notify registry. + handle.abort.cancel(); + if let Some(h) = engine_handle.take() { + let _ = h.await; + } + handle.terminal.store(true, Ordering::SeqCst); + state.runs.finish(&run_id); + state + .sessions + .finish_run(&bot.provider_bot_ref, &session_id, &run_id) + .await; +} + +/// Push one engine event as a frame into buffer+broadcast. On oversize +/// ([`FrameError::FrameTooLarge`]) emit a terminal `chat_error` instead and +/// signal termination — never emit an oversize frame. +/// +/// Synchronous (no `.await`) — the buffer mutex is never held across an await. +fn push_frame( + handle: &RunHandle, + seq: &mut u64, + run_id: &str, + ev: &StreamEvent, + trace: Option<&TraceContext>, +) -> PushOutcome { + *seq += 1; + let ts = now_ms(); + let frame = match event_to_frame(ev, *seq, ts, run_id) { + Ok(f) => f, + Err(FrameError::FrameTooLarge(_)) => { + // Emit a bounded terminal error instead of the oversize frame. + *seq += 1; + let err_ev = sse::chat_error(run_id, "frame too large", Some("runtime_error")); + let err_frame = match event_to_frame(&err_ev, *seq, now_ms(), run_id) { + Ok(f) => f, + Err(_) => return PushOutcome::Terminate, + }; + let _ = push_raw(handle, &err_frame); + return PushOutcome::Terminate; + } + Err(_) => return PushOutcome::Terminate, + }; + if let Some(trace) = trace { + trace.record_converted(ev, *seq); + trace.record_sse(*seq, &frame); + } + if push_raw(handle, &frame) { + PushOutcome::Ok + } else { + PushOutcome::Disconnect + } +} + +/// Append a pre-formatted frame string to the buffer and broadcast it. The +/// buffer write + broadcast send happen under the buffer mutex so that a +/// concurrent forwarder's `(subscribe, snapshot)` observes the two as one atomic +/// operation — neither duplicated nor lost. Returns `false` if the broadcast had +/// no live subscribers (BCS disconnect). +fn push_raw(handle: &RunHandle, frame: &str) -> bool { + let send_ok = { + let mut buf = handle.buffer.lock().unwrap_or_else(|p| p.into_inner()); + buf.push(frame.to_string()); + handle.tx.send(frame.to_string()) + }; + send_ok.is_ok() +} + +/// Build the client-facing SSE response: spawn a forwarder that replays the +/// buffered frames then follows the broadcast, and wrap its mpsc receiver as a +/// `Body::from_stream`. This is the re-attach path too — the same handle is +/// reused, so a second subscriber replays the buffer and joins the live stream. +/// +/// `subscribe()` + buffer snapshot are taken atomically (under the buffer +/// mutex) so the snapshot's contents exactly partition from the broadcast's +/// post-subscribe messages — no duplicate frames, no lost frames. The forwarder +/// then drains the broadcast until the run is terminal and the receiver is +/// empty; a short poll wakes it after the driver marks terminal so it exits +/// promptly (broadcast `Closed` never fires because the registry retains the +/// `Sender` for terminal replay). +pub fn forward_stream(handle: RunHandle) -> impl Stream + Send + 'static { + let (tx, rx) = mpsc::channel::(64); + // Atomic (w.r.t. pushes): snapshot the buffer and subscribe so the partition + // is exact — snapshot holds frames pushed up to here; broadcast carries only + // frames pushed after subscribe. + let (snapshot, subscriber) = { + let buf = handle.buffer.lock().unwrap_or_else(|p| p.into_inner()); + let snap = buf.clone(); + let sub = handle.tx.subscribe(); + (snap, sub) + }; + tokio::spawn(async move { + // Replay the buffer snapshot first. + for frame in snapshot { + if tx.send(frame).await.is_err() { + return; + } + } + // Then follow the live broadcast until terminal + drained. + let mut sub = subscriber; + loop { + tokio::select! { + ev = sub.recv() => { + match ev { + Ok(frame) => { + if tx.send(frame).await.is_err() { + return; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!( + target: "bridge_provider", + n, "SSE broadcast lagged; BCS tolerates seq gaps" + ); + continue; + } + Err(broadcast::error::RecvError::Closed) => return, + } + } + _ = tokio::time::sleep(TERMINAL_POLL) => { + // Driver marked terminal: drain any remaining buffered + // broadcast messages, then stop. + if handle.is_terminal() { + loop { + match sub.try_recv() { + Ok(frame) => { + if tx.send(frame).await.is_err() { + return; + } + } + Err(broadcast::error::TryRecvError::Empty) + | Err(broadcast::error::TryRecvError::Closed) => return, + Err(broadcast::error::TryRecvError::Lagged(_)) => continue, + } + } + } + } + } + } + }); + tokio_stream::wrappers::ReceiverStream::new(rx) +} + +/// Wrap a frame stream as an SSE `text/event-stream` response. +pub fn sse_response(stream: impl Stream + Send + 'static) -> Response { + let body = Body::from_stream( + stream.map(|s| Ok::<_, Infallible>(Bytes::from(s))), + ); + let mut resp = Response::new(body); + resp.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/event-stream; charset=utf-8"), + ); + resp.headers_mut().insert(CACHE_CONTROL, HeaderValue::from_static("no-cache")); + resp +} + +/// Spawn the run-driver task for a freshly-created run and return the SSE +/// response streaming its frames. The caller must have already reserved the +/// session slot and confirmed `is_new == true` via [`RunRegistry::begin`]. +pub fn spawn_run( + state: Arc, + req: DownstreamRequest, + bot: BotConfig, + session_id: String, + handle: RunHandle, +) -> Response { + // Build the forward stream BEFORE spawning the driver. `forward_stream` + // subscribes to the broadcast synchronously inside the call; the driver's + // heartbeat interval first-ticks immediately, and a broadcast send with no + // live receiver reads as a BCS disconnect (`push_raw` returns false → the + // run breaks as a false-positive disconnect). Subscribing first guarantees a + // receiver exists before the driver can send its first heartbeat. + let stream = forward_stream(handle.clone()); + let driver_handle = handle; + tokio::spawn(async move { + run_driver(state, driver_handle, req, bot, session_id).await; + }); + sse_response(stream) +} + +/// Re-attach a terminal run's buffered frames as a fresh one-shot SSE stream +/// (no driver, no broadcast subscription — the run is already closed). +pub fn terminal_replay_response(handle: RunHandle) -> Response { + let (tx, rx) = mpsc::channel::(64); + let h = handle; + tokio::spawn(async move { + let snapshot = h + .buffer + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone(); + for frame in snapshot { + if tx.send(frame).await.is_err() { + return; + } + } + }); + sse_response(tokio_stream::wrappers::ReceiverStream::new(rx)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn begin_new_then_existing_returns_same_handle() { + let reg = RunRegistry::new(); + let (h1, is_new) = reg.begin("r-1", "fp-a".into()); + assert!(is_new); + let (h2, is_new2) = reg.begin("r-1", "fp-a".into()); + assert!(!is_new2); + assert!(h2.matches("fp-a"), "existing handle carries the same fingerprint"); + assert!(h1.matches("fp-a")); + assert!(!h1.matches("fp-b")); + assert!(reg.get("r-1").is_some()); + assert!(reg.get("r-2").is_none()); + } + + #[test] + fn finish_marks_terminal_and_retains_for_replay() { + let reg = RunRegistry::new(); + let (h, _) = reg.begin("r-9", "fp".into()); + assert!(!h.is_terminal()); + reg.finish("r-9"); + assert!(h.is_terminal()); + assert!(reg.get("r-9").is_some(), "terminal run retained within grace TTL"); + } + + #[test] + fn body_fingerprint_stable_for_same_body_distinct_for_different() { + let mk = |msg: serde_json::Value, sid: &str, ref_: &str| { + let req = DownstreamRequest { + id: "x".into(), + method: "chat.send".into(), + to_bot: crate::webhook::ToBot { provider_id: "p".into(), provider_bot_ref: ref_.into() }, + session_id: Some(sid.into()), + message: Some(msg), + from: None, + timeout_ms: None, + params: None, + }; + body_fingerprint(&req, sid) + }; + let m = serde_json::json!({"role":"user","content":[{"type":"text","text":"hi"}]}); + let a = mk(m.clone(), "s-1", "b-1"); + let b = mk(m.clone(), "s-1", "b-1"); + assert_eq!(a, b, "same body → same fingerprint"); + let c = mk(m.clone(), "s-1", "b-2"); + assert_ne!(a, c, "different ref → different fingerprint"); + let d = mk(serde_json::json!({"role":"user","content":[{"type":"text","text":"yo"}]}), "s-1", "b-1"); + assert_ne!(a, d, "different message → different fingerprint"); + } + + #[test] + fn extract_message_text_joins_multiple_content_blocks() { + let m = serde_json::json!({"content":[ + {"type":"text","text":"line1"}, + {"type":"image","text":"ignored"}, // non-text type but text present: still joined + {"type":"text","text":"line2"}, + {"type":"text"}, // no text: skipped + ]}); + assert_eq!(extract_message_text(Some(&m)), "line1\nignored\nline2"); + assert_eq!(extract_message_text(None), ""); + } + + #[test] + fn request_abort_sets_flag_overrides_reason_and_cancels_token() { + // A fresh run handle's flag is false and stop_reason defaults to + // "user_cancelled"; calling request_abort flips the flag, overrides the + // stop_reason, and cancels the CancellationToken. + let reg = RunRegistry::new(); + let (h, _) = reg.begin("r-a", "fp".into()); + assert!(!h.is_abort_requested(), "fresh run is not aborted"); + assert_eq!(h.abort_stop_reason(), "user_cancelled"); + + let r2 = h.clone(); + h.request_abort("provider_shutdown"); + assert!(h.is_abort_requested(), "flag set after request_abort"); + assert_eq!(h.abort_stop_reason(), "provider_shutdown", "reason overridden"); + assert!(r2.is_abort_requested(), "shared flag visible to cloned handle"); + assert_eq!(r2.abort_stop_reason(), "provider_shutdown"); + // Cancellation propagates to all clones (CancellationToken is shared). + assert!(h.abort.is_cancelled(), "token cancelled after request_abort"); + } + + #[test] + fn find_terminal_run_returns_terminal_match_none_for_active_or_unknown() { + // Active run (not terminal): find_terminal_run returns None — abort + // goes through the sessions.active_run path instead. + let reg = RunRegistry::new(); + let (_h_active, _) = reg.begin("r-active", "fp".into()); + reg.record_session("r-active", "bot-1", "s-1"); + assert_eq!(reg.find_terminal_run("bot-1", "s-1"), None, "active run is not terminal"); + + // Mark it terminal: now find_terminal_run resolves to its run_id. + reg.finish("r-active"); + assert_eq!(reg.find_terminal_run("bot-1", "s-1").as_deref(), Some("r-active")); + + // Unknown session and bot mismatch: no match. + assert_eq!(reg.find_terminal_run("bot-1", "s-unknown"), None, "unknown session"); + assert_eq!(reg.find_terminal_run("bot-other", "s-1"), None, "bot mismatch"); + } + + #[test] + fn remove_drops_entry_and_run_session_association_for_rollback() { + // chat.send's 429 rollback path: the placeholder entry created by + // `begin` (and its record_session association, if any) must be wiped + // so the same run_id is not pinned in the registry and a same-id retry + // is not later surprised by a stale terminal-replay entry. `remove` + // bypasses the grace-TTL retention `finish` relies on. + let reg = RunRegistry::new(); + let (_h, is_new) = reg.begin("r-roll", "fp".into()); + assert!(is_new); + reg.record_session("r-roll", "bot-1", "s-1"); + assert!(reg.get("r-roll").is_some()); + + reg.remove("r-roll"); + + assert!(reg.get("r-roll").is_none(), "entry removed by rollback"); + assert_eq!(reg.find_terminal_run("bot-1", "s-1"), None, + "run_session association pruned alongside the entry"); + } + + #[tokio::test] + async fn abort_all_cancels_every_active_run_and_skips_terminal() { + // Two active + one terminal: only the two active handles have their + // abort tokens cancelled after abort_all; the terminal one stays as-is + // (it was already cancelled when its run loop finalized). + let reg = RunRegistry::new(); + let (h_a, _) = reg.begin("r-a", "fp".into()); + let (h_b, _) = reg.begin("r-b", "fp".into()); + let (h_t, _) = reg.begin("r-t", "fp".into()); + reg.finish("r-t"); + assert!(!h_a.abort.is_cancelled()); + assert!(!h_b.abort.is_cancelled()); + + reg.abort_all("provider_shutdown").await; + + assert!(h_a.abort.is_cancelled(), "active run a cancelled"); + assert!(h_b.abort.is_cancelled(), "active run b cancelled"); + assert!(!h_t.abort.is_cancelled(), "terminal run skipped"); + assert!(h_a.is_abort_requested() && h_b.is_abort_requested(), "flag set on each"); + assert_eq!(h_a.abort_stop_reason(), "provider_shutdown"); + } +} diff --git a/src/bcs/crates/adapters/bridge-provider/src/session.rs b/src/bcs/crates/adapters/bridge-provider/src/session.rs new file mode 100644 index 0000000000..42f394e55e --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/session.rs @@ -0,0 +1,130 @@ +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, Clone)] +pub struct InjectedMessage { + pub run_id: String, + pub from_name: Option, + pub text: String, +} + +#[derive(Debug, Clone, Default)] +pub struct SessionMapping { + pub engine_session_id: Option, + pub pending_injects: Vec, + pub active_run: Option, +} + +#[derive(Debug, thiserror::Error)] +#[error("session already has an active run")] +pub struct SessionBusy; + +type Key = (String, String); // (provider_bot_ref, bcs_session_id) + +#[derive(Clone, Default)] +pub struct SessionStore { + map: Arc>>, +} + +impl SessionStore { + pub fn new() -> Self { + Self::default() + } + + pub async fn mapping(&self, bot: &str, s: &str) -> SessionMapping { + self.map + .read() + .await + .get(&(bot.into(), s.into())) + .cloned() + .unwrap_or_default() + } + + pub async fn set_engine_session_id(&self, bot: &str, s: &str, engine_id: &str) { + self.map + .write() + .await + .entry((bot.into(), s.into())) + .or_default() + .engine_session_id = Some(engine_id.into()); + } + + pub async fn add_inject(&self, bot: &str, s: &str, msg: InjectedMessage) { + self.map + .write() + .await + .entry((bot.into(), s.into())) + .or_default() + .pending_injects.push(msg); + } + + pub async fn take_pending_injects(&self, bot: &str, s: &str) -> Vec { + let mut map = self.map.write().await; + match map.get_mut(&(bot.into(), s.into())) { + Some(m) => std::mem::take(&mut m.pending_injects), + None => Vec::new(), + } + } + + pub async fn try_start_run(&self, bot: &str, s: &str, run_id: &str) -> Result<(), SessionBusy> { + let mut map = self.map.write().await; + let m = map.entry((bot.into(), s.into())).or_default(); + if m.active_run.is_some() { + return Err(SessionBusy); + } + m.active_run = Some(run_id.into()); + Ok(()) + } + + pub async fn finish_run(&self, bot: &str, s: &str, run_id: &str) { + let mut map = self.map.write().await; + if let Some(m) = map.get_mut(&(bot.into(), s.into())) { + if m.active_run.as_deref() == Some(run_id) { + m.active_run = None; + } + } + } + + pub async fn active_run(&self, bot: &str, s: &str) -> Option { + self.map + .read() + .await + .get(&(bot.into(), s.into())) + .and_then(|m| m.active_run.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn dual_id_mapping_and_run_exclusion() { + let store = SessionStore::new(); + let m = store.mapping("bot-a", "s-1").await; + assert!(m.engine_session_id.is_none()); + + store.set_engine_session_id("bot-a", "s-1", "engine-sess-9").await; + assert_eq!(store.mapping("bot-a", "s-1").await.engine_session_id.as_deref(), + Some("engine-sess-9")); + // 另一个 bcs session 不受影响 + assert!(store.mapping("bot-a", "s-2").await.engine_session_id.is_none()); + + store.try_start_run("bot-a", "s-1", "run-1").await.unwrap(); + assert!(store.try_start_run("bot-a", "s-1", "run-2").await.is_err()); + store.finish_run("bot-a", "s-1", "run-1").await; + store.try_start_run("bot-a", "s-1", "run-2").await.unwrap(); + } + + #[tokio::test] + async fn pending_injects_fifo_drain() { + let store = SessionStore::new(); + store.add_inject("b", "s", InjectedMessage{ run_id: "i1".into(), from_name: None, text: "m1".into() }).await; + store.add_inject("b", "s", InjectedMessage{ run_id: "i2".into(), from_name: Some("张三".into()), text: "m2".into() }).await; + let drained = store.take_pending_injects("b", "s").await; + assert_eq!(drained.len(), 2); + assert_eq!(drained[0].text, "m1"); + assert!(store.take_pending_injects("b", "s").await.is_empty()); + } +} diff --git a/src/bcs/crates/adapters/bridge-provider/src/sse.rs b/src/bcs/crates/adapters/bridge-provider/src/sse.rs new file mode 100644 index 0000000000..4a46d4be25 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/sse.rs @@ -0,0 +1,278 @@ +use bcs_protocol::stream::{ + AgentData, AgentEvent, ChatEvent, ChatState, InteractionEvent, InteractionKind, + InteractionPhase, LifecycleData, StreamEvent, ThinkingData, ToolData, +}; +use serde_json::{json, Value}; + +pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024; +pub const HEARTBEAT: &str = ": heartbeat\n\n"; + +#[derive(Debug, thiserror::Error)] +pub enum FrameError { + #[error("SSE frame too large: {0} bytes")] + FrameTooLarge(usize), + #[error("serialize SSE data: {0}")] + Json(#[from] serde_json::Error), + #[error("SSE data must be single-line JSON")] + MultilineData, + #[error("event kind is not emittable on the wire")] + Unsupported, +} + +/// Encode one SSE frame from a pre-serialized single-line JSON string. +/// +/// `data_json` must already be compact, single-line JSON (no `\n`/`\r`). +/// Callers holding a `serde_json::Value` should serialize it first with +/// `serde_json::to_string`; its error converts into `FrameError::Json` via `?`. +pub fn encode_frame(event: &str, id: Option, data_json: &str) -> Result { + // SSE data: 行必须单行;先拒绝内嵌换行,避免拆成多帧 + if data_json.contains('\n') || data_json.contains('\r') { + return Err(FrameError::MultilineData); + } + let mut frame = String::with_capacity(event.len() + data_json.len() + 24); + frame.push_str("event: "); + frame.push_str(event); + frame.push('\n'); + if let Some(id) = id { + frame.push_str("id: "); + frame.push_str(&id.to_string()); + frame.push('\n'); + } + frame.push_str("data: "); + frame.push_str(data_json); + frame.push_str("\n\n"); + if frame.len() > MAX_FRAME_BYTES { + return Err(FrameError::FrameTooLarge(frame.len())); + } + Ok(frame) +} + +// ---- emit-side constructors (driver uses these; seq is None, run loop stamps it) + +pub fn chat_delta(run_id: &str, text: &str) -> StreamEvent { + StreamEvent::Chat(ChatEvent { + run_id: run_id.into(), + seq: None, + state: ChatState::Delta, + session_key: None, + delta_text: Some(text.into()), + stop_reason: None, + error_message: None, + error_kind: None, + error_code: None, + message: None, + raw: Value::Null, + }) +} + +pub fn chat_final(run_id: &str, text: String) -> StreamEvent { + let message = json!({"role":"assistant","content":[{"type":"text","text":text}]}); + StreamEvent::Chat(ChatEvent { + run_id: run_id.into(), + seq: None, + state: ChatState::Final, + session_key: None, + delta_text: None, + stop_reason: Some("completed".into()), + error_message: None, + error_kind: None, + error_code: None, + message: Some(message), + raw: Value::Null, + }) +} + +pub fn chat_error(run_id: &str, message: &str, kind: Option<&str>) -> StreamEvent { + StreamEvent::Chat(ChatEvent { + run_id: run_id.into(), + seq: None, + state: ChatState::Error, + session_key: None, + delta_text: None, + stop_reason: None, + error_message: Some(message.into()), + error_kind: kind.map(str::to_string), + error_code: None, + message: None, + raw: Value::Null, + }) +} + +pub fn chat_aborted(run_id: &str, stop_reason: &str) -> StreamEvent { + StreamEvent::Chat(ChatEvent { + run_id: run_id.into(), + seq: None, + state: ChatState::Aborted, + session_key: None, + delta_text: None, + stop_reason: Some(stop_reason.into()), + error_message: None, + error_kind: None, + error_code: None, + message: None, + raw: Value::Null, + }) +} + +pub fn agent_tool(run_id: &str, data: ToolData) -> StreamEvent { + StreamEvent::Agent(AgentEvent { + run_id: run_id.into(), + seq: None, + ts: None, + session_key: None, + data: AgentData::Tool(data), + raw: Value::Null, + }) +} + +pub fn agent_thinking(run_id: &str, delta: Option, text: Option) -> StreamEvent { + StreamEvent::Agent(AgentEvent { + run_id: run_id.into(), + seq: None, + ts: None, + session_key: None, + data: AgentData::Thinking(ThinkingData { delta, text }), + raw: Value::Null, + }) +} + +pub fn agent_lifecycle(run_id: &str, phase: &str, model: Option) -> StreamEvent { + StreamEvent::Agent(AgentEvent { + run_id: run_id.into(), + seq: None, + ts: None, + session_key: None, + data: AgentData::Lifecycle(LifecycleData { + phase: phase.into(), + model, + agent_mode: None, + }), + raw: Value::Null, + }) +} + +pub fn interaction_event( + run_id: &str, + phase: InteractionPhase, + kind: InteractionKind, + interaction_id: &str, + extra: Value, +) -> StreamEvent { + StreamEvent::Interaction(InteractionEvent { + run_id: run_id.into(), + seq: None, + ts: None, + session_key: None, + phase, + interaction_id: interaction_id.into(), + kind, + raw: extra, + }) +} + +// ---- StreamEvent -> Provider 2.0 wire frame +// +// 线格式 camelCase 键:runId/deltaText/toolCallId/...。ChatEvent/AgentEvent 不 +// derive Serialize,故 encoder 手工构造 Value("复用协议类型做语义、 +// encoder 独占线格式" 的边界)。调用方保证 seq 单调;ts 由 run loop 注入。 +pub fn event_to_frame(ev: &StreamEvent, seq: u64, ts: u64, run_id: &str) -> Result { + let (event, data): (&str, Value) = match ev { + StreamEvent::Chat(c) => { + let mut d = json!({ "runId": run_id, "seq": seq, "ts": ts }); + let obj = d.as_object_mut().ok_or(FrameError::Unsupported)?; + match c.state { + ChatState::Delta => { + obj.insert("state".into(), json!("delta")); + if let Some(t) = &c.delta_text { + obj.insert("deltaText".into(), json!(t)); + } + } + ChatState::Final => { + obj.insert("state".into(), json!("final")); + if let Some(m) = &c.message { + obj.insert("message".into(), m.clone()); + } + if let Some(s) = &c.stop_reason { + obj.insert("stopReason".into(), json!(s)); + } + } + ChatState::Error => { + obj.insert("state".into(), json!("error")); + if let Some(m) = &c.error_message { + obj.insert("errorMessage".into(), json!(m)); + } + if let Some(k) = &c.error_kind { + obj.insert("errorKind".into(), json!(k)); + } + } + ChatState::Aborted => { + obj.insert("state".into(), json!("aborted")); + if let Some(s) = &c.stop_reason { + obj.insert("stopReason".into(), json!(s)); + } + } + } + ("chat", d) + } + StreamEvent::Agent(a) => { + let mut d = json!({ "runId": run_id, "seq": seq, "ts": ts }); + let obj = d.as_object_mut().ok_or(FrameError::Unsupported)?; + match &a.data { + AgentData::Tool(t) => { + obj.insert("stream".into(), json!("tool")); + let v = serde_json::to_value(t)?; + merge(obj, v); + } + AgentData::Thinking(t) => { + obj.insert("stream".into(), json!("thinking")); + let v = serde_json::to_value(t)?; + merge(obj, v); + } + AgentData::Lifecycle(l) => { + obj.insert("stream".into(), json!("lifecycle")); + let v = serde_json::to_value(l)?; + merge(obj, v); + } + // Approval 属旧兼容结构,禁止输出(spec §2);Phase 暂不发; + // Unknown 不可线编码。 + AgentData::Approval(_) | AgentData::Phase(_) | AgentData::Unknown { .. } => { + return Err(FrameError::Unsupported); + } + } + ("agent", d) + } + StreamEvent::Interaction(i) => { + let mut d = json!({ + "runId": run_id, "seq": seq, "ts": ts, + "phase": match i.phase { + InteractionPhase::Requested => "requested", + InteractionPhase::Resolved => "resolved", + }, + "interactionId": i.interaction_id, + "kind": match i.kind { + InteractionKind::Exec => "exec", + InteractionKind::AskUser => "ask_user", + InteractionKind::ModeSwitch => "mode_switch", + }, + }); + let obj = d.as_object_mut().ok_or(FrameError::Unsupported)?; + // raw 承载 kind 专有三白名单字段(options/questions/…) + merge(obj, i.raw.clone()); + ("interaction", d) + } + // Ping 由 run loop 直发注释帧;Unknown 不可线编码。 + StreamEvent::Ping { .. } | StreamEvent::Unknown { .. } => { + return Err(FrameError::Unsupported); + } + }; + let data_json = serde_json::to_string(&data)?; + encode_frame(event, Some(seq), &data_json) +} + +fn merge(obj: &mut serde_json::Map, v: Value) { + if let Value::Object(m) = v { + for (k, val) in m { + obj.insert(k, val); + } + } +} diff --git a/src/bcs/crates/adapters/bridge-provider/src/webhook.rs b/src/bcs/crates/adapters/bridge-provider/src/webhook.rs new file mode 100644 index 0000000000..a758f3b08f --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/src/webhook.rs @@ -0,0 +1,477 @@ +use std::sync::Arc; +use axum::{extract::State, http::HeaderMap, response::{IntoResponse, Response}, routing::post, Json, Router}; +use bcs_protocol::BCN_PROTOCOL_VERSION_HEADER; +use serde::Deserialize; +use serde_json::{json, Value}; +use crate::{config::{EngineKind, ProviderConfig}, engine::transcript::TranscriptSink, error::BridgeError, idempotency::IdemDecision, interaction::ResolveOutcome, run::RunRegistry}; + +#[derive(Debug, Deserialize)] +pub struct ToBot { pub provider_id: String, pub provider_bot_ref: String } + +#[derive(Debug, Deserialize)] +pub struct DownstreamRequest { + pub id: String, + pub method: String, + pub to_bot: ToBot, + pub session_id: Option, + pub message: Option, + pub from: Option, // {"kind","name","actor_id"};inject 前置注入用 name + pub timeout_ms: Option, + pub params: Option, +} + +pub struct AppState { + pub config: ProviderConfig, + pub idem: crate::idempotency::IdempotencyLedger, + pub sessions: crate::session::SessionStore, + pub runs: RunRegistry, + pub interactions: crate::interaction::InteractionRegistry, + pub trace: Option>, +} +impl AppState { + pub fn new(config: ProviderConfig) -> Self { + let trace = config.trace_dir.as_deref().and_then(|dir| { + match crate::engine::trace::TraceStore::open(dir) { + Ok(trace) => Some(trace), + Err(error) => { + tracing::warn!( + target: "bridge_provider::trace", + path = %dir.display(), + error = %error, + "failed to open bridge trace directory; tracing disabled" + ); + None + } + } + }); + Self { + config, + idem: crate::idempotency::IdempotencyLedger::new(), + sessions: crate::session::SessionStore::new(), + runs: RunRegistry::new(), + interactions: crate::interaction::InteractionRegistry::new(), + trace, + } + } +} + +pub fn router(state: Arc) -> Router { + Router::new().route("/webhook", post(handle_webhook)).with_state(state) +} + +async fn handle_webhook( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Response { + match dispatch(state, headers, req).await { + Ok(resp) => resp, + Err(err) => err.into_response(), + } +} + +async fn dispatch( + state: Arc, + headers: HeaderMap, + req: DownstreamRequest, +) -> Result { + // 1. token + let auth = headers.get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()).unwrap_or_default(); + let expected = format!("Bearer {}", state.config.bcs_to_provider_token); + if auth != expected { return Err(BridgeError::unauthorized()); } + // 2. provider_id + if req.to_bot.provider_id != state.config.provider_id { + return Err(BridgeError::provider_id_mismatch()); + } + // 3. method + match req.method.as_str() { + "bot.ping" => Ok(Json(json!({"ok": true})).into_response()), + "chat.send" => handle_chat_send(state, headers, req).await, + // `interaction.resolve` carries the BCS HITL decision back to a parked + // cc control request. Its ACK error shape is a STRING error (spec + // §5.1), distinct from `BridgeError`'s object shape — handled in its + // own branch so it never reaches the shared error renderer. + "interaction.resolve" => Ok(handle_interaction_resolve(state, req).await), + "chat.inject" => handle_chat_inject(state, req).await, + "chat.abort" => handle_chat_abort(state, req).await, + other => Err(BridgeError::unsupported_method(other)), + } +} + +async fn handle_chat_send( + state: Arc, + headers: HeaderMap, + req: DownstreamRequest, +) -> Result { + // 1. X-BCN-Protocol-Version: 2.0 + let pv = headers + .get(BCN_PROTOCOL_VERSION_HEADER) + .and_then(|v| v.to_str().ok()); + if pv != Some("2.0") { + return Err(BridgeError::invalid_request("X-BCN-Protocol-Version 2.0 required")); + } + // 2. session_id present + let session_id = req + .session_id + .as_ref() + .ok_or_else(|| BridgeError::invalid_request("session_id required"))? + .clone(); + // 3. message present + if req.message.is_none() { + return Err(BridgeError::invalid_request("message required")); + } + // 4. bot exists + let bot = state + .config + .bot(&req.to_bot.provider_bot_ref) + .ok_or_else(|| BridgeError::bot_not_found(&req.to_bot.provider_bot_ref))? + .clone(); + + let run_id = req.id.clone(); + let fp = crate::run::body_fingerprint(&req, &session_id); + + // 5. Atomic begin FIRST — get-or-create the run entry BEFORE claiming the + // session slot. This closes the startup TOCTOU between + // `sessions.try_start_run` (sets `active_run`) and `runs.begin` (creates + // the registry entry): a chat.abort landing in that window used to see + // `active_run=Some` + `runs.get(None)` and wrongly return + // `{"aborted":false}`. With begin-first, the registry entry exists the + // moment `active_run` becomes `Some`, so the abort handler always reads + // a consistent pair. + // + // Re-attach ordering (Task 11): a same-id retry MUST NOT 429 and MUST NOT + // touch the session slot. `begin` is the get-or-create atomic — it returns + // `is_new == false` for an existing id, so the re-attach / terminal-replay + // / conflict decision below runs entirely before `try_start_run` (which is + // new-id only). Same-id retries with a different body still 409. + let (handle, is_new) = state.runs.begin(&run_id, fp.clone()); + if !is_new { + if !handle.matches(&fp) { + // same id, different body → conflict (409) + return Err(BridgeError::conflict()); + } + if handle.is_terminal() { + // terminal: replay buffered frames as a fresh one-shot stream + return Ok(crate::run::terminal_replay_response(handle)); + } + // active: re-attach — replay buffer then follow broadcast. Do NOT + // call try_start_run: the slot is already held by this same run_id, + // and a fresh claim would 429 ourselves. + return Ok(crate::run::sse_response(crate::run::forward_stream(handle))); + } + + // 6. New run: claim the session slot (429 if a different run is busy). + // On contention, roll back the placeholder entry we just created via + // `remove` (not `finish` — finishing would pin a never-spawned entry + // for TERMINAL_GRACE and stall same-id retries behind a fake terminal + // replay). The run_id is unique at this point (a colliding id would + // have returned `is_new == false` above), so removing it cannot touch + // another run's entry. + if state + .sessions + .try_start_run(&bot.provider_bot_ref, &session_id, &run_id) + .await + .is_err() + { + state.runs.remove(&run_id); + return Err(BridgeError::rate_limited()); + } + + // 7. Record the (provider_bot_ref, session_id) association BEFORE spawning + // the driver so a chat.abort landing in the (small) window between + // claiming the slot and the driver taking flight can still resolve the + // run — via the active leg if mid-flight, or via `find_terminal_run` + // (410 leg) once the driver self-terminates. + state + .runs + .record_session(&run_id, &bot.provider_bot_ref, &session_id); + Ok(crate::run::spawn_run(state, req, bot, session_id, handle)) +} + +/// `interaction.resolve` ACK with a STRING error (spec §5.1 — this method does +/// not share `BridgeError`'s `{code,message,retryable}` object shape). 200 OK +/// carries the protocol-level ok/false so the BCS retry layer reads `error`. +fn resolve_err_ack(message: &str) -> Response { + Json(json!({ "ok": false, "retryable": false, "error": message })).into_response() +} + +/// Handle `interaction.resolve`: deliver the BCS decision (exec `decision` or +/// ask_user `action`+`answers`) to the parked driver via the registry. +/// +/// ACK semantics (spec §5.1): +/// - `Delivered` (first resolve) and `Duplicate` (idempotent replay of an +/// already-delivered interaction) → `{"ok":true}` — `Duplicate` does not +/// re-write the engine control channel. +/// - `Unknown` (no such `interactionId`) → `{"ok":false,"retryable":false, +/// "error":"unknown interaction"}`. +/// - Malformed params (missing interactionId/idempotencyKey or +/// decision|action) → `{"ok":false,"retryable":false,"error":""}`. +async fn handle_interaction_resolve(state: Arc, req: DownstreamRequest) -> Response { + let params = req.params.clone().unwrap_or(Value::Null); + + let Some(interaction_id) = params.get("interactionId").and_then(|v| v.as_str()) else { + return resolve_err_ack("missing interactionId"); + }; + let Some(idempotency_key) = params.get("idempotencyKey").and_then(|v| v.as_str()) else { + return resolve_err_ack("missing idempotencyKey"); + }; + + // Build the resolution payload consumed by the driver's behavior mapping. + // exec: {"decision": }. ask_user: {"action": , "answers": + // [...]}. The driver collapses ask_user to allow/deny (cc v1 has no answers + // channel); `action:"cancel"` and missing answers map to deny. + let resolution = if let Some(decision) = params.get("decision").cloned() { + json!({ "decision": decision }) + } else if let Some(action) = params.get("action").cloned() { + let answers = params.get("answers").cloned().unwrap_or_else(|| json!([])); + json!({ "action": action, "answers": answers }) + } else { + return resolve_err_ack("decision or action required"); + }; + + match state.interactions.resolve(interaction_id, idempotency_key, resolution) { + ResolveOutcome::Delivered | ResolveOutcome::Duplicate => { + Json(json!({ "ok": true })).into_response() + } + ResolveOutcome::Unknown => resolve_err_ack("unknown interaction"), + } +} + +/// Handle `chat.inject`: queue an observation message into the session without +/// driving an engine turn (spec §5.1: inject never triggers a run). +/// +/// Flow: +/// 1. Validate `session_id` + `message` + bot exists. +/// 2. Pass through the idempotency ledger (Task 5) with fingerprint +/// `method + provider_bot_ref + session_id + message` — replay serves the +/// prior `{"ok":true}` ACK, mismatch yields 409. +/// 3. Sink-first-then-store (Task 13 brief choice — SessionStore has no +/// remove-one API): for `cc` bots with an established `engine_session_id`, +/// attempt [`ClaudeJsonlSink`]; on success the message is in the engine's +/// own transcript and the BCS re-send will resume against it, so we do NOT +/// add it to `pending_injects`. On sink failure (or no engine session yet, or +/// `$HOME` unset, or codex engine) we fall back to `pending_injects`, which +/// `run::assemble_prompt` drains FIFO and prepends to the next chat.send +/// prompt as `[from:{name}] {text}` lines (codex path / cc-without-session). +/// 4. Complete the idempotency ledger and ACK `{"ok":true}`. +async fn handle_chat_inject( + state: Arc, + req: DownstreamRequest, +) -> Result { + // 1. session_id required + let session_id = req + .session_id + .as_ref() + .ok_or_else(|| BridgeError::invalid_request("session_id required"))? + .clone(); + // 2. message required + let message = req + .message + .clone() + .ok_or_else(|| BridgeError::invalid_request("message required"))?; + // 3. bot exists + let bot = state + .config + .bot(&req.to_bot.provider_bot_ref) + .ok_or_else(|| BridgeError::bot_not_found(&req.to_bot.provider_bot_ref))? + .clone(); + + // 4. Idempotency: fingerprint = method + provider_bot_ref + session_id + message. + let msg_str = serde_json::to_string(&message).unwrap_or_default(); + let fp = crate::idempotency::fingerprint(&[ + "chat.inject", + &req.to_bot.provider_bot_ref, + &session_id, + &msg_str, + ]); + let run_id = req.id.clone(); + match state.idem.begin(&run_id, &fp) { + IdemDecision::Proceed => {} + IdemDecision::Replay { status, body } => return Ok((status, Json(body)).into_response()), + IdemDecision::Conflict => return Err(BridgeError::conflict()), + } + + // 5. Flatten `from.name` (optional) + `message.content[].text` into the + // `InjectedMessage` shape reused by both sink and pending-store paths. + let from_name = req + .from + .as_ref() + .and_then(|f| f.get("name")) + .and_then(|n| n.as_str()) + .map(str::to_string); + let text = crate::run::extract_message_text(Some(&message)); + + // 6. Sink-first-then-store. + let mapping = state.sessions.mapping(&bot.provider_bot_ref, &session_id).await; + let mut sunk = false; + if bot.engine == EngineKind::CfuseCc { + if let (Some(sink), Some(engine_session_id)) = ( + crate::engine::transcript::ClaudeJsonlSink::default_home(), + mapping.engine_session_id.as_deref(), + ) { + let inj = crate::session::InjectedMessage { + run_id: run_id.clone(), + from_name: from_name.clone(), + text: text.clone(), + }; + match sink.append_user_message(&bot.cwd, engine_session_id, &inj) { + Ok(()) => sunk = true, + Err(e) => tracing::warn!( + target: "bridge_provider", + error = %e, + "transcript sink failed; falling back to pending injects" + ), + } + } + } + if !sunk { + state + .sessions + .add_inject( + &bot.provider_bot_ref, + &session_id, + crate::session::InjectedMessage { + run_id: run_id.clone(), + from_name, + text, + }, + ) + .await; + } + + // 7. Complete the ledger and ACK. + let resp = json!({ "ok": true }); + state.idem.complete(&run_id, resp.clone()); + Ok(Json(resp).into_response()) +} + +/// Handle `chat.abort` (Task 14, spec §5.3): cancel the active run for the +/// session (if any), emit a terminal `chat_aborted` SSE frame on that run's +/// stream (driven by the run loop, which sees the abort flag and surfaces the +/// final state), and respond per the abort matrix. The 200 abort ACK does NOT +/// wait for the engine to die — the run loop's post-cancel finalize drains it +/// asynchronously (the BCS x-PC observes the `state=aborted` frame on the +/// chat.send SSE stream, not a flushed abort ACK). +/// +/// Response matrix (spec §5.3): +/// - Active run for the session: invalidate its parked HITL interactions with +/// the deny fallback (so the driver never blocks on a dead receiver), call +/// its `request_abort()` (sets the abort flag + cancels the token + records +/// the `user_cancelled` stop_reason for the terminal `chat_aborted` SSE +/// frame), then 200 `{"ok":true,"aborted":true,"aborted_run_ids":[]}`. +/// - No active run, but a terminal run recorded for this session in +/// `RunRegistry`'s `run_session` reverse index: 410 `run_terminated` — +/// repeating abort on the same terminal run stably returns 410. +/// - No record at all (and the edge case where the session store claims an +/// active run_id that the registry has already swept): 200 +/// `{"ok":true,"aborted":false,"aborted_run_ids":[]}`. +/// +/// Passes through the idempotency ledger (Task 5) with fingerprint +/// `method + provider_bot_ref + session_id` (no message body — abort has +/// none). Replays the prior status+body verbatim (via +/// [`IdempotencyLedger::complete_with_status`], including 410s) so a same-id +/// retry of any branch returns the exact same response — the brief's +/// "对同 terminal run 重复 abort 稳定同答;幂等台账保证同 id 重放". +async fn handle_chat_abort( + state: Arc, + req: DownstreamRequest, +) -> Result { + // 1. session_id required + let session_id = req + .session_id + .as_ref() + .ok_or_else(|| BridgeError::invalid_request("session_id required"))? + .clone(); + // 2. bot exists + let bot = state + .config + .bot(&req.to_bot.provider_bot_ref) + .ok_or_else(|| BridgeError::bot_not_found(&req.to_bot.provider_bot_ref))? + .clone(); + + // 3. Idempotency: fingerprint = method + provider_bot_ref + session_id + // (no message — abort carries none). Same shape as `chat.inject` minus + // the body. + let fp = crate::idempotency::fingerprint(&[ + "chat.abort", + &req.to_bot.provider_bot_ref, + &session_id, + ]); + let run_id = req.id.clone(); + match state.idem.begin(&run_id, &fp) { + IdemDecision::Proceed => {} + IdemDecision::Replay { status, body } => { + return Ok((status, Json(body)).into_response()) + } + IdemDecision::Conflict => return Err(BridgeError::conflict()), + } + + // 4. Matrix. Resolve the active run's handle up-front so the active branch + // is a single atomic invalidate+request_abort against the same handle + // (no TOCTOU between re-reading sessions.active_run and runs.get). + let active_run_id = state + .sessions + .active_run(&bot.provider_bot_ref, &session_id) + .await; + let active_handle = active_run_id + .as_deref() + .and_then(|rid| state.runs.get(rid)); + + // Branch 1 — active run: invalidate + request_abort → 200 aborted:true. + if let (Some(run_id_active), Some(handle)) = (active_run_id.as_deref(), active_handle) { + // Invalidate BEFORE the engine kill (spec §6.3): the resolution channel + // delivers the deny fallback rather than a dropped-receiver error. The + // run loop's finalize calls invalidate_run again — idempotent, so the + // second call's resolver-clear is a no-op. + state + .interactions + .invalidate_run(run_id_active, json!({ "decision": "deny" })); + // request_abort: set abort_requested (→ run loop emits chat_aborted), + // store "user_cancelled" as the SSE frame's stopReason, cancel the + // engine's CancellationToken (the driver's select! arm fires, kills + // the cli, returns TurnError::Aborted). + handle.request_abort("user_cancelled"); + let body = json!({ + "ok": true, + "aborted": true, + "aborted_run_ids": [run_id_active], + }); + state + .idem + .complete_with_status(&run_id, axum::http::StatusCode::OK, body.clone()); + return Ok((axum::http::StatusCode::OK, Json(body)).into_response()); + } + + // Branch 2 — terminal run recorded for this session → 410 run_terminated. + if state + .runs + .find_terminal_run(&bot.provider_bot_ref, &session_id) + .is_some() + { + let (status, body) = BridgeError::run_terminated().into_parts(); + state + .idem + .complete_with_status(&run_id, status, body.clone()); + return Ok((status, Json(body)).into_response()); + } + + // Branch 3 — no active run and no terminal record: 200 aborted:false. + // The only sources of `active_run=Some` + `handle=None` used to be the + // startup TOCTOU between try_start_run and begin (chat.send now does + // begin-first, so the registry entry exists the moment active_run is + // set) and a run swept past grace after finish (impossible: the driver + // calls sessions.finish_run between runs.finish and the sweep's grace + // expiry, so active_run is cleared before the entry is reclaimable). + // Reaching here on a session the provider has never seen, or whose run + // finished long ago past grace — BCS may send a fresh request normally. + let body = json!({ + "ok": true, + "aborted": false, + "aborted_run_ids": [], + }); + state + .idem + .complete_with_status(&run_id, axum::http::StatusCode::OK, body.clone()); + Ok((axum::http::StatusCode::OK, Json(body)).into_response()) +} diff --git a/src/bcs/crates/adapters/bridge-provider/tests/e2e_webhook.rs b/src/bcs/crates/adapters/bridge-provider/tests/e2e_webhook.rs new file mode 100644 index 0000000000..6cd65bd6b5 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/tests/e2e_webhook.rs @@ -0,0 +1,875 @@ +use axum::http::StatusCode; +use futures::StreamExt; +use serde_json::json; +use std::path::Path; + +mod support; // tests/support/mod.rs:spawn_app(config_toml: &str) -> String(base_url) + +/// Serialize tests that mutate the process-global `HOME` env var. The cc +/// transcript sink reads `~/.claude/projects` from `$HOME`, and +/// `std::env::set_var` mutates the global env — pairing this lock with the +/// [`HomeGuard`] RAII below keeps HOME-mutating tests out of each other's way. +/// `std::sync::Mutex` (const constructor; `!Send` guard is fine because the +/// `#[tokio::test]` runtime is current-thread and no other task takes this +/// lock — only HOME-mutating tests touch it, and they all acquire it). +static HOME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// RAII guard that restores the original `HOME` on drop. Pair with the +/// [`HOME_LOCK`] mutex to isolate tests that need the cc transcript sink to +/// resolve a tempdir as `$HOME/.claude/projects`. +struct HomeGuard(Option); +#[allow(unsafe_code)] // env mutation is unsafe on edition 2024; HOME_LOCK serializes access +impl HomeGuard { + fn set(dir: &Path) -> Self { + let prev = std::env::var_os("HOME"); + // SAFETY: the caller holds HOME_LOCK for the duration of the test, so + // no other code in this process mutates/reads HOME concurrently. + unsafe { std::env::set_var("HOME", dir) }; + Self(prev) + } +} +#[allow(unsafe_code)] // see impl HomeGuard above — same HOME_LOCK exclusivity +impl Drop for HomeGuard { + fn drop(&mut self) { + // SAFETY: same HOME_LOCK exclusivity protects the restore path. + match self.0.take() { + Some(v) => unsafe { std::env::set_var("HOME", v) }, + None => unsafe { std::env::remove_var("HOME") }, + } + } +} + +#[tokio::test] +async fn ping_requires_auth_and_matching_provider() { + let url = support::spawn_app(r#" +provider_id = "bridge-1" +listen = "127.0.0.1:0" +bcs_to_provider_token = "tok-b2p" +[[bot]] +provider_bot_ref = "cc-worker" +engine = "cfuse-cc" +cwd = "/tmp" +"#).await; + let client = reqwest::Client::new(); + + // 无 token → 401 + let resp = client.post(format!("{url}/webhook")) + .json(&json!({"type":"req","id":"1","method":"bot.ping", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"cc-worker"}})) + .send().await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["error"]["code"], json!("unauthorized")); + + // provider_id 不匹配 → 403 + let resp = client.post(format!("{url}/webhook")) + .bearer_auth("tok-b2p") + .json(&json!({"type":"req","id":"2","method":"bot.ping", + "to_bot":{"provider_id":"other","provider_bot_ref":"cc-worker"}})) + .send().await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + // 未知 method → 501 + let resp = client.post(format!("{url}/webhook")) + .bearer_auth("tok-b2p") + .json(&json!({"type":"req","id":"3","method":"chat.explode", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"cc-worker"}})) + .send().await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED); + + // ping 正常 → 200 + let resp = client.post(format!("{url}/webhook")) + .bearer_auth("tok-b2p") + .json(&json!({"type":"req","id":"4","method":"bot.ping", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"cc-worker"}})) + .send().await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.json::().await.unwrap()["ok"], json!(true)); +} + +#[tokio::test] +async fn chat_send_streams_sse_to_final() { + // mock_cc.sh:读一行 stdin 后回放 cc_turn.ndjson,完整跑一轮到 result/success。 + let url = support::spawn_app_with_mock("mock_cc.sh", "cfuse-cc").await; + let resp = reqwest::Client::new() + .post(format!("{url}/webhook")) + .bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&json!({ + "type": "req", "id": "run-1", "method": "chat.send", + "session_id": "s-1", + "to_bot": {"provider_id": "bridge-1", "provider_bot_ref": "worker-1"}, + "message": {"role": "user", "content": [{"type": "text", "text": "你好"}]} + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert!( + resp.headers()["content-type"] + .to_str() + .unwrap() + .starts_with("text/event-stream") + ); + let text = resp.text().await.unwrap(); + assert!(text.contains("event: agent"), "missing agent tool event: {text}"); + assert!(text.contains("\"state\":\"delta\""), "missing delta state: {text}"); + assert!(text.contains("\"deltaText\":\"正在\""), "missing delta text: {text}"); + assert!(text.contains("\"state\":\"final\""), "missing final state: {text}"); + assert!(text.contains("完成了"), "missing final assistant text: {text}"); + // seq 单调递增 + let seqs = support::extract_seqs(&text); + assert!(!seqs.is_empty(), "no seq frames: {text}"); + assert!(seqs.windows(2).all(|w| w[0] < w[1]), "seq not monotonic: {seqs:?}"); +} + +#[tokio::test] +async fn concurrent_send_same_session_gets_429() { + // mock_cc_slow.sh:读 stdin 后 sleep 30 再吐结果,保证第一个 run 仍在执行。 + let url = support::spawn_app_with_mock("mock_cc_slow.sh", "cfuse-cc").await; + let client = reqwest::Client::new(); + let body = |id: &str| serde_json::json!({ + "type": "req", "id": id, "method": "chat.send", + "session_id": "s-1", + "to_bot": {"provider_id": "bridge-1", "provider_bot_ref": "worker-1"}, + "message": {"role": "user", "content": [{"type": "text", "text": "hi"}]} + }); + + let first = client + .post(format!("{url}/webhook")) + .bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&body("run-a")) + .send() + .await + .unwrap(); + assert_eq!(first.status(), StatusCode::OK, "first run SSE stream must start"); + + let second = client + .post(format!("{url}/webhook")) + .bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&body("run-b")) + .send() + .await + .unwrap(); + assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS); + let err: serde_json::Value = second.json().await.unwrap(); + assert_eq!(err["error"]["code"], serde_json::json!("rate_limited")); + assert_eq!(err["error"]["retryable"], serde_json::json!(true)); +} + +#[tokio::test] +async fn interaction_roundtrip_over_sse_and_resolve_webhook() { + // mock_cc_approval.sh:读 user 消息后吐 control_request(can_use_tool Bash), + // 等待 stdin 的 control_response,按 behavior 吐 result。 + let url = support::spawn_app_with_mock("mock_cc_approval.sh", "cfuse-cc").await; + let client = reqwest::Client::new(); + let resp = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&json!({"type":"req","id":"run-1","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"执行一下"}]}})) + .send().await.unwrap(); + assert_eq!(resp.status(), 200); + let mut stream = resp.bytes_stream(); + let mut acc = String::new(); + // 读到 interaction/requested 帧为止 + let iid = loop { + let chunk = stream.next().await.unwrap().unwrap(); + acc.push_str(&String::from_utf8_lossy(&chunk)); + if acc.contains("\"phase\":\"requested\"") { + break support::extract_first_interaction_id(&acc); + } + }; + // BCS 回程:interaction.resolve + let ack = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&json!({"type":"req","id":"resolve-1","method":"interaction.resolve", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "params":{"bcsRunId":"run-1","runId":"run-1","interactionId":iid, + "kind":"exec","idempotencyKey":"key-1","decision":"allow_once"}})) + .send().await.unwrap(); + assert_eq!(ack.json::().await.unwrap()["ok"], json!(true)); + // 幂等重放同 key + let dup = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&json!({"type":"req","id":"resolve-2","method":"interaction.resolve", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "params":{"bcsRunId":"run-1","runId":"run-1","interactionId":iid, + "kind":"exec","idempotencyKey":"key-1","decision":"allow_once"}})) + .send().await.unwrap(); + assert_eq!(dup.json::().await.unwrap()["ok"], json!(true)); + // 未知 interactionId → 字符串形态 error + let unknown = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&json!({"type":"req","id":"resolve-3","method":"interaction.resolve", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "params":{"bcsRunId":"run-1","runId":"run-1","interactionId":"int-nope", + "kind":"exec","idempotencyKey":"key-9","decision":"deny"}})) + .send().await.unwrap(); + let body: serde_json::Value = unknown.json().await.unwrap(); + assert_eq!(body["ok"], json!(false)); + assert!(body["error"].is_string()); // 注意:此方法的 error 是字符串(spec §5.1) + // 流继续:resolved → chat/final + while let Some(chunk) = stream.next().await { + acc.push_str(&String::from_utf8_lossy(&chunk.unwrap())); + if acc.contains("\"state\":\"final\"") { break; } + } + assert!(acc.contains("\"phase\":\"resolved\"")); + assert!(acc.contains("\"state\":\"final\"")); +} + +#[tokio::test] +async fn inject_then_send_prepends_for_codex() { + // mock_codex_app_server.py emits app-server delta notifications, so the + // chat.send SSE body carries the assembled prompt text. + let url = support::spawn_app_with_mock("mock_codex_app_server.py", "cfuse-codex").await; + let client = reqwest::Client::new(); + let resp = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&json!({"type":"req","id":"inj-1","method":"chat.inject", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"观察上下文"}]}, + "from":{"kind":"bot","name":"观察者"}})) + .send().await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.json::().await.unwrap()["ok"], json!(true)); + + let resp = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&json!({"type":"req","id":"run-9","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"正式问题"}]}})) + .send().await.unwrap(); + let text = resp.text().await.unwrap(); + // The prefix from the injected message is prepended with the `[from:{name}]` + // envelope, and it must precede the current `正式问题` message body in the + // assembled prompt. mock_codex_app_server.py echoes it as an + // `item/agentMessage/delta` notification that the app-server driver maps + // to a chat_delta SSE event — so both needles appear literally in the SSE + // stream text (no JSON-escaping of these ASCII-bracket/multi-byte chars). + assert!(text.contains("[from:观察者] 观察上下文"), "inject prefix missing: {text}"); + let pos_inject = text.find("[from:观察者] 观察上下文") + .expect("inject prefix position"); + let pos_main = text.find("正式问题").expect("main message position"); + assert!(pos_inject < pos_main, "inject must precede the current message: {text}"); +} + +#[tokio::test] +async fn codex_app_server_resume_streams_two_turns() { + // The app-server peer handles both thread/start and thread/resume and + // emits a real item/agentMessage/delta notification for each turn. + let url = support::spawn_app_with_mock("mock_codex_app_server.py", "cfuse-codex").await; + let client = reqwest::Client::new(); + + let send = |id: &str, text: &str| { + client + .post(format!("{url}/webhook")) + .bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&json!({ + "type": "req", "id": id, "method": "chat.send", + "session_id": "s-1", + "to_bot": {"provider_id": "bridge-1", "provider_bot_ref": "worker-1"}, + "message": {"role": "user", "content": [{"type": "text", "text": text}]} + })) + }; + + let first = send("codex-run-1", "首轮").send().await.unwrap(); + assert_eq!(first.status(), StatusCode::OK); + let first_text = first.text().await.unwrap(); + assert!(first_text.contains("首轮"), "first app-server turn failed: {first_text}"); + assert!(first_text.contains("\"state\":\"final\""), "missing first final: {first_text}"); + + let second = send("codex-run-2", "续轮").send().await.unwrap(); + assert_eq!(second.status(), StatusCode::OK); + let second_text = second.text().await.unwrap(); + assert!(second_text.contains("续轮"), "app-server resume turn failed: {second_text}"); + let delta = second_text.find("\"state\":\"delta\"").expect("resume delta"); + let final_ = second_text.find("\"state\":\"final\"").expect("resume final"); + assert!(delta < final_, "streamed delta must precede final: {second_text}"); + assert!(second_text.contains("\"state\":\"final\""), "missing resume final: {second_text}"); +} + +#[tokio::test] +async fn codex_app_server_emits_thinking_and_tool_events() { + let url = support::spawn_app_with_mock("mock_codex_app_server.py", "cfuse-codex").await; + let resp = reqwest::Client::new() + .post(format!("{url}/webhook")) + .bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&json!({ + "type": "req", "id": "events-run", "method": "chat.send", + "session_id": "events-session", + "to_bot": {"provider_id": "bridge-1", "provider_bot_ref": "worker-1"}, + "message": {"role": "user", "content": [{"type": "text", "text": "事件测试"}]} + })) + .send() + .await + .unwrap(); + let text = resp.text().await.unwrap(); + assert!(text.contains("event: agent"), "missing agent events: {text}"); + assert!(text.contains("\"stream\":\"tool\""), "missing tool stream: {text}"); + assert!(text.contains("\"phase\":\"start\""), "missing tool start: {text}"); + assert!(text.contains("\"phase\":\"update\""), "missing tool update: {text}"); + assert!(text.contains("\"phase\":\"result\""), "missing tool result: {text}"); + assert!(text.contains("\"stream\":\"thinking\""), "missing thinking stream: {text}"); + assert!(text.contains("事件测试"), "missing chat output: {text}"); + assert!(text.contains("\"state\":\"final\""), "missing final: {text}"); +} + +#[tokio::test] +async fn trace_records_raw_converted_and_sse_events() { + let trace_dir = tempfile::tempdir().unwrap(); + let bin = format!( + "{}/tests/fixtures/mock_codex_app_server.py", + env!("CARGO_MANIFEST_DIR") + ); + let (url, _state) = support::spawn_app_with_state(&format!( + r#" +provider_id = "bridge-1" +listen = "127.0.0.1:0" +bcs_to_provider_token = "tok-b2p" +trace_dir = "{}" +[[bot]] +provider_bot_ref = "worker-1" +engine = "cfuse-codex" +cwd = "/tmp" +cfuse_bin = "{}" +"#, + trace_dir.path().display(), + bin + )) + .await; + + let response = reqwest::Client::new() + .post(format!("{url}/webhook")) + .bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&json!({ + "type": "req", "id": "trace-run", "method": "chat.send", + "session_id": "trace-session", + "to_bot": {"provider_id": "bridge-1", "provider_bot_ref": "worker-1"}, + "message": {"role": "user", "content": [{"type": "text", "text": "trace"}]} + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let _ = response.text().await.unwrap(); + + let raw = std::fs::read_to_string(trace_dir.path().join("engine.raw.ndjson")).unwrap(); + let converted = std::fs::read_to_string(trace_dir.path().join("bridge.converted.ndjson")).unwrap(); + let sse = std::fs::read_to_string(trace_dir.path().join("bridge.sse.ndjson")).unwrap(); + assert!(raw.contains("\"engine_to_bridge\"")); + assert!(raw.contains("item/agentMessage/delta")); + assert!(converted.contains("\"bridge_converted\"")); + assert!(converted.contains("\"stream\":\"tool\"")); + assert!(converted.contains("\"stream\":\"thinking\"")); + assert!(sse.contains("\"bridge_to_bcs\"")); + assert!(sse.contains("event: chat")); + assert!(sse.contains("event: agent")); +} + +#[tokio::test] +async fn inject_sinks_to_cc_transcript_and_does_not_pending() { + // cc sink-success branch: with an established `engine_session_id`, a cc + // bot's inject must land in the engine transcript file (and NOT also be + // added to `pending_injects` — that would double-deliver on the next + // chat.send). Idempotent replay keeps the file at exactly one entry. + // + // Serialize HOME-mutating tests (set_var/restore) — `cargo test` runs tests + // in parallel across OS threads within the same process, so env mutation + // is process-global. The cc transcript sink resolves `~/.claude/projects` + // from `$HOME`. Only HOME-mutating tests acquire HOME_LOCK, and no other + // test reads `$HOME`, so the lock isolates the set/restore window. + let _home_lock = HOME_LOCK.lock().unwrap(); + let home = tempfile::tempdir().unwrap(); + let _guard = HomeGuard::set(home.path()); + let projects = home.path().join(".claude").join("projects"); + + // cc bot with cwd=/tmp (encodes to `-tmp`); cfuse_bin never spawned by + // chat.inject (spec §5.1: inject does not drive an engine run), so the + // mock cc script content is irrelevant here — we only need the cc engine + // kind so the handler takes the `ClaudeJsonlSink` branch. + let (url, state) = support::spawn_app_with_mock_and_state("mock_cc.sh", "cfuse-cc").await; + // Pre-seed the engine session id so the sink path is taken. + state + .sessions + .set_engine_session_id("worker-1", "s-1", "cc-sess-1") + .await; + + let client = reqwest::Client::new(); + let body = json!({"type":"req","id":"inj-1","method":"chat.inject", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"观察"}]}, + "from":{"kind":"bot","name":"张三"}}); + let resp = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&body).send().await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.json::().await.unwrap()["ok"], json!(true)); + + // (a) The cc transcript file received one user entry tagged with the + // inject's run_id (bridgeInjectId), living at + // `/.claude/projects/-tmp/cc-sess-1.jsonl` (cwd `/tmp`→`-tmp`). + let transcript = projects.join("-tmp").join("cc-sess-1.jsonl"); + let content = std::fs::read_to_string(&transcript) + .expect("transcript file was created on sink success"); + let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect(); + assert_eq!(lines.len(), 1, "exactly one user entry: {content}"); + let entry: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + assert_eq!(entry["type"], json!("user")); + assert_eq!(entry["bridgeInjectId"], json!("inj-1")); + assert_eq!(entry["sessionId"], json!("cc-sess-1")); + assert_eq!( + entry["message"]["content"][0]["text"], + json!("[from:张三] 观察") + ); + + // (b) `pending_injects` stayed empty — a regression that always calls + // `add_inject` after a successful sink would surface here (the next + // chat.send would then both read the transcript AND prepend the prompt). + let pending = state + .sessions + .take_pending_injects("worker-1", "s-1") + .await; + assert!(pending.is_empty(), "sink success must NOT also add_inject: {pending:?}"); + + // Replay: same id empotently serves the prior {"ok":true} and does NOT + // append a second transcript line (per-run_id idempotency). + let resp = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&body).send().await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.json::().await.unwrap()["ok"], json!(true)); + let content2 = std::fs::read_to_string(&transcript).unwrap(); + let lines2: Vec<&str> = content2.lines().filter(|l| !l.trim().is_empty()).collect(); + assert_eq!(lines2.len(), 1, "idempotent replay appended a second line: {content2}"); +} + +/// Task 14 — chat.abort terminal-state matrix: +/// - Active run present: 200 `{ok, aborted:true, aborted_run_ids:[run_id]}`; the +/// SSE stream for the aborted chat.send must emit a terminal `state=aborted` +/// frame (the 200 abort ACK does not wait for the engine to die — coordination +/// is via the run loop, which the test asserts by reading the SSE body). +/// - Repeating abort on the same now-terminal run: 410 `run_terminated` (stable; +/// the run→session reverse index on RunRegistry remembers the terminal run). +/// - Unknown session: 200 `{ok, aborted:false, aborted_run_ids:[]}`. +#[tokio::test] +async fn abort_active_run_emits_aborted_terminal() { + let url = support::spawn_app_with_mock("mock_cc_slow.sh", "cfuse-cc").await; + let client = reqwest::Client::new(); + let send = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&serde_json::json!({"type":"req","id":"run-1","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"慢任务"}]}})); + // 后台持有 SSE 响应体 + let sse = tokio::spawn(async move { send.send().await.unwrap().text().await.unwrap() }); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; // 等 run 起跑 + + let resp = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"abort-1","method":"chat.abort", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}})) + .send().await.unwrap(); + let status = resp.status().as_u16(); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(status, 200); + assert_eq!(body["ok"], serde_json::json!(true)); + assert_eq!(body["aborted"], serde_json::json!(true)); + assert_eq!(body["aborted_run_ids"], serde_json::json!(["run-1"])); + + let sse_text = tokio::time::timeout(std::time::Duration::from_secs(5), sse).await.unwrap().unwrap(); + assert!(sse_text.contains("\"state\":\"aborted\"")); + + // 对同一 terminal run 重复 abort → 410 run_terminated(稳定) + let again = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"abort-2","method":"chat.abort", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}})) + .send().await.unwrap(); + assert_eq!(again.status(), 410); + assert_eq!(again.json::().await.unwrap()["error"]["code"], + serde_json::json!("run_terminated")); + + // 无任何记录的 session → aborted:false + let none = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"abort-3","method":"chat.abort", + "session_id":"s-unknown", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}})) + .send().await.unwrap(); + let none_body: serde_json::Value = none.json().await.unwrap(); + assert_eq!(none_body["aborted"], serde_json::json!(false)); + assert_eq!(none_body["aborted_run_ids"], serde_json::json!([])); +} + +/// Regression test for the chat.send/abort startup TOCTOU (review fix round 1). +/// +/// Invariant: the moment `sessions.active_run(bot, session)` is `Some(run_id)`, +/// `RunRegistry::get(run_id)` MUST also be `Some`. The original bug reserved +/// the session slot (`try_start_run`) BEFORE creating the registry entry +/// (`begin`), leaving a window where a chat.abort could see +/// `active_run=Some` + `runs.get=None` and wrongly return `{"aborted":false}` +/// (matrix branch 3 instead of branch 1). The fix inverts the order in +/// `handle_chat_send`: begin → try_start_run → (on 429) rollback. +/// +/// Forces the window deterministically by polling AppState's `sessions` + +/// `runs` directly while a slow-mock chat.send is in flight: the moment +/// `active_run` flips to `Some`, `runs.get(rid)` must be `Some`. mock_cc_slow +/// keeps the run alive long enough to reliably observe the transition. The +/// `(url, state)` helper is what exposes AppState for this check. +#[tokio::test] +async fn chat_send_creates_run_entry_before_session_slot_claim() { + let (url, state) = support::spawn_app_with_mock_and_state("mock_cc_slow.sh", "cfuse-cc").await; + let client = reqwest::Client::new(); + let send = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&serde_json::json!({"type":"req","id":"r-order","method":"chat.send", + "session_id":"s-order", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"hi"}]}})); + // 后台持有 SSE 响应体(mock_cc_slow.sh 会读 stdin 后 sleep 30s) + let sse = tokio::spawn(async move { send.send().await.unwrap().text().await.unwrap() }); + + // 轮询 AppState:active_run 一旦变成 Some(r-order),runs.get(r-order) 必须也是 + // Some —— 这是 begin-first 不变量的直接断言。原 bug 在 try_start_run 与 begin + // 之间的窗口里该断言会失败(active_run=Some, runs.get=None)。 + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut observed = false; + loop { + if let Some(rid) = state + .sessions + .active_run("worker-1", "s-order") + .await + .as_deref() + { + if rid == "r-order" { + assert!( + state.runs.get(rid).is_some(), + "TOCTOU regression: sessions.active_run=Some({rid}) but \ + RunRegistry::get returned None — abort landing now would \ + return aborted:false instead of aborted:true" + ); + observed = true; + break; + } + } + if std::time::Instant::now() > deadline { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(2)).await; + } + assert!( + observed, + "active_run did not flip to Some(r-order) before the 2s deadline; \ + the slow mock should keep the run in flight — rerun if flaky" + ); + + // Drain the SSE so the slow mock is reclaimed via kill_on_drop (aborted + // path cancels the engine, the test never waits the 30s sleep). + let _ = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"abort-order","method":"chat.abort", + "session_id":"s-order", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}})) + .send().await; + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), sse).await; +} + +/// Task 15 — binary entrypoint smoke: the `bridge-provider` binary loads config +/// from `BRIDGE_CONFIG`, binds `config.listen`, answers `bot.ping` with HTTP 200, +/// and exits 0 on SIGTERM (axum graceful shutdown → `RunRegistry::abort_all`). +/// +/// Uses the fixed test port 21999 (pick-port-0 isn't reachable through the +/// config, which needs a literal `SocketAddr`). If 21999 is already taken the +/// child exits early at `bind`; the poll loop detects that via `try_wait` and +/// surfaces a clear `early exit` panic instead of silently dead-waiting 5s. +/// The subprocess's stdout/stderr are piped to null so a successful run emits +/// nothing into `cargo test`'s stream (pristine output). +#[allow(unsafe_code)] // libc::kill (deliver SIGTERM) is an unsafe extern call +#[tokio::test] +async fn binary_starts_and_serves_ping() { + let dir = tempfile::tempdir().unwrap(); + let cfg_path = dir.path().join("bridge.toml"); + std::fs::write( + &cfg_path, + r#" +provider_id = "bridge-1" +listen = "127.0.0.1:21999" +bcs_to_provider_token = "tok-b2p" +[[bot]] +provider_bot_ref = "worker-1" +engine = "cfuse-cc" +cwd = "/tmp" +"#, + ) + .unwrap(); + let bin = env!("CARGO_BIN_EXE_bridge-provider"); + let mut child = std::process::Command::new(bin) + .env("BRIDGE_CONFIG", &cfg_path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + + // 轮询直到端口就绪(最多 5s)。每次迭代先确认子进程仍在运行——若它在 + // bind 前就退出(端口被占用 / 配置非法),try_wait 会让测试明确失败。 + let client = reqwest::Client::new(); + let mut ok = false; + for _ in 0..50 { + if let Some(status) = child.try_wait().unwrap() { + panic!("bridge-provider exited before binding 21999: {status} (port taken?)"); + } + let resp = client + .post("http://127.0.0.1:21999/webhook") + .bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"p1","method":"bot.ping", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}})) + .send() + .await; + if let Ok(r) = resp { + if r.status() == 200 { + ok = true; + break; + } + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + assert!(ok, "ping should succeed on port 21999 while bridge-provider runs"); + + // 优雅退出:SIGTERM 应让进程经 graceful-shutdown 路径干净退出(exit 0)。 + unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }; + let status = tokio::time::timeout( + std::time::Duration::from_secs(5), + tokio::task::spawn_blocking(move || child.wait()), + ) + .await + .expect("process exits within 5s") + .unwrap() + .unwrap(); + assert!(status.success(), "graceful shutdown should yield exit 0, got {status}"); +} + +/// Task 16 — protocol regression: idempotent re-attach replays buffered frames +/// then follows the live broadcast (spec §5/§6). +/// +/// `mock_cc_burst.sh` emits two `text_delta` lines IMMEDIATELY (突发一, 突发二) +/// then sleeps 30s, so the first chat.send pushes seq 1 and seq 2 into the +/// run's buffer BEFORE the re-attach. A same-id, same-body retry then takes +/// the active re-attach path (`RunRegistry::begin` returns `is_new == false`, +/// fingerprint matches) → 200 (not 429 — `try_start_run` is not re-invoked; not +/// 409 — body matches). The re-attached stream's forwarder snapshots the buffer +/// (seq 1, 2) and subscribes to the broadcast. +/// +/// Strengthened per review round 1: the test now genuinely covers the buffer +/// snapshot → replay → live-follow partition — +/// 1. read the FIRST stream until both deltas arrive (proves the buffer is +/// non-empty before the re-attach), +/// 2. re-attach (second POST) → 200, +/// 3. read the SECOND stream until both 突发一 + 突发二 replay — these arrive +/// from the snapshot at seq 1, 2 BEFORE any live frame (asserting them +/// here, before the abort, proves the replay came from the buffer not a +/// live re-emission), +/// 4. chat.abort → the run loop emits a terminal `chat_aborted` frame (seq 3) +/// pushed AFTER the re-attach's subscribe, so it arrives via the live +/// broadcast leg, +/// 5. drain until the aborted terminal at seq 3 — verifying seq continuity +/// 1→2→3 across the buffer→broadcast partition. +/// +/// `kill_on_drop` reaps the mock subprocess on runtime teardown; the 5s ceilings +/// protect against the 30s sleep (never reached in practice — the abort +/// finalizes within milliseconds). +#[tokio::test] +async fn duplicate_send_reattaches_with_replay() { + let url = support::spawn_app_with_mock("mock_cc_burst.sh", "cfuse-cc").await; + let client = reqwest::Client::new(); + let body = serde_json::json!({"type":"req","id":"run-dup","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"hi"}]}}); + + // 1. First POST — read its chunks until both deltas are buffered. The burst + // mock emits both lines immediately, so this resolves within + // milliseconds; the 5s ceiling guards against a stalled mock. + let first = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0").json(&body).send().await.unwrap(); + assert_eq!(first.status(), 200); + let mut first_stream = first.bytes_stream(); + let mut first_acc = String::new(); + let first_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if first_acc.contains("突发一") && first_acc.contains("突发二") { break; } + if std::time::Instant::now() > first_deadline { + panic!("first stream did not emit both deltas within 5s: {first_acc}"); + } + match tokio::time::timeout(std::time::Duration::from_millis(500), first_stream.next()).await { + Ok(Some(chunk)) => first_acc.push_str(&String::from_utf8_lossy(&chunk.unwrap())), + Ok(None) => panic!("first stream ended before both deltas: {first_acc}"), + Err(_) => continue, + } + } + // Both deltas are now in the run's buffer (push_raw pushes buffer+broadcast + // under one mutex), so the re-attach's snapshot will contain them. + + // 2. Same id + same body retry → 200 re-attach (not 429, not 409). The + // forwarder snapshots the buffer (seq 1, 2) and subscribes to the + // broadcast BEFORE the response is returned, so any later push (the + // aborted frame) reaches this stream via the live broadcast leg. + let second = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0").json(&body).send().await.unwrap(); + assert_eq!(second.status(), 200); + let mut second_stream = second.bytes_stream(); + let mut second_acc = String::new(); + + // 3. Read the re-attached stream until BOTH buffered deltas replay — these + // arrive from the snapshot at seq 1, 2 BEFORE any live frame. Asserting + // them here (before the abort) proves the replay came from the buffer, + // not a live broadcast re-emission. If the buffer-replay loop in + // `forward_stream` were deleted, this loop would block past the deadline + // (the broadcast is quiet during the 30s sleep) and fail. + let replay_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if second_acc.contains("突发一") && second_acc.contains("突发二") { break; } + if std::time::Instant::now() > replay_deadline { + panic!("re-attached stream did not replay both deltas within 5s: {second_acc}"); + } + match tokio::time::timeout(std::time::Duration::from_millis(500), second_stream.next()).await { + Ok(Some(chunk)) => second_acc.push_str(&String::from_utf8_lossy(&chunk.unwrap())), + Ok(None) => panic!("re-attached stream ended before both deltas replayed: {second_acc}"), + Err(_) => continue, + } + } + let replay_seqs = support::extract_seqs(&second_acc); + assert_eq!(replay_seqs, vec![1, 2], + "buffered replay covers seq 1 and 2 (replay precedes any live frame): {second_acc}"); + + // 4. Abort the run to trigger a terminal frame promptly (the slow mock + // would otherwise block 30s). The aborted frame is pushed AFTER the + // re-attach's subscribe, so it arrives via the live broadcast leg — + // verifying buffer-replay → live-follow continuity. + let _ = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"abort-dup","method":"chat.abort", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}})) + .send().await; + + // 5. Drain until the aborted terminal arrives at seq 3 (5s ceiling — the + // run loop finalizes within milliseconds of the abort). first_stream is + // held until scope end so the broadcast retains a subscriber while the + // aborted frame is pushed; kill_on_drop reaps the mock subprocess. + let abort_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if second_acc.contains("\"state\":\"aborted\"") { break; } + if std::time::Instant::now() > abort_deadline { + panic!("aborted terminal not received within 5s: {second_acc}"); + } + match tokio::time::timeout(std::time::Duration::from_millis(500), second_stream.next()).await { + Ok(Some(chunk)) => second_acc.push_str(&String::from_utf8_lossy(&chunk.unwrap())), + Ok(None) => break, + Err(_) => continue, + } + } + assert!(second_acc.contains("\"state\":\"aborted\""), "aborted terminal: {second_acc}"); + let seqs = support::extract_seqs(&second_acc); + assert_eq!(seqs, vec![1, 2, 3], + "seq continuity 1→2→3 across buffer→broadcast: {second_acc}"); + drop(first_stream); +} + +/// Task 16 — protocol regression: same id, different body → 409 conflict +/// (spec §5). `RunRegistry::begin` returns the existing handle with +/// `matches(fp) == false`, so the handler renders `BridgeError::conflict()`. +/// The slow mock keeps the first run active while the conflicting retry +/// arrives; `kill_on_drop` reclaims the engine subprocess on test exit. +#[tokio::test] +async fn same_id_different_body_conflicts() { + let url = support::spawn_app_with_mock("mock_cc_slow.sh", "cfuse-cc").await; + let client = reqwest::Client::new(); + let mut body = serde_json::json!({"type":"req","id":"run-x","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"hi"}]}}); + let _first = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0").json(&body).send().await.unwrap(); + body["message"]["content"][0]["text"] = serde_json::json!("changed"); + let second = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0").json(&body).send().await.unwrap(); + assert_eq!(second.status(), 409); + let err: serde_json::Value = second.json().await.unwrap(); + assert_eq!(err["error"]["code"], serde_json::json!("conflict")); +} + +/// Task 16 — protocol regression: missing `X-BCN-Protocol-Version: 2.0` +/// header → 400 (spec §5). `handle_chat_send` is the only method that gates on +/// this header; a chat.send without it short-circuits to +/// `BridgeError::invalid_request` after token/provider_id checks pass. +#[tokio::test] +async fn missing_protocol_2_header_rejected() { + let url = support::spawn_app_with_mock("mock_cc.sh", "cfuse-cc").await; + let resp = reqwest::Client::new().post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"r","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"hi"}]}})) + .send().await.unwrap(); + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["error"]["code"], serde_json::json!("invalid_request")); +} + +/// Task 16 — protocol regression: UTF-8 deltas stay intact end-to-end (spec +/// §5/§6). `mock_cc_utf8.sh` emits 40 Chinese `text_delta` lines then a +/// terminal result. `reqwest` `resp.text()` requires the whole body to be +/// valid UTF-8 (a half-character byte slice anywhere would fail); additionally +/// every `data:` line must parse as JSON — the SSE encoder and the cc driver's +/// NDJSON reader must never slice multi-byte sequences. +#[tokio::test] +async fn utf8_chinese_deltas_stay_intact() { + // mock_cc_utf8.sh:逐行吐 40 条中文 delta(每条一个完整 JSON 事件行) + let url = support::spawn_app_with_mock("mock_cc_utf8.sh", "cfuse-cc").await; + let resp = reqwest::Client::new().post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&serde_json::json!({"type":"req","id":"run-u","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"hi"}]}})) + .send().await.unwrap(); + let text = resp.text().await.unwrap(); // text() 要求全程合法 UTF-8 + assert!(text.contains("中文增量"), "missing Chinese delta: {text}"); + // 每个 data 行都是合法 JSON(无半个字符截断) + for line in text.lines().filter_map(|l| l.strip_prefix("data: ")) { + serde_json::from_str::(line).expect("valid json frame"); + } + // 40 条 delta 均应在流中(progress check beyond the contains needle) + let delta_count = text.lines().filter(|l| l.contains("\"deltaText\":\"中文增量\"")).count(); + assert_eq!(delta_count, 40, "expected 40 Chinese deltas, found {delta_count}: {text}"); +} + +/// Task 16 — protocol regression: an oversize single frame becomes a terminal +/// `chat/error`, never an oversize emission (spec §5/§7). `mock_cc_big.sh` +/// emits one `text_delta` with 9,000,000 chars of padding (~9 MiB JSON frame), +/// exceeding `MAX_FRAME_BYTES` (8 MiB). The run loop's `push_frame` catches +/// `FrameError::FrameTooLarge`, emits a bounded `chat_error` terminal instead, +/// and signals termination — no oversize frame reaches the wire. +#[tokio::test] +async fn oversize_single_frame_becomes_chat_error() { + // mock_cc_big.sh:吐一条 >8MiB 的 text_delta + let url = support::spawn_app_with_mock("mock_cc_big.sh", "cfuse-cc").await; + let resp = reqwest::Client::new().post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&serde_json::json!({"type":"req","id":"run-big","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"hi"}]}})) + .send().await.unwrap(); + let text = resp.text().await.unwrap(); + assert!(text.contains("\"state\":\"error\""), "超限 → error 终态: {text}"); + assert!(!text.contains("\"state\":\"final\""), "error path must not also emit final: {text}"); + assert!(text.len() < 9 * 1024 * 1024, "没有超限帧被发出: {}", text.len()); + // The error frame carries the "frame too large" diagnostic from push_frame. + assert!(text.contains("\"errorMessage\":\"frame too large\""), "error cause: {text}"); +} diff --git a/src/bcs/crates/adapters/bridge-provider/tests/fixtures/cc_turn.ndjson b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/cc_turn.ndjson new file mode 100644 index 0000000000..b930c7f32a --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/cc_turn.ndjson @@ -0,0 +1,6 @@ +{"type":"system","subtype":"init","session_id":"cc-sess-1"} +{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"正在"}}} +{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"分析"}}} +{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}} +{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]}} +{"type":"result","subtype":"success","result":"完成了","session_id":"cc-sess-1"} diff --git a/src/bcs/crates/adapters/bridge-provider/tests/fixtures/codex_turn.jsonl b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/codex_turn.jsonl new file mode 100644 index 0000000000..aff0eebb9f --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/codex_turn.jsonl @@ -0,0 +1,6 @@ +{"type":"thread.started","thread_id":"codex-thread-1"} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"正在"}} +{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"排查"}} +{"type":"item.completed","item":{"id":"item_2","type":"reasoning","text":"正在分析根因"}} +{"type":"turn.completed","usage":{"input_tokens":5523,"output_tokens":24}} diff --git a/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc.sh b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc.sh new file mode 100755 index 0000000000..9cf2753ac4 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Mock cfuse --cc engine: read one stdin line (the user message JSON) then +# replay the recorded cc_turn.ndjson stream-json lines to stdout. +IFS= read -r _first +cat "$(dirname "$0")/cc_turn.ndjson" diff --git a/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc_approval.sh b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc_approval.sh new file mode 100755 index 0000000000..878a2ba468 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc_approval.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Mock cfuse --cc engine for the HITL interaction roundtrip (Task 12): +# read the user message, emit a can_use_tool control_request for Bash, wait for +# the engine's control_response on stdin, then emit a terminal result keyed on +# the received behavior. +# +# Uses /bin/echo (external, flushes its stdio buffer on exit) for the +# control_request line so the driver can observe it before this script blocks +# on `read` — a bash `printf` builtin would block-buffer a non-tty stdout and +# deadlock the turn (driver waits for the control_request while the mock waits +# for the control_response). +IFS= read -r _user +/bin/echo '{"type":"control_request","request":{"subtype":"can_use_tool","request_id":"req-1","tool_name":"Bash","input":{"command":"npm run deploy"}}}' +IFS= read -r ctrl +behavior=$(printf '%s\n' "$ctrl" | sed -n 's/.*"behavior":"\([^"]*\)".*/\1/p') +if [ "$behavior" = "allow" ]; then + /bin/echo '{"type":"result","subtype":"success","result":"approved","session_id":"cc-approval-1"}' +else + /bin/echo '{"type":"result","subtype":"success","result":"denied","session_id":"cc-approval-1"}' +fi diff --git a/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc_big.sh b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc_big.sh new file mode 100755 index 0000000000..ba859d0aad --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc_big.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Mock cfuse --cc engine for the oversize-frame regression (Task 16): read one +# stdin line, then emit a single `text_delta` whose text exceeds the 8 MiB SSE +# frame cap (MAX_FRAME_BYTES = 8 * 1024 * 1024). The run loop's `push_frame` +# converts `FrameTooLarge` into a terminal `chat/error` frame — the test +# asserts that `state:"error"` is present, `state:"final"` is absent, and no +# oversize frame reaches the wire (body < 9 MiB). +# +# The 9,000,000-char text is generated inline (`head -c /dev/zero | tr` to +# 'x') and embedded in a single NDJSON line: prefix + 9M 'x' + suffix, with +# no interior newline, so the cc driver reads it as one `stream_event` line +# (codex/cc drivers read NDJSON lines; a 9 MiB single line is fine). +IFS= read -r _user +printf '{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"' +head -c 9000000 /dev/zero | tr '\0' 'x' +printf '"}}}\n' +/bin/echo '{"type":"result","subtype":"success","result":"done","session_id":"cc-big-1"}' diff --git a/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc_burst.sh b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc_burst.sh new file mode 100755 index 0000000000..32e4b2df9f --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc_burst.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Mock cfuse --cc engine for the re-attach buffered-replay regression (Task 16 +# review fix round 1): read one stdin line, emit two `text_delta` lines +# IMMEDIATELY (突发一, 突二), then sleep 30s so the run stays active while a +# same-id retry re-attaches. The two deltas are pushed into the run's buffer +# (seq 1, 2) BEFORE the re-attach, so the re-attached stream's forwarder +# snapshots the buffer and replays them — verifying the buffer-replay leg of +# `forward_stream` (not just the live broadcast). `chat.abort` kills the +# 30s sleep; `kill_on_drop` reaps the subprocess on runtime teardown. +# +# Uses /bin/echo (external, flushes its stdio buffer on exit) so the driver +# observes each delta line before the script blocks on `sleep` — a bash +# `printf` builtin would block-buffer a non-tty stdout and the driver would +# deadlock waiting for the deltas while the mock sleeps. +IFS= read -r _first +/bin/echo '{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"突发一"}}}' +/bin/echo '{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"突发二"}}}' +sleep 30 diff --git a/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc_slow.sh b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc_slow.sh new file mode 100755 index 0000000000..836d7e5adc --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc_slow.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Slow mock cfuse --cc engine: read one stdin line, sleep long enough to keep +# the first chat.send run active, then emit a terminal result. Used by the +# concurrent-send 429 test to guarantee the first run is still in flight when +# the second webhook arrives. +IFS= read -r _first +sleep 30 +printf '{"type":"result","subtype":"success","result":"done","session_id":"sess-1"}\n' diff --git a/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc_utf8.sh b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc_utf8.sh new file mode 100755 index 0000000000..b029d890a0 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_cc_utf8.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Mock cfuse --cc engine for the UTF-8 regression (Task 16): read one stdin +# line (the user message), then emit 40 Chinese `text_delta` stream events +# followed by a terminal `result/success`. Each line is a complete JSON event +# the cc driver maps to a `chat_delta` SSE frame (spec §5/§6). +# +# `resp.text()` must observe valid UTF-8 throughout (no half-character byte +# slicing), and every `data:` line must parse as JSON. Uses /bin/echo +# (external, flushes its stdio buffer on exit) so the driver observes each +# delta line before the script exits — a bash `printf` builtin would +# block-buffer a non-tty stdout and deadlock the turn. +IFS= read -r _user +i=0 +while [ "$i" -lt 40 ]; do + /bin/echo '{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"中文增量"}}}' + i=$((i + 1)) +done +/bin/echo '{"type":"result","subtype":"success","result":"完成","session_id":"cc-utf8-1"}' diff --git a/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_codex.sh b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_codex.sh new file mode 100755 index 0000000000..2cba99eeb9 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_codex.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Mock cfuse --codex engine: take the trailing argv as the prompt and emit +# codex `exec --json` JSONL that echoes each non-blank prompt line as an +# `item.completed`/`agent_message` delta, then `turn.completed`. The CfuseCodex +# driver maps these to chat_delta StreamEvents so the chat.send SSE body carries +# the assembled prompt text (verifying inject prepending end-to-end). +# +# Per Task 13 amendment: emits JSONL (codex exec --json shape), NOT SSE. +is_resume=0 +has_skip_git_repo_check=0 +for arg in "$@"; do + [ "$arg" = "resume" ] && is_resume=1 + [ "$arg" = "--skip-git-repo-check" ] && has_skip_git_repo_check=1 +done +if [ "$is_resume" -eq 1 ] && [ "$has_skip_git_repo_check" -ne 1 ]; then + printf 'Not inside a trusted directory and --skip-git-repo-check was not specified.\n' >&2 + exit 1 +fi +while [ "$#" -gt 1 ]; do shift; done +prompt="$1" +printf '{"type":"thread.started","thread_id":"t-1"}\n' +printf '{"type":"turn.started"}\n' +while IFS= read -r line; do + [ -z "$line" ] && continue + printf '{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"%s"}}\n' "$line" +done <<< "$prompt" +printf '{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}\n' diff --git a/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_codex_app_server.py b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_codex_app_server.py new file mode 100755 index 0000000000..160f8be938 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_codex_app_server.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Minimal Codex app-server JSON-RPC peer for bridge-provider tests.""" + +import json +import sys + + +def send(value: dict) -> None: + print(json.dumps(value, ensure_ascii=False, separators=(",", ":")), flush=True) + + +for raw in sys.stdin: + request = json.loads(raw) + method = request.get("method") + request_id = request.get("id") + params = request.get("params") or {} + + if method == "initialize": + send({"jsonrpc": "2.0", "id": request_id, "result": {"capabilities": {}}}) + elif method == "initialized": + continue + elif method in ("thread/start", "thread/resume"): + # Real Codex may emit unrelated status notifications before the RPC + # response. The bridge must keep reading stdout instead of spinning on + # the first buffered notification forever. + send({"jsonrpc": "2.0", "method": "configWarning", "params": {"summary": "test"}}) + send({"jsonrpc": "2.0", "method": "remoteControl/status/changed", "params": {"status": "disabled"}}) + send({"jsonrpc": "2.0", "id": request_id, "result": {"thread": {"id": "t-1"}}}) + elif method == "turn/start": + turn_id = "turn-1" + send({"jsonrpc": "2.0", "id": request_id, "result": {"turn": {"id": turn_id}}}) + input_items = params.get("input") or [] + text = "\n".join(item.get("text", "") for item in input_items) + send({ + "jsonrpc": "2.0", + "method": "item/started", + "params": { + "threadId": "t-1", + "turnId": turn_id, + "item": { + "type": "commandExecution", + "id": "cmd-1", + "command": "printf tool-output", + "cwd": "/tmp", + }, + }, + }) + send({ + "jsonrpc": "2.0", + "method": "item/commandExecution/outputDelta", + "params": { + "threadId": "t-1", + "turnId": turn_id, + "itemId": "cmd-1", + "delta": "tool-output", + }, + }) + send({ + "jsonrpc": "2.0", + "method": "item/completed", + "params": { + "threadId": "t-1", + "turnId": turn_id, + "item": { + "type": "commandExecution", + "id": "cmd-1", + "command": "printf tool-output", + "cwd": "/tmp", + "status": "completed", + "exitCode": 0, + "durationMs": 3, + "aggregatedOutput": "tool-output", + }, + }, + }) + send({ + "jsonrpc": "2.0", + "method": "item/reasoning/textDelta", + "params": { + "threadId": "t-1", + "turnId": turn_id, + "delta": "先处理工具结果,再回答。", + }, + }) + send({ + "jsonrpc": "2.0", + "method": "item/agentMessage/delta", + "params": {"threadId": "t-1", "turnId": turn_id, "delta": text}, + }) + send({ + "jsonrpc": "2.0", + "method": "turn/completed", + "params": { + "threadId": "t-1", + "turnId": turn_id, + "turn": {"status": "completed"}, + }, + }) diff --git a/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_engine.sh b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_engine.sh new file mode 100755 index 0000000000..d5dfdc00e6 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_engine.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# mock cfuse:按行读 stdin;每读一行回显 "ack:";收到 "quit" 时输出终态并退出 +while IFS= read -r line; do + if [ "$line" = "quit" ]; then + printf '{"type":"result","subtype":"success","result":"done","session_id":"sess-1"}\n' + exit 0 + fi + printf 'ack:%s\n' "$line" +done diff --git a/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_engine_nonblocking_long_line.sh b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_engine_nonblocking_long_line.sh new file mode 100644 index 0000000000..a8d78597ce --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/tests/fixtures/mock_engine_nonblocking_long_line.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Emit one JSONL event through a non-blocking stdout descriptor. This models +# cfuse/codex's long `item.completed` write on hosts with a small pipe buffer. +exec perl -MFcntl -e ' + fcntl(STDOUT, F_SETFL, O_NONBLOCK) or die "set stdout nonblocking: $!"; + my $line = q({"type":"item.completed","item":{"type":"agent_message","text":"}) + . ("x" x 7000) + . q("}}) + . "\n"; + syswrite(STDOUT, $line); +' diff --git a/src/bcs/crates/adapters/bridge-provider/tests/golden_frames.rs b/src/bcs/crates/adapters/bridge-provider/tests/golden_frames.rs new file mode 100644 index 0000000000..82e1c6168d --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/tests/golden_frames.rs @@ -0,0 +1,203 @@ +use bridge_provider::sse::*; +use bcs_protocol::stream::{parse_stream_event, ChatState, StreamEvent, ToolPhase}; +use serde_json::json; + +#[test] +fn encodes_chat_delta_golden() { + let frame = encode_frame( + "chat", + Some(605), + r#"{"state":"delta","deltaText":"查询。","runId":"r-1","seq":605,"ts":1786276303908}"#, + ) + .unwrap(); + let expected = "event: chat\nid: 605\ndata: {\"state\":\"delta\",\"deltaText\":\"查询。\",\"runId\":\"r-1\",\"seq\":605,\"ts\":1786276303908}\n\n"; + assert_eq!(frame, expected); +} + +#[test] +fn encodes_frame_without_id() { + let frame = encode_frame("ping", None, r#"{"ts":1}"#).unwrap(); + assert_eq!(frame, "event: ping\ndata: {\"ts\":1}\n\n"); +} + +#[test] +fn rejects_frame_over_8mib() { + let big = "x".repeat(MAX_FRAME_BYTES); + let data_json = format!(r#"{{"deltaText":"{}"}}"#, big); + let err = encode_frame("chat", None, &data_json).unwrap_err(); + assert!(matches!(err, FrameError::FrameTooLarge(_))); +} + +#[test] +fn rejects_multiline_data() { + let err = encode_frame("chat", None, "{\"ts\":1}\n{\"ts\":2}").unwrap_err(); + assert!(matches!(err, FrameError::MultilineData)); +} + +#[test] +fn heartbeat_is_sse_comment() { + assert_eq!(HEARTBEAT, ": heartbeat\n\n"); +} + +/// 从编码帧中抽出 event 名与 data JSON(测试辅助) +fn split_frame(frame: &str) -> (String, serde_json::Value) { + let mut event = String::new(); + let mut data = String::new(); + for line in frame.lines() { + if let Some(v) = line.strip_prefix("event: ") { + event = v.to_string(); + } + if let Some(v) = line.strip_prefix("data: ") { + data = v.to_string(); + } + } + (event, serde_json::from_str(&data).unwrap()) +} + +#[test] +fn chat_delta_roundtrips_through_bcs_parser() { + let frame = event_to_frame(&chat_delta("r-1", "正在分析"), 1, 100, "r-1").unwrap(); + let (event, data) = split_frame(&frame); + assert_eq!(event, "chat"); + match parse_stream_event(&event, data) { + StreamEvent::Chat(c) => { + assert_eq!(c.state, ChatState::Delta); + assert_eq!(c.delta_text.as_deref(), Some("正在分析")); + assert_eq!(c.seq, Some(1)); + } + other => panic!("expected chat, got {other:?}"), + } +} + +#[test] +fn chat_final_is_full_snapshot_terminal() { + let frame = event_to_frame(&chat_final("r-1", "最终答案".to_string()), 5, 200, "r-1").unwrap(); + let (event, data) = split_frame(&frame); + match parse_stream_event(&event, data.clone()) { + StreamEvent::Chat(c) => { + assert_eq!(c.state, ChatState::Final); + assert_eq!(data["message"]["content"][0]["text"], json!("最终答案")); + } + other => panic!("expected final, got {other:?}"), + } +} + +#[test] +fn tool_result_roundtrips() { + let ev = agent_tool( + "r-1", + bcs_protocol::stream::ToolData { + phase: ToolPhase::Result, + name: Some("exec".into()), + tool_call_id: Some("tc-1".into()), + is_error: Some(false), + exit_code: Some(0), + duration_ms: Some(120), + cwd: None, + args: None, + result: Some(json!({"content":[{"type":"text","text":"ok"}]})), + partial_result: None, + }, + ); + let frame = event_to_frame(&ev, 4, 100, "r-1").unwrap(); + let (event, data) = split_frame(&frame); + match parse_stream_event(&event, data) { + StreamEvent::Agent(a) => match a.data { + bcs_protocol::stream::AgentData::Tool(t) => { + assert_eq!(t.phase, ToolPhase::Result); + assert_eq!(t.tool_call_id.as_deref(), Some("tc-1")); + } + other => panic!("expected tool, got {other:?}"), + }, + other => panic!("expected agent, got {other:?}"), + } +} + +#[test] +fn interaction_requested_exec_roundtrips() { + let ev = interaction_event( + "r-1", + bcs_protocol::stream::InteractionPhase::Requested, + bcs_protocol::stream::InteractionKind::Exec, + "int-1", + json!({"title":"Run command?","command":"npm run deploy", + "options":[{"decision":"allow_once","label":"Allow once"}, + {"decision":"deny","label":"Deny"}]}), + ); + let frame = event_to_frame(&ev, 7, 100, "r-1").unwrap(); + let (event, data) = split_frame(&frame); + assert_eq!(event, "interaction"); + match parse_stream_event(&event, data.clone()) { + StreamEvent::Interaction(i) => { + assert_eq!(i.interaction_id, "int-1"); + assert_eq!(i.kind, bcs_protocol::stream::InteractionKind::Exec); + assert_eq!(data["options"][0]["decision"], json!("allow_once")); + } + other => panic!("expected interaction, got {other:?}"), + } +} + +#[test] +fn forbidden_event_kinds_are_rejected() { + // 每个变体走 event_to_frame 必须返回 FrameError::Unsupported:这是 + // "禁止上线" 契约的直接断言(spec §2 旧 approval/phase 不得用于新接入; + // ping/unknown 由调用方过滤,不可当业务帧编码)。 + use bcs_protocol::stream::{AgentData, AgentEvent, ApprovalData, ApprovalPhase, PhaseData}; + use serde_json::Value; + + fn assert_unsupported(ev: &StreamEvent) { + match event_to_frame(ev, 1, 1, "r") { + Err(FrameError::Unsupported) => {} + other => panic!("expected FrameError::Unsupported, got {other:?}"), + } + } + + // ping 不是业务帧(调用方过滤),不可编码 + let ping = StreamEvent::Ping { ts: None }; + assert_unsupported(&ping); + + // unknown 顶层事件不可编码 + let unknown = StreamEvent::Unknown { event: "mystery".into(), raw: Value::Null }; + assert_unsupported(&unknown); + + // 旧 approval 结构禁止上线(spec:不能用于新接入) + let approval = StreamEvent::Agent(AgentEvent { + run_id: "r".into(), + seq: None, + ts: None, + session_key: None, + data: AgentData::Approval(ApprovalData { + phase: ApprovalPhase::Requested, + kind: Some("exec".into()), + status: None, + approval_id: None, + tool_call_id: None, + questions: None, + answers: None, + }), + raw: Value::Null, + }); + assert_unsupported(&approval); + + // Phase 暂不发 + let phase = StreamEvent::Agent(AgentEvent { + run_id: "r".into(), + seq: None, + ts: None, + session_key: None, + data: AgentData::Phase(PhaseData { from_phase: None, to_phase: None }), + raw: Value::Null, + }); + assert_unsupported(&phase); + + // agent unknown stream 不可编码 + let agent_unknown = StreamEvent::Agent(AgentEvent { + run_id: "r".into(), + seq: None, + ts: None, + session_key: None, + data: AgentData::Unknown { stream: "bogus".into(), raw: Value::Null }, + raw: Value::Null, + }); + assert_unsupported(&agent_unknown); +} diff --git a/src/bcs/crates/adapters/bridge-provider/tests/support/mod.rs b/src/bcs/crates/adapters/bridge-provider/tests/support/mod.rs new file mode 100644 index 0000000000..65a8530856 --- /dev/null +++ b/src/bcs/crates/adapters/bridge-provider/tests/support/mod.rs @@ -0,0 +1,74 @@ +use std::{net::SocketAddr, sync::Arc}; +use bridge_provider::{config::ProviderConfig, webhook, AppState}; + +pub async fn spawn_app(toml_text: &str) -> String { + spawn_app_with_state(toml_text).await.0 +} + +/// 与 [`spawn_app`] 同样起服务,但额外返回共享的 [`AppState`] 句柄,供测试 +/// 直接编排会话状态(例如预置 `engine_session_id`、排空 `pending_injects`)。 +pub async fn spawn_app_with_state(toml_text: &str) -> (String, Arc) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bridge.toml"); + std::fs::write(&path, toml_text).unwrap(); + let config = ProviderConfig::load(&path).unwrap(); + // tempdir 不能 drop:泄漏到测试生命周期结束即可(测试进程退出清理) + std::mem::forget(dir); + let mut cfg = config; + cfg.listen = "127.0.0.1:0".parse::().unwrap(); + let state = Arc::new(AppState::new(cfg)); + let app = webhook::router(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), state) +} + +/// 用指定 mock 脚本作为 cfuse binary 起服务(engine 由参数选择 cc/codex 语义)。 +/// +/// 单 bot `worker-1`,cfuse_bin 指向 `tests/fixtures/{script}`;两个端到端 +/// 测试都经此构造,避免真实 LLM 调用。 +pub async fn spawn_app_with_mock(script: &str, engine: &str) -> String { + spawn_app_with_mock_and_state(script, engine).await.0 +} + +/// [`spawn_app_with_mock`] 的 state-暴露版本:额外返回共享 [`AppState`]。 +pub async fn spawn_app_with_mock_and_state(script: &str, engine: &str) -> (String, Arc) { + let bin = format!("{}/tests/fixtures/{script}", env!("CARGO_MANIFEST_DIR")); + spawn_app_with_state(&format!( + r#" +provider_id = "bridge-1" +listen = "127.0.0.1:0" +bcs_to_provider_token = "tok-b2p" +[[bot]] +provider_bot_ref = "worker-1" +engine = "{engine}" +cwd = "/tmp" +cfuse_bin = "{bin}" +"# + )) + .await +} + +/// 从 SSE 文本抽取所有 data 帧里的 seq 序列(按出现顺序)。非 data 行/非 +/// JSON/无 seq 字段均跳过;用于断言 seq 单调递增。 +pub fn extract_seqs(sse_text: &str) -> Vec { + sse_text + .lines() + .filter_map(|l| l.strip_prefix("data: ")) + .filter_map(|d| serde_json::from_str::(d).ok()) + .filter_map(|v| v["seq"].as_u64()) + .collect() +} + +/// 从 SSE 文本抽取首个 interaction 帧的 `interactionId`。用于端到端 +/// interaction 回环测试:BCS 侧拿到 iid 再调 `interaction.resolve`。 +pub fn extract_first_interaction_id(sse_text: &str) -> String { + sse_text + .lines() + .filter_map(|l| l.strip_prefix("data: ")) + .filter_map(|d| serde_json::from_str::(d).ok()) + .find(|v| v["interactionId"].is_string()) + .and_then(|v| v["interactionId"].as_str().map(str::to_string)) + .expect("interaction requested frame present") +} diff --git a/src/bcs/docs/superpowers/plans/2026-08-31-bridge-provider.md b/src/bcs/docs/superpowers/plans/2026-08-31-bridge-provider.md new file mode 100644 index 0000000000..882ec599c6 --- /dev/null +++ b/src/bcs/docs/superpowers/plans/2026-08-31-bridge-provider.md @@ -0,0 +1,2422 @@ +# Bridge Provider Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 bcs workspace 新增独立服务 `bridge-provider`:实现 BCN Provider 2.0 webhook(SSE 下行),把 `chat.send` 等请求桥接到 cfuse 的 cc/codex 引擎执行。 + +**Architecture:** axum webhook → RunRegistry/SessionStore/InteractionRegistry(进程内存)→ `Engine` trait(`CfuseCc`/`CfuseCodex`,共享 `CliSession` 子进程管道)→ SSE 帧经统一 encoder 输出。引擎事件映射为 `bcs_protocol::stream::StreamEvent`,由 encoder 赋 `seq` 并编码为 Provider 2.0 SSE 帧。 + +**Tech Stack:** Rust(workspace edition)、axum 0.8、tokio、serde/serde_json、bcs-protocol(契约测试用 parser)、tokio-util(CancellationToken)、reqwest(仅测试)。 + +**Spec:** `src/bcs/docs/superpowers/specs/2026-08-31-bridge-provider-design.md` + +## Global Constraints + +- 禁止 `cargo fmt`(项目 CLAUDE.md);只改动本任务需要的行。 +- UTF-8 安全:禁止字节索引切片字符串,一律 `char_indices()`(项目 CLAUDE.md)。 +- 生产代码不新增 `unwrap/expect/panic`(测试代码除外);错误用 `thiserror`。 +- 依赖只允许 workspace 已有共享依赖 + 必要时在 `[workspace.dependencies]` 新增 `tokio-stream`。 +- 构建/测试只用 `cargo test -p bridge-provider`(本 worktree 磁盘受限,禁止全 workspace 构建)。 +- 测试零真实 LLM 调用:引擎一律用 mock 可执行文件替代。 +- `interaction.resolve` 的错误 ACK 形态是 `{"ok":false,"retryable":bool,"error":""}`(error 为字符串),**不要**用通用错误对象形态。 +- SSE 协议约束(spec §2):`seq` 同流单调递增、interaction 必须有 seq;terminal 后不再发帧;`agent/stream:approval` 禁止发送;单帧 ≤ 8 MiB。 +- BCS 无重连/续传:SSE 写失败(无订阅者)即终止 run。 + +## File Structure + +``` +crates/adapters/bridge-provider/ +├── Cargo.toml +├── src/ +│ ├── lib.rs # 模块声明 + 公共 re-export +│ ├── main.rs # binary:加载配置、起 server、优雅退出 +│ ├── config.rs # ProviderConfig / BotConfig / EngineKind +│ ├── error.rs # BridgeError → HTTP status + 线错误体 +│ ├── sse.rs # encode_frame / StreamEvent→帧映射 / 构造器 +│ ├── idempotency.rs # 幂等台账 +│ ├── session.rs # SessionStore(双 id 映射 + pending injects + active_run) +│ ├── interaction.rs # InteractionRegistry(pending/resolve/兜底/失效) +│ ├── run.rs # RunRegistry + run loop(select: 引擎事件/心跳/deadline/abort) +│ ├── webhook.rs # axum router + 5 个 method handler + 校验链 +│ └── engine/ +│ ├── mod.rs # Engine trait / TurnRequest / TurnOutcome / TurnError / build_engine +│ ├── cli.rs # CliSession(spawn/stdin/stdout/kill,kill_on_drop) +│ ├── cfuse_cc.rs # cfuse cc 模式:stream-json ↔ StreamEvent + 控制通道 +│ └── cfuse_codex.rs# cfuse codex 模式:codex SSE ↔ StreamEvent +└── tests/ + ├── fixtures/ # mock cfuse 脚本(bash) + ├── golden_frames.rs # SSE 线格式 golden + bcs-protocol parser 往返 + └── e2e_webhook.rs # 全链路:webhook ↔ mock 引擎 ↔ SSE 消费 +``` + +--- + +### Task 1: Crate scaffold + 配置加载 + +**Files:** +- Create: `crates/adapters/bridge-provider/Cargo.toml` +- Create: `crates/adapters/bridge-provider/src/lib.rs` +- Create: `crates/adapters/bridge-provider/src/config.rs` +- Modify: `Cargo.toml`(workspace 根,members 列表 + 可能新增 tokio-stream) + +**Interfaces:** +- Produces: + - `pub enum EngineKind { CfuseCc, CfuseCodex }`(serde: `"cfuse-cc" | "cfuse-codex"`) + - `pub struct BotConfig { provider_bot_ref: String, engine: EngineKind, model: Option, cwd: PathBuf, permission_mode: Option, cfuse_bin: Option }` + - `pub struct ProviderConfig { provider_id: String, listen: SocketAddr, bcs_to_provider_token: String, bot_runtime_token: Option, bots: Vec }` + - `impl ProviderConfig { pub fn load(path: &Path) -> Result; pub fn bot(&self, provider_bot_ref: &str) -> Option<&BotConfig>; }` + +- [ ] **Step 1: 注册 workspace member + 建 Cargo.toml** + +根 `Cargo.toml` members 的 adapters 段加一行 `"crates/adapters/bridge-provider",`。 + +```toml +[package] +name = "bridge-provider" +description = "BCN Provider 2.0 bridge to local coding engines (cfuse cc/codex)" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +axum = { workspace = true } +bcs-protocol = { workspace = true } +futures = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-stream = { workspace = true } +tokio-util = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +reqwest = { workspace = true } +tempfile = "3" +``` + +若根 `[workspace.dependencies]` 无 `tokio-stream`,加 `tokio-stream = "0.1"`。 + +- [ ] **Step 2: 写失败测试 `config.rs` 的 `#[cfg(test)]`** + +```rust +#[test] +fn loads_provider_config_and_finds_bot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bridge.toml"); + std::fs::write(&path, r#" +provider_id = "bridge-1" +listen = "127.0.0.1:21100" +bcs_to_provider_token = "tok-b2p" + +[[bot]] +provider_bot_ref = "cc-worker" +engine = "cfuse-cc" +model = "sonnet" +cwd = "/tmp" +"#).unwrap(); + let cfg = ProviderConfig::load(&path).unwrap(); + assert_eq!(cfg.provider_id, "bridge-1"); + let bot = cfg.bot("cc-worker").unwrap(); + assert_eq!(bot.engine, EngineKind::CfuseCc); + assert_eq!(bot.model.as_deref(), Some("sonnet")); + assert!(cfg.bot("nope").is_none()); +} + +#[test] +fn rejects_unknown_engine_kind() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bridge.toml"); + std::fs::write(&path, r#" +provider_id = "bridge-1" +listen = "127.0.0.1:21100" +bcs_to_provider_token = "t" +[[bot]] +provider_bot_ref = "x" +engine = "bogus" +cwd = "/tmp" +"#).unwrap(); + assert!(ProviderConfig::load(&path).is_err()); +} +``` + +- [ ] **Step 3: 运行确认失败** + +Run: `cargo test -p bridge-provider config` +Expected: 编译失败(`ProviderConfig` 不存在) + +- [ ] **Step 4: 实现 `config.rs`** + +```rust +use std::{net::SocketAddr, path::{Path, PathBuf}}; +use serde::Deserialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum EngineKind { CfuseCc, CfuseCodex } + +#[derive(Debug, Clone, Deserialize)] +pub struct BotConfig { + pub provider_bot_ref: String, + pub engine: EngineKind, + pub model: Option, + pub cwd: PathBuf, + pub permission_mode: Option, + pub cfuse_bin: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ProviderConfig { + pub provider_id: String, + pub listen: SocketAddr, + pub bcs_to_provider_token: String, + pub bot_runtime_token: Option, + #[serde(rename = "bot")] + pub bots: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("read config: {0}")] + Read(#[from] std::io::Error), + #[error("parse config: {0}")] + Parse(#[from] toml::de::Error), +} + +impl ProviderConfig { + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path)?; + Ok(toml::from_str(&text)?) + } + pub fn bot(&self, provider_bot_ref: &str) -> Option<&BotConfig> { + self.bots.iter().find(|b| b.provider_bot_ref == provider_bot_ref) + } +} +``` + +`lib.rs` 先只放 `pub mod config;`。 + +- [ ] **Step 5: 运行确认通过并提交** + +Run: `cargo test -p bridge-provider config` +Expected: PASS + +```bash +git add Cargo.toml crates/adapters/bridge-provider +git commit -m "feat(bridge-provider): crate scaffold + provider config" +``` + +--- + +### Task 2: SSE 帧编码器(纯函数) + +**Files:** +- Create: `crates/adapters/bridge-provider/src/sse.rs` +- Test: `crates/adapters/bridge-provider/tests/golden_frames.rs` + +**Interfaces:** +- Consumes: `serde_json::Value` +- Produces: + - `pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;` + - `pub const HEARTBEAT: &str = ": heartbeat\n\n";` + - `pub fn encode_frame(event: &str, id: Option, data: &serde_json::Value) -> Result` + - `pub enum FrameError { FrameTooLarge(usize), Json(serde_json::Error) }` + +- [ ] **Step 1: 写失败测试(对齐 spec §10 线上样本形态)** + +```rust +use bridge_provider::sse::{encode_frame, HEARTBEAT, MAX_FRAME_BYTES}; +use serde_json::json; + +#[test] +fn encodes_chat_delta_golden() { + let frame = encode_frame("chat", Some(605), &json!({ + "state":"delta","deltaText":"查询。","runId":"r-1","seq":605,"ts":1786276303908u64 + })).unwrap(); + let expected = "event: chat\nid: 605\ndata: {\"state\":\"delta\",\"deltaText\":\"查询。\",\"runId\":\"r-1\",\"seq\":605,\"ts\":1786276303908}\n\n"; + assert_eq!(frame, expected); +} + +#[test] +fn encodes_frame_without_id() { + let frame = encode_frame("ping", None, &json!({"ts":1})).unwrap(); + assert_eq!(frame, "event: ping\ndata: {\"ts\":1}\n\n"); +} + +#[test] +fn rejects_frame_over_8mib() { + let big = "x".repeat(MAX_FRAME_BYTES); + let err = encode_frame("chat", None, &json!({"deltaText": big})).unwrap_err(); + assert!(matches!(err, bridge_provider::sse::FrameError::FrameTooLarge(_))); +} + +#[test] +fn heartbeat_is_sse_comment() { + assert_eq!(HEARTBEAT, ": heartbeat\n\n"); +} +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cargo test -p bridge-provider --test golden_frames` +Expected: 编译失败(`sse` 模块不存在) + +- [ ] **Step 3: 实现 `sse.rs` 编码部分** + +```rust +use serde_json::Value; + +pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024; +pub const HEARTBEAT: &str = ": heartbeat\n\n"; + +#[derive(Debug, thiserror::Error)] +pub enum FrameError { + #[error("SSE frame too large: {0} bytes")] + FrameTooLarge(usize), + #[error("serialize SSE data: {0}")] + Json(#[from] serde_json::Error), +} + +pub fn encode_frame(event: &str, id: Option, data: &Value) -> Result { + // 单行紧凑 JSON;data 内不出现裸换行(serde_json 会转义) + let data_json = serde_json::to_string(data)?; + let mut frame = String::with_capacity(event.len() + data_json.len() + 24); + frame.push_str("event: "); + frame.push_str(event); + frame.push('\n'); + if let Some(id) = id { + frame.push_str("id: "); + frame.push_str(&id.to_string()); + frame.push('\n'); + } + frame.push_str("data: "); + frame.push_str(&data_json); + frame.push_str("\n\n"); + if frame.len() > MAX_FRAME_BYTES { + return Err(FrameError::FrameTooLarge(frame.len())); + } + Ok(frame) +} +``` + +注意:`frame.len()` 是字节数(`String::len` 即字节长度),这正是 8 MiB 约束的度量单位;不存在逐字节切片问题。 + +- [ ] **Step 4: 运行确认通过** + +Run: `cargo test -p bridge-provider --test golden_frames` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add crates/adapters/bridge-provider +git commit -m "feat(bridge-provider): SSE frame encoder with 8MiB guard" +``` + +--- + +### Task 3: StreamEvent → 线帧映射(往返契约测试) + +**Files:** +- Modify: `crates/adapters/bridge-provider/src/sse.rs` +- Test: `crates/adapters/bridge-provider/tests/golden_frames.rs` + +**Interfaces:** +- Consumes: `bcs_protocol::stream::{StreamEvent, ChatEvent, ChatState, AgentEvent, AgentData, ToolData, ToolPhase, ThinkingData, LifecycleData, InteractionEvent, InteractionKind, InteractionPhase}`(字段均为 pub;emit 侧忽略其 `raw` 字段) +- Produces: + - `pub fn event_to_frame(ev: &StreamEvent, seq: u64, ts: u64, run_id: &str) -> Result` — 把事件转为线帧;**调用方保证 seq 单调**;`Ping` 与 `Unknown` 由调用方过滤,此函数对二者返回 `FrameError::Unsupported` + - 构造器(驱动侧使用,seq 恒为 `None`,由 run loop 赋): + - `pub fn chat_delta(run_id: &str, text: &str) -> StreamEvent` + - `pub fn chat_final(run_id: &str, text: String) -> StreamEvent` + - `pub fn chat_error(run_id: &str, message: &str, kind: Option<&str>) -> StreamEvent` + - `pub fn chat_aborted(run_id: &str, stop_reason: &str) -> StreamEvent` + - `pub fn agent_tool(run_id: &str, data: ToolData) -> StreamEvent` + - `pub fn agent_thinking(run_id: &str, delta: Option, text: Option) -> StreamEvent` + - `pub fn agent_lifecycle(run_id: &str, phase: &str, model: Option) -> StreamEvent` + - `pub fn interaction_event(run_id: &str, phase: InteractionPhase, kind: InteractionKind, interaction_id: &str, extra: Value) -> StreamEvent` + +设计要点(spec §5.2):线格式 camelCase 键(`runId/deltaText/toolCallId`);`final` 的 `message` 是 full snapshot;`ChatEvent/AgentEvent` 不 derive Serialize,故 encoder 手工构造 `Value`(这就是"复用协议类型做语义、encoder 独占线格式"的边界)。 + +- [ ] **Step 1: 写失败测试——golden 帧 + BCS parser 往返** + +```rust +use bcs_protocol::stream::{parse_stream_event, ChatState, StreamEvent, ToolPhase}; +use bridge_provider::sse::*; +use serde_json::json; + +/// 从编码帧中抽出 event 名与 data JSON(测试辅助,复制到测试文件顶部) +fn split_frame(frame: &str) -> (String, serde_json::Value) { + let mut event = String::new(); + let mut data = String::new(); + for line in frame.lines() { + if let Some(v) = line.strip_prefix("event: ") { event = v.to_string(); } + if let Some(v) = line.strip_prefix("data: ") { data = v.to_string(); } + } + (event, serde_json::from_str(&data).unwrap()) +} + +#[test] +fn chat_delta_roundtrips_through_bcs_parser() { + let frame = event_to_frame(&chat_delta("r-1", "正在分析"), 1, 100, "r-1").unwrap(); + let (event, data) = split_frame(&frame); + assert_eq!(event, "chat"); + match parse_stream_event(&event, data) { + StreamEvent::Chat(c) => { + assert_eq!(c.state, ChatState::Delta); + assert_eq!(c.delta_text.as_deref(), Some("正在分析")); + assert_eq!(c.seq, Some(1)); + } + other => panic!("expected chat, got {other:?}"), + } +} + +#[test] +fn chat_final_is_full_snapshot_terminal() { + let frame = event_to_frame(&chat_final("r-1", "最终答案".to_string()), 5, 200, "r-1").unwrap(); + let (event, data) = split_frame(&frame); + match parse_stream_event(&event, data.clone()) { + StreamEvent::Chat(c) => { + assert_eq!(c.state, ChatState::Final); + assert_eq!(data["message"]["content"][0]["text"], json!("最终答案")); + } + other => panic!("expected final, got {other:?}"), + } +} + +#[test] +fn tool_result_roundtrips() { + let ev = agent_tool("r-1", bcs_protocol::stream::ToolData { + phase: ToolPhase::Result, + name: Some("exec".into()), + tool_call_id: Some("tc-1".into()), + is_error: Some(false), + exit_code: Some(0), + duration_ms: Some(120), + cwd: None, + args: None, + result: Some(json!({"content":[{"type":"text","text":"ok"}]})), + partial_result: None, + }); + let frame = event_to_frame(&ev, 4, 100, "r-1").unwrap(); + let (event, data) = split_frame(&frame); + match parse_stream_event(&event, data) { + StreamEvent::Agent(a) => match a.data { + bcs_protocol::stream::AgentData::Tool(t) => { + assert_eq!(t.phase, ToolPhase::Result); + assert_eq!(t.tool_call_id.as_deref(), Some("tc-1")); + } + other => panic!("expected tool, got {other:?}"), + }, + other => panic!("expected agent, got {other:?}"), + } +} + +#[test] +fn interaction_requested_exec_roundtrips() { + let ev = interaction_event( + "r-1", + bcs_protocol::stream::InteractionPhase::Requested, + bcs_protocol::stream::InteractionKind::Exec, + "int-1", + json!({"title":"Run command?","command":"npm run deploy", + "options":[{"decision":"allow_once","label":"Allow once"}, + {"decision":"deny","label":"Deny"}]}), + ); + let frame = event_to_frame(&ev, 7, 100, "r-1").unwrap(); + let (event, data) = split_frame(&frame); + assert_eq!(event, "interaction"); + match parse_stream_event(&event, data.clone()) { + StreamEvent::Interaction(i) => { + assert_eq!(i.interaction_id, "int-1"); + assert_eq!(i.kind, bcs_protocol::stream::InteractionKind::Exec); + assert_eq!(data["options"][0]["decision"], json!("allow_once")); + } + other => panic!("expected interaction, got {other:?}"), + } +} +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cargo test -p bridge-provider --test golden_frames` +Expected: 编译失败(`event_to_frame` 等不存在) + +- [ ] **Step 3: 实现映射(`sse.rs` 追加)** + +```rust +use bcs_protocol::stream::{ + AgentData, AgentEvent, ChatEvent, ChatState, InteractionEvent, InteractionKind, + InteractionPhase, LifecycleData, StreamEvent, ThinkingData, ToolData, +}; +use serde_json::{json, Value}; + +pub fn chat_delta(run_id: &str, text: &str) -> StreamEvent { + StreamEvent::Chat(ChatEvent { + run_id: run_id.into(), seq: None, state: ChatState::Delta, session_key: None, + delta_text: Some(text.into()), stop_reason: None, error_message: None, + error_kind: None, error_code: None, message: None, raw: Value::Null, + }) +} + +pub fn chat_final(run_id: &str, text: String) -> StreamEvent { + let message = json!({"role":"assistant","content":[{"type":"text","text":text}]}); + StreamEvent::Chat(ChatEvent { + run_id: run_id.into(), seq: None, state: ChatState::Final, session_key: None, + delta_text: None, stop_reason: Some("completed".into()), error_message: None, + error_kind: None, error_code: None, message: Some(message), raw: Value::Null, + }) +} + +pub fn chat_error(run_id: &str, message: &str, kind: Option<&str>) -> StreamEvent { + StreamEvent::Chat(ChatEvent { + run_id: run_id.into(), seq: None, state: ChatState::Error, session_key: None, + delta_text: None, stop_reason: None, error_message: Some(message.into()), + error_kind: kind.map(str::to_string), error_code: None, message: None, raw: Value::Null, + }) +} + +pub fn chat_aborted(run_id: &str, stop_reason: &str) -> StreamEvent { + StreamEvent::Chat(ChatEvent { + run_id: run_id.into(), seq: None, state: ChatState::Aborted, session_key: None, + delta_text: None, stop_reason: Some(stop_reason.into()), error_message: None, + error_kind: None, error_code: None, message: None, raw: Value::Null, + }) +} + +pub fn agent_tool(run_id: &str, data: ToolData) -> StreamEvent { + StreamEvent::Agent(AgentEvent { + run_id: run_id.into(), seq: None, ts: None, session_key: None, + data: AgentData::Tool(data), raw: Value::Null, + }) +} + +pub fn agent_thinking(run_id: &str, delta: Option, text: Option) -> StreamEvent { + StreamEvent::Agent(AgentEvent { + run_id: run_id.into(), seq: None, ts: None, session_key: None, + data: AgentData::Thinking(ThinkingData { delta, text }), raw: Value::Null, + }) +} + +pub fn agent_lifecycle(run_id: &str, phase: &str, model: Option) -> StreamEvent { + StreamEvent::Agent(AgentEvent { + run_id: run_id.into(), seq: None, ts: None, session_key: None, + data: AgentData::Lifecycle(LifecycleData { phase: phase.into(), model, agent_mode: None }), + raw: Value::Null, + }) +} + +pub fn interaction_event(run_id: &str, phase: InteractionPhase, kind: InteractionKind, + interaction_id: &str, extra: Value) -> StreamEvent { + StreamEvent::Interaction(InteractionEvent { + run_id: run_id.into(), seq: None, ts: None, session_key: None, + phase, interaction_id: interaction_id.into(), kind, raw: extra, + }) +} + +pub fn event_to_frame(ev: &StreamEvent, seq: u64, ts: u64, run_id: &str) + -> Result +{ + let (event, data) = match ev { + StreamEvent::Chat(c) => { + let mut d = json!({"runId": run_id, "seq": seq, "ts": ts}); + let obj = d.as_object_mut().expect("freshly built object"); + match c.state { + ChatState::Delta => { + obj.insert("state".into(), json!("delta")); + if let Some(t) = &c.delta_text { obj.insert("deltaText".into(), json!(t)); } + } + ChatState::Final => { + obj.insert("state".into(), json!("final")); + if let Some(m) = &c.message { obj.insert("message".into(), m.clone()); } + if let Some(s) = &c.stop_reason { obj.insert("stopReason".into(), json!(s)); } + } + ChatState::Error => { + obj.insert("state".into(), json!("error")); + if let Some(m) = &c.error_message { obj.insert("errorMessage".into(), json!(m)); } + if let Some(k) = &c.error_kind { obj.insert("errorKind".into(), json!(k)); } + } + ChatState::Aborted => { + obj.insert("state".into(), json!("aborted")); + if let Some(s) = &c.stop_reason { obj.insert("stopReason".into(), json!(s)); } + } + } + ("chat", d) + } + StreamEvent::Agent(a) => { + let mut d = json!({"runId": run_id, "seq": seq, "ts": ts}); + let obj = d.as_object_mut().expect("freshly built object"); + match &a.data { + AgentData::Tool(t) => { + obj.insert("stream".into(), json!("tool")); + let v = serde_json::to_value(t)?; + merge(obj, v); + } + AgentData::Thinking(t) => { + obj.insert("stream".into(), json!("thinking")); + let v = serde_json::to_value(t)?; + merge(obj, v); + } + AgentData::Lifecycle(l) => { + obj.insert("stream".into(), json!("lifecycle")); + let v = serde_json::to_value(l)?; + merge(obj, v); + } + // Approval 属旧兼容结构,禁止输出(spec §2);Phase 暂不发 + AgentData::Approval(_) | AgentData::Phase(_) | AgentData::Unknown { .. } => { + return Err(FrameError::Unsupported); + } + } + ("agent", d) + } + StreamEvent::Interaction(i) => { + let mut d = json!({ + "runId": run_id, "seq": seq, "ts": ts, + "phase": match i.phase { + InteractionPhase::Requested => "requested", + InteractionPhase::Resolved => "resolved", + }, + "interactionId": i.interaction_id, + "kind": match i.kind { + InteractionKind::Exec => "exec", + InteractionKind::AskUser => "ask_user", + InteractionKind::ModeSwitch => "mode_switch", + }, + }); + let obj = d.as_object_mut().expect("freshly built object"); + merge(obj, i.raw.clone()); // raw 承载 kind 专有三白名单字段(options/questions/…) + ("interaction", d) + } + StreamEvent::Ping { .. } | StreamEvent::Unknown { .. } => { + return Err(FrameError::Unsupported); + } + }; + encode_frame(event, Some(seq), &data) +} + +fn merge(obj: &mut serde_json::Map, v: Value) { + if let Value::Object(m) = v { + for (k, val) in m { obj.insert(k, val); } + } +} +``` + +`FrameError` 增加变体:`#[error("event kind is not emittable on the wire")] Unsupported`。 + +注意:`AgentEvent`/`ChatEvent` 的 `raw` 字段在 emit 侧无意义,填 `Value::Null`;`ts`/`session_key` 由 encoder 与 run loop 统一填。 + +- [ ] **Step 4: 运行确认通过** + +Run: `cargo test -p bridge-provider --test golden_frames` +Expected: PASS(4 个测试) + +- [ ] **Step 5: 提交** + +```bash +git add crates/adapters/bridge-provider +git commit -m "feat(bridge-provider): StreamEvent to Provider 2.0 wire mapping" +``` + +--- + +### Task 4: 错误类型 + webhook 骨架(校验链 + bot.ping) + +**Files:** +- Create: `crates/adapters/bridge-provider/src/error.rs` +- Create: `crates/adapters/bridge-provider/src/webhook.rs` +- Modify: `crates/adapters/bridge-provider/src/lib.rs` +- Test: `crates/adapters/bridge-provider/tests/e2e_webhook.rs`(本任务先建骨架) + +**Interfaces:** +- Produces: + - `pub struct BridgeError { status: StatusCode, code: &'static str, message: String, retryable: bool }`,构造器:`invalid_request(msg)/unauthorized()/provider_id_mismatch()/bot_not_found(ref)/conflict()/rate_limited()/unsupported_method(method)/unavailable(msg)/timeout()`;`pub fn into_response(self) -> axum::response::Response` + - `pub struct AppState { config: ProviderConfig, … }`(后续任务逐步加字段) + - `pub fn router(state: Arc) -> axum::Router` + - 请求 DTO: + ```rust + pub struct DownstreamRequest { + pub id: String, + pub method: String, + pub to_bot: ToBot, + pub session_id: Option, + pub message: Option, + pub timeout_ms: Option, + pub params: Option, + } + pub struct ToBot { pub provider_id: String, pub provider_bot_ref: String } + ``` + +校验链顺序(spec §5.1,任一不过即返回对应错误,不进入业务): +1. `Authorization: Bearer ` → 401 +2. body `to_bot.provider_id == config.provider_id` → 403 +3. method ∈ 已知集合 → 501 +4. method 级参数校验 → 400 + +- [ ] **Step 1: 写失败测试** + +```rust +// tests/e2e_webhook.rs +use axum::http::{header, StatusCode}; +use serde_json::json; + +mod support; // tests/support/mod.rs:spawn_app(config_toml: &str) -> String(base_url) + +#[tokio::test] +async fn ping_requires_auth_and_matching_provider() { + let url = support::spawn_app(r#" +provider_id = "bridge-1" +listen = "127.0.0.1:0" +bcs_to_provider_token = "tok-b2p" +[[bot]] +provider_bot_ref = "cc-worker" +engine = "cfuse-cc" +cwd = "/tmp" +"#).await; + let client = reqwest::Client::new(); + + // 无 token → 401 + let resp = client.post(format!("{url}/webhook")) + .json(&json!({"type":"req","id":"1","method":"bot.ping", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"cc-worker"}})) + .send().await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["error"]["code"], json!("unauthorized")); + + // provider_id 不匹配 → 403 + let resp = client.post(format!("{url}/webhook")) + .bearer_auth("tok-b2p") + .json(&json!({"type":"req","id":"2","method":"bot.ping", + "to_bot":{"provider_id":"other","provider_bot_ref":"cc-worker"}})) + .send().await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + // 未知 method → 501 + let resp = client.post(format!("{url}/webhook")) + .bearer_auth("tok-b2p") + .json(&json!({"type":"req","id":"3","method":"chat.explode", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"cc-worker"}})) + .send().await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED); + + // ping 正常 → 200 + let resp = client.post(format!("{url}/webhook")) + .bearer_auth("tok-b2p") + .json(&json!({"type":"req","id":"4","method":"bot.ping", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"cc-worker"}})) + .send().await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.json::().await.unwrap()["ok"], json!(true)); +} +``` + +`tests/support/mod.rs`: + +```rust +use std::{net::SocketAddr, sync::Arc}; +use bridge_provider::{config::ProviderConfig, webhook}; + +pub async fn spawn_app(toml_text: &str) -> String { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bridge.toml"); + std::fs::write(&path, toml_text).unwrap(); + let config = ProviderConfig::load(&path).unwrap(); + // tempdir 不能 drop:泄漏到测试生命周期结束即可(测试进程退出清理) + std::mem::forget(dir); + let mut cfg = config; + cfg.listen = "127.0.0.1:0".parse::().unwrap(); + let app = webhook::router(Arc::new(bridge_provider::AppState::new(cfg))); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") +} +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cargo test -p bridge-provider --test e2e_webhook` +Expected: 编译失败 + +- [ ] **Step 3: 实现 `error.rs` + `webhook.rs` 骨架** + +`error.rs`: + +```rust +use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; +use serde_json::json; + +#[derive(Debug)] +pub struct BridgeError { + pub status: StatusCode, + pub code: &'static str, + pub message: String, + pub retryable: bool, +} + +impl BridgeError { + fn new(status: StatusCode, code: &'static str, message: impl Into, retryable: bool) -> Self { + Self { status, code, message: message.into(), retryable } + } + pub fn invalid_request(m: impl Into) -> Self { Self::new(StatusCode::BAD_REQUEST, "invalid_request", m, false) } + pub fn unauthorized() -> Self { Self::new(StatusCode::UNAUTHORIZED, "unauthorized", "invalid token", false) } + pub fn provider_id_mismatch() -> Self { Self::new(StatusCode::FORBIDDEN, "provider_id_mismatch", "provider_id does not match this bridge", false) } + pub fn bot_not_found(r: &str) -> Self { Self::new(StatusCode::NOT_FOUND, "bot_not_found", format!("bot {r} is not registered on this bridge"), false) } + pub fn conflict() -> Self { Self::new(StatusCode::CONFLICT, "conflict", "same idempotency key with different body", false) } + pub fn rate_limited() -> Self { Self::new(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "a run is already active for this session", true) } + pub fn unsupported_method(m: &str) -> Self { Self::new(StatusCode::NOT_IMPLEMENTED, "unsupported_method", format!("method {m} is not supported"), false) } + pub fn unavailable(m: impl Into) -> Self { Self::new(StatusCode::SERVICE_UNAVAILABLE, "unavailable", m, true) } + pub fn timeout() -> Self { Self::new(StatusCode::GATEWAY_TIMEOUT, "timeout", "dependency timed out", true) } +} + +impl IntoResponse for BridgeError { + fn into_response(self) -> Response { + (self.status, Json(json!({ + "ok": false, + "error": { "code": self.code, "message": self.message, "retryable": self.retryable } + }))).into_response() + } +} +``` + +`webhook.rs` 骨架: + +```rust +use std::sync::Arc; +use axum::{extract::State, http::{HeaderMap, StatusCode}, response::Response, routing::post, Json, Router}; +use serde::Deserialize; +use serde_json::{json, Value}; +use crate::{config::ProviderConfig, error::BridgeError}; + +#[derive(Debug, Deserialize)] +pub struct ToBot { pub provider_id: String, pub provider_bot_ref: String } + +#[derive(Debug, Deserialize)] +pub struct DownstreamRequest { + pub id: String, + pub method: String, + pub to_bot: ToBot, + pub session_id: Option, + pub message: Option, + pub from: Option, // {"kind","name","actor_id"};inject 前置注入用 name + pub timeout_ms: Option, + pub params: Option, +} + +pub struct AppState { pub config: ProviderConfig } +impl AppState { pub fn new(config: ProviderConfig) -> Self { Self { config } } } + +pub fn router(state: Arc) -> Router { + Router::new().route("/webhook", post(handle_webhook)).with_state(state) +} + +async fn handle_webhook( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Response { + match dispatch(&state, &headers, &req) { + Ok(resp) => resp, + Err(err) => err.into_response(), + } +} + +fn dispatch(state: &AppState, headers: &HeaderMap, req: &DownstreamRequest) + -> Result +{ + // 1. token + let auth = headers.get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()).unwrap_or_default(); + let expected = format!("Bearer {}", state.config.bcs_to_provider_token); + if auth != expected { return Err(BridgeError::unauthorized()); } + // 2. provider_id + if req.to_bot.provider_id != state.config.provider_id { + return Err(BridgeError::provider_id_mismatch()); + } + // 3. method + match req.method.as_str() { + "bot.ping" => Ok(Json(json!({"ok": true})).into_response()), + "chat.send" | "chat.inject" | "chat.abort" | "interaction.resolve" => { + // 后续任务实现;先返回 503 占位…… + Err(BridgeError::unavailable("not yet implemented")) + } + other => Err(BridgeError::unsupported_method(other)), + } +} + +use axum::response::IntoResponse; +``` + +- [ ] **Step 4: 运行确认通过** + +Run: `cargo test -p bridge-provider --test e2e_webhook` +Expected: PASS(chat.* 方法本任务不测试) + +- [ ] **Step 5: 提交** + +```bash +git add crates/adapters/bridge-provider +git commit -m "feat(bridge-provider): webhook skeleton with auth chain + bot.ping" +``` + +--- + +### Task 5: 幂等台账 + +**Files:** +- Create: `crates/adapters/bridge-provider/src/idempotency.rs` +- Modify: `crates/adapters/bridge-provider/src/webhook.rs`(挂入 AppState,chat.inject/chat.abort 使用) + +**Interfaces:** +- Produces: + - `pub enum IdemDecision { Proceed, Replay(serde_json::Value), Conflict }` + - `pub struct IdempotencyLedger { … }` + - `impl IdempotencyLedger { pub fn new() -> Self; pub fn begin(&self, id: &str, fingerprint: &str) -> IdemDecision; pub fn complete(&self, id: &str, response: serde_json::Value); }` + - `pub fn fingerprint(body: &serde_json::Value) -> String`(对 `DownstreamRequest` 的关键字段做稳定序列化:method/to_bot/session_id/message/params;排除易变字段) + +语义(spec §5.5):同 id 异 fingerprint → `Conflict`(409);同 id 同 fingerprint 且已完成 → `Replay`(直接返回上次响应);同 id 同 fingerprint 进行中 → `Replay({"ok":true})` 幂等应答(inject/abort 的场景不要求重入执行)。`chat.send` 不走本台账(走 RunRegistry,Task 10)。 + +- [ ] **Step 1: 写失败测试(`idempotency.rs` 内 `#[cfg(test)]`)** + +```rust +#[test] +fn dedupes_same_id_same_body_and_conflicts_different_body() { + let ledger = IdempotencyLedger::new(); + assert!(matches!(ledger.begin("id-1", "fp-a"), IdemDecision::Proceed)); + ledger.complete("id-1", serde_json::json!({"ok": true})); + match ledger.begin("id-1", "fp-a") { + IdemDecision::Replay(v) => assert_eq!(v["ok"], serde_json::json!(true)), + _ => panic!("expected replay"), + } + assert!(matches!(ledger.begin("id-1", "fp-b"), IdemDecision::Conflict)); +} + +#[test] +fn in_progress_same_body_replays_ok_ack() { + let ledger = IdempotencyLedger::new(); + assert!(matches!(ledger.begin("id-2", "fp-a"), IdemDecision::Proceed)); + match ledger.begin("id-2", "fp-a") { + IdemDecision::Replay(v) => assert_eq!(v["ok"], serde_json::json!(true)), + _ => panic!("expected replay"), + } +} +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cargo test -p bridge-provider idempotency` +Expected: 编译失败 + +- [ ] **Step 3: 实现** + +```rust +use std::{collections::HashMap, sync::Mutex}; + +pub enum IdemDecision { Proceed, Replay(serde_json::Value), Conflict } + +enum Entry { InProgress { fingerprint: String }, Completed { fingerprint: String, response: serde_json::Value } } + +#[derive(Default)] +pub struct IdempotencyLedger { map: Mutex> } + +impl IdempotencyLedger { + pub fn new() -> Self { Self::default() } + + pub fn begin(&self, id: &str, fingerprint: &str) -> IdemDecision { + let mut map = self.map.lock().unwrap_or_else(|p| p.into_inner()); + match map.get(id) { + Some(Entry::InProgress { fingerprint: f }) if f == fingerprint => + IdemDecision::Replay(serde_json::json!({"ok": true})), + Some(Entry::Completed { fingerprint: f, response }) if f == fingerprint => + IdemDecision::Replay(response.clone()), + Some(_) => IdemDecision::Conflict, + None => { + map.insert(id.to_string(), Entry::InProgress { fingerprint: fingerprint.to_string() }); + IdemDecision::Proceed + } + } + } + + pub fn complete(&self, id: &str, response: serde_json::Value) { + let mut map = self.map.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(Entry::InProgress { fingerprint }) = map.get(id) { + let fingerprint = fingerprint.clone(); + map.insert(id.to_string(), Entry::Completed { fingerprint, response }); + } + } +} + +pub fn fingerprint(parts: &[&str]) -> String { + // 稳定拼接;调用方传入已选定的关键字段,避免引入哈希依赖 + parts.join("\u{1f}") +} +``` + +- [ ] **Step 4: 运行确认通过并提交** + +Run: `cargo test -p bridge-provider idempotency` + +```bash +git add crates/adapters/bridge-provider +git commit -m "feat(bridge-provider): idempotency ledger" +``` + +--- + +### Task 6: SessionStore(双 id 映射 + pending injects + active_run) + +**Files:** +- Create: `crates/adapters/bridge-provider/src/session.rs` +- Modify: `crates/adapters/bridge-provider/src/webhook.rs`(AppState 加 `sessions: SessionStore`) + +**Interfaces:** +- Produces: + - `pub struct InjectedMessage { pub run_id: String, pub from_name: Option, pub text: String }` + - `pub struct SessionMapping { pub engine_session_id: Option, pub pending_injects: Vec, pub active_run: Option }` + - `pub struct SessionStore { … }`,方法: + - `pub async fn mapping(&self, bot: &str, bcs_session: &str) -> SessionMapping`(克隆快照;不存在返回默认) + - `pub async fn set_engine_session_id(&self, bot: &str, bcs_session: &str, engine_session_id: &str)` + - `pub async fn add_inject(&self, bot: &str, bcs_session: &str, msg: InjectedMessage)` + - `pub async fn take_pending_injects(&self, bot: &str, bcs_session: &str) -> Vec` + - `pub async fn try_start_run(&self, bot: &str, bcs_session: &str, run_id: &str) -> Result<(), SessionBusy>` + - `pub async fn finish_run(&self, bot: &str, bcs_session: &str, run_id: &str)` + - `pub async fn active_run(&self, bot: &str, bcs_session: &str) -> Option` + +- [ ] **Step 1: 写失败测试** + +```rust +#[tokio::test] +async fn dual_id_mapping_and_run_exclusion() { + let store = SessionStore::new(); + let m = store.mapping("bot-a", "s-1").await; + assert!(m.engine_session_id.is_none()); + + store.set_engine_session_id("bot-a", "s-1", "engine-sess-9").await; + assert_eq!(store.mapping("bot-a", "s-1").await.engine_session_id.as_deref(), + Some("engine-sess-9")); + // 另一个 bcs session 不受影响 + assert!(store.mapping("bot-a", "s-2").await.engine_session_id.is_none()); + + store.try_start_run("bot-a", "s-1", "run-1").await.unwrap(); + assert!(store.try_start_run("bot-a", "s-1", "run-2").await.is_err()); + store.finish_run("bot-a", "s-1", "run-1").await; + store.try_start_run("bot-a", "s-1", "run-2").await.unwrap(); +} + +#[tokio::test] +async fn pending_injects_fifo_drain() { + let store = SessionStore::new(); + store.add_inject("b", "s", InjectedMessage{ run_id: "i1".into(), from_name: None, text: "m1".into() }).await; + store.add_inject("b", "s", InjectedMessage{ run_id: "i2".into(), from_name: Some("张三".into()), text: "m2".into() }).await; + let drained = store.take_pending_injects("b", "s").await; + assert_eq!(drained.len(), 2); + assert_eq!(drained[0].text, "m1"); + assert!(store.take_pending_injects("b", "s").await.is_empty()); +} +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cargo test -p bridge-provider session` +Expected: 编译失败 + +- [ ] **Step 3: 实现 `session.rs`** + +```rust +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, Clone)] +pub struct InjectedMessage { pub run_id: String, pub from_name: Option, pub text: String } + +#[derive(Debug, Clone, Default)] +pub struct SessionMapping { + pub engine_session_id: Option, + pub pending_injects: Vec, + pub active_run: Option, +} + +#[derive(Debug, thiserror::Error)] +#[error("session already has an active run")] +pub struct SessionBusy; + +type Key = (String, String); // (provider_bot_ref, bcs_session_id) + +#[derive(Clone, Default)] +pub struct SessionStore { map: Arc>> } + +impl SessionStore { + pub fn new() -> Self { Self::default() } + + pub async fn mapping(&self, bot: &str, s: &str) -> SessionMapping { + self.map.read().await.get(&(bot.into(), s.into())).cloned().unwrap_or_default() + } + + pub async fn set_engine_session_id(&self, bot: &str, s: &str, engine_id: &str) { + self.map.write().await.entry((bot.into(), s.into())) + .or_default().engine_session_id = Some(engine_id.into()); + } + + pub async fn add_inject(&self, bot: &str, s: &str, msg: InjectedMessage) { + self.map.write().await.entry((bot.into(), s.into())) + .or_default().pending_injects.push(msg); + } + + pub async fn take_pending_injects(&self, bot: &str, s: &str) -> Vec { + let mut map = self.map.write().await; + match map.get_mut(&(bot.into(), s.into())) { + Some(m) => std::mem::take(&mut m.pending_injects), + None => Vec::new(), + } + } + + pub async fn try_start_run(&self, bot: &str, s: &str, run_id: &str) -> Result<(), SessionBusy> { + let mut map = self.map.write().await; + let m = map.entry((bot.into(), s.into())).or_default(); + if m.active_run.is_some() { return Err(SessionBusy); } + m.active_run = Some(run_id.into()); + Ok(()) + } + + pub async fn finish_run(&self, bot: &str, s: &str, run_id: &str) { + let mut map = self.map.write().await; + if let Some(m) = map.get_mut(&(bot.into(), s.into())) { + if m.active_run.as_deref() == Some(run_id) { m.active_run = None; } + } + } + + pub async fn active_run(&self, bot: &str, s: &str) -> Option { + self.map.read().await.get(&(bot.into(), s.into())) + .and_then(|m| m.active_run.clone()) + } +} +``` + +`AppState` 增加 `pub sessions: SessionStore`,`AppState::new` 里初始化。 + +- [ ] **Step 4: 运行确认通过并提交** + +Run: `cargo test -p bridge-provider session` + +```bash +git add crates/adapters/bridge-provider +git commit -m "feat(bridge-provider): session store with dual-id mapping" +``` + +--- + +### Task 7: CliSession(子进程管道) + +**Files:** +- Create: `crates/adapters/bridge-provider/src/engine/mod.rs` +- Create: `crates/adapters/bridge-provider/src/engine/cli.rs` +- Create: `crates/adapters/bridge-provider/tests/fixtures/mock_engine.sh` + +**Interfaces:** +- Produces: + - `pub struct CliSession { … }` + - `impl CliSession { pub async fn spawn(bin: &Path, args: &[String], cwd: &Path, env: &[(String, String)]) -> std::io::Result; pub async fn write_line(&mut self, line: &str) -> std::io::Result<()>; pub async fn next_line(&mut self) -> std::io::Result>; pub async fn kill(&mut self); }` + - spawn 必须 `kill_on_drop(true)`(进程随 bridge 退出/句柄释放被回收,防 zombie) + +- [ ] **Step 1: 写 mock 引擎脚本 + 失败测试** + +`tests/fixtures/mock_engine.sh`(echo 服务:读 stdin 行、原样写回,再逐行吐两个事件并等 EOF): + +```bash +#!/usr/bin/env bash +# mock cfuse:按行读 stdin;每读一行回显 "ack:";收到 "quit" 时输出终态并退出 +while IFS= read -r line; do + if [ "$line" = "quit" ]; then + printf '{"type":"result","subtype":"success","result":"done","session_id":"sess-1"}\n' + exit 0 + fi + printf 'ack:%s\n' "$line" +done +``` + +测试(`cli.rs` 内 `#[cfg(test)]`): + +```rust +#[tokio::test] +async fn cli_session_echo_and_kill() { + let mut cli = CliSession::spawn( + std::path::Path::new("bash"), + &["tests/fixtures/mock_engine.sh".to_string()], + std::path::Path::new("."), + &[], + ).await.unwrap(); + cli.write_line("hello").await.unwrap(); + let line = cli.next_line().await.unwrap().unwrap(); + assert_eq!(line, "ack:hello"); + cli.kill().await; +} +``` + +(注:集成测试 cwd 是 crate 根;若失败用 `env!("CARGO_MANIFEST_DIR")` 拼绝对路径。) + +- [ ] **Step 2: 运行确认失败** + +Run: `cargo test -p bridge-provider engine::cli` +Expected: 编译失败 + +- [ ] **Step 3: 实现 `engine/cli.rs`** + +```rust +use std::path::Path; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, Command}; + +pub struct CliSession { + child: Child, + stdin: ChildStdin, + stdout: BufReader, +} + +impl CliSession { + pub async fn spawn(bin: &Path, args: &[String], cwd: &Path, env: &[(String, String)]) + -> std::io::Result + { + let mut cmd = Command::new(bin); + cmd.args(args) + .current_dir(cwd) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + for (k, v) in env { cmd.env(k, v); } + let mut child = cmd.spawn()?; + let stdin = child.stdin.take().ok_or_else(|| io_err("stdin not piped"))?; + let stdout = child.stdout.take().ok_or_else(|| io_err("stdout not piped"))?; + let mut stderr = child.stderr.take().ok_or_else(|| io_err("stderr not piped"))?; + tokio::spawn(async move { + let mut reader = BufReader::new(&mut stderr); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => break, + Ok(_) => tracing::debug!(target: "bridge_provider::engine", stderr = line.trim_end()), + } + } + }); + Ok(Self { child, stdin, stdout: BufReader::new(stdout) }) + } + + pub async fn write_line(&mut self, line: &str) -> std::io::Result<()> { + self.stdin.write_all(line.as_bytes()).await?; + self.stdin.write_all(b"\n").await?; + self.stdin.flush().await + } + + pub async fn next_line(&mut self) -> std::io::Result> { + let mut line = String::new(); + let n = self.stdout.read_line(&mut line).await?; + if n == 0 { return Ok(None); } + Ok(Some(line.trim_end_matches(['\n', '\r']).to_string())) + } + + pub async fn kill(&mut self) { + if let Err(e) = self.child.start_kill() { + tracing::debug!(target: "bridge_provider::engine", "kill failed: {e}"); + } + let _ = self.child.wait().await; + } +} + +fn io_err(msg: &'static str) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::BrokenPipe, msg) +} +``` + +`engine/mod.rs` 先只放 `pub mod cli;`。 + +- [ ] **Step 4: 运行确认通过并提交** + +Run: `cargo test -p bridge-provider engine::cli` + +```bash +git add crates/adapters/bridge-provider +git commit -m "feat(bridge-provider): CliSession subprocess plumbing" +``` + +--- + +### Task 8: Engine trait + 工厂 + +**Files:** +- Modify: `crates/adapters/bridge-provider/src/engine/mod.rs` + +**Interfaces:** +- Consumes: `config::{EngineKind, BotConfig}`、`session::SessionStore`、`interaction::InteractionRegistry`(Task 12 才创建;本任务先把参数类型定义为 `crate::interaction::InteractionRegistry` 的前置引用——为避免循环依赖,本任务先定义 trait 对象里不带 interaction;Task 12 再扩展 `TurnRequest`) +- Produces: + ```rust + pub struct TurnRequest { + pub run_id: String, // = BCS 下游 body id;帧 runId 用它 + pub prompt: String, + pub engine_session_id: Option, + pub cwd: PathBuf, + pub model: Option, + pub cfuse_bin: PathBuf, + pub permission_mode: Option, + } + pub struct TurnOutcome { pub engine_session_id: Option, pub final_text: Option } + pub enum TurnError { Spawn(std::io::Error), EngineExited(String), Aborted, Protocol(String) } + + #[async_trait::async_trait] + pub trait Engine: Send + Sync { + fn kind(&self) -> EngineKind; + async fn run_turn(&self, req: TurnRequest, + events: tokio::sync::mpsc::Sender, + abort: tokio_util::sync::CancellationToken) + -> Result; + } + + pub fn build_engine(bot: &BotConfig) -> std::sync::Arc + ``` + +- [ ] **Step 1: 写失败测试(fake engine 编译锚点)** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use bcs_protocol::stream::StreamEvent; + + struct FakeEngine; + #[async_trait::async_trait] + impl Engine for FakeEngine { + fn kind(&self) -> EngineKind { EngineKind::CfuseCc } + async fn run_turn(&self, req: TurnRequest, + events: tokio::sync::mpsc::Sender, + _abort: tokio_util::sync::CancellationToken) + -> Result { + let _ = events.send(crate::sse::chat_delta(&req.run_id, "fake")).await; + Ok(TurnOutcome { engine_session_id: Some("e-1".into()), final_text: Some("done".into()) }) + } + } + + #[tokio::test] + async fn fake_engine_emits_delta() { + let (tx, mut rx) = tokio::sync::mpsc::channel(8); + let engine = FakeEngine; + let req = TurnRequest { + run_id: "r-1".into(), prompt: "hi".into(), engine_session_id: None, + cwd: ".".into(), model: None, cfuse_bin: "cfuse".into(), permission_mode: None, + }; + let outcome = engine.run_turn(req, tx, tokio_util::sync::CancellationToken::new()).await.unwrap(); + assert_eq!(outcome.engine_session_id.as_deref(), Some("e-1")); + assert!(rx.recv().await.is_some()); + } +} +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cargo test -p bridge-provider engine` +Expected: 编译失败(trait 未定义) + +- [ ] **Step 3: 实现 trait/类型/工厂骨架** + +`engine/mod.rs` 写入上述 `TurnRequest/TurnOutcome/TurnError/Engine`。`build_engine` 本任务先返回一个内部 `StubEngine`(`run_turn` 立即返回 `Err(TurnError::EngineExited("engine not wired".into()))`),Task 9/10 完成后替换为 `CfuseCc::new(bin)` / `CfuseCodex::new(bin)`——**禁止用 `unimplemented!`**(生产代码不 panic)。 + +- [ ] **Step 4: 运行确认通过** + +Run: `cargo test -p bridge-provider engine` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add crates/adapters/bridge-provider +git commit -m "feat(bridge-provider): Engine trait and turn types" +``` + +(本任务故意薄:类型对齐是目的。) + +--- + +### Task 9: CfuseCc 驱动(stream-json ↔ StreamEvent) + +**Files:** +- Create: `crates/adapters/bridge-provider/src/engine/cfuse_cc.rs` +- Create: `crates/adapters/bridge-provider/tests/fixtures/cc_turn.ndjson`(录制的协议形状样例) +- Modify: `crates/adapters/bridge-provider/src/engine/mod.rs`(`build_engine` 接上) + +**Interfaces:** +- Consumes: `engine/mod.rs` 的 `Engine/TurnRequest/TurnOutcome/TurnError`、`cli::CliSession`、`sse` 构造器 +- Produces: + - `pub struct CfuseCc { bin: PathBuf }`,`impl Engine for CfuseCc` + - `pub(crate) fn map_cc_line(line: &str, run_id: &str) -> CcMap`(纯函数,便于测试): + `enum CcMap { Events(Vec), SessionId(String), Final(String), Ignore, Malformed }` + +引擎调用形态(对齐 aix-relay `codefuse_direct_args`,spec §4.2): + +```text +cfuse --cc --output-format stream-json --verbose --input-format stream-json + --include-partial-messages + [--permission-mode ] [--resume ] [--model ] +``` + +启动后立刻向 stdin 写一条 user 消息(claude stream-json 输入格式): + +```json +{"type":"user","message":{"role":"user","content":[{"type":"text","text":""}]}} +``` + +事件映射表(cc stream-json → StreamEvent): + +| cc 事件 | 映射 | +| --- | --- | +| `{"type":"system","subtype":"init","session_id":…}` | `CcMap::SessionId` | +| `{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":…}}}` | `chat_delta` | +| `{"type":"assistant","message":{"content":[{"type":"tool_use","id","name","input"}]}}` | `agent_tool(Start, name, toolCallId=id, args=input)` | +| `{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id","content"}]}}` | `agent_tool(Result, toolCallId=tool_use_id, result=…)` | +| `{"type":"result","subtype":"success","result":…}` | `CcMap::Final(text)` | +| `{"type":"result","subtype":"error*"}` / 非零退出 | `TurnError::EngineExited` | +| `{"type":"control_request","request":{"subtype":"can_use_tool",…}}` | Task 12 接线;本任务先映射为 `agent_thinking`(占位行为会产生一条可观测 thinking 事件,但绝不发 `stream:approval`) | + +- [ ] **Step 1: 录制 fixture + 写失败测试** + +`tests/fixtures/cc_turn.ndjson`(逐行): + +```json +{"type":"system","subtype":"init","session_id":"cc-sess-1"} +{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"正在"}}} +{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"分析"}}} +{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}} +{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]}} +{"type":"result","subtype":"success","result":"完成了","session_id":"cc-sess-1"} +``` + +测试: + +```rust +#[test] +fn maps_cc_ndjson_turn() { + let lines: Vec = std::fs::read_to_string( + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/cc_turn.ndjson")) + .unwrap().lines().map(str::to_string).collect(); + let mut session_id = None; + let mut deltas = String::new(); + let mut tools = 0; + let mut final_text = None; + for line in &lines { + match map_cc_line(line, "r-1") { + CcMap::SessionId(s) => session_id = Some(s), + CcMap::Events(events) => for ev in events { + match ev { + StreamEvent::Chat(c) if c.state == ChatState::Delta => + deltas.push_str(&c.delta_text.unwrap()), + StreamEvent::Agent(_) => tools += 1, + _ => {} + } + }, + CcMap::Final(text) => final_text = Some(text), + CcMap::Ignore | CcMap::Malformed => {} + } + } + assert_eq!(session_id.as_deref(), Some("cc-sess-1")); + assert_eq!(deltas, "正在分析"); + assert_eq!(tools, 2); + assert_eq!(final_text.as_deref(), Some("完成了")); +} +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cargo test -p bridge-provider cfuse_cc` +Expected: 编译失败(`map_cc_line` 不存在) + +- [ ] **Step 3: 实现 `map_cc_line` + `CfuseCc::run_turn`** + +实现要点:`map_cc_line` 按上表逐类解析(`serde_json::from_str::` 后按 `type`/`subtype` 分派;无法识别 → `CcMap::Ignore`,JSON 非法 → `CcMap::Malformed`)。`run_turn`:`CliSession::spawn` → 写 user 消息行 → 关键循环如下。EOF 无 final → `TurnError::EngineExited`;abort → `cli.kill()`。`TurnError` 增加变体 `Io(#[from] std::io::Error)`。`build_engine` 在此接上:`EngineKind::CfuseCc => Arc::new(CfuseCc::new(bot.cfuse_bin.clone().unwrap_or_else(|| "cfuse".into())))`。 + +`run_turn` 关键循环: + +```rust +loop { + tokio::select! { + _ = abort.cancelled() => { cli.kill().await; return Err(TurnError::Aborted); } + line = cli.next_line() => { + let Some(line) = line.map_err(TurnError::Io)? else { + return Err(TurnError::EngineExited("stdout EOF before result".into())); + }; + match map_cc_line(&line, &req.run_id) { + CcMap::SessionId(s) => engine_session_id = Some(s), + CcMap::Events(evs) => for ev in evs { + if events.send(ev).await.is_err() { cli.kill().await; return Err(TurnError::Aborted); } + }, + CcMap::Final(text) => return Ok(TurnOutcome { engine_session_id, final_text: Some(text) }), + CcMap::Ignore | CcMap::Malformed => {} + } + } + } +} +``` + +- [ ] **Step 4: 运行确认通过** + +Run: `cargo test -p bridge-provider cfuse_cc` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add crates/adapters/bridge-provider +git commit -m "feat(bridge-provider): CfuseCc driver mapping claude stream-json" +``` + +--- + +### Task 10: CfuseCodex 驱动(codex SSE ↔ StreamEvent) + +**Files:** +- Create: `crates/adapters/bridge-provider/src/engine/cfuse_codex.rs` +- Create: `crates/adapters/bridge-provider/tests/fixtures/codex_turn.sse` +- Modify: `crates/adapters/bridge-provider/src/engine/mod.rs` + +**Interfaces:** +- Produces: + - `pub struct CfuseCodex { bin: PathBuf }`,`impl Engine` + - `pub(crate) fn map_codex_block(event: &str, data: &str, run_id: &str) -> CodexMap`:`enum CodexMap { Events(Vec), Final(String), Failed(String), Ignore }` + +codex 输出为 SSE 帧(`event:/data:` 空行分隔),CliSession 需按块读:本任务给 `cli.rs` 加 `pub async fn next_sse_block(&mut self) -> std::io::Result>`(聚合到空行)。 + +映射表(对齐 aix-relay `codefuse_codex.rs` 测试样本): + +| codex 事件 | 映射 | +| --- | --- | +| `response.output_text.delta` `{"delta":…}` | `chat_delta` | +| `response.completed` | `CodexMap::Final(累计文本)` | +| `response.failed` / `error` | `CodexMap::Failed(脱敏 message)` | + +**实现前置调研步骤(必做)**:读 `~/workspace/aix-engine-workspace/crates/relay/src/runtime/codefuse_codex.rs` 的 spawn 参数构造与会话 resume 方式(搜索 `Command`/`resume`/`session`),把真实 cfuse codex 调用参数与本驱动对齐;若 cfuse codex 模式不支持 `--resume` 等价物,则 `engine_session_id` 恒为 `None` 并在代码注释注明限制(spec 允许:会话上下文由引擎 transcript 保证的前提不成立时,回退为"每次新会话 + pending injects 前置")。 + +> **执行期修正(controller ruling,已实测验证)**:`cfuse --codex` 是 codex CLI 透传;真实输出形态是 `codex exec --json` 的 **JSONL**(`thread.started`/`turn.started`/`item.completed{agent_message,reasoning}`/`turn.completed`),**不是** SSE。resume 用 `codex exec resume [prompt]`(engine session id = thread_id)。映射:`thread.started`→SessionId;`agent_message`→chat_delta;`reasoning`→agent_thinking;`turn.completed`→Final(累计文本);`turn.failed`/`error`→Failed。上方 SSE 映射表与 fixture 形态以本修正为准。 + +- [ ] **Step 1: 录制 fixture + 写失败测试** + +`tests/fixtures/codex_turn.sse`: + +```text +event: response.output_text.delta +data: {"type":"response.output_text.delta","delta":"正在"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","delta":"排查"} + +event: response.completed +data: {"type":"response.completed"} + +``` + +测试(`cfuse_codex.rs` 内 `#[cfg(test)]`): + +```rust +#[test] +fn maps_codex_sse_turn() { + let text = std::fs::read_to_string( + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/codex_turn.sse")).unwrap(); + let mut deltas = String::new(); + let mut final_seen = false; + for block in text.split("\n\n").filter(|b| !b.trim().is_empty()) { + let mut event = String::new(); + let mut data = String::new(); + for line in block.lines() { + if let Some(v) = line.strip_prefix("event: ") { event = v.to_string(); } + if let Some(v) = line.strip_prefix("data: ") { data = v.to_string(); } + } + match map_codex_block(&event, &data, "r-1") { + CodexMap::Events(evs) => for ev in evs { + if let StreamEvent::Chat(c) = ev { deltas.push_str(&c.delta_text.unwrap_or_default()); } + }, + CodexMap::Final(_) => final_seen = true, + CodexMap::Failed(_) | CodexMap::Ignore => {} + } + } + assert_eq!(deltas, "正在排查"); + assert!(final_seen); +} + +#[test] +fn maps_codex_failure() { + match map_codex_block("response.failed", + "{\"type\":\"response.failed\",\"error\":{\"message\":\"boom\"}}", "r-1") { + CodexMap::Failed(msg) => assert!(msg.contains("boom")), + _ => panic!("expected Failed"), + } +} +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cargo test -p bridge-provider cfuse_codex` +Expected: 编译失败 + +- [ ] **Step 3: 实现 `next_sse_block` + mapper + `run_turn`** + +`cli.rs` 增加: + +```rust +/// 读取一个 SSE 块(以空行分隔),返回 (event, data)。EOF 且无残留 → Ok(None)。 +pub async fn next_sse_block(&mut self) -> std::io::Result> { + let mut event = String::new(); + let mut data_lines: Vec = Vec::new(); + let mut saw_any = false; + loop { + match self.next_line().await? { + None => return Ok(saw_any.then(|| (event, data_lines.join("\n")))), + Some(line) if line.is_empty() => { + return Ok(if saw_any { Some((event, data_lines.join("\n"))) } else { None }); + } + Some(line) => { + saw_any = true; + if let Some(v) = line.strip_prefix("event: ") { event = v.to_string(); } + if let Some(v) = line.strip_prefix("data: ") { data_lines.push(v.to_string()); } + } + } + } +} +``` + +`run_turn`:prompt 经 argv 或 stdin 传入(以前置调研结论为准);逐 block map → `events.send`;`Final` → 返回 `TurnOutcome`;EOF 无 terminal → `TurnError::EngineExited`。 + +- [ ] **Step 4: 运行确认通过** + +Run: `cargo test -p bridge-provider cfuse_codex` + +- [ ] **Step 5: 提交** + +```bash +git add crates/adapters/bridge-provider +git commit -m "feat(bridge-provider): CfuseCodex driver mapping codex SSE" +``` + +--- + +### Task 11: RunRegistry + run loop + chat.send 端到端 + +**Files:** +- Create: `crates/adapters/bridge-provider/src/run.rs` +- Modify: `crates/adapters/bridge-provider/src/webhook.rs`(chat.send handler) +- Test: `crates/adapters/bridge-provider/tests/e2e_webhook.rs` + +**Interfaces:** +- Consumes: 此前全部任务 +- Produces: + - `pub struct RunRegistry { … }` + - `pub fn begin(&self, run_id: &str) -> RunHandle`(创建 buffer/broadcast/abort token;同 id 已存在 → 返回既有 handle 用于重挂判断) + - `pub fn get(&self, run_id: &str) -> Option` + - `pub fn finish(&self, run_id: &str)`(标记 terminal,buffer 保留进 grace TTL,由 lazy sweep 清理) + - `pub struct RunHandle { pub abort: CancellationToken, tx: broadcast::Sender, buffer: Arc>>, terminal: Arc }` + - `pub fn spawn_run(state: Arc, req: DownstreamRequest, bot: BotConfig) -> impl Stream`:驱动整个 turn 并把帧推入 broadcast+buffer + +run loop(spec §6.2/§6.4,核心 select): + +```rust +let mut seq: u64 = 0; +let mut heartbeat = tokio::time::interval(Duration::from_secs(20)); +let deadline = tokio::time::sleep(Duration::from_millis(timeout_ms.saturating_sub(30_000))); +tokio::pin!(deadline); +loop { + tokio::select! { + _ = &mut deadline => { + push(chat_error(&run_id, "run deadline exceeded", Some("deadline"))); + break; + } + _ = heartbeat.tick() => { push_raw(HEARTBEAT); } + ev = events_rx.recv() => { + match ev { + Some(StreamEvent::Chat(c)) if c.state == ChatState::Final => { push_ev; break; } + Some(ev) => push_ev, + None => { push(chat_error(&run_id, "engine exited without terminal", Some("runtime_error"))); break; } + } + } + } +} +// push 时:seq += 1;event_to_frame(ev, seq, now_ms, run_id) → buffer.push + tx.send; +// tx.send 返回 Err(无订阅者)= BCS 断连 → abort 引擎、收尾退出(spec 修正项:写失败即杀) +``` + +handler 流程(chat.send): + +1. 校验 `X-BCN-Protocol-Version: 2.0`(否则 400)、`message` 存在、`session_id` 存在(400) +2. `config.bot(ref)` → 404 +3. `sessions.try_start_run` → 冲突 429 +4. 幂等:RunRegistry 同 id 活跃 handle → 重挂(replay buffer + subscribe broadcast);同 id terminal → 单帧重放终态 +5. 正常:立即构造 SSE 响应流(`Body::from_stream`),spawn run task + +响应构造(自管理帧文本,不经 axum Event 格式化——单一路径可测): + +```rust +let (tx, rx) = tokio::sync::mpsc::channel::(64); +// attach: 先回放 buffer,再转发 broadcast +let stream = tokio_stream::wrappers::ReceiverStream::new(rx) + .map(|s| Ok::<_, std::convert::Infallible>(axum::body::Bytes::from(s))); +Response::builder() + .header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8") + .header(header::CACHE_CONTROL, "no-cache") + .body(Body::from_stream(stream)) +``` + +转发 task:回放 buffer 快照 → 循环 `broadcast::Receiver::recv()` → 写入 mpsc;`Err(Lagged)` 记 warning 继续(BCS 侧按 seq gap 容忍);terminal 帧后退出。 + +本任务同时把 `tests/support/mod.rs` 扩展出两个 helper(此前任务未用到,故放在这里定义,避免 dead_code lint): + +```rust +/// 用指定 mock 脚本作为 cfuse binary 起服务。 +pub async fn spawn_app_with_mock(script: &str, engine: &str) -> String { + let bin = format!("{}/tests/fixtures/{script}", env!("CARGO_MANIFEST_DIR")); + spawn_app(&format!(r#" +provider_id = "bridge-1" +listen = "127.0.0.1:0" +bcs_to_provider_token = "tok-b2p" +[[bot]] +provider_bot_ref = "worker-1" +engine = "{engine}" +cwd = "/tmp" +cfuse_bin = "{bin}" +"#)).await +} + +/// 从 SSE 文本抽取 data 里的 seq 序列。 +pub fn extract_seqs(sse_text: &str) -> Vec { + sse_text.lines() + .filter_map(|l| l.strip_prefix("data: ")) + .filter_map(|d| serde_json::from_str::(d).ok()) + .filter_map(|v| v["seq"].as_u64()) + .collect() +} +``` + +另两个接线约定: + +- `AppState::new(config)` 内部构造全部 store(`SessionStore/RunRegistry/InteractionRegistry/IdempotencyLedger`),测试侧的调用签名不变。 +- `webhook.rs` 的 `dispatch` 从本任务起改为 `async fn`(chat.send 要构造流式响应);`handle_webhook` 相应 `.await`。 + +- [ ] **Step 1: 写失败测试(端到端:mock cc 引擎跑完整 turn)** + +用 `tests/fixtures/mock_cc.sh`(读一行 stdin,逐行吐 `cc_turn.ndjson` 内容)作为 `cfuse_bin`: + +```bash +#!/usr/bin/env bash +IFS= read -r _first +cat "$(dirname "$0")/cc_turn.ndjson" +``` + +`mock_cc_slow.sh`(429 测试用): + +```bash +#!/usr/bin/env bash +IFS= read -r _first +sleep 30 +printf '{"type":"result","subtype":"success","result":"done","session_id":"sess-1"}\n' +``` + +测试: + +```rust +#[tokio::test] +async fn chat_send_streams_sse_to_final() { + let url = support::spawn_app_with_mock("mock_cc.sh", "cfuse-cc").await; + let resp = reqwest::Client::new().post(format!("{url}/webhook")) + .bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&json!({"type":"req","id":"run-1","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"cc-worker"}, + "message":{"role":"user","content":[{"type":"text","text":"你好"}]}})) + .send().await.unwrap(); + assert_eq!(resp.status(), 200); + assert!(resp.headers()["content-type"].to_str().unwrap().starts_with("text/event-stream")); + let text = resp.text().await.unwrap(); + assert!(text.contains("event: agent")); // tool 事件 + assert!(text.contains("\"state\":\"delta\"")); + assert!(text.contains("\"deltaText\":\"正在\"")); + assert!(text.contains("\"state\":\"final\"")); + assert!(text.contains("完成了")); + // seq 单调 + let seqs = support::extract_seqs(&text); + assert!(seqs.windows(2).all(|w| w[0] < w[1])); +} + +#[tokio::test] +async fn concurrent_send_same_session_gets_429() { + // mock_cc_slow.sh:读 stdin 后 sleep 30 再吐结果,保证第一个 run 仍在执行 + let url = support::spawn_app_with_mock("mock_cc_slow.sh", "cfuse-cc").await; + let client = reqwest::Client::new(); + let body = |id: &str| serde_json::json!({"type":"req","id":id,"method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"hi"}]}}); + + let first = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0").json(&body("run-a")).send().await.unwrap(); + assert_eq!(first.status(), 200); // SSE 流已建立(后台持有) + + let second = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0").json(&body("run-b")).send().await.unwrap(); + assert_eq!(second.status(), 429); + let err: serde_json::Value = second.json().await.unwrap(); + assert_eq!(err["error"]["code"], serde_json::json!("rate_limited")); + assert_eq!(err["error"]["retryable"], serde_json::json!(true)); +} +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cargo test -p bridge-provider --test e2e_webhook chat_send` +Expected: FAIL(chat.send 仍是 503 占位) + +- [ ] **Step 3: 实现 RunRegistry + run loop + handler** + +按本任务上方的循环骨架与 handler 流程实现。`chat.send` handler 取代 Task 4 的占位分支;幂等/429/404/400 校验顺序见流程 1–5。 + +- [ ] **Step 4: 运行确认通过** + +Run: `cargo test -p bridge-provider` + +- [ ] **Step 5: 提交** + +```bash +git add crates/adapters/bridge-provider +git commit -m "feat(bridge-provider): run loop and chat.send SSE end-to-end" +``` + +--- + +### Task 12: InteractionRegistry + interaction.resolve + 驱动接线 + +**Files:** +- Create: `crates/adapters/bridge-provider/src/interaction.rs` +- Modify: `crates/adapters/bridge-provider/src/engine/mod.rs`(`TurnRequest` 加 `pub interactions: InteractionRegistry`) +- Modify: `crates/adapters/bridge-provider/src/engine/cfuse_cc.rs`(control_request 接线) +- Modify: `crates/adapters/bridge-provider/src/webhook.rs`(interaction.resolve handler) +- Create: `crates/adapters/bridge-provider/tests/fixtures/mock_cc_approval.sh` + +**Interfaces:** +- Produces: + ```rust + pub struct InteractionRegistry { … } // Clone,内部 Arc> + pub struct PendingInteraction { + pub run_id: String, + pub kind: InteractionKind, + pub engine_request_id: String, // 引擎原生 id(cc 的 control request_id),不回泄 + pub idempotency_key: Option, + resolver: Option>, + } + pub enum ResolveOutcome { Delivered, Duplicate, Unknown } + impl InteractionRegistry { + pub fn register(&self, run_id: &str, kind: InteractionKind, engine_request_id: String) + -> (String /*interaction_id*/, oneshot::Receiver); + pub fn resolve(&self, interaction_id: &str, key: &str, resolution: Value) -> ResolveOutcome; + pub fn invalidate_run(&self, run_id: &str, fallback: Value); // abort/deadline 时兜底释放 + } + ``` + interaction_id 生成:`format!("int-{}", uuid::Uuid::new_v4().simple())`。 +- webhook `interaction.resolve`(spec §5.1;注意 ACK 错误形态为字符串 error): + - 参数:`params.{interactionId, idempotencyKey, kind, decision|action+answers}` + - `Delivered` → `{"ok":true}`;`Duplicate` → `{"ok":true}`;`Unknown` → `{"ok":false,"retryable":false,"error":"unknown interaction"}` + - 幂等:同 `idempotencyKey` 重复 → `Duplicate` 直接成功,不重复回写引擎 + +本任务给 `CcMap` 增加变体(Task 9 的占位分支随之删除): + +```rust +enum CcMap { /* …已有…, */ ControlRequest { request_id: String, tool_name: String, input: Value } } +``` + +cc 驱动接线(`map_cc_line` 的 control_request 分支改为真正挂起): + +```rust +// run_turn 内 +CcMap::ControlRequest { request_id, tool_name, input } => { + let kind = if tool_name == "AskUserQuestion" { InteractionKind::AskUser } else { InteractionKind::Exec }; + let (iid, resolution_rx) = interactions.register(&req.run_id, kind, request_id.clone()); + let requested = build_requested_extra(&tool_name, &input); // exec: command/options;ask_user: questions + let _ = events.send(interaction_event(&req.run_id, InteractionPhase::Requested, kind, &iid, requested)).await; + let resolution = tokio::select! { + _ = abort.cancelled() => { json!({"decision":"deny"}) } + r = resolution_rx => r.unwrap_or_else(|_| json!({"decision":"deny"})), + }; + let behavior = if resolution["decision"].as_str() == Some("deny") { "deny" } else { "allow" }; + cli.write_line(&json!({ + "type":"control_response", + "response":{"request_id": request_id, + "response":{"behavior": behavior, + "updatedInput": (behavior == "allow").then(|| input.clone())}} + }).to_string()).await.map_err(TurnError::Io)?; + let _ = events.send(interaction_event(&req.run_id, InteractionPhase::Resolved, kind, &iid, + json!({"decision": resolution["decision"]}))).await; +} +``` + +- AskUserQuestion 的 `input.questions[]` → BCN ask_user `questions[]`:`header/question/multiSelect/options[].{label→label,label→value}`(cc 无独立 value,用 label 回填;对齐 baas fallback 策略)。**含 secret 标记的问题拒绝转换**(spec §5.2):该 control request 直接回 deny 并记 warning。 +- exec 的 options 固定合成:`[{"decision":"allow_once","label":"Allow once"},{"decision":"deny","label":"Deny"}]`(对齐协议推荐值)。 +- abort/deadline 时 run loop 调 `interactions.invalidate_run(run_id, fallback)` 释放挂起的 oneshot(driver 收到 fallback 后向引擎写 deny)。 + +mock 引擎 `mock_cc_approval.sh`:读 user 消息 → 吐 `control_request(can_use_tool)` → 等 stdin 的 `control_response` → 按 behavior 吐 result。 + +- [ ] **Step 1: 写失败测试(registry 单测 + e2e:interaction 全流程)** + +registry 单测(`interaction.rs` 内): + +```rust +#[tokio::test] +async fn resolve_delivers_and_duplicate_key_replays() { + let reg = InteractionRegistry::new(); + let (iid, rx) = reg.register("run-1", InteractionKind::Exec, "engine-req-1".into()); + assert!(matches!( + reg.resolve(&iid, "key-1", serde_json::json!({"decision":"allow_once"})), + ResolveOutcome::Delivered)); + assert_eq!(rx.await.unwrap()["decision"], serde_json::json!("allow_once")); + // 同 key 重复 → Duplicate(不再投递) + assert!(matches!( + reg.resolve(&iid, "key-1", serde_json::json!({"decision":"allow_once"})), + ResolveOutcome::Duplicate)); + // 未知 id → Unknown + assert!(matches!( + reg.resolve("int-nope", "key-2", serde_json::json!({"decision":"deny"})), + ResolveOutcome::Unknown)); +} +``` + +e2e(`tests/e2e_webhook.rs`)——`mock_cc_approval.sh`:读 user 消息后吐 +`control_request`(`can_use_tool` Bash),`read` 等待 `control_response`,按 +`behavior` 吐 result: + +```rust +#[tokio::test] +async fn interaction_roundtrip_over_sse_and_resolve_webhook() { + let url = support::spawn_app_with_mock("mock_cc_approval.sh", "cfuse-cc").await; + let client = reqwest::Client::new(); + let resp = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&serde_json::json!({"type":"req","id":"run-1","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"执行一下"}]}})) + .send().await.unwrap(); + assert_eq!(resp.status(), 200); + let mut stream = resp.bytes_stream(); + use futures::StreamExt; + let mut acc = String::new(); + // 读到 interaction/requested 帧为止 + let iid = loop { + let chunk = stream.next().await.unwrap().unwrap(); + acc.push_str(&String::from_utf8_lossy(&chunk)); + if acc.contains("\"phase\":\"requested\"") { + break support::extract_first_interaction_id(&acc); + } + }; + // BCS 回程:interaction.resolve + let ack = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"resolve-1","method":"interaction.resolve", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "params":{"bcsRunId":"run-1","runId":"run-1","interactionId":iid, + "kind":"exec","idempotencyKey":"key-1","decision":"allow_once"}})) + .send().await.unwrap(); + assert_eq!(ack.json::().await.unwrap()["ok"], serde_json::json!(true)); + // 幂等重放同 key + let dup = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"resolve-2","method":"interaction.resolve", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "params":{"bcsRunId":"run-1","runId":"run-1","interactionId":iid, + "kind":"exec","idempotencyKey":"key-1","decision":"allow_once"}})) + .send().await.unwrap(); + assert_eq!(dup.json::().await.unwrap()["ok"], serde_json::json!(true)); + // 未知 interactionId → 字符串形态 error + let unknown = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"resolve-3","method":"interaction.resolve", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "params":{"bcsRunId":"run-1","runId":"run-1","interactionId":"int-nope", + "kind":"exec","idempotencyKey":"key-9","decision":"deny"}})) + .send().await.unwrap(); + let body: serde_json::Value = unknown.json().await.unwrap(); + assert_eq!(body["ok"], serde_json::json!(false)); + assert!(body["error"].is_string()); // 注意:此方法的 error 是字符串(spec §5.1) + // 流继续:resolved → chat/final + while let Some(chunk) = stream.next().await { + acc.push_str(&String::from_utf8_lossy(&chunk.unwrap())); + if acc.contains("\"state\":\"final\"") { break; } + } + assert!(acc.contains("\"phase\":\"resolved\"")); + assert!(acc.contains("\"state\":\"final\"")); +} +``` + +`tests/support/mod.rs` 增加: + +```rust +pub fn extract_first_interaction_id(sse_text: &str) -> String { + sse_text.lines() + .filter_map(|l| l.strip_prefix("data: ")) + .filter_map(|d| serde_json::from_str::(d).ok()) + .find(|v| v["interactionId"].is_string()) + .and_then(|v| v["interactionId"].as_str().map(str::to_string)) + .expect("interaction requested frame present") +} +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cargo test -p bridge-provider interaction` +Expected: 编译失败(`InteractionRegistry` 不存在) + +- [ ] **Step 3: 实现 registry + resolve handler + 驱动接线** + +- [ ] **Step 4: 运行确认通过** + +Run: `cargo test -p bridge-provider interaction` + +- [ ] **Step 5: 提交** + +```bash +git add crates/adapters/bridge-provider +git commit -m "feat(bridge-provider): HITL interaction bridging via control channel" +``` + +--- + +### Task 13: chat.inject + cc TranscriptSink + codex 降级 + +**Files:** +- Modify: `crates/adapters/bridge-provider/src/webhook.rs`(chat.inject handler) +- Create: `crates/adapters/bridge-provider/src/engine/transcript.rs` +- Modify: `crates/adapters/bridge-provider/src/run.rs`(prompt 组装时消费 pending injects) + +**Interfaces:** +- Produces: + - `pub trait TranscriptSink: Send + Sync { fn append_user_message(&self, cwd: &Path, engine_session_id: &str, msg: &InjectedMessage) -> Result<(), TranscriptError>; }` + - `pub struct ClaudeJsonlSink;`(cc 用;`~/.claude/projects//.jsonl`;幂等:条目带 `bridgeInjectId = run_id`,append 前扫尾部去重;仿 aix-relay `ClaudeJsonlSink` 但只做最小集:leaf uuid 链接可省——新消息 parentUuid 取文件末行 uuid,找不到则省略) + - codex:`None` sink → injects 留在 `pending_injects`,下次 chat.send 时前置注入 prompt: + + ```text + [from:张三] 注入的消息一 + 注入的消息二 + + <本次 message 文本> + ``` + +- chat.inject handler 流程:幂等台账(Task 5)→ `sessions.add_inject` → sink 成功则从 pending 移除(已落引擎 transcript)→ `{"ok":true}`。 + +- [ ] **Step 1: 写失败测试** + +```rust +// engine/transcript.rs 内 #[cfg(test)] +#[test] +fn claude_jsonl_sink_appends_idempotently() { + let dir = tempfile::tempdir().unwrap(); + // Claude 项目目录布局://.jsonl;encoded-cwd = 路径 '/'→'-' + let projects = dir.path().join("projects"); + let sess_dir = projects.join("-tmp-work"); + std::fs::create_dir_all(&sess_dir).unwrap(); + let sess_file = sess_dir.join("sess-1.jsonl"); + std::fs::write(&sess_file, "{\"type\":\"assistant\",\"uuid\":\"u1\",\"message\":{}}\n").unwrap(); + + let sink = ClaudeJsonlSink::with_projects_root(projects.clone()); + let msg = InjectedMessage { run_id: "inj-1".into(), from_name: Some("张三".into()), text: "观察".into() }; + sink.append_user_message(Path::new("/tmp/work"), "sess-1", &msg).unwrap(); + sink.append_user_message(Path::new("/tmp/work"), "sess-1", &msg).unwrap(); // 幂等 + + let content = std::fs::read_to_string(&sess_file).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 2); // 只新增一条 + let appended: serde_json::Value = serde_json::from_str(lines[1]).unwrap(); + assert_eq!(appended["type"], serde_json::json!("user")); + assert_eq!(appended["parentUuid"], serde_json::json!("u1")); + assert_eq!(appended["bridgeInjectId"], serde_json::json!("inj-1")); + assert_eq!(appended["message"]["content"][0]["text"], serde_json::json!("[from:张三] 观察")); +} +``` + +`ClaudeJsonlSink::with_projects_root` 是测试构造器;生产 `ClaudeJsonlSink::default_home()` +解析 `$HOME/.claude/projects`。 + +```rust +// tests/e2e_webhook.rs +#[tokio::test] +async fn inject_then_send_prepends_for_codex() { + // mock_codex.sh:把 argv 里的 prompt 作为 delta 回显 + let url = support::spawn_app_with_mock("mock_codex.sh", "cfuse-codex").await; + let client = reqwest::Client::new(); + let resp = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"inj-1","method":"chat.inject", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"观察上下文"}]}, + "from":{"kind":"bot","name":"观察者"}})) + .send().await.unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(resp.json::().await.unwrap()["ok"], serde_json::json!(true)); + + let resp = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&serde_json::json!({"type":"req","id":"run-9","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"正式问题"}]}})) + .send().await.unwrap(); + let text = resp.text().await.unwrap(); + assert!(text.contains("观察上下文")); // 注入被前置进 prompt + assert!(text.contains("正式问题")); +} +``` + +`mock_codex.sh`(回显 prompt 的 codex 假引擎)——注意:**执行期修正后 codex 输出是 JSONL**(`codex exec --json` 形态),不是 SSE: + +```bash +#!/usr/bin/env bash +prompt="$*" +printf '{"type":"thread.started","thread_id":"mock-thread-1"}\n' +printf '{"type":"turn.started"}\n' +# prompt 里的双引号/反斜杠需转义后再嵌入 JSON;mock 场景的 prompt 不含特殊字符,直接拼接 +printf '{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"%s"}}\n' "$prompt" +printf '{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}\n' +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cargo test -p bridge-provider transcript inject` +Expected: 编译失败(`transcript` 模块不存在) + +- [ ] **Step 3: 实现 transcript.rs + inject handler + prompt 前置组装** + +- [ ] **Step 4: 运行确认通过** + +Run: `cargo test -p bridge-provider` + +- [ ] **Step 5: 提交** + +```bash +git add crates/adapters/bridge-provider +git commit -m "feat(bridge-provider): chat.inject with engine transcript sink" +``` + +--- + +### Task 14: chat.abort + +**Files:** +- Modify: `crates/adapters/bridge-provider/src/webhook.rs`、`run.rs`、`session.rs` + +**Interfaces:** +- handler 流程(spec §5.3 响应形态): + 1. `sessions.active_run(bot, session_id)` 有值 → `runs.get(run_id).abort.cancel()` → run loop 收到 abort → driver `cli.kill()` → 发 `chat_aborted` 终态 → `{"ok":true,"aborted":true,"aborted_run_ids":[run_id]}` + 2. 无活跃但 RunRegistry 有该 session 的 terminal run → 410 `run_terminated`(对同 terminal run 重复 abort 稳定同答;幂等台账保证同 id 重放) + 3. 无任何记录 → `{"ok":true,"aborted":false,"aborted_run_ids":[]}` + 4. abort 命中时先 `interactions.invalidate_run(run_id, deny-fallback)` 释放挂起的 interaction +- `RunRegistry` 需要 `run_session: HashMap` 反查索引 +- `RunRegistry` 增加 `pub async fn abort_all(&self, reason: &str)`(Task 15 优雅退出用):遍历活跃 run 逐一 `abort.cancel()` + +- [ ] **Step 1: 写失败测试** + +```rust +#[tokio::test] +async fn abort_active_run_emits_aborted_terminal() { + let url = support::spawn_app_with_mock("mock_cc_slow.sh", "cfuse-cc").await; + let client = reqwest::Client::new(); + let send = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&serde_json::json!({"type":"req","id":"run-1","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"慢任务"}]}})); + // 后台持有 SSE 响应体 + let sse = tokio::spawn(async move { send.send().await.unwrap().text().await.unwrap() }); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; // 等 run 起跑 + + let resp = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"abort-1","method":"chat.abort", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}})) + .send().await.unwrap(); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(resp.status().as_u16(), 200); + assert_eq!(body["ok"], serde_json::json!(true)); + assert_eq!(body["aborted"], serde_json::json!(true)); + assert_eq!(body["aborted_run_ids"], serde_json::json!(["run-1"])); + + let sse_text = tokio::time::timeout(std::time::Duration::from_secs(5), sse).await.unwrap().unwrap(); + assert!(sse_text.contains("\"state\":\"aborted\"")); + + // 对同一 terminal run 重复 abort → 410 run_terminated(稳定) + let again = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"abort-2","method":"chat.abort", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}})) + .send().await.unwrap(); + assert_eq!(again.status(), 410); + assert_eq!(again.json::().await.unwrap()["error"]["code"], + serde_json::json!("run_terminated")); + + // 无任何记录的 session → aborted:false + let none = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"abort-3","method":"chat.abort", + "session_id":"s-unknown", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}})) + .send().await.unwrap(); + let none_body: serde_json::Value = none.json().await.unwrap(); + assert_eq!(none_body["aborted"], serde_json::json!(false)); + assert_eq!(none_body["aborted_run_ids"], serde_json::json!([])); +} +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cargo test -p bridge-provider --test e2e_webhook abort` +Expected: FAIL(chat.abort 返回 501/未实现) + +- [ ] **Step 3: 实现 abort handler + run 反查索引 + invalidate_run 接线** + +- [ ] **Step 4: 运行确认通过** + +Run: `cargo test -p bridge-provider` + +- [ ] **Step 5: 提交** + +```bash +git add crates/adapters/bridge-provider +git commit -m "feat(bridge-provider): chat.abort with terminal-state matrix" +``` + +--- + +### Task 15: main.rs + 优雅退出 + HTTP/2 验证 + +**Files:** +- Create: `crates/adapters/bridge-provider/src/main.rs` + +**内容:** +- 从 `BRIDGE_CONFIG`(默认 `bridge.toml`)加载配置;`tracing_subscriber` 初始化(env-filter) +- axum server 绑定 `config.listen`;`tokio::signal` SIGTERM/SIGINT → 停接新连接(hyper graceful)→ 遍历 RunRegistry 全部 `abort.cancel()` → 退出 +- 手动验证步骤(不进 CI):`curl --http2-prior-knowledge -N` 打 webhook,确认 h2c SSE 可用(协议要求生产 HTTP/2;axum/hyper auto builder 支持 h2c 先验) + +- [ ] **Step 1: 写 smoke 测试** + +```rust +// tests/e2e_webhook.rs +#[tokio::test] +async fn binary_starts_and_serves_ping() { + let dir = tempfile::tempdir().unwrap(); + let cfg_path = dir.path().join("bridge.toml"); + std::fs::write(&cfg_path, r#" +provider_id = "bridge-1" +listen = "127.0.0.1:21999" +bcs_to_provider_token = "tok-b2p" +[[bot]] +provider_bot_ref = "worker-1" +engine = "cfuse-cc" +cwd = "/tmp" +"#).unwrap(); + let bin = env!("CARGO_BIN_EXE_bridge-provider"); + let mut child = std::process::Command::new(bin) + .env("BRIDGE_CONFIG", &cfg_path) + .spawn().unwrap(); + // 轮询直到端口就绪(最多 5s) + let client = reqwest::Client::new(); + let mut ok = false; + for _ in 0..50 { + let resp = client.post("http://127.0.0.1:21999/webhook") + .bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"p1","method":"bot.ping", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}})) + .send().await; + if let Ok(r) = resp { + if r.status() == 200 { ok = true; break; } + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + // 优雅退出:SIGTERM 应让进程退出 + unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }; + let status = tokio::time::timeout(std::time::Duration::from_secs(5), + tokio::task::spawn_blocking(move || child.wait())).await + .expect("process exits within 5s").unwrap().unwrap(); + assert!(status.success()); + assert!(ok, "ping should succeed while running"); +} +``` + +(需要 `libc` dev-dependency:在 crate 的 `[dev-dependencies]` 加 `libc = "0.2"`——若根 workspace 已有则改 `{ workspace = true }`。) + +- [ ] **Step 2: 运行确认失败** + +Run: `cargo test -p bridge-provider --test e2e_webhook binary` +Expected: FAIL(binary 不存在) + +- [ ] **Step 3: 实现 main.rs** + +```rust +use std::{path::PathBuf, sync::Arc}; +use bridge_provider::{config::ProviderConfig, webhook, AppState}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + let config_path: PathBuf = std::env::var("BRIDGE_CONFIG") + .map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("bridge.toml")); + let config = ProviderConfig::load(&config_path)?; + let listen = config.listen; + let state = Arc::new(AppState::new(config)); + let app = webhook::router(state.clone()); + let listener = tokio::net::TcpListener::bind(listen).await?; + tracing::info!(%listen, "bridge-provider listening"); + axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = tokio::signal::ctrl_c().await; + // 先停新连接;再中止全部活跃 run(aborted 终态),子进程随之回收 + state.runs.abort_all("shutdown").await; + }) + .await?; + Ok(()) +} +``` + +(`anyhow` 已在 workspace 依赖;`AppState::new` 此时已含 `runs: RunRegistry`,`runs.abort_all(reason)` 在 Task 14 实现。) + +- [ ] **Step 4: 运行确认通过** + +Run: `cargo test -p bridge-provider --test e2e_webhook` + +- [ ] **Step 5: 提交** + +```bash +git add crates/adapters/bridge-provider +git commit -m "feat(bridge-provider): binary entrypoint with graceful shutdown" +``` + +--- + +### Task 16: 协议回归 e2e(mock BCS 客户端语义) + +**Files:** +- Test: `crates/adapters/bridge-provider/tests/e2e_webhook.rs`(追加) + +**测试清单(对 spec §5/§6):** + +- [ ] **Step 1: 幂等重挂 + 终态重放 + 409/400** + +```rust +#[tokio::test] +async fn duplicate_send_reattaches_with_replay() { + // mock_cc_slow.sh:run 进行中时第二个同 id 请求到达 + let url = support::spawn_app_with_mock("mock_cc_slow.sh", "cfuse-cc").await; + let client = reqwest::Client::new(); + let body = serde_json::json!({"type":"req","id":"run-dup","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"hi"}]}}); + let first = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0").json(&body).send().await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + // 同 id 同 body 重发 → 200 且新流自 seq 1 重放(BCS 按 seq 去重,重放无害) + let second = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0").json(&body).send().await.unwrap(); + assert_eq!(second.status(), 200); + let second_text = second.text().await.unwrap(); + let seqs = support::extract_seqs(&second_text); + assert_eq!(seqs.first(), Some(&1)); + drop(first); // 让第一个连接断开,不阻塞测试结束 +} + +#[tokio::test] +async fn same_id_different_body_conflicts() { + let url = support::spawn_app_with_mock("mock_cc_slow.sh", "cfuse-cc").await; + let client = reqwest::Client::new(); + let mut body = serde_json::json!({"type":"req","id":"run-x","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"hi"}]}}); + let _first = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0").json(&body).send().await.unwrap(); + body["message"]["content"][0]["text"] = serde_json::json!("changed"); + let second = client.post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0").json(&body).send().await.unwrap(); + assert_eq!(second.status(), 409); +} + +#[tokio::test] +async fn missing_protocol_2_header_rejected() { + let url = support::spawn_app_with_mock("mock_cc.sh", "cfuse-cc").await; + let resp = reqwest::Client::new().post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .json(&serde_json::json!({"type":"req","id":"r","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"hi"}]}})) + .send().await.unwrap(); + assert_eq!(resp.status(), 400); +} +``` + +- [ ] **Step 2: UTF-8 与超大帧回归** + +```rust +#[tokio::test] +async fn utf8_chinese_deltas_stay_intact() { + // mock_cc_utf8.sh:逐行吐 40 条中文 delta(每条一个完整 JSON 事件行) + let url = support::spawn_app_with_mock("mock_cc_utf8.sh", "cfuse-cc").await; + let resp = reqwest::Client::new().post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&serde_json::json!({"type":"req","id":"run-u","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"hi"}]}})) + .send().await.unwrap(); + let text = resp.text().await.unwrap(); // text() 要求全程合法 UTF-8 + assert!(text.contains("中文增量")); + // 每个 data 行都是合法 JSON(无半个字符截断) + for line in text.lines().filter_map(|l| l.strip_prefix("data: ")) { + serde_json::from_str::(line).expect("valid json frame"); + } +} + +#[tokio::test] +async fn oversize_single_frame_becomes_chat_error() { + // mock_cc_big.sh:吐一条 >8MiB 的 text_delta + let url = support::spawn_app_with_mock("mock_cc_big.sh", "cfuse-cc").await; + let resp = reqwest::Client::new().post(format!("{url}/webhook")).bearer_auth("tok-b2p") + .header("X-BCN-Protocol-Version", "2.0") + .json(&serde_json::json!({"type":"req","id":"run-big","method":"chat.send", + "session_id":"s-1", + "to_bot":{"provider_id":"bridge-1","provider_bot_ref":"worker-1"}, + "message":{"role":"user","content":[{"type":"text","text":"hi"}]}})) + .send().await.unwrap(); + let text = resp.text().await.unwrap(); + assert!(text.contains("\"state\":\"error\"")); // 超限 → error 终态 + assert!(!text.contains("\"state\":\"final\"")); + assert!(text.len() < 9 * 1024 * 1024); // 没有超限帧被发出 +} +``` + +`mock_cc_utf8.sh` / `mock_cc_big.sh` 均为逐行 printf 的 bash fixture(big 用 `head -c 9000000 /dev/zero | tr '\0' 'x'` 生成单条 delta 文本)。 + +- [ ] **Step 3: 运行全部失败 → 修复实现 → 通过** + +Run: `cargo test -p bridge-provider` + +- [ ] **Step 4: 提交** + +```bash +git add crates/adapters/bridge-provider +git commit -m "test(bridge-provider): protocol regression e2e" +``` + +--- + +## Self-Review 记录 + +**Spec 覆盖:** §4 组件(webhook/Engine/CliSession/EventMapper/SseEncoder/SessionStore/RunRegistry/InteractionRegistry/TranscriptSink/CallbackClient)——CallbackClient 属 JSON-callback fallback,v1 恒走 SSE,未单列任务(非目标,spec §3 已注明 SSE always);其余一一对应 Task 1–14。§5 线协议 → Task 2/3/4/11/12/13/14/16。§6 生命周期 → Task 11/12/14/15。§7 错误 → Task 4/11/14。§8 测试 → Task 16 + 各任务内嵌。 + +**已知留白(实现期验证,非 placeholder):** cfuse codex 的 resume 参数与权限请求事件形态(Task 10 前置调研步骤);AskUserQuestion → ask_user 的字段细节(Task 12,以真实 cfuse cc 输出校准 fixture)。 diff --git a/src/bcs/docs/superpowers/specs/2026-08-31-bridge-provider-design.md b/src/bcs/docs/superpowers/specs/2026-08-31-bridge-provider-design.md new file mode 100644 index 0000000000..be9ce34161 --- /dev/null +++ b/src/bcs/docs/superpowers/specs/2026-08-31-bridge-provider-design.md @@ -0,0 +1,345 @@ +# Bridge Provider 设计:BCN Provider 2.0 × cfuse(cc/codex) 引擎桥接 + +日期:2026-08-31 +状态:待评审 + +## 1. 背景与目标 + +BCS 已支持下行调用模式,并定义了标准的 Provider 2.0 SSE 协议 +(`docs/bcs-provider-2.0-sse-protocol.md`)。本设计实现一个 **bridge-provider**: +一个独立的 Rust 服务,作为 BCN Provider 2.0 与 BCS 进行 gateway 通信, +把下游 `chat.send` 等请求桥接到本地编码引擎执行。 + +引擎支持优先级: + +1. **v1(本设计范围)**:cfuse 的 cc 模式(`--cc` / claude-code)与 codex 模式 + (`--codex`)。 +2. **后续**:原生 claude(`claude-code-agent-sdk`)与原生 codex。 + +非本设计的另一部分:Provider/Bot 在 BCS 侧的注册引导(已有 BCS API,本服务 +以配置方式消费注册结果)。 + +## 2. 既有契约(北向固定,不可协商) + +北向协议就是 BCN Provider 2.0,权威定义见: + +- `src/bcs/docs/bcs-provider-2.0-sse-protocol.md`(SSE 帧、interaction、resolve 回程) +- `docs/bot-provider-integration.md`(仓根;webhook 方法集、token 模型、错误表) + +要点摘录(本服务必须满足): + +- BCS `POST `,`Authorization: Bearer `, + `X-BCN-Protocol-Version: 2.0`,`X-BCN-Message-Id`(仅追踪)、 + `X-BCN-Timestamp`。 +- `chat.send` 且 `Accept: text/event-stream, application/json` 时,应答 + `Content-Type: text/event-stream` 则 run 绑定 SSE;JSON ack 则走 + `/bot/events` callback。一个 run 不可混用两种 transport。 +- SSE 帧 `event: agent|chat|ping|interaction`,`data` 为单行紧凑 JSON; + `data.seq` 在同一 SSE 内跨事件类型单调递增(interaction 必填 seq); + `data.runId` 必填(除 ping)但不作 BCS 路由依据;terminal 为 + `chat` 的 `final|error|aborted`。 +- 传输约束:生产 HTTP/2;单帧 ≤ 8 MiB;BCS 侧 15 分钟无字节判 idle timeout; + **BCS 不支持 Last-Event-ID/自动重连/断点续传**;EOF 无 terminal 时 BCS 合成 + `chat/error` 并将 run 置为 terminal。 +- `event: agent + stream: approval` 为旧兼容结构,**禁止发送**;HITL 必须走顶层 + `event: interaction`。 +- `interaction.resolve` 由 BCS 以独立 POST 打到同一 webhook + (`X-BCN-Transport: callback`,`Accept: application/json`),ACK 成功为 + `{"ok":true}`;失败为 `{"ok":false,"retryable":bool,"error":"..."}` + (注意:此方法的 error 是字符串,与通用错误对象不同)。 +- 通用错误:`{"ok":false,"error":{"code","message","retryable","retry_after_ms?"}}`, + code→HTTP 映射见 §5.4。 + +## 3. 关键决策记录 + +| 决策点 | 结论 | 理由 / 被否决项 | +| --- | --- | --- | +| 技术栈 | Rust,bcs workspace 内独立 crate + binary | 复用 `bcs-protocol` SSE 类型;与 BCS 侧 `bcs-provider-http` 对应 | +| 总体架构 | 自包含 cfuse-subprocess provider(方案 A) | 不引入对 aix-relay 的运行时依赖;aix-relay 仅作参考实现 | +| A2A/ACP 调研 | 不采用 | ACP 已并入 A2A(Linux Foundation);二者都不能替代北向(BCS 只讲 Provider 2.0);A2A 可作为远期南向出口,非 v1 | +| 引擎抽象命名 | trait `Engine` + `enum EngineKind` | 否决:`AgentProvider`(BCN 已占用 Provider=本桥)、`AgentBridge`(bridge=整体组件名)、`AgentBackend`(与本仓 `src/backend` 服务冲突)、`AgentEngine`(agent≈engine 同义重复)、`EngineDriver`(driver=BCS 群组角色);`bcs-protocol` 已有 `EngineType`(bot 平台类型),故枚举名用 `EngineKind` | +| v1 方法集 | `chat.send`(SSE)、`chat.inject`、`chat.abort`、`interaction.resolve`、`bot.ping` | 用户明确要求交互必须支持;`chat.history` 不做 | +| 交互(HITL) | 必须支持;引擎以交互模式运行 | 不做 yolo/自动批准;v1 支持 `exec` + `ask_user`,`mode_switch` 不合成(协议标记为可选能力,cfuse 无双向模式切换语义) | +| inject 处理 | session store 为事实源 + 引擎原生 transcript sink | 仿 aix-relay `sessions/inject.rs`:cc 写 Claude session JSONL;codex 格式待实现期确认,降级方案为下次 send 前置注入 | +| BCS 断连 | **写失败即杀 run**(修正项) | 协议文档 §1.3 明确无重连/续传,EOF 后 BCS 已合成 terminal error,保活无意义。引擎 transcript 仍在,BCS 后续新 run 可 resume | +| 同 session 并发 chat.send | `429 rate_limited`(retryable) | 引擎会话天然串行;v1 不排队 | +| 幂等重挂 | 每 run 内存 frame buffer,重放自 seq 1 + live follow | 仅服务"首个响应丢失后 BCS 同 id 重试"窗口;BCS 按 seq 去重,重放无害 | +| session 标识 | 双 id 模型:`bcs_session_id` ↔ `engine_session_id` | 映射归 session store;Engine 只见 engine session id;BCN id 永不传给 `--resume` | +| SSE 事件类型 | 复用 `bcs_protocol::stream` 类型 | 编译期保证发出帧可被 BCS 解析;不另造 `RuntimeEvent` 式平行类型 | + +## 4. 架构与组件 + +### 4.1 crate 位置 + +`src/bcs/crates/adapters/bridge-provider/`(library + binary `bridge-provider`)。 + +它是被 BCS 调用的外部独立进程,不在 BCS inbound `application→core→port` +分层之内;放在 `adapters/` 下与 BCS 侧对应物 `adapters/http/bcs-provider-http` +并列。依赖面保持小:axum、tokio、serde/serde_json、reqwest(仅 callback +fallback 需要)、`bcs-protocol`(SSE 流类型)。 + +### 4.2 组件 + +```text +POST /webhook ──→ WebhookServer ──→ Dispatcher(按 method) + │ ├─ chat.send ──→ RunRegistry ──→ Engine(经 CliSession) + │ ├─ chat.inject ─→ SessionStore (+ TranscriptSink) + │ ├─ chat.abort ──→ RunRegistry.abort + │ ├─ interaction.resolve ─→ InteractionRegistry + │ └─ bot.ping ──→ 引擎可用性探针 + └─ 校验: token / provider_id / method / 幂等台账 +Engine stdout ──→ EventMapper ──→ SseEncoder(seq/buffer) ──→ SSE response +``` + +1. **WebhookServer**(axum):唯一入口 `POST /webhook`。校验顺序: + `Authorization`(401)→ `to_bot.provider_id` 匹配本桥(403)→ method 已知 + (501)→ 幂等台账(同 id 异 body → 409)。`X-BCN-Message-Id` 只记日志。 +2. **Engine trait + EngineKind**(模块 `engine`): + + ```rust + pub trait Engine: Send + Sync { + fn kind(&self) -> EngineKind; + async fn run_turn(&self, req: TurnRequest, + events: mpsc::Sender, // bcs_protocol::stream + abort: CancellationToken) -> Result; + } + pub enum EngineKind { CfuseCc, CfuseCodex, /* 后续 */ ClaudeCode, CodexCli } + ``` + + v1 实现 `engine::CfuseCc` / `engine::CfuseCodex`,二者共享具体类型 + **`CliSession`**(tokio 子进程、stdin 喂入、stdout 分帧、kill/abort、 + Unix zombie reap——参考 aix-relay `runtime/claude.rs` 的 waitpid 模式), + 子进程机制不进 trait。 +3. **EventMapper**:引擎原生事件 → `bcs_protocol::stream::StreamEvent`。 + cfuse cc:stream-json(`--input-format stream-json --output-format + stream-json --verbose --include-partial-messages`),assistant/text 增量 → + `chat/delta`,tool_use/tool_result → `agent/tool`,`can_use_tool` 控制消息 → + `interaction/requested(exec)`,AskUserQuestion → `interaction/requested(ask_user)`; + cfuse codex:Codex app-server JSON-RPC over stdio( + `item/agentMessage/delta` → `chat/delta`, + `turn/completed/agent/turn_failed` → terminal;其它 app-server + notifications 按 thread/turn 关联过滤)。精确字段映射以实现期对真实 + cfuse 输出的契约测试为准。 +4. **SseEncoder**:`StreamEvent` → `event:/id:/data:` 文本帧;赋 `seq` + (per-run 单调,自 1 起,跨 chat/agent/interaction 共享),SSE `id:` 镜像 + `seq`;帧 ≤ 8 MiB;UTF-8 安全切分(`char_indices`,禁止字节切片—— + CLAUDE.md 硬性要求)。 +5. **SessionStore**(进程内存):键 `(provider_bot_ref, bcs_session_id)`。 + + ```rust + struct SessionMapping { + bcs_session_id: BcsSessionId, + engine_session_id: Option, // 首 turn 从引擎流捕获,之后用于 --resume + pending_injects: Vec, // transcript sink 不可用时待注入 + active_run: Option, + } + ``` +6. **RunRegistry**:活跃 run 与 grace 期内 terminal run;每 run 持有 + `buffer: Vec`(重放用)、`live_tx: broadcast::Sender`、 + `abort: CancellationToken`、`pending_interactions`。 +7. **InteractionRegistry**:`interactionId → oneshot::Sender`; + 公开 `interactionId` 由本桥铸造(run 内唯一不复用),引擎内部请求 id 不外泄; + 同时保存 resolve 回写引擎所需的 engine-native 关联信息(对齐协议文档 §11 + "Provider 在 requested 时保存 engine-native correlation")。 +8. **TranscriptSink**(per-engine 可选):把 inject 消息幂等追加进引擎原生 + transcript(cc = Claude session JSONL,仿 aix-relay `ClaudeJsonlSink`: + leaf-linked、按 run_id 去重);sink 永远不是 session 状态的第二事实源。 +9. **CallbackClient**(仅 JSON-ack fallback 路径用):`POST /bot/events`。 + SSE 绑定的 run 禁止走它(BCS 会 409)。 +10. **ProviderConfig**:静态配置(见 §4.3),含 token 与 bot→引擎绑定。 + +### 4.3 配置形态(示意) + +```toml +provider_id = "bridge-provider-1" +bcs_to_provider_token = { env = "BRIDGE_B2P_TOKEN" } +bot_runtime_token = { env = "BRIDGE_BOT_RUNTIME_TOKEN" } # callback fallback 用 +listen = "0.0.0.0:21100" + +[[bot]] +provider_bot_ref = "cc-worker" +engine = "cfuse-cc" # EngineKind +model = "sonnet" # 可选,-m 透传 +cwd = "/data/work/cc" +permission_mode = "default" # 交互模式;禁止 yolo/bypass + +[[bot]] +provider_bot_ref = "codex-worker" +engine = "cfuse-codex" +cwd = "/data/work/codex" +``` + +## 5. 线协议面(本桥实现侧) + +### 5.1 方法表 + +| method | transport | 行为 | +| --- | --- | --- | +| `chat.send` | SSE | 解析 bot→engine → 会话映射(resume 或新建)→ spawn → 流式转发 → terminal 关流 | +| `chat.inject` | JSON | 写 SessionStore + TranscriptSink,**不触发引擎** → `200 {"ok":true}` | +| `chat.abort` | JSON | 按 `session_id` 反查活跃 run → 取消 → 其流上发 `aborted` 终态;响应形态见 §5.3 | +| `interaction.resolve` | JSON | 按 `interactionId` 查 pending → 幂等(同 key 同 resolution 直接成功)→ 回写引擎控制通道 → 引擎应用后在原 SSE 发 `interaction/resolved` → ACK `{"ok":true}`;引擎暂不可写 → `{"ok":false,"retryable":true,"error":"..."}` | +| `bot.ping` | JSON | `200 {"ok":true}` + 引擎 binary 可用性 | + +### 5.2 chat.send 发出的帧 + +- 首帧建议 `agent/lifecycle start`;末两帧 `agent/lifecycle end` + `chat/final` + (对齐线上真实样本形态,协议文档 §10)。 +- `chat/delta`:`{"runId","seq","ts","state":"delta","deltaText":"…"}`。 +- `chat/final`:`state:"final"` + `message{role:"assistant",content:[{type:"text",text:}],timestamp}` + 可选 `stopReason`。 +- `chat/error`:`state:"error"` + `errorMessage`(脱敏)+ 可选 `errorKind`。 +- `chat/aborted`:`state:"aborted","stopReason":"user_cancelled"`。 +- `agent/tool`:`phase:"start|update|result"`,`name/toolCallId` 关联, + result 携带 `result/isError/exitCode/durationMs/cwd`(有则给)。 +- `agent/thinking`:`delta` + 可选累计 `text`。 +- `interaction/requested`:`runId/seq/ts/phase/interactionId/kind` 必填, + 字段白名单按协议 §5/§6(exec: command+options[decision,label];ask_user: + questions 1–4、questionId/question 必填、自由文本题省略 options 与 + allowOther;secret 类问题**拒绝转换**,不降级明文)。 +- `interaction/resolved`:仅在引擎真正应用决议后发送;exec 回显 decision; + ask_user 最小回显可只带 phase/interactionId/kind。 +- 心跳:默认 SSE comment(`: heartbeat`)每 20–30s;需要业务可观测性时才用 + `event: ping`。二者均不含 seq。 + +### 5.3 chat.abort 响应形态(协议固定) + +| 会话状态 | HTTP | Body | +| --- | --- | --- | +| 有 RUNNING/PENDING run | 200 | `{"ok":true,"aborted":true,"aborted_run_ids":["…"]}` | +| 仅 terminal 记录 | 410 | `{"ok":false,"error":{"code":"run_terminated",…}}`(重复 abort 稳定同答) | +| 无记录 | 200 | `{"ok":true,"aborted":false,"aborted_run_ids":[]}` | + +### 5.4 错误表(应答前失败) + +| code | HTTP | retryable | 场景 | +| --- | --- | --- | --- | +| `invalid_request` | 400 | false | header/body 非法 | +| `unauthorized` | 401 | false | token 错 | +| `provider_id_mismatch` | 403 | false | provider_id 不匹配 | +| `bot_not_found` | 404 | false | provider_bot_ref 未配置 | +| `conflict` | 409 | false | 同幂等键不同 body | +| `run_terminated` | 410 | false | abort 已终结 run | +| `rate_limited` | 429 | true | 同 session 已有活跃 run | +| `unsupported_method` | 501 | false | 未知 method | +| `unavailable` | 503 | true | 引擎 binary 缺失 / spawn 失败 | +| `timeout` | 504 | true | 依赖超时 | + +应答后(流已开)的失败一律以 `chat/error` 终态帧表达,不再改 HTTP 状态。 + +### 5.5 幂等 + +| 场景 | 键 | 行为 | +| --- | --- | --- | +| `chat.send` | body `id` | 活跃 run 同 body → 迁移/重挂流(buffer 自 seq 1 重放 + live follow);terminal → 重放终态帧的单帧 SSE;异 body → 409 | +| `chat.inject` | body `id` | 同键同 body 直接成功,不重复写 transcript | +| `chat.abort` | body `id` | 重复 abort 同 terminal run 稳定 410 | +| `interaction.resolve` | `params.idempotencyKey` | 同键同 resolution → 直接 ACK 成功,不重复回写引擎;引擎侧重复投递需容忍(协议 §8) | + +## 6. Run 生命周期与数据流 + +### 6.1 状态机 + +```text +Accepted → Starting → Streaming ⇄ AwaitingInteraction → Terminal → Evicted +``` + +### 6.2 chat.send 主流程 + +1. 校验 + 幂等认领 → 按 `provider_bot_ref` 取 `EngineKind`/model/cwd → + 查 SessionMapping(有 `engine_session_id` 则 resume,无则新会话)→ + 取出 pending injects(transcript sink 不可用引擎的前置注入)。 +2. 立即 `200 + Content-Type: text/event-stream` 应答(远早于 125s 响应头 + deadline),run task 持有该响应流。 +3. spawn 引擎(cfuse cc stream-json 或 codex app-server JSON-RPC I/O,交互权限模式)。 +4. 从引擎流捕获 engine session id → **立即持久化映射**(run 中途失败也保留下次 + resume 能力)。 +5. 引擎事件 → EventMapper → SseEncoder(赋 seq)→ 写 SSE + 入 run buffer。 +6. terminal(final/error/aborted)→ 关流、标记 Terminal、使该 run 全部 + Pending/Accepted interaction 失效、buffer 进 grace TTL(默认 10 min)后驱逐。 + +### 6.3 Interaction 子流程 + +1. 引擎发权限/提问请求(cc:`can_use_tool` 控制消息;codex:权限请求事件)。 +2. EventMapper 铸造公开 `interactionId`,InteractionRegistry 存 oneshot + + engine-native 关联,发 `interaction/requested`,run 挂起。 +3. BCS 经 InteractionService 路由给 Human;之后独立 POST + `interaction.resolve` 到本 webhook。 +4. 校验 + 幂等 → 触发 resolver → Driver 经引擎控制通道写回决议 → 引擎应用后 + EventMapper 发 `interaction/resolved` → ACK。 +5. run deadline 先到:按 kind 安全兜底(exec→deny、ask_user→cancel)写回引擎并 + 记录 warning;run 终态时所有未决 interaction 失效(协议 §9)。 + +### 6.4 abort / deadline / 心跳 + +- abort:CancellationToken → cc 走控制通道 interrupt(SDK 语义)→ 宽限后 + SIGTERM→SIGKILL → 发 `aborted` 终态。挂起中的 interaction 先按兜底决议释放。 +- deadline:以 BCS `timeout_ms` 为上限,bridge 提前 ~30s 自发 `chat/error` + (`errorKind:"deadline"`)关流,不让 BCS 掐连接。 +- 心跳:comment heartbeat 20–30s;不超过 15min idle 与 run deadline。 + +### 6.5 失败矩阵 + +| 故障 | 检测 | 行为 | +| --- | --- | --- | +| 引擎崩溃(无结果退出) | stdout EOF / 非零 exit | `chat/error` 终态(脱敏),关流 | +| 引擎僵死无输出 | run deadline | 杀进程 + `chat/error` 终态 | +| BCS 断连(写失败) | SSE write error | **立即杀 run**(协议无重连续传;BCS 已合成 terminal error) | +| 重复 chat.send(同 id 同 body) | 幂等台账 | 见 §5.5(重挂/重放) | +| 同 session 并发第二个 chat.send | SessionMapping.active_run 占用 | `429 rate_limited`(retryable) | +| bridge 进程重启 | — | 子进程同灭、run 全失;BCS 新 run 凭 Codex app-server `thread/resume` 或 cc transcript `--resume` 恢复上下文 | +| interaction.resolve 指向未知 id | 查 registry | `{"ok":false,"retryable":false,"error":"unknown interaction"}` | +| 单帧 > 8 MiB 风险 | encoder 侧检查 | 截断/降级为 error 帧(脱敏),不产生超限帧 | + +## 7. 错误处理细则 + +- **脱敏**:发往 BCS 的 `errorMessage` 不含本地路径/token/命令原文;细节只进 + 结构化日志(带 run_id、provider_bot_ref、bcs_session_id)。interaction 业务 + payload(command/questions/answers)不写 INFO/WARN 日志(对齐协议 §9 的日志 + 约束)。 +- **panic 隔离**:每 run 独立 task,panic 捕获 → `chat/error` 终态,不拖垮 + webhook。 +- **优雅退出**:SIGTERM → 停接新 run → 活跃流发 `aborted` 终态 → 杀子进程并 + reap。 +- **引擎 stderr**:按行进日志(带关联标签),永不进入协议帧。 + +## 8. 测试策略(零真实 LLM 调用) + +1. **Golden-frame 契约测试**:encoder 输出逐字节对齐协议文档 §10 的真实样本 + 形态与 BCS 侧 `bcs-provider-http` 的 fixture;并用 + `bcs_protocol::stream::parse_stream_event` 做往返解析断言。 +2. **Mock 引擎二进制**(仿 aix-testkit):脚本化假 `cfuse`,按剧本吐 + stream-json(含 permission 请求)→ 覆盖 chat.send→final、interaction + requested→resolve→resolved、abort(含挂起在 interaction 上的 abort)、 + inject、幂等重挂、429 竞争、错误映射表。 +3. **Mock BCS 客户端**:测试 client 按 Provider 2.0 发 POST、消费 SSE, + 模拟 BCS 的 seq 去重验证重放安全性。 +4. **单元测试**:SseEncoder(多行 data、seq、`id:` 镜像、8 MiB 检查)、 + 双 id 映射(首 turn 捕获→后续 resume)、InteractionRegistry + (resolve/超时兜底/abort 竞态/幂等键)、幂等台账。 +5. **UTF-8 专项**:中文 delta 跨帧切分必须走 `char_indices` 安全边界。 +6. **轻依赖**:`cargo test -p bridge-provider` 可在本 worktree 独立构建 + (磁盘受限,不做全 workspace 构建)。 + +## 9. 非目标与未来工作 + +- 原生 claude(claude-code-agent-sdk)/ 原生 codex:`EngineKind` 已预留 + `ClaudeCode`/`CodexCli`,v2 实现。 +- `chat.history`:不做。 +- `mode_switch` interaction:不合成(协议允许 Provider 不声明该能力)。 +- A2A 出口层(让非 BCS 网络调用本桥引擎):远期可选。 +- chat.send 排队(替代 429)、SessionStore/InteractionRegistry 持久化 + (BCS 自身首版亦为进程内存):按运行需要再做。 +- cfuse `proxy` 模式(HTTP 常驻)替代 per-turn 子进程:实现期若并发/冷启动 + 成为瓶颈再评估。 + +## 10. 参考资料 + +- `src/bcs/docs/bcs-provider-2.0-sse-protocol.md` — Provider 2.0 SSE 权威协议 +- `docs/bot-provider-integration.md`(仓根)— Provider 集成契约(token/方法/错误) +- `src/bcs/crates/adapters/http/bcs-provider-http/` — BCS 侧传输实现与合约测试 +- aix-engine-workspace `crates/relay/` — 引擎驱动参考(`runtime/mod.rs` 的 + provider 抽象与 `RuntimeEvent`、`sessions/inject.rs` 的 inject/transcript + 模式、`interactions/` 的 pending/resolve 模式、`runtime/claude.rs` 的 + SDK interrupt/zombie reap)。仅参考,不产生依赖。 +- `src/baas/docs/2026-08-19-baas-bcn-interaction-sse-design.md` — 引擎事件 → + BCN interaction 的白名单转换与容错参考