09. 消息、事件与前端一致性
Claude 与 Pi 的原始事件不同,Electron 与 WebUI 的状态模型也不同。Craft 用两层稳定协议把它们解耦:backend 发 AgentEvent,SessionManager 归约并扩展成带 session id 的 SessionEvent,renderer 再用纯函数投影。
9.1 三种对象不要混淆
Section titled “9.1 三种对象不要混淆”| 对象 | 生命周期 | 是否持久化 | 用途 |
|---|---|---|---|
| provider event/message | SDK 内部 | provider 自己决定 | 原始流式与 resume |
AgentEvent |
一个 backend turn | 间接 | provider-neutral 执行事件 |
SessionEvent |
server → clients | event buffer 短存 | 产品状态增量 |
Message/StoredMessage |
session 长期 | JSONL | transcript 事实源 |
一个 text_delta 不一定对应一行 StoredMessage;一个 tool call 通常先有 start message,再被 result 更新;SessionEvent 里还有 flag/status/source 等根本不来自 provider 的产品事件。
9.2 AgentEvent 联合类型
Section titled “9.2 AgentEvent 联合类型”定义:core/types/message.ts。主要分组:
文本 text_delta, text_complete, pi_turn_anchor工具 tool_start, tool_result, permission_request生命周期 status, info, error, typed_error, complete环境 working_directory_changed, source_activated后台 task_backgrounded/progress/completed, shell_backgrounded/killed工作流 workflow_agent_completed用量 usage_update转向 steer_undelivered联合类型的价值不仅是 TypeScript autocomplete,而是强迫 renderer/server switch 做 exhaustive check。新增事件若没处理,会在编译期暴露。
9.3 关联 ID
Section titled “9.3 关联 ID”流式 UI 能否稳定,取决于关联键而非数组位置:
sessionId:事件属于哪个会话;turnId:同一 assistant turn 的 text/tool;messageId:权威持久消息;toolUseId:tool start/result;parentToolUseId:子 agent/嵌套工具;taskId/shellId/workflowId:后台实体;requestId:permission/auth/JSONL RPC。
前端 helper 明确“message lookup by ID, never position”。位置会被中间 thinking block、迟到 tool result、reload merge 破坏。
9.4 文本事件的两阶段语义
Section titled “9.4 文本事件的两阶段语义”text_delta
Section titled “text_delta”短暂、可丢、用于即时反馈:
- SessionManager 合批;
- renderer 按 turnId 找 streaming message;
- 没找到就创建临时 message;
isStreaming/isPending = true;- 不更新
lastMessageAt,避免每 token 改列表排序。
text_complete
Section titled “text_complete”权威纠正点:
- 携带完整
text; - SessionManager 创建持久 assistant message;
- 赋权威 message id 和单调 timestamp;
- renderer 替换临时 id;
- 清 streaming/pending;
- 若 delta 从未到达,也创建完整 message。
源码:handleTextDelta、handleTextComplete。
这个设计使系统可以容忍:
- delta 合批/丢包;
- React 更新比 complete 慢;
- complete 在本地 state 中“先于”某次 delta commit;
- reconnect 只拿到最终状态。
9.5 Intermediate text
Section titled “9.5 Intermediate text”Agent 可能在工具之间输出 reasoning/status text。isIntermediate 区分中间文本与最终答案:
- 中间 complete 不应更新会话
lastMessageAt; - 多个中间 block 不能互相覆盖;
- 消息列表要保留 parentToolUseId/turnId;
- 消息网关的
progress/final_only模式可选择不发送中间文本。
renderer 的 complete handler 如果发现现有 completed intermediate,又收到另一个 intermediate,会创建新 message,而不是复用同一条。
9.6 工具状态机
Section titled “9.6 工具状态机”stateDiagram-v2 [*] --> Pending: tool_start Pending --> Running: UI renders activity Running --> Success: tool_result isError=false Running --> Error: tool_result isError=true Running --> Background: task/shell_backgrounded Background --> Success: task_completed Background --> Error: failed/stoppedtool_start 生成带 input/intent/display metadata 的 tool message;tool_result 按 toolUseId 原位更新,不追加一条无关消息。
Provider 有时会从 assistant content 与 stream event 两处报告同一个 tool start,SessionManager/ToolIndex 必须去重。否则 UI 会出现重复卡片,result 只完成其中一张。
大结果不能直接塞 JSONL/UI:server 持久化前截断或外置到 session data,message 留摘要与文件引用;UI overlay 按类型提供 JSON/table/code/file preview。
9.7 User message 的确认状态
Section titled “9.7 User message 的确认状态”发送并非只有成功/失败:
optimistic (renderer 临时)→ accepted(服务端已 durable)→ processing(当前执行)
或
optimistic→ queued(当前 turn 之后)→ processing(队首重放)SendMessageOptions.optimisticMessageId 让 server event 能把 renderer 临时 bubble 与权威 message 对齐。若失败,UI 可以移除/标错对应 optimistic message,而不是猜最后一条。
9.8 SessionEvent 扩展产品语义
Section titled “9.8 SessionEvent 扩展产品语义”定义:protocol/dto.ts。除 AgentEvent 外还包括:
interrupted;title_generated/regenerating;- source/label/project/status/model/connection 变化;
- flag/archive/read/share;
- permission/credential/auth/plan request;
session_created/deleted;- user message accepted/queued/processing;
- annotations;
- working directory error;
- compaction-specific status。
为何不直接让 backend 发这些?因为它们属于产品 session,可能由 RPC、automation、filesystem watcher 或 UI command 触发,与模型无关。
9.9 服务端归约与持久化顺序
Section titled “9.9 服务端归约与持久化顺序”典型事件处理:
backend AgentEvent→ 检查 generation / 去重→ 更新 ManagedSession→ 对关键状态 enqueue/flush JSONL→ 生成 SessionEvent(sessionId + authoritative ids)→ event sink push不同事件对 durability 要求不同:
- 用户消息:必须 flush 后 ack;
- final text/tool result:应持久化并最终 flush;
- delta/status:不必逐条落盘;
- permission request:pending runtime + transcript/product event;
- metadata command:原子保存后 push。
9.10 单调时间戳
Section titled “9.10 单调时间戳”Date.now() 分辨率有限,多事件可同毫秒;queue replay 还会把一条早创建的 user message放到稍后的 assistant 后面。仅按 timestamp 排序会重排 transcript。
SessionManager 生成单调 timestamp,并在 queue replay 时 restamp:
queued.createdAt = t1current assistant complete = t2replay queued.timestamp = max(now, t2 + 1)renderer text_complete 用服务端 timestamp 覆盖 delta 时本地生成的时间,保证 reload 与 live 顺序一致。
9.11 Renderer 纯事件处理器
Section titled “9.11 Renderer 纯事件处理器”processEvent 签名:
processEvent(state: SessionState, event: AgentEvent): { state: SessionState effects: Effect[]}它保证:
- 无副作用;
- 所有分支返回新引用;
- 按 ID 更新;
- unknown event 有 exhaustive never;
- toast、scroll、dialog 等作为 effect 描述返回。
useEventProcessor() 再负责:
- per-session streaming accumulator Map;
- 调纯函数;
- 执行/上报外部副作用;
- error/typed_error 送 Sentry,但剔除可能含敏感内容的 details。
这是一种 Elm/Redux 风格小型 reducer,但没有强迫所有 React UI 状态都进一个大 store。
9.12 Streaming state 为什么放 ref
Section titled “9.12 Streaming state 为什么放 ref”每 token 都写全局 Jotai atom 会触发更多订阅和渲染。hook 用 Map<sessionId, StreamingState> ref 累积,再把需要展示的 message 投影进 session。
ref 不是事实源:complete 后清掉,reload 从持久消息恢复。它只是高频短期 buffer。
9.13 Effect 分离
Section titled “9.13 Effect 分离”纯 reducer 可以返回:
- toast error;
- 打开 plan/auth/permission UI;
- scroll/notification;
- 其他 shell effect。
如果在 reducer 内直接调用 window.* 或 RPC,重放同一事件会重复副作用,单元测试也难。Effect runner 可根据 event source/replay context 去重。
9.14 Unread 与 active viewing
Section titled “9.14 Unread 与 active viewing”Unread 不是“最后一条是 assistant”这么简单:
- assistant final 在用户未查看时完成 →
hasUnread=true; - 正在查看且非 processing 时可 mark read;
- intermediate/delta 不应触发 unread;
- 多窗口要由服务端知道 active viewing workspace/session;
- reconnect/reload 用持久
hasUnread/lastReadMessageId恢复。
这也是 session 是工作单元而非聊天数组的体现。
9.15 断线、重放与幂等
Section titled “9.15 断线、重放与幂等”WS replay 可能使客户端再次看到 event。理想处理层级:
- transport seq/ack 尽量不重复投递;
- event 带 stable ids;
- reducer 对
messageId/toolUseId/requestIdupsert; - 长断线后以 snapshot 替代盲目 replay;
- effects 不应仅靠“收到事件”执行不可逆动作。
当前 pure processor 的 ID 查找是基础,但并非所有 metadata event 都天然幂等;新增事件要明确重复语义。
9.16 错误模型
Section titled “9.16 错误模型”简单字符串,兼容未知/legacy 错误。
typed_error
Section titled “typed_error”包含稳定 code、title、message、canRetry、actions 和安全 metadata。UI 可给“重试/compact/重新认证”等动作,而不是解析错误字符串。
Provider error 先经 Claude/Pi mapper,SessionManager 再加 session context。原始 error/details 可能含路径、prompt 或 token,遥测需脱敏。
9.17 常见竞态与代码答案
Section titled “9.17 常见竞态与代码答案”| 竞态 | 结果 | 防线 |
|---|---|---|
| complete 先于 renderer delta state commit | 找不到 message | complete 可创建 message |
| 重复 tool start | 双卡片 | toolUseId 去重 |
| old generator late event | 污染新 turn | processing generation |
| queued user 时间更早 | reload 顺序错 | replay restamp |
| client ack 后 server 崩 | 用户消息消失 | flush before accepted |
| reconnect 重放 | 重复消息/effect | seq + stable id upsert |
| tool result 太大 | UI/JSONL 卡死 | guard + external file |
| optimistic id 与 server id 不同 | branch/annotation 找不到 | complete/user event 替换权威 id |
9.18 本章小结
Section titled “9.18 本章小结”Craft 的消息系统不是一条 websocket 文本流,而是“provider event → server authoritative mutation → typed session delta → pure UI projection”。完整文本纠正 delta、权威 ID 纠正 optimistic ID、snapshot 纠正 replay,这是最终一致性的三层保险。
下一章进入模型真正看到的内容:system prompt、稳定/易变上下文、技能/项目 memory、恢复上下文和 compaction 如何控制 token 与缓存。