跳到主要内容

数据截至 (上游 commit eb980a5c9eea)

多种 agent 后端与 App 端的消息归一

30 秒导读: Happy 的手机 App 只认一种消息格式,但它背后可能是 Claude Code、可能是 codex app-server、可能是任何讲 ACP 的 CLI。本章讲两件事:CLI 侧怎么把几种毫不相干的协议收敛成同一种线协议信封,App 侧怎么把这些乱序到达的信封还原成一屏能看的对话。


1. 这是什么(零基础也能懂)

一句话定义: 这是 Happy 里的「翻译层 + 装配层」——CLI 把各家 agent 的方言翻译成统一信封,App 把信封装配成界面。

1.1 为什么会有这个问题

Happy 最初只驾驭 Claude Code。后来要接 Codex、Gemini 等,就撞上一个尴尬事实:这些 agent 谁也不像谁

后端它对外吐什么CLI 怎么拿到
Claude Code会话 JSONL 转录行(RawJSONLines读 SDK 流 / 监视转录文件
CodexJSON-RPC 2.0 通知(codex/eventitem/*自己 spawn codex app-server 讲 stdio
Gemini / OpenCodeACP 的 sessionUpdate 通知官方 SDK ClientSideConnection
agy(Antigravity)一坨纯文本,然后进程退出agy --print,一回合一个进程

如果 App 为每种后端写一套渲染,屏幕上的「工具调用卡片」就要写四遍,权限弹窗也要写四遍。

1.2 Happy 的解法(一句话直觉)

把 agent 后端当成「驱动」,把消息当成「USB 包」。

驱动各自处理自家协议的脏活,往上只吐一种标准包;上层(服务器中继、手机 App)只认这种包,永远不知道底下插的是哪个牌子的设备。

具体到代码,这条链上有两层收敛:

  • 第一层收敛(进程内):各后端 → AgentMessage 联合类型(packages/happy-cli/src/agent/core/AgentBackend.ts:25)。
  • 第二层收敛(线协议)AgentMessage → happy-wire 的 SessionEnvelopepackages/happy-wire/src/sessionProtocol.ts:109),这才是真正加密后发给服务器、再发给手机的东西。

关于信封怎么加密、怎么中继,见 02-sync-and-rpc.md;关于一次远程回合的整体时序、以及 Claude 侧的信封成型细节,见 04-remote-turn-and-permissions.md。本章只管跨后端的格式怎么统一到手机后怎么变成界面


2. 顶层全景(它大概怎么转)

怎么读这张图: 从左到右是数据流向。左边几条是互不相干的真实协议,中间是收敛点,右边是 App 的装配线。

真实后端协议 CLI 内部统一 线协议格式 App 装配
───────────────── ───────────────── ────────────── ───────────────
┌─────────────┐
Claude Code ────────────► (直接映射,不经 ┐ │ normalize │
会话 JSONL 转录 AgentMessage) │ │ RawRecord │
│ │ → Normal- │
codex app-server ───────► CodexEvent ├──► SessionEnvelope │ izedMsg │
JSON-RPC over stdio (legacy 事件型) │ { id,time,role, │ │ │
│ turn,ev } │ ▼ │
ACP agent ──────────────► AgentMessage │ │ reducer │
(gemini/opencode/…) 统一联合类型 ┘ │ 5 个阶段 │
│ │ │ │
agy --print ───────────────────┘ │ ▼ │
纯文本 + 退出码 │ Message[] │
└─────────────┘

部件职责一览:

部件干什么文件
AgentBackend所有后端要实现的统一接口(启动/发提示/取消/收消息)packages/happy-cli/src/agent/core/AgentBackend.ts:104
AgentMessage后端往上吐的统一消息联合类型packages/happy-cli/src/agent/core/AgentBackend.ts:25
CodexAppServerClientCodex 路线:手搓 JSON-RPC 客户端packages/happy-cli/src/codex/codexAppServerClient.ts:213
AcpBackendACP 路线:包官方 SDK 的 ClientSideConnectionpackages/happy-cli/src/agent/acp/AcpBackend.ts:317
AcpSessionManagerAgentMessage 折成 SessionEnvelopepackages/happy-cli/src/agent/acp/AcpSessionManager.ts:103
ApiSessionClient出口:sendAgentMessage / sendSessionProtocolMessagepackages/happy-cli/src/api/apiSession.ts:814
normalizeRawMessageApp 入口:把线协议解成 NormalizedMessagepackages/happy-app/sources/sync/typesRaw.ts:761
reducerApp 装配线:5 阶段拼装 + 三重去重packages/happy-app/sources/sync/reducer/reducer.ts:268

3. 统一契约:AgentBackendAgentMessage

这节讲什么: 所有后端要签的那份「合同」长什么样。

3.1 合同正文

接口本身克制得近乎简陋——只有五个必需成员:

// packages/happy-cli/src/agent/core/AgentBackend.ts:104 interface AgentBackend
startSession(initialPrompt?: string): Promise<StartSessionResult>;
sendPrompt(sessionId: SessionId, prompt: string): Promise<void>;
cancel(sessionId: SessionId): Promise<void>;
onMessage(handler: AgentMessageHandler): void;
dispose(): Promise<void>;

respondToPermissionwaitForResponseComplete 是可选的(AgentBackend.ts:157:165)。这里有个不显然的注释值得看:ACP 后端的权限是requestPermission RPC 处理函数里同步答完的,所以 respondToPermission 对它们只是补一条事件给 UI 看,真正的应答早已发出(AgentBackend.ts:145-149)。

3.2 消息联合类型:一份「最小公倍数」

AgentMessageAgentBackend.ts:25-38)分两半,前九种是各家都有的通用语义,后四种是 Codex 硬塞进来的:

分组成员说明
文本流model-outputterminal-outputtextDelta 增量或 fullText 全量
生命周期statusstarting/running/idle/stopped/error)、event驱动 keepAlive 与回合边界
工具tool-calltool-resultfs-editcallId 配对
权限permission-requestpermission-responseid 配对
Codex 特供token-countexec-approval-requestpatch-apply-beginpatch-apply-end注释直说 "like Codex exec_approval_request"

这就是抽象的第一道裂缝:联合类型里躺着四个以 Codex 事件命名的成员,说明「统一格式」并没有真的做到与厂商无关。后面 §10 再算这笔账。

3.3 传输三分与 agent 名单

传输方式只有三种(AgentBackend.ts:48):

AgentTransport含义谁在用
native-claude走 Claude 自己的 SDK / 转录文件claude
mcp-codexcodex app-server 的 JSON-RPCcodex
acp走 Agent Client Protocolgemini、opencode、任意 happy acp -- <cmd>

AgentId 则列了八个(AgentBackend.ts:51):claudecodexgeminiopencodeopenclawagyclaude-acpcodex-acp

诚实提示: 这八个里,openclawagy 在 CLI 里有独立子命令,而 claude-acp / codex-acp 只出现在这一行类型定义里,全仓库再无第二处引用——AgentId 是一份愿望清单而非实现清单。真正的命令分发在 packages/happy-cli/src/index.ts 里按 subcommand 逐个 if/else(如 index.ts:361acp:404openclaw:446agy)。

3.4 注册表:写好了但没接线

AgentRegistrypackages/happy-cli/src/agent/core/AgentRegistry.ts:37)是标准的工厂注册表,全局单例在 AgentRegistry.ts:88。唯一的注册方是 registerGeminiAgent()packages/happy-cli/src/agent/factories/gemini.ts:183),它被 initializeAgents() 调用(packages/happy-cli/src/agent/index.ts:39)。

initializeAgents() 在整个 CLI 源码里没有任何调用点grep -rn "initializeAgents" 只命中定义本身)。

所以这条路径的现状是:接口是活的(AgyBackend implements AgentBackend,见 §6),注册表是死的。读代码时别被它误导成「运行时插件系统」。

3.5 MessageAdapter:上一代的收敛口

MessageAdapter.normalize()packages/happy-cli/src/agent/adapters/MessageAdapter.ts:85)把 AgentMessage 逐个 case 摊平成 NormalizedMobilePayloadtool-call 变成 {toolName, toolArgs, toolCallId}permission-request 变成 {permissionId, permissionReason, permissionPayload},未知类型兜底塞进 eventPayloadMessageAdapter.ts:149-151)。

它还预置了四个实例(MessageAdapter.ts:279:gemini/codex/claude/opencode)。

但主流程里没人用它——现役的 ACP 路线走的是 AcpSessionManager(§5.3),Codex 走的是自己的映射器(packages/happy-cli/src/codex/utils/sessionProtocolMapper.ts:936)。把它当「这个仓库的收敛思路演进标本」来读即可。


4. 路线一:Codex —— 手搓 JSON-RPC 打 codex app-server

这节讲什么: 为什么 Happy 宁可自己写协议客户端,也不用官方 SDK;以及审批请求怎么从 Codex 一路飘到手机上再飘回来。

4.1 它要解决的小问题

Codex 的官方 SDK 只包了 codex exec——一次性、发完就完、不能中途问你「这条 rm -rf 能跑吗」。但 Happy 的核心卖点恰恰是在手机上点「允许」

文件头把这个决策写得非常直白:

// packages/happy-cli/src/codex/codexAppServerClient.ts:8-13
// WARNING: @openai/codex-sdk (v0.118.0) exists but only wraps `codex exec`
// (non-interactive, fire-and-forget). It has NO support for `app-server`,
// interactive approvals, or bidirectional JSON-RPC.

一句话:要双向,就只能自己写。

4.2 连接与握手

connect()codexAppServerClient.ts:595)做四件事:

  1. 先用 codex --version 校验版本,minor >= 100 才认为有 app-serverisAppServerAvailablecodexAppServerClient.ts:117)。
  2. spawn codex app-server --listen stdio://codexAppServerClient.ts:609),Windows 上用 cross-spawn 绕开 .cmd shim 的 ENOENT。
  3. readline 逐行解 stdout 的 newline-delimited JSON(codexAppServerClient.ts:684)。
  4. initialize 请求 + initialized 通知,完成握手(codexAppServerClient.ts:701-702)。

processEpoch 这个小设计值得抄。 每次 spawn 递增一个纪元号(codexAppServerClient.ts:644),所有回调进门先比对纪元(:661:678:686)。重启 app-server 时,旧进程的临终事件不会污染新会话——比「设个 boolean 标记」抗竞态得多。

4.3 三种进站消息,一个分发器

handleLine()codexAppServerClient.ts:1286)按 JSON-RPC 的形状三分:

一行 JSON

├── 有 id + 有 result/error ──► 是「我请求的回应」,resolve 掉 pending 里的 promise

├── 有 id + 有 method ──► 是「服务器反向请求我」(审批!)→ handleServerRequest

└── 无 id + 有 method ──► 是通知(事件流)→ handleNotification

中间那条分支是整章的关键:JSON-RPC 是对称的,服务器可以反过来请求客户端。Codex 的审批就走这条路。

4.4 线程四件套

对话在 Codex 里叫 thread,客户端包了四个动作:

方法打的 RPC用途行号
startThreadthread/start新开会话codexAppServerClient.ts:812
resumeThreadthread/resume恢复已有 thread:848
forkThreadthread/fork从某个 thread 分叉(App 的「复制会话」,见 05-daemon-and-machines.md:886
interruptTurnturn/interrupt打断当前回合:1208

发一个回合是 sendTurn()turn/startcodexAppServerClient.ts:1129),它立即返回,回合结束靠事件流里的 task_complete / turn_aborted 判定。

sendTurnAndWait() 里藏了一个被踩出来的坑(codexAppServerClient.ts:1155-1164):新回合开始前必须先 await this.pendingInterrupt,否则上一回合迟到的 turn/interrupt 会把新回合打断掉。等完还要 setTimeout(0) 让事件循环把旧通知先消化掉。

4.5 审批回路:从 Codex 到手机再回来

怎么读这张图: 竖着看是时间。注意整条链是一个未 resolve 的 JSON-RPC 请求在等——Codex 那边一直阻塞着。

codex app-server happy-cli 手机 App
│ │ │
│ ① item/commandExecution/ │ │
│ requestApproval (id=7) │ │
├─────────────────────────────►│ │
│ │ handleServerRequest │
│ │ → handleApproval │
│ │ → permissionHandler │
│ │ .handleToolCall(callId) │
│ ├──── ② 加密推送权限请求 ────────►│
│ │ │
│ (Codex 阻塞等待) │ (CLI 的 promise 挂起) │ 用户点「允许」
│ │◄─── ③ RPC 回传 decision ───────┤
│ ④ respond(id=7, │ │
│ {decision:"accept"}) │ │
│◄─────────────────────────────┤ │
│ │ │

三种反向请求都在 handleServerRequestcodexAppServerClient.ts:1411)里:

RPC method类型传给上层的 payload行号
mcpServer/elicitation/requestmcptoolNameserverNamemessage:1412
item/commandExecution/requestApproval(旧名 execCommandApprovalexeccommand[]cwdreason:1435
item/fileChange/requestApproval(旧名 applyPatchApprovalpatchfileChanges:1472

没有注册 handler 时默认拒绝handleApprovalcodexAppServerClient.ts:1524);handler 抛异常也拒绝。安全默认值给得对。

runCodex.ts:686setApprovalHandler 把这三类统一成三个假工具名喂给权限 UI:exec → CodexBashpatch → CodexPatchmcp → 真实工具名或 McpToolpackages/happy-cli/src/codex/runCodex.ts:687-696)。挂起后到手机之间那一段(agentState.requests 待办清单、推送、RPC 回收)是所有后端共用的,细节见 04-remote-turn-and-permissions.md

4.6 callId 为什么要加前缀

Codex 的 item id 是按 thread 计数的,所以主线程的 item_3 和子 agent 线程的 item_3 会撞。解法是拼上 thread id:

// packages/happy-cli/src/codex/codexAppServerClient.ts:89 formatScopedItemKey
function formatScopedItemKey(threadId: string | null, itemId: string): string {
return threadId ? `${threadId}:${itemId}` : itemId;
}

上面那段注释点出了真正的约束(:84-88):同一个 item 的工具调用事件和审批请求必须用同一个 scoped id,因为 App 是靠 id 严格相等把权限卡片挂到工具调用上的。id 对不上,界面上就会出现一个孤零零的权限卡和一个没有权限的工具卡。

还有个更细的坑:同一个 item 若并发来第二次审批,直接复用 base id 会把第一个 pending 覆盖掉、让 Codex 那边永久挂起。所以 resolveApprovalCallIdcodexAppServerClient.ts:1509)只在冲突时才加消歧后缀——保证常见的单次审批仍然能靠 id 相等 join 上。

4.7 权限模式:在 SDK 边界做映射

Claude 和 Codex 的权限模型压根不是一回事。Claude 有 plan/acceptEdits/bypassPermissions,Codex 有 approvalPolicyuntrusted/on-request/never)× sandboxread-only/workspace-write/danger-full-access)两个正交维度。

resolveCodexExecutionPolicypackages/happy-cli/src/codex/executionPolicy.ts:4)做的就是这张翻译表:

Happy PermissionModeapprovalPolicysandbox
defaultuntrustedworkspace-write
read-onlyneverread-only
safe-yoloneverworkspace-write
yoloneverdanger-full-access
bypassPermissions(Claude 专有)neverdanger-full-access
acceptEdits(Claude 专有)on-requestworkspace-write
plan(Claude 专有)untrustedworkspace-write

下面三行是防御性回退——源码注释直接写着 "Defensive fallback for Claude-specific modes"(executionPolicy.ts:22)。也就是说 App 可能把 Claude 的模式名发给 Codex 会话,CLI 只能尽量猜一个语义相近的。这不是原生一致,是边界翻译。(Claude 侧的对偶动作是把 7 种模式压成 SDK 的 4 种,见 04-remote-turn-and-permissions.md §4.6。)

配套的 shouldAutoApproveCodexApprovalexecutionPolicy.ts:48)里有一条精彩的注释:safe-yolo 故意不在自动批准名单里——它的回合本来就跑在 approvalPolicy: 'never' + 工作区沙箱下,那么 Codex 还坚持要问的,必然是沙箱逃逸重试或 MCP elicitation,「正是 safe-yolo 承诺要问用户的东西」(executionPolicy.ts:56-59)。

4.8 反向注入:让 Codex 能改会话标题

Happy 要让模型能改会话标题,但 Codex 不认识 Happy 的 HTTP API。桥接方案是塞一个极小的 MCP server 给它:

happyMcpStdioBridge.ts 只暴露一个工具 change_title,收到调用后用 StreamableHTTPClientTransport 转发给本地 Happy HTTP MCP server(文件头注释 packages/happy-cli/src/codex/happyMcpStdioBridge.ts:1-11)。它自己绝不能往 stdout 打字,否则会污染 MCP 的 stdio 通道(:11)。

runCodex.ts:857 把它配成 thread/startmcp_servers,而且刻意用 process.execPath(node 绝对路径)而不是靠 shebang——注释解释了原因:Windows 执行不了 shebang 脚本,一旦启动失败模型会改用 shell echo 去「假装」改标题runCodex.ts:852-855)。

ACP 路线用同一招(packages/happy-cli/src/agent/acp/runAcp.ts:532)。


5. 路线二:ACP —— 复用别人已经定好的协议

这节讲什么: 当上游已经有开放协议时,Happy 就不重复造轮子。

5.1 一条命令接入任意 agent

resolveAcpAgentConfigpackages/happy-cli/src/agent/acp/acpAgentConfig.ts:17)的规则只有三档:

输入结果
happy acp geminiKNOWN_ACP_AGENTSgemini --experimental-acp
happy acp opencode查表 → opencode acp
happy acp -- mycli --foo直接透传,agentName 取命令名
happy acp mycli表里没有就当命令名用(acpAgentConfig.ts:48

内置表只有两项(acpAgentConfig.ts:6)。任何讲 ACP 的 CLI 都能被 Happy 遥控,这是这条路线最大的价值。

Gemini 的工厂函数(packages/happy-cli/src/agent/factories/gemini.ts:74)额外处理了凭据优先级(cloud token > 本地 ~/.gemini/ 配置 > 环境变量)和一个小细节:模型通过 GEMINI_MODEL 环境变量传,不用 --model 参数,注释说是为了避免污染 ACP 的 stdout(gemini.ts:104-106)。

5.2 权限:同步应答,不用回调表

ACP 后端把 SDK 的 requestPermission 处理函数直接写成 async(packages/happy-cli/src/agent/acp/AcpBackend.ts:567),在里面 await 手机的决定,然后返回一个 optionId。判定与挂起的骨架来自 BasePermissionHandler 的子类 GenericAcpPermissionHandlerpackages/happy-cli/src/agent/acp/runAcp.ts:407)。

关键取舍在 AcpBackend.ts:572-575permissionId 直接复用 toolCallId

这样手机回传的 id 天然就等于工具调用 id,App 那边不用再维护映射表——和 §4.6 的 Codex scoped key 是同一个思路,只是 Codex 因为 id 会撞才被迫加前缀。

决定映射回 ACP 的 optionId 时做了模糊匹配:找 proceed_once / proceed_always / cancel,找不到就退到 options[0]AcpBackend.ts:645-661)。

还有一个只为 UI 服务的动作:批准后额外 emit 一条 tool-result,「因为 tool_call_update 带的是另一个 id」,手机上的计时器需要这条才能收掉(AcpBackend.ts:663-670)。

5.3 AcpSessionManager:把消息流折成信封

这是 ACP 路线的第二层收敛。mapMessage()packages/happy-cli/src/agent/acp/AcpSessionManager.ts:103)不是逐条转换,而是带缓冲的流合并

原理演示(示意,非源码):

// 演示「同类文本累积、切换类型时才落地」的思路
let pending = '', pendingType = null;

function onDelta(type, text) { // type: 'thinking' | 'output'
const out = pendingType !== type ? flush() : []; // 类型变了才把旧的封成一条
pendingType = type;
pending += text; // 同类就继续攒
return out;
}
// 重点看:连续的 model-output 增量只会产出一条 text 信封,而不是几百条

真实实现里 pendingText / pendingType 就是这两个字段(AcpSessionManager.ts:39-40),flush():58,切换判断在 :112:139。工具调用到来时也会先 flush():146),保证文本不会跨过工具卡片粘在一起。

另外两个细节:

  • ACP 的 callId 会被换成本地 cuid2ensureSessionCallIdAcpSessionManager.ts:47),映射表在回合结束时清空(:96)。
  • 时钟被强制单调nextTime()max(lastTime + 1, Date.now())AcpSessionManager.ts:42),防止同毫秒内的多条信封在 App 里乱序。

回合边界由 runAcp 的主循环显式打点:拿到用户消息先 startTurn()packages/happy-cli/src/agent/acp/runAcp.ts:914),正常结束 endTurn('completed'):925),抛异常 endTurn('failed'):931)。所有产出统一经 sendEnvelopes():588)发走。


6. 路线三:agy —— 连协议都没有的后端

这节讲什么: 用一个极端案例证明 AgentBackend 这层抽象确实站得住。

agy(Antigravity CLI)没有任何流式事件协议,唯一的非交互入口是 agy --print "<prompt>":吐完最终答案,进程退出。

AgyBackendpackages/happy-cli/src/agy/AgyBackend.ts:59)的做法是一回合 spawn 一个进程,然后把进程生命周期硬映射成 AgentMessage

agy 的动静映射成
spawn 成功{ type: 'status', status: 'running' }
stdout 有数据{ type: 'model-output', textDelta }
exit code 0{ type: 'status', status: 'idle' }
exit code ≠ 0{ type: 'status', status: 'error', detail }

文件头老实交代了代价:print 模式没有工具调用事件,也没有权限事件,安全只能靠 CLI 参数和 agy 自己的 settings.json 兜(AgyBackend.ts:14-15)。

这个案例说明:接口设计对了,接一个「什么都没有」的后端也只需要写 200 行。


7. 汇流:两代归一格式并存

这节讲什么: 几条路线最后进的是哪个口子——答案是两个,而且是新旧两代。

7.1 ACPMessageData:第一代

packages/happy-cli/src/api/apiSession.ts:29 定义的联合类型,出口是 sendAgentMessage(provider, body)apiSession.ts:814),包成:

{ "role": "agent",
"content": { "type": "acp", "provider": "gemini", "data": { "type": "message", "message": "..." } },
"meta": { "sentFrom": "cli" } }

注意它保留了 provider 字段——归一得不彻底,App 仍然知道消息来自谁。

现役用户只剩 Gemini 的老路径(packages/happy-cli/src/gemini/runGemini.ts 里二十多处 session.sendAgentMessage('gemini', …))。

7.2 SessionEnvelope:第二代

happy-wire 里的 zod schema(packages/happy-wire/src/sessionProtocol.ts:109)。信封外层携带的是路由与锚点信息

字段作用
id / time身份与单调时间戳
roleuser | agent
turn回合 id(agent 信封几乎必填)
subagent子 agent 的 cuid2,用来标 sidechain
claudeUuid / codexItemId回退锚点:会话 fork/复制时精确定位到某条消息
usagetoken 用量,只更新状态条不渲染新行
ev真正的事件负载

ev 是一个九选一的可辨识联合(sessionProtocol.ts:95),逐项含义与回合开合规则在 04-remote-turn-and-permissions.md §7 讲过,这里只强调一点:九种事件够小,所以每个后端都实现得起——这正是「多后端归一」能成立的前提。

出口是 sendSessionProtocolMessageapiSession.ts:793),包成 role: 'session'

7.3 谁走哪条

后端映射器线协议格式
ClaudemapClaudeLogMessageToSessionEnvelopes(调用点 apiSession.ts:719SessionEnvelope
CodexmapCodexMcpMessageToSessionEnvelopes(调用点 runCodex.ts:829SessionEnvelope
ACP(gemini/opencode/…)AcpSessionManager.mapMessage(调用点 runAcp.ts:828SessionEnvelope
Gemini 老路径无(直接手写 payload)ACPMessageData
Codex 更老路径sendCodexMessageapiSession.ts:767

结论:新代码一律走信封,旧格式为了兼容历史会话还留在 App 的解析器里。


8. App 端:从信封到界面

这节讲什么: 手机收到一堆加密信封之后,怎么变成能滑的聊天列表。

8.1 为什么需要一整条流水线

如果消息是有序、不重复、结构完整的,messages.map(render) 就够了。但真实情况是三个「不」:

  • 不有序:sidechain(子 agent)的消息可能比它的父 Task 先到。
  • 不唯一:本地乐观插入的消息 + 服务器回传的同一条消息 + 重连后的全量补拉,同一条可能来三次。
  • 不完整:权限请求走 agentState 通道,工具调用走消息通道,两者到达时间不确定——先到的那个要先建卡片,后到的那个要认领它。

reducer 就是为这三件事写的。

8.2 入口:先解成 NormalizedMessage

normalizeRawMessagepackages/happy-app/sources/sync/typesRaw.ts:761)用 zod 判别联合把四种线协议格式收进同一个 NormalizedMessagetypesRaw.ts:517):

raw.content.type处理行号
outputClaude 原生 JSONL 转录typesRaw.ts:791
codex第一代 codex 格式:957
sessionhappy-wire 信封:1032
acp第一代 ACP 格式(带 provider):1036

信封的处理在 normalizeSessionEnvelopetypesRaw.ts:545),有三条不显然的规则:

  1. agent 信封没有 turn 就直接丢:559)——例外是「纯 usage 更新」,它可能在 turn-end 之后才到。
  2. turn-start 返回 null:569)——生命周期标记,不该出现在聊天流里。
  3. turn-end 被翻译成 { type: 'ready' } 事件:578)——App 用它判断「agent 空了,可以发下一条」。

subagent 字段一旦存在就置 isSidechain = truetypesRaw.ts:565-566),这是信封与 sidechain 机制的唯一接口。

8.3 分阶段流水

怎么读这张图: 从上往下是执行顺序,顺序本身就是设计——每一阶段都依赖上一阶段建立的状态。

NormalizedMessage[]

traceMessages ────────► 标出 sidechainId,分成两堆

┌─────┴──────┐
非sidechain sidechain
│ │
▼ │
Phase 0.5 │ 把 ready / "Turn started" 滤掉;
消息转事件 │ 特判消息变成事件(限额、改标题、进 plan 模式)
│ │
▼ │
Phase 0 │ 读 agentState 的权限请求,
权限占位 │ 先给「还没来的工具调用」造好卡片
│ │
▼ │
Phase 1 │ 用户消息 + agent 文本(跳过工具)
│ │
▼ │
Phase 2 │ 工具调用:能认领 Phase 0 的卡片就认领,
│ │ 不能就新建
▼ │
Phase 3 │ 工具结果:填回已有卡片,改状态
│ │
▼ ▼
Phase 4 ◄────────┘ sidechain 挂到父 Task 名下


Phase 5 剩下的 event 消息建卡


收集 changed → convertReducerMessageToMessage → Message[]

各阶段职责与源码位置:

阶段干什么行号
trace识别 sidechain、缓冲孤儿消息reducer.ts:284
Phase 0.5消息→事件转换,转过的跳过后续所有阶段reducer.ts:302-391
Phase 0agentState 权限 → 占位工具消息reducer.ts:412
Phase 1用户消息与 agent 文本reducer.ts:664
Phase 2工具调用(认领或新建)reducer.ts:744
Phase 3工具结果(回填)reducer.ts:840
Phase 4sidechain 归并reducer.ts:906
Phase 5剩余事件reducer.ts:1108
收尾只转换本轮 changed 的消息reducer.ts:1129

为什么 Phase 0 必须在 Phase 2 之前? 因为权限请求通常比工具调用先到(agent 得先问才能跑)。先建卡片,工具调用来了填内容,用户就能看到「正在等你批准 → 已批准,运行中」的连续动画,而不是卡片闪一下重建。

8.4 三重去重

同一条消息可能从三个方向重复进来,所以有三张表(ReducerStatereducer.ts:151-157):

key → value挡住什么
localIds本地乐观 id → 内部 id自己刚发的消息被服务器回显
messageIds服务器消息 id → 内部 id重连后的全量补拉
toolIdToMessageId工具/权限 id → 内部 id权限卡与工具卡重复建两张

前两张在 Phase 0.5 入口就短路(reducer.ts:304:307),Phase 1 再查一次(:670:674)。

被滤掉的消息也要登记进 messageIds(如 reducer.ts:314 的 ready 事件、:321 的 "Turn started"),否则下一批数据到达时它们会被当成新消息重新处理一遍。这一步很容易漏。

8.5 权限卡与工具卡怎么 join

这是全章最微妙的一段。Phase 0 里的 join key 不是权限 id 本身:

// packages/happy-app/sources/sync/reducer/reducer.ts:424
const joinId = request.toolUseId || permId;

原因写在上面的注释(:421-423):Claude 的子 agent 权限请求 id 是 agentID:toolUseID 这种带作用域的复合 id,而工具调用事件带的是裸 toolUseId。所以 join 要用裸 id,回传决定时才用 permId——StoredPermission.id 专门保存这个「规范请求 id」(reducer.ts:136-139)。

这正好和 §4.6 的 Codex 侧构成对照:Codex 选择让两边都用 scoped id 来保证相等,Claude 则是两边 id 天然不同、由 App 拆开 join。同一个问题,两端各解一半。

Phase 2 的两条分支:

  • 认领reducer.ts:751):找到已有卡片,合并 input(mergeToolInputs 让已有值优先,reducer.ts:194),并把「已批准且显示为 completed」的卡片改回 running:763-767)。
  • 新建reducer.ts:771):查 state.permissions 有无对应权限;有的话用权限的 createdAt 而不是消息的reducer.ts:782),这样卡片在列表里的位置不会因为工具晚到而跳动。

Phase 3 回填结果时有个防御:if (message.tool.state !== 'running') continue;reducer.ts:855)——已经因权限被拒而变成 error 的卡片,不会被迟到的结果覆盖回 completed

整个 reducer 的核心不变量写在文件头注释里,值得抄进任何同类系统:消息创建后,只允许改工具状态/结果,绝不允许改 createdAtreducer.ts:80-84)。

8.6 sidechain:子 agent 的消息去哪了

reducerTracer 负责把子 agent 的消息认领回它的父 Task。核心状态四张表(reducerTracer.ts:67-81):

用途
taskTools / promptToTaskId记住 Task 工具调用及其 prompt
uuidToSidechainId消息 uuid → 所属 Task 的消息 id
toolCallToMessageId工具调用 id → 父消息 id(信封协议的 subagent 支持)
orphanMessages父消息还没到的孤儿,按 parentUuid 缓冲

匹配方式是用 prompt 内容对上:sidechain 的根消息带的 prompt 与某个 Task 的 prompt 相同,就认亲(reducerTracer.ts:24-42 的说明)。父认下之后,所有子孙沿 parentUUID 继承同一个 sidechainId,缓冲的孤儿递归放行。

最终 sidechain 不进主列表,而是挂在父工具卡片的 children 里(convertReducerMessageToMessagereducer.ts:1220)。

8.7 Phase 0.5 的「消息变事件」

有些消息不该当聊天内容渲染,而该变成一条系统提示。parseMessageAsEventpackages/happy-app/sources/sync/reducer/messageToEvent.ts:23)目前认三种:

触发条件变成
文本匹配 ^Claude AI usage limit reached|(\d+)${ type: 'limit-reached', endsAt }
调用 mcp__happy__change_title提示 "Title changed to …"
调用 EnterPlanMode / enter_plan_mode提示 "Entering plan mode"

这三条规则全是 Claude 专有的(正则里就写着 "Claude AI")。归一层之上仍然坐着厂商特判——这是下一节要算的账。


9. 巧妙之处(可以直接抄的)

  1. 纪元号防僵尸回调。 每次 spawn 递增 processEpoch,所有异步回调进门先比对;旧进程的临终事件自动作废(codexAppServerClient.ts:644 / :661 / :686)。比布尔标记抗竞态。

  2. id 相等就是 join,不建映射表。 ACP 直接令 permissionId = toolCallIdAcpBackend.ts:575),省掉一整张跨端映射表;Codex 因为 id 会撞才被迫加 thread 前缀(codexAppServerClient.ts:89),思路仍然一致。

  3. 只在冲突时才消歧。 resolveApprovalCallIdcodexAppServerClient.ts:1509)保留「常见情况下 id 严格相等」的好性质,只有第二个并发审批才加后缀——避免了「为了极少数情况牺牲多数情况」。

  4. 单调时钟。 nextTime()max(lastTime + 1, Date.now())AcpSessionManager.ts:42),同毫秒内的信封仍有稳定顺序。

  5. 文本流缓冲,按类型切换才落地。 上百条 textDelta 只产出一条信封(AcpSessionManager.ts:112 / :139),显著降低加密和网络开销。

  6. 占位先行的时间戳继承。 工具卡片用权限的 createdAt 而非消息的(reducer.ts:782),列表位置不会因为工具晚到而跳。

  7. 安全默认拒绝。 没有审批 handler、handler 抛异常,一律返回 deniedcodexAppServerClient.ts:1521-1524)。

  8. 未知消息也要响应。 不认识的服务器请求也回一个空 {}codexAppServerClient.ts:1500-1501),否则对面永久挂起。


10. 边界与局限(诚实清单)

10.1 协议自陈「未定型」

happy-wire 的文件头挂着最直白的免责声明:

// packages/happy-wire/src/sessionProtocol.ts:1-13
* UNDER REVIEW - NEEDS MORE CAREFUL DESIGN
* ...
* Treat this as a compatibility contract for current producers/consumers,
* not as a final cross-agent standard.
* ...
* Types are kept here for reference but are frozen. Do not add new consumers.

它甚至点名建议去看 pi.dev 怎么做标准化。所以「统一信封」目前是一份冻结的兼容契约,不是稳定的跨 agent 标准。

10.2 抽象在四处漏了厂商

漏点证据
AgentMessage 里有四个 Codex 专有成员AgentBackend.ts:35-38token-countexec-approval-requestpatch-apply-*
信封字段里有两个厂商专有锚点sessionProtocol.ts:124claudeUuid)、:127codexItemId
App 的事件规则写死 Claude 文案messageToEvent.ts:34Claude AI usage limit reached
ACPMessageData 仍带 provider 字段apiSession.ts:814

10.3 权限模式是映射,不是原生一致

executionPolicy.ts:22-26 明确把 bypassPermissions / acceptEdits / plan 标为 "Defensive fallback for Claude-specific modes"。Claude 的 plan 模式在 Codex 下只是「对不可信命令发问」,并不真的进入规划模式。跨后端切模式时行为不等价。

10.4 注册表是死代码

AgentRegistryinitializeAgents() 写完了但没接线(§3.4)。真正的分发是 index.ts 里的一串 if/else,加新后端仍需改主入口。

10.5 两代格式并存

ACPMessageDataSessionEnvelope 同时在跑,App 侧 typesRaw.ts 要同时解 output/codex/acp/session 四种。历史包袱明摆着,尚未收口。

10.6 agy 路线没有工具与权限

AgyBackend 的 print 模式不产生任何工具调用或权限事件AgyBackend.ts:14-15),Happy 的核心卖点「手机上批准」对它完全失效,安全只能靠 CLI 参数。

10.7 分叉能力也不齐

会话 fork / 回溯只对 Claude(改 JSONL 转录文件)和 Codex(thread/fork)实现了,Gemini / openclaw / agy 没有对应的 RPC,细节见 05-daemon-and-machines.md


11. 代码地图(导航索引)

主题文件路径关键符号
后端统一接口与消息类型packages/happy-cli/src/agent/core/AgentBackend.tsAgentBackendAgentMessageAgentTransportAgentId
工厂注册表(未接线)packages/happy-cli/src/agent/core/AgentRegistry.tsAgentRegistryagentRegistry
注册表初始化(无调用点)packages/happy-cli/src/agent/index.tsinitializeAgents
上一代收敛适配器packages/happy-cli/src/agent/adapters/MessageAdapter.tsMessageAdapter.normalizeadapters
ACP 传输默认行为packages/happy-cli/src/agent/transport/DefaultTransport.tsDefaultTransport.filterStdoutLine
Codex JSON-RPC 客户端packages/happy-cli/src/codex/codexAppServerClient.tsCodexAppServerClientconnecthandleLinehandleServerRequestformatScopedItemKeyresolveApprovalCallIdsendTurnAndWaitinterruptTurn
Codex 回合与审批编排packages/happy-cli/src/codex/runCodex.tssetApprovalHandlersetEventHandler
Codex → 信封映射packages/happy-cli/src/codex/utils/sessionProtocolMapper.tsmapCodexMcpMessageToSessionEnvelopes
权限模式翻译表packages/happy-cli/src/codex/executionPolicy.tsresolveCodexExecutionPolicyshouldAutoApproveCodexApproval
改标题的 MCP 桥packages/happy-cli/src/codex/happyMcpStdioBridge.tschange_title
ACP 后端与权限应答packages/happy-cli/src/agent/acp/AcpBackend.tsAcpBackendrequestPermission
ACP → 信封映射packages/happy-cli/src/agent/acp/AcpSessionManager.tsmapMessagestartTurnendTurnensureSessionCallId
ACP 运行循环与权限骨架packages/happy-cli/src/agent/acp/runAcp.tsrunAcponBackendMessagesendEnvelopesGenericAcpPermissionHandler
ACP agent 解析packages/happy-cli/src/agent/acp/acpAgentConfig.tsKNOWN_ACP_AGENTSresolveAcpAgentConfig
Gemini 工厂packages/happy-cli/src/agent/factories/gemini.tscreateGeminiBackendregisterGeminiAgent
无协议后端范例packages/happy-cli/src/agy/AgyBackend.tsAgyBackend
会话出口(两代格式)packages/happy-cli/src/api/apiSession.tsACPMessageDatasendAgentMessagesendSessionProtocolMessagesendClaudeSessionMessage
线协议定义packages/happy-wire/src/sessionProtocol.tssessionEnvelopeSchemasessionEventSchemacreateEnvelope
App 侧线协议解析packages/happy-app/sources/sync/typesRaw.tsnormalizeRawMessagenormalizeSessionEnvelopeNormalizedMessagepreprocessMessageContent
App 侧装配流水packages/happy-app/sources/sync/reducer/reducer.tsreducerReducerStatecreateReducerconvertReducerMessageToMessagemergeToolInputs
sidechain 追踪packages/happy-app/sources/sync/reducer/reducerTracer.tstraceMessagesTracerStatecreateTracer
消息→事件规则packages/happy-app/sources/sync/reducer/messageToEvent.tsparseMessageAsEvent
渲染用消息类型packages/happy-app/sources/sync/typesMessage.tsMessageToolCall

12. 相关章节

  • index.md —— Happy 整体架构与阅读地图
  • 02-sync-and-rpc.md —— 本章的信封是怎么加密上线、怎么被服务器盲转的
  • 04-remote-turn-and-permissions.md —— 一次远程回合的完整时序、信封九种事件的逐项含义,本章的审批回路是其中一环
  • 05-daemon-and-machines.md —— happy codex / happy acp 这些子命令是怎么被守护进程凭空拉起来的,以及各后端不齐的 fork 能力