首页
/ LobeHub 异构 Agent 管线解析:从 CLI 原始流到 UI 的 Claude Code / Codex 适配器调试实战

LobeHub 异构 Agent 管线解析:从 CLI 原始流到 UI 的 Claude Code / Codex 适配器调试实战

2026-09-06 22:11:08作者:申梦珏Efrain

在 LobeHub 的桌面端(Electron)中,Claude Code、Codex 等外部 CLI Agent 并不走常规的服务器端 Agent 运行时,而是走一条独立的"异构 Agent"(Heterogeneous Agent)管线:Electron 主进程负责拉起 CLI 子进程并广播原始 stdout,渲染进程中的适配器把各家私有的 NDJSON/JSONL 事件映射成统一的 HeterogeneousAgentEvent,再经由执行器完成消息持久化与 UI 水合。本文基于仓库内的技能文档 SKILL.md 与其配套参考 debug-workflow.md,完整讲清这条管线的分层结构、各层的关键不变量、原始 trace 的抓取方法、常见故障模式以及"复现到修复"的标准工作流,读完即可独立定位并修复 Claude Code / Codex 流式渲染、工具持久化、子 Agent 线程路由等各类管线缺陷。

一、管线全景:六层管道与"从最左端修起"原则

技能文档给出的 Pipeline Map 描述了数据从 CLI 到界面的完整流转:

CLI raw stdout
  -> HeterogeneousAgentCtr (Electron main)
  -> heteroAgentRawLine broadcast
  -> createAdapter(...)
  -> executeHeterogeneousAgent(...)
  -> persistToolBatch / persistToolResult
  -> createGatewayEventHandler(...)
  -> UI hydration

即:

  1. CLI 的原始 stdout / JSONL;
  2. Electron 主进程中的 HeterogeneousAgentCtr 拉起 CLI 子进程,并广播 heteroAgentRawLine
  3. 适配器(createAdapter)把原始 provider 事件映射为统一的 HeterogeneousAgentEvent
  4. executeHeterogeneousAgent 持久化 assistant / tool 消息,并转发流事件;
  5. createGatewayEventHandler 完成 UI 水合;
  6. 只有当这条路径验证正确之后,才应该进入 agent-tracing 或 context-engine 的排查。

核心调试原则是"从最左端出错的层开始"(Start at the leftmost broken layer):如果原始事件与适配后事件都正确,才考虑动 UI 代码。文档还明确指出,哪些改动应该触发这套排查:新增或修改 apps/desktop/src/main/modules/heterogeneousAgent/drivers/ 下的 driver、编辑 packages/heterogeneous-agents/src/adapters/ 下的 adapter、调试 heteroAgentRawLine 传输 / window.__HETERO_AGENT_TRACE / executeHeterogeneousAgent、修复 Claude Code 的重复 partial/full chunk、message.id 边界错乱、tool_result 缺失、TodoWrite 状态漂移、子 Agent 线程路由等问题,以及 Codex 的多工具消息混杂、turn 边界断裂、工具结果映射缺失等问题。

从源码结构看,Electron 主进程侧的入口是 HeterogeneousAgentCtr,它以一个稳定的 IPC 表面注册 startSessionsendPromptlistModelscancelSession 等方法,而真正的 CLI 适配、流管线、配额采样实现被懒加载(getImplementation 动态 import('./HeterogeneousAgentImpl')),避免在应用启动时就加载全部适配器代码。

二、第一步永远是抓取原始 trace

2.1 应用内 live trace(最忠实的抓取方式)

运行中的应用已经会记录它拉起的每一个 CLI 会话。这是最忠实的 trace,因为它捕获的是应用实际使用的 spawn 参数、env 键、cwd、--resume / --mcp-config 标志、模型和 stdin——手工敲 claude -p / codex exec 是无法完整复现这些细节的。记录器位于 HeterogeneousAgentCtr.ts 所在的控制器模块中(createCliTraceSessionshouldTraceCliOutputresolveTraceRootDir)。

记录时机:

  • 开发构建!app.isPackaged):始终记录;
  • 打包构建:仅当用户在 Help 菜单打开开发者开关(heteroTracingEnabled)时记录,默认关闭以免污染正常运行;
  • NODE_ENV=test 下永不记录。

写入位置:

  • 开关关闭(普通 dev 运行):写到 <cwd>/.heerogeneous-tracing/,即你正在运行的仓库目录内(注意目录名拼写就是 heerogeneous,这是真实路径);
  • 开关打开:写到 <appStoragePath>/heteroAgent/tracing/,避免 trace 落入用户项目目录,这也是打包构建唯一使用的路径。

每个会话的目录布局为 .../<agentType>/<YYYYMMDD-HHMMSS>-<sessionId>/,包含五个文件:

文件 内容
meta.json spawn argscommandcwdenvKeysmodelresumeSessionId / agentSessionId、附件摘要。应最先读它,以确认 CLI 到底是怎么被拉起的
stdin.txt 喂给 CLI 的 stream-json 请求
stdout.jsonl 原始 provider NDJSON,即真正要读的那份 trace
stderr.log CLI 的 stderr
exit.json { code, signal, finishedAt }

.heerogeneous-tracing/.last-live-trace 始终指向最近一次会话目录,因此"刚才发生了什么"的最快路径是:

dir=$(cat .heerogeneous-tracing/.last-live-trace)
cat "$dir/meta.json"      # CLI 是如何被拉起的
wc -l "$dir/stdout.jsonl" # 原始事件条数

需要自己复现同一会话时,直接复用 meta.json 里的 argsstdin.txt(args 中已包含 --resume <sessionId>),而不是去猜命令行参数。

2.2 手工抓取 Codex 原始 JSONL

使用只读 prompt,并把 trace 存到仓库本地 scratch 目录 .heerogeneous-tracing/ 下:

ts=$(date +%Y%m%d-%H%M%S)
out=".heerogeneous-tracing/codex-${ts}.jsonl"
last=".heerogeneous-tracing/codex-${ts}.last.txt"

cat << 'EOF' | codex exec --json --skip-git-repo-check --sandbox read-only -C "$PWD" -o "$last" - > "$out"
You are being run only to collect a raw Codex JSON event trace.
Do not modify any files.
Use at least 4 separate shell tool invocations, one invocation per command.
Run a short sequence of read-only repo checks and then reply with a one-sentence summary.
EOF

在 JSONL 中重点观察以下事件:

  • thread.started
  • turn.started
  • item.started / item.completed
  • item.type === 'command_execution'
  • item.type === 'agent_message'
  • turn.completed

判定逻辑很直接:如果原始 Codex 输出本身就把多个工具合并进了一个 item,那么适配器是无辜的;如果原始输出是独立 item 而 UI 却把它们折叠了,则 bug 在下游。另外,如果仓库 .heerogeneous-tracing/ 下已有可用的 trace,应先检查它们再手动复现。

2.3 手工抓取 Claude Code 原始 NDJSON

命令行参数应与桌面 driver 保持一致。桌面端 driver claudeCode.ts 中的参数由共享基础参数与调用方特定参数组合而成:

const DESKTOP_CLAUDE_CODE_ARGS = [
  ...CLAUDE_CODE_BASE_ARGS,          // 共享不变基础参数
  '--include-partial-messages',     // 桌面端渲染实时气泡,永远需要 token 级增量
  '--permission-mode',
  'bypassPermissions',
] as const;

其中 CLAUDE_CODE_BASE_ARGS 定义在 spawnAgent.ts,包含 -p--input-format stream-json--output-format stream-json--verbose 四个共享不变参数。此外 driver 在构造 spawn plan 时按需追加 --mcp-config <path>(控制器管理的临时 mcp.json,CC 不接受内联 JSON)与 --resume <sessionId>。因此手工抓取命令为:

ts=$(date +%Y%m%d-%H%M%S)
out=".heerogeneous-tracing/claude-${ts}.ndjson"

cat << 'EOF' | claude -p \
  --input-format stream-json \
  --output-format stream-json \
  --verbose \
  --include-partial-messages \
  --permission-mode bypassPermissions \
  > "$out"
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Do a few read-only repo checks, use several tool calls, and then summarize briefly."}]}}
EOF

Claude Code 原始 trace 中应关注的事件:

  • type: 'system', subtype: 'init'
  • type: 'assistant' 块(thinkingtool_usetext
  • 包含 tool_resulttype: 'user'
  • type: 'stream_event' 中的 message_startcontent_block_deltamessage_delta
  • type: 'result'
  • type: 'rate_limit_event'

Claude Code 的关键流语义(也是 适配器源码头部注释 明确写下的协议事实):

  • 每个 content block 通常以各自独立的 assistant 事件到达;
  • 多个 assistant 事件可以共享同一个 message.id——它们仍然是同一个 turn
  • message.id 变化才是主 Agent 的 step 边界;
  • partial delta(stream_event)先于携带完整内容块的 assistant 事件到达,适配器对已流式输出过的 message.id 抑制后续重复输出;
  • message_delta.usage 是每个 turn 权威的 usage 来源,不要信任每个 assistant 块上回显的 usage;
  • 子 Agent 事件带有 parent_tool_use_id 标记。

文档同时建议:若仓库中已有参考 trace(如 .heerogeneous-tracing/cc-monitor-real-trace.jsonl.heerogeneous-tracing/cc-stream-chain-reference.md),优先检查它们;如果只需要验证边界语义或工具持久化行为,优先看现成的适配器测试 claudeCode.test.tsclaudeCode.e2e.test.ts

三、对比原始事件与适配事件

在开发构建中,executeHeterogeneousAgent 会把原始行和适配后事件挂到 window.__HETERO_AGENT_TRACE 上。执行器实现位于 heterogeneousAgentExecutor.ts,对应的单测是 heterogeneousAgentExecutor.test.ts

用该 trace 逐项对比:

  • 原始 item.started / item.completed
  • 适配后的 stream_chunk { chunkType: 'tools_calling' }
  • 适配后的 tool_result
  • 适配后的 tool_end

对 Codex,典型映射关系为:

原始事件 适配后事件
item.started(command_execution) tools_calling + tool_start
item.completed(command_execution) tool_result + tool_end
item.completed(agent_message) stream_chunk(text)

结论规则:如果原始 trace 正确而适配事件错误,先修适配器,再谈持久化。例如 CodexAdapteradapt 方法就是按 thread.startedturn.starteditem.started/updated/completeditem.agent_message.deltaitem.command_execution.output_deltaturn.completederror / turn.failed 等原始事件类型做 switch 分发的,任何映射错误都能在这一层被单测复现。

四、先查 step 边界,再查持久化

"多个工具混进一条 assistant 消息"这类 bug 的第一检查项就是 step 边界。

4.1 Claude Code 的边界语义

Claude Code 的 step 边界以 assistant 的 message.id 变化为键。适配器应发出:

  • stream_end
  • stream_start { newStep: true }

同时需要验证以下 Claude 特有不变量:

  • init 之后的第一个 assistant 事件不打开新 step;
  • 相同 message.id 的重复 assistant 事件不打开新 step;
  • partial content_block_delta 的 text/thinking 不被后续完整 assistant 事件重复输出;
  • 来自 type: 'user' 事件的 tool_result 更新对应的工具行;
  • parent_tool_use_id 生成 thread 作用域的子 Agent chunk,而不是主流 chunk;
  • TodoWrite 的 tool_use.inputtool_result 时转换为合成的 pluginState.todos

claudeCode.ts 的适配器源码可以看到,这套语义还通过 ClaudeCompatibleAdapterProfile.assistantMessageIdsDefineTurns 字段做了参数化——Claude 用同一个 message.id 覆盖一个 turn 内的所有 content block,所以 id 变化即 turn 边界;而兼容 provider(如 CodeBuddy)会给推理项和文本项分配独立 id,边界规则不同。另外,新版本的 CC 已将声明式 TodoWrite 替换为命令式三件套(TaskCreate / TaskUpdate / TaskList),适配器会按会话累积这些事件并在每次 task 工具的 tool_result 上合成共享的 pluginState.todos 结构,让既有 TodoProgress UI 继续工作(见 claudeCode.ts 中的常量与解析模式定义)。

4.2 Codex 的边界语义

Codex 原始 trace 通常通过 turn.started / turn.completed 提供 turn 级边界。执行器只有在收到它能理解的 step 边界信号时才会切分新的 assistant 消息。如果适配器发出的 stream_start 不带 newStep,多个 Codex 工具与文本 chunk 可能会在同一个 assistant 下累积得比预期更久。相关文件:codex.tsheterogeneousAgentExecutor.ts。从源码看,CodexAdapter.handleTurnStarted 在首个 turn 发出 stream_start,后续每个 turn 先置 pendingTurnStartBoundary 并只发出 stream_end,边界信号在下一个工具或内容到达时通过 consumePendingTurnStart 消费——这正是"延迟边界"的实现细节。

五、工具持久化不变量(persistToolBatch / persistToolResult)

改 UI 代码之前先读 persistToolBatch 与 persistToolResult 所在执行器

persistToolBatch 的期望顺序是三步:

  1. 预注册 assistant 的 tools[]
  2. 创建 role: 'tool' 消息;
  3. result_msg_id 回填到 assistant 的 tools[]

如果工具行先于 assistant 的 tools[] 注册而创建,就会得到孤儿工具消息。执行器源码中 toolMsgIdByCallId 全局映射 承担"工具调用 id → 工具消息 id"的解析职责:persistToolBatch 创建工具消息时写入 toolMsgIdByCallId.set(toolCallId, message.id)persistToolResult 则必须通过 toolMsgIdByCallId.get(toolCallId) 解析到既有工具行。

persistToolResult 的告警信号:

  • tool_result for unknown toolCallId
  • 工具行内容永远为空
  • 缺失 result_msg_id

对 Claude Code 要记住:tool_result 源自原始 type: 'user' 事件,而不是 assistant 事件。

主/子 Agent 作用域规则:

  • 主 Agent 的工具状态是 per-step 的;
  • toolMsgIdByCallIdglobal 的,跨主/子 Agent 作用域共享,因此绝不能在主 step 边界处清空它;
  • 子 Agent chunk 不得被转发进主 gateway handler,否则主气泡会继承错误的 tools[] 与内容。

六、必须守住的关键不变量清单

技能文档归纳的 Critical Invariants 是整个管线的契约,可视为"回归红线":

  • 一条原始工具 item 必须映射到一个稳定的 ToolCallPayload.id
  • 新的主 Agent step 在把事件转发给新 assistant 之前,必须先发出边界信号;
  • Claude Code 中,共享同一 message.id 的多个 assistant 事件是一个 turn,而不是多个 turn;
  • Claude Code 中 tool_result 位于 type: 'user' 事件里,而非 assistant 事件;
  • Claude Code partial 模式下,message_delta.usage 是权威来源,不要信任每个 assistant 块回显的 usage;
  • persistToolBatch 必须在创建工具消息之前预注册 assistant 的 tools[]
  • 每条工具消息必须保持 parentId 等于所属 assistant、tool_call_id 等于工具 id;
  • tool_result 必须能解析到既有的 toolMsgIdByCallId 条目;
  • 子 Agent chunk 必须留在 thread 作用域内,不得转发进主流 assistant;
  • 绝不在主 step 边界清空全局 toolMsgIdByCallId 映射。

七、常见 bug 模式与排查入口

症状 优先检查
Claude Code 文本或 thinking 重复 partial delta 与后续完整 assistant 块是否同时被发出
Claude Code 打开了过多 assistant 消息 适配器是否在每个 assistant 事件上都切 step,而不是只在 message.id 变化时切
Claude Code 工具结果永不落位 是否因为代码只检查 assistant 事件,而忽略了 type: 'user'tool_result
Claude Code TodoWrite 卡片过期 合成的 pluginState.todos 是否附着在 tool-result 时点上
Claude Code 子 Agent 转录泄漏进主气泡 parent_tool_use_id 的处理,以及子 Agent chunk 是否被转发到主 gateway handler
Codex 多个工具折叠进一条 assistant 消息 适配器是否发出可用 step 边界,如 newStep 或等价 turn 变化信号
孤儿工具消息 step 转换顺序,以及 persistToolBatch 的 Phase 1 是否先于工具消息创建执行
工具气泡一直 loading 查找 tool_result for unknown toolCallId 与缺失的 result_msg_id 回填
子 Agent 工具出现在主气泡 检查子 Agent chunk 是否到达了主 gateway handler
错误的终端错误指引(如网络断连却提示"usage limit reached") 分类器在某个结构化字段上分支,但字段存在并不等于其语义成立——见下文第 8 节

八、用真实 trace 校验"结构化字段分类器"

这是 debug-workflow 中反复出现的一条纪律:每当适配器基于原始流中的结构化字段做分支——statususagerateLimitTypestop_reasonparent_tool_use_idsubtype 等——都不要相信脑中的 wire format 模型。你要判定的字段几乎总会在良性/非目标事件上同样出现,忽略周围状态分类器就会在这些事件上误触发。

标准流程(每次都执行):

  1. 拉取最近一次真实会话:dir=$(cat .heerogeneous-tracing/.last-live-trace)

  2. grep 该字段在所有事件状态中的分布,而不只是失败那次,并按共现状态计数,例如:

    # 哪些 status 的事件携带 rate_limit_info 块?
    grep -o '"status":"[a-z]*"' "$dir/stdout.jsonl" | sort | uniq -c
    grep -c 'rate_limit_info' "$dir/stdout.jsonl"
    
  3. 如果该字段出现在你没有考虑过的状态上,分类器需要加一道门;并把 trace 固化为适配器测试的 fixture/断言,防止回归复发。

文档给出了一个完整实例——CC 的用量上限 vs 瞬时限流误分类:

  • 症状:一次无关的终端失败(如 ECONNRESET 网络掉线)渲染出虚假的 "usage limit reached, resets at X" 指引;
  • trace 揭示:Anthropic 即使请求成功放行status: "allowed")也会给事件打上 rate_limit_info 块(携带 resetsAtrateLimitType,如 seven_day)。真实 trace 中这些窗口字段出现在几乎所有 rate_limit_info 块上,而其中绝大多数是 allowed 而非 rejected——因此该窗口只是放行调用的滚动窗口元数据,不是"已触及限额"的证据;
  • bug 成因isUserQuotaRateLimit(位于 claudeCode.ts)只以"存在重置窗口"(info.resetsAt != null || info.rateLimitType != null)为判定依据,随后的终端错误继承了上一个 allowed 事件的窗口,造成误报;
  • 修复:要求 status === 'rejected' 有具体重置窗口同时成立;仅有 rejected 而无窗口的情况属于瞬时服务端限流,留给 overloaded(重试)分类器。状态码(429 / 529)与消息文本被刻意不参考——只有这个结构化信号决定指引。回归断言固化在 claudeCode.test.ts 中。

通用教训一句话:字段的"存在"不等于它的"含义"。基于某个判别字段做分支之前,先在真实录制的 trace 中确认它与哪些事件状态共现。

九、聚焦测试与"复现到修复"工作流

优先跑最小的有用测试集:

bunx vitest run --silent='passed-only' 'packages/heterogeneous-agents/src/adapters/codex.test.ts'
bunx vitest run --silent='passed-only' 'packages/heterogeneous-agents/src/adapters/claudeCode.test.ts'
bunx vitest run --silent='passed-only' 'src/store/chat/slices/agentRun/actions/__tests__/heterogeneousAgentExecutor.test.ts'

修复 Claude Code 类 bug 时值得补充的断言:

  • 相同 message.id 不发出 newStep
  • message.id 变化时发出 stream_end + stream_start { newStep: true }
  • partial text/thinking 只输出一次;
  • 来自 user 事件的 tool_result 到达正确的工具行;
  • 子 Agent chunk 携带 subagent.parentToolCallId
  • TodoWrite 结果合成 pluginState.todos

如果 bug 源自真实 trace,应把它提炼到最接近的既有测试文件中,而不是依赖纯手工 UI 复现。

完整的 Repro-to-Fix 工作流共六步:

  1. 抓取原始 trace,保存到 .heerogeneous-tracing/
  2. 确认 bug 出现在原始事件、适配事件还是持久化哪一层;
  3. 在出错的层附近新增或更新最窄的失败测试;
  4. 修复能解释该症状的最小层;
  5. 重跑聚焦测试;
  6. 仅当仍需 UI 确认时,才做 Electron 冒烟测试。

原则:如果原始 trace 或适配器测试能更快证明故障区,就不要从宽泛的 Electron 复现开始。

十、参考文件索引

文件 角色
SKILL.md 技能入口:适用场景、管线地图、调试顺序、不变量与 bug 模式
debug-workflow.md 命令、trace 抓取、不变量细节与聚焦测试命令的完整参考
HeterogeneousAgentCtr.ts Electron 主进程 IPC 表面与懒加载实现边界
drivers/claudeCode.ts 桌面端 CC 的 spawn 参数、provider 绑定与 server-default 绑定
drivers/codex.ts 桌面端 Codex driver
adapters/claudeCode.ts CC stream-json NDJSON → HeterogeneousAgentEvent 映射
adapters/codex.ts Codex JSONL → 统一事件映射与 pluginState 合成
heterogeneousAgentExecutor.ts 渲染进程执行器:持久化、边界处理、toolMsgIdByCallId
spawnAgent.ts CLAUDE_CODE_BASE_ARGS 等共享 spawn 常量
claudeCode.test.ts / codex.test.ts / executor.test.ts 各层的行为验证与回归断言

小结

LobeHub 异构 Agent 管线的设计精髓在于"分层隔离 + 契约不变量":每一层(driver / 适配器 / 执行器 / gateway handler)都有明确的输入输出契约,调试时从原始 trace 逐层右移定位,用真实 live trace 校验每一个基于结构化字段的分支,最后把修复固化为最窄的层内测试。掌握本文的管线地图、不变量清单、trace 抓取命令和 bug 模式对照表,即可对 Claude Code / Codex 适配器及其上下游做出可验证的修改。

登录后查看全文
热门项目推荐
相关项目推荐