An Agent that runs only within a single process can treat an execution as a function call: input comes in, the model and tool loop advances, the function returns, and the caller gets the result. Once it moves to the Web, however, that assumption quickly breaks down.
A browser page may refresh, a network connection may drop, a user may close a tab, and the Agent may still be running; another page may then reopen the same Session and need to recover the messages, tool calls, and current execution state that have already been produced. At that point, an HTTP request, SSE, or WebSocket connection can represent only part of the communication relationship; none can naturally bear responsibility for the Agent's execution lifecycle.
So the first thing a web Agent needs to address is not “how to stream tokens to the browser,” but the ownership relationships among several lifecycles:
Session 长期会话事实
Run 一次独立执行
Connection 一段网络连接
Browser Page 一个前端实例
If any two of these objects are merged, refreshing, disconnecting, resuming, or concurrent access will create conflicting semantics.
Starting with a Standard Streaming Request
The most straightforward web implementation usually looks like this:
Browser
│ POST /chat
▼
Server
│ stream model output
▼
HTTP Response / SSE
│
▼
Browser UI
If execution time is short, this structure works well enough. The request object holds an AbortSignal, model streaming follows the connection, and when the connection ends, execution ends with it.
The problem arises once the execution lifecycle outlasts the connection lifecycle.
For example, an Agent workflow might include:
LLM
↓
Tool A
↓
LLM
↓
等待外部审批
↓
Tool B
↓
LLM
There is no need to keep the original HTTP call stack alive during the wait, and the browser may also have refreshed. If the Server binds “this execution” directly to the Response, the Runtime faces two conflicting options once the Response disappears:
- Cancel execution. That would allow a network failure to alter the business semantics.
- Continue execution. The original Response would no longer be the owner of the execution state.
The second option effectively requires the system to introduce an execution object independent of the connection.
Session Cannot Substitute for Run
A Session is well suited to representing long-lived facts: messages, branches, user input, model output, tool results, session metadata, and the persistent state needed later to build the Context.
An execution also needs a separate set of state:
queued
running
waiting
completed
failed
cancelled
as well as:
startedAt
finishedAt
abortHandle
currentStep
waitingReason
parentRunId?
If all these fields were written directly into the Session, a structural problem would arise: the Session's lifecycle is far longer than that of a single execution, whereas Run state has clear start and end boundaries. A single Session may also produce multiple Runs in sequence, including background Runs, SubAgent Runs, or retry Runs.
The more stable model at the application layer is therefore:
Session
├── Message / Event ...
├── Run 1
├── Run 2
└── Run 3
Here, Run is an abstraction shared by the product layer and the Durable Web Runtime layer. Not every Agent framework elevates it to a core domain object. For example, DeepSeek Harness currently places more emphasis on Session, Turn, and Step at its core; a web product can still build independent Run records on top for task status, recovery, auditing, and scheduling.
Run answers “What state is this execution in now?” while Session answers “What has happened over this long-running interaction?”
Connection Should Not Own Run Either
A connection's lifecycle is inherently incidental.
The key relationship here is:
A connection is merely a channel through which a client observes the Run and Session.
If the Server automatically cancels a Run when the network disconnects, the Connection controls the business lifecycle. If refreshing the page creates a new Run, the Browser Page gains that control instead. Either way, a “communication failure” becomes conflated with a “business cancellation.”
A clearer control model separates the two:
transport disconnect
→ 结束当前连接
cancel run
→ 显式调用 Runtime cancellation
A user clicking Stop belongs to the latter. A Wi-Fi disconnection belongs to the former.
What Boundaries Does the Vercel AI SDK Provide?
In the Vercel AI SDK, useChat works well for the UI and Transport layers of a web Agent. In the current API, useChat manages UIMessage[], status, errors, and the send, stop, and resume operations. The Transport can be replaced with a custom HTTP or WebSocket implementation, or with one that calls the Agent directly.[1][2]
This shows that the main responsibility of useChat lies in:
UI State
+
Transport
rather than defining a complete Durable Session model for the application.
The AI SDK's persistence documentation also makes it clear that message persistence is the application's responsibility. UIMessage is designed for frontend display and is not the same structure as the ModelMessage sent to the model.[3]
This maps to an important layer boundary:
Frontend UIMessage[]
│
│ Transport payload
▼
Server Canonical Session
│
│ Context Builder
▼
ModelMessage[]
The frontend owns the UI State required for display, while the Server should retain final authority over session facts.
For a small application, you can persist UIMessage[] directly. Once an Agent adds an Event Log, Tool State, Approval, SubAgent, or Compaction, the Server Canonical State is usually richer than UIMessage[], and the UI Message becomes one of its projections.
Stream Resume Still Requires Persistent State
The AI SDK currently supports stream resume for useChat, but its official documentation also makes it clear that the resumption mechanism requires the application to persist the Message and Active Stream itself and maintain the relationship between the Chat and Stream IDs.[4]
The practical resumption problem can be expressed as follows:
The Streaming Transport alone cannot answer this question.
At least one recoverable source of truth is required:
Durable Event Tail
或
Resumable Stream Store
或
Server Projection + Durable Delta
Only then can the client perform the following:
load baseline at N
+
replay durable events > N
+
attach live stream
If a system claims to support strict resumption, it must handle the gap between “finishing the historical read” and “establishing the live subscription.” A common approach is to have replay and live attach share one continuous sequence space on the Server, or to establish a checkpoint before subscribing.
There Is a Real Conflict Between Abort and Resume
The current AI SDK documentation specifically points out that a resumable stream using resume: true conflicts with the abort/stop mechanism: refreshing or closing the page triggers the AbortSignal and may break stream resumption.[4][5]
This is not an incidental limitation of a particular library; it exposes a more general problem:
Connection Abort
与
Execution Cancel
In simple Streaming architectures, the two often share the same AbortSignal.
Once a Run is required to outlive its connection, the two forms of cancellation must be separated:
connection.abort()
只关闭客户端消费通道
run.cancel()
终止 Agent Runtime
This is a clear boundary in the evolution of a web Agent from a “streaming HTTP request” into a Runtime.
Browser Page Owns Only the View Instance
Refreshing a page means losing everything held in JavaScript memory. If the page object owns the only copy of the Session, a refresh is equivalent to losing the Session; if it owns the only control state for the Run, a refresh loses the current execution.
A more stable frontend relationship looks like this:
The page can destroy the entire Client Runtime. On reload, a new Runtime rebuilds itself from the Server Canonical State.
The Client Runtime can cache lastSeq, the current Session ID, or UI preferences, but these values exist to improve recovery efficiency and should not become the sole source of truth for business facts.
DeepSeek Harness's Client Runtime
DeepSeek Harness's browser client has already adopted this layering explicitly. Its client constraint documentation describes the data object layer as follows:
ConnectionController
↓
SessionManager
↓
Session
This layer must not depend on React; the Session itself maintains the event window, Streaming accumulation, reconnection fixes, and an observable Snapshot. The React bindings live in a separate web-react layer.[6]
ConnectionController manages the connection generation and exponential backoff itself. Once a new generation is established, it triggers onConnected; SessionManager.handleConnected() refreshes the Session list and lets each already-instantiated Session perform resync().[7][8]
A Session's resync() increments openGeneration, discards in-flight opens on the old connection, clears the window, and rereads the history; live Events enter liveBuffer while an open or gap repair is in progress. Once the history is committed, the buffer is appended to the end of the window according to seq.[9]
This implementation shows that the recovery object in a web Runtime is neither a React Component nor a Socket, but a Client Session with well-defined state and sequence rules.
Server State, Client State, and Run State
With these relationships consolidated, we get a relatively stable ownership model:
This makes several boundaries explicit:
- A Session's facts are not owned by the browser connection.
- A Run's termination is not implicitly determined by a network disconnection.
- The Event Stream transports changes; it is not the sole source of state.
- The Client Runtime handles recovery and projection instead of stuffing this kind of business state into a React Component.
- The UI handles display and local interaction state.
A Design Judgment
The core problem for a web Agent is not choosing between SSE and WebSocket.
The Transport can be replaced. A more stable design instead asks who owns execution, facts, connections, and the view, and whether the remaining objects can retain the correct semantics after one disappears.
Once these four lifecycles are separated, the structure for recovery after a disconnection naturally emerges:
Session / Run 继续存在
↓
Connection 可以反复建立
↓
Client Runtime 根据 checkpoint 重建
↓
UI 只是当前投影
The next article will tackle the most critical issue inside the Client Runtime: the Server sends Events, while React ultimately needs View State. Where should the Projection live, and how can historical Replay and live Append share the same computation logic?
References
[1] Vercel AI SDK useChat: https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat [2] Vercel AI SDK Transport: https://ai-sdk.dev/docs/ai-sdk-ui/transport [3] Vercel AI SDK Chatbot Message Persistence: https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence [4] Vercel AI SDK Chatbot Resume Streams: https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-resume-streams [5] Vercel AI SDK Abort breaks resumable streams: https://ai-sdk.dev/docs/troubleshooting/abort-breaks-resumable-streams [6] DeepSeek Harness Web Client Rules: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/AGENTS.md [7] DeepSeek Harness ConnectionController: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/connection/src/client/connection.ts [8] DeepSeek Harness SessionManager: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/sessions/manager.ts [9] DeepSeek Harness Client Session: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/sessions/session.ts