The first few articles established three boundaries: Server-side Session and Run don't belong to network connections; Client Runtime is responsible for Event Window, recovery, and Projection; React or other UI layers only consume already-formed View State and compose Feature Renderers through Slots.
This article connects them with a minimal implementation.
The Demo doesn't implement a real LLM, nor does it introduce React, Redis, or a database. The goal is to verify several structural judgments:
1. Network interruption doesn't terminate Server Run
2. Events first enter Canonical Log, then publish to connections
3. Client can recover missing tail by seq
4. Replay and Live Append use the same Projection Engine
5. Tool UI can register through Slot without modifying Chat main renderer
If these five conditions hold, replacing with real models, database, React, or WebSocket won't change core ownership relationships.
Demo Structure
The directory is deliberately split into four layers:
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
Dependencies flow in one direction:
Server Event Log
│
▼
Transport
│
▼
Client Runtime
│
▼
Projection Snapshot
│
▼
Slot Renderer
│
▼
DOM
The Presentation layer doesn't know how Events are recovered, and the Server doesn't know how Tool Cards are displayed.
Server Owns Session First, Then Connection
The Server maintains for each Session:
{
id,
events: [],
subscribers: Set<Response>,
run: null | ActiveRun,
}
Where events[] is the Canonical State for this Demo. A real system could replace this with a database Event Log.
The sequence for appending Events is:
session.events.push(event)
for (const res of session.subscribers) {
res.write(frame)
}
Namely:
append durable/canonical fact
↓
publish transport frame
This ordering is critical.
If data is written to SSE first, then persisted asynchronously:
Client sees seq=12
↓
Server crash
↓
12 not persisted
↓
After reconnect, Server can only recover to 11
Client has already observed a fact that Server cannot rebuild.
Production implementations typically need database transactions, Outbox, or durable streams to establish stronger guarantees. The Demo uses an in-memory Event Log, only verifying the structure that "fact commit precedes publish."
Run Is Completely Separated from SSE
POST /prompt only accepts new work:
POST /prompt
↓
create ActiveRun
↓
202 Accepted
Actual execution continues via independent runAgent().
It generates in sequence:
run.started
message.user
message.assistant.started
message.assistant.delta
tool.call
tool.result
message.assistant.delta
message.assistant.completed
run.completed
Whether SSE subscribers exist does not affect Run state judgment.
Therefore:
SSE Connection #1 ─────X
Run ───────────────────────────>
SSE Connection #2 ─────>
Connection disconnect does not call run.cancel().
Only explicit:
POST /cancel
Changes the ActiveRun's cancellation state.
This verifies the core boundary from the first article of Unit Four: Connection Abort and Execution Cancel are two different control channels.
SSE Uses seq as Recovery Coordinate
Event structure:
{
sessionId,
seq,
time,
type,
data,
}
seq increments consecutively within a Session.
SSE Endpoint accepts:
GET /api/sessions/:id/events?after=N
And first sends:
all events where seq > N
Then keeps the connection open to receive live events.
SSE frame also writes:
id: <seq>
event: session-event
data: {...}
Therefore seq can also serve as:
event identity
ordering coordinate
replay checkpoint
dedup key
gap detection coordinate
A real system doesn't necessarily need one field to bear all responsibilities, but there must be a coordinate that can prove continuity.
No Gap Between Replay and Live Attach
A seemingly reasonable but problematic implementation:
1. query history > N
2. history response returns
3. subscribe live stream
If Event is produced between steps 2 and 3:
history ends at 20
21 produced here
live subscribe starts at 22
21 is permanently lost.
The Demo's SSE Endpoint executes within the same Node Event Loop turn:
read current tail
→ write backlog
→ register subscriber
No await in between, so this small code segment establishes the atomic boundary for replay/attach that this Demo requires.
Production systems spanning databases, message systems, and multi-instance deployments need more formal checkpoint or durable subscription mechanisms.
Test: Deliberately Create a Disconnect
The test code first establishes SSE, then submits a Prompt.
After receiving the first four Events, it deliberately Aborts:
seq 0 run.started
seq 1 message.user
seq 2 assistant.started
seq 3 assistant.delta
↓
disconnect
At this point, save:
checkpoint = 3
The test waits for Server Run to continue executing without client connection.
Then reconnect:
GET /events?after=3
Actual run result:
checkpoint: 3
replayed tail: 4,5,6,7,8
final seq: 8
projection equality: ok
tool slot dispatch: ok
run status: completed
Therefore the recovery path is:
Client observed 0..3
↓
disconnect
↓
Server produced 4..8
↓
reconnect(after=3)
↓
receive 4..8
After merging the two segments, recheck:
all.map(event => event.seq)
Must equal:
[0, 1, 2, 3, 4, 5, 6, 7, 8]
Projection Doesn't Understand Transport
ProjectionEngine has only one core entry:
projection.apply(event)
It doesn't know whether Event came from:
history query
SSE
WebSocket
local fixture
replay test
The only requirement is seq continuity.
For example, Assistant Streaming:
message.assistant.started
↓
create node(status=streaming)
message.assistant.delta
↓
append text
message.assistant.completed
↓
status=completed
For Tools:
tool.call
↓
ToolNode(status=running)
tool.result
↓
ToolNode(status=completed)
Final Snapshot only contains what UI needs:
{
lastSeq,
run,
nodes,
}
This is the protocol between Client Runtime and Presentation.
An Important Test: Full Replay and Segmented Fold Must Be Identical
The test creates two Projections.
The first reads all Events at once:
const oneShot = replay(all)
The second simulates real disconnect process:
for (const event of first) incremental.apply(event)
for (const event of second) incremental.apply(event)
Finally asserts:
assert.deepEqual(
incremental.snapshot(),
oneShot,
)
This test is more important than "the final page can display."
It verifies:
Projection(history + tail)
=
Projection(history) continued with tail
If Plan, Approval, or SubAgent Nodes are added in the future, this property should be maintained.
Slot Registry Doesn't Participate in Projection
Projection Engine ultimately produces:
message.user
message.assistant
tool-call
It doesn't reference specific UI Components.
SlotRegistry declares separately:
slots.declare('chat.node', { kind: 'keyed' })
slots.declare('tool.view', { kind: 'keyed' })
Chat Feature registers:
message.user
→ UserMessage renderer
message.assistant
→ AssistantMessage renderer
tool-call
→ ToolCall renderer
When ToolCall Renderer encounters a Tool, it continues dispatching:
toolName = read_file
↓
tool.view
↓
read_file plugin renderer
This maintains the same structural relationship as DSH's current:
conversation.chat.node
↓
ToolCallTree
↓
tool.call.toolview
Only removing Cordis, React Scope, and Store productivity capabilities.
New Tool UI Doesn't Modify Chat Renderer
read_file Renderer as independent contribution:
slots.register(
'tool.view',
{ key: 'read_file' },
renderReadFile,
)
If there's no match, ToolCall uses generic fallback.
The test ultimately confirms:
tool slot dispatch: ok
Therefore Feature extension path is:
New Tool
↓
register new tool.view entry
Instead of:
modify ChatView
modify ToolCall switch
modify global component map
Browser Client Runtime Only Exposes Observable Snapshot
Browser's ClientSession holds:
EventSource
ProjectionEngine
Connection State
Listeners
UI only calls:
runtime.subscribe(render)
runtime.getSnapshot()
This aligns with DSH Object Layer → React Binding concept.
Current Demo uses plain DOM:
Runtime Snapshot
↓
renderSnapshot()
↓
innerHTML
When replacing with React, only need to add at the outermost layer:
useSyncExternalStore(
runtime.subscribe,
runtime.getSnapshot,
)
Projection, Reconnect, and Slot Registry don't need to move into Components.
Why Demo Doesn't Use React Directly
This Demo's goal is to verify architectural dependencies, not to showcase React API.
If React were used directly, space would easily be consumed by:
Vite
JSX
package setup
hook code
CSS
Then readers could only confirm "the page runs," but would have difficulty judging whether Client Runtime is truly independent.
Here, deliberately using DOM Binding反而可以验证:
As long as Projection output and Slot Composition don't depend on React, they truly belong to Runtime and UI Composition layers.
Article Fifteen already explained how production-level React Binding is implemented through DSH's current web-react source code.
What Production Capabilities This Demo Still Lacks
It only verifies the core model and shouldn't be used as production implementation directly.
At minimum, it lacks:
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
Especially the current in-memory events[] is not Durable Storage. Process Crash will lose all state.
If continuing evolution, priority order should be:
1. Event Log persistence
2. Run state persistence
3. durable stream / pub-sub
4. Snapshot + Tail Recovery
5. React Binding
6. Plugin Scope / Store lifecycle
Not adding more Components first.
Model Formed by Unit Four
After four articles, Web Agent can converge to this structure:
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 │
└──────────────────────────────────┘
In this structure, each layer can be replaced:
SSE ↔ WebSocket
Memory Log ↔ PostgreSQL / SQLite
DOM ↔ React
Simple Slot Registry ↔ Cordis + DSH Slots
Fake Agent ↔ Real Agent Loop
After replacement, core ownership relationships don't change.
Design Decisions
Unit Four ultimately leaves three decisions.
First, Web Agent's execution lifecycle must be independent of network connections. Network connection is a transport resource; Run is business execution.
Second, Client Runtime should own Replay, Gap Repair, and Projection. React only consumes Snapshot, avoiding distributing recovery algorithms throughout the component tree.
Third, plugin-based UI should be built after stable View Model. Feature Plugin registers renderer, doesn't directly interpret raw Events, nor implicitly read all Runtime Service through global Context.
Thus far, the first four units have covered four fundamental aspects of a modern Agent Harness:
Execution
State & Persistence
Composition
Web Runtime & UI
The next unit can begin converging these models, comparing boundary choices made by Vercel AI SDK, Pi, and DeepSeek Harness, and据此设计一套自己的 Agent Runtime / Harness API。
Demo Files
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
Run:
cd demo/web-runtime
npm test
npm start
References
[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