src/vs/platform/agentHost/protocol.md
Keep this document in sync with the code. Changes to the state model, action types, protocol messages, or versioning strategy must be reflected here. Implementation lives in
common/state/.
Pre-production. This protocol is under active development and is not shipped yet. Breaking changes to wire types, actions, and state shapes are fine — do not worry about backward compatibility until the protocol is in production. The versioning machinery exists for future use.
For process architecture and IPC details, see architecture.md. For design decisions, see design.md.
The sessions process is a portable, standalone server that multiple clients can connect to. Clients see a synchronized view of sessions and can send commands that are reflected back as state-changing actions. The protocol is designed around four requirements:
Use this checklist when adding a new action, command, state field, or notification to the protocol.
protocolWebSocket.integrationTest.ts that exercises the action end-to-end through the WebSocket server. The test should fail until the implementation is complete.mockAgent.ts.sessionActions.ts. Extend ISessionActionBase (for session-scoped) or define a standalone root action. Add it to the ISessionAction or IRootAction union.sessionReducers.ts. The switch must remain exhaustive — the compiler will error if a case is missing.versions/v1.ts. Mirror the action interface shape. Add it to the IV1_SessionAction or IV1_RootAction union.versionRegistry.ts:
IV1_* type.AssertCompatible check.ISessionAction_v1 union.void expression.ACTION_INTRODUCED_IN (compiler enforces this).protocol.md (this file) — add the action to the Actions table.protocolWebSocket.integrationTest.ts. The test should fail until the implementation is complete.sessionProtocol.ts.protocolServerHandler.ts _handleRequestAsync(). The method returns the result; the caller wraps it in a JSON-RPC response or error automatically.IProtocolSideEffectHandler if the command requires I/O or agent interaction. Implement it in agentHostServerMain.ts.protocol.md — add the command to the Commands table.sessionState.ts (e.g. ISessionSummary, IActiveTurn, ITurn).createSessionState(), createActiveTurn()) to initialize the field.versions/v1.ts. Optional fields are safe; required fields break the bidirectional AssertCompatible check (intentionally — add as optional or bump the protocol version).sessionReducers.ts if the field needs to be mutated by actions.finalizeTurn() if the field lives on IActiveTurn and should transfer to ITurn on completion.protocolWebSocket.integrationTest.ts.sessionActions.ts. Add it to the INotification union.NOTIFICATION_INTRODUCED_IN in versionRegistry.ts.SessionStateManager or the relevant server-side code.mockAgent.ts sendMessage() to trigger the behavior.IAgentProgressEvent via _fireSequence() or manually through _onDidSessionProgress.All state is identified by URIs. Clients subscribe to a URI to receive its current state snapshot and subsequent action updates. This is the single universal mechanism for state synchronization:
agenthost:root) — always-present global state (agents and their models). Clients subscribe to this on connect.copilot:/<uuid>, etc.) — per-session state loaded on demand. Clients subscribe when opening a session.The subscribe(uri) / unsubscribe(uri) mechanism works identically for all resource types.
Subscribable at agenthost:root. Contains global, lightweight data that all clients need. Does not contain the session list — that is fetched imperatively via RPC (see Commands).
RootState {
agents: AgentInfo[]
}
Each AgentInfo includes the models available for that agent:
AgentInfo {
provider: string
displayName: string
description: string
models: ModelInfo[]
}
Subscribable at the session's URI (e.g. copilot:/<uuid>). Contains the full state for a single session.
SessionState {
summary: SessionSummary
lifecycle: 'creating' | 'ready' | 'creationFailed'
creationError?: ErrorInfo
turns: Turn[]
activeTurn: ActiveTurn | undefined
}
lifecycle tracks the asynchronous creation process. When a client creates a session, it picks a URI, sends the command, and subscribes immediately. The initial snapshot has lifecycle: 'creating'. The server asynchronously initializes the backend and dispatches session/ready or session/creationFailed.
Turn {
id: string
userMessage: UserMessage
responseParts: ResponsePart[]
toolCalls: CompletedToolCall[]
usage: UsageInfo | undefined
state: 'complete' | 'cancelled' | 'error'
}
ActiveTurn {
id: string
userMessage: UserMessage
streamingText: string
responseParts: ResponsePart[]
toolCalls: Record<toolCallId, ToolCallState>
pendingPermissions: Record<requestId, PermissionRequest>
reasoning: string
usage: UsageInfo | undefined
}
The session list can be arbitrarily large and is not part of the state tree. Instead:
listSessions() RPC.sessionAdded, sessionRemoved) so connected clients can update a local cache without re-fetching.Notifications are ephemeral — not processed by reducers, not stored in state, not replayed on reconnect. On reconnect, clients re-fetch the list.
Large content is not inlined in state. A ContentRef placeholder is used instead:
ContentRef {
uri: string // scheme://sessionId/contentId
sizeHint?: number
mimeType?: string
}
Clients fetch content separately via fetchContent(uri). This keeps the state tree small and serializable.
Actions are the sole mutation mechanism for subscribable state. They form a discriminated union keyed by type. Every action is wrapped in an ActionEnvelope for sequencing and origin tracking.
ActionEnvelope {
action: Action
serverSeq: number // monotonic, assigned by server
origin: { clientId: string, clientSeq: number } | undefined // undefined = server-originated
rejectionReason?: string // present when the server rejected the action
}
These mutate the root state. All root actions are server-only — clients observe them but cannot produce them.
| Type | Payload | When |
|---|---|---|
root/agentsChanged | AgentInfo[] | Available agent backends or their models changed |
All scoped to a session URI. Some are server-only (produced by the agent backend), others can be dispatched directly by clients.
When a client dispatches an action, the server applies it to the state and also reacts to it as a side effect (e.g., session/turnStarted triggers agent processing, session/turnCancelled aborts it). This avoids a separate command→action translation layer for the common interactive cases.
| Type | Payload | Client-dispatchable? | When |
|---|---|---|---|
session/ready | — | No | Session backend initialized successfully |
session/creationFailed | ErrorInfo | No | Session backend failed to initialize |
session/turnStarted | turnId, UserMessage | Yes | User sent a message; server starts processing |
session/delta | turnId, content | No | Streaming text chunk from assistant |
session/responsePart | turnId, ResponsePart | No | Structured content appended |
session/toolStart | turnId, ToolCallState | No | Tool execution began |
session/toolComplete | turnId, toolCallId, ToolCallResult | No | Tool execution finished |
session/permissionRequest | turnId, PermissionRequest | No | Permission needed from user |
session/permissionResolved | turnId, requestId, approved | Yes | Permission granted or denied |
session/turnComplete | turnId | No | Turn finished (assistant idle) |
session/turnCancelled | turnId | Yes | Turn was aborted; server stops processing |
session/error | turnId, ErrorInfo | No | Error during turn processing |
session/titleChanged | title | No | Session title updated |
session/usage | turnId, UsageInfo | No | Token usage report |
session/reasoning | turnId, content | No | Reasoning/thinking text |
session/modelChanged | model | Yes | Model changed for this session |
Notifications are ephemeral broadcasts that are not part of the state tree. They are not processed by reducers and are not replayed on reconnect.
| Type | Payload | When |
|---|---|---|
notify/sessionAdded | SessionSummary | A new session was created |
notify/sessionRemoved | session URI | A session was disposed |
Clients use notifications to maintain a local session list cache. On reconnect, clients should re-fetch via listSessions() rather than relying on replayed notifications.
Clients interact with the server in two ways:
session/turnStarted, session/turnCancelled). The server applies it to state and reacts with side effects. These are write-ahead: the client applies them optimistically.| Action | Server-side effect |
|---|---|
session/turnStarted | Begins agent processing for the new turn |
session/permissionResolved | Unblocks the pending tool execution |
session/turnCancelled | Aborts the in-progress turn |
| Command | Effect |
|---|---|
createSession(uri, config) | Server creates session, client subscribes to URI |
disposeSession(session) | Server disposes session, broadcasts sessionRemoved notification |
listSessions(filter?) | Returns SessionSummary[] |
fetchContent(uri) | Returns content bytes |
fetchTurns(session, range) | Returns historical turns |
browseDirectory(uri) | Lists directory entries at a file URI on the server's filesystem |
browseDirectory(uri) succeeds only if the target exists and is a directory. If the target does not exist, is not a directory, or cannot be accessed, the server MUST return a JSON-RPC error.
copilot:/<new-uuid>)createSession(uri, config) commandsubscribe(uri) (can be batched with the command)lifecycle: 'creating' and sends the subscription snapshotsession/ready actionsession/creationFailed action with error detailsnotify/sessionAdded to all clientsThe protocol uses JSON-RPC 2.0 framing over the transport (WebSocket, MessagePort, etc.).
unsubscribe, dispatchActioninitialize, reconnect, subscribe, createSession, disposeSession, listSessions, fetchTurns, fetchContent, browseDirectoryaction, notificationid): success result or JSON-RPC errorinitialize is a JSON-RPC request — the server MUST respond with a result or error:
1. Client → Server: { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { protocolVersion, clientId, initialSubscriptions? } }
2. Server → Client: { "jsonrpc": "2.0", "id": 1, "result": { protocolVersion, serverSeq, snapshots[], defaultDirectory? } }
initialSubscriptions allows the client to subscribe to root state (and any previously-open sessions on reconnect) in the same round-trip as the handshake. The server returns snapshots for each in the response.
subscribe is a JSON-RPC request — the client receives the snapshot as the response result:
Client → Server: { "jsonrpc": "2.0", "id": 1, "method": "subscribe", "params": { "resource": "copilot:/session-1" } }
Server → Client: { "jsonrpc": "2.0", "id": 1, "result": { "resource": ..., "state": ..., "fromSeq": 5 } }
After subscribing, the client receives all actions scoped to that URI with serverSeq > fromSeq. Multiple concurrent subscriptions are supported.
unsubscribe is a notification (no response needed):
Client → Server: { "jsonrpc": "2.0", "method": "unsubscribe", "params": { "resource": "copilot:/session-1" } }
The server broadcasts action envelopes as JSON-RPC notifications:
Server → Client: { "jsonrpc": "2.0", "method": "action", "params": { "envelope": { action, serverSeq, origin } } }
Protocol notifications (sessionAdded/sessionRemoved) are broadcast similarly:
Server → Client: { "jsonrpc": "2.0", "method": "notification", "params": { "notification": { type, ... } } }
Commands are JSON-RPC requests. The server returns a result or a JSON-RPC error:
Client → Server: { "jsonrpc": "2.0", "id": 2, "method": "createSession", "params": { session, provider?, model? } }
Server → Client: { "jsonrpc": "2.0", "id": 2, "result": null }
On failure:
Server → Client: { "jsonrpc": "2.0", "id": 2, "error": { "code": -32603, "message": "No agent for provider" } }
Actions are sent as notifications (fire-and-forget, write-ahead):
Client → Server: { "jsonrpc": "2.0", "method": "dispatchAction", "params": { clientSeq, action } }
reconnect is a JSON-RPC request. The server MUST include all replayed data in the response:
Client → Server: { "jsonrpc": "2.0", "id": 2, "method": "reconnect", "params": { clientId, lastSeenServerSeq, subscriptions } }
If the gap is within the replay buffer, the response contains missed action envelopes:
Server → Client: { "jsonrpc": "2.0", "id": 2, "result": { "type": "replay", "actions": [...] } }
If the gap exceeds the buffer, the response contains fresh snapshots:
Server → Client: { "jsonrpc": "2.0", "id": 2, "result": { "type": "snapshot", "snapshots": [...] } }
Protocol notifications are not replayed — the client should re-fetch the session list.
Each client maintains per-subscription:
confirmedState — last fully server-acknowledged statependingActions[] — optimistically applied but not yet echoed by serveroptimisticState — confirmedState with pendingActions replayed on top (computed, not stored)When the client receives an ActionEnvelope from the server:
origin.clientId === myId and matches head of pendingActions → pop from pending, apply to confirmedStateconfirmedState, rebase remaining pendingActionsrejectionReason present → remove from pending (optimistic effect reverted). The rejectionReason MAY be surfaced to the user.optimisticState from confirmedState + remaining pendingActionsMost session actions are append-only (add turn, append delta, add tool call). Pending actions still apply cleanly to an updated confirmed state because they operate on independent data (the turn the client created still exists; the content it appended is additive). The rare true conflict (two clients abort the same turn) is resolved by server-wins semantics.
Two constants define the version window:
PROTOCOL_VERSION — the current version that new code speaks.MIN_PROTOCOL_VERSION — the oldest version we maintain compatibility with.Bump PROTOCOL_VERSION when:
Adding optional fields to existing action/state types does NOT require a bump. Adding required fields or removing/renaming fields is a compile error (see below).
Version history:
1 — Initial: core session lifecycle, streaming, tools, permissions
Each protocol version has a type file (versions/v1.ts, versions/v2.ts, etc.) that captures the wire format shape of every state type and action type in that version.
The latest version file is the editable "tip" — it can be modified alongside the living types in sessionState.ts / sessionActions.ts. The compiler enforces that all changes are backwards-compatible. When PROTOCOL_VERSION is bumped, the previous version file becomes truly frozen and a new tip is created.
The version registry (versions/versionRegistry.ts) performs bidirectional assignability checks between the version types and the living types:
// AssertCompatible requires BOTH directions:
// Current extends Frozen → can't remove fields or change field types
// Frozen extends Current → can't add required fields
// The only allowed evolution is adding optional fields.
type AssertCompatible<Frozen, Current extends Frozen> = Frozen extends Current ? true : never;
type _check = AssertCompatible<IV1_TurnStartedAction, ITurnStartedAction>;
| Change to living type | Also update tip? | Compile result |
|---|---|---|
| Add optional field | Yes, add it to tip too | ✅ Passes |
| Add optional field | No, only in living type | ✅ Passes (tip is a subset) |
| Remove a field | — | ❌ Current extends Frozen fails |
| Change a field's type | — | ❌ Current extends Frozen fails |
| Add required field | — | ❌ Frozen extends Current fails |
The registry also maintains an exhaustive runtime map:
export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: number } = {
'root/agentsChanged': 1,
'session/turnStarted': 1,
// ...every action type must have an entry
};
The index signature [K in IStateAction['type']] means adding a new action to the IStateAction union without adding it to this map is a compile error. The developer is forced to pick a version number.
The server uses this for one-line filtering — no if/else chains:
function isActionKnownToVersion(action: IStateAction, clientVersion: number): boolean {
return ACTION_INTRODUCED_IN[action.type] <= clientVersion;
}
The protocol version maps to a ProtocolCapabilities interface for higher-level feature gating:
interface ProtocolCapabilities {
// v1 — always present
readonly sessions: true;
readonly tools: true;
readonly permissions: true;
// v2+
readonly reasoning?: true;
}
A newer client connecting to an older server:
initialize response.ProtocolCapabilities from the server version.isActionKnownToVersion).type values.When MIN_PROTOCOL_VERSION is raised from N to N+1:
versions/vN.ts.versions/versionRegistry.ts.We do not guarantee backward compatibility (older clients connecting to newer servers). Clients should update before the server.
PROTOCOL_VERSION in versions/versionRegistry.ts.versions/v{N}.ts — freeze the current types (copy from v{N-1} and add your new types).sessionActions.ts.ACTION_INTRODUCED_IN with version N (compiler forces this).AssertCompatible checks for the new types in versionRegistry.ts.ProtocolCapabilities if needed.State is mutated by pure reducer functions that take (state, action) → newState. The same reducer code runs on both server and client, which is what makes write-ahead possible: the client can locally predict the result of its own action using the same logic the server will run.
rootReducer(state: RootState, action: RootAction): RootState
sessionReducer(state: SessionState, action: SessionAction): SessionState
Reducers are pure (no side effects, no I/O). Server-side effects (e.g. forwarding a sendMessage command to the Copilot SDK) are handled by a separate dispatch layer, not in the reducer.
src/vs/platform/agent/common/state/
├── sessionState.ts # Immutable state types (RootState, SessionState, Turn, etc.)
├── sessionActions.ts # Action + notification discriminated unions, ActionEnvelope
├── sessionReducers.ts # Pure reducer functions (rootReducer, sessionReducer)
├── sessionProtocol.ts # JSON-RPC message types, request params/results, type guards
├── sessionCapabilities.ts # Re-exports version constants + ProtocolCapabilities
├── sessionClientState.ts # Client-side state manager (confirmed + pending + reconciliation)
└── versions/
├── v1.ts # v1 wire format types (tip — editable, compiler-enforced compat)
└── versionRegistry.ts # Compile-time compat checks + runtime action→version map
The existing IAgentProgressEvent union in agentService.ts captures raw streaming events from the Copilot SDK. The new action types in sessionActions.ts are a higher-level abstraction: they represent state transitions rather than SDK events.
In the server process, the mapping is:
IAgentDeltaEvent → session/delta actionIAgentToolStartEvent → session/toolStart actionIAgentIdleEvent → session/turnComplete actionThe existing IAgentService RPC interface remains unchanged. The new protocol layer sits on top: the sessions process uses IAgentService internally to talk to agent backends, and produces actions for connected clients.