The Server side of an Agent is well-suited to using Events to express the execution process: Turn start, model output, Tool Call, Tool Result, Approval, Compaction, and Run state changes can all form facts that arrive in sequence.
What the browser ultimately needs is not an Event list.
A chat interface needs:
Message Row
Tool Card
Approval Panel
Plan Block
SubAgent Node
Running Indicator
Composer State
Therefore, there must be a state transformation in the Web Runtime:
Event
↓
Projection
↓
View State
↓
UI
Where the Projection is placed directly determines whether the system can correctly handle Replay, Streaming, Reconnect, and plugin extensions.
The Problem with Handling Events Directly in React
The easiest structure to implement is having components subscribe to the Event Stream:
useEffect(() => {
connection.onEvent(event => {
setMessages(messages => reduce(messages, event))
})
}, [])
In the short term, this code is direct enough. But as soon as history recovery is added, a second path appears:
const history = await loadHistory()
setMessages(buildMessages(history))
At this point, the system already has two sets of state construction logic:
History Replay
→ buildMessages()
Live Event
→ reduce()
If the rules of the two paths are not completely consistent, the same Session will get different UI between "first open" and "continuous online."
Tool Call is a typical example. In history, there may already be both:
tool/call
...
tool/result
Real-time state goes through:
tool/call
→ running card
→ tool/result
→ completed card
If the history constructor directly reads the final result while the real-time reducer maintains intermediate state, the two sets of code can easily gradually diverge.
Therefore, a more stable principle is:
Replay and Live Append should enter the same Projection Engine.
Projection Is Repeatable State Folding
The minimal Projection can be written as:
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)
}
}
History recovery:
initialState
↓ event 0
state 1
↓ event 1
state 2
↓ event 2
...
↓ event N
View State
Real-time execution just continues from this state:
View State at N
↓ event N+1
View State at N+1
This gives the system an important invariant:
fold(history + live)
=
fold(history) continued with live tail
This equivalence makes Replay, Reconnect, and online Streaming share the same semantics.
Message Is Not the Only Projection
Agent UI tends to compress everything into messages[]. This reintroduces the problem discussed in the second article: Agent execution contains many states that are not suitable to be expressed as Messages.
For example:
turn/start
step/start
assistant/chunk
tool/call
tool/result
approval/requested
turn/end
From these Events, multiple Projections can be derived simultaneously:
SessionEvent[]
│
├── Model History Projection
├── Chat Projection
├── Tool State Projection
├── Run/Turn Status Projection
└── Analytics Projection
Server and Client can have different Projections, but they should be based on a clear source of truth and consistent sequence boundaries.
DeepSeek Harness already embodies this in the Server Session: SessionEvent Log is the source of truth, deriveMessages() only projects the model's visible history. The Browser side establishes a Conversation Projection that transforms Events into Nodes suitable for UI. [1][2]
Why Client Runtime Should Exist
React components are suitable for managing component lifecycle and local interaction state, but not for taking ownership of network reconnection, Event Window, Gap Repair, and business Projection.
These states have several characteristics:
- Multiple components in the page need to share them.
- State update sources include not only React events but also network and history reads.
- UI may unmount, but the Session still needs to continue receiving Events.
- State must support full Replay.
- Update frequency may be much higher than the frequency of needing to re-render.
Therefore, DSH currently defines the browser-side data object layer as React-free:
ConnectionController
↓
SessionManager
↓
Session
↓
Conversation Assembler
And React only observes this layer through subscribe() / getSnapshot(). [3]
This is also different from traditional frontend "putting state in Store." DSH's constraint document explicitly requires Session, Frame, Connection and other business states to remain in the object layer; Slot Store only carries shared UI states like selection, draft, panel width. [3]
This boundary is worth preserving:
Business Runtime State
→ Client Runtime object
Shared UI Interaction State
→ UI Store
Component-local State
→ React state
The three state sources are different and don't need to be unified into one global Store.
Session Needs to Maintain a Continuous Event Window
The current DSH Client Session maintains internally:
private events: SessionEvent[] = []
private baseSeq = 0
private liveBuffer = []
private openGeneration = 0
private stitching = false
private subscribedLastSeq: number | null = null
Its goal is not to preserve the Server's complete Session, but to maintain a continuous window that the browser has currently loaded. [4]
On first open:
Session.open()
↓
history(maxMessages = 50)
↓
installWindow()
↓
Conversation.replaceWindow()
If real-time Events have already arrived during history reading, these Events first go into liveBuffer. After the history window is installed, they are appended according to seq. [4]
The key here is not the Buffer itself, but the continuity requirement.
Assume the tail of history is:
seq = 120
Then the Client receives:
seq = 123
Direct append would permanently lose 121, 122. DSH currently does not accept such a window with gaps, but puts 123 into Buffer and triggers tail page repull:
120
↓
123 arrives
↓
gap detected
↓
123 → liveBuffer
↓
reload tail history
↓
121, 122, 123...
↓
installWindow + dedup
Seq therefore simultaneously carries three responsibilities: ordering, deduplication, and Gap Detection. [4]
Reconnect Is Essentially Re-establishing Baseline
After network reconnection, the Client cannot assume nothing changed during the disconnection.
DSH's SessionManager.handleConnected() executes:
refresh session list
refresh relevant subagent catalogs
resync every resident Session
Session.resync() increases the generation, clears the old window and re-open(). [5][6]
openGeneration solves a typical concurrency problem:
Generation 1
history request -------------------->
connection lost
↓
Generation 2 starts
new history request ------>
old request returns ---------------->
If the old request can still write state after returning, it would overwrite the new generation with results from the old connection.
Therefore, doOpen(generation) checks after each await:
if (generation !== this.openGeneration) return
This generation token is a common and effective race isolation technique in Client Runtime. [4]
Projection Also Needs Incremental Paths
If every received Event scans the entire Session Window:
new event
↓
scan event 0..N
↓
rebuild every node
Long sessions will incur obvious costs.
DSH's current ConversationNodeAssembler supports three paths simultaneously:
replaceWindow()
used for open / resync / gap repair
prepend()
used for loading earlier history
append()
used for real-time tail Events
append() does not rescan existing Context, but only processes the current Event, updating the corresponding Context and View Builder. [7]
Therefore, the Client Projection's computational model can be written as:
Low-frequency path: full rebuild
replaceWindow(history)
High-frequency path: incremental folding
append(event)
Both must produce equivalent business results, but the performance strategies can differ.
ConversationNodeDefinition Moves Business Fold Out of Runtime
If Runtime internally writes a huge switch:
switch (event.type) {
case 'tool/call': ...
case 'approval/requested': ...
case 'plan/...': ...
case 'subagent/...': ...
}
Every UI Feature addition would require modifying the core Projection Engine.
DSH currently uses ConversationNodeDefinition<State> to register this business logic. [8]
A Definition mainly provides:
match(event)
start(context, match, reader)
update(context, match)
buildLocationData?(context, scope)
buildViewNode?(context)
It expresses an independent Event → State → ViewNode state machine.
For example, the conceptual model for Tool Call can be:
tool/call(callId = A)
↓ match
Context(kind=tool, id=A)
↓ start
ToolState(running)
↓
tool/result(callId = A)
↓ match/update
ToolState(completed)
↓ buildViewNode
ToolCallNode
Runtime is responsible for:
Event ordering
Context identity
Turn / Step Location
Calling Definition
Caching State
Publishing View Snapshot
Feature Plugin is responsible for:
Which Events belong to itself
How to update business State
What View Node to ultimately produce
This makes Projection itself have plugin boundaries.
Why Generate View Node First, Then Enter React
ConversationViewNode is the last business representation before React:
interface ConversationViewNode {
key: string
kind: string
id: string
target: string
data: unknown
}
Chat targets further carry Location, Anchor Sequence, and Visibility. [8]
This layer is important because it separates business semantics from React Component:
SessionEvent
↓
Business State
↓
ConversationViewNode
↓
UI Renderer
↓
React Component
Thus the same Projection Engine can be tested without React.
Business Features can also first verify if they correctly produce:
ToolCallNode {
id,
status,
call,
result,
subCalls
}
Then separately test how this Node is displayed.
Streaming Fold and React Publication Don't Need to Be the Same Frequency
LLM Streaming produces many Chunks. If every Chunk forces the entire React Tree to synchronously render, even though the Projection layer is correct, UI performance will still suffer.
DSH currently adds publication() in ConversationNodeDefinition, whose result can be:
none
animation-frame
immediate
Assembler takes the highest publication cadence required by the current transaction. [7][8]
This creates two different frequencies:
Event Fold Frequency
may execute for every chunk
React Publication Frequency
can be merged by animation frame
Therefore, Client Runtime undertakes a responsibility often overlooked: business state must be calculated promptly, but UI notifications can be batched.
If these two things are directly bound to React setState(), it's hard to control them independently.
Observable Is the Narrow Interface Between Runtime and React
When Client Runtime already has complete state, React doesn't need to know about Event Stream, History API, or Reconnection mechanism.
Only this is needed:
interface Observable<T> {
getSnapshot(): T
subscribe(listener: () => void): () => void
}
Then the React Binding layer calls useSyncExternalStore(). [3]
Therefore, dependencies remain one-way:
Connection / Session / Projection
│
│ Observable Snapshot
▼
React Binding
│
▼
Component
Business components do not directly listen to Socket, nor do they run Projection.
A More Complete Data Flow
Connecting the layers from this article:
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
This chain applies simultaneously to:
First open
History pagination
Real-time Streaming
Network Reconnect
Gap Repair
Projection rebuild after plugin re-registration
A Design Decision
The stable state model of Agent UI should satisfy two conditions.
First, Replay and Live use the same set of Projection rules. Otherwise, the restored UI and online runtime state will eventually diverge.
Second, Projection belongs to Client Runtime, not the component tree. React should observe Projection results, not be responsible for maintaining the source of truth and recovery algorithms.
Therefore, a more appropriate relationship for Agent Web UI is:
Event is the fact change
Projection is the business interpretation
View State is the UI input
React is the renderer
The next article continues with the last problem: after obtaining ConversationViewNode, who decides which component renders it, and how Features like Tool, Plan, SubAgent, Workflow can join the UI without modifying the central ChatView.
References
[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