The previous three articles established three boundaries: on the Server, Sessions and Runs are not tied to network connections; the Client Runtime owns the Event Window, recovery, and Projection; and a React or other UI layer consumes only the already-formed View State and composes Feature Renderers through Slots.
This article connects them with a minimal implementation.
The Demo does not implement a real LLM or introduce React, Redis, or a database. Its goal is to validate several structural assumptions:
If these five conditions are met, replacing them later with a real model, database, React, or WebSocket will not require changing the core ownership relationships.
Demo Structure
The directory is deliberately split into four layers:
Dependencies flow in one direction:
The Presentation layer doesn't know how Events are recovered, and the Server doesn't know how Tool Cards are rendered.
The Server Owns the Session Before the Connection
The Server stores the following for each Session:
Here, events[] is the Demo's Canonical State. A real system could replace it with an Event Log in a database.
Appending an Event follows this order:
That is:
append durable/canonical fact
↓
publish transport frame
This ordering is critical.
If data is written to SSE first and then persisted asynchronously:
Client 看到了 seq=12
↓
Server crash
↓
12 没有持久化
↓
Reconnect 后 Server 只能恢复到 11
The Client has observed a fact that the Server cannot reconstruct.
Production implementations typically need database transactions, an Outbox, or a durable stream to provide stronger guarantees. The Demo uses an in-memory Event Log and validates only the structure of “committing the fact before publishing it.”
Runs Are Fully Decoupled from SSE
POST /prompt is responsible only for accepting new work:
POST /prompt
↓
create ActiveRun
↓
202 Accepted
The actual execution continues independently through runAgent().
It emits the following, in order:
run.started
message.user
message.assistant.started
message.assistant.delta
tool.call
tool.result
message.assistant.delta
message.assistant.completed
run.completed
The presence of an SSE subscriber does not affect Run state decisions.
Therefore:
A dropped connection does not invoke run.cancel().
Only an explicit request changes the ActiveRun's cancellation state:
POST /cancel
This validates the core boundary established in the first article of Unit 4: Connection Abort and Execution Cancel are two separate control channels.
SSE Uses seq as a Recovery Coordinate
The structure of an Event is:
The seq value increments consecutively within a Session.
The SSE endpoint accepts:
GET /api/sessions/:id/events?after=N
and first sends:
all events where seq > N
It then keeps the connection open to receive live events.
Each SSE frame also contains:
id: <seq>
event: session-event
data: {...}
So seq can serve as all of the following:
Event identity
Ordering coordinate
Replay checkpoint
Dedup key
Gap detection coordinate
A production system may not assign all of these responsibilities to a single field, but it must have a coordinate that can prove continuity.
No Gap Between Replay and Live Attach
One implementation that seems reasonable but has a problem:
1. query history > N
2. history response returns
3. subscribe live stream
If an Event is generated between steps 2 and 3:
history ends at 20
21 produced here
live subscribe starts at 22
21 is lost forever.
The Demo's SSE endpoint performs these steps within the same Node Event Loop turn:
read current tail
→ write backlog
→ register subscriber
There is no await between them, so this small block of code establishes the atomic replay/attach boundary the Demo needs.
A production system that spans a database, messaging system, and multiple instances needs a more formal checkpoint or durable subscription mechanism.
The Test Deliberately Causes a Disconnect
The test first establishes the SSE connection, 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 that point, it stores:
checkpoint = 3
The test waits for the Server Run to continue with no client connected.
It then reconnects:
GET /events?after=3
The actual output is:
checkpoint: 3
replayed tail: 4,5,6,7,8
final seq: 8
projection equality: ok
tool slot dispatch: ok
run status: completed
So the recovery path is:
Client observed 0..3
↓
disconnect
↓
Server produced 4..8
↓
reconnect(after=3)
↓
receive 4..8
After merging the two parts, it checks again:
which must equal:
Projection Is Transport-Agnostic
ProjectionEngine has a single core entry point:
It doesn't know where an Event came from:
history query
SSE
WebSocket
local fixture
replay test
The only requirement is that the seq values are contiguous.
For example, Assistant Streaming works like this:
For a Tool:
tool.call
↓
ToolNode(status=running)
tool.result
↓
ToolNode(status=completed)
The final Snapshot contains only what the UI needs:
This is the contract between the Client Runtime and Presentation.
An Important Test: A Full Replay and an Incremental Fold Must Match
The test creates two Projections.
The first reads all Events at once:
The second simulates a real disconnect:
It ends with the assertion:
This test matters more than merely verifying that “the final page renders.”
It verifies:
Projection(history + tail)
=
Projection(history) continued with tail
If Plan, Approval, and SubAgent Node support are added in the future, this property should still hold.
The Slot Registry Is Separate from Projection
The Projection Engine ultimately produces:
message.user
message.assistant
tool-call
It doesn't reference any specific UI Component.
The SlotRegistry declares the following separately:
The Chat Feature registers:
message.user
→ UserMessage renderer
message.assistant
→ AssistantMessage renderer
tool-call
→ ToolCall renderer
When the ToolCall Renderer encounters a Tool, it dispatches further:
toolName = read_file
↓
tool.view
↓
read_file plugin renderer
This mirrors the structural relationship in DSH's current:
conversation.chat.node
↓
ToolCallTree
↓
tool.call.toolview
It preserves the same structural relationships while omitting production capabilities such as Cordis, React Scope, and Store.
Adding Tool UI Doesn't Modify the Chat Renderer
The read_file Renderer is registered as an independent contribution:
If nothing matches, ToolCall uses a generic fallback.
The test ultimately confirms:
tool slot dispatch: ok
So the path for extending a Feature is:
New Tool
↓
register new tool.view entry
rather than:
modify ChatView
modify ToolCall switch
modify global component map
The Browser Client Runtime Exposes Only an Observable Snapshot
The browser's ClientSession holds:
EventSource
ProjectionEngine
Connection State
Listeners
The UI only calls:
This follows the same model as DSH's Object Layer → React Binding.
The current Demo uses plain DOM:
Runtime Snapshot
↓
renderSnapshot()
↓
innerHTML
After switching to React, you only need to add something like this at the outermost layer:
Projection, Reconnect, and the Slot Registry don't need to move into a Component.
Why the Demo Doesn't Use React Directly
The goal of this Demo is to validate the architectural dependencies, not to showcase the React API.
If we used React directly, much of the space could easily be spent on:
Vite
JSX
package setup
hook code
CSS
Then readers could only confirm that “the page runs,” making it hard to determine whether the Client Runtime is genuinely independent.
Using DOM Binding here actually lets us verify:
Only if Projection output and Slot Composition are independent of React do they truly belong to the Runtime and UI Composition layers.
The fifteenth article already used DSH's current web-react source to show how a production-grade React Binding is implemented.
What Production Capabilities the Demo Still Lacks
It validates only the core model and should not be used directly as a production implementation.
At a minimum, it still lacks:
In particular, the current in-memory events[] is not Durable Storage. A process crash loses all state.
If development continues, the priority order should be:
1. Event Log 持久化
2. Run 状态持久化
3. durable stream / pub-sub
4. Snapshot + Tail Recovery
5. React Binding
6. Plugin Scope / Store lifecycle
rather than adding more Components first.
The Model Emerging from Unit Four
After four articles, the Web Agent can converge on the following structure:
Every layer in this structure can be replaced:
SSE ↔ WebSocket
Memory Log ↔ PostgreSQL / SQLite
DOM ↔ React
Simple Slot Registry ↔ Cordis + DSH Slots
Fake Agent ↔ Real Agent Loop
These substitutions do not change the core ownership relationships.
Design Conclusions
Unit Four ultimately arrives at three conclusions.
First, a Web Agent's execution lifecycle must be independent of the network connection. A network connection is a transport resource, while the Run is the business-level execution.
Second, the Client Runtime should own Replay, Gap Repair, and Projection. React consumes only the Snapshot, which keeps recovery algorithms from being scattered throughout the component tree.
Third, plugin-based UI should be built on top of a stable View Model. A Feature Plugin registers a renderer rather than interpreting raw Events directly or implicitly reading all Runtime Services through a global Context.
By now, 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, compare how Vercel AI SDK, Pi, and DeepSeek Harness choose their boundaries, and use those differences to design our own Agent Runtime / Harness API.
Demo Files
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