Agent 的 Server 端很适合使用 Event 表达执行过程:Turn 开始、模型输出、Tool Call、Tool Result、Approval、Compaction、Run 状态变化,都可以形成按序到达的事实。
浏览器最终需要的内容却不是 Event 列表。
一个聊天界面需要的是:
Message Row
Tool Card
Approval Panel
Plan Block
SubAgent Node
Running Indicator
Composer State
因此 Web Runtime 中间必然存在一次状态转换:
Event
↓
Projection
↓
View State
↓
UI
这个 Projection 放在哪里,会直接决定系统能否正确处理 Replay、Streaming、Reconnect 和插件扩展。
直接在 React 中处理 Event 的问题
最容易实现的结构是让组件订阅 Event Stream:
useEffect(() => {
connection.onEvent(event => {
setMessages(messages => reduce(messages, event))
})
}, [])
短期看,这段代码足够直接。但只要增加历史恢复,就会出现第二条路径:
const history = await loadHistory()
setMessages(buildMessages(history))
此时系统已经存在两套状态构造逻辑:
History Replay
→ buildMessages()
Live Event
→ reduce()
如果两条路径的规则没有完全一致,同一 Session 在“首次打开”和“持续在线”时会得到不同 UI。
Tool Call 是典型例子。历史中可能已经同时存在:
tool/call
...
tool/result
实时状态则会经历:
tool/call
→ running card
→ tool/result
→ completed card
如果历史构造函数直接读取最终结果,而实时 reducer 维护中间状态,两套代码很容易逐渐产生不同规则。
因此更稳定的原则是:
Replay 与 Live Append 应该进入同一个 Projection Engine。
Projection 是可重复执行的状态折叠
最小 Projection 可以写成:
function reduce(state, event) {
switch (event.type) {
case 'user/message':
return appendUserMessage(state, event)
case 'assistant/message':
return appendAssistantMessage(state, event)
case 'tool/call':
return openToolCall(state, event)
case 'tool/result':
return closeToolCall(state, event)
}
}
历史恢复:
initialState
↓ event 0
state 1
↓ event 1
state 2
↓ event 2
...
↓ event N
View State
实时执行只是在这个状态之后继续:
View State at N
↓ event N+1
View State at N+1
这样系统获得一个很重要的不变量:
fold(history + live)
=
fold(history) continued with live tail
这个等价关系让 Replay、Reconnect 和在线 Streaming 共享同一套语义。
Message 并不是唯一 Projection
Agent UI 容易把所有内容都压成 messages[]。这会重新引入第二单元讨论过的问题:Agent 执行包含大量不适合表达成 Message 的状态。
例如:
turn/start
step/start
assistant/chunk
tool/call
tool/result
approval/requested
turn/end
从这些 Event 可以同时派生多个 Projection:
SessionEvent[]
│
├── Model History Projection
├── Chat Projection
├── Tool State Projection
├── Run/Turn Status Projection
└── Analytics Projection
Server 与 Client 可以拥有不同 Projection,但它们应当基于明确的事实源和一致的序列边界。
DeepSeek Harness 在 Server Session 中已经体现了这一点:SessionEvent Log 是事实源,deriveMessages() 只投影模型可见历史。Browser 端又建立 Conversation Projection,把 Event 转成适合 UI 的 Node。[1][2]
为什么 Client Runtime 应该存在
React 组件适合管理组件生命周期和局部交互状态,不适合承担网络重连、Event Window、Gap Repair 和业务 Projection 的所有权。
这些状态具有几个特征:
- 页面中多个组件需要共享。
- 状态更新来源不仅是 React 事件,还包括网络与历史读取。
- UI 可能卸载,但 Session 仍需要继续接收 Event。
- 状态必须支持完整 Replay。
- 更新频率可能远高于需要重新渲染的频率。
因此 DSH 当前把浏览器侧数据对象层定义为 React-free:
ConnectionController
↓
SessionManager
↓
Session
↓
Conversation Assembler
而 React 只通过 subscribe() / getSnapshot() 观察这一层。[3]
这与传统前端“把状态放进 Store”也存在区别。DSH 的约束文档明确要求 Session、Frame、Connection 等业务状态保留在对象层;Slot Store 只承载 selection、draft、panel width 等共享 UI 状态。[3]
这个边界值得保留:
Business Runtime State
→ Client Runtime object
Shared UI Interaction State
→ UI Store
Component-local State
→ React state
三种状态来源不同,不需要统一放进一个全局 Store。
Session 需要维护一个连续 Event Window
当前 DSH Client Session 内部维护:
private events: SessionEvent[] = []
private baseSeq = 0
private liveBuffer = []
private openGeneration = 0
private stitching = false
private subscribedLastSeq: number | null = null
它的目标不是保存 Server 的完整 Session,而是维护浏览器当前已经加载的一个连续窗口。[4]
第一次打开时:
Session.open()
↓
history(maxMessages = 50)
↓
installWindow()
↓
Conversation.replaceWindow()
如果历史读取期间实时 Event 已经到达,这些 Event 先进入 liveBuffer。历史窗口安装完成以后,再按照 seq 追加。[4]
这里的关键不是 Buffer 本身,而是连续性要求。
假设历史尾部是:
seq = 120
随后 Client 收到:
seq = 123
直接 append 会永久丢失 121、122。DSH 当前不会接受这种有洞的窗口,而是把 123 放进 Buffer 并触发 tail page repull:
120
↓
123 arrives
↓
gap detected
↓
123 → liveBuffer
↓
reload tail history
↓
121, 122, 123...
↓
installWindow + dedup
seq 因此同时承担排序、去重和 Gap Detection 三个职责。[4]
Reconnect 本质上是重新建立 Baseline
网络重连后,客户端不能假定断线期间没有发生变化。
DSH 的 SessionManager.handleConnected() 会执行:
refresh session list
refresh relevant subagent catalogs
resync every resident Session
Session.resync() 会提高 generation,清理旧窗口并重新 open()。[5][6]
openGeneration 解决一个典型并发问题:
Generation 1
history request -------------------->
connection lost
↓
Generation 2 starts
new history request ------>
old request returns ---------------->
如果旧请求返回后仍能写状态,就会用旧连接得到的结果覆盖新 generation。
因此 doOpen(generation) 在每个 await 后检查:
if (generation !== this.openGeneration) return
这种 generation token 是 Client Runtime 中常见且有效的竞态隔离手段。[4]
Projection 也需要增量路径
如果每收到一个 Event 都重新扫描整个 Session Window:
new event
↓
scan event 0..N
↓
rebuild every node
长会话会产生明显成本。
DSH 当前 ConversationNodeAssembler 同时支持三条路径:
replaceWindow()
用于 open / resync / gap repair
prepend()
用于加载更早历史
append()
用于实时尾部 Event
append() 不重新扫描已有 Context,而是只处理当前 Event,更新对应的 Context 与 View Builder。[7]
因此可以把 Client Projection 的计算模型写成:
低频路径:完整重建
replaceWindow(history)
高频路径:增量折叠
append(event)
两者必须得到等价的业务结果,但性能策略可以不同。
ConversationNodeDefinition 把业务 Fold 从 Runtime 中移走
如果 Runtime 内部写一个巨大的 switch:
switch (event.type) {
case 'tool/call': ...
case 'approval/requested': ...
case 'plan/...': ...
case 'subagent/...': ...
}
每增加一个 UI Feature 都要修改核心 Projection Engine。
DSH 当前使用 ConversationNodeDefinition<State> 把这部分业务规则注册化。[8]
一个 Definition 主要提供:
match(event)
start(context, match, reader)
update(context, match)
buildLocationData?(context, scope)
buildViewNode?(context)
它表达的是一个独立的 Event → State → ViewNode 状态机。
例如 Tool Call 的概念模型可以是:
tool/call(callId = A)
↓ match
Context(kind=tool, id=A)
↓ start
ToolState(running)
↓
tool/result(callId = A)
↓ match/update
ToolState(completed)
↓ buildViewNode
ToolCallNode
Runtime 负责:
Event 顺序
Context identity
Turn / Step Location
调用 Definition
缓存 State
发布 View Snapshot
Feature Plugin 负责:
哪些 Event 属于自己
如何更新业务 State
最终产生什么 View Node
这使 Projection 本身也具备插件边界。
为什么先生成 View Node,再进入 React
ConversationViewNode 是 React 之前的最后一层业务表示:
interface ConversationViewNode {
key: string
kind: string
id: string
target: string
data: unknown
}
Chat 目标会进一步携带 Location、Anchor Sequence 和 Visibility。[8]
这层很重要,因为它让业务语义和 React Component 分开:
SessionEvent
↓
Business State
↓
ConversationViewNode
↓
UI Renderer
↓
React Component
于是同一个 Projection Engine 可以在没有 React 的情况下测试。
业务 Feature 也可以先判断自己是否正确地产生:
ToolCallNode {
id,
status,
call,
result,
subCalls
}
然后单独测试这个 Node 如何显示。
Streaming Fold 与 React Publication 不必同频
LLM Streaming 会产生大量 Chunk。如果每个 Chunk 都强制整棵 React Tree 同步 render,Projection 层虽然正确,UI 性能仍然会受到影响。
DSH 当前在 ConversationNodeDefinition 中增加 publication(),其结果可以是:
none
animation-frame
immediate
Assembler 会取当前 transaction 所要求的最高 publication cadence。[7][8]
这形成两个不同频率:
Event Fold Frequency
可能每个 chunk 都执行
React Publication Frequency
可以按 animation frame 合并
Client Runtime 因此承担了一个经常被忽略的职责:业务状态必须及时计算,但 UI 通知可以批处理。
如果这两件事直接绑在 React setState() 上,很难独立控制。
Observable 是 Runtime 与 React 之间的窄接口
当 Client Runtime 已经拥有完整状态后,React 不需要知道 Event Stream、History API 或 Reconnect 机制。
只需要:
interface Observable<T> {
getSnapshot(): T
subscribe(listener: () => void): () => void
}
然后由 React Binding 层调用 useSyncExternalStore()。[3]
因此依赖关系保持单向:
Connection / Session / Projection
│
│ Observable Snapshot
▼
React Binding
│
▼
Component
业务组件不直接监听 Socket,也不直接运行 Projection。
一个更完整的数据流
把这一篇的几层连接起来,可以得到:
Server SessionEvent Log
│
├── history pull
│
└── live event stream
│
▼
Client Session
┌──────────────┐
│ Event Window │
│ liveBuffer │
│ gap repair │
│ generation │
└──────┬───────┘
│
▼
ConversationNodeAssembler
│
├── Definition.match
├── start / update
├── buildLocationData
└── buildViewNode
│
▼
View Snapshot
│
subscribe/getSnapshot
│
▼
React Binding
│
▼
UI
这条链路同时适用于:
首次打开
历史分页
实时 Streaming
网络重连
Gap Repair
插件重新注册后的 Projection rebuild
一个设计判断
Agent UI 的稳定状态模型应当满足两个条件。
第一,Replay 与 Live 使用同一套 Projection 规则。否则恢复后的 UI 与在线运行时状态迟早产生偏差。
第二,Projection 属于 Client Runtime,而非组件树。React 应该观察 Projection 结果,不负责维护事实源和恢复算法。
因此,Agent Web UI 更合适的关系是:
Event 是事实变化
Projection 是业务解释
View State 是 UI 输入
React 是渲染器
下一篇继续处理最后一个问题:已经得到 ConversationViewNode 后,谁决定它由哪个组件渲染,以及 Tool、Plan、SubAgent、Workflow 等 Feature 如何在不修改中心 ChatView 的前提下加入 UI。
参考资料
[1] DeepSeek Harness Session Architecture: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/session.md
[2] DeepSeek Harness Session Surface: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/core/session/src/surface.ts
[3] DeepSeek Harness Web Client Rules: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/AGENTS.md
[4] DeepSeek Harness Client Session: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/sessions/session.ts
[5] DeepSeek Harness ConnectionController: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/connection/src/client/connection.ts
[6] DeepSeek Harness SessionManager: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/sessions/manager.ts
[7] DeepSeek Harness ConversationNodeAssembler: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/sessions/conversation-assembler.ts
[8] DeepSeek Harness Conversation contracts: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/contract/conversation.ts