跳转到内容

09. 消息、事件与前端一致性

Claude 与 Pi 的原始事件不同,Electron 与 WebUI 的状态模型也不同。Craft 用两层稳定协议把它们解耦:backend 发 AgentEvent,SessionManager 归约并扩展成带 session id 的 SessionEvent,renderer 再用纯函数投影。

对象 生命周期 是否持久化 用途
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 的产品事件。

定义: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。新增事件若没处理,会在编译期暴露。

流式 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 破坏。

短暂、可丢、用于即时反馈:

  • SessionManager 合批;
  • renderer 按 turnId 找 streaming message;
  • 没找到就创建临时 message;
  • isStreaming/isPending = true
  • 不更新 lastMessageAt,避免每 token 改列表排序。

权威纠正点:

  • 携带完整 text
  • SessionManager 创建持久 assistant message;
  • 赋权威 message id 和单调 timestamp;
  • renderer 替换临时 id;
  • 清 streaming/pending;
  • 若 delta 从未到达,也创建完整 message。

源码:handleTextDeltahandleTextComplete

这个设计使系统可以容忍:

  • delta 合批/丢包;
  • React 更新比 complete 慢;
  • complete 在本地 state 中“先于”某次 delta commit;
  • reconnect 只拿到最终状态。

Agent 可能在工具之间输出 reasoning/status text。isIntermediate 区分中间文本与最终答案:

  • 中间 complete 不应更新会话 lastMessageAt
  • 多个中间 block 不能互相覆盖;
  • 消息列表要保留 parentToolUseId/turnId;
  • 消息网关的 progress/final_only 模式可选择不发送中间文本。

renderer 的 complete handler 如果发现现有 completed intermediate,又收到另一个 intermediate,会创建新 message,而不是复用同一条。

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/stopped

tool_start 生成带 input/intent/display metadata 的 tool message;tool_resulttoolUseId 原位更新,不追加一条无关消息。

Provider 有时会从 assistant content 与 stream event 两处报告同一个 tool start,SessionManager/ToolIndex 必须去重。否则 UI 会出现重复卡片,result 只完成其中一张。

大结果不能直接塞 JSONL/UI:server 持久化前截断或外置到 session data,message 留摘要与文件引用;UI overlay 按类型提供 JSON/table/code/file preview。

发送并非只有成功/失败:

optimistic (renderer 临时)
→ accepted(服务端已 durable)
→ processing(当前执行)
optimistic
→ queued(当前 turn 之后)
→ processing(队首重放)

SendMessageOptions.optimisticMessageId 让 server event 能把 renderer 临时 bubble 与权威 message 对齐。若失败,UI 可以移除/标错对应 optimistic message,而不是猜最后一条。

定义: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 触发,与模型无关。

典型事件处理:

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。

Date.now() 分辨率有限,多事件可同毫秒;queue replay 还会把一条早创建的 user message放到稍后的 assistant 后面。仅按 timestamp 排序会重排 transcript。

SessionManager 生成单调 timestamp,并在 queue replay 时 restamp:

queued.createdAt = t1
current assistant complete = t2
replay queued.timestamp = max(now, t2 + 1)

renderer text_complete 用服务端 timestamp 覆盖 delta 时本地生成的时间,保证 reload 与 live 顺序一致。

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。

每 token 都写全局 Jotai atom 会触发更多订阅和渲染。hook 用 Map<sessionId, StreamingState> ref 累积,再把需要展示的 message 投影进 session。

ref 不是事实源:complete 后清掉,reload 从持久消息恢复。它只是高频短期 buffer。

纯 reducer 可以返回:

  • toast error;
  • 打开 plan/auth/permission UI;
  • scroll/notification;
  • 其他 shell effect。

如果在 reducer 内直接调用 window.* 或 RPC,重放同一事件会重复副作用,单元测试也难。Effect runner 可根据 event source/replay context 去重。

Unread 不是“最后一条是 assistant”这么简单:

  • assistant final 在用户未查看时完成 → hasUnread=true
  • 正在查看且非 processing 时可 mark read;
  • intermediate/delta 不应触发 unread;
  • 多窗口要由服务端知道 active viewing workspace/session;
  • reconnect/reload 用持久 hasUnread/lastReadMessageId 恢复。

这也是 session 是工作单元而非聊天数组的体现。

WS replay 可能使客户端再次看到 event。理想处理层级:

  1. transport seq/ack 尽量不重复投递;
  2. event 带 stable ids;
  3. reducer 对 messageId/toolUseId/requestId upsert;
  4. 长断线后以 snapshot 替代盲目 replay;
  5. effects 不应仅靠“收到事件”执行不可逆动作。

当前 pure processor 的 ID 查找是基础,但并非所有 metadata event 都天然幂等;新增事件要明确重复语义。

简单字符串,兼容未知/legacy 错误。

包含稳定 code、title、message、canRetry、actions 和安全 metadata。UI 可给“重试/compact/重新认证”等动作,而不是解析错误字符串。

Provider error 先经 Claude/Pi mapper,SessionManager 再加 session context。原始 error/details 可能含路径、prompt 或 token,遥测需脱敏。

竞态 结果 防线
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

Craft 的消息系统不是一条 websocket 文本流,而是“provider event → server authoritative mutation → typed session delta → pure UI projection”。完整文本纠正 delta、权威 ID 纠正 optimistic ID、snapshot 纠正 replay,这是最终一致性的三层保险。

下一章进入模型真正看到的内容:system prompt、稳定/易变上下文、技能/项目 memory、恢复上下文和 compaction 如何控制 token 与缓存。