前三篇分别建立了三个边界:Server 端的 Session 与 Run 不属于网络连接;Client Runtime 负责 Event Window、恢复和 Projection;React 或其他 UI 层只消费已经形成的 View State,并通过 Slot 组合 Feature Renderer。
这一篇用一个最小实现把它们连接起来。
Demo 不实现真实 LLM,也不引入 React、Redis 或数据库。目标是验证几个结构性判断:
1. 网络连接中断不终止 Server Run
2. Event 先进入 Canonical Log,再向连接发布
3. Client 可以按 seq 恢复缺失尾部
4. Replay 与 Live Append 使用同一个 Projection Engine
5. Tool UI 可以通过 Slot 注册,不修改 Chat 主 renderer
如果这五个条件成立,后续替换成真实模型、数据库、React 或 WebSocket,不需要改变核心所有权关系。
Demo 的结构
目录被刻意拆成四层:
Server
demo/web-runtime/src/server.mjs
Client Runtime
demo/web-runtime/public/client-runtime.js
demo/web-runtime/public/projection.js
Plugin UI
demo/web-runtime/public/slots.js
Presentation
demo/web-runtime/public/app.js
demo/web-runtime/public/index.html
依赖方向保持单向:
Server Event Log
│
▼
Transport
│
▼
Client Runtime
│
▼
Projection Snapshot
│
▼
Slot Renderer
│
▼
DOM
Presentation 层不知道 Event 如何恢复,Server 也不知道 Tool Card 如何显示。
Server 先拥有 Session,再拥有连接
Server 为每个 Session 保存:
{
id,
events: [],
subscribers: Set<Response>,
run: null | ActiveRun,
}
其中 events[] 是这个 Demo 的 Canonical State。真实系统可以将其替换成数据库 Event Log。
追加 Event 的顺序是:
session.events.push(event)
for (const res of session.subscribers) {
res.write(frame)
}
即:
append durable/canonical fact
↓
publish transport frame
这条顺序非常重要。
如果先向 SSE 写数据,再异步持久化:
Client 看到了 seq=12
↓
Server crash
↓
12 没有持久化
↓
Reconnect 后 Server 只能恢复到 11
Client 已经观察到一个 Server 无法重建的事实。
生产实现通常需要数据库事务、Outbox 或 durable stream 来建立更强保证。Demo 使用内存 Event Log,只验证“事实提交先于发布”的结构。
Run 与 SSE 完全分离
POST /prompt 只负责接受新的工作:
POST /prompt
↓
create ActiveRun
↓
202 Accepted
真正执行通过独立的 runAgent() 继续推进。
它依次生成:
run.started
message.user
message.assistant.started
message.assistant.delta
tool.call
tool.result
message.assistant.delta
message.assistant.completed
run.completed
SSE subscriber 是否存在,不参与 Run 状态判断。
因此:
SSE Connection #1 ─────X
Run ───────────────────────────>
SSE Connection #2 ─────>
连接断开不会调用 run.cancel()。
只有显式:
POST /cancel
才改变 ActiveRun 的 cancellation state。
这验证了第四单元第一篇的核心边界:Connection Abort 与 Execution Cancel 是两条不同控制通道。
SSE 使用 seq 作为恢复坐标
Event 的结构为:
{
sessionId,
seq,
time,
type,
data,
}
seq 在一个 Session 内连续递增。
SSE Endpoint 接受:
GET /api/sessions/:id/events?after=N
并先发送:
all events where seq > N
然后保持连接接收 live event。
SSE frame 同时写入:
id: <seq>
event: session-event
data: {...}
因此 seq 同时可以作为:
Event identity
Ordering coordinate
Replay checkpoint
Dedup key
Gap detection coordinate
真实系统不一定使用一个字段承担所有职责,但必须存在一个可以证明连续性的坐标。
Replay 与 Live Attach 之间不能出现 Gap
一个看似合理但有问题的实现是:
1. query history > N
2. history response returns
3. subscribe live stream
如果 Event 在第 2、3 步之间产生:
history ends at 20
21 produced here
live subscribe starts at 22
21 永久丢失。
Demo 的 SSE Endpoint 在同一个 Node Event Loop turn 内执行:
read current tail
→ write backlog
→ register subscriber
中间没有 await,因此这一小段代码建立了本 Demo 所需的 replay/attach 原子边界。
生产系统如果跨数据库、消息系统和多实例部署,则需要更正式的 checkpoint 或 durable subscription 机制。
测试主动制造一次断线
测试代码首先建立 SSE,然后提交 Prompt。
收到前四个 Event 后主动 Abort:
seq 0 run.started
seq 1 message.user
seq 2 assistant.started
seq 3 assistant.delta
↓
disconnect
此时保存:
checkpoint = 3
测试等待 Server Run 在没有客户端连接的情况下继续执行。
随后重新连接:
GET /events?after=3
实际运行结果为:
checkpoint: 3
replayed tail: 4,5,6,7,8
final seq: 8
projection equality: ok
tool slot dispatch: ok
run status: completed
因此恢复链路是:
Client observed 0..3
↓
disconnect
↓
Server produced 4..8
↓
reconnect(after=3)
↓
receive 4..8
两段合并后重新检查:
all.map(event => event.seq)
必须等于:
[0, 1, 2, 3, 4, 5, 6, 7, 8]
Projection 不理解 Transport
ProjectionEngine 只有一个核心入口:
projection.apply(event)
它不知道 Event 来自:
history query
SSE
WebSocket
local fixture
replay test
唯一要求是 seq 连续。
例如 Assistant Streaming:
message.assistant.started
↓
create node(status=streaming)
message.assistant.delta
↓
append text
message.assistant.completed
↓
status=completed
Tool:
tool.call
↓
ToolNode(status=running)
tool.result
↓
ToolNode(status=completed)
最终 Snapshot 只包含 UI 需要的:
{
lastSeq,
run,
nodes,
}
这就是 Client Runtime 和 Presentation 之间的协议。
一个重要测试:完整 Replay 与分段 Fold 必须相同
测试创建两种 Projection。
第一种一次读取全部 Event:
const oneShot = replay(all)
第二种模拟真实断线过程:
for (const event of first) incremental.apply(event)
for (const event of second) incremental.apply(event)
最后断言:
assert.deepEqual(
incremental.snapshot(),
oneShot,
)
这个测试比“最终页面能显示”更重要。
它验证:
Projection(history + tail)
=
Projection(history) continued with tail
如果未来增加 Plan、Approval、SubAgent Node,也应该继续保持这个性质。
Slot Registry 不参与 Projection
Projection Engine 最终产生:
message.user
message.assistant
tool-call
它不引用具体 UI Component。
SlotRegistry 单独声明:
slots.declare('chat.node', { kind: 'keyed' })
slots.declare('tool.view', { kind: 'keyed' })
Chat Feature 注册:
message.user
→ UserMessage renderer
message.assistant
→ AssistantMessage renderer
tool-call
→ ToolCall renderer
ToolCall Renderer 遇到 Tool 后继续分发:
toolName = read_file
↓
tool.view
↓
read_file plugin renderer
这和 DSH 当前的:
conversation.chat.node
↓
ToolCallTree
↓
tool.call.toolview
保持相同的结构关系,只去掉 Cordis、React Scope 和 Store 等生产能力。
新增 Tool UI 不修改 Chat Renderer
read_file Renderer 作为独立 contribution:
slots.register(
'tool.view',
{ key: 'read_file' },
renderReadFile,
)
如果没有匹配项,ToolCall 使用 generic fallback。
测试最终确认:
tool slot dispatch: ok
因此 Feature 扩展路径为:
New Tool
↓
register new tool.view entry
而非:
modify ChatView
modify ToolCall switch
modify global component map
Browser Client Runtime 只暴露 Observable Snapshot
浏览器的 ClientSession 持有:
EventSource
ProjectionEngine
Connection State
Listeners
UI 只调用:
runtime.subscribe(render)
runtime.getSnapshot()
这与 DSH Object Layer → React Binding 的思想一致。
当前 Demo 使用普通 DOM:
Runtime Snapshot
↓
renderSnapshot()
↓
innerHTML
替换成 React 后,只需要在最外层增加类似:
useSyncExternalStore(
runtime.subscribe,
runtime.getSnapshot,
)
Projection、Reconnect 和 Slot Registry 都不需要移进 Component。
为什么 Demo 没有直接使用 React
这个 Demo 的目标是验证架构依赖,而不是展示 React API。
如果直接使用 React,很容易把篇幅消耗在:
Vite
JSX
package setup
hook code
CSS
然后读者只能确认“页面跑起来了”,却很难判断 Client Runtime 是否真正独立。
这里刻意使用 DOM Binding,反而可以验证:
只要 Projection 输出和 Slot Composition 不依赖 React,它们才真正属于 Runtime 与 UI Composition 层。
第十五篇已经通过 DSH 当前 web-react 源码说明了生产级 React Binding 如何实现。
这个 Demo 还缺少哪些生产能力
它只验证核心模型,不应直接作为生产实现。
至少还缺少:
Durable database event log
Authentication / authorization
multi-process pub/sub
backpressure
stream retention
snapshot/checkpoint
idempotent prompt admission
Run persistence
approval/wait state
compaction
multi-tab coordination
observability
protocol versioning
尤其是当前内存 events[] 不是 Durable Storage。Process Crash 会丢失全部状态。
如果继续演进,优先顺序应当是:
1. Event Log 持久化
2. Run 状态持久化
3. durable stream / pub-sub
4. Snapshot + Tail Recovery
5. React Binding
6. Plugin Scope / Store lifecycle
而不是先增加更多 Component。
单元四形成的模型
经过四篇,Web Agent 可以收敛成以下结构:
SERVER
┌──────────────────────────────────┐
│ Session │
│ durable facts │
│ │
│ Run │
│ active execution │
│ │
│ Event Stream │
│ replay + live transport │
└───────────────┬──────────────────┘
│
│ seq / checkpoint
▼
CLIENT
┌──────────────────────────────────┐
│ ConnectionController │
│ ↓ │
│ Session Runtime │
│ ↓ │
│ Event Window │
│ ↓ │
│ Projection Engine │
│ ↓ │
│ View Snapshot │
└───────────────┬──────────────────┘
│ observable
▼
UI
┌──────────────────────────────────┐
│ Slot Owner │
│ ↓ │
│ Slot Registry │
│ ↓ │
│ Feature Renderer │
│ ↓ │
│ React / DOM Component │
└──────────────────────────────────┘
这套结构中,每一层都可以替换:
SSE ↔ WebSocket
Memory Log ↔ PostgreSQL / SQLite
DOM ↔ React
Simple Slot Registry ↔ Cordis + DSH Slots
Fake Agent ↔ Real Agent Loop
替换之后,核心所有权关系不发生变化。
设计判断
第四单元最终留下三个判断。
第一,Web Agent 的执行生命周期必须独立于网络连接。网络连接是 transport resource,Run 是业务执行。
第二,Client Runtime 应该拥有 Replay、Gap Repair 和 Projection。React 只消费 Snapshot,避免把恢复算法分散在组件树中。
第三,插件化 UI 应建立在稳定 View Model 之后。Feature Plugin 注册 renderer,不直接解释原始 Event,也不通过全局 Context 隐式读取所有 Runtime Service。
至此,前四个单元已经覆盖一个现代 Agent Harness 的四个基本面:
Execution
State & Persistence
Composition
Web Runtime & UI
下一单元可以开始收敛这些模型,比较 Vercel AI SDK、Pi 与 DeepSeek Harness 在边界选择上的差异,并据此设计一套自己的 Agent Runtime / Harness API。
Demo 文件
demo/web-runtime/src/server.mjs
demo/web-runtime/src/test.mjs
demo/web-runtime/public/client-runtime.js
demo/web-runtime/public/projection.js
demo/web-runtime/public/slots.js
demo/web-runtime/public/app.js
demo/web-runtime/public/index.html
运行:
cd demo/web-runtime
npm test
npm start
参考资料
[1] DeepSeek Harness Client Runtime: https://github.com/deepseek-ai/deepseek-harness/tree/master/packages/client/runtime/src/client
[2] DeepSeek Harness Web Client Rules: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/AGENTS.md
[3] DeepSeek Harness UI Slots: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-slots/README.md