青雲的博客
深入浅出 DeepSeek Harness 第七部:产品面——同一运行时的不同面孔 第 43 章

Conversation UI:消费事件但不冒充事实源

前端 UI 消费 Host 通过 MuxFrame 推送的 session events,将其投影为对话界面。UI 不持有 Session 引用、不修改 event log、不做 domain folding。所有用户操作走 ApiProxy RPC 发回 Host,Host 产生新事件后经 mux 流返回客户端,投影更新触发重渲染。断线重连靠重新订阅 mux 流加 history tail 重建所有 projection store,不靠本地持久化。人类 transcript 用 isAppendSurfaceEvent 而非 surface。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

你在对话框里看到 Agent 回复逐字流出、tool call 卡片展开收缩、approval 弹窗出现、queue dock 里排着待发消息、右侧 trajectory 面板时间线增长。你可能以为”浏览器持有当前对话的完整状态”——你发消息、本地状态更新、后端同步。

完全不是。浏览器里没有 Session、没有 Agent、没有 Cordis 容器、没有 event log。浏览器是一个纯粹的投影消费者:它从 Host 进程收 MuxFrame 流,把 frame 投影成 UI 状态,把用户操作发回 Host。浏览器从来不是任何数据的 owner。

这层关系要是搞错,你会把 bug 全部查错地方:去翻 localStorage、去怀疑前端状态同步、甚至想上本地 CRDT。其实前端层干的事很朴素:收 frame、折快照、渲染,再把用户动作发回 host。

这章你先把四个角色分开就行:

  1. Session log 是事实源:真相在 Host 进程里的 append-only 事件日志。
  2. MuxFrame 流 是运输:把 host 决定好的事件和投影送到浏览器。
  3. Projection stores 是折叠:把 frame 折成快照,不重写领域规则。
  4. React UI 是渲染:订阅快照、画出来、把用户动作发回 host。

后面讲 transcript、reconnect、tool card presenter,其实都是围着这四个角色打转。

人类 transcript 用 append-origin 事件,不用 surface

DSH session log 里有一个 surface 层——模型可见的消息序列。surface 支持两种操作:append(新消息追加到尾部)和 replace(用新消息替换之前的一段 surface range)。模型只需要看最新版本,所以 replace 是合理的——比如 tool/result 重试时,新结果替换旧结果。

但人类读的对话 transcript 不能用 surface。如果用了,当一条 tool/result 被 replace,用户之前看到的那条结果会从界面消失——这不可接受。

isAppendSurfaceEvent 解决了这个问题。它只筛选 surfaceOp === 'append' 的事件——那些在自己的 log 位置新增到 surface 尾部、从未作为 replacement copy 存在的事件。注释写得很明确:

The model-visible surface deliberately shadows replaced ranges, so it is the wrong source for a human transcript — a landed replacement would erase conversation the user already saw. Append-origin events are that transcript’s durable source material; replacement copies stay model-only.

这是两层的设计:模型看 surface(包含 replace),人类看 append-origin 子集。UI 层消费的是后者。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '40,55p' "$repo/packages/core/session/src/surface.ts"

deriveEventMessage:事件到消息的唯一投影规则

无论是 Host 端为模型构建 messages 数组,还是客户端为 UI 渲染对话,都经过同一个纯函数:deriveEventMessage。它是 per-node projection rule——把一个 session event 投影成一条 LLM Message,或者 null(非 surface 事件不产生消息)。

三种事件类型能产生 Message:

  • user/message:直接返回 event.data
  • assistant/message:返回 event.data.message(空 content 的返回 null——那只用来承载 usage)
  • tool/result:返回 event.data.message

其余事件类型(turn/step boundaries、chunks、usage events)全部返回 null。注释强调:“only message-producing events derive history; turn/step boundaries, chunks, usage, and errors are trace/replay data”。

这个函数标注了 browser-safe:web clients 通过 subpath export 消费它,所以不能引入 node: 模块。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '83,114p' "$repo/packages/core/session/src/surface.ts"

MuxFrame:Host 推给客户端的全部词汇

Client Runtime 通过 WebSocket 订阅 events.mux 流。Host 端通过这条流推送的全部帧类型定义在 MuxFrame union 中:

  • session/event:raw session event passthrough + 可选的 ToolEventView(渲染意图)
  • session/subscribed:连接建立时的基线帧,告知 lastSeq
  • approval/requested / approval/resolved:权限审批流
  • question/requested / question/resolved:AskUser 问答流
  • session/queue:inbox 快照(完整替换语义)
  • session/jobs:后台任务快照(完整替换语义)
  • session/projection:host 端计算的投影值推送
  • stream/error:流级错误

注意 session/queuesession/jobs 都是 whole snapshot 语义——不是增量 delta,而是每次变更后推送完整当前状态。这是设计上的选择:inbox 和 job registry 是 process-local transient state(没有 durable event),用完整快照让 reconnect、多 tab、编辑、删除全部收敛到一个权威值。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '69,108p' "$repo/packages/host/apiproxy/src/api/events.ts"

ConversationSnapshot:UI 层唯一的数据形状

客户端的 conversation.ts 定义了 UI 消费的纯数据类型:

  • UserMessageNode:已定型的用户消息(kind: ‘user’, seq, time, content)
  • AssistantMessageNode:已定型或中断冻结的 assistant 消息(blocks、usage、timing、interrupted 标记)
  • SteeringMessageNode:turn 运行中被 admitted 的 inbox 消息
  • ContextMessageNode:context/system injection
  • ModelRetryNode:等待重试的失败 step 标记
  • TurnErrorNode:无重试的终态失败

toAssistantBlock 把 core ContentBlock 分类成五种 UI 关心的形状(text / reasoning / image / tool-call / other),不做逻辑判断,只做形状映射。文件头注释明确说:“Publication contract: every change swaps the top-level object; unchanged substructures keep their references (the React.memo premise)”。

这些类型不持有 Session 引用、不持有 Agent 引用。它们是纯数据——可序列化、可快照对比、可 memo。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '1-7p' "$repo/packages/client/runtime/src/client/sessions/conversation.ts"

ProjectionValueStore:higher seq wins,零客户端 folding

Host 端通过 session-projection 子系统为每个 session 计算投影值(title、context pressure、token usage 等),通过 session/projection MuxFrame 推送给客户端。客户端的 ProjectionValueStore 遵循一个极其简单的规则:higher seq wins

文件头注释:

the host is the only computation site; the client holds finished whole values per key — key -> { value, seq } — seeded by the history tail page’s projections block and updated by session/projection push frames, under the single rule higher seq wins. No client-side domain folding exists: a domain ships projection support with zero client code.

这意味着:

  1. 新增一个 projection key 不需要写任何客户端代码——只要 host 端的 domain plugin 注册 unit、计算并推送
  2. 不存在客户端和 host 计算不一致的问题——客户端根本不计算
  3. 断线重连时,history tail page 的 projections block 就是所有投影值的 baseline
  4. UseProjection hook 直接从 store 读 key,reference 稳定性保证 re-render 最小化

UseProjection 的类型签名支持两种 overload:直接读 key 值(返回 T | undefined),或传入 selector + eq 做更细粒度的订阅。undefined 统一表示”capability absent”——host unit 未挂载,或没有 baseline/frame 携带过该 key。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '1-53p' "$repo/packages/client/runtime/src/client/sessions/projection-store.ts"

ToolCallView / ToolResultView:Host 端 presenter 派生的渲染意图

当 Agent 调用一个 tool,Host 端在 emit session event 的同时调用 tool definition 上的 presentCall / presentResult,派生出 ToolCallView / ToolResultView——一个 provider-neutral 的 tagged union 告诉客户端”这个 call 怎么渲染”。

这些渲染意图通过 MuxFrame 的 view 字段跟随 session/event 帧到达客户端。关键约束:从不持久化。session log 只存 event,不存 view。注释:

A pure derivation of args/result through the presenter registered at emission time — never persisted (the session log carries only the event), so the same event may carry a different view (or none) on a later delivery.

五种 call card:generic(默认)、terminal(shell 命令)、diff(文件编辑);六种 result card:genericterminal(带 output/exitCode)、diffsearch(matches/paths 两种 shape)、read(行号高亮代码窗口)、web(search/fetch 两种 kind)。

客户端收到 view 后不做逻辑——只做形状到组件的映射。如果没有 view(历史事件回放时 presenter 可能不在了),fallback 到 generic JSON card。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '26-35p' "$repo/packages/host/apiproxy/src/api/events.ts"

Session 类:事件窗口 + 投影组装器,不是 owner

客户端的 Session 类(packages/client/runtime/src/client/sessions/session.ts)持有什么?

private events: SessionEvent[] = []
private views: (ToolEventView | undefined)[] = []
private baseSeq = 0

它持有一个事件窗口——从 history tail 加载的过去事件 + 实时 mux 推送的新事件。注意不是完整 log(太长的 session 只保留最近的 window),hasMore 标记表示前面还有更多事件可以分页加载。

Session 还持有一个 ConversationNodeAssembler——conversation domain 的事件到节点的增量 fold 引擎。每次有新 event 到达或 pending state 变化,notifier.notify() 触发一次 flush:

this.notifier = new Notifier(() => {
  this.conversation.flush()
  this.snapshotCache = this.buildSnapshot()
})

flush 后 rebuild snapshot,React 组件订阅 snapshot 变化。整个链路是:mux frame -> Session.consumeFrame -> events.push -> notifier.notify -> conversation.flush -> buildSnapshot -> React re-render。

Session 从不修改 Host 端的 event log。它是一个 read-only window + fold 引擎。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '65-80p' "$repo/packages/client/runtime/src/client/sessions/session.ts"

断线重连:重新投影,不靠本地持久化

WebSocket 断了怎么办?不是从 localStorage 恢复。Session 类有明确的重连机制:

  1. openGeneration 递增,invalidate 任何 in-flight 的 doOpen
  2. stitching = true:新到达的 live events 进入 liveBuffer 而非直接消费
  3. 重新 fetch history tail page(包含 events + projections block)
  4. History 到达后,按 seq 与 liveBuffer 缝合,重建完整事件窗口
  5. projections baseline 从 history tail 的 block 恢复
  6. 继续消费 mux 流的新 frame

注释写道:

Bumped by resync to invalidate an in-flight doOpen: a reconnect must rebuild, never adopt a pre-disconnect open whose history request is already doomed. Stale doOpen passes drop all writes once the generation moves on.

这个设计的核心思想:客户端是无状态投影层。任何时刻断线,不需要保存本地状态——重连后从 Host 重新拉取 + 重新 fold 就能恢复到一致的状态。同一组 events fold 出同一组 snapshots,因为 session log 是 append-only 的。

不需要 CRDT、不需要冲突解决、不需要 offline first。Host 的 append-only log 是唯一真相源。

用户操作:全走 RPC,不改本地状态

用户发消息、approve 权限请求、cancel 操作、改 settings——所有这些操作都不先改本地状态。它们通过 api 客户端发 RPC 到 ApiProxy:

  • 发消息:api.session.prompt(sessionId, content) -> Host 的 Agent 收到 inbox message -> Agent 处理 -> 产生 session events -> mux 流推回来
  • Approve:api.session.resolveApproval(sessionId, approvalId, outcome) -> Host 解决 approval -> 产生 event -> 推回来
  • 改 settings:api.settings.update(key, value) -> Host 更新 settings -> session-projection 重算 -> session/projection frame 推回来

唯一的”乐观更新”是 promptAttempted 标记——Session 在 prompt() 的第一个 await 之前同步设置它,用于 composer phase 状态机(让 UI 立即从 idle 切到 engaging),但它不改 conversation snapshot 的内容。真正的消息出现要等 Host 端的 user/message event 通过 mux 回来。

sequenceDiagram
    participant User as 用户
    participant UI as React Component
    participant Session as Client Session
    participant API as ApiProxy RPC
    participant Host as Host Agent
    participant Log as Session Log

    User->>UI: 点发送
    UI->>Session: prompt(content)
    Session->>Session: promptAttempted = true (composer phase)
    Session->>API: session.prompt(id, content)
    API->>Host: inbox.deliver(message)
    Host->>Log: append user/message event
    Log->>Host: committed
    Host-->>Session: MuxFrame{session/event, user/message}
    Session->>Session: events.push(event)
    Session->>Session: notifier.notify()
    Session->>UI: snapshot changed
    UI->>User: 消息出现在对话里

ConversationNodeAssembler:可扩展的事件到节点 fold 引擎

ConversationNodeAssembler 不是硬编码的”把 events 映射到 nodes”。它是一个可扩展的 registration-driven 引擎:

  • event registry:注册”哪些事件类型/模式由我处理”
  • definition registry:注册”一个匹配产生什么 ConversationNodeDefinition”
  • view registry:注册”一组 nodes 怎么组装成 view snapshot”

每个 domain(cordis tool cards、approval cards、retry markers、error nodes)注册自己的 entries。Assembler 负责:

  1. 新 event 到达时查 event registry 看谁 match
  2. Match 到的 definition 管理自己的 state + revision
  3. 调度 flush 时各 definition 产出最新 nodes
  4. View builders 组装 timeline snapshot

ConversationPublication 控制渲染节拍:none(不触发重渲染)、animation-frame(下一帧渲染)、immediate(立即 flush)。streaming partial 用 animation-frame 避免每个 chunk 都触发 React commit。

这个设计意味着:扩展 UI 显示新类型的 card 只需要在 domain plugin 侧注册 node definition + view builder,不需要修改 Assembler 的核心 fold 逻辑。

Host 端 session-projection:eager drive 的纯函数 unit

回到 Host 端看 ProjectionValueStore 的数据从哪来。ctx.sessionProjectionsSessionProjectionRegistry)的核心循环是 eager drive

ctx.on('session/event', (session, event) => this.drive(session, event))

每个 committed session event 同步经过所有 registered units 的 apply(state, event)。如果返回的 state reference 变了(!Object.is(next, cell.state)),通过 change feed 通知 carrier(ApiProxy 的 mux 流)推送新值。

Domain plugins 注册的是 ProjectionDefinition——三个纯同步函数(initapplyview)+ 声明(key、schema、stateVersion)。它们不持有订阅、不做异步操作。框架拥有订阅和缓存。

这就是为什么客户端可以做到 higher-seq-wins 零 folding:真正的 domain logic 全在 host 端的 pure unit 里,客户端只是一个接收 finished values 的 display pipe。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '404,426p' "$repo/packages/session/session-projection/src/index.ts"

SessionManager:frame 分发入口

SessionManager 是 Client Runtime 的 session 实例集群(Map<SessionId, Session>)+ MuxFrame 分发入口。收到一个 MuxFrame 后,manager 根据 sessionId 路由到对应 Session 实例的 consumeFrame 方法。

几个值得注意的设计:

  • Session 是 lazy-built, resident:第一次被路由到时创建,之后常驻内存继续消费 frame,即使用户切走了这个 session 的 tab
  • List data(session 列表)不进 zustand——React 通过 subscribe/getListSnapshot 连接
  • HostFrame(session added/removed/status)走独立的 host 流,由 manager 直接处理更新 list state
  • Subagent catalog 和 jobs 信息也归 manager 管理

Manager 还处理 session/projection frame 的路由:frame 到达后按 sessionId 找到对应 Session 的 projections store,调用 store 的 update 方法(higher-seq-wins)。

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '1-24p' "$repo/packages/client/runtime/src/client/sessions/manager.ts"

你在 UI 看到的一切都不是事实

把整章的核心原则画成图:

flowchart TD
    subgraph Host["Host 进程 (事实源)"]
        Log["Session Log\n(append-only JSONL)"]
        Proj["SessionProjectionRegistry\n(eager drive)"]
        Mux["events.mux()\nMuxFrame 流"]
    end

    subgraph Client["浏览器 Client Runtime (投影消费者)"]
        WS["WebSocket downlink"]
        Mgr["SessionManager\n(frame 分发)"]

        Mgr --> Conv["ConversationNodeAssembler\n(append-origin events → nodes)"]
        Mgr --> PVS["ProjectionValueStore\n(higher-seq-wins)"]
        Mgr --> Queue["SessionQueueMirror\n(session/queue snapshot)"]
        Mgr --> Pending["PendingInteraction\n(approval/question)"]

        Conv --> ChatUI["ChatView 组件"]
        PVS --> TitleUI["Session Title / Model Badge"]
        Queue --> QueueUI["QueueDock"]
        Pending --> ApprovalUI["RiskConfirmation"]
    end

    User["用户操作"] -->|"RPC (HTTP)"| ApiProxy["ApiProxy"]
    ApiProxy --> Host
    Log --> Proj
    Proj --> Mux
    Mux -->|"MuxFrame stream"| WS
    WS --> Mgr

    style Host fill:#1e3a8a,stroke:#3b82f6,color:#fff
    style Client fill:#831843,stroke:#ec4899,color:#fff

所有箭头都是单向的。Host -> Client 走 MuxFrame stream。Client -> Host 走 RPC。Client 从不直接写 session log。

当你在 UI 上看到一条消息出现,这意味着:

  1. Host 已经把 user/message event append 到 session log 了
  2. session log commit 触发了 mux 流推送 session/event frame
  3. 客户端的 Session.consumeFrame 把 event push 到 window
  4. notifier flush 触发 conversation assembler fold 出新 node
  5. snapshot 变化触发 React re-render

你看到消息出现 确实意味着 这条消息已经持久化了——因为 mux frame 来自已 committed 的 event。但反过来不成立:你没看到更新不意味着操作没成功(可能只是 WebSocket 延迟)。

UI 是投影。投影可以重建。重建的前提是事实源不变。事实源是 Host 的 append-only session log。全部。

常见误解

你以为的实际的
UI 存着对话状态UI 只存事件窗口的 fold 产物(snapshot)
发消息 = 本地先加,服务器确认发消息 = RPC 到 Host,等 event 回来才出现
断线后从 localStorage 恢复断线后重新 fetch history + 重建 projection
projection value 在客户端计算host 算好推过来,客户端零 folding
surface 是 transcript 来源append-origin events 才是 transcript 来源
tool card 渲染逻辑在客户端Host 端 presenter 派生 ToolCallView,客户端只做形状映射
改 settings 重建 sessionsettings 走 projection 链热更新,不重建任何东西

所以这层关系要钉牢:浏览器是投影幕布,不是放映机。放映机在 Host 进程里。