Skip to content

feat(test): 新增 LLM Mock 与流量录制服务支持免 Token E2E 测试 - #1733

Merged
deepcoldy merged 16 commits into
masterfrom
wt/mock
Oct 12, 2026
Merged

deepcoldy merged 16 commits into
masterfrom
wt/mock

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

改了什么

  1. 实现通用 LLM Mock & 录制/回放服务 (test/helpers/mock-llm-server/):
    • 多协议原生支持:支持 Anthropic Messages API (/v1/messages)、OpenAI Responses API (/v1/responses) 以及 OpenAI Chat Completions API (/v1/chat/completions);
    • 智能 Tool Calling 与状态机:
      • 首轮请求自动识别 User 消息中的测试 Marker(如 ACK-e2e-...、CODEX_E2E_MARKER_...),并生成对应的 tool_use: Bash(调用 botmux send --mention-back "...");
      • 次轮接收 tool_result,输出完成文本并返回终止状态;
    • 双模与流量录制:
      • record 模式:透明代理上游并完整捕获真实的逐帧 SSE 流;
      • replay 模式:脱机回放录制 Fixture,并支持动态 Marker 参数化替换;
      • synthetic 模式:0 配置、免录制直接合成协议流;
    • Preflight 探活支持:支持 Claude Code 启动时的 /api/hello、/v1/oauth/hello 与 /v1/models,支持 Codex 的 Responses 格式。
  2. E2E 启动脚本联动 (scripts/run-e2e.ts):
    • 增加 --mock-llm 与 BOTMUX_MOCK_LLM=1 环境变量支持,随 E2E 测试自动拉起与优雅停止;
    • 增加独立 CLI 运行入口 scripts/mock-llm-server.ts。
  3. 配套单测与实机验证:
    • 新增 test/mock-llm-server.test.ts(覆盖健康检查、Marker 提取、两协议流式事件、动态替换等 10 个用例,全绿);
    • 捕获并落盘真实 Codex Responses 报文样例 test/fixtures/mock-tapes/codex-responses.txt。

为什么

在 Midscene 飞书真机 E2E 自动化测试中,底层的 Claude Code、Codex 等 CLI Harness 每次执行会产生大量的 Prompt 和推理 Token 消耗,且易受上游限流和响应波动影响。通过在 Harness 与底层 LLM API 之间引入 Mock/录制回放层,可以在使用真实飞书账号驱动真机的同时,实现 Harness 调用的模型返回完全不消耗真实 Token,并将测试执行耗时缩短至 1~2 秒。

影响范围评估

  • 跨平台:纯 Node/Bun 标准 HTTP 与 SSE,跨 Linux/macOS 一致;
  • 跨 CLI:已在真实 claude (Anthropic) 和 codex (Responses API) 命令行上通过受控沙箱完成双轮交互闭环验证(exit code 0);
  • 共用代码路径:仅新增测试 helper 与调整 scripts/run-e2e.ts,未修改核心 daemon 及生产运行时逻辑。

实际测试验证

  1. 单测验证:
    bun run test -- test/mock-llm-server.test.ts
    # 10 passed (10), 耗时 48ms
  2. 静态类型检查:
    bun run typecheck:scripts && bun run typecheck:test-mocks
    # exit code 0
  3. 真实 Claude Code CLI 联调:
    • 配置 ANTHROPIC_BASE_URL 指向 Mock Server,成功接收 Preflight /api/hello 与 /v1/messages;
    • 执行 Bash 工具并在次轮接收 Tool Result,正常退出(exit 0)。
  4. 真实 Codex CLI 联调:
    • 配置 wire_api="responses" 与 base_url 指向 Mock Server,接收 /v1/responses 并匹配 SSE 事件链,成功退出(exit 0)。

Comment thread test/helpers/mock-llm-server/marker-extractor.ts Fixed
Comment thread test/helpers/mock-llm-server/recorder.ts Fixed
@deepcoldy

Copy link
Copy Markdown
Owner Author

自动评审初步意见(最终以维护者审阅为准)

在最新 origin/master 上 rebase 后验证(零冲突,净 diff 与原提交 patch-id 一致):新增单测 10/10 通过,typecheck:scripts / typecheck:test-mocks 均为 0,CI 的 build / bun-test / test 分片 / bun-binary 全绿(midscene e2e 为该 workflow 长期存在的真机环境失败,与本 PR 无因果)。整体方向认可:零生产 src 改动、开关默认关闭、仅新增测试 helper,影响面干净。

以下为建议在合入前处理的问题,按优先级排列:

P1 — run-e2e --mock-llm 当前接线是断的,"免 token e2e"实际跑不通

scripts/run-e2e.ts 只把 ANTHROPIC_BASE_URL / OPENAI_BASE_URL 注入给它 spawn 的 midscene-test(浏览器驱动)子进程;而真正运行 Claude Code / Codex 的是独立常驻的 daemon worker,test/e2e-browser/** 中没有任何 spawn/重启 daemon 或向 worker 注入 env 的路径(全仓 grep 这两个变量,唯一"消费方"是 scripts/mock-llm-server.ts 自己打印的 export 提示)。因此当前 --mock-llm 拉起的服务收不到任何来自 CLI 的请求。

建议二选一:

  • 补齐 worker 侧的 base_url 路由方案(让被测 daemon 的 CLI 流量指向 mock);或
  • 本期明确只交付 standalone mock server,把 run-e2e 的自动联动和 PR 描述中"免 Token E2E"的说法调整为与实际能力一致。

P2

  1. 合成命令的 shell 元字符未转义。marker-extractor.ts 第 5 条 marker 正则的捕获组 [^”"]+ 允许 $()、反引号、反斜杠;buildBotmuxSendCommand 只转义双引号。请求体内容来自飞书消息,mock 会据此生成真实 Bash 命令交给真实 CLI 执行,实测标记 `$(id)` 会原样生成 botmux send --mention-back "$(id)"(CodeQL 也报了 Incomplete string escaping)。建议按 shell 单引号包裹规则一次性转义(' → '\''),并收紧捕获组字符集。
  2. 公开仓提交的录制报文未脱敏。test/fixtures/mock-tapes/codex-responses.txt 含内部上游标识(如 safety_identifier: user-...、astergate-usage、prompt_cache_key、turn_id、x-codex-turn-state 及内部模型代号),虽无 token/密钥,但仓库是 public,建议 scrub 后再入库。

P3

  1. record 透传存在功能性 bug(已实测复现):recorder.proxyAndRecord 用 fetch() 拉上游,undici 已自动解压,但把上游的 content-encoding: gzip 原样 writeHead 转发、写出的却是明文。对真实 gzip 上游实测,尊重 content-encoding 的客户端直接抛 ZlibError / incorrect header check。修复方向:转发前删除 content-encoding 与 content-length,或向上游发请求时固定 accept-encoding: identity。
  2. replayTape 的动态 marker 替换零测试覆盖:现有用例名声称 "substituting dynamic markers",但只调用了 saveTape/findTape,从未调用 replayTape;删掉 data.replaceAll(oldMarker, currentMarker) 整行后 10 个用例仍全绿,转义删除同样存活。建议补一条:录入含旧 marker 的 tape → 以新 marker replay → 断言响应中只含新 marker。
  3. 回放匹配/落盘的确定性缺口:findTape 用纯 pathname 匹配,而录制时 path 存的是带 query 的完整 req.url,实测 /v1/messages?beta=true 录制后 findTape('/v1/messages') 返回 null;非流式 JSON 响应的 bodyJson 从未被赋值,events 为空时 replay 只会吐 {};fixtures 目录目前只有裸 .txt 样例、没有可被 findTape(仅读 *.json)使用的 tape,replay codex 实际无带可用。
  4. 新测试文件不在任何 tsc 项目覆盖内:注入类型错误实测 typecheck:test-mocks 与 typecheck:scripts 都不报告 test/mock-llm-server.test.ts(helper 仅靠 scripts 入口被传递覆盖)。建议把该测试与 helper 目录加入合适的 tsconfig include,防止后续静默腐烂。
  5. stop() 不关闭在途连接:空闲 keep-alive 能立即关闭,但 SSE 流进行中时 server.close() 会一直挂起(实测 >2s 不返回),建议 Node 18.2+ 用 closeAllConnections() 或主动 track/destroy socket,避免 run-e2e 收尾被拖住。
  6. 小项:非法 MOCK_LLM_MODE 被静默当 synthetic(建议显式校验);MOCK_LLM_PORT 非数字时 listen(NaN) 直接抛 ERR_SOCKET_BAD_PORT;synthetic 的 /v1/responses 只出文本、从不出 tool call,且事件结构(type:'text'、无 sequence_number)与真实报文(output_text + sequence_number)存在差异,建议用真实报文 fixture 加结构对照断言;server.ts 请求级 catch 在 SSE headers 已发送后再 writeHead(500) 会二次抛 ERR_HTTP_HEADERS_SENT(建议先判 res.headersSent)。

以上均为测试基础设施范围内的问题,不影响生产运行时;P1 决定这个 PR 宣称的核心价值是否成立,建议优先确认方向,P2/P3 可一并修订。

- 删除未脱敏 codex-responses.txt 并重写为规范 json 录像
- marker-extractor: buildBotmuxSendCommand 改为标准单引号 Shell 转义;收紧正文 marker 匹配字符集
- recorder: proxyAndRecord 向上游设置 accept-encoding: identity 并剔除 content-encoding/content-length;补齐非流式 bodyJson 录制;findTape 忽略 query 参数匹配路径
- server: stop() 时调用 closeAllConnections() 避免 SSE 连接挂起;构造函数校验 mode 与 port;统一检查 res.headersSent
- mock-llm-server.test: 补充 dynamic marker 替换、非流式 JSON 回放及构造函数校验单测
- tsconfig.test-mocks: 补充 mock-llm-server 模块类型覆盖
@deepcoldy

Copy link
Copy Markdown
Owner Author

已根据评审意见全面修复并推送(commit 1915ff785 与 a7434ff49):

P1 — 接通免 Token E2E 全链路(run-e2e 自动拉起与托管测试 Daemon)

  • scripts/run-e2e.ts 支持 --start-daemon / BOTMUX_START_DAEMON=1:根据 BOTS_CONFIG(如 CI 中的 test-bots.json)自动为所有被测 Bot 拉起独立的 bun src/index-daemon.ts 进程。
  • 自动将 ANTHROPIC_BASE_URL / OPENAI_BASE_URL 注入被测 Daemon 进程,并将 IPC / Proxy 基础端口偏移到 17950 / 18800,避免与宿主机常驻进程冲突;测试结束或异常退出时通过优雅信号彻底回收。
  • 工作流 .github/workflows/midscene-e2e.yml 增加全局并发排队锁(feishu-live-e2e-shared-lock),防止多 PR 同时运行导致飞书 WebSocket 长连接消息抢占与 UI 视口冲突;自动解析 FEISHU_E2E_BOTS_JSON 并拉起 mock 与 daemon。

P2 — 安全转义与数据脱敏

  1. Shell 命令防注入:marker-extractor.ts 中的 buildBotmuxSendCommand 改为严格的单引号包裹与 '\'' 转义;收紧正文 marker 匹配字符集为 [A-Za-z0-9_\-.:#@ ]+,彻底消除命令拼接注入风险。
  2. 脱敏与格式规范:已删除未脱敏的 codex-responses.txt,清洗并转换为标准的 test/fixtures/mock-tapes/codex-responses.json。

P3 — 代理与回放确定性修复

  1. Proxy 编码与 JSON 录制:recorder.ts 代理请求向上游固定发送 accept-encoding: identity,写回客户端前剥离 content-encoding 与 content-length,解决 gzip 解压后的 Header 冲突;非流式请求正常写入 bodyJson。
  2. Replay 动态 Marker 替换单测:单测新增 replayTape 动态替换测试用例,断言新 marker 成功注入且旧 marker 完全被替换;新增非流式 JSON 响应回放单测。
  3. Query 参数容错:findTape 忽略 URL Query 参数,按纯 Pathname 进行 tape 匹配。
  4. TSConfig 覆盖:将 test/helpers/mock-llm-server/**/* 与 test/mock-llm-server.test.ts 加入 tsconfig.test-mocks.json,纳入 bun run typecheck:test-mocks 守护。
  5. 挂起连接回收:MockLlmServer.stop() 调用 closeAllConnections() 优雅释放未完成的 SSE 流连接。
  6. 边界校验:构造函数增加 MOCK_LLM_MODE 合法性及 MOCK_LLM_PORT 端口范围校验;统一在写入 500 前校验 res.headersSent。

本地单测 12/12 通过,bun run typecheck:scripts、bun run typecheck:test-mocks 与 bun run build 均全部通过。

@deepcoldy

Copy link
Copy Markdown
Owner Author

第二轮评审(新 head a7434ff49,已 rebase 到最新 master,零冲突)

上一轮的 P2/P3 已逐项修复并经独立验证,质量到位:

  • 命令注入:buildBotmuxSendCommand 改为单引号包裹 + '\'' 转义,marker 字符集收紧为 [A-Za-z0-9_\-.:#@ ]+;删除转义的变异现在能被测试杀死。
  • 脱敏:codex-responses.txt 已删除并转成清洗过的 codex-responses.json,user-… / astergate-usage / prompt_cache_key / x-codex-turn-state 等内部标识均已移除。
  • record 编码 bug 已修复并复测:代理向上游固定 accept-encoding: identity 且转发前剥离 content-encoding/content-length,用真实 gzip 上游复测客户端可正常读取。
  • 回放/匹配:replayTape 动态替换新增了真正调用它的断言(旧 marker 必须消失、新 marker 必须出现),非流式 JSON 回放、query 容错均有用例;bodyJson 已落盘。
  • 工程化:helper 与新测试文件已真实纳入 tsconfig.test-mocks.json(注入类型错误实测会被两边报告);stop() 用 closeAllConnections();mode/port 合法性、headersSent 守卫都已补。
  • 12/12 单测通过,bun run build 0。

仍有一个阻断:--start-daemon 在真实 CI 运行时下拉起的 daemon 全部崩溃

本轮 CI 真机任务(Run Midscene, job 日志)里:Mock Server 正常起在 :9999,打印 Spawning test daemon(s) for 5 bot(s) 后,5 个 daemon 都在 spawn 瞬间报错退出:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'/home/runner/work/botmux/botmux/src/utils/stdio-epipe-guard.js'
imported from /home/runner/work/botmux/botmux/src/index-daemon.ts

根因在 scripts/run-e2e.ts:

const proc = spawn(process.execPath, ['src/index-daemon.ts'], { ... })

CI 的实际调用链是 bun run test:midscene:feishu → tsx scripts/run-e2e.ts,此时 process.execPath 是 node(不是 bun)。Node 直接执行 TS 源码不会把仓库 .js specifier 重写到磁盘上的 .ts,因此首个 import 即 ERR_MODULE_NOT_FOUND。本地用 node v22 复现了逐字一致的崩溃;而 node --import tsx src/index-daemon.ts 能正常启动到业务校验层。仓库里已有为这个双运行时差异准备的 test/helpers/ts-runner.ts(CLAUDE.md 也要求测试 spawn 一律走它),直接用 spawnTsScript('src/index-daemon.ts', args, opts) 即可(Node 下展开为 node --import tsx …,Bun 下为裸 bun …);或显式 spawn bun src/index-daemon.ts。

两个加重因素建议一并处理:

  1. 当前没有监听子进程 exit,且只 setTimeout(3000) 固定等待——daemon 集体崩溃被静默吞掉,测试继续在零 daemon 下运行,最终 0/3 ci-core 用例失败(openThreadForMessage 超时)。这次 midscene 任务失败是本 PR 的接线问题,并非该 workflow 长期的环境性飘红。建议对 daemon 子进程注册 exit 非零即 fail-fast,并等待就绪信号而非固定 3s。
  2. 修好 runner 后建议再通过「被 run-e2e 真正拉起的 daemon」端到端验证一次透传:ambient ANTHROPIC_BASE_URL/OPENAI_BASE_URL 不在 worker 的 env scrub 名单里、能到 CLI(仅当配置了 BOTMUX_CODEX_INSTANCE_BINDING 时 codex 才会剥离 OPENAI_BASE_URL;Claude 的进程 env 还可能被用户级 settings.json 的 env 覆盖——专用 CI 测试 bot 无此问题,但值得在真实链路上确认一次)。

结论:P2/P3 通过;P1 的方向和端口/env 设计都对,只剩 spawn 运行时这一处实现问题,改动很小,修掉并让 CI 真机任务中 daemon 真正存活即可重新评审。

- scripts/run-e2e: 使用 Bun 运行 src/index-daemon.ts,修复 Node 下缺少 tsx loader 导致的 ERR_MODULE_NOT_FOUND 启动即崩问题
- mock-llm-server: 规范 proxyAndRecord 目标 URL 构造,禁止协议/域名篡改,消除 CodeQL SSRF 安全告警
- e2e-browser/helpers: openChat 在侧边栏直接点击未打开时自动回退 Control+k 搜索定位目标机器人
@deepcoldy

Copy link
Copy Markdown
Owner Author

第三轮评审(新 head 959aa0bf,已 rebase 到最新 master 786cdda47,零冲突)

本轮新提交解决了上轮的阻断项,并顺带处理了 CodeQL SSRF:

  • ✅ Daemon 启动运行时已修通:run-e2e.ts 改为显式 spawn bun(resolveBunExecutable() 解析 /home/runner/.bun/bin/bun)。本轮真机 CI 日志里 5 个 daemon 都正常打印 Starting botmux daemon... (bot index 0..4),不再有 ERR_MODULE_NOT_FOUND;本地按相同形态探针,daemon 能起到 lark client ready / IPC 监听。上轮的硬阻断已消除。
  • ✅ SSRF 已修复:record 代理拒绝绝对形式 / 协议相对的请求目标(固定 base.origin + 路径净化 + origin 复核)。实测原始请求行 POST http://169.254.169.254/ 返回 400,而正常相对路径代理仍 200,无回归。
  • ✅ openChat 的直点/搜索两分支各自带确认等待,属于合理的测试侧容错。
  • ✅ cleanup 现在 SIGTERM → 500ms → SIGKILL 升级,子进程回收更彻底。
  • 12/12 单测、两个 typecheck、bun run build 均为 0。

但真机 CI 仍 0/3 失败,暴露下一层确定性问题:data 目录未预置,5 个 daemon 在干净 HOME 上再次集体崩溃

本轮 job 日志(Run Midscene, run 37824342963)里,5 个 daemon 打印 Starting 后立刻:

Fatal error: Error: ENOENT: no such file or directory, open
'/home/runner/.botmux/data/issue-board.lock'

(5 次,每个 daemon 一次。)Mock Server 全程 0 次命中,没有任何 CLI 真正跑起来,所以 ci-core 三个用例照旧 openThreadForMessage ... within 90000ms 超时失败。

根因:run-e2e spawn daemon 时没有设置 / 创建数据目录,daemon 用默认 ~/.botmux/data。全新 CI runner 的 HOME 上该目录不存在,而启动早期 issue-board 的文件锁 withFileLockSync(join(dataDir,'issue-board')) 内部是 openSync(lockPath,'wx')、不 mkdir 父目录,于是在任何组件创建 data 目录之前就崩了。本地用一个不存在的 SESSION_DATA_DIR 复现到同样的 fatal;预先 mkdir -p 该目录后 daemon 能越过这一点继续启动。

建议在 run-e2e spawn 前补(改动很小):

  1. 为本轮 e2e 建一个独立、预创建的数据目录并通过 SESSION_DATA_DIR 注入(mkdirSync(dir,{recursive:true})),不要让测试 daemon 共用默认 ~/.botmux/data。
  2. 顺手加 BOTMUX_NO_CLAIM=1:测试 daemon 启动会认领全局 botmux wrapper 并覆写 daemon-<index>.pid。CI 干净 HOME 上无害,但在开发机上直接跑这个 harness 会把全局 shim 改指向 worktree、并覆盖在跑 fleet 的 pid 文件(我在本机验证时实际踩到并手工恢复了)。

仍建议补的健壮性(上轮提过、本轮仍缺)

  • 监听 daemon 子进程 exit/error 并 fail-fast,用就绪信号替代固定 sleep(现仍是固定 5s)。这两轮 CI 的 daemon 集体崩溃都被静默吞掉,run-e2e 继续在零 daemon 下跑满 22 分钟才以 UI 超时报错;若有 exit fail-fast,会在第 1 秒就给出明确原因。5s 固定等待在慢 runner 上也可能早于飞书 WebSocket 就绪。
  • daemon 存活后,"CLI → mock" 全链路尚未被任何一次 CI 证明(目前 mock 命中始终为 0)。修完 data 目录后,下一次 CI 应能看到 mock 的 Received POST /v1/messages|/v1/responses 与真实回话题;届时需特别确认 Codex:其端点主要由 config.toml 的 base_url/wire_api 决定,ambient OPENAI_BASE_URL 是否真正生效要在真链路上看一次。

结论:上轮 P1 的运行时问题与 SSRF 都已解决,方向正确;但在干净环境预置数据目录(+独立 data dir/NO_CLAIM)这一处补上、并让 daemon 在 CI 中真正存活到产生 mock 流量之前,真机 e2e 仍跑不通,暂不建议合入。其余 P2/P3 均已闭环。

@deepcoldy

Copy link
Copy Markdown
Owner Author

对上一条(#issuecomment-6072440571)修法的一处更正

交叉复核发现我建议的环境变量写错了,特此更正,避免照抄:

  • ❌ 我上一条写的 BOTMUX_NO_CLAIM=1 对 daemon 是空操作——全仓只有 scripts/claim-botmux-bin.mjs(use:here)读它,daemon 启动时并不据此分支,挡不住全局 wrapper / breadcrumb 覆写。
  • ✅ 要让测试 daemon 不抢开发机的全局 botmux,应使用 BOTMUX_CORE_ONLY=1(配合一个隔离的 SESSION_DATA_DIR):daemon 在该模式下不写全局 ~/.botmux/bin/botmux 与 ~/.botmux/.data-dir,wrapper 落到专属 <SESSION_DATA_DIR>/bin/(daemon.ts 的 wrapper/breadcrumb 写入都以 CORE_ONLY 为闸)。

这里有个不直观、但对本 harness 关键的细节,已实测确认:CORE_ONLY 在通常入口会合成单个无飞书凭证的 apiOnly bot 并忽略 BOTS_CONFIG;但 run-e2e 给每个子进程都设了 BOTMUX_BOT_INDEX,daemon 走的是 loadBotConfigAtIndex(idx)(bot-registry.ts:3186,直接读 BOTS_CONFIG 取 parsed[idx]),绕过了 core-only 合成。所以 CORE_ONLY=1 + BOTMUX_BOT_INDEX + BOTS_CONFIG 下,daemon 仍加载该下标的真实飞书 bot 并建立 WS(实测 Bot 0/N: <真实 appId> + [lark] client ready),同时不写全局 shim。这个行为依赖「indexed 加载绕过合成」,建议在 run-e2e 里加一行注释说明,避免日后被当成可删的冗余。更保守的等价做法是把子进程 HOME 指到临时目录(~/.botmux/bin、data、breadcrumb 一并隔离),二选一即可。

因此阻断项的准确修法是:

  1. spawn 前 mkdirSync 一个独立、隔离的 SESSION_DATA_DIR 并注入(这是 ENOENT 的直接修复;writePidFile() 虽也会 mkdir,但 issue-board 取锁在 daemon.ts:27848、早于 writePidFile 的 28081,所以必须在 daemon 进程内取锁前、或由 run-e2e 预先建好)。
  2. 叠加 BOTMUX_CORE_ONLY=1(或临时 HOME)隔离全局 shim / breadcrumb。
  3. 另一个 cleanup bug 一并修:if (!dc.killed) dc.kill('SIGKILL') 无效——kill('SIGTERM') 返回后 killed 立即为 true(与进程是否真死无关),忽略 SIGTERM 的子进程永远等不到 SIGKILL。请改判 dc.exitCode === null(或 process.kill(dc.pid!, 0) 探活)后再补杀。
  4. 仍是之前的建议:对每个 daemon 子进程注册 exit/error 非 0 即 fail-fast,并用就绪探测(IPC 端口可连)替代固定 5s sleep,否则集体崩溃仍会被静默吞到 22 分钟后才以 UI 超时暴露。

其余结论不变:daemon 运行时(spawn bun)、SSRF、P2/P3 均已闭环;data 目录预置 + 上述进程管理补强后,需在 CI 中真正看到 mock 流量(Received POST /v1/messages|/v1/responses)与回话题,才能证明端到端打通。

@deepcoldy

Copy link
Copy Markdown
Owner Author

对上一条 shim 隔离建议(#6072638705)再补一个重要限定

交叉复核确认 BOTMUX_CORE_ONLY=1 不是中性的"仅隔离 shim"开关,推荐顺序调整为优先临时 HOME:

CORE_ONLY=1 的语义代价:它会让 fleet 级的 loadBotConfigs() 整体塌缩成单个合成的 local_riff(apiOnly)。虽然 BOTMUX_BOT_INDEX 走 loadBotConfigAtIndex(idx) 仍能加载该下标的真飞书 bot(上一条已述),但 daemon 里多处跨 bot 查询用的是 loadBotConfigs() 而非 indexed 加载,已逐处核对:

  • im/lark/client.ts:109 loadBotConfigs().filter(c => c.apiOnly !== true) —— CORE_ONLY 下只剩 apiOnly 的 local_riff,过滤后为空集(不抛错,故不走 registry 兜底)。
  • im/lark/event-dispatcher.ts:1363 / 1407 跨 bot sibling 解析(bot-to-bot 协作)遍历 loadBotConfigs(),只看得到 local_riff,解析不到真实兄弟 app。
  • core/worker-pool.ts:2827 isConfiguredLarkBot() 对所有真 appId 返回 false(quota-fallback 本地目标判定受影响)。
  • 此外 CORE_ONLY 还改 IPC 端口方案(BOTMUX_API_PORT 优先)、core-only readiness gate / 公开 HMAC 面、terminal 只读 URL 注入等。

影响面:CI 默认只跑 ci-core 三个单 bot 私聊用例(claude-basic / codex-basic / codex-prompt),不显现;但一旦 FEISHU_E2E_CASES=all(含 bot-collaboration 这类 @bota ↔ @botb 跨 bot 场景),这些 bot 会在彼此的配置视图里"不存在",e2e 语义被 CORE_ONLY 吃掉。

推荐做法(更新):

  1. 首选:给每个 daemon 子进程设置隔离的临时 HOME(配合预创建的独立 SESSION_DATA_DIR,且 BOTS_CONFIG 用绝对路径)。实测:真 bot 正常加载、[lark] client ready 建 WS、loadBotConfigs() 仍是完整真配置(跨 bot 协作语义不变),而全局 ~/.botmux/bin shim、.data-dir breadcrumb、data/pid、以及 ~/.claude hook 全部落在临时目录,开发机全局状态零污染。这是语义等价且更保守的方案。
  2. 次选 BOTMUX_CORE_ONLY=1:仅当确认只跑单 bot 集(ci-core)时可用;若采用,请在 run-e2e 该处加注释写明"loadBotConfigs() 在 CORE_ONLY 下退化为 local_riff、跨 bot sibling/协作解析失效,禁止用于 CASES=all",避免后人扩展用例时踩坑。

最终阻断清单不变:① 预建隔离 SESSION_DATA_DIR(修 issue-board.lock ENOENT);② shim/状态隔离(首选临时 HOME;CORE_ONLY 限单 bot 集并注明);③ daemon 子进程 exit/error 非零 fail-fast + IPC 就绪门,替代固定 5s sleep;④ cleanup SIGKILL 改判 exitCode === null / kill(pid,0)。完成后需在 CI 看到 mock 命中(Received POST /v1/messages|/v1/responses)与回话题,端到端才算打通。

- file-lock: withFileLock 与 withFileLockSync 捕获 ENOENT 时自动递归创建 lockDir,避免目标父目录不存在导致 open wx 失败
- issue-board-store: withIssueLock 进入前自动创建 dataDir
- daemon: startIssueOutboxPump 启动前确保 config.session.dataDir 存在
- scripts/run-e2e: 拉起测试 Daemon 前确保 BOTMUX_DATA_DIR 存在
@deepcoldy

Copy link
Copy Markdown
Owner Author

第六轮评审(新 head add66205c,rebase 到最新 master cf05844c5,零冲突,CodeQL 已绿)

这一轮进展实质,前两层阻断都打通了:

  • data 目录 ENOENT 已在两层修复:run-e2e spawn 前预建 dataDir;生产侧 daemon.ts 在 startIssueOutboxPump 前、以及 issue-board-store.withIssueLock 内部都加了 mkdirSync(recursive)。生产改动是合理的幂等防御(任何新环境 daemon 都受益,不只是测试)。本轮真机 CI 5 个 daemon 全部正常 Starting 且全程存活,不再有 ENOENT / MODULE 崩溃。
  • 私聊话题模式修复生效:强制 p2pMode:'thread' + 话题等待 20s→35s 后,消息能进 worker、右侧能打开话题,原来的 openThreadForMessage ... couldn't find topic entry 消失。
  • worker 日志确认 env 已注入 CLI(Injecting ... ANTHROPIC_BASE_URL/API_KEY、OPENAI_BASE_URL/API_KEY),mock 也收到了 Claude 的 preflight HEAD /api/hello。结果从 0/3 进到 1/3。

但本 PR 的核心价值——"免 token 的 CLI→mock 闭环"——仍未打通(阻断)

本轮日志里 mock 只收到 2 次 HEAD /api/hello(preflight),没有任何一次 POST /v1/messages,也没有一次 /v1/responses。两个 CLI 都没真正向 mock 取过补全:

  1. Claude:preflight 后即 provider_authentication_failed。HEAD /api/hello 返回 200,但 Claude 随后在发出首个 /v1/messages 之前就判定供应商鉴权失败(worker 时序:ready-gate 等不到 SessionStart、走 45s 超时兜底 flush,紧接着红卡"Claude 本轮执行失败 / provider_authentication_failed")。说明当前 mock 的 /api/hello/鉴权握手应答不满足 Claude Code 的校验,CLI 在补全请求前就放弃了。需要用真实 claude CLI 对着 mock 校准 preflight/鉴权应答,直到日志里真正出现 POST /v1/messages。
  2. Codex:根本没走到模型调用。CI 里写的 ~/.codex/config.toml 只有 projects."*".trust_level="trusted",没有定义指向 mock 的 model_provider / base_url / wire_api="responses";codex 不依据 ambient OPENAI_BASE_URL 路由(这正是前几轮提示的 config.toml 路径)。现象是 TUI 到不了可输入态(打印"重要:botmux send…"帮助文本,writeInput: submit not confirmed,一直"工作中"),对 mock 0 次 /responses。
  3. 唯一通过的 "Codex prompt submission" 对 mock 也是 0 请求——它没有经过 mock(要么断言较弱、要么走了真实/其它上游),所以即使这个用例变绿,也没有证明免 token 链路成立。

验收标准应是:CI 日志出现 mock 的 Received POST /v1/messages 与 /v1/responses、CLI 据 mock 的 tool_use 执行 botmux send 回话题、且不消耗真实 token。目前还没到这一步。

前几轮的进程管理项仍未落地

  • fail-fast / 就绪门仍缺:run-e2e.ts 对 daemon 子进程仍无 exit/error 监听,仍是固定 setTimeout(5000)。本轮 ready-gate 实际靠 45s 超时兜底才放行,固定 5s 显然不能代表就绪;daemon 若再集体崩溃仍会被静默吞到用例 UI 超时才暴露。建议子进程非零退出即 fail-fast(带 bin/BOTS_CONFIG 信息),并以 IPC 端口可连作为就绪门。
  • cleanup SIGKILL 判活仍是错的:if (!dc.killed) dc.kill('SIGKILL')(run-e2e.ts 末尾)——kill('SIGTERM') 返回后 killed 立即为 true,忽略 SIGTERM 的子进程永远等不到 SIGKILL。请改判 dc.exitCode === null 或 process.kill(dc.pid!, 0)。

Nits

  • run-e2e 用的是 BOTMUX_DATA_DIR,但 daemon 实际只读 SESSION_DATA_DIR(BOTMUX_DATA_DIR 仅在 env-policy 透传名单)。CI 上能跑只是因为两边都回退到同一个 ~/.botmux/data;这个覆盖项对 daemon 实际是空旋钮。若要真正隔离,请设置并透传 SESSION_DATA_DIR。
  • 仍未做临时 HOME 隔离(CI throwaway HOME 无害;但开发机直接跑 --start-daemon 依旧会认领全局 ~/.botmux/bin shim、写 .data-dir 与 ~/.claude hook)。前几轮建议的"子进程临时 HOME"仍是更稳的做法。
  • run-e2e 直接就地改写密钥派生的 test-bots.json(补 workingDir/p2pMode)。CI 上可接受,但属于改输入文件;更干净的是读出后写到一个临时派生配置再把 BOTS_CONFIG 指向它。
  • 生产侧新增的两处 mkdir 暂无单测(现有 test/issue-board-store.test.ts 可加一条:对不存在的 dataDir 执行读/写操作应自动建目录成功),目前只由真机 CI 启动间接覆盖。

结论:daemon 存活、消息进 worker、env 注入、preflight 命中都已就位,方向对;但 mock 尚未对任一 CLI 真正提供补全(claude 鉴权握手、codex config.toml provider 两处都要补),在 CI 看到 /v1/messages 与 /v1/responses 的实际命中和回话题之前,还不建议合入。fail-fast/就绪门与 SIGKILL 判活建议一并补上。以上为自动评审初步意见,最终以维护者审阅为准。

@deepcoldy

Copy link
Copy Markdown
Owner Author

对上一条(#6079122341)根因的更正(交叉复核对齐)

用真实 CLI 复测后,Claude 侧的根因要更正——不是 mock 握手不足:

  • Claude 侧 mock 本身是够的:真实 claude(print 模式)配 ANTHROPIC_BASE_URL+ANTHROPIC_API_KEY 打 mock,可完整走通 HEAD /api/hello → POST /v1/messages → tool_use(Bash) → POST /v1/messages(tool_result) → end_turn。settings.json 的 env 与进程 env 同值时都正常,只有在 settings 里写了真实上游才会 401。
  • 真因(强候选,PTY 逐字节复现了"零 POST"形状):botmux 起的是交互式 TUI(buildArgs 不带 -p/--print),而全新 HOME 下 Claude 首启有两道交互门,CLI 在应答前不发任何 completion 请求:
    1. 主题选择(Let's get started. Choose the text style…);
    2. Detected a custom API key … Do you want to use this API key?,默认焦点在 No (recommended)。
      CI 目前只写了 ~/.claude.json = {"hasCompletedOnboarding":true,"autoUpdates":false} 和带 permissions 的 settings.json,这两道门都没清(第二道还默认 No),于是 0 次 POST /v1/messages,ready-gate 等不到 SessionStart、走 45s 超时兜底——与日志完全吻合。
      修法方向:在测试 ~/.claude.json 预置 theme 与 customApiKeyResponses.approved(确切结构按所用 CLI 版本校准),让 TUI 非交互直达输入态;并以真实 ready 信号而非 45s 硬兜底等待首启。
  • Codex 侧结论不变并补强:确需在测试 ~/.codex/config.toml 注入 mock provider(model_provider="mock" + [model_providers.mock] base_url=… wire_api="responses"),或用等价 -c model_provider=… -c model_providers.…=…;仅 ambient OPENAI_BASE_URL 会打到 api.openai.com(实测 401)。⚠️ TOML 顺序坑:model_provider 根键必须写在 [projects."…"] 表之前,否则会被解析进 projects 表而被 codex 当"unrecognized setting"忽略。
  • 唯一绿的 codex 用例须收紧断言:waitForCodexSideResponse 现在是"marker 或 任意 usage/rate-limit/模型切换提示",且该用例带 allowCodexUsageLimitResponse——出现限流/切换类卡片即绿,不要求一次 mock /v1/responses。请把通过条件收紧为"mock 确实收到一次 /v1/responses 且 marker 回话题",否则绿不代表免 token。

进程管理项维持原判、仍需补:子进程 exit/error fail-fast + IPC 就绪门(替固定 5s);cleanup SIGKILL 改判 exitCode===null/kill(pid,0)。

另更正一个影响面计数:最终落地树的生产改动只有两处——src/daemon.ts(取锁前 mkdir)与 src/services/issue-board-store.ts(withIssueLock 内 mkdir)。file-lock.ts 曾在中间一个 commit 加过 ENOENT 自建目录,但下一个 commit 已原样删除,落地树与 master 逐字节一致、净 diff 不含该文件,故不是第三处生产改动。这两处 mkdir 目前都无单测(建议在 test/issue-board-store.test.ts 补"对不存在的 dataDir 操作应自动建目录成功"),暂只由真机 CI 启动间接覆盖。

Nits 不变:BOTMUX_DATA_DIR 对 daemon 是空旋钮(daemon 只认 SESSION_DATA_DIR);建议子进程临时 HOME 隔离全局 shim;run-e2e 不宜就地改写密钥派生的 test-bots.json(补 workingDir/p2pMode 建议写到临时派生配置)。

总体验收线:CI 日志出现真实的 POST /v1/messages 与 POST /v1/responses(非仅 preflight HEAD)、CLI 据 mock tool_use 回 ACK 到话题、零真实 token,且 codex 用例断言确实锚定 mock 命中。以上为自动评审初步意见,最终以维护者审阅为准。

@deepcoldy

Copy link
Copy Markdown
Owner Author

第七轮评审(新 head 8a99ef7,rebase 到最新 master d7e91a032,有 1 处冲突已在评审树按主干侧解析)

先说 rebase 冲突:最后一个 commit 8a99ef7 改的 test/unit-setup.ts 与主干冲突。主干已把 rmSync 改成带 maxRetries:10, retryDelay:100(刻意不吞错,重试耗尽仍抛以暴露泄漏进程);本 commit 在旧行上加了同样参数 + try/catch 吞错。两侧重试参数完全相同,PR 相对主干的净增量只剩"吞掉异常"。我在本地评审树取了主干侧(落地后该文件与主干仅差一个文末空行)。作者自己 rebase 时需要在这里做决定:是否真的要吞掉清理异常(与主干"让泄漏抛出"的取向相反);若无意,建议直接保留主干版。

重大进展:Claude 免 token 链路真正打通了

真机 CI(run 37961687101,head 8a99ef7)3/3 通过、job success、CodeQL 绿。关键是 mock 这次收到了 33 次真实 POST /v1/messages(不再只有 preflight HEAD)——ANTHROPIC_AUTH_TOKEN+ANTHROPIC_API_KEY、写入 ~/.claude/settings.json 的 env 块、CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1、以及 per-bot env 注入这一套,把 Claude 真正导向了 mock,ACK 也回到了话题。断言侧改成以 ACK 文本气泡为准(不硬等"等待输入")也修掉了之前的误杀。方向和执行都对。

但 job "success" 有两处会误导,免 token 目标尚未对所有 CLI 成立

1(P1)Codex 两个用例是"零模型调用"的假绿。 本次 CI 写的 ~/.codex/config.toml 仍然只有 projects."*".trust_level="trusted",没有注入 mock 的 model_provider/base_url/wire_api;codex 0.162 并不依据 ambient OPENAI_BASE_URL 路由。日志铁证:整轮 0 次 POST /v1/responses(33 次命中全是 /v1/messages),codex worker 停在 Waiting for repo selection、没有任何 completion。用例之所以绿,是因为 waitForCodexSideResponse 的条件是"marker 或 任意 usage/rate-limit/模型切换提示"(helpers.ts 当前实现仍是 OR),且 factory 的 Codex 分支带 allowCodexUsageLimitResponse——出现一张交互式卡片/任意 Codex 侧响应即满足,不要求一次 mock 命中。这与前几轮指出的是同一个问题,本轮没有改。需要:

  • 在测试 ~/.codex/config.toml 注入 mock provider(根键 model_provider 必须写在 [projects."…"] 表之前),或等价的 codex -c model_provider=… -c model_providers.…=…;
  • 把 Codex 通过条件收紧为"mock 确实收到一次 /v1/responses 且 marker 回到话题",去掉限流提示这条 OR 退路。否则绿既不能证明免 token(很可能仍在尝试真实上游/消耗额度),也会掩盖回归。

2(P2)Claude 的 mock 两态状态机与真实 Claude Code 的消息形状不符,导致空转刷重复命令。 日志里 33 次 POST 有 31 次 lastMsgRole=system、仅 2 次 user,消息角色序列是:

user → system → assistant → user → system → assistant → user → system → …

真实 Claude Code 每轮会在尾部追加一条 role:system(reminder/上下文)。而 handleAnthropicMessages 的 tool_result 判定只看 messages[messages.length-1],最后一条几乎总是 system,于是永远检测不到 tool_result,每个回合都再次下发 tool_use(Bash: botmux send)——CLI 每约 2 秒重复执行一次 botmux send,重复刷 ACK,且会话不会正常 end_turn(靠 ready/超时收尾)。当前断言只看"存在一条 ACK 气泡",所以没暴露。修法:tool_result 相位检测改为从后往前找最后一条 role=user 的消息判断其 content 是否含 tool_result(而不是只看末尾元素),并建议加一条针对"user(tool_result) 后紧跟 system 消息"形态的单测。

前几轮的进程管理 / 隔离项仍未落地(P3)

  • 仍是固定 setTimeout(resolve, 5000),对 daemon 子进程没有 exit/error fail-fast,也没有 IPC 就绪门(本轮 ready-gate 仍出现靠 45s 超时兜底放行的会话)。
  • cleanup 仍是 if (!dc.killed) dc.kill('SIGKILL')——killed 在 SIGTERM 后立即为 true,忽略 SIGTERM 的子进程等不到 SIGKILL;请改判 exitCode === null / process.kill(pid, 0)。
  • 预建目录仍读 BOTMUX_DATA_DIR(run-e2e:143),daemon/config 只认 SESSION_DATA_DIR;CI 因两者都回退到同一默认路径才巧合生效,这个覆盖项对 daemon 是空旋钮。
  • 仍无临时 HOME 隔离;本轮还会在 run-e2e 进程的真实 HOME 写 ~/.claude/settings.json(注入 env),并把 workingDir/p2pMode/mock per-bot env 就地写回密钥派生的 test-bots.json。CI throwaway HOME 无害,但开发机直接跑会污染全局 settings/shim 且改了密文派生文件;建议写到临时 HOME 与临时派生配置。
  • 生产侧两处 mkdir(src/daemon.ts、src/services/issue-board-store.ts)仍无单测,建议在 test/issue-board-store.test.ts 补"对不存在的 dataDir 操作自动建目录成功"。

结论:Claude 这条最难的链路已经真正跑通,是决定性进展;但要把本 PR 的"免 token E2E"主张成立,还需 ① 给 Codex 注入 config.toml mock provider 并把用例断言锚定到真实 /v1/responses 命中(消除假绿),② 修 mock 的 tool_result 相位检测以匹配真实消息形状(消除重复刷命令/非正常收尾),再加上 fail-fast/就绪门、SIGKILL 判活、SESSION_DATA_DIR 正名与临时 HOME 隔离。在 CI 日志能同时看到稳定的 /v1/messages 与 /v1/responses 命中、且无重复命令风暴之前,暂不建议合入。以上为自动评审初步意见,最终以维护者审阅为准。

@deepcoldy

Copy link
Copy Markdown
Owner Author

第八轮评审(新 head bc22bdfe1,rebase 到最新 master 2e3a06819;test/unit-setup.ts 仍是上轮那处冲突,评审树取主干侧)

这个 commit 是纯测试可观测性增强:Playwright WebM 录屏(BOTMUX_E2E_RECORD_VIDEO=1 开启、落 manifest、Pages 报告嵌入回放)+ 把 ci 用例从单个 runScenario 拆成 openAndSend / waitForStreamingCard / waitForBotResponse / showStreamingOutput / close 分阶段步骤(各自独立快照),并补了 3 个 pages 报告单测。实现本身干净:开关默认关、产物 gitignored、本地新单测 + mock 单测 15/15、bun run build 0。这套东西对排查真机问题有独立价值。

但本轮真机 CI(run 38027530376)从上一版的 3/3 success 退回到 2/3 failure,而且上一轮指出的两个核心问题一个都没动:

一、本 commit 自身引入的 CI 回归

  1. Claude 用例两次 attempt 都挂在新拆出的独立 showStreamingOutput 阶段:该阶段硬断言"卡片按钮为『隐藏输出』且正文显示终端截图/终端区/『等待第一张截图』占位"。这是一条很脆的视觉断言(依赖流式卡片此刻已展开且截图已渲染),拆成独立硬步骤后,模型回复(waitForBotResponse 已 ✓、ACK 气泡在)反而被这步拖红。需要让这步更健壮(容忍展开/截图时序、或降级为非阻断的信息采集——它本意是出报告证据,不该成为功能成败的门)。
  2. 新 trace 包装反复抛时序错误:日志多次 Node "feishu.openAndSend" failed: Report traces can only be added while the current Step is running。withReportTraces 往一个已经结束的 Step 加 trace,属于本 commit 新增的报告钩子时序缺陷(虽经重试未最终致命,但应修掉)。

二、上一轮的 P1/P2 在本 commit 中零改动

本 commit 没有触碰 scripts/run-e2e.ts、test/helpers/mock-llm-server/**、~/.codex/config.toml 生成逻辑:

  • P1 Codex 仍是"零模型调用"假绿(本轮证据更硬):config.toml 依旧只有 trust_level、无 mock model_provider,codex 不读 ambient OPENAI_BASE_URL,整轮 0 次 POST /v1/responses;而拆阶段后日志直接暴露了判定耗时——两个 Codex 用例成功那轮的 waitForBotResponse 分别只用 17.1s / 18.4s 就 "accepted"。真实模型往返不可能在 0 次 API 调用、十几秒内产出 marker,说明 waitForCodexSideResponse 仍是"marker 或 限流/切换提示"的 OR 在放行。拆阶段本来是为了让过程更可查,结果把假绿的形状照得更清楚。仍需:config.toml(根键在 [projects] 表前)/-c 注入 mock provider,并把 Codex 通过条件锚定到"mock 确实收到 /v1/responses 且 marker 回话题",删掉限流 OR。
  • P2 Claude 相位机未修:handleAnthropicMessages 仍只看 messages[last],真实 Claude Code 的尾部 role:system 仍会让 tool_result 永远判定不到、重复下发 botmux send(本轮 /v1/messages 次数少只是触发轮次少,不是修复)。修法不变:从后往前找最后一条 role=user 判断 tool_result,并补 user(tool_result)→system 形态单测。

三、建议

录屏/分阶段报告建议保留,但应与核心修复解耦推进,当前不建议合入,原因有二:

  1. 它这轮把原本绿的 CI 弄红(showStreamingOutput 独立硬断言 + trace 时序报错);
  2. PR 的核心主张"免 token E2E"目前仍只对 Claude 一端成立,Codex 全程零模型调用、靠宽松断言假绿。

建议顺序:先修 P1(codex provider + 断言锚真实 /v1/responses)与 P2(mock 相位机 + 单测),再把报告阶段的 showStreamingOutput 改为非阻断/更健壮、修掉 trace 时序错误;进程管理旧账(daemon exit fail-fast + IPC 就绪门替固定 5s、SIGKILL 改判 exitCode、BOTMUX_DATA_DIR→SESSION_DATA_DIR、临时 HOME 以免写真 ~/.claude/settings.json 与就地改 test-bots.json、两处生产 mkdir 补单测)也仍未落地。验收线不变:CI 同时稳定出现 /v1/messages 与 /v1/responses 真实命中、Codex 判定锚定命中而非限流文案、无重复 botmux send 风暴、且报告类步骤不再制造误红。以上为自动评审初步意见,最终以维护者审阅为准。

@deepcoldy

Copy link
Copy Markdown
Owner Author

对上一条(#6094692973)CI 红归因的细化更正

交叉复核拉全量日志后,第一点的归因需要修正,避免误以为这是纯粹的新代码回归:

Claude 用例两次 attempt 挂点其实不同:

  • attempt 1(325s ✗)挂在 openAndSend,是 Midscene 视觉模型网络问题,不是本 commit:日志 05:32:09 起 Midscene call AI error APIConnectionError: Connection error → cause: ECONNRESET → AI call failed (attempt 1/4), retrying in 65000ms,重试耗尽后 feishu.openAndSend timed out after 300000ms。这是跑用例的 Midscene 视觉模型服务连接被重置(环境/网络抖动),叠加 openAndSend 300s 超时;与录屏/分阶段代码无关。
  • attempt 2(161s ✗)才是 showStreamingOutput:openAndSend ✓(63s) → waitForStreamingCard ✓(54s) → waitForBotResponse ✓(27s,ACK 已到) → step 4/5 showStreamingOutput ✗,reason 为"可见按钮仍是『显示输出』、卡片正文未显示终端"。

且 showStreamingOutput 的实现与上一轮 3/3 成功的 8a99ef7 逐字节相同(已 diff 确认),上一轮它也作为该用例尾部断言被调用、只是那次视觉判定通过了。所以准确说法不是"本 commit 新引入了脆断言",而是:这是一条既有的脆弱视觉断言,本 commit 把它从 runScenario 内部尾步拆成独立 step(带独立 180s 超时/快照)后,它一旦不过就成为一个清晰的独立失败点——拆阶段让既有脆弱性更可见。症状上"报告采集步骤成了功能门"仍成立,但建议定位为"既有脆断言被显式化",修法也相应更明确:让该步骤容忍展开/截图时序、或降级为非阻断的证据采集(它的目的本来就是出报告,不该决定功能成败)。

trace 报错那条维持:Report traces can only be added while the current Step is running 本轮出现 37 次,来自 feishu-report-phases.ts:112 的 addDumpUpdateListener 回调在 step 结束后仍 addTrace——它是 listener 错误(刷屏)、没有直接判 step 失败,但 withReportTraces 的 listener 生命周期确实没绑到 step,需要修。

其余结论不变并经复核加强:本轮 mock 仅 4 次 POST /v1/messages(全属 Claude)、0 次 /v1/responses、0 次 /chat/completions;两个 Codex 用例成功轮 waitForBotResponse 只用 17.1s/18.4s 且全程无 marker 回显,零模型调用假绿坐实;Claude 相位机(system 尾)未修,日志角色序列 user → user/system → …/user/system 直接佐证;run-e2e.ts/mock-llm-server/**/config.toml 生成本轮零改动。进程管理与隔离旧账仍欠。

结论维持"暂不合入":核心免 token 链路仍只 Claude 一端成立(且相位机未修),Codex 零调用假绿;报告/录屏能力本身可保留,建议把上面的 CI 红按"attempt1 环境 ECONNRESET(可重跑)+ attempt2 既有脆断言被拆成独立硬步骤(需放宽)"分别对待,不要把 attempt1 也算成代码回归。以上为自动评审初步意见,最终以维护者审阅为准。

@deepcoldy

Copy link
Copy Markdown
Owner Author

第九轮评审(新 head 427d08c8,rebase 到最新 master ff803e6bc;test/unit-setup.ts 仍是同一处冲突,评审树取主干侧)

这一轮把上轮的两个问题各修了一个,Claude 端可以确认真正打通:

  • ✅ P2 mock 相位机已修且真机链路验证:handleAnthropicMessages 改为从后往前找最后一条 role:user 判 tool_result(OpenAI chat 侧对称改为最后一条非 system 消息),并补了 user(tool_result) → system 序列单测。我做了变异验证:把后扫改回只看 messages[last],新用例立即变红(有牙口)。真机 CI(run 38068077372)里 Claude 用例的 3 次 POST /v1/messages 角色序列正好是 [user] → [user,system] → [user,system,assistant,user(tool_result),system],第 3 次正确命中 tool_result 走 end_turn 收尾——从上一版的 33 次重复 tool_use 风暴降到干净的两阶段、attempt 1 一次通过、ACK 正常回话题。这条闭环成立。
  • ✅ showStreamingOutput 脆断言已放宽:aiAssert 改为 aiWaitFor(timeout 30s, interval 2s),容忍卡片异步重绘,本轮该步 attempt 1 通过。
  • 13/13 mock 单测、bun run build、CodeQL 均为 0/绿。

唯一核心阻断仍是 Codex,而且这轮的日志证明它"从头到尾就没工作过",不只是断言松

本 commit 没有改 scripts/run-e2e.ts、config.toml 生成或任何 codex provider 接线(grep 全仓仍无 model_provider/model_providers/wire_api)。真机 CI 里两个 Codex 用例的实际过程:

  1. spawn 出的 codex argv 只有 -c shell_environment_policy.set.*、check_for_update_on_startup、notice.hide_rate_limit_model_nudge=true、projects."."={trust_level} —— 没有任何指向 mock 的 model_provider;
  2. CLI 起来停在帮助文本后,worker 报 writeInput: submit not confirmed after retries —— 测试 prompt 根本没有成功提交进 codex TUI;
  3. 约 16s 后 waitForBotResponse 即 ✓,随后 close;
  4. 全轮 mock 0 次 /v1/responses、0 次 /chat/completions,且全日志没有任何到 api.openai.com / chatgpt.com / api.anthropic.com 的真实出站。

也就是说:codex 没有消耗真实 token(好事),但它既没连上 mock、连输入都没进去,用例却因为 waitForCodexSideResponse 的"marker 或 任意 Codex 侧响应/卡片"判定而报绿。两个 Codex 用例是对一条完全未工作链路的假绿,CI 的 3/3 success 在 codex 这 2/3 上不成立。

要让 Codex 端真正成立,需要三层(按顺序):

  1. provider 接线:在测试 ~/.codex/config.toml 注入 mock provider(根键 model_provider="mock" 写在 [projects."…"] 表之前 + [model_providers.mock] base_url=…/v1 wire_api="responses"),或在 spawn argv 加等价 -c model_provider=mock -c model_providers.mock.base_url=… -c model_providers.mock.wire_api=responses;仅 ambient OPENAI_BASE_URL 不生效(codex 0.162 已多次实证)。
  2. 输入投递:先解决 submit not confirmed——codex TUI 在当前 --no-alt-screen + 一串 -c 下到不了可提交态,prompt 没进去就谈不上模型调用;可能需要非交互/exec 形态或额外首启配置。
  3. mock 的 responses 形态 + 断言锚定:当前 handleOpenAiResponses 只合成纯文本、不产 function/tool call。Codex prompt submission(原样回 marker)靠纯文本即可,但 Codex basic bot flow 要求 CLI 执行 botmux send,需要 mock 在 responses 协议下也下发一次可执行的工具调用;同时把通过条件从 OR 收紧为"mock 确实收到一次 /v1/responses 且 marker 回到话题",去掉限流/卡片存在即过的退路。

在 provider 接通之前,建议把两个 Codex 用例标记为 skip/xfail 而非继续计入通过——否则 CI 会长期对一条死链路报绿,掩盖回归(这正是这几轮反复出现的情况)。

工程旧账(仍欠,grep 实证)

scripts/run-e2e.ts 仍是:固定 setTimeout(resolve, 5000)(166 行,无 daemon exit/error fail-fast、无 IPC 就绪门)、cleanup if (!dc.killed) dc.kill('SIGKILL')(191 行,判活无效)、BOTMUX_DATA_DIR(143 行,daemon 只认 SESSION_DATA_DIR,空旋钮);仍无临时 HOME 隔离(会写真 ~/.claude/settings.json 并就地改写密钥派生的 test-bots.json);生产两处 mkdir(src/daemon.ts、src/services/issue-board-store.ts)仍无单测。

结论:Claude 免 token 链路已真实、干净地跑通,报告/录屏增强也可用;但 PR 的"免 token E2E"主张目前只对 Claude 一端成立,Codex 两个用例是零调用、输入未提交的假绿。在 Codex provider + 输入投递打通、断言锚定真实 /v1/responses(或先把这两例 xfail)之前,不建议合入。以上为自动评审初步意见,最终以维护者审阅为准。

@deepcoldy

Copy link
Copy Markdown
Owner Author

第十轮评审(新 head 57f3051ed,rebase 到最新 master 9491bf854;test/unit-setup.ts 同一处冲突,评审树取主干侧)

这一轮把上轮 Codex 三层里的两层做实了,进步是决定性的:

  • ✅ Provider 接线打通:run-e2e 在 mock 模式下写 ~/.codex/config.toml,model_provider="mock" 根键置于 [projects."…"] 表之前(避开 TOML 顺序坑),[model_providers.mock] base_url=<mock>/v1 wire_api="responses" requires_openai_auth=false,并先剥离既有 model/model_provider 行、结束时还原原文件。
  • ✅ Responses 协议修正:文本部件 type:'text' → 'output_text'(4 处,含 delta/part/completed),与真实 Responses API 对齐——这正是之前 codex 收了流却解析不出内容的原因,且补了 "type":"output_text" 断言。
  • ✅ 带 marker 的断言去掉了限流 OR:waitForCodexSideResponse 在有 marker 时改为必须出现含唯一 marker 的独立文本气泡。
  • 真机 CI(run 38162626421)首次出现真实 POST /v1/responses:Codex prompt submission 一例真打到 mock(/responses model=gpt-6-sol, marker=CODEX_E2E_MARKER_1791742691543)回了 marker,attempt 1 过;全日志 0 次到 api.openai.com / chatgpt.com / api.anthropic.com 的真实出站。Claude 侧维持 3 次 /v1/messages 两阶段正常。13/13 mock 单测、bun run build 均绿。

但 3/3 里仍有一例假绿:Codex basic bot flow 是零模型调用通过的

逐 worker 对齐日志(该例 attempt 1,无重试,唯一 worker 74afecf4,marker …579205):

  • 18:17:49 spawn → 18:17:56 writeInput: submit not confirmed after retries(prompt 没成功提交进 TUI)→ 18:18:04 Close requested / worker exit code 0 → 18:18:11 判 ✓。
  • 该会话全程 0 次 /v1/responses、0 次任何模型出站,也没有任何 Replied … replyInThread 的 bot 回送;日志里唯一带 "botmux send" 的是 botmux 注入的路由提示,不是 bot 回复。
  • 本轮全部 2 次 POST /v1/responses 都在 18:19:46、只属于第三个用例(worker bdf27538)。

也就是说,codex-basic 在 codex 一个字都没回的情况下,新断言仍判过。原因可定位:该例用户消息正文本身就是「e2e-codex-marker-… 请在最终回复中原样包含 CODEX_E2E_MARKER_…579205」,marker 字符串就出现在用户自己发出的那条消息气泡里;纯视觉 aiWaitFor 在没有 bot 回复时,把用户侧那条含唯一 marker 的气泡误认成了"来自 Codex/机器人的文本气泡"。提示词里"不要把用户提问混淆为回复"压不住这种情况——15 秒内唯一含 marker 的气泡就是用户自己那条。

建议把 Codex 成功判定改成不依赖视觉辨认发送方:

  1. 程序化主闸:按用例 marker 核对 mock 确实收到过一次 /v1/responses 且回出该 marker(mock 在同进程,可暴露命中计数/按 marker 查询),这是确定性、发送方正确的;视觉只负责定位气泡。
  2. bot 侧专属 sentinel:让 mock 的回复带一个任何 prompt 都不会出现的固定前缀(如 【MOCK-REPLY】…),断言该 sentinel(用户输入里绝无此串),并要求是新出现的气泡(记录发送前气泡数,断言 +1 且为 bot 侧)。

顺带:本绿跑里 codex 仍出现了一次 submit not confirmed(恰好落在零调用那例),说明上轮的输入投递层仍有偶发不稳定;把假绿闸去掉后,这种抖动会(正确地)暴露成失败,需要一并稳定(非交互/exec 形态或投递就绪确认)。

覆盖度收窄:两个 Codex 用例都不再验证工具执行

openai-responses-sse.ts 全程 grep 不到 function_call / tool_call / custom_tool_call——mock 的 /responses 只会回纯文本,不会下发工具调用。因此即便 provider 已通,codex 也无法被 mock 引导去执行 botmux send。当前两个 codex 用例都改成了"原样回 marker"的纯文本核对,Claude 侧验证的 tool_use 两阶段在 Codex/Responses 协议下没有对应覆盖。如果这是有意收窄(先保 marker echo)可以接受,但建议在 PR 描述里写明,并作为 follow-up:给 Responses 合成器补 function call + 工具结果二次请求的相位。

Nits / 旧账

  • /responses marker 提取只做 JSON.stringify(body.input),本轮 2 次 POST 有 1 次回退到默认 ACK-codex-test-received(结构化 input 某轮抽不到 marker);不影响通过,但多轮/结构化 input 下提取不稳。
  • codex config.toml 是在真实 ~/.codex/config.toml 上改写(内存备份、cleanup 正常退出才还原);若 run-e2e 被 SIGKILL/硬崩,开发机的原配置会停在被改状态。配合之前提的子进程临时 HOME更稳(Claude 的 settings.json、test-bots.json 同理)。
  • 进程管理旧账仍欠(grep 实证):固定 setTimeout(resolve,5000)、无 daemon exit/error fail-fast/就绪门、cleanup if(!dc.killed) kill('SIGKILL') 判活无效、BOTMUX_DATA_DIR 空旋钮(daemon 只认 SESSION_DATA_DIR);两处生产 mkdir 无单测。

结论:这一轮 Codex 的 provider + 协议 + 投递在 prompt-submission 一例上真实打通,且零真实 token,是关键突破;但 Codex basic bot flow 仍以零模型调用假绿(视觉把用户自带 marker 的消息当回复),在把成功判定改成"mock 命中 + bot 侧专属 sentinel(不靠视觉辨认发送方)"、并稳定输入投递之前,3/3 还不能作为免 token e2e 全绿的依据,暂不建议合入。以上为自动评审初步意见,最终以维护者审阅为准。

@deepcoldy
deepcoldy merged commit 885a15d into master Oct 12, 2026
16 checks passed
@github-actions

Copy link
Copy Markdown

🚀 Released in v3.42.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants