first working protocol version

align more closely with protocol

json rpc and some gaps
This commit is contained in:
Connor Peet committed 2026-03-11 14:25:45 -07:00
1 parent f1239c83e4
commit e9299757f2
38 files changed
+5217 -1369

No files matched your search

+17
View File
@@ -348,3 +348,20 @@ Verify it's gone:
# Confirm no process is listening on the debug port
lsof -i :9224 # should return nothing
```
## Quick Test: Send a Chat Message as Local Agent
There's a helper script that automates the full flow — launch Code OSS, switch to Local Agent mode, send a message, and print the response:
```bash
# From the repo root:
./src/vs/platform/agent/test/node/createAndSendMessageAsLocalAgent.sh "Hello, what can you do?"
# Options:
# --port <N> CDP port (default: 9224)
# --timeout <N> Response wait in seconds (default: 30)
# --no-kill Keep Code OSS running after
# --skip-launch Connect to already-running instance
```
This uses the JS mouse-event focus + `press`-per-key approach internally, handles session target switching, and cleans up on exit.
+1
View File
@@ -1499,6 +1499,7 @@ export default tseslint.config(
'vscode-regexpp',
'vscode-textmate',
'worker_threads',
'ws',
'@xterm/addon-clipboard',
'@xterm/addon-image',
'@xterm/addon-ligatures',
+2 -1
View File
@@ -59,6 +59,7 @@
"vscode-oniguruma": "1.7.0",
"vscode-regexpp": "^3.1.0",
"vscode-textmate": "^9.3.2",
"ws": "^8.19.0",
"yauzl": "^3.0.0",
"yazl": "^2.4.3"
},
@@ -81,6 +82,7 @@
"@types/wicg-file-system-access": "^2023.10.7",
"@types/windows-foreground-love": "^0.3.0",
"@types/winreg": "^1.2.30",
"@types/ws": "^8.18.1",
"@types/yauzl": "^2.10.0",
"@types/yazl": "^2.4.2",
"@typescript-eslint/utils": "^8.45.0",
@@ -21155,7 +21157,6 @@
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+2
View File
@@ -129,6 +129,7 @@
"vscode-oniguruma": "1.7.0",
"vscode-regexpp": "^3.1.0",
"vscode-textmate": "^9.3.2",
"ws": "^8.19.0",
"yauzl": "^3.0.0",
"yazl": "^2.4.3"
},
@@ -151,6 +152,7 @@
"@types/wicg-file-system-access": "^2023.10.7",
"@types/windows-foreground-love": "^0.3.0",
"@types/winreg": "^1.2.30",
"@types/ws": "^8.18.1",
"@types/yauzl": "^2.10.0",
"@types/yazl": "^2.4.2",
"@typescript-eslint/utils": "^8.45.0",
+79
View File
@@ -0,0 +1,79 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// @ts-check
const cp = require('child_process');
const path = require('path');
const minimist = require('minimist');
async function main() {
const args = minimist(process.argv.slice(2), {
boolean: ['help', 'no-launch'],
string: ['port'],
});
if (args.help) {
console.log(
'Usage: ./scripts/code-agent-host.sh [options]\n' +
'\n' +
'Options:\n' +
' --port <number> Port to listen on (default: 8081, or VSCODE_AGENT_HOST_PORT env)\n' +
' --no-launch Start server without additional actions\n' +
' --help Show this help message',
);
return;
}
const port = args.port || process.env['VSCODE_AGENT_HOST_PORT'] || '8081';
const addr = await startServer(['--port', String(port)]);
console.log(`Agent Host server listening on ${addr}`);
}
function startServer(programArgs) {
return new Promise((resolve, reject) => {
const env = { ...process.env };
const entryPoint = path.join(
__dirname,
'..',
'out',
'vs',
'platform',
'agent',
'node',
'agentHostServerMain.js',
);
console.log(
`Starting agent host server: ${entryPoint} ${programArgs.join(' ')}`,
);
const proc = cp.spawn(process.execPath, [entryPoint, ...programArgs], {
env,
stdio: [process.stdin, null, process.stderr],
});
proc.stdout.on('data', (data) => {
const text = data.toString();
process.stdout.write(text);
const m = text.match(/READY:(\d+)/);
if (m) {
resolve(`ws://127.0.0.1:${m[1]}`);
}
});
proc.on('exit', (code) => process.exit(code));
process.on('exit', () => proc.kill());
process.on('SIGINT', () => {
proc.kill();
process.exit(128 + 2);
});
process.on('SIGTERM', () => {
proc.kill();
process.exit(128 + 15);
});
});
}
main();
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
if [[ "$OSTYPE" == "darwin"* ]]; then
realpath() { [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}"; }
ROOT=$(dirname $(dirname $(realpath "$0")))
else
ROOT=$(dirname $(dirname $(readlink -f $0)))
fi
function code() {
pushd $ROOT
# Get electron, compile, built-in extensions
if [[ -z "${VSCODE_SKIP_PRELAUNCH}" ]]; then
node build/lib/preLaunch.ts
fi
NODE=$(node build/lib/node.ts)
if [ ! -e $NODE ];then
# Load remote node
npm run gulp node
fi
popd
NODE_ENV=development \
VSCODE_DEV=1 \
exec "$NODE" "$ROOT/scripts/code-agent-host.js" "$@"
}
code "$@"
+3 -3
View File
@@ -5263,7 +5263,7 @@ declare namespace monaco.editor {
export const EditorOptions: {
acceptSuggestionOnCommitCharacter: IEditorOption<EditorOption.acceptSuggestionOnCommitCharacter, boolean>;
acceptSuggestionOnEnter: IEditorOption<EditorOption.acceptSuggestionOnEnter, 'on' | 'off' | 'smart'>;
accessibilitySupport: IEditorOption<EditorOption.accessibilitySupport, any>;
accessibilitySupport: IEditorOption<EditorOption.accessibilitySupport, AccessibilitySupport>;
accessibilityPageSize: IEditorOption<EditorOption.accessibilityPageSize, number>;
allowOverflow: IEditorOption<EditorOption.allowOverflow, boolean>;
allowVariableLineHeights: IEditorOption<EditorOption.allowVariableLineHeights, boolean>;
@@ -5326,7 +5326,7 @@ declare namespace monaco.editor {
foldingMaximumRegions: IEditorOption<EditorOption.foldingMaximumRegions, number>;
unfoldOnClickAfterEndOfLine: IEditorOption<EditorOption.unfoldOnClickAfterEndOfLine, boolean>;
fontFamily: IEditorOption<EditorOption.fontFamily, string>;
fontInfo: IEditorOption<EditorOption.fontInfo, any>;
fontInfo: IEditorOption<EditorOption.fontInfo, FontInfo>;
fontLigatures2: IEditorOption<EditorOption.fontLigatures, string>;
fontSize: IEditorOption<EditorOption.fontSize, number>;
fontWeight: IEditorOption<EditorOption.fontWeight, string>;
@@ -5366,7 +5366,7 @@ declare namespace monaco.editor {
pasteAs: IEditorOption<EditorOption.pasteAs, Readonly<Required<IPasteAsOptions>>>;
parameterHints: IEditorOption<EditorOption.parameterHints, Readonly<Required<IEditorParameterHintOptions>>>;
peekWidgetDefaultFocus: IEditorOption<EditorOption.peekWidgetDefaultFocus, 'tree' | 'editor'>;
placeholder: IEditorOption<EditorOption.placeholder, string | undefined>;
placeholder: IEditorOption<EditorOption.placeholder, string>;
definitionLinkOpensInPeek: IEditorOption<EditorOption.definitionLinkOpensInPeek, boolean>;
quickSuggestions: IEditorOption<EditorOption.quickSuggestions, InternalQuickSuggestionsOptions>;
quickSuggestionsDelay: IEditorOption<EditorOption.quickSuggestionsDelay, number>;
@@ -6,6 +6,8 @@
import { Event } from '../../../base/common/event.js';
import { URI } from '../../../base/common/uri.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import type { IActionEnvelope, INotification, ISessionAction } from './state/sessionActions.js';
import type { IStateSnapshot } from './state/sessionProtocol.js';
// IPC contract between the renderer and the agent host utility process.
// Defines all serializable event types, the IAgent provider interface,
@@ -30,7 +32,7 @@ export interface IAgentSessionMetadata {
readonly summary?: string;
}
export type AgentProvider = 'copilot';
export type AgentProvider = 'copilot' | 'local' | 'mock';
/** Metadata describing an agent backend, discovered over IPC. */
export interface IAgentDescriptor {
@@ -247,7 +249,7 @@ export namespace AgentSession {
*/
export function provider(session: URI): AgentProvider | undefined {
const scheme = session.scheme;
if (scheme === 'copilot') {
if (scheme === 'copilot' || scheme === 'local' || scheme === 'mock') {
return scheme;
}
return undefined;
@@ -283,6 +285,9 @@ export interface IAgent {
/** Abort the current turn, stopping any in-flight processing. */
abortSession(session: URI): Promise<void>;
/** Change the model for an existing session. */
changeModel?(session: URI, model: string): Promise<void>;
/** Respond to a pending permission request from the SDK. */
respondToPermissionRequest(requestId: string, approved: boolean): void;
@@ -312,21 +317,25 @@ export const IAgentService = createDecorator<IAgentService>('agentService');
/**
* Service contract for communicating with the agent host process. Methods here
* are proxied across MessagePort via `ProxyChannel`.
*
* State is synchronized via the subscribe/unsubscribe/dispatchAction protocol.
* Clients observe root state (agents, models) and session state via subscriptions,
* and mutate state by dispatching actions (e.g. session/turnStarted, session/turnCancelled).
*/
export interface IAgentService {
readonly _serviceBrand: undefined;
/** Fires when the agent host streams progress for a session. */
readonly onDidSessionProgress: Event<IAgentProgressEvent>;
/** Discover available agent backends from the agent host. */
listAgents(): Promise<IAgentDescriptor[]>;
/** Set the GitHub auth token used by the Copilot SDK. */
setAuthToken(token: string): Promise<void>;
/** List available models from the agent. */
listModels(): Promise<IAgentModelInfo[]>;
/**
* Refresh the model list from all providers, publishing updated
* agents (with models) to root state via `root/agentsChanged`.
*/
refreshModels(): Promise<void>;
/** List all available sessions from the Copilot CLI. */
listSessions(): Promise<IAgentSessionMetadata[]>;
@@ -334,23 +343,43 @@ export interface IAgentService {
/** Create a new session. Returns the session URI. */
createSession(config?: IAgentCreateSessionConfig): Promise<URI>;
/** Send a user message into an existing session. */
sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise<void>;
/** Retrieve all session events/messages for reconstruction, including tool invocations. */
getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]>;
/** Dispose a session in the agent host, freeing SDK resources. */
disposeSession(session: URI): Promise<void>;
/** Abort the current turn in a session. */
abortSession(session: URI): Promise<void>;
/** Respond to a pending permission request. */
respondToPermissionRequest(requestId: string, approved: boolean): void;
/** Gracefully shut down all sessions and the underlying client. */
shutdown(): Promise<void>;
// ---- Protocol methods (sessions process protocol) ----------------------
/**
* Subscribe to state at the given URI. Returns a snapshot of the current
* state and the serverSeq at snapshot time. Subsequent actions for this
* resource arrive via {@link onDidAction}.
*/
subscribe(resource: URI): Promise<IStateSnapshot>;
/** Unsubscribe from state updates for the given URI. */
unsubscribe(resource: URI): void;
/**
* Fires when the server applies an action to subscribable state.
* Clients use this alongside {@link subscribe} to keep their local
* state in sync.
*/
readonly onDidAction: Event<IActionEnvelope>;
/**
* Fires when the server broadcasts an ephemeral notification
* (e.g. sessionAdded, sessionRemoved).
*/
readonly onDidNotification: Event<INotification>;
/**
* Dispatch a client-originated action to the server. The server applies
* it to state, triggers side effects, and echoes it back via
* {@link onDidAction} with the client's origin for reconciliation.
*/
dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void;
}
export const IAgentHostService = createDecorator<IAgentHostService>('agentHostService');
@@ -361,6 +390,8 @@ export const IAgentHostService = createDecorator<IAgentHostService>('agentHostSe
*/
export interface IAgentHostService extends IAgentService {
/** Unique identifier for this client window, used as the origin in action envelopes. */
readonly clientId: string;
readonly onAgentHostExit: Event<number>;
readonly onAgentHostStart: Event<void>;
@@ -16,11 +16,9 @@
import { URI } from '../../../../base/common/uri.js';
import type {
IAgentInfo,
ICompletedToolCall,
IErrorInfo,
IPermissionRequest,
IResponsePart,
ISessionModelInfo,
ISessionSummary,
IToolCallState,
IUsageInfo,
@@ -59,18 +57,12 @@ export interface IActionOrigin {
// ---- Root actions (server-only, mutate RootState) ---------------------------
export interface IModelsChangedAction {
readonly type: 'root/modelsChanged';
readonly models: readonly ISessionModelInfo[];
}
export interface IAgentsChangedAction {
readonly type: 'root/agentsChanged';
readonly agents: readonly IAgentInfo[];
}
export type IRootAction =
| IModelsChangedAction
| IAgentsChangedAction;
// ---- Session actions (mutate SessionState, scoped to a session URI) ---------
@@ -126,7 +118,15 @@ export interface IToolCompleteAction extends ISessionActionBase {
readonly type: 'session/toolComplete';
readonly turnId: string;
readonly toolCallId: string;
readonly result: Omit<ICompletedToolCall, 'toolCallId' | 'toolName' | 'displayName'>;
readonly result: IToolCompleteResult;
}
/** The data delivered with a tool completion event. */
export interface IToolCompleteResult {
readonly success: boolean;
readonly pastTenseMessage: string;
readonly toolOutput?: string;
readonly error?: { readonly message: string; readonly code?: string };
}
// -- Permissions --
@@ -189,6 +189,12 @@ export interface IReasoningAction extends ISessionActionBase {
readonly content: string;
}
/** Server-only. Dispatched when the session's model is changed. */
export interface IModelChangedAction extends ISessionActionBase {
readonly type: 'session/modelChanged';
readonly model: string;
}
export type ISessionAction =
| ISessionReadyAction
| ISessionCreationFailedAction
@@ -204,7 +210,8 @@ export type ISessionAction =
| ISessionErrorAction
| ITitleChangedAction
| IUsageAction
| IReasoningAction;
| IReasoningAction
| IModelChangedAction;
// ---- Combined state action type ---------------------------------------------
@@ -11,7 +11,7 @@
// or sends concurrent actions from other sources.
//
// This operates on two kinds of subscribable state:
// - Root state (agents, models) — server-only mutations, no write-ahead.
// - Root state (agents + their models) — server-only mutations, no write-ahead.
// - Session state — mixed: some actions client-sendable (write-ahead),
// others server-only.
@@ -3,199 +3,178 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// Protocol messages for the sessions process client-server communication.
// See protocol.md -> Client-server protocol for the full design.
// Protocol messages using JSON-RPC 2.0 framing for the sessions process.
// See protocol.md for the full design.
//
// These types define the wire format for handshake, URI-based subscription,
// commands, notifications, and reconnection. They are transport-agnostic —
// the actual transport (MessagePort, WebSocket, stdio) is plugged in separately.
// Client → Server messages are either:
// - Notifications (fire-and-forget): initialize, reconnect, unsubscribe, dispatchAction
// - Requests (expect a correlated response): subscribe, createSession, disposeSession,
// listSessions, fetchTurns, fetchContent
//
// Server → Client messages are either:
// - Notifications (pushed to clients): serverHello, reconnectResponse, action, notification
// - Responses (correlated to a client request by id)
import { hasKey } from '../../../../base/common/types.js';
import { URI } from '../../../../base/common/uri.js';
import type { IActionEnvelope, INotification, ISessionAction, IStateAction } from './sessionActions.js';
import type { IRootState, ISessionState, ISessionSummary } from './sessionState.js';
// ---- Client → Server messages -----------------------------------------------
// ---- JSON-RPC 2.0 base types -----------------------------------------------
export interface IClientHello {
readonly type: 'clientHello';
readonly protocolVersion: number;
readonly clientId: string;
/** Subscribe to these URIs as part of the handshake (saves a round-trip). */
readonly initialSubscriptions?: readonly URI[];
/** A JSON-RPC notification: has `method` but no `id`. */
export interface IProtocolNotification {
readonly jsonrpc: '2.0';
readonly method: string;
readonly params?: unknown;
}
export interface IClientReconnect {
readonly type: 'clientReconnect';
readonly clientId: string;
readonly lastSeenServerSeq: number;
/** URIs the client was subscribed to before disconnection. */
readonly subscriptions: readonly URI[];
/** A JSON-RPC request: has both `method` and `id`. */
export interface IProtocolRequest {
readonly jsonrpc: '2.0';
readonly id: number;
readonly method: string;
readonly params?: unknown;
}
export interface ISubscribe {
readonly type: 'subscribe';
/** URI to subscribe to (e.g. `agenthost:root` or `copilot:/<uuid>`). */
readonly resource: URI;
/** A JSON-RPC success response. */
export interface IJsonRpcSuccessResponse {
readonly jsonrpc: '2.0';
readonly id: number;
readonly result: unknown;
}
export interface IUnsubscribe {
readonly type: 'unsubscribe';
readonly resource: URI;
/** A JSON-RPC error response. */
export interface IJsonRpcErrorResponse {
readonly jsonrpc: '2.0';
readonly id: number;
readonly error: {
readonly code: number;
readonly message: string;
readonly data?: unknown;
};
}
/**
* A client-dispatched action. The server applies it to state and
* reacts with side effects (e.g., starting agent processing).
* Used for write-ahead actions like turnStarted, turnCancelled,
* permissionResolved.
*/
export interface IClientAction {
readonly type: 'action';
readonly clientSeq: number;
readonly action: ISessionAction;
export type IJsonRpcResponse = IJsonRpcSuccessResponse | IJsonRpcErrorResponse;
/** Any message that flows over the protocol transport. */
export type IProtocolMessage = IProtocolNotification | IProtocolRequest | IJsonRpcResponse;
// ---- Type guards -----------------------------------------------------------
export function isJsonRpcRequest(msg: IProtocolMessage): msg is IProtocolRequest {
return hasKey(msg, { id: true, method: true });
}
/**
* A command from the client requesting an imperative operation
* that doesn't map directly to a single state action.
*/
export interface IClientCommand {
readonly type: 'command';
readonly command: ISessionCommand;
export function isJsonRpcNotification(msg: IProtocolMessage): msg is IProtocolNotification {
return hasKey(msg, { method: true }) && !hasKey(msg, { id: true });
}
export type IClientMessage =
| IClientHello
| IClientReconnect
| ISubscribe
| IUnsubscribe
| IClientAction
| IClientCommand;
// ---- Commands (embedded in IClientCommand) ----------------------------------
export interface ICreateSessionCommand {
readonly type: 'createSession';
/** URI the client has chosen for this session (client picks the ID). */
readonly session: URI;
readonly provider?: string;
readonly model?: string;
readonly workingDirectory?: string;
export function isJsonRpcResponse(msg: IProtocolMessage): msg is IJsonRpcResponse {
return hasKey(msg, { id: true }) && !hasKey(msg, { method: true });
}
export interface IDisposeSessionCommand {
readonly type: 'disposeSession';
readonly session: URI;
}
// ---- JSON-RPC error codes ---------------------------------------------------
export interface IFetchContentCommand {
readonly type: 'fetchContent';
readonly uri: URI;
}
export const JSON_RPC_INTERNAL_ERROR = -32603;
export interface IFetchTurnsCommand {
readonly type: 'fetchTurns';
readonly session: URI;
readonly startTurn: number;
readonly count: number;
}
// ---- Shared data types ------------------------------------------------------
export interface IListSessionsCommand {
readonly type: 'listSessions';
}
export type ISessionCommand =
| ICreateSessionCommand
| IDisposeSessionCommand
| IFetchContentCommand
| IFetchTurnsCommand
| IListSessionsCommand;
// ---- Server → Client messages -----------------------------------------------
export interface IServerHello {
readonly type: 'serverHello';
readonly protocolVersion: number;
readonly serverSeq: number;
/** Snapshots for each URI in the client's `initialSubscriptions`. */
readonly snapshots: readonly IStateSnapshot[];
}
/**
* Response to a subscribe request. Contains the state snapshot and
* the server sequence at snapshot time. The client processes subsequent
* actions with serverSeq > fromSeq.
*/
/** State snapshot returned by subscribe and included in handshake/reconnect. */
export interface IStateSnapshot {
readonly type: 'stateSnapshot';
readonly resource: URI;
readonly state: IRootState | ISessionState;
readonly fromSeq: number;
}
/**
* A state-changing action broadcast to subscribed clients.
*/
export interface IActionMessage {
readonly type: 'action';
readonly envelope: IActionEnvelope<IStateAction>;
// ---- Client → Server: Notification params -----------------------------------
export interface IInitializeParams {
readonly protocolVersion: number;
readonly clientId: string;
readonly initialSubscriptions?: readonly URI[];
}
/**
* An ephemeral notification broadcast to all connected clients.
* Not stored in state, not replayed on reconnect.
*/
export interface INotificationMessage {
readonly type: 'notification';
readonly notification: INotification;
export interface IReconnectParams {
readonly clientId: string;
readonly lastSeenServerSeq: number;
readonly subscriptions: readonly URI[];
}
/**
* Response to a fetchContent command.
*/
export interface IContentResponse {
readonly type: 'contentResponse';
readonly uri: URI;
readonly data: string; // base64-encoded for binary safety over JSON
readonly mimeType?: string;
export interface IUnsubscribeParams {
readonly resource: URI;
}
/**
* Response to a fetchTurns command.
*/
export interface ITurnsResponse {
readonly type: 'turnsResponse';
export interface IDispatchActionParams {
readonly clientSeq: number;
readonly action: ISessionAction;
}
// ---- Client → Server: Request params and results ----------------------------
export interface ISubscribeParams {
readonly resource: URI;
}
// Result: IStateSnapshot
export interface ICreateSessionParams {
readonly session: URI;
readonly provider?: string;
readonly model?: string;
readonly workingDirectory?: string;
}
// Result: void (null)
export interface IDisposeSessionParams {
readonly session: URI;
}
// Result: void (null)
// listSessions: no params
export interface IListSessionsResult {
readonly sessions: readonly ISessionSummary[];
}
export interface IFetchTurnsParams {
readonly session: URI;
readonly startTurn: number;
readonly count: number;
}
export interface IFetchTurnsResult {
readonly session: URI;
readonly startTurn: number;
readonly turns: ISessionState['turns'];
readonly totalTurns: number;
}
/**
* Response to a listSessions command.
*/
export interface IListSessionsResponse {
readonly type: 'listSessionsResponse';
readonly sessions: readonly ISessionSummary[];
export interface IFetchContentParams {
readonly uri: URI;
}
/**
* Sent on reconnection. Contains fresh snapshots for all previously
* subscribed URIs. Notifications are NOT replayed — the client should
* re-fetch the session list.
*/
export interface IReconnectResponse {
readonly type: 'reconnectResponse';
export interface IFetchContentResult {
readonly uri: URI;
readonly data: string; // base64-encoded for binary safety
readonly mimeType?: string;
}
// ---- Server → Client: Notification params -----------------------------------
export interface IServerHelloParams {
readonly protocolVersion: number;
readonly serverSeq: number;
readonly snapshots: readonly IStateSnapshot[];
}
export type IServerMessage =
| IServerHello
| IStateSnapshot
| IActionMessage
| INotificationMessage
| IContentResponse
| ITurnsResponse
| IListSessionsResponse
| IReconnectResponse;
export interface IReconnectResponseParams {
readonly serverSeq: number;
readonly snapshots: readonly IStateSnapshot[];
}
export interface IActionBroadcastParams {
readonly envelope: IActionEnvelope<IStateAction>;
}
export interface INotificationBroadcastParams {
readonly notification: INotification;
}
@@ -17,6 +17,7 @@
import type { IRootAction, ISessionAction } from './sessionActions.js';
import {
type ICompletedToolCall,
type IErrorInfo,
type IRootState,
type ISessionState,
type IToolCallState,
@@ -36,9 +37,6 @@ import {
*/
export function rootReducer(state: IRootState, action: IRootAction): IRootState {
switch (action.type) {
case 'root/modelsChanged': {
return { ...state, models: action.models };
}
case 'root/agentsChanged': {
return { ...state, agents: action.agents };
}
@@ -118,6 +116,9 @@ export function sessionReducer(state: ISessionState, action: ISessionAction): IS
toolCalls.set(action.toolCallId, {
...toolCall,
status: action.result.success ? ToolCallStatus.Completed : ToolCallStatus.Failed,
pastTenseMessage: action.result.pastTenseMessage,
toolOutput: action.result.toolOutput,
error: action.result.error,
});
return {
...state,
@@ -161,7 +162,9 @@ export function sessionReducer(state: ISessionState, action: ISessionAction): IS
const mutable = new Map(toolCalls);
mutable.set(resolved.toolCallId, {
...toolCall,
status: action.approved ? ToolCallStatus.Running : ToolCallStatus.Failed,
status: action.approved ? ToolCallStatus.Running : ToolCallStatus.Cancelled,
confirmed: action.approved ? 'user-action' : 'denied',
cancellationReason: action.approved ? undefined : 'denied',
});
toolCalls = mutable;
}
@@ -178,18 +181,31 @@ export function sessionReducer(state: ISessionState, action: ISessionAction): IS
return finalizeTurn(state, action.turnId, TurnState.Cancelled);
}
case 'session/error': {
return finalizeTurn(state, action.turnId, TurnState.Error);
return finalizeTurn(state, action.turnId, TurnState.Error, action.error);
}
case 'session/titleChanged': {
return {
...state,
summary: { ...state.summary, title: action.title },
summary: { ...state.summary, title: action.title, modifiedAt: Date.now() },
};
}
case 'session/modelChanged': {
return {
...state,
summary: { ...state.summary, model: action.model, modifiedAt: Date.now() },
};
}
case 'session/usage': {
// Usage is informational; stored on the active turn for now,
// then captured on the finalized Turn.
return state;
if (!state.activeTurn || state.activeTurn.id !== action.turnId) {
return state;
}
return {
...state,
activeTurn: {
...state.activeTurn,
usage: action.usage,
},
};
}
case 'session/reasoning': {
if (!state.activeTurn || state.activeTurn.id !== action.turnId) {
@@ -211,7 +227,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction): IS
/**
* Moves the active turn into the completed turns array and clears `activeTurn`.
*/
function finalizeTurn(state: ISessionState, turnId: string, turnState: TurnState): ISessionState {
function finalizeTurn(state: ISessionState, turnId: string, turnState: TurnState, error?: IErrorInfo): ISessionState {
if (!state.activeTurn || state.activeTurn.id !== turnId) {
return state;
}
@@ -223,25 +239,32 @@ function finalizeTurn(state: ISessionState, turnId: string, turnState: TurnState
toolCallId: tc.toolCallId,
toolName: tc.toolName,
displayName: tc.displayName,
invocationMessage: tc.invocationMessage,
success: tc.status === ToolCallStatus.Completed,
pastTenseMessage: tc.invocationMessage,
toolOutput: tc.toolInput,
pastTenseMessage: tc.pastTenseMessage ?? tc.invocationMessage,
toolInput: tc.toolInput,
toolKind: tc.toolKind,
language: tc.language,
toolOutput: tc.toolOutput,
error: tc.error,
});
}
const finalizedTurn: ITurn = {
id: active.id,
userMessage: active.userMessage,
responseText: active.streamingText,
responseParts: active.responseParts,
toolCalls: completedToolCalls,
usage: undefined,
usage: active.usage,
state: turnState,
error,
};
return {
...state,
turns: [...state.turns, finalizedTurn],
activeTurn: undefined,
summary: { ...state.summary, status: SessionStatus.Idle },
summary: { ...state.summary, status: SessionStatus.Idle, modifiedAt: Date.now() },
};
}
@@ -37,6 +37,7 @@ export interface ISessionSummary {
readonly status: SessionStatus;
readonly createdAt: number;
readonly modifiedAt: number;
readonly model?: string;
}
// ---- Model info -------------------------------------------------------------
@@ -45,6 +46,9 @@ export interface ISessionModelInfo {
readonly id: string;
readonly provider: AgentProvider;
readonly name: string;
readonly maxContextWindow?: number;
readonly supportsVision?: boolean;
readonly policyState?: 'enabled' | 'disabled' | 'unconfigured';
}
// ---- Root state (subscribable at ROOT_STATE_URI) ----------------------------
@@ -56,13 +60,13 @@ export interface ISessionModelInfo {
*/
export interface IRootState {
readonly agents: readonly IAgentInfo[];
readonly models: readonly ISessionModelInfo[];
}
export interface IAgentInfo {
readonly provider: AgentProvider;
readonly displayName: string;
readonly description: string;
readonly models: readonly ISessionModelInfo[];
}
// ---- Session lifecycle ------------------------------------------------------
@@ -109,10 +113,14 @@ export interface IMessageAttachment {
export interface ITurn {
readonly id: string;
readonly userMessage: IUserMessage;
/** The final assistant response text (captured from streamingText on turn completion). */
readonly responseText: string;
readonly responseParts: readonly IResponsePart[];
readonly toolCalls: readonly ICompletedToolCall[];
readonly usage: IUsageInfo | undefined;
readonly state: TurnState;
/** Error info if the turn ended with {@link TurnState.Error}. */
readonly error?: IErrorInfo;
}
export const enum TurnState {
@@ -132,6 +140,7 @@ export interface IActiveTurn {
readonly toolCalls: ReadonlyMap<string, IToolCallState>;
readonly pendingPermissions: ReadonlyMap<string, IPermissionRequest>;
readonly reasoning: string;
readonly usage: IUsageInfo | undefined;
}
// ---- Response parts ---------------------------------------------------------
@@ -162,12 +171,22 @@ export type IResponsePart = IMarkdownResponsePart | IContentRef;
// ---- Tool calls -------------------------------------------------------------
export const enum ToolCallStatus {
/** Tool is actively executing. */
Running = 'running',
/** Waiting for user to approve before execution. */
PendingPermission = 'pending-permission',
/** Tool finished successfully. */
Completed = 'completed',
/** Tool failed with an error. */
Failed = 'failed',
/** Tool was denied or skipped by the user. */
Cancelled = 'cancelled',
}
/**
* Represents the full lifecycle state of a tool invocation within an active turn.
* Modeled after {@link IChatToolInvocation.State} to enable direct mapping to the chat UI.
*/
export interface IToolCallState {
readonly toolCallId: string;
readonly toolName: string;
@@ -176,16 +195,34 @@ export interface IToolCallState {
readonly toolInput?: string;
readonly toolKind?: 'terminal';
readonly language?: string;
readonly toolArguments?: string;
readonly status: ToolCallStatus;
/** Parsed tool parameters (from toolArguments). */
readonly parameters?: unknown;
/** How the tool was confirmed before execution (set after PendingPermission → Running). */
readonly confirmed?: 'not-needed' | 'user-action' | 'setting' | 'denied' | 'skipped';
/** Set when status transitions to Completed or Failed. */
readonly pastTenseMessage?: string;
/** Set when status transitions to Completed or Failed. */
readonly toolOutput?: string;
/** Set when status transitions to Failed. */
readonly error?: { readonly message: string; readonly code?: string };
/** Why the tool was cancelled (set when status is Cancelled). */
readonly cancellationReason?: 'denied' | 'skipped';
}
export interface ICompletedToolCall {
readonly toolCallId: string;
readonly toolName: string;
readonly displayName: string;
readonly invocationMessage: string;
readonly success: boolean;
readonly pastTenseMessage: string;
readonly toolInput?: string;
readonly toolKind?: 'terminal';
readonly language?: string;
readonly toolOutput?: string;
readonly error?: { readonly message: string; readonly code?: string };
}
// ---- Permission requests ----------------------------------------------------
@@ -197,6 +234,9 @@ export interface IPermissionRequest {
readonly path?: string;
readonly fullCommandText?: string;
readonly intention?: string;
readonly serverName?: string;
readonly toolName?: string;
readonly rawRequest?: string;
}
// ---- Usage info -------------------------------------------------------------
@@ -221,7 +261,6 @@ export interface IErrorInfo {
export function createRootState(): IRootState {
return {
agents: [],
models: [],
};
}
@@ -243,5 +282,6 @@ export function createActiveTurn(id: string, userMessage: IUserMessage): IActive
toolCalls: new Map(),
pendingPermissions: new Map(),
reasoning: '',
usage: undefined,
};
}
@@ -0,0 +1,42 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// Transport abstraction for the sessions process protocol.
// See protocol.md -> Client-server protocol for the full design.
//
// The transport is pluggable — the same protocol runs over MessagePort
// (ProxyChannel), WebSocket, or stdio. This module defines the contract;
// concrete implementations live in platform-specific folders.
import { Event } from '../../../../base/common/event.js';
import { IDisposable } from '../../../../base/common/lifecycle.js';
import type { IProtocolMessage } from './sessionProtocol.js';
/**
* A bidirectional transport for protocol messages. Implementations handle
* serialization, framing, and connection management.
*/
export interface IProtocolTransport extends IDisposable {
/** Fires when a message is received from the remote end. */
readonly onMessage: Event<IProtocolMessage>;
/** Fires when the transport connection closes. */
readonly onClose: Event<void>;
/** Send a message to the remote end. */
send(message: IProtocolMessage): void;
}
/**
* Server-side transport that accepts multiple client connections.
* Each connected client gets its own {@link IProtocolTransport}.
*/
export interface IProtocolServer extends IDisposable {
/** Fires when a new client connects. */
readonly onConnection: Event<IProtocolTransport>;
/** The port or address the server is listening on. */
readonly address: string | undefined;
}
@@ -17,19 +17,22 @@ import type { AgentProvider } from '../../agentService.js';
export interface IV1_RootState {
readonly agents: readonly IV1_AgentInfo[];
readonly models: readonly IV1_SessionModelInfo[];
}
export interface IV1_AgentInfo {
readonly provider: AgentProvider;
readonly displayName: string;
readonly description: string;
readonly models: readonly IV1_SessionModelInfo[];
}
export interface IV1_SessionModelInfo {
readonly id: string;
readonly provider: AgentProvider;
readonly name: string;
readonly maxContextWindow?: number;
readonly supportsVision?: boolean;
readonly policyState?: 'enabled' | 'disabled' | 'unconfigured';
}
export interface IV1_SessionSummary {
@@ -39,6 +42,7 @@ export interface IV1_SessionSummary {
readonly status: 'idle' | 'in-progress' | 'error';
readonly createdAt: number;
readonly modifiedAt: number;
readonly model?: string;
}
export interface IV1_SessionState {
@@ -63,10 +67,12 @@ export interface IV1_MessageAttachment {
export interface IV1_Turn {
readonly id: string;
readonly userMessage: IV1_UserMessage;
readonly responseText: string;
readonly responseParts: readonly IV1_ResponsePart[];
readonly toolCalls: readonly IV1_CompletedToolCall[];
readonly usage: IV1_UsageInfo | undefined;
readonly state: 'complete' | 'cancelled' | 'error';
readonly error?: IV1_ErrorInfo;
}
export interface IV1_ActiveTurn {
@@ -77,6 +83,7 @@ export interface IV1_ActiveTurn {
readonly toolCalls: ReadonlyMap<string, IV1_ToolCallState>;
readonly pendingPermissions: ReadonlyMap<string, IV1_PermissionRequest>;
readonly reasoning: string;
readonly usage: IV1_UsageInfo | undefined;
}
export interface IV1_MarkdownResponsePart {
@@ -101,16 +108,28 @@ export interface IV1_ToolCallState {
readonly toolInput?: string;
readonly toolKind?: 'terminal';
readonly language?: string;
readonly status: 'running' | 'pending-permission' | 'completed' | 'failed';
readonly toolArguments?: string;
readonly status: 'running' | 'pending-permission' | 'completed' | 'failed' | 'cancelled';
readonly parameters?: unknown;
readonly confirmed?: 'not-needed' | 'user-action' | 'setting' | 'denied' | 'skipped';
readonly pastTenseMessage?: string;
readonly toolOutput?: string;
readonly error?: { readonly message: string; readonly code?: string };
readonly cancellationReason?: 'denied' | 'skipped';
}
export interface IV1_CompletedToolCall {
readonly toolCallId: string;
readonly toolName: string;
readonly displayName: string;
readonly invocationMessage: string;
readonly success: boolean;
readonly pastTenseMessage: string;
readonly toolInput?: string;
readonly toolKind?: 'terminal';
readonly language?: string;
readonly toolOutput?: string;
readonly error?: { readonly message: string; readonly code?: string };
}
export interface IV1_PermissionRequest {
@@ -120,6 +139,9 @@ export interface IV1_PermissionRequest {
readonly path?: string;
readonly fullCommandText?: string;
readonly intention?: string;
readonly serverName?: string;
readonly toolName?: string;
readonly rawRequest?: string;
}
export interface IV1_UsageInfo {
@@ -141,11 +163,6 @@ interface IV1_SessionActionBase {
readonly session: URI;
}
export interface IV1_ModelsChangedAction {
readonly type: 'root/modelsChanged';
readonly models: readonly IV1_SessionModelInfo[];
}
export interface IV1_AgentsChangedAction {
readonly type: 'root/agentsChanged';
readonly agents: readonly IV1_AgentInfo[];
@@ -188,7 +205,14 @@ export interface IV1_ToolCompleteAction extends IV1_SessionActionBase {
readonly type: 'session/toolComplete';
readonly turnId: string;
readonly toolCallId: string;
readonly result: Omit<IV1_CompletedToolCall, 'toolCallId' | 'toolName' | 'displayName'>;
readonly result: IV1_ToolCompleteResult;
}
export interface IV1_ToolCompleteResult {
readonly success: boolean;
readonly pastTenseMessage: string;
readonly toolOutput?: string;
readonly error?: { readonly message: string; readonly code?: string };
}
export interface IV1_PermissionRequestAction extends IV1_SessionActionBase {
@@ -237,8 +261,12 @@ export interface IV1_ReasoningAction extends IV1_SessionActionBase {
readonly content: string;
}
export interface IV1_ModelChangedAction extends IV1_SessionActionBase {
readonly type: 'session/modelChanged';
readonly model: string;
}
export type IV1_RootAction =
| IV1_ModelsChangedAction
| IV1_AgentsChangedAction;
export type IV1_SessionAction =
@@ -256,7 +284,8 @@ export type IV1_SessionAction =
| IV1_SessionErrorAction
| IV1_TitleChangedAction
| IV1_UsageAction
| IV1_ReasoningAction;
| IV1_ReasoningAction
| IV1_ModelChangedAction;
export type IV1_StateAction = IV1_RootAction | IV1_SessionAction;
@@ -9,7 +9,7 @@
import type {
IAgentsChangedAction,
IDeltaAction,
IModelsChangedAction,
IModelChangedAction,
INotification,
IPermissionRequestAction,
IPermissionResolvedAction,
@@ -58,7 +58,7 @@ import type {
IV1_ErrorInfo,
IV1_MarkdownResponsePart,
IV1_MessageAttachment,
IV1_ModelsChangedAction,
IV1_ModelChangedAction,
IV1_PermissionRequest,
IV1_PermissionRequestAction,
IV1_PermissionResolvedAction,
@@ -134,7 +134,6 @@ type _v1_ErrorInfo = AssertCompatible<IV1_ErrorInfo, IErrorInfo>;
// -- v1 action compatibility --
type _v1_ModelsChanged = AssertCompatible<IV1_ModelsChangedAction, IModelsChangedAction>;
type _v1_AgentsChanged = AssertCompatible<IV1_AgentsChangedAction, IAgentsChangedAction>;
type _v1_SessionReady = AssertCompatible<IV1_SessionReadyAction, ISessionReadyAction>;
type _v1_CreationFailed = AssertCompatible<IV1_SessionCreationFailedAction, ISessionCreationFailedAction>;
@@ -151,6 +150,7 @@ type _v1_SessionError = AssertCompatible<IV1_SessionErrorAction, ISessionErrorAc
type _v1_TitleChanged = AssertCompatible<IV1_TitleChangedAction, ITitleChangedAction>;
type _v1_Usage = AssertCompatible<IV1_UsageAction, IUsageAction>;
type _v1_Reasoning = AssertCompatible<IV1_ReasoningAction, IReasoningAction>;
type _v1_ModelChanged = AssertCompatible<IV1_ModelChangedAction, IModelChangedAction>;
// Suppress unused-variable warnings for compile-time-only checks.
void (0 as unknown as
@@ -159,11 +159,11 @@ void (0 as unknown as
_v1_ActiveTurn & _v1_MarkdownResponsePart & _v1_ContentRef &
_v1_ToolCallState & _v1_CompletedToolCall & _v1_PermissionRequest &
_v1_UsageInfo & _v1_ErrorInfo &
_v1_ModelsChanged & _v1_AgentsChanged & _v1_SessionReady & _v1_CreationFailed &
_v1_AgentsChanged & _v1_SessionReady & _v1_CreationFailed &
_v1_TurnStarted & _v1_Delta & _v1_ResponsePart & _v1_ToolStart &
_v1_ToolComplete & _v1_PermissionRequestAction & _v1_PermissionResolved &
_v1_TurnComplete & _v1_TurnCancelled & _v1_SessionError & _v1_TitleChanged &
_v1_Usage & _v1_Reasoning
_v1_Usage & _v1_Reasoning & _v1_ModelChanged
);
// ---- Runtime action → version map -------------------------------------------
@@ -178,7 +178,6 @@ void (0 as unknown as
/** Maps every action type string to the protocol version that introduced it. */
export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: number } = {
// Root actions (v1)
'root/modelsChanged': 1,
'root/agentsChanged': 1,
// Session lifecycle (v1)
'session/ready': 1,
@@ -201,6 +200,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe
'session/titleChanged': 1,
'session/usage': 1,
'session/reasoning': 1,
'session/modelChanged': 1,
};
/** Maps every notification type string to the protocol version that introduced it. */
@@ -233,13 +233,14 @@ export function isNotificationKnownToVersion(notification: INotification, client
// When you add a new protocol version, define its additions and extend the map.
/** Action types introduced in v1. */
type IRootAction_v1 = IV1_ModelsChangedAction | IV1_AgentsChangedAction;
type IRootAction_v1 = IV1_AgentsChangedAction;
type ISessionAction_v1 = IV1_SessionReadyAction | IV1_SessionCreationFailedAction
| IV1_TurnStartedAction | IV1_DeltaAction | IV1_ResponsePartAction
| IV1_ToolStartAction | IV1_ToolCompleteAction
| IV1_PermissionRequestAction | IV1_PermissionResolvedAction
| IV1_TurnCompleteAction | IV1_TurnCancelledAction | IV1_SessionErrorAction
| IV1_TitleChangedAction | IV1_UsageAction | IV1_ReasoningAction;
| IV1_TitleChangedAction | IV1_UsageAction | IV1_ReasoningAction
| IV1_ModelChangedAction;
/**
* Maps protocol versions to their cumulative action type unions.
@@ -6,14 +6,18 @@
import { DeferredPromise } from '../../../base/common/async.js';
import { Emitter } from '../../../base/common/event.js';
import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { getDelayedChannel, ProxyChannel } from '../../../base/parts/ipc/common/ipc.js';
import { Client as MessagePortClient } from '../../../base/parts/ipc/common/ipc.mp.js';
import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js';
import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js';
import { IConfigurationService } from '../../configuration/common/configuration.js';
import { ILogService } from '../../log/common/log.js';
import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentService, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../common/agentService.js';
import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata } from '../common/agentService.js';
import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js';
import type { IStateSnapshot } from '../common/state/sessionProtocol.js';
import { revive } from '../../../base/common/marshalling.js';
import { URI } from '../../../base/common/uri.js';
/**
* Renderer-side implementation of {@link IAgentHostService} that connects
@@ -24,6 +28,9 @@ import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentAttachment, IAge
class AgentHostServiceClient extends Disposable implements IAgentHostService {
declare readonly _serviceBrand: undefined;
/** Unique identifier for this window, used in action envelope origin tracking. */
readonly clientId = generateUuid();
private readonly _clientEventually = new DeferredPromise<MessagePortClient>();
private readonly _proxy: IAgentService;
@@ -32,8 +39,11 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService {
private readonly _onAgentHostStart = this._register(new Emitter<void>());
readonly onAgentHostStart = this._onAgentHostStart.event;
private readonly _onDidSessionProgress = this._register(new Emitter<IAgentProgressEvent>());
readonly onDidSessionProgress = this._onDidSessionProgress.event;
private readonly _onDidAction = this._register(new Emitter<IActionEnvelope>());
readonly onDidAction = this._onDidAction.event;
private readonly _onDidNotification = this._register(new Emitter<INotification>());
readonly onDidNotification = this._onDidNotification.event;
constructor(
@ILogService private readonly _logService: ILogService,
@@ -61,9 +71,11 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService {
const client = store.add(new MessagePortClient(port, `agentHost:window`));
this._clientEventually.complete(client);
store.add(this._proxy.onDidSessionProgress(e => {
// Events from ProxyChannel don't auto-revive nested URIs -- revive the session URI
this._onDidSessionProgress.fire({ ...e, session: URI.revive(e.session) });
store.add(this._proxy.onDidAction(e => {
this._onDidAction.fire(revive(e));
}));
store.add(this._proxy.onDidNotification(e => {
this._onDidNotification.fire(revive(e));
}));
this._logService.info('[AgentHost:renderer] Direct MessagePort connection established');
this._onAgentHostStart.fire();
@@ -77,8 +89,8 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService {
listAgents(): Promise<IAgentDescriptor[]> {
return this._proxy.listAgents();
}
listModels(): Promise<IAgentModelInfo[]> {
return this._proxy.listModels();
refreshModels(): Promise<void> {
return this._proxy.refreshModels();
}
listSessions(): Promise<IAgentSessionMetadata[]> {
return this._proxy.listSessions();
@@ -86,24 +98,21 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService {
createSession(config?: IAgentCreateSessionConfig): Promise<URI> {
return this._proxy.createSession(config);
}
sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise<void> {
return this._proxy.sendMessage(session, prompt, attachments);
}
getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> {
return this._proxy.getSessionMessages(session);
}
disposeSession(session: URI): Promise<void> {
return this._proxy.disposeSession(session);
}
abortSession(session: URI): Promise<void> {
return this._proxy.abortSession(session);
}
respondToPermissionRequest(requestId: string, approved: boolean): void {
this._proxy.respondToPermissionRequest(requestId, approved);
}
shutdown(): Promise<void> {
return this._proxy.shutdown();
}
subscribe(resource: URI): Promise<IStateSnapshot> {
return this._proxy.subscribe(resource);
}
unsubscribe(resource: URI): void {
this._proxy.unsubscribe(resource);
}
dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void {
this._proxy.dispatchAction(action, clientId, clientSeq);
}
async restartAgentHost(): Promise<void> {
// Restart is handled by the main process side
}
@@ -0,0 +1,160 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type {
IAgentProgressEvent,
IAgentToolStartEvent,
IAgentToolCompleteEvent,
IAgentPermissionRequestEvent,
IAgentErrorEvent,
IAgentReasoningEvent,
IAgentUsageEvent,
IAgentDeltaEvent,
IAgentTitleChangedEvent,
} from '../common/agentService.js';
import type {
ISessionAction,
IDeltaAction,
IToolStartAction,
IToolCompleteAction,
ITurnCompleteAction,
ISessionErrorAction,
IUsageAction,
ITitleChangedAction,
IPermissionRequestAction,
IReasoningAction,
} from '../common/state/sessionActions.js';
import { ToolCallStatus } from '../common/state/sessionState.js';
import { URI } from '../../../base/common/uri.js';
/**
* Maps a flat {@link IAgentProgressEvent} from the agent host into
* a protocol {@link ISessionAction} suitable for dispatch to the reducer.
* Returns `undefined` for events that have no corresponding action.
*/
export function mapProgressEventToAction(event: IAgentProgressEvent, session: URI, turnId: string): ISessionAction | undefined {
switch (event.type) {
case 'delta':
return {
type: 'session/delta',
session,
turnId,
content: (event as IAgentDeltaEvent).content,
} satisfies IDeltaAction;
case 'tool_start': {
const e = event as IAgentToolStartEvent;
return {
type: 'session/toolStart',
session,
turnId,
toolCall: {
toolCallId: e.toolCallId,
toolName: e.toolName,
displayName: e.displayName,
invocationMessage: e.invocationMessage,
toolInput: e.toolInput,
toolKind: e.toolKind,
language: e.language,
toolArguments: e.toolArguments,
status: ToolCallStatus.Running,
},
} satisfies IToolStartAction;
}
case 'tool_complete': {
const e = event as IAgentToolCompleteEvent;
return {
type: 'session/toolComplete',
session,
turnId,
toolCallId: e.toolCallId,
result: {
success: e.success,
pastTenseMessage: e.pastTenseMessage,
toolOutput: e.toolOutput,
error: e.error,
},
} satisfies IToolCompleteAction;
}
case 'idle':
return {
type: 'session/turnComplete',
session,
turnId,
} satisfies ITurnCompleteAction;
case 'error': {
const e = event as IAgentErrorEvent;
return {
type: 'session/error',
session,
turnId,
error: {
errorType: e.errorType,
message: e.message,
stack: e.stack,
},
} satisfies ISessionErrorAction;
}
case 'usage': {
const e = event as IAgentUsageEvent;
return {
type: 'session/usage',
session,
turnId,
usage: {
inputTokens: e.inputTokens,
outputTokens: e.outputTokens,
model: e.model,
cacheReadTokens: e.cacheReadTokens,
},
} satisfies IUsageAction;
}
case 'title_changed':
return {
type: 'session/titleChanged',
session,
title: (event as IAgentTitleChangedEvent).title,
} satisfies ITitleChangedAction;
case 'permission_request': {
const e = event as IAgentPermissionRequestEvent;
return {
type: 'session/permissionRequest',
session,
turnId,
request: {
requestId: e.requestId,
permissionKind: e.permissionKind,
toolCallId: e.toolCallId,
path: e.path,
fullCommandText: e.fullCommandText,
intention: e.intention,
serverName: e.serverName,
toolName: e.toolName,
rawRequest: e.rawRequest,
},
} satisfies IPermissionRequestAction;
}
case 'reasoning':
return {
type: 'session/reasoning',
session,
turnId,
content: (event as IAgentReasoningEvent).content,
} satisfies IReasoningAction;
case 'message':
return undefined;
default:
return undefined;
}
}
@@ -0,0 +1,286 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// Standalone agent host server with WebSocket protocol transport.
// Start with: node out/vs/platform/agentHost/node/agentHostServerMain.js [--port <port>] [--enable-mock-agent]
import { DisposableStore } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { localize } from '../../../nls.js';
import { NativeEnvironmentService } from '../../environment/node/environmentService.js';
import { INativeEnvironmentService } from '../../environment/common/environment.js';
import { parseArgs, OPTIONS } from '../../environment/node/argv.js';
import { getLogLevel, ILogService, NullLogService } from '../../log/common/log.js';
import { LogService } from '../../log/common/logService.js';
import { LoggerService } from '../../log/node/loggerService.js';
import product from '../../product/common/product.js';
import { IProductService } from '../../product/common/productService.js';
import { InstantiationService } from '../../instantiation/common/instantiationService.js';
import { ServiceCollection } from '../../instantiation/common/serviceCollection.js';
import { CopilotAgent } from './copilot/copilotAgent.js';
import { AgentSession, type AgentProvider, type IAgent } from '../common/agentService.js';
import { SessionStateManager } from './sessionStateManager.js';
import { WebSocketProtocolServer } from './webSocketTransport.js';
import { ProtocolServerHandler, type IProtocolSideEffectHandler } from './protocolServerHandler.js';
import { mapProgressEventToAction } from './agentEventMapper.js';
import {
ISessionModelInfo,
SessionStatus, type ISessionSummary
} from '../common/state/sessionState.js';
import type { ISessionAction } from '../common/state/sessionActions.js';
import type { ICreateSessionParams } from '../common/state/sessionProtocol.js';
// ---- Options ----------------------------------------------------------------
interface IServerOptions {
readonly port: number;
readonly enableMockAgent: boolean;
readonly quiet: boolean;
}
function parseServerOptions(): IServerOptions {
const argv = process.argv.slice(2);
const envPort = parseInt(process.env['VSCODE_AGENT_HOST_PORT'] ?? '8081', 10);
const portIdx = argv.indexOf('--port');
const port = portIdx >= 0 ? parseInt(argv[portIdx + 1], 10) : envPort;
const enableMockAgent = argv.includes('--enable-mock-agent');
const quiet = argv.includes('--quiet');
return { port, enableMockAgent, quiet };
}
// ---- Main -------------------------------------------------------------------
function main(): void {
const options = parseServerOptions();
const disposables = new DisposableStore();
// Services — production logging unless --quiet
let logService: ILogService;
let loggerService: LoggerService | undefined;
if (options.quiet) {
logService = new NullLogService();
} else {
const services = new ServiceCollection();
const productService: IProductService = { _serviceBrand: undefined, ...product };
services.set(IProductService, productService);
const args = parseArgs(process.argv.slice(2), OPTIONS);
const environmentService = new NativeEnvironmentService(args, productService);
services.set(INativeEnvironmentService, environmentService);
loggerService = new LoggerService(getLogLevel(environmentService), environmentService.logsHome);
const logger = loggerService.createLogger('agenthost-server', { name: localize('agentHostServer', "Agent Host Server") });
logService = disposables.add(new LogService(logger));
services.set(ILogService, logService);
}
logService.info('[AgentHostServer] Starting standalone agent host server');
// Create state manager
const stateManager = disposables.add(new SessionStateManager(logService));
// Agent registry — maps provider id to agent instance
const agents = new Map<AgentProvider, IAgent>();
function registerAgent(agent: IAgent): void {
agents.set(agent.id, agent);
disposables.add(agent.onDidSessionProgress(e => {
const turnId = stateManager.getActiveTurnId(e.session);
if (turnId) {
const action = mapProgressEventToAction(e, e.session, turnId);
if (action) {
stateManager.dispatchServerAction(action);
}
}
}));
// Publish agent to root state (models fetched async)
publishAgentsToRootState();
logService.info(`[AgentHostServer] Registered agent: ${agent.id}`);
}
async function publishAgentsToRootState(): Promise<void> {
const agentInfos = await Promise.all([...agents.values()].map(async a => {
const d = a.getDescriptor();
let models: ISessionModelInfo[];
try {
const rawModels = await a.listModels();
models = rawModels.map(m => ({
id: m.id, provider: m.provider, name: m.name,
maxContextWindow: m.maxContextWindow, supportsVision: m.supportsVision,
policyState: m.policyState,
}));
} catch {
models = [];
}
return { provider: d.provider, displayName: d.displayName, description: d.description, models };
}));
stateManager.dispatchServerAction({ type: 'root/agentsChanged', agents: agentInfos });
}
function getAgent(session: URI): IAgent | undefined {
const provider = AgentSession.provider(session);
return provider ? agents.get(provider) : agents.values().next().value;
}
// Register agents
if (!options.quiet) {
// Production agents (require DI)
const services = new ServiceCollection();
const productService: IProductService = { _serviceBrand: undefined, ...product };
services.set(IProductService, productService);
const args = parseArgs(process.argv.slice(2), OPTIONS);
const environmentService = new NativeEnvironmentService(args, productService);
services.set(INativeEnvironmentService, environmentService);
services.set(ILogService, logService);
const instantiationService = new InstantiationService(services);
const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent));
registerAgent(copilotAgent);
}
if (options.enableMockAgent) {
// Dynamic import to avoid bundling test code in production
import('../test/node/mockAgent.js').then(({ ScriptedMockAgent }) => {
const mockAgent = disposables.add(new ScriptedMockAgent());
registerAgent(mockAgent);
}).catch(err => {
logService.error('[AgentHostServer] Failed to load mock agent', err);
});
}
// WebSocket server
const wsServer = disposables.add(new WebSocketProtocolServer(options.port, logService));
// Side-effect handler — routes to the correct agent based on session URI
const sideEffects: IProtocolSideEffectHandler = {
handleAction(action: ISessionAction): void {
switch (action.type) {
case 'session/turnStarted': {
const agent = getAgent(action.session);
if (!agent) {
stateManager.dispatchServerAction({
type: 'session/error',
session: action.session,
turnId: action.turnId,
error: { errorType: 'noAgent', message: 'No agent found for session' },
});
return;
}
const attachments = action.userMessage.attachments?.map(a => ({
type: a.type,
path: a.path,
displayName: a.displayName,
}));
agent.sendMessage(action.session, action.userMessage.text, attachments).catch(err => {
logService.error('[AgentHostServer] sendMessage failed', err);
stateManager.dispatchServerAction({
type: 'session/error',
session: action.session,
turnId: action.turnId,
error: { errorType: 'sendFailed', message: String(err) },
});
});
break;
}
case 'session/permissionResolved': {
const agent = getAgent(action.session);
agent?.respondToPermissionRequest(action.requestId, action.approved);
break;
}
case 'session/turnCancelled': {
const agent = getAgent(action.session);
agent?.abortSession(action.session).catch(() => { });
break;
}
case 'session/modelChanged': {
const agent = getAgent(action.session);
agent?.changeModel?.(action.session, action.model).catch(err => {
logService.error('[AgentHostServer] changeModel failed', err);
});
break;
}
}
},
async handleCreateSession(command: ICreateSessionParams): Promise<void> {
const provider = (command.provider ?? agents.keys().next().value) as AgentProvider;
const agent = agents.get(provider);
if (!agent) {
throw new Error(`No agent registered for provider: ${provider}`);
}
const session = await agent.createSession({
provider,
model: command.model,
workingDirectory: command.workingDirectory,
});
const summary: ISessionSummary = {
resource: session,
provider,
title: 'Session',
status: SessionStatus.Idle,
createdAt: Date.now(),
modifiedAt: Date.now(),
};
stateManager.createSession(summary);
stateManager.dispatchServerAction({ type: 'session/ready', session });
},
handleDisposeSession(session: URI): void {
const agent = getAgent(session);
agent?.disposeSession(session).catch(() => { });
stateManager.removeSession(session);
},
async handleListSessions(): Promise<ISessionSummary[]> {
const allSessions: ISessionSummary[] = [];
for (const agent of agents.values()) {
const sessions = await agent.listSessions();
const provider = agent.id;
for (const s of sessions) {
allSessions.push({
resource: s.session,
provider,
title: s.summary ?? 'Session',
status: SessionStatus.Idle,
createdAt: s.startTime,
modifiedAt: s.modifiedTime,
});
}
}
return allSessions;
},
};
// Wire up protocol handler
disposables.add(new ProtocolServerHandler(stateManager, wsServer, sideEffects, logService));
// Report ready
const address = wsServer.address;
if (address) {
const listeningPort = address.split(':').pop();
process.stdout.write(`READY:${listeningPort}\n`);
logService.info(`[AgentHostServer] WebSocket server listening on ws://${address}`);
} else {
const interval = setInterval(() => {
const addr = wsServer.address;
if (addr) {
clearInterval(interval);
const listeningPort = addr.split(':').pop();
process.stdout.write(`READY:${listeningPort}\n`);
logService.info(`[AgentHostServer] WebSocket server listening on ws://${addr}`);
}
}, 10);
}
// Keep alive until stdin closes or signal
process.stdin.resume();
process.stdin.on('end', shutdown);
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
function shutdown(): void {
logService.info('[AgentHostServer] Shutting down...');
disposables.dispose();
loggerService?.dispose();
process.exit(0);
}
}
main();
+140 -52
View File
@@ -7,7 +7,15 @@ import { Emitter } from '../../../base/common/event.js';
import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { ILogService } from '../../log/common/log.js';
import { AgentProvider, IAgentAttachment, IAgentCreateSessionConfig, IAgentModelInfo, IAgentProgressEvent, IAgentMessageEvent, IAgent, IAgentService, IAgentSessionMetadata, IAgentToolStartEvent, IAgentToolCompleteEvent, AgentSession, IAgentDescriptor } from '../common/agentService.js';
import { AgentProvider, IAgentAttachment, IAgentCreateSessionConfig, IAgent, IAgentService, IAgentSessionMetadata, AgentSession, IAgentDescriptor } from '../common/agentService.js';
import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js';
import type { IStateSnapshot } from '../common/state/sessionProtocol.js';
import {
ISessionModelInfo,
SessionStatus, type ISessionSummary
} from '../common/state/sessionState.js';
import { mapProgressEventToAction } from './agentEventMapper.js';
import { SessionStateManager } from './sessionStateManager.js';
/**
* The agent service implementation that runs inside the agent-host utility
@@ -17,8 +25,16 @@ import { AgentProvider, IAgentAttachment, IAgentCreateSessionConfig, IAgentModel
export class AgentService extends Disposable implements IAgentService {
declare readonly _serviceBrand: undefined;
private readonly _onDidSessionProgress = this._register(new Emitter<IAgentProgressEvent>());
readonly onDidSessionProgress = this._onDidSessionProgress.event;
/** Protocol: fires when state is mutated by an action. */
private readonly _onDidAction = this._register(new Emitter<IActionEnvelope>());
readonly onDidAction = this._onDidAction.event;
/** Protocol: fires for ephemeral notifications (sessionAdded/Removed). */
private readonly _onDidNotification = this._register(new Emitter<INotification>());
readonly onDidNotification = this._onDidNotification.event;
/** Authoritative state manager for the sessions process protocol. */
private readonly _stateManager: SessionStateManager;
/** Registered providers keyed by their {@link AgentProvider} id. */
private readonly _providers = new Map<AgentProvider, IAgent>();
@@ -36,6 +52,9 @@ export class AgentService extends Disposable implements IAgentService {
) {
super();
this._logService.info('AgentService initialized');
this._stateManager = this._register(new SessionStateManager(_logService));
this._register(this._stateManager.onDidEmitEnvelope(e => this._onDidAction.fire(e)));
this._register(this._stateManager.onDidEmitNotification(e => this._onDidNotification.fire(e)));
}
// ---- provider registration ----------------------------------------------
@@ -48,16 +67,27 @@ export class AgentService extends Disposable implements IAgentService {
this._providers.set(provider.id, provider);
this._providerSubscriptions.add(
provider.onDidSessionProgress(e => {
// Track permission requests so respondToPermissionRequest can route
// Track permission requests so dispatchAction can route
if (e.type === 'permission_request') {
this._pendingPermissions.set(e.requestId, provider.id);
}
this._onDidSessionProgress.fire(e);
// Map to protocol action and dispatch through state manager
const turnId = this._stateManager.getActiveTurnId(e.session);
if (turnId) {
const action = mapProgressEventToAction(e, e.session, turnId);
if (action) {
this._stateManager.dispatchServerAction(action);
}
}
})
);
if (!this._defaultProvider) {
this._defaultProvider = provider.id;
}
// Update root state with current agents list
this._publishAgentsToRootState();
}
// ---- auth ---------------------------------------------------------------
@@ -87,14 +117,13 @@ export class AgentService extends Disposable implements IAgentService {
return flat;
}
async listModels(): Promise<IAgentModelInfo[]> {
this._logService.trace('[AgentService] listModels called');
const results = await Promise.all(
[...this._providers.values()].map(p => p.listModels())
);
const flat = results.flat();
this._logService.trace(`[AgentService] listModels returned ${flat.length} models`);
return flat;
/**
* Refreshes the model list from all providers and publishes the updated
* agents (with their models) to root state via `root/agentsChanged`.
*/
async refreshModels(): Promise<void> {
this._logService.trace('[AgentService] refreshModels called');
await this._publishAgentsToRootState();
}
async createSession(config?: IAgentCreateSessionConfig): Promise<URI> {
@@ -107,28 +136,22 @@ export class AgentService extends Disposable implements IAgentService {
const session = await provider.createSession(config);
this._sessionToProvider.set(session.toString(), provider.id);
this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`);
// Create state in the state manager
const summary: ISessionSummary = {
resource: session,
provider: provider.id,
title: 'New Session',
status: SessionStatus.Idle,
createdAt: Date.now(),
modifiedAt: Date.now(),
};
this._stateManager.createSession(summary);
this._stateManager.dispatchServerAction({ type: 'session/ready', session });
return session;
}
async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise<void> {
this._logService.trace(`[AgentService] sendMessage: session=${session.toString()}, prompt=${prompt.length} chars, attachments=${attachments?.length ?? 0}`);
const provider = this._getProviderForSession(session);
await provider.sendMessage(session, prompt, attachments);
this._logService.trace(`[AgentService] sendMessage returned for ${session.toString()}`);
}
async getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> {
this._logService.trace(`[AgentService] getSessionMessages: ${session.toString()}`);
const provider = this._findProviderForSession(session);
if (!provider) {
this._logService.trace(`[AgentService] getSessionMessages: no provider found, returning empty`);
return [];
}
const messages = await provider.getSessionMessages(session);
this._logService.trace(`[AgentService] getSessionMessages returned ${messages.length} events`);
return messages;
}
async disposeSession(session: URI): Promise<void> {
this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`);
const provider = this._findProviderForSession(session);
@@ -136,26 +159,76 @@ export class AgentService extends Disposable implements IAgentService {
await provider.disposeSession(session);
this._sessionToProvider.delete(session.toString());
}
this._stateManager.removeSession(session);
}
async abortSession(session: URI): Promise<void> {
this._logService.trace(`[AgentService] abortSession: ${session.toString()}`);
const provider = this._findProviderForSession(session);
if (provider) {
await provider.abortSession(session);
// ---- Protocol methods ---------------------------------------------------
async subscribe(resource: URI): Promise<IStateSnapshot> {
this._logService.trace(`[AgentService] subscribe: ${resource.toString()}`);
const snapshot = this._stateManager.getSnapshot(resource);
if (!snapshot) {
throw new Error(`Cannot subscribe to unknown resource: ${resource.toString()}`);
}
return snapshot;
}
respondToPermissionRequest(requestId: string, approved: boolean): void {
this._logService.trace(`[AgentService] respondToPermissionRequest: ${requestId} approved=${approved}`);
const providerId = this._pendingPermissions.get(requestId);
if (!providerId) {
this._logService.warn(`[AgentService] No pending permission request for: ${requestId}`);
return;
unsubscribe(resource: URI): void {
this._logService.trace(`[AgentService] unsubscribe: ${resource.toString()}`);
// Server-side tracking of per-client subscriptions will be added
// in Phase 4 (multi-client). For now this is a no-op.
}
dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void {
this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action);
const origin = { clientId, clientSeq };
const state = this._stateManager.dispatchClientAction(action, origin);
this._logService.trace(`[AgentService] resulting state:`, state);
// Trigger side effects based on the action type
switch (action.type) {
case 'session/turnStarted': {
const provider = this._findProviderForSession(action.session);
if (provider) {
const attachments = action.userMessage.attachments?.map(a => ({
type: a.type,
path: a.path,
displayName: a.displayName,
}) satisfies IAgentAttachment);
provider.sendMessage(action.session, action.userMessage.text, attachments).catch(err => {
this._logService.error(`[AgentService] sendMessage failed for session/turnStarted`, err);
this._stateManager.dispatchServerAction({
type: 'session/error',
session: action.session,
turnId: action.turnId,
error: { errorType: 'sendFailed', message: String(err) },
});
});
}
break;
}
case 'session/permissionResolved': {
const providerId = this._pendingPermissions.get(action.requestId);
if (providerId) {
this._pendingPermissions.delete(action.requestId);
const permProvider = this._providers.get(providerId);
permProvider?.respondToPermissionRequest(action.requestId, action.approved);
} else {
this._logService.warn(`[AgentService] No pending permission request for: ${action.requestId}`);
}
break;
}
case 'session/turnCancelled': {
const provider = this._findProviderForSession(action.session);
if (provider) {
provider.abortSession(action.session).catch(err => {
this._logService.error(`[AgentService] abortSession failed for session/turnCancelled`, err);
});
}
break;
}
}
this._pendingPermissions.delete(requestId);
const provider = this._providers.get(providerId);
provider?.respondToPermissionRequest(requestId, approved);
}
async shutdown(): Promise<void> {
@@ -170,12 +243,27 @@ export class AgentService extends Disposable implements IAgentService {
// ---- helpers ------------------------------------------------------------
private _getProviderForSession(session: URI): IAgent {
const provider = this._findProviderForSession(session);
if (!provider) {
throw new Error(`No provider found for session: ${session.toString()}`);
}
return provider;
/**
* Fetches models from all providers and dispatches `root/agentsChanged`
* with the merged agent + model data.
*/
private async _publishAgentsToRootState(): Promise<void> {
const agents = await Promise.all([...this._providers.values()].map(async p => {
const d = p.getDescriptor();
let models: ISessionModelInfo[];
try {
const rawModels = await p.listModels();
models = rawModels.map(m => ({
id: m.id, provider: m.provider, name: m.name,
maxContextWindow: m.maxContextWindow, supportsVision: m.supportsVision,
policyState: m.policyState,
}));
} catch {
models = [];
}
return { provider: d.provider, displayName: d.displayName, description: d.description, models };
}));
this._stateManager.dispatchServerAction({ type: 'root/agentsChanged', agents });
}
private _findProviderForSession(session: URI): IAgent | undefined {
@@ -0,0 +1,334 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { ILogService } from '../../log/common/log.js';
import { IActionEnvelope, INotification, isSessionAction } from '../common/state/sessionActions.js';
import { isActionKnownToVersion, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js';
import {
isJsonRpcRequest,
isJsonRpcNotification,
JSON_RPC_INTERNAL_ERROR,
type ICreateSessionParams,
type IDispatchActionParams,
type IDisposeSessionParams,
type IFetchTurnsParams,
type IInitializeParams,
type IProtocolMessage,
type IReconnectParams,
type IStateSnapshot,
type ISubscribeParams,
type IUnsubscribeParams,
} from '../common/state/sessionProtocol.js';
import { ROOT_STATE_URI } from '../common/state/sessionState.js';
import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js';
import { SessionStateManager } from './sessionStateManager.js';
/** Default capacity of the server-side action replay buffer. */
const REPLAY_BUFFER_CAPACITY = 1000;
/**
* Represents a connected protocol client with its subscription state.
*/
interface IConnectedClient {
readonly clientId: string;
readonly protocolVersion: number;
readonly transport: IProtocolTransport;
readonly subscriptions: Set<string>;
readonly disposables: DisposableStore;
}
/**
* Server-side handler that manages protocol connections, routes JSON-RPC
* messages to the state manager, and broadcasts actions/notifications
* to subscribed clients.
*/
export class ProtocolServerHandler extends Disposable {
private readonly _clients = new Map<string, IConnectedClient>();
private readonly _replayBuffer: IActionEnvelope[] = [];
constructor(
private readonly _stateManager: SessionStateManager,
private readonly _server: IProtocolServer,
private readonly _sideEffectHandler: IProtocolSideEffectHandler,
@ILogService private readonly _logService: ILogService,
) {
super();
this._register(this._server.onConnection(transport => {
this._handleNewConnection(transport);
}));
this._register(this._stateManager.onDidEmitEnvelope(envelope => {
this._replayBuffer.push(envelope);
if (this._replayBuffer.length > REPLAY_BUFFER_CAPACITY) {
this._replayBuffer.shift();
}
this._broadcastAction(envelope);
}));
this._register(this._stateManager.onDidEmitNotification(notification => {
this._broadcastNotification(notification);
}));
}
// ---- Connection handling -------------------------------------------------
private _handleNewConnection(transport: IProtocolTransport): void {
const disposables = new DisposableStore();
let client: IConnectedClient | undefined;
disposables.add(transport.onMessage(msg => {
if (isJsonRpcRequest(msg)) {
// Request — expects a correlated response
if (!client) {
return;
}
this._handleRequest(client, msg.method, msg.params, msg.id);
} else if (isJsonRpcNotification(msg)) {
// Notification — fire-and-forget
switch (msg.method) {
case 'initialize':
client = this._handleInitialize(msg.params as IInitializeParams, transport, disposables);
break;
case 'reconnect':
client = this._handleReconnect(msg.params as IReconnectParams, transport, disposables);
break;
case 'unsubscribe':
if (client) {
client.subscriptions.delete((msg.params as IUnsubscribeParams).resource.toString());
}
break;
case 'dispatchAction':
if (client) {
const params = msg.params as IDispatchActionParams;
const origin = { clientId: client.clientId, clientSeq: params.clientSeq };
this._stateManager.dispatchClientAction(params.action, origin);
this._sideEffectHandler.handleAction(params.action);
}
break;
}
}
// Responses from the client (if any) are ignored on the server side.
}));
disposables.add(transport.onClose(() => {
if (client) {
this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}`);
this._clients.delete(client.clientId);
}
disposables.dispose();
}));
disposables.add(transport);
}
// ---- Notifications (fire-and-forget) ------------------------------------
private _handleInitialize(
params: IInitializeParams,
transport: IProtocolTransport,
disposables: DisposableStore,
): IConnectedClient {
this._logService.info(`[ProtocolServer] Initialize: clientId=${params.clientId}, version=${params.protocolVersion}`);
const client: IConnectedClient = {
clientId: params.clientId,
protocolVersion: params.protocolVersion,
transport,
subscriptions: new Set(),
disposables,
};
this._clients.set(params.clientId, client);
const snapshots: IStateSnapshot[] = [];
if (params.initialSubscriptions) {
for (const uri of params.initialSubscriptions) {
const snapshot = this._stateManager.getSnapshot(uri);
if (snapshot) {
snapshots.push(snapshot);
client.subscriptions.add(uri.toString());
}
}
}
this._sendNotification(transport, 'serverHello', {
protocolVersion: PROTOCOL_VERSION,
serverSeq: this._stateManager.serverSeq,
snapshots,
});
return client;
}
private _handleReconnect(
params: IReconnectParams,
transport: IProtocolTransport,
disposables: DisposableStore,
): IConnectedClient {
this._logService.info(`[ProtocolServer] Reconnect: clientId=${params.clientId}, lastSeenSeq=${params.lastSeenServerSeq}`);
const client: IConnectedClient = {
clientId: params.clientId,
protocolVersion: PROTOCOL_VERSION,
transport,
subscriptions: new Set(),
disposables,
};
this._clients.set(params.clientId, client);
const oldestBuffered = this._replayBuffer.length > 0 ? this._replayBuffer[0].serverSeq : this._stateManager.serverSeq;
const canReplay = params.lastSeenServerSeq >= oldestBuffered;
if (canReplay) {
for (const sub of params.subscriptions) {
client.subscriptions.add(sub.toString());
}
for (const envelope of this._replayBuffer) {
if (envelope.serverSeq > params.lastSeenServerSeq) {
if (this._isRelevantToClient(client, envelope)) {
this._sendNotification(transport, 'action', { envelope });
}
}
}
} else {
const snapshots: IStateSnapshot[] = [];
for (const sub of params.subscriptions) {
const snapshot = this._stateManager.getSnapshot(sub);
if (snapshot) {
snapshots.push(snapshot);
client.subscriptions.add(sub.toString());
}
}
this._sendNotification(transport, 'reconnectResponse', {
serverSeq: this._stateManager.serverSeq,
snapshots,
});
}
return client;
}
// ---- Requests (expect a response) ---------------------------------------
private _handleRequest(client: IConnectedClient, method: string, params: unknown, id: number): void {
this._handleRequestAsync(client, method, params).then(result => {
client.transport.send({ jsonrpc: '2.0', id, result: result ?? null });
}).catch(err => {
this._logService.error(`[ProtocolServer] Request '${method}' failed`, err);
client.transport.send({
jsonrpc: '2.0',
id,
error: { code: JSON_RPC_INTERNAL_ERROR, message: String(err?.message ?? err) },
});
});
}
private async _handleRequestAsync(client: IConnectedClient, method: string, params: unknown): Promise<unknown> {
switch (method) {
case 'subscribe': {
const p = params as ISubscribeParams;
const snapshot = this._stateManager.getSnapshot(p.resource);
if (snapshot) {
client.subscriptions.add(p.resource.toString());
}
return snapshot ?? null;
}
case 'createSession': {
await this._sideEffectHandler.handleCreateSession(params as ICreateSessionParams);
return null;
}
case 'disposeSession': {
this._sideEffectHandler.handleDisposeSession((params as IDisposeSessionParams).session);
return null;
}
case 'listSessions': {
const sessions = await this._sideEffectHandler.handleListSessions();
return { sessions };
}
case 'fetchTurns': {
const p = params as IFetchTurnsParams;
const state = this._stateManager.getSessionState(p.session);
if (state) {
const turns = state.turns;
const start = Math.max(0, p.startTurn);
const end = Math.min(turns.length, start + p.count);
return {
session: p.session,
startTurn: start,
turns: turns.slice(start, end),
totalTurns: turns.length,
};
}
return {
session: p.session,
startTurn: p.startTurn,
turns: [],
totalTurns: 0,
};
}
default:
throw new Error(`Unknown method: ${method}`);
}
}
// ---- Broadcasting -------------------------------------------------------
private _sendNotification(transport: IProtocolTransport, method: string, params: unknown): void {
transport.send({ jsonrpc: '2.0', method, params });
}
private _broadcastAction(envelope: IActionEnvelope): void {
const msg: IProtocolMessage = { jsonrpc: '2.0', method: 'action', params: { envelope } };
for (const client of this._clients.values()) {
if (this._isRelevantToClient(client, envelope)) {
client.transport.send(msg);
}
}
}
private _broadcastNotification(notification: INotification): void {
const msg: IProtocolMessage = { jsonrpc: '2.0', method: 'notification', params: { notification } };
for (const client of this._clients.values()) {
client.transport.send(msg);
}
}
private _isRelevantToClient(client: IConnectedClient, envelope: IActionEnvelope): boolean {
const action = envelope.action;
if (!isActionKnownToVersion(action, client.protocolVersion)) {
return false;
}
if (action.type.startsWith('root/')) {
return client.subscriptions.has(ROOT_STATE_URI.toString());
}
if (isSessionAction(action)) {
return client.subscriptions.has(action.session.toString());
}
return false;
}
override dispose(): void {
for (const client of this._clients.values()) {
client.disposables.dispose();
}
this._clients.clear();
this._replayBuffer.length = 0;
super.dispose();
}
}
/**
* Interface for side effects that the protocol server delegates to.
* These are operations that involve I/O, agent backends, etc.
*/
export interface IProtocolSideEffectHandler {
handleAction(action: import('../common/state/sessionActions.js').ISessionAction): void;
handleCreateSession(command: import('../common/state/sessionProtocol.js').ICreateSessionParams): Promise<void>;
handleDisposeSession(session: URI): void;
handleListSessions(): Promise<import('../common/state/sessionState.js').ISessionSummary[]>;
}
@@ -0,0 +1,218 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { ILogService } from '../../log/common/log.js';
import { IActionEnvelope, IActionOrigin, INotification, ISessionAction, IRootAction, IStateAction, isRootAction, isSessionAction } from '../common/state/sessionActions.js';
import { IStateSnapshot } from '../common/state/sessionProtocol.js';
import { rootReducer, sessionReducer } from '../common/state/sessionReducers.js';
import { createRootState, createSessionState, IRootState, ISessionState, ISessionSummary, ROOT_STATE_URI } from '../common/state/sessionState.js';
/**
* Server-side state manager for the sessions process protocol.
*
* Maintains the authoritative state tree (root + per-session), applies actions
* through pure reducers, assigns monotonic sequence numbers, and emits
* {@link IActionEnvelope}s for subscribed clients.
*/
export class SessionStateManager extends Disposable {
private _serverSeq = 0;
private _rootState: IRootState;
private readonly _sessionStates = new Map<string, ISessionState>();
/** Tracks which session URI each active turn belongs to, keyed by turnId. */
private readonly _activeTurnToSession = new Map<string, string>();
private readonly _onDidEmitEnvelope = this._register(new Emitter<IActionEnvelope>());
readonly onDidEmitEnvelope: Event<IActionEnvelope> = this._onDidEmitEnvelope.event;
private readonly _onDidEmitNotification = this._register(new Emitter<INotification>());
readonly onDidEmitNotification: Event<INotification> = this._onDidEmitNotification.event;
constructor(
@ILogService private readonly _logService: ILogService,
) {
super();
this._rootState = createRootState();
}
// ---- State accessors ----------------------------------------------------
get rootState(): IRootState {
return this._rootState;
}
getSessionState(session: URI): ISessionState | undefined {
return this._sessionStates.get(session.toString());
}
get serverSeq(): number {
return this._serverSeq;
}
// ---- Snapshots ----------------------------------------------------------
/**
* Returns a state snapshot for a given resource URI.
* The `fromSeq` in the snapshot is the current serverSeq at snapshot time;
* the client should process subsequent envelopes with serverSeq > fromSeq.
*/
getSnapshot(resource: URI): IStateSnapshot | undefined {
const key = resource.toString();
if (key === ROOT_STATE_URI.toString()) {
return {
resource,
state: this._rootState,
fromSeq: this._serverSeq,
};
}
const sessionState = this._sessionStates.get(key);
if (!sessionState) {
return undefined;
}
return {
resource,
state: sessionState,
fromSeq: this._serverSeq,
};
}
// ---- Session lifecycle --------------------------------------------------
/**
* Creates a new session in state with `lifecycle: 'creating'`.
* Returns the initial session state.
*/
createSession(summary: ISessionSummary): ISessionState {
const key = summary.resource.toString();
if (this._sessionStates.has(key)) {
this._logService.warn(`[SessionStateManager] Session already exists: ${key}`);
return this._sessionStates.get(key)!;
}
const state = createSessionState(summary);
this._sessionStates.set(key, state);
this._logService.trace(`[SessionStateManager] Created session: ${key}`);
this._onDidEmitNotification.fire({
type: 'notify/sessionAdded',
summary,
});
return state;
}
/**
* Removes a session from state and emits a sessionRemoved notification.
*/
removeSession(session: URI): void {
const key = session.toString();
const state = this._sessionStates.get(key);
if (!state) {
return;
}
// Clean up active turn tracking
if (state.activeTurn) {
this._activeTurnToSession.delete(state.activeTurn.id);
}
this._sessionStates.delete(key);
this._logService.trace(`[SessionStateManager] Removed session: ${key}`);
this._onDidEmitNotification.fire({
type: 'notify/sessionRemoved',
session,
});
}
// ---- Turn tracking ------------------------------------------------------
/**
* Registers a mapping from turnId to session URI so that incoming
* provider events (which carry only session URI) can be associated
* with the correct active turn.
*/
getActiveTurnId(session: URI): string | undefined {
const state = this._sessionStates.get(session.toString());
return state?.activeTurn?.id;
}
// ---- Action dispatch ----------------------------------------------------
/**
* Dispatch a server-originated action (from the agent backend).
* The action is applied to state via the reducer and emitted as an
* envelope with no origin (server-produced).
*/
dispatchServerAction(action: IStateAction): void {
this._applyAndEmit(action, undefined);
}
/**
* Dispatch a client-originated action (write-ahead from a renderer).
* The action is applied to state and emitted with the client's origin
* so the originating client can reconcile.
*/
dispatchClientAction(action: ISessionAction, origin: IActionOrigin): unknown {
return this._applyAndEmit(action, origin);
}
// ---- Internal -----------------------------------------------------------
private _applyAndEmit(action: IStateAction, origin: IActionOrigin | undefined): unknown {
let resultingState: unknown = undefined;
// Apply to state
if (isRootAction(action)) {
this._rootState = rootReducer(this._rootState, action as IRootAction);
resultingState = this._rootState;
}
if (isSessionAction(action)) {
const sessionAction = action as ISessionAction;
const key = sessionAction.session.toString();
const state = this._sessionStates.get(key);
if (state) {
const newState = sessionReducer(state, sessionAction);
this._sessionStates.set(key, newState);
// Track active turn for turn lifecycle
if (sessionAction.type === 'session/turnStarted') {
this._activeTurnToSession.set(sessionAction.turnId, key);
} else if (
sessionAction.type === 'session/turnComplete' ||
sessionAction.type === 'session/turnCancelled' ||
sessionAction.type === 'session/error'
) {
this._activeTurnToSession.delete(sessionAction.turnId);
}
resultingState = newState;
} else {
this._logService.warn(`[SessionStateManager] Action for unknown session: ${key}, type=${action.type}`);
}
}
// Emit envelope
const envelope: IActionEnvelope = {
action,
serverSeq: ++this._serverSeq,
origin,
};
this._logService.trace(`[SessionStateManager] Emitting envelope: seq=${envelope.serverSeq}, type=${action.type}${origin ? `, origin=${origin.clientId}:${origin.clientSeq}` : ''}`);
this._onDidEmitEnvelope.fire(envelope);
return resultingState;
}
}
@@ -0,0 +1,135 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// WebSocket transport for the sessions process protocol.
// Uses JSON serialization with URI revival for cross-process communication.
import { WebSocketServer, WebSocket } from 'ws';
import { Emitter } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { ILogService } from '../../log/common/log.js';
import type { IProtocolMessage } from '../common/state/sessionProtocol.js';
import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js';
// ---- JSON serialization helpers ---------------------------------------------
function uriReplacer(_key: string, value: unknown): unknown {
if (value instanceof URI) {
return value.toJSON();
}
if (value instanceof Map) {
return { $type: 'Map', entries: [...value.entries()] };
}
return value;
}
function uriReviver(_key: string, value: unknown): unknown {
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
if (obj.$mid === 1) {
return URI.revive(value as URI);
}
if (obj.$type === 'Map' && Array.isArray(obj.entries)) {
return new Map(obj.entries as [unknown, unknown][]);
}
}
return value;
}
// ---- Per-connection transport -----------------------------------------------
/**
* Wraps a single WebSocket connection as an {@link IProtocolTransport}.
* Messages are serialized as JSON with URI revival.
*/
export class WebSocketProtocolTransport extends Disposable implements IProtocolTransport {
private readonly _onMessage = this._register(new Emitter<IProtocolMessage>());
readonly onMessage = this._onMessage.event;
private readonly _onClose = this._register(new Emitter<void>());
readonly onClose = this._onClose.event;
constructor(private readonly _ws: WebSocket) {
super();
this._ws.on('message', (data: Buffer | string) => {
try {
const text = typeof data === 'string' ? data : data.toString('utf-8');
const message = JSON.parse(text, uriReviver) as IProtocolMessage;
this._onMessage.fire(message);
} catch {
// Malformed message — drop. No logger available at transport level.
}
});
this._ws.on('close', () => {
this._onClose.fire();
});
this._ws.on('error', () => {
// Error always precedes close — closing is handled in the close handler.
this._onClose.fire();
});
}
send(message: IProtocolMessage): void {
if (this._ws.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify(message, uriReplacer));
}
}
override dispose(): void {
this._ws.close();
super.dispose();
}
}
// ---- Server -----------------------------------------------------------------
/**
* WebSocket server that accepts client connections and wraps each one
* as an {@link IProtocolTransport}.
*/
export class WebSocketProtocolServer extends Disposable implements IProtocolServer {
private readonly _wss: WebSocketServer;
private readonly _onConnection = this._register(new Emitter<IProtocolTransport>());
readonly onConnection = this._onConnection.event;
get address(): string | undefined {
const addr = this._wss.address();
if (!addr || typeof addr === 'string') {
return addr ?? undefined;
}
return `${addr.address}:${addr.port}`;
}
constructor(
private readonly _port: number,
@ILogService private readonly _logService: ILogService,
) {
super();
this._wss = new WebSocketServer({ port: this._port, host: '127.0.0.1' });
this._logService.info(`[WebSocketProtocol] Server listening on 127.0.0.1:${this._port}`);
this._wss.on('connection', (ws) => {
this._logService.trace('[WebSocketProtocol] New client connection');
const transport = new WebSocketProtocolTransport(ws);
this._onConnection.fire(transport);
});
this._wss.on('error', (err) => {
this._logService.error('[WebSocketProtocol] Server error', err);
});
}
override dispose(): void {
this._wss.close();
super.dispose();
}
}
+122 -15
View File
@@ -2,6 +2,8 @@
> **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](architecture.md). For design decisions, see [design.md](design.md). For the task backlog, see [backlog.md](backlog.md).
## Goal
@@ -13,11 +15,62 @@ The sessions process is a portable, standalone server that multiple clients can
3. **Write-ahead with reconciliation** — clients optimistically apply their own actions locally, then reconcile when the server echoes them back alongside any concurrent actions from other clients or the server itself.
4. **Forward-compatible versioning** — newer clients can connect to older servers. A single protocol version number maps to a capabilities object; clients check capabilities before using features.
## Protocol development checklist
Use this checklist when adding a new action, command, state field, or notification to the protocol.
### Adding a new action type
1. **Write an E2E test first** in `protocolWebSocket.integrationTest.ts` that exercises the action end-to-end through the WebSocket server. The test should fail until the implementation is complete.
2. **Add mock agent support** if the test needs a new prompt/behavior in `mockAgent.ts`.
3. **Define the action interface** in `sessionActions.ts`. Extend `ISessionActionBase` (for session-scoped) or define a standalone root action. Add it to the `ISessionAction` or `IRootAction` union.
4. **Add a reducer case** in `sessionReducers.ts`. The switch must remain exhaustive — the compiler will error if a case is missing.
5. **Add a v1 wire type** in `versions/v1.ts`. Mirror the action interface shape. Add it to the `IV1_SessionAction` or `IV1_RootAction` union.
6. **Register in `versionRegistry.ts`**:
- Import the new `IV1_*` type.
- Add an `AssertCompatible` check.
- Add the type to the `ISessionAction_v1` union.
- Add the type string to the suppress-warnings `void` expression.
- Add an entry to `ACTION_INTRODUCED_IN` (compiler enforces this).
7. **Update `protocol.md`** (this file) — add the action to the Actions table.
8. **Verify the E2E test passes.**
### Adding a new command
1. **Write an E2E test first** in `protocolWebSocket.integrationTest.ts`. The test should fail until the implementation is complete.
2. **Define the request params and result interfaces** in `sessionProtocol.ts`.
3. **Handle it in `protocolServerHandler.ts`** `_handleRequestAsync()`. The method returns the result; the caller wraps it in a JSON-RPC response or error automatically.
4. **Add the side-effect** in `IProtocolSideEffectHandler` if the command requires I/O or agent interaction. Implement it in `agentHostServerMain.ts`.
5. **Update `protocol.md`** — add the command to the Commands table.
6. **Verify the E2E test passes.**
### Adding a new state field
1. **Add the field** to the relevant interface in `sessionState.ts` (e.g. `ISessionSummary`, `IActiveTurn`, `ITurn`).
2. **Update the factory** (`createSessionState()`, `createActiveTurn()`) to initialize the field.
3. **Add to the v1 wire type** in `versions/v1.ts`. Optional fields are safe; required fields break the bidirectional `AssertCompatible` check (intentionally — add as optional or bump the protocol version).
4. **Update reducers** in `sessionReducers.ts` if the field needs to be mutated by actions.
5. **Update `finalizeTurn()`** if the field lives on `IActiveTurn` and should transfer to `ITurn` on completion.
### Adding a new notification
1. **Write an E2E test first** in `protocolWebSocket.integrationTest.ts`.
2. **Define the notification interface** in `sessionActions.ts`. Add it to the `INotification` union.
3. **Add to `NOTIFICATION_INTRODUCED_IN`** in `versionRegistry.ts`.
4. **Emit it** from `SessionStateManager` or the relevant server-side code.
5. **Verify the E2E test passes.**
### Adding mock agent support (for testing)
1. **Add a prompt case** in `mockAgent.ts` `sendMessage()` to trigger the behavior.
2. **Fire the corresponding `IAgentProgressEvent`** via `_fireSequence()` or manually through `_onDidSessionProgress`.
## URI-based subscriptions
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:
- **Root state** (`agenthost:root`) — always-present global state (agents, models). Clients subscribe to this on connect.
- **Root state** (`agenthost:root`) — always-present global state (agents and their models). Clients subscribe to this on connect.
- **Session state** (`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.
@@ -31,6 +84,16 @@ Subscribable at `agenthost:root`. Contains global, lightweight data that all cli
```
RootState {
agents: AgentInfo[]
}
```
Each `AgentInfo` includes the models available for that agent:
```
AgentInfo {
provider: string
displayName: string
description: string
models: ModelInfo[]
}
```
@@ -69,6 +132,7 @@ ActiveTurn {
toolCalls: Map<toolCallId, ToolCallState>
pendingPermissions: Map<requestId, PermissionRequest>
reasoning: string
usage: UsageInfo | undefined
}
```
@@ -114,8 +178,7 @@ These mutate the root state. **All root actions are server-only** — clients ob
| Type | Payload | When |
|---|---|---|
| `root/modelsChanged` | `ModelInfo[]` | Available models changed |
| `root/agentsChanged` | `AgentInfo[]` | Available agent backends changed |
| `root/agentsChanged` | `AgentInfo[]` | Available agent backends or their models changed |
### Session actions
@@ -140,6 +203,7 @@ When a client dispatches an action, the server applies it to the state and also
| `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
@@ -190,45 +254,88 @@ Clients interact with the server in two ways:
## Client-server protocol
The protocol uses **JSON-RPC 2.0** framing over the transport (WebSocket, MessagePort, etc.).
### Message categories
- **Client → Server notifications** (fire-and-forget): `initialize`, `reconnect`, `unsubscribe`, `dispatchAction`
- **Client → Server requests** (expect a correlated response): `subscribe`, `createSession`, `disposeSession`, `listSessions`, `fetchTurns`, `fetchContent`
- **Server → Client notifications** (pushed): `serverHello`, `reconnectResponse`, `action`, `notification`
- **Server → Client responses** (correlated to requests by `id`): success result or JSON-RPC error
### Connection handshake
```
1. Client → Server: ClientHello { protocolVersion, clientId, initialSubscriptions?: URI[] }
2. Server → Client: ServerHello { protocolVersion, serverSeq, snapshots[] }
1. Client → Server: { "jsonrpc": "2.0", "method": "initialize", "params": { protocolVersion, clientId, initialSubscriptions? } }
2. Server → Client: { "jsonrpc": "2.0", "method": "serverHello", "params": { protocolVersion, serverSeq, snapshots[] } }
```
`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 responds with snapshots for each.
### URI subscription
After handshake, clients can subscribe/unsubscribe at any time:
`subscribe` is a JSON-RPC **request** — the client receives the snapshot as the response result:
```
Client → Server: Subscribe { resource: URI }
Server → Client: StateSnapshot { resource: URI, state, fromSeq }
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: Unsubscribe { resource: URI }
Client → Server: { "jsonrpc": "2.0", "method": "unsubscribe", "params": { "resource": "copilot:/session-1" } }
```
### Action delivery
The server broadcasts `ActionEnvelope`s to subscribed clients:
The server broadcasts action envelopes as JSON-RPC notifications:
```
Server → Client: { "jsonrpc": "2.0", "method": "action", "params": { "envelope": { action, serverSeq, origin } } }
```
- Root actions go to all clients subscribed to root state.
- Session actions go to all clients subscribed to that session's URI.
Notifications go to all connected clients (no subscription required).
Protocol notifications (sessionAdded/sessionRemoved) are broadcast similarly:
```
Server → Client: { "jsonrpc": "2.0", "method": "notification", "params": { "notification": { type, ... } } }
```
### Commands as JSON-RPC requests
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" } }
```
### Client-dispatched actions
Actions are sent as notifications (fire-and-forget, write-ahead):
```
Client → Server: { "jsonrpc": "2.0", "method": "dispatchAction", "params": { clientSeq, action } }
```
### Reconnection
```
Client → Server: ClientReconnect { clientId, lastSeenServerSeq, subscriptions: URI[] }
Client → Server: { "jsonrpc": "2.0", "method": "reconnect", "params": { clientId, lastSeenServerSeq, subscriptions } }
```
Server replays actions since `lastSeenServerSeq` from a bounded replay buffer. If the gap exceeds the buffer, sends fresh snapshots. Notifications are **not** replayed — the client should re-fetch the session list.
Server replays actions since `lastSeenServerSeq` from a bounded replay buffer. If the gap exceeds the buffer, sends fresh snapshots via a `reconnectResponse` notification. Notifications are **not** replayed — the client should re-fetch the session list.
## Write-ahead reconciliation
@@ -303,7 +410,7 @@ The registry also maintains an exhaustive runtime map:
```typescript
export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: number } = {
'root/modelsChanged': 1,
'root/agentsChanged': 1,
'session/turnStarted': 1,
// ...every action type must have an entry
};
@@ -383,7 +490,7 @@ 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 # Protocol messages (handshake, subscribe, reconnect, RPC)
├── 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/
@@ -0,0 +1,221 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import type {
IAgentDeltaEvent,
IAgentErrorEvent,
IAgentIdleEvent,
IAgentMessageEvent,
IAgentPermissionRequestEvent,
IAgentReasoningEvent,
IAgentTitleChangedEvent,
IAgentToolCompleteEvent,
IAgentToolStartEvent,
IAgentUsageEvent,
} from '../../common/agentService.js';
import type {
IDeltaAction,
IPermissionRequestAction,
IReasoningAction,
ISessionErrorAction,
ITitleChangedAction,
IToolCompleteAction,
IToolStartAction,
ITurnCompleteAction,
IUsageAction,
} from '../../common/state/sessionActions.js';
import { ToolCallStatus } from '../../common/state/sessionState.js';
import { mapProgressEventToAction } from '../../node/agentEventMapper.js';
suite('AgentEventMapper', () => {
const session = URI.from({ scheme: 'copilot', path: '/test-session' });
const turnId = 'turn-1';
ensureNoDisposablesAreLeakedInTestSuite();
test('delta event maps to session/delta action', () => {
const event: IAgentDeltaEvent = {
session,
type: 'delta',
messageId: 'msg-1',
content: 'hello world',
};
const action = mapProgressEventToAction(event, session, turnId);
assert.ok(action);
assert.strictEqual(action.type, 'session/delta');
const delta = action as IDeltaAction;
assert.strictEqual(delta.content, 'hello world');
assert.strictEqual(delta.session.toString(), session.toString());
assert.strictEqual(delta.turnId, turnId);
});
test('tool_start event maps to session/toolStart action', () => {
const event: IAgentToolStartEvent = {
session,
type: 'tool_start',
toolCallId: 'tc-1',
toolName: 'readFile',
displayName: 'Read File',
invocationMessage: 'Reading file...',
toolInput: '/src/foo.ts',
toolKind: 'terminal',
language: 'shellscript',
};
const action = mapProgressEventToAction(event, session, turnId);
assert.ok(action);
assert.strictEqual(action.type, 'session/toolStart');
const toolCall = (action as IToolStartAction).toolCall;
assert.strictEqual(toolCall.toolCallId, 'tc-1');
assert.strictEqual(toolCall.toolName, 'readFile');
assert.strictEqual(toolCall.displayName, 'Read File');
assert.strictEqual(toolCall.invocationMessage, 'Reading file...');
assert.strictEqual(toolCall.toolInput, '/src/foo.ts');
assert.strictEqual(toolCall.toolKind, 'terminal');
assert.strictEqual(toolCall.language, 'shellscript');
assert.strictEqual(toolCall.status, ToolCallStatus.Running);
});
test('tool_complete event maps to session/toolComplete action', () => {
const event: IAgentToolCompleteEvent = {
session,
type: 'tool_complete',
toolCallId: 'tc-1',
success: true,
pastTenseMessage: 'Read file successfully',
toolOutput: 'file contents here',
};
const action = mapProgressEventToAction(event, session, turnId);
assert.ok(action);
assert.strictEqual(action.type, 'session/toolComplete');
const complete = action as IToolCompleteAction;
assert.strictEqual(complete.toolCallId, 'tc-1');
assert.strictEqual(complete.result.success, true);
assert.strictEqual(complete.result.pastTenseMessage, 'Read file successfully');
assert.strictEqual(complete.result.toolOutput, 'file contents here');
});
test('idle event maps to session/turnComplete action', () => {
const event: IAgentIdleEvent = {
session,
type: 'idle',
};
const action = mapProgressEventToAction(event, session, turnId);
assert.ok(action);
assert.strictEqual(action.type, 'session/turnComplete');
const turnComplete = action as ITurnCompleteAction;
assert.strictEqual(turnComplete.session.toString(), session.toString());
assert.strictEqual(turnComplete.turnId, turnId);
});
test('error event maps to session/error action', () => {
const event: IAgentErrorEvent = {
session,
type: 'error',
errorType: 'runtime',
message: 'Something went wrong',
stack: 'Error: Something went wrong\n at foo.ts:1',
};
const action = mapProgressEventToAction(event, session, turnId);
assert.ok(action);
assert.strictEqual(action.type, 'session/error');
const errorAction = action as ISessionErrorAction;
assert.strictEqual(errorAction.error.errorType, 'runtime');
assert.strictEqual(errorAction.error.message, 'Something went wrong');
assert.strictEqual(errorAction.error.stack, 'Error: Something went wrong\n at foo.ts:1');
});
test('usage event maps to session/usage action', () => {
const event: IAgentUsageEvent = {
session,
type: 'usage',
inputTokens: 100,
outputTokens: 50,
model: 'gpt-4',
cacheReadTokens: 25,
};
const action = mapProgressEventToAction(event, session, turnId);
assert.ok(action);
assert.strictEqual(action.type, 'session/usage');
const usageAction = action as IUsageAction;
assert.strictEqual(usageAction.usage.inputTokens, 100);
assert.strictEqual(usageAction.usage.outputTokens, 50);
assert.strictEqual(usageAction.usage.model, 'gpt-4');
assert.strictEqual(usageAction.usage.cacheReadTokens, 25);
});
test('title_changed event maps to session/titleChanged action', () => {
const event: IAgentTitleChangedEvent = {
session,
type: 'title_changed',
title: 'New Title',
};
const action = mapProgressEventToAction(event, session, turnId);
assert.ok(action);
assert.strictEqual(action.type, 'session/titleChanged');
assert.strictEqual((action as ITitleChangedAction).title, 'New Title');
});
test('permission_request event maps to session/permissionRequest action', () => {
const event: IAgentPermissionRequestEvent = {
session,
type: 'permission_request',
requestId: 'perm-1',
permissionKind: 'shell',
toolCallId: 'tc-2',
fullCommandText: 'rm -rf /',
intention: 'Delete all files',
rawRequest: '{}',
};
const action = mapProgressEventToAction(event, session, turnId);
assert.ok(action);
assert.strictEqual(action.type, 'session/permissionRequest');
const req = (action as IPermissionRequestAction).request;
assert.strictEqual(req.requestId, 'perm-1');
assert.strictEqual(req.permissionKind, 'shell');
assert.strictEqual(req.toolCallId, 'tc-2');
assert.strictEqual(req.fullCommandText, 'rm -rf /');
assert.strictEqual(req.intention, 'Delete all files');
});
test('reasoning event maps to session/reasoning action', () => {
const event: IAgentReasoningEvent = {
session,
type: 'reasoning',
content: 'Let me think about this...',
};
const action = mapProgressEventToAction(event, session, turnId);
assert.ok(action);
assert.strictEqual(action.type, 'session/reasoning');
const reasoning = action as IReasoningAction;
assert.strictEqual(reasoning.content, 'Let me think about this...');
assert.strictEqual(reasoning.turnId, turnId);
});
test('message event returns undefined', () => {
const event: IAgentMessageEvent = {
session,
type: 'message',
role: 'assistant',
messageId: 'msg-1',
content: 'Some full message',
};
const action = mapProgressEventToAction(event, session, turnId);
assert.strictEqual(action, undefined);
});
});
@@ -10,6 +10,7 @@ import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { NullLogService } from '../../../log/common/log.js';
import { AgentSession, IAgent, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent, AgentProvider } from '../../common/agentService.js';
import { IActionEnvelope } from '../../common/state/sessionActions.js';
import { AgentService } from '../../node/agentService.js';
class MockAgent implements IAgent {
@@ -107,16 +108,21 @@ suite('AgentService (node dispatcher)', () => {
assert.throws(() => service.registerProvider(duplicate), /already registered/);
});
test('forwards progress events from registered providers', async () => {
test('maps progress events to protocol actions via onDidAction', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
const events: IAgentProgressEvent[] = [];
disposables.add(service.onDidSessionProgress(e => events.push(e)));
// Start a turn so there's an active turn to map events to
service.dispatchAction(
{ type: 'session/turnStarted', session, turnId: 'turn-1', userMessage: { text: 'hello' } },
'test-client', 1,
);
const envelopes: IActionEnvelope[] = [];
disposables.add(service.onDidAction(e => envelopes.push(e)));
copilotAgent.fireProgress({ session, type: 'delta', messageId: 'msg-1', content: 'hello' });
assert.strictEqual(events.length, 1);
assert.strictEqual(events[0].type, 'delta');
assert.ok(envelopes.some(e => e.action.type === 'session/delta'));
});
});
@@ -161,39 +167,6 @@ suite('AgentService (node dispatcher)', () => {
});
});
// ---- sendMessage ----------------------------------------------------
suite('sendMessage', () => {
test('dispatches to the correct provider based on session tracking', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
await service.sendMessage(session, 'hello');
assert.strictEqual(copilotAgent.sendMessageCalls.length, 1);
assert.strictEqual(copilotAgent.sendMessageCalls[0].prompt, 'hello');
});
test('infers provider from URI scheme for untracked sessions', async () => {
service.registerProvider(copilotAgent);
const session = AgentSession.uri('copilot', 'external-session');
await service.sendMessage(session, 'hello from untracked');
assert.strictEqual(copilotAgent.sendMessageCalls.length, 1);
});
test('falls back to default provider for unrecognized URI scheme', async () => {
service.registerProvider(copilotAgent);
const unknownSession = URI.from({ scheme: 'unknown', path: '/sess-1' });
// Should not throw -- falls back to the default provider
await service.sendMessage(unknownSession, 'hello');
assert.strictEqual(copilotAgent.sendMessageCalls.length, 1);
});
});
// ---- disposeSession -------------------------------------------------
suite('disposeSession', () => {
@@ -243,12 +216,16 @@ suite('AgentService (node dispatcher)', () => {
assert.strictEqual(sessions.length, 1);
});
test('listModels aggregates models from all providers', async () => {
test('refreshModels publishes models in root state via agentsChanged', async () => {
service.registerProvider(copilotAgent);
const models = await service.listModels();
assert.strictEqual(models.length, 1);
assert.ok(models.some(m => m.provider === 'copilot'));
const envelopes: IActionEnvelope[] = [];
disposables.add(service.onDidAction(e => envelopes.push(e)));
await service.refreshModels();
const agentsChanged = envelopes.find(e => e.action.type === 'root/agentsChanged');
assert.ok(agentsChanged);
});
});
@@ -0,0 +1,241 @@
#!/usr/bin/env bash
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------
# Launches Code OSS, switches to Local Agent mode, sends a chat message,
# waits for the response, and prints it to stdout.
#
# Usage:
# ./createAndSendMessageAsLocalAgent.sh "Hello, what can you do?"
# ./createAndSendMessageAsLocalAgent.sh --port 9225 "Explain this code"
#
# Options:
# --port <N> CDP debugging port (default: 9224)
# --timeout <N> Seconds to wait for response (default: 30)
# --no-kill Don't kill Code OSS after the test
# --skip-launch Assume Code OSS is already running on the given port
#
# Requires: agent-browser (npm install -g agent-browser, or use npx)
set -e
ROOT="$(cd "$(dirname "$0")/../../../../../.." && pwd)"
CDP_PORT=9224
RESPONSE_TIMEOUT=30
KILL_AFTER=true
SKIP_LAUNCH=false
MESSAGE=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--port)
CDP_PORT="$2"
shift 2
;;
--timeout)
RESPONSE_TIMEOUT="$2"
shift 2
;;
--no-kill)
KILL_AFTER=false
shift
;;
--skip-launch)
SKIP_LAUNCH=true
shift
;;
-*)
echo "Unknown option: $1" >&2
exit 1
;;
*)
MESSAGE="$1"
shift
;;
esac
done
if [ -z "$MESSAGE" ]; then
echo "Usage: $0 [--port <N>] [--timeout <N>] [--no-kill] [--skip-launch] <message>" >&2
exit 1
fi
AB="npx agent-browser"
cleanup() {
if [ "$KILL_AFTER" = true ] && [ "$SKIP_LAUNCH" = false ]; then
$AB close 2>/dev/null || true
local PID
PID=$(lsof -t -i :"$CDP_PORT" 2>/dev/null || true)
if [ -n "$PID" ]; then
kill "$PID" 2>/dev/null || true
fi
fi
}
trap cleanup EXIT
# ---- Step 1: Launch Code OSS ------------------------------------------------
if [ "$SKIP_LAUNCH" = false ]; then
# Check if already running
if lsof -i :"$CDP_PORT" >/dev/null 2>&1; then
echo "ERROR: Port $CDP_PORT already in use. Use --skip-launch or --port <other>" >&2
exit 1
fi
echo "Launching Code OSS on CDP port $CDP_PORT..." >&2
cd "$ROOT"
VSCODE_SKIP_PRELAUNCH=1 ./scripts/code.sh --remote-debugging-port="$CDP_PORT" &>/dev/null &
# Wait for it to start
echo "Waiting for Code OSS to start..." >&2
for i in $(seq 1 20); do
if $AB connect "$CDP_PORT" 2>/dev/null; then
break
fi
sleep 2
if [ "$i" -eq 20 ]; then
echo "ERROR: Code OSS did not start within 40 seconds" >&2
exit 1
fi
done
else
echo "Connecting to existing Code OSS on port $CDP_PORT..." >&2
$AB connect "$CDP_PORT" 2>/dev/null || {
echo "ERROR: Cannot connect to Code OSS on port $CDP_PORT" >&2
exit 1
}
fi
echo "Connected to Code OSS" >&2
# ---- Step 2: Switch to Local Agent mode -------------------------------------
# Check current session target
CURRENT_TARGET=$($AB snapshot -i 2>&1 | grep "Set Session Target" | head -1)
if ! echo "$CURRENT_TARGET" | grep -q "Local Agent"; then
echo "Switching to Local Agent mode..." >&2
# Find and click the session target button
TARGET_REF=$($AB snapshot -i 2>&1 | grep "Set Session Target" | head -1 | grep -o 'ref=e[0-9]*' | head -1 | sed 's/ref=//')
if [ -z "$TARGET_REF" ]; then
echo "ERROR: Cannot find session target button" >&2
exit 1
fi
$AB click "@$TARGET_REF" 2>/dev/null
sleep 0.5
# Navigate to Local Agent via arrow keys
# Menu items: Local (checked), Copilot CLI, Cloud, Local Agent, ...
$AB press ArrowDown 2>/dev/null # Copilot CLI
$AB press ArrowDown 2>/dev/null # Cloud
$AB press ArrowDown 2>/dev/null # Local Agent
$AB press Enter 2>/dev/null
sleep 0.5
# Verify
VERIFY=$($AB snapshot -i 2>&1 | grep "Set Session Target" | head -1)
if echo "$VERIFY" | grep -q "Local Agent"; then
echo "Switched to Local Agent mode" >&2
else
echo "WARNING: Could not confirm Local Agent mode. Current: $VERIFY" >&2
fi
else
echo "Already in Local Agent mode" >&2
fi
# ---- Step 3: Focus chat input and type message ------------------------------
echo "Sending message: $MESSAGE" >&2
# Focus chat input via JavaScript mouse events (universal approach)
$AB eval '
(() => {
const sidebar = document.querySelector(".part.auxiliarybar");
if (!sidebar) return "no sidebar";
const inputPart = sidebar.querySelector(".interactive-input-part");
if (!inputPart) return "no input part";
const editor = inputPart.querySelector(".monaco-editor");
if (!editor) return "no editor";
const rect = editor.getBoundingClientRect();
const x = rect.x + rect.width / 2;
const y = rect.y + rect.height / 2;
editor.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: x, clientY: y }));
editor.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, clientX: x, clientY: y }));
editor.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: x, clientY: y }));
return "focused";
})()' >/dev/null 2>&1
sleep 0.3
# Clear any existing text
$AB press Meta+a 2>/dev/null
$AB press Backspace 2>/dev/null
# Type message character by character
for (( i=0; i<${#MESSAGE}; i++ )); do
CHAR="${MESSAGE:$i:1}"
case "$CHAR" in
" ") $AB press Space 2>/dev/null ;;
"?") $AB press Shift+/ 2>/dev/null ;;
"!") $AB press Shift+1 2>/dev/null ;;
",") $AB press , 2>/dev/null ;;
".") $AB press . 2>/dev/null ;;
"'") $AB press "'" 2>/dev/null ;;
'"') $AB press 'Shift+'"'" 2>/dev/null ;;
*) $AB press "$CHAR" 2>/dev/null ;;
esac
done
# Verify text entered
ENTERED=$($AB eval '
(() => {
const sidebar = document.querySelector(".part.auxiliarybar");
const viewLines = sidebar?.querySelectorAll(".interactive-input-editor .view-line");
return Array.from(viewLines || []).map(vl => vl.textContent).join("");
})()' 2>&1 | tr -d '"')
echo "Entered text: $ENTERED" >&2
# Send the message
$AB press Enter 2>/dev/null
# ---- Step 4: Wait for response ----------------------------------------------
echo "Waiting for response (timeout: ${RESPONSE_TIMEOUT}s)..." >&2
RESPONSE=""
for i in $(seq 1 "$RESPONSE_TIMEOUT"); do
sleep 1
RESPONSE=$($AB eval '
(() => {
const sidebar = document.querySelector(".part.auxiliarybar");
if (!sidebar) return "";
const items = sidebar.querySelectorAll(".interactive-item-container");
if (items.length < 2) return "";
// Last item is the response
const lastItem = items[items.length - 1];
const text = lastItem.textContent || "";
// Check if it looks like a complete response (has content beyond the header)
if (text.length > 20) return text;
return "";
})()' 2>&1 | sed 's/^"//;s/"$//')
if [ -n "$RESPONSE" ]; then
break
fi
done
if [ -z "$RESPONSE" ]; then
echo "ERROR: No response received within ${RESPONSE_TIMEOUT}s" >&2
exit 1
fi
# ---- Step 5: Output response ------------------------------------------------
echo "---" >&2
echo "$RESPONSE"
@@ -0,0 +1,162 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Emitter } from '../../../../base/common/event.js';
import { URI } from '../../../../base/common/uri.js';
import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js';
export class ScriptedMockAgent implements IAgent {
readonly id: AgentProvider = 'mock';
private readonly _onDidSessionProgress = new Emitter<IAgentProgressEvent>();
readonly onDidSessionProgress = this._onDidSessionProgress.event;
private readonly _sessions = new Map<string, URI>();
private _nextId = 1;
// Track pending permission requests
private readonly _pendingPermissions = new Map<string, (approved: boolean) => void>();
// Track pending abort callbacks for slow responses
private readonly _pendingAborts = new Map<string, () => void>();
getDescriptor(): IAgentDescriptor {
return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent', requiresAuth: false };
}
async listModels(): Promise<IAgentModelInfo[]> {
return [{ provider: 'mock', id: 'mock-model', name: 'Mock Model', maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }];
}
async listSessions(): Promise<IAgentSessionMetadata[]> {
return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now() }));
}
async createSession(_config?: IAgentCreateSessionConfig): Promise<URI> {
const rawId = `mock-session-${this._nextId++}`;
const session = AgentSession.uri('mock', rawId);
this._sessions.set(rawId, session);
return session;
}
async sendMessage(session: URI, prompt: string, _attachments?: IAgentAttachment[]): Promise<void> {
switch (prompt) {
case 'hello':
this._fireSequence(session, [
{ type: 'delta', session, messageId: 'msg-1', content: 'Hello, world!' },
{ type: 'idle', session },
]);
break;
case 'use-tool':
this._fireSequence(session, [
{ type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'echo_tool', displayName: 'Echo Tool', invocationMessage: 'Running echo tool...' },
{ type: 'tool_complete', session, toolCallId: 'tc-1', success: true, pastTenseMessage: 'Ran echo tool', toolOutput: 'echoed' },
{ type: 'delta', session, messageId: 'msg-1', content: 'Tool done.' },
{ type: 'idle', session },
]);
break;
case 'error':
this._fireSequence(session, [
{ type: 'error', session, errorType: 'test_error', message: 'Something went wrong' },
]);
break;
case 'permission': {
// Fire permission_request, then wait for respondToPermissionRequest
const permEvent: IAgentProgressEvent = {
type: 'permission_request',
session,
requestId: 'perm-1',
permissionKind: 'shell',
fullCommandText: 'echo test',
intention: 'Run a test command',
rawRequest: JSON.stringify({ permissionKind: 'shell', fullCommandText: 'echo test', intention: 'Run a test command' }),
};
setTimeout(() => this._onDidSessionProgress.fire(permEvent), 10);
this._pendingPermissions.set('perm-1', (approved) => {
if (approved) {
this._fireSequence(session, [
{ type: 'delta', session, messageId: 'msg-1', content: 'Allowed.' },
{ type: 'idle', session },
]);
}
});
break;
}
case 'with-usage':
this._fireSequence(session, [
{ type: 'delta', session, messageId: 'msg-1', content: 'Usage response.' },
{ type: 'usage', session, inputTokens: 100, outputTokens: 50, model: 'mock-model' },
{ type: 'idle', session },
]);
break;
case 'slow': {
// Slow response for cancel testing — fires delta after a long delay
const timer = setTimeout(() => {
this._fireSequence(session, [
{ type: 'delta', session, messageId: 'msg-1', content: 'Slow response.' },
{ type: 'idle', session },
]);
}, 5000);
this._pendingAborts.set(session.toString(), () => clearTimeout(timer));
break;
}
default:
this._fireSequence(session, [
{ type: 'delta', session, messageId: 'msg-1', content: 'Unknown prompt: ' + prompt },
{ type: 'idle', session },
]);
break;
}
}
async getSessionMessages(_session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> {
return [];
}
async disposeSession(session: URI): Promise<void> {
this._sessions.delete(AgentSession.id(session));
}
async abortSession(session: URI): Promise<void> {
const callback = this._pendingAborts.get(session.toString());
if (callback) {
this._pendingAborts.delete(session.toString());
callback();
}
}
async changeModel(_session: URI, _model: string): Promise<void> {
// Mock agent doesn't track model state
}
respondToPermissionRequest(requestId: string, approved: boolean): void {
const callback = this._pendingPermissions.get(requestId);
if (callback) {
this._pendingPermissions.delete(requestId);
callback(approved);
}
}
async setAuthToken(_token: string): Promise<void> { }
async shutdown(): Promise<void> { }
dispose(): void {
this._onDidSessionProgress.dispose();
}
private _fireSequence(session: URI, events: IAgentProgressEvent[]): void {
let delay = 0;
for (const event of events) {
delay += 10;
setTimeout(() => this._onDidSessionProgress.fire(event), delay);
}
}
}
@@ -0,0 +1,308 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { Emitter } from '../../../../base/common/event.js';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { NullLogService } from '../../../log/common/log.js';
import type { ISessionAction } from '../../common/state/sessionActions.js';
import { isJsonRpcNotification, isJsonRpcResponse, type ICreateSessionParams, type IProtocolMessage, type IProtocolNotification, type IServerHelloParams, type IStateSnapshot } from '../../common/state/sessionProtocol.js';
import { SessionStatus, type ISessionSummary } from '../../common/state/sessionState.js';
import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js';
import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js';
import { ProtocolServerHandler, type IProtocolSideEffectHandler } from '../../node/protocolServerHandler.js';
import { SessionStateManager } from '../../node/sessionStateManager.js';
// ---- Mock helpers -----------------------------------------------------------
class MockProtocolTransport implements IProtocolTransport {
private readonly _onMessage = new Emitter<IProtocolMessage>();
readonly onMessage = this._onMessage.event;
private readonly _onClose = new Emitter<void>();
readonly onClose = this._onClose.event;
readonly sent: IProtocolMessage[] = [];
send(message: IProtocolMessage): void {
this.sent.push(message);
}
simulateMessage(msg: IProtocolMessage): void {
this._onMessage.fire(msg);
}
simulateClose(): void {
this._onClose.fire();
}
dispose(): void {
this._onMessage.dispose();
this._onClose.dispose();
}
}
class MockProtocolServer implements IProtocolServer {
private readonly _onConnection = new Emitter<IProtocolTransport>();
readonly onConnection = this._onConnection.event;
readonly address = 'mock://test';
simulateConnection(transport: IProtocolTransport): void {
this._onConnection.fire(transport);
}
dispose(): void {
this._onConnection.dispose();
}
}
class MockSideEffectHandler implements IProtocolSideEffectHandler {
readonly handledActions: ISessionAction[] = [];
handleAction(action: ISessionAction): void {
this.handledActions.push(action);
}
async handleCreateSession(_command: ICreateSessionParams): Promise<void> { }
handleDisposeSession(_session: URI): void { }
async handleListSessions(): Promise<ISessionSummary[]> { return []; }
}
// ---- Helpers ----------------------------------------------------------------
function notification(method: string, params?: unknown): IProtocolMessage {
return { jsonrpc: '2.0', method, params } as IProtocolMessage;
}
function request(id: number, method: string, params?: unknown): IProtocolMessage {
return { jsonrpc: '2.0', id, method, params } as IProtocolMessage;
}
function findNotification(sent: IProtocolMessage[], method: string): IProtocolNotification | undefined {
return sent.find(isJsonRpcNotification) as IProtocolNotification | undefined;
}
function findNotifications(sent: IProtocolMessage[], method: string): IProtocolNotification[] {
return sent.filter(isJsonRpcNotification) as IProtocolNotification[];
}
function findResponse(sent: IProtocolMessage[], id: number): IProtocolMessage | undefined {
return sent.find(isJsonRpcResponse) as IProtocolMessage | undefined;
}
// ---- Tests ------------------------------------------------------------------
suite('ProtocolServerHandler', () => {
let disposables: DisposableStore;
let stateManager: SessionStateManager;
let server: MockProtocolServer;
let sideEffects: MockSideEffectHandler;
const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' });
function makeSessionSummary(resource?: URI): ISessionSummary {
return {
resource: resource ?? sessionUri,
provider: 'copilot',
title: 'Test',
status: SessionStatus.Idle,
createdAt: Date.now(),
modifiedAt: Date.now(),
};
}
function connectClient(clientId: string, initialSubscriptions?: readonly URI[]): MockProtocolTransport {
const transport = new MockProtocolTransport();
server.simulateConnection(transport);
transport.simulateMessage(notification('initialize', {
protocolVersion: PROTOCOL_VERSION,
clientId,
initialSubscriptions,
}));
return transport;
}
setup(() => {
disposables = new DisposableStore();
stateManager = disposables.add(new SessionStateManager(new NullLogService()));
server = disposables.add(new MockProtocolServer());
sideEffects = new MockSideEffectHandler();
disposables.add(new ProtocolServerHandler(
stateManager,
server,
sideEffects,
new NullLogService(),
));
});
teardown(() => {
disposables.dispose();
});
ensureNoDisposablesAreLeakedInTestSuite();
test('handshake sends serverHello notification', () => {
const transport = connectClient('client-1');
const hello = findNotification(transport.sent, 'serverHello');
assert.ok(hello, 'should have sent serverHello');
const params = hello.params as IServerHelloParams;
assert.strictEqual(params.protocolVersion, PROTOCOL_VERSION);
assert.strictEqual(params.serverSeq, stateManager.serverSeq);
});
test('handshake with initialSubscriptions returns snapshots', () => {
stateManager.createSession(makeSessionSummary());
const transport = connectClient('client-1', [sessionUri]);
const hello = findNotification(transport.sent, 'serverHello');
assert.ok(hello);
const params = hello.params as IServerHelloParams;
assert.strictEqual(params.snapshots.length, 1);
assert.strictEqual(params.snapshots[0].resource.toString(), sessionUri.toString());
});
test('subscribe request returns snapshot', async () => {
stateManager.createSession(makeSessionSummary());
const transport = connectClient('client-1');
transport.sent.length = 0;
transport.simulateMessage(request(1, 'subscribe', { resource: sessionUri }));
// Wait for async response
await new Promise(resolve => setTimeout(resolve, 10));
const resp = findResponse(transport.sent, 1);
assert.ok(resp, 'should have sent response');
const snapshot = (resp as { result: IStateSnapshot }).result;
assert.strictEqual(snapshot.resource.toString(), sessionUri.toString());
});
test('client action is dispatched and echoed', () => {
stateManager.createSession(makeSessionSummary());
stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri });
const transport = connectClient('client-1', [sessionUri]);
transport.sent.length = 0;
transport.simulateMessage(notification('dispatchAction', {
clientSeq: 1,
action: {
type: 'session/turnStarted',
session: sessionUri,
turnId: 'turn-1',
userMessage: { text: 'hello' },
},
}));
const actionMsgs = findNotifications(transport.sent, 'action');
const turnStarted = actionMsgs.find(m => {
const params = m.params as { envelope: { action: { type: string } } };
return params.envelope.action.type === 'session/turnStarted';
});
assert.ok(turnStarted, 'should have echoed turnStarted');
const envelope = (turnStarted!.params as { envelope: { origin: { clientId: string; clientSeq: number } } }).envelope;
assert.strictEqual(envelope.origin.clientId, 'client-1');
assert.strictEqual(envelope.origin.clientSeq, 1);
});
test('actions are scoped to subscribed sessions', () => {
stateManager.createSession(makeSessionSummary());
stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri });
const transportA = connectClient('client-a', [sessionUri]);
const transportB = connectClient('client-b');
transportA.sent.length = 0;
transportB.sent.length = 0;
stateManager.dispatchServerAction({
type: 'session/titleChanged',
session: sessionUri,
title: 'New Title',
});
assert.strictEqual(findNotifications(transportA.sent, 'action').length, 1);
assert.strictEqual(findNotifications(transportB.sent, 'action').length, 0);
});
test('notifications are broadcast to all clients', () => {
const transportA = connectClient('client-a');
const transportB = connectClient('client-b');
transportA.sent.length = 0;
transportB.sent.length = 0;
stateManager.createSession(makeSessionSummary());
assert.strictEqual(findNotifications(transportA.sent, 'notification').length, 1);
assert.strictEqual(findNotifications(transportB.sent, 'notification').length, 1);
});
test('reconnect replays missed actions', () => {
stateManager.createSession(makeSessionSummary());
stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri });
const transport1 = connectClient('client-r', [sessionUri]);
const hello = findNotification(transport1.sent, 'serverHello');
const helloSeq = (hello!.params as IServerHelloParams).serverSeq;
transport1.simulateClose();
stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'Title A' });
stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'Title B' });
const transport2 = new MockProtocolTransport();
server.simulateConnection(transport2);
transport2.simulateMessage(notification('reconnect', {
clientId: 'client-r',
lastSeenServerSeq: helloSeq,
subscriptions: [sessionUri],
}));
const replayed = findNotifications(transport2.sent, 'action');
assert.strictEqual(replayed.length, 2);
});
test('reconnect sends fresh snapshots when gap too large', () => {
stateManager.createSession(makeSessionSummary());
stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri });
const transport1 = connectClient('client-g', [sessionUri]);
transport1.simulateClose();
for (let i = 0; i < 1100; i++) {
stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: `Title ${i}` });
}
const transport2 = new MockProtocolTransport();
server.simulateConnection(transport2);
transport2.simulateMessage(notification('reconnect', {
clientId: 'client-g',
lastSeenServerSeq: 0,
subscriptions: [sessionUri],
}));
const reconnectResp = findNotification(transport2.sent, 'reconnectResponse');
assert.ok(reconnectResp, 'should receive a reconnectResponse');
const params = reconnectResp!.params as { snapshots: IStateSnapshot[] };
assert.ok(params.snapshots.length > 0, 'should contain snapshots');
});
test('client disconnect cleans up', () => {
stateManager.createSession(makeSessionSummary());
stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri });
const transport = connectClient('client-d', [sessionUri]);
transport.sent.length = 0;
transport.simulateClose();
stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'After Disconnect' });
assert.strictEqual(transport.sent.length, 0);
});
});
@@ -0,0 +1,663 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { ChildProcess, fork } from 'child_process';
import { fileURLToPath } from 'url';
import { WebSocket } from 'ws';
import { URI } from '../../../../base/common/uri.js';
import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js';
import {
isJsonRpcNotification,
isJsonRpcResponse,
type IActionBroadcastParams,
type IFetchTurnsResult,
type IJsonRpcErrorResponse,
type IJsonRpcSuccessResponse,
type IListSessionsResult,
type INotificationBroadcastParams,
type IProtocolMessage,
type IProtocolNotification,
type IServerHelloParams,
type IStateSnapshot,
} from '../../common/state/sessionProtocol.js';
import type { IDeltaAction, ISessionAddedNotification, ISessionRemovedNotification, IUsageAction } from '../../common/state/sessionActions.js';
import type { ISessionState } from '../../common/state/sessionState.js';
// ---- JSON serialization helpers (mirror webSocketTransport.ts) --------------
function uriReplacer(_key: string, value: unknown): unknown {
if (value instanceof URI) {
return value.toJSON();
}
if (value instanceof Map) {
return { $type: 'Map', entries: [...value.entries()] };
}
return value;
}
function uriReviver(_key: string, value: unknown): unknown {
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
if (obj.$mid === 1) {
return URI.revive(value as URI);
}
if (obj.$type === 'Map' && Array.isArray(obj.entries)) {
return new Map(obj.entries as [unknown, unknown][]);
}
}
return value;
}
// ---- JSON-RPC test client ---------------------------------------------------
interface IPendingCall {
resolve: (result: unknown) => void;
reject: (err: Error) => void;
}
class TestProtocolClient {
private readonly _ws: WebSocket;
private _nextId = 1;
private readonly _pendingCalls = new Map<number, IPendingCall>();
private readonly _notifications: IProtocolNotification[] = [];
private readonly _notifWaiters: { predicate: (n: IProtocolNotification) => boolean; resolve: (n: IProtocolNotification) => void; reject: (err: Error) => void }[] = [];
constructor(port: number) {
this._ws = new WebSocket(`ws://127.0.0.1:${port}`);
}
async connect(): Promise<void> {
return new Promise<void>((resolve, reject) => {
this._ws.on('open', () => {
this._ws.on('message', (data: Buffer | string) => {
const text = typeof data === 'string' ? data : data.toString('utf-8');
const msg = JSON.parse(text, uriReviver);
this._handleMessage(msg);
});
resolve();
});
this._ws.on('error', reject);
});
}
private _handleMessage(msg: IProtocolMessage): void {
if (isJsonRpcResponse(msg)) {
// JSON-RPC response — resolve pending call
const pending = this._pendingCalls.get(msg.id);
if (pending) {
this._pendingCalls.delete(msg.id);
const errResp = msg as IJsonRpcErrorResponse;
if (errResp.error) {
pending.reject(new Error(errResp.error.message));
} else {
pending.resolve((msg as IJsonRpcSuccessResponse).result);
}
}
} else if (isJsonRpcNotification(msg)) {
// JSON-RPC notification from server
const notif = msg;
// Check waiters first
for (let i = this._notifWaiters.length - 1; i >= 0; i--) {
if (this._notifWaiters[i].predicate(notif)) {
const waiter = this._notifWaiters.splice(i, 1)[0];
waiter.resolve(notif);
}
}
this._notifications.push(notif);
}
}
/** Send a JSON-RPC notification (fire-and-forget). */
notify(method: string, params?: unknown): void {
const msg: IProtocolMessage = { jsonrpc: '2.0', method, params };
this._ws.send(JSON.stringify(msg, uriReplacer));
}
/** Send a JSON-RPC request and await the response. */
call<T>(method: string, params?: unknown, timeoutMs = 5000): Promise<T> {
const id = this._nextId++;
const msg: IProtocolMessage = { jsonrpc: '2.0', id, method, params };
this._ws.send(JSON.stringify(msg, uriReplacer));
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this._pendingCalls.delete(id);
reject(new Error(`Timeout waiting for response to ${method} (id=${id}, ${timeoutMs}ms)`));
}, timeoutMs);
this._pendingCalls.set(id, {
resolve: result => { clearTimeout(timer); resolve(result as T); },
reject: err => { clearTimeout(timer); reject(err); },
});
});
}
/** Wait for a server notification matching a predicate. */
waitForNotification(predicate: (n: IProtocolNotification) => boolean, timeoutMs = 5000): Promise<IProtocolNotification> {
const existing = this._notifications.find(predicate);
if (existing) {
return Promise.resolve(existing);
}
return new Promise<IProtocolNotification>((resolve, reject) => {
const timer = setTimeout(() => {
const idx = this._notifWaiters.findIndex(w => w.resolve === resolve);
if (idx >= 0) {
this._notifWaiters.splice(idx, 1);
}
reject(new Error(`Timeout waiting for notification (${timeoutMs}ms)`));
}, timeoutMs);
this._notifWaiters.push({
predicate,
resolve: n => { clearTimeout(timer); resolve(n); },
reject,
});
});
}
/** Return all received notifications matching a predicate. */
receivedNotifications(predicate?: (n: IProtocolNotification) => boolean): IProtocolNotification[] {
return predicate ? this._notifications.filter(predicate) : [...this._notifications];
}
close(): void {
for (const w of this._notifWaiters) {
w.reject(new Error('Client closed'));
}
this._notifWaiters.length = 0;
for (const [, p] of this._pendingCalls) {
p.reject(new Error('Client closed'));
}
this._pendingCalls.clear();
this._ws.close();
}
clearReceived(): void {
this._notifications.length = 0;
}
}
// ---- Server process lifecycle -----------------------------------------------
async function startServer(): Promise<{ process: ChildProcess; port: number }> {
return new Promise((resolve, reject) => {
const serverPath = fileURLToPath(new URL('../../node/agentHostServerMain.js', import.meta.url));
const child = fork(serverPath, ['--enable-mock-agent', '--quiet', '--port', '0'], {
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
});
const timeout = setTimeout(() => {
child.kill();
reject(new Error('Server startup timed out'));
}, 10_000);
child.stdout!.on('data', (data: Buffer) => {
const text = data.toString();
const match = text.match(/READY:(\d+)/);
if (match) {
clearTimeout(timeout);
resolve({ process: child, port: parseInt(match[1], 10) });
}
});
child.stderr!.on('data', (data: Buffer) => {
console.error('[TestServer]', data.toString());
});
child.on('error', err => {
clearTimeout(timeout);
reject(err);
});
child.on('exit', code => {
clearTimeout(timeout);
reject(new Error(`Server exited prematurely with code ${code}`));
});
});
}
// ---- Helpers ----------------------------------------------------------------
let sessionCounter = 0;
function nextSessionUri(): URI {
return URI.from({ scheme: 'mock', path: `/test-session-${++sessionCounter}` });
}
function isActionNotification(n: IProtocolNotification, actionType: string): boolean {
if (n.method !== 'action') {
return false;
}
const params = n.params as IActionBroadcastParams;
return params.envelope.action.type === actionType;
}
function getActionParams(n: IProtocolNotification): IActionBroadcastParams {
return n.params as IActionBroadcastParams;
}
/** Perform handshake, create a session, subscribe, and return its URI. */
async function createAndSubscribeSession(c: TestProtocolClient, clientId: string): Promise<URI> {
c.notify('initialize', { protocolVersion: PROTOCOL_VERSION, clientId });
await c.waitForNotification(n => n.method === 'serverHello');
await c.call('createSession', { session: nextSessionUri(), provider: 'mock' });
const notif = await c.waitForNotification(n =>
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'
);
const realSessionUri = ((notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource;
await c.call<IStateSnapshot>('subscribe', { resource: realSessionUri });
c.clearReceived();
return realSessionUri;
}
function dispatchTurnStarted(c: TestProtocolClient, session: URI, turnId: string, text: string, clientSeq: number): void {
c.notify('dispatchAction', {
clientSeq,
action: {
type: 'session/turnStarted',
session,
turnId,
userMessage: { text },
},
});
}
// ---- Test suite -------------------------------------------------------------
suite('Protocol WebSocket E2E', function () {
let server: { process: ChildProcess; port: number };
let client: TestProtocolClient;
suiteSetup(async function () {
this.timeout(15_000);
server = await startServer();
});
suiteTeardown(function () {
server.process.kill();
});
setup(async function () {
this.timeout(10_000);
client = new TestProtocolClient(server.port);
await client.connect();
});
teardown(function () {
client.close();
});
// 1. Handshake
test('handshake returns serverHello with protocol version', async function () {
this.timeout(5_000);
client.notify('initialize', {
protocolVersion: PROTOCOL_VERSION,
clientId: 'test-handshake',
initialSubscriptions: [URI.from({ scheme: 'agenthost', path: '/root' })],
});
const hello = await client.waitForNotification(n => n.method === 'serverHello');
const params = hello.params as IServerHelloParams;
assert.strictEqual(params.protocolVersion, PROTOCOL_VERSION);
assert.ok(params.serverSeq >= 0);
assert.ok(params.snapshots.length >= 1, 'should have root state snapshot');
});
// 2. Create session
test('create session triggers sessionAdded notification', async function () {
this.timeout(10_000);
client.notify('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-create-session' });
await client.waitForNotification(n => n.method === 'serverHello');
await client.call('createSession', { session: nextSessionUri(), provider: 'mock' });
const notif = await client.waitForNotification(n =>
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'
);
const notification = (notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification;
assert.strictEqual(notification.summary.resource.scheme, 'mock');
assert.strictEqual(notification.summary.provider, 'mock');
});
// 3. Send message and receive response
test('send message and receive delta + turnComplete', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-send-message');
dispatchTurnStarted(client, sessionUri, 'turn-1', 'hello', 1);
const delta = await client.waitForNotification(n => isActionNotification(n, 'session/delta'));
const deltaAction = getActionParams(delta).envelope.action;
assert.strictEqual(deltaAction.type, 'session/delta');
if (deltaAction.type === 'session/delta') {
assert.strictEqual(deltaAction.content, 'Hello, world!');
}
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
});
// 4. Tool invocation lifecycle
test('tool invocation: toolStart → toolComplete → delta → turnComplete', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-tool-invocation');
dispatchTurnStarted(client, sessionUri, 'turn-tool', 'use-tool', 1);
await client.waitForNotification(n => isActionNotification(n, 'session/toolStart'));
const toolComplete = await client.waitForNotification(n => isActionNotification(n, 'session/toolComplete'));
const tcAction = getActionParams(toolComplete).envelope.action;
if (tcAction.type === 'session/toolComplete') {
assert.strictEqual(tcAction.result.success, true);
}
await client.waitForNotification(n => isActionNotification(n, 'session/delta'));
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
});
// 5. Error handling
test('error prompt triggers session/error', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-error');
dispatchTurnStarted(client, sessionUri, 'turn-err', 'error', 1);
const errorNotif = await client.waitForNotification(n => isActionNotification(n, 'session/error'));
const errorAction = getActionParams(errorNotif).envelope.action;
if (errorAction.type === 'session/error') {
assert.strictEqual(errorAction.error.message, 'Something went wrong');
}
});
// 6. Permission flow
test('permission request → resolve → response', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-permission');
dispatchTurnStarted(client, sessionUri, 'turn-perm', 'permission', 1);
await client.waitForNotification(n => isActionNotification(n, 'session/permissionRequest'));
client.notify('dispatchAction', {
clientSeq: 2,
action: {
type: 'session/permissionResolved',
session: sessionUri,
turnId: 'turn-perm',
requestId: 'perm-1',
approved: true,
},
});
const delta = await client.waitForNotification(n => isActionNotification(n, 'session/delta'));
const content = (getActionParams(delta).envelope.action as IDeltaAction).content;
assert.strictEqual(content, 'Allowed.');
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
});
// 7. Session list
test('listSessions returns sessions', async function () {
this.timeout(10_000);
client.notify('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-list-sessions' });
await client.waitForNotification(n => n.method === 'serverHello');
await client.call('createSession', { session: nextSessionUri(), provider: 'mock' });
await client.waitForNotification(n =>
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'
);
const result = await client.call<IListSessionsResult>('listSessions');
assert.ok(Array.isArray(result.sessions));
assert.ok(result.sessions.length >= 1, 'should have at least one session');
});
// 8. Reconnect
test('reconnect replays missed actions', async function () {
this.timeout(15_000);
const sessionUri = await createAndSubscribeSession(client, 'test-reconnect');
dispatchTurnStarted(client, sessionUri, 'turn-recon', 'hello', 1);
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
const allActions = client.receivedNotifications(n => n.method === 'action');
assert.ok(allActions.length > 0);
const missedFromSeq = getActionParams(allActions[0]).envelope.serverSeq - 1;
client.close();
const client2 = new TestProtocolClient(server.port);
await client2.connect();
client2.notify('reconnect', {
clientId: 'test-reconnect',
lastSeenServerSeq: missedFromSeq,
subscriptions: [sessionUri],
});
await new Promise(resolve => setTimeout(resolve, 500));
const replayed = client2.receivedNotifications();
assert.ok(replayed.length > 0, 'should receive replayed actions or reconnect response');
const hasActions = replayed.some(n => n.method === 'action');
const hasReconnect = replayed.some(n => n.method === 'reconnectResponse');
assert.ok(hasActions || hasReconnect);
client2.close();
});
// ---- Gap tests: functionality bugs ----------------------------------------
test('usage info is captured on completed turn', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-usage');
dispatchTurnStarted(client, sessionUri, 'turn-usage', 'with-usage', 1);
const usageNotif = await client.waitForNotification(n => isActionNotification(n, 'session/usage'));
const usageAction = getActionParams(usageNotif).envelope.action as IUsageAction;
assert.strictEqual(usageAction.usage.inputTokens, 100);
assert.strictEqual(usageAction.usage.outputTokens, 50);
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
const snapshot = await client.call<IStateSnapshot>('subscribe', { resource: sessionUri });
const state = snapshot.state as ISessionState;
assert.ok(state.turns.length >= 1);
const turn = state.turns[state.turns.length - 1];
assert.ok(turn.usage);
assert.strictEqual(turn.usage!.inputTokens, 100);
assert.strictEqual(turn.usage!.outputTokens, 50);
});
test('modifiedAt updates on turn completion', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-modifiedAt');
const initialSnapshot = await client.call<IStateSnapshot>('subscribe', { resource: sessionUri });
const initialModifiedAt = (initialSnapshot.state as ISessionState).summary.modifiedAt;
await new Promise(resolve => setTimeout(resolve, 50));
dispatchTurnStarted(client, sessionUri, 'turn-mod', 'hello', 1);
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
const updatedSnapshot = await client.call<IStateSnapshot>('subscribe', { resource: sessionUri });
const updatedModifiedAt = (updatedSnapshot.state as ISessionState).summary.modifiedAt;
assert.ok(updatedModifiedAt >= initialModifiedAt);
});
test('createSession with invalid provider does not crash server', async function () {
this.timeout(10_000);
client.notify('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-invalid-create' });
await client.waitForNotification(n => n.method === 'serverHello');
// This should return a JSON-RPC error
let gotError = false;
try {
await client.call('createSession', { session: nextSessionUri(), provider: 'nonexistent' });
} catch {
gotError = true;
}
assert.ok(gotError, 'should have received an error for invalid provider');
// Server should still be functional
await client.call('createSession', { session: nextSessionUri(), provider: 'mock' });
const notif = await client.waitForNotification(n =>
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'
);
assert.ok(notif);
});
test('fetchTurns returns completed turn history', async function () {
this.timeout(15_000);
const sessionUri = await createAndSubscribeSession(client, 'test-fetchTurns');
dispatchTurnStarted(client, sessionUri, 'turn-ft-1', 'hello', 1);
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
dispatchTurnStarted(client, sessionUri, 'turn-ft-2', 'hello', 2);
await new Promise(resolve => setTimeout(resolve, 200));
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
const result = await client.call<IFetchTurnsResult>('fetchTurns', { session: sessionUri, startTurn: 0, count: 10 });
assert.ok(result.turns.length >= 2);
assert.ok(result.totalTurns >= 2);
});
// ---- Gap tests: coverage ---------------------------------------------------
test('dispose session sends sessionRemoved notification', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-dispose');
await client.call('disposeSession', { session: sessionUri });
const notif = await client.waitForNotification(n =>
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved'
);
const removed = (notif.params as INotificationBroadcastParams).notification as ISessionRemovedNotification;
assert.strictEqual(removed.session.toString(), sessionUri.toString());
});
test('cancel turn stops in-progress processing', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-cancel');
dispatchTurnStarted(client, sessionUri, 'turn-cancel', 'slow', 1);
client.notify('dispatchAction', {
clientSeq: 2,
action: { type: 'session/turnCancelled', session: sessionUri, turnId: 'turn-cancel' },
});
await client.waitForNotification(n => isActionNotification(n, 'session/turnCancelled'));
const snapshot = await client.call<IStateSnapshot>('subscribe', { resource: sessionUri });
const state = snapshot.state as ISessionState;
assert.ok(state.turns.length >= 1);
assert.strictEqual(state.turns[state.turns.length - 1].state, 'cancelled');
});
test('multiple sequential turns accumulate in history', async function () {
this.timeout(15_000);
const sessionUri = await createAndSubscribeSession(client, 'test-multi-turns');
dispatchTurnStarted(client, sessionUri, 'turn-m1', 'hello', 1);
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
dispatchTurnStarted(client, sessionUri, 'turn-m2', 'hello', 2);
await new Promise(resolve => setTimeout(resolve, 200));
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
const snapshot = await client.call<IStateSnapshot>('subscribe', { resource: sessionUri });
const state = snapshot.state as ISessionState;
assert.ok(state.turns.length >= 2, `expected >= 2 turns but got ${state.turns.length}`);
assert.strictEqual(state.turns[0].id, 'turn-m1');
assert.strictEqual(state.turns[1].id, 'turn-m2');
});
test('two clients on same session both see actions', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-multi-client-1');
const client2 = new TestProtocolClient(server.port);
await client2.connect();
client2.notify('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-multi-client-2' });
await client2.waitForNotification(n => n.method === 'serverHello');
await client2.call('subscribe', { resource: sessionUri });
client2.clearReceived();
dispatchTurnStarted(client, sessionUri, 'turn-mc', 'hello', 1);
const d1 = await client.waitForNotification(n => isActionNotification(n, 'session/delta'));
const d2 = await client2.waitForNotification(n => isActionNotification(n, 'session/delta'));
assert.ok(d1);
assert.ok(d2);
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
client2.close();
});
test('unsubscribe stops receiving session actions', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-unsubscribe');
client.notify('unsubscribe', { resource: sessionUri });
await new Promise(resolve => setTimeout(resolve, 100));
client.clearReceived();
const client2 = new TestProtocolClient(server.port);
await client2.connect();
client2.notify('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-unsub-helper' });
await client2.waitForNotification(n => n.method === 'serverHello');
await client2.call('subscribe', { resource: sessionUri });
dispatchTurnStarted(client2, sessionUri, 'turn-unsub', 'hello', 1);
await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
await new Promise(resolve => setTimeout(resolve, 300));
const sessionActions = client.receivedNotifications(n => isActionNotification(n, 'session/'));
assert.strictEqual(sessionActions.length, 0, 'unsubscribed client should not receive session actions');
client2.close();
});
test('change model within session updates state', async function () {
this.timeout(10_000);
const sessionUri = await createAndSubscribeSession(client, 'test-change-model');
client.notify('dispatchAction', {
clientSeq: 1,
action: { type: 'session/modelChanged', session: sessionUri, model: 'new-mock-model' },
});
const modelChanged = await client.waitForNotification(n => isActionNotification(n, 'session/modelChanged'));
const action = getActionParams(modelChanged).envelope.action;
assert.strictEqual(action.type, 'session/modelChanged');
if (action.type === 'session/modelChanged') {
assert.strictEqual((action as { model: string }).model, 'new-mock-model');
}
const snapshot = await client.call<IStateSnapshot>('subscribe', { resource: sessionUri });
const state = snapshot.state as ISessionState;
assert.strictEqual(state.summary.model, 'new-mock-model');
});
});
@@ -0,0 +1,163 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { NullLogService } from '../../../log/common/log.js';
import type { IActionEnvelope, INotification } from '../../common/state/sessionActions.js';
import { ISessionSummary, ROOT_STATE_URI, SessionLifecycle, SessionStatus, type ISessionState } from '../../common/state/sessionState.js';
import { SessionStateManager } from '../../node/sessionStateManager.js';
suite('SessionStateManager', () => {
let disposables: DisposableStore;
let manager: SessionStateManager;
const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' });
function makeSessionSummary(resource?: URI): ISessionSummary {
return {
resource: resource ?? sessionUri,
provider: 'copilot',
title: 'Test',
status: SessionStatus.Idle,
createdAt: Date.now(),
modifiedAt: Date.now(),
};
}
setup(() => {
disposables = new DisposableStore();
manager = disposables.add(new SessionStateManager(new NullLogService()));
});
teardown(() => {
disposables.dispose();
});
ensureNoDisposablesAreLeakedInTestSuite();
test('createSession creates initial state with lifecycle Creating', () => {
const state = manager.createSession(makeSessionSummary());
assert.strictEqual(state.lifecycle, SessionLifecycle.Creating);
assert.strictEqual(state.turns.length, 0);
assert.strictEqual(state.activeTurn, undefined);
assert.strictEqual(state.summary.resource.toString(), sessionUri.toString());
});
test('getSnapshot returns undefined for unknown session', () => {
const unknown = URI.from({ scheme: 'copilot', path: '/unknown' });
const snapshot = manager.getSnapshot(unknown);
assert.strictEqual(snapshot, undefined);
});
test('getSnapshot returns root snapshot', () => {
const snapshot = manager.getSnapshot(ROOT_STATE_URI);
assert.ok(snapshot);
assert.strictEqual(snapshot.resource.toString(), ROOT_STATE_URI.toString());
assert.deepStrictEqual(snapshot.state, { agents: [] });
});
test('getSnapshot returns session snapshot after creation', () => {
manager.createSession(makeSessionSummary());
const snapshot = manager.getSnapshot(sessionUri);
assert.ok(snapshot);
assert.strictEqual(snapshot.resource.toString(), sessionUri.toString());
assert.strictEqual((snapshot.state as ISessionState).lifecycle, SessionLifecycle.Creating);
});
test('dispatchServerAction applies action and emits envelope', () => {
manager.createSession(makeSessionSummary());
const envelopes: IActionEnvelope[] = [];
disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e)));
manager.dispatchServerAction({
type: 'session/ready',
session: sessionUri,
});
const state = manager.getSessionState(sessionUri);
assert.ok(state);
assert.strictEqual(state.lifecycle, SessionLifecycle.Ready);
assert.strictEqual(envelopes.length, 1);
assert.strictEqual(envelopes[0].action.type, 'session/ready');
assert.strictEqual(envelopes[0].serverSeq, 1);
assert.strictEqual(envelopes[0].origin, undefined);
});
test('serverSeq increments monotonically', () => {
manager.createSession(makeSessionSummary());
const envelopes: IActionEnvelope[] = [];
disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e)));
manager.dispatchServerAction({ type: 'session/ready', session: sessionUri });
manager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'Updated' });
assert.strictEqual(envelopes.length, 2);
assert.strictEqual(envelopes[0].serverSeq, 1);
assert.strictEqual(envelopes[1].serverSeq, 2);
assert.ok(envelopes[1].serverSeq > envelopes[0].serverSeq);
});
test('dispatchClientAction includes origin in envelope', () => {
manager.createSession(makeSessionSummary());
const envelopes: IActionEnvelope[] = [];
disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e)));
const origin = { clientId: 'renderer-1', clientSeq: 42 };
manager.dispatchClientAction(
{ type: 'session/ready', session: sessionUri },
origin,
);
assert.strictEqual(envelopes.length, 1);
assert.deepStrictEqual(envelopes[0].origin, origin);
});
test('removeSession clears state and emits notification', () => {
manager.createSession(makeSessionSummary());
const notifications: INotification[] = [];
disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));
manager.removeSession(sessionUri);
assert.strictEqual(manager.getSessionState(sessionUri), undefined);
assert.strictEqual(manager.getSnapshot(sessionUri), undefined);
assert.strictEqual(notifications.length, 1);
assert.strictEqual(notifications[0].type, 'notify/sessionRemoved');
});
test('createSession emits sessionAdded notification', () => {
const notifications: INotification[] = [];
disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));
manager.createSession(makeSessionSummary());
assert.strictEqual(notifications.length, 1);
assert.strictEqual(notifications[0].type, 'notify/sessionAdded');
});
test('getActiveTurnId returns active turn id after turnStarted', () => {
manager.createSession(makeSessionSummary());
manager.dispatchServerAction({ type: 'session/ready', session: sessionUri });
assert.strictEqual(manager.getActiveTurnId(sessionUri), undefined);
manager.dispatchServerAction({
type: 'session/turnStarted',
session: sessionUri,
turnId: 'turn-1',
userMessage: { text: 'hello' },
});
assert.strictEqual(manager.getActiveTurnId(sessionUri), 'turn-1');
});
});
@@ -6,7 +6,10 @@
import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js';
import { URI } from '../../../../../../base/common/uri.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { IAgentHostService, AgentHostEnabledSettingId, IAgentDescriptor } from '../../../../../../platform/agentHost/common/agentService.js';
import { IAgentHostService, AgentHostEnabledSettingId, type AgentProvider } from '../../../../../../platform/agentHost/common/agentService.js';
import { isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js';
import { ROOT_STATE_URI, type IAgentInfo, type IRootState } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js';
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
import { ILogService, LogLevel } from '../../../../../../platform/log/common/log.js';
@@ -38,6 +41,10 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
private _outputChannel: IOutputChannel | undefined;
private _isChannelRegistered = false;
private _clientState: SessionClientState | undefined;
private readonly _agentRegistrations = new Map<AgentProvider, DisposableStore>();
/** Model providers keyed by agent provider, for pushing model updates. */
private readonly _modelProviders = new Map<AgentProvider, AgentHostLanguageModelProvider>();
constructor(
@IAgentHostService private readonly _agentHostService: IAgentHostService,
@@ -57,7 +64,30 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
}
this._setupIpcLogging();
this._discoverAndRegisterAgents();
// Shared client state for protocol reconciliation
this._clientState = this._register(new SessionClientState(this._agentHostService.clientId));
// Forward action envelopes from the host to client state
this._register(this._agentHostService.onDidAction(envelope => {
// Only root actions are relevant here; session actions are
// handled by individual session handlers.
if (!isSessionAction(envelope.action)) {
this._clientState!.receiveEnvelope(envelope);
}
}));
// Forward notifications to client state
this._register(this._agentHostService.onDidNotification(n => {
this._clientState!.receiveNotification(n);
}));
// React to root state changes (agent discovery / removal)
this._register(this._clientState.onDidChangeRootState(rootState => {
this._handleRootStateChange(rootState);
}));
this._initializeAndSubscribe();
}
// ---- IPC output channel (trace-level only) ------------------------------
@@ -66,9 +96,12 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
this._updateOutputChannel();
this._register(this._logService.onDidChangeLogLevel(() => this._updateOutputChannel()));
// Subscribe to all progress events for IPC logging
this._register(this._agentHostService.onDidSessionProgress(e => {
this._traceIpc('event', 'onDidSessionProgress', e);
// Subscribe to action / notification streams for IPC logging
this._register(this._agentHostService.onDidAction(e => {
this._traceIpc('event', 'onDidAction', e);
}));
this._register(this._agentHostService.onDidNotification(e => {
this._traceIpc('event', 'onDidNotification', e);
}));
}
@@ -121,22 +154,47 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
this._outputChannel.append(`[${timestamp}] [trace] ${arrow} ${method}${payload ? `\n${payload}` : ''}\n`);
}
private async _discoverAndRegisterAgents(): Promise<void> {
private async _initializeAndSubscribe(): Promise<void> {
try {
const agents = await this._agentHostService.listAgents();
const snapshot = await this._agentHostService.subscribe(ROOT_STATE_URI);
if (this._store.isDisposed) {
return;
}
for (const agent of agents) {
this._registerAgent(agent);
}
// Feed snapshot into client state — fires onDidChangeRootState
this._clientState!.handleSnapshot(ROOT_STATE_URI, snapshot.state, snapshot.fromSeq);
} catch (err) {
this._logService.error(err, '[AgentHost] Failed to discover agents');
this._logService.error('[AgentHost] Failed to subscribe to root state', err);
}
}
private _registerAgent(agent: IAgentDescriptor): void {
const store = this._register(new DisposableStore());
private _handleRootStateChange(rootState: IRootState): void {
const incoming = new Set(rootState.agents.map(a => a.provider));
// Remove agents that are no longer present
for (const [provider, store] of this._agentRegistrations) {
if (!incoming.has(provider)) {
store.dispose();
this._agentRegistrations.delete(provider);
this._modelProviders.delete(provider);
}
}
// Register new agents and push model updates to existing ones
for (const agent of rootState.agents) {
if (!this._agentRegistrations.has(agent.provider)) {
this._registerAgent(agent);
} else {
// Push updated models to existing model provider
const modelProvider = this._modelProviders.get(agent.provider);
modelProvider?.updateModels(agent.models);
}
}
}
private _registerAgent(agent: IAgentInfo): void {
const store = new DisposableStore();
this._agentRegistrations.set(agent.provider, store);
this._register(store);
const sessionType = `agent-host-${agent.provider}`;
const agentId = sessionType;
const vendor = sessionType;
@@ -169,17 +227,18 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
const vendorDescriptor = { vendor, displayName: agent.displayName, configuration: undefined, managementCommand: undefined, when: undefined };
this._languageModelsService.deltaLanguageModelChatProviderDescriptors([vendorDescriptor], []);
store.add(toDisposable(() => this._languageModelsService.deltaLanguageModelChatProviderDescriptors([], [vendorDescriptor])));
const modelProvider = store.add(this._instantiationService.createInstance(AgentHostLanguageModelProvider, sessionType, vendor, agent.provider));
const modelProvider = store.add(new AgentHostLanguageModelProvider(sessionType, vendor));
modelProvider.updateModels(agent.models);
this._modelProviders.set(agent.provider, modelProvider);
store.add(toDisposable(() => this._modelProviders.delete(agent.provider)));
store.add(this._languageModelsService.registerLanguageModelProvider(vendor, modelProvider));
// Auth (only for agents that need it)
if (agent.requiresAuth) {
this._pushAuthToken().then(() => modelProvider.refresh());
store.add(this._defaultAccountService.onDidChangeDefaultAccount(() =>
this._pushAuthToken().then(() => modelProvider.refresh())));
store.add(this._authenticationService.onDidChangeSessions(() =>
this._pushAuthToken().then(() => modelProvider.refresh())));
}
// Push auth token and refresh models from server
this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ });
store.add(this._defaultAccountService.onDidChangeDefaultAccount(() =>
this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ })));
store.add(this._authenticationService.onDidChangeSessions(() =>
this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ })));
}
private async _pushAuthToken(): Promise<void> {
@@ -7,63 +7,60 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js
import { Emitter } from '../../../../../../base/common/event.js';
import { Disposable } from '../../../../../../base/common/lifecycle.js';
import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js';
import { ILogService } from '../../../../../../platform/log/common/log.js';
import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js';
import { ISessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { ILanguageModelChatProvider, ILanguageModelChatMetadataAndIdentifier } from '../../../common/languageModels.js';
/**
* Exposes models available from the agent host process as selectable
* language models in the chat model picker.
* language models in the chat model picker. Models are provided from
* root state (via {@link IAgentInfo.models}) rather than via RPC.
*/
export class AgentHostLanguageModelProvider extends Disposable implements ILanguageModelChatProvider {
private readonly _onDidChange = this._register(new Emitter<void>());
readonly onDidChange = this._onDidChange.event;
private _models: readonly ISessionModelInfo[] = [];
constructor(
private readonly _sessionType: string,
private readonly _vendor: string,
private readonly _provider: string,
@IAgentHostService private readonly _agentHostService: IAgentHostService,
@ILogService private readonly _logService: ILogService,
) {
super();
}
refresh(): void {
/**
* Called by {@link AgentHostContribution} when models change in root state.
*/
updateModels(models: readonly ISessionModelInfo[]): void {
this._models = models;
this._onDidChange.fire();
}
async provideLanguageModelChatInfo(_options: unknown, _token: CancellationToken): Promise<ILanguageModelChatMetadataAndIdentifier[]> {
try {
const models = await this._agentHostService.listModels();
return models
.filter(m => m.provider === this._provider && m.policyState !== 'disabled')
.map(m => ({
identifier: `${this._vendor}:${m.id}`,
metadata: {
extension: new ExtensionIdentifier('vscode.agent-host'),
name: m.name,
id: m.id,
vendor: this._vendor,
version: '1.0',
family: m.id,
maxInputTokens: m.maxContextWindow,
maxOutputTokens: 0,
isDefaultForLocation: {},
isUserSelectable: true,
modelPickerCategory: undefined,
targetChatSessionType: this._sessionType,
capabilities: {
vision: m.supportsVision,
toolCalling: true,
agentMode: true,
},
return this._models
.filter(m => m.policyState !== 'disabled')
.map(m => ({
identifier: `${this._vendor}:${m.id}`,
metadata: {
extension: new ExtensionIdentifier('vscode.agent-host'),
name: m.name,
id: m.id,
vendor: this._vendor,
version: '1.0',
family: m.id,
maxInputTokens: m.maxContextWindow ?? 0,
maxOutputTokens: 0,
isDefaultForLocation: {},
isUserSelectable: true,
modelPickerCategory: undefined,
targetChatSessionType: this._sessionType,
capabilities: {
vision: m.supportsVision ?? false,
toolCalling: true,
agentMode: true,
},
}));
} catch (err) {
this._logService.trace('[AgentHost] Models not available yet, will retry on next refresh');
return [];
}
},
}));
}
async sendChatRequest(): Promise<never> {
@@ -6,114 +6,35 @@
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
import { Emitter } from '../../../../../../base/common/event.js';
import { MarkdownString } from '../../../../../../base/common/htmlContent.js';
import { Disposable, toDisposable } from '../../../../../../base/common/lifecycle.js';
import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js';
import { observableValue } from '../../../../../../base/common/observable.js';
import { generateUuid } from '../../../../../../base/common/uuid.js';
import { URI } from '../../../../../../base/common/uri.js';
import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js';
import { ILogService } from '../../../../../../platform/log/common/log.js';
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
import { IProductService } from '../../../../../../platform/product/common/productService.js';
import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js';
import { IAgentHostService, IAgentAttachment, IAgentMessageEvent, IAgentToolCompleteEvent, IAgentToolStartEvent, AgentProvider, AgentSession, IAgentProgressEvent } from '../../../../../../platform/agentHost/common/agentService.js';
import { IAgentHostService, IAgentAttachment, AgentProvider, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js';
import { isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js';
import { ToolCallStatus, TurnState, type IMessageAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js';
import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../../common/participants/chatAgents.js';
import { IChatProgress, IChatTerminalToolInvocationData, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js';
import { IChatProgress, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js';
import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js';
import { IPreparedToolInvocation, IToolConfirmationMessages, IToolData, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js';
import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem } from '../../../common/chatSessionsService.js';
import { getAgentHostIcon } from '../agentSessions.js';
import { turnsToHistory, toolCallStateToInvocation, permissionToConfirmation, finalizeToolInvocation } from './stateToProgressAdapter.js';
// =============================================================================
// AgentHostSessionHandler - renderer-side handler for a single agent host
// chat session. Bridges the agent host IPC service with the chat UI:
// creates sessions, streams responses, manages tool invocations, and
// reconstructs history for session restore.
// AgentHostSessionHandler — renderer-side handler for a single agent host
// chat session type. Bridges the protocol state layer with the chat UI:
// subscribes to session state, derives IChatProgress[] from immutable state
// changes, and dispatches client actions (turnStarted, permissionResolved,
// turnCancelled) back to the server.
// =============================================================================
/**
* Converts a flat array of IPC events (messages + tool events) into
* request/response history items for the chat model.
*/
function buildHistory(
events: readonly (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[],
history: IChatSessionHistoryItem[],
participantId: string,
): void {
let currentResponseParts: IChatProgress[] | undefined;
for (const e of events) {
if (e.type === 'message') {
if (e.role === 'user') {
if (currentResponseParts) {
history.push({ type: 'response', parts: currentResponseParts, participant: participantId });
currentResponseParts = undefined;
}
history.push({ type: 'request', prompt: e.content, participant: participantId });
} else {
if (!currentResponseParts) {
currentResponseParts = [];
}
if (e.content) {
currentResponseParts.push({ kind: 'markdownContent', content: new MarkdownString(e.content) });
}
}
} else if (e.type === 'tool_start') {
if (!currentResponseParts) {
currentResponseParts = [];
}
const toolSpecificData = (e.toolKind === 'terminal' && e.toolInput)
? { kind: 'terminal' as const, commandLine: { original: e.toolInput }, language: e.language ?? 'shellscript' }
: undefined;
currentResponseParts.push({
kind: 'toolInvocationSerialized',
toolCallId: e.toolCallId,
toolId: e.toolName,
source: ToolDataSource.Internal,
invocationMessage: new MarkdownString(e.invocationMessage),
originMessage: undefined,
pastTenseMessage: undefined,
isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded },
isComplete: false,
presentation: undefined,
toolSpecificData,
} satisfies IChatToolInvocationSerialized);
} else if (e.type === 'tool_complete') {
if (currentResponseParts) {
const idx = currentResponseParts.findIndex(
p => p.kind === 'toolInvocationSerialized' && p.toolCallId === e.toolCallId
);
if (idx >= 0) {
const existing = currentResponseParts[idx] as IChatToolInvocationSerialized;
const isTerminal = existing.toolSpecificData?.kind === 'terminal';
currentResponseParts[idx] = {
...existing,
isComplete: true,
pastTenseMessage: isTerminal ? undefined : new MarkdownString(e.pastTenseMessage),
toolSpecificData: isTerminal
? {
...existing.toolSpecificData as IChatTerminalToolInvocationData,
terminalCommandOutput: e.toolOutput !== undefined ? { text: e.toolOutput } : undefined,
terminalCommandState: { exitCode: e.success ? 0 : 1 },
}
: existing.toolSpecificData,
};
}
}
}
}
// Mark incomplete tool invocations as complete (orphaned tool_start without tool_complete)
if (currentResponseParts) {
for (let i = 0; i < currentResponseParts.length; i++) {
const part = currentResponseParts[i];
if (part.kind === 'toolInvocationSerialized' && !part.isComplete) {
currentResponseParts[i] = { ...part, isComplete: true };
}
}
history.push({ type: 'response', parts: currentResponseParts, participant: participantId });
}
}
// =============================================================================
// Chat session
// =============================================================================
@@ -131,7 +52,7 @@ class AgentHostChatSession extends Disposable implements IChatSession {
constructor(
readonly sessionResource: URI,
readonly history: readonly IChatSessionHistoryItem[],
private readonly _sendRequest: (message: string, progress: (parts: IChatProgress[]) => void, token: CancellationToken) => Promise<void>,
private readonly _sendRequest: (request: IChatAgentRequest, progress: (parts: IChatProgress[]) => void, token: CancellationToken) => Promise<void>,
onDispose: () => void,
@ILogService private readonly _logService: ILogService,
) {
@@ -143,7 +64,7 @@ class AgentHostChatSession extends Disposable implements IChatSession {
this.requestHandler = async (request, progress, _history, cancellationToken) => {
this._logService.info('[AgentHost] requestHandler called');
this.isCompleteObs.set(false, undefined);
await this._sendRequest(request.message, progress, cancellationToken);
await this._sendRequest(request, progress, cancellationToken);
this.isCompleteObs.set(true, undefined);
};
@@ -167,10 +88,14 @@ export interface IAgentHostSessionHandlerConfig {
export class AgentHostSessionHandler extends Disposable implements IChatSessionContentProvider {
private readonly _resourceToSession = new Map<string, URI>();
private readonly _activeSessions = new Map<string, AgentHostChatSession>();
/** Maps UI resource keys to resolved backend session URIs. */
private readonly _sessionToBackend = new Map<string, URI>();
private readonly _config: IAgentHostSessionHandlerConfig;
/** Client state manager shared across all sessions for this handler. */
private readonly _clientState: SessionClientState;
constructor(
config: IAgentHostSessionHandlerConfig,
@IAgentHostService private readonly _agentHostService: IAgentHostService,
@@ -182,35 +107,69 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
) {
super();
this._config = config;
// Create shared client state manager for this handler instance
this._clientState = this._register(new SessionClientState(this._agentHostService.clientId));
// Forward action envelopes from IPC to client state
this._register(this._agentHostService.onDidAction(envelope => {
if (isSessionAction(envelope.action)) {
this._clientState.receiveEnvelope(envelope);
}
}));
this._registerAgent();
}
async provideChatSessionContent(sessionResource: URI, _token: CancellationToken): Promise<IChatSession> {
const resourceKey = sessionResource.path.substring(1);
const resolvedSession = await this._resolveSession(sessionResource);
// For untitled (new) sessions, defer backend session creation until the
// first request arrives so the user-selected model is available.
// For existing sessions we resolve immediately to load history.
let resolvedSession: URI | undefined;
const isUntitled = resourceKey.startsWith('untitled-');
const history: IChatSessionHistoryItem[] = [];
if (!resourceKey.startsWith('untitled-')) {
const events = await this._agentHostService.getSessionMessages(resolvedSession);
buildHistory(events, history, this._config.agentId);
if (!isUntitled) {
resolvedSession = this._resolveSessionUri(sessionResource);
this._sessionToBackend.set(resourceKey, resolvedSession);
try {
const snapshot = await this._agentHostService.subscribe(resolvedSession);
this._clientState.handleSnapshot(resolvedSession, snapshot.state, snapshot.fromSeq);
const sessionState = this._clientState.getSessionState(resolvedSession);
if (sessionState) {
history.push(...turnsToHistory(sessionState.turns, this._config.agentId));
}
} catch (err) {
this._logService.warn(`[AgentHost] Failed to subscribe to existing session: ${resolvedSession.toString()}`, err);
}
}
const session = this._instantiationService.createInstance(
AgentHostChatSession,
sessionResource,
history,
(message: string, progress: (parts: IChatProgress[]) => void, token: CancellationToken) =>
this._sendAndStreamResponse(resolvedSession, message, [], progress, token),
async (request: IChatAgentRequest, progress: (parts: IChatProgress[]) => void, token: CancellationToken) => {
const backendSession = resolvedSession ?? await this._createAndSubscribe(sessionResource, request.userSelectedModelId);
resolvedSession = backendSession;
this._sessionToBackend.set(resourceKey, backendSession);
return this._handleTurn(backendSession, request, progress, token);
},
() => {
this._activeSessions.delete(resourceKey);
this._resourceToSession.delete(sessionResource.toString());
this._agentHostService.disposeSession(resolvedSession);
this._sessionToBackend.delete(resourceKey);
if (resolvedSession) {
this._clientState.unsubscribe(resolvedSession);
this._agentHostService.unsubscribe(resolvedSession);
this._agentHostService.disposeSession(resolvedSession);
}
},
);
this._activeSessions.set(resourceKey, session);
return session;
}
// ---- Agent registration -------------------------------------------------
private _registerAgent(): void {
const agentData: IChatAgentData = {
id: this._config.agentId,
@@ -246,13 +205,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
cancellationToken: CancellationToken,
): Promise<IChatAgentResult> {
this._logService.info(`[AgentHost] _invokeAgent called for resource: ${request.sessionResource.toString()}`);
const session = await this._resolveSession(request.sessionResource, request.userSelectedModelId);
this._logService.info(`[AgentHost] resolved session: ${session.toString()}`);
const attachments = this._convertVariablesToAttachments(request);
await this._sendAndStreamResponse(session, request.message, attachments, progress, cancellationToken);
// Resolve or create backend session
const resourceKey = request.sessionResource.path.substring(1);
let resolvedSession = this._sessionToBackend.get(resourceKey);
if (!resolvedSession) {
resolvedSession = await this._createAndSubscribe(request.sessionResource, request.userSelectedModelId);
this._sessionToBackend.set(resourceKey, resolvedSession);
}
await this._handleTurn(resolvedSession, request, progress, cancellationToken);
const activeSession = this._activeSessions.get(resourceKey);
if (activeSession) {
activeSession.isCompleteObs.set(true, undefined);
@@ -261,10 +224,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
return {};
}
private async _sendAndStreamResponse(
// ---- Turn handling (state-driven) ---------------------------------------
private async _handleTurn(
session: URI,
message: string,
attachments: IAgentAttachment[],
request: IChatAgentRequest,
progress: (parts: IChatProgress[]) => void,
cancellationToken: CancellationToken,
): Promise<void> {
@@ -272,7 +236,54 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
return;
}
const turnId = generateUuid();
const attachments = this._convertVariablesToAttachments(request);
const messageAttachments: IMessageAttachment[] = attachments.map(a => ({
type: a.type,
path: a.path,
displayName: a.displayName,
}));
// If the user selected a different model since the session was created
// (or since the last turn), dispatch a model change action first so the
// agent backend picks up the new model before processing the turn.
const rawModelId = this._extractRawModelId(request.userSelectedModelId);
if (rawModelId) {
const currentModel = this._clientState.getSessionState(session)?.summary.model;
if (currentModel !== rawModelId) {
const modelAction = {
type: 'session/modelChanged' as const,
session,
model: rawModelId,
};
const modelSeq = this._clientState.applyOptimistic(modelAction);
this._agentHostService.dispatchAction(modelAction, this._clientState.clientId, modelSeq);
}
}
// Dispatch session/turnStarted — the server will call sendMessage on
// the provider as a side effect.
const turnAction = {
type: 'session/turnStarted' as const,
session,
turnId,
userMessage: {
text: request.message,
attachments: messageAttachments.length > 0 ? messageAttachments : undefined,
},
};
const clientSeq = this._clientState.applyOptimistic(turnAction);
this._agentHostService.dispatchAction(turnAction, this._clientState.clientId, clientSeq);
// Track live ChatToolInvocation/permission objects for this turn
const activeToolInvocations = new Map<string, ChatToolInvocation>();
const activePermissions = new Map<string, ChatToolInvocation>();
// Track last-emitted lengths to compute deltas from immutable state
let lastStreamedTextLen = 0;
let lastReasoningLen = 0;
const turnDisposables = new DisposableStore();
let resolveDone: () => void;
const done = new Promise<void>(resolve => { resolveDone = resolve; });
@@ -283,237 +294,157 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
return;
}
finished = true;
this._finalizeOutstandingTools(activeToolInvocations);
listener.dispose();
// Finalize any outstanding tool invocations
for (const [, invocation] of activeToolInvocations) {
invocation.didExecuteTool(undefined);
}
activeToolInvocations.clear();
turnDisposables.dispose();
resolveDone();
};
const sessionStr = session.toString();
const listener = this._agentHostService.onDidSessionProgress(e => {
if (e.session.toString() !== sessionStr || cancellationToken.isCancellationRequested) {
// Listen to state changes and translate to IChatProgress[]
turnDisposables.add(this._clientState.onDidChangeSessionState(e => {
if (e.session.toString() !== session.toString() || cancellationToken.isCancellationRequested) {
return;
}
switch (e.type) {
case 'delta':
this._logService.trace(`[AgentHost:${sessionStr}] delta: ${e.content.length} chars`);
progress([{ kind: 'markdownContent', content: new MarkdownString(e.content) }]);
break;
const activeTurn = e.state.activeTurn;
case 'tool_start': {
this._logService.trace(`[AgentHost:${sessionStr}] tool_start: ${e.toolName} (${e.toolCallId}), kind=${e.toolKind ?? 'generic'}`);
const invocation = this._createToolInvocation(e);
activeToolInvocations.set(e.toolCallId, invocation);
progress([invocation]);
break;
if (!activeTurn || activeTurn.id !== turnId) {
// Turn completed (activeTurn cleared by reducer).
// Check if the finalized turn ended with an error and emit it.
const lastTurn = e.state.turns[e.state.turns.length - 1];
if (lastTurn?.id === turnId && lastTurn.state === TurnState.Error && lastTurn.error) {
progress([{ kind: 'markdownContent', content: new MarkdownString(`\n\nError: (${lastTurn.error.errorType}) ${lastTurn.error.message}`) }]);
}
case 'tool_complete': {
this._logService.trace(`[AgentHost:${sessionStr}] tool_complete: ${e.toolCallId}, success=${e.success}`);
const invocation = activeToolInvocations.get(e.toolCallId);
if (invocation) {
activeToolInvocations.delete(e.toolCallId);
this._finalizeToolInvocation(invocation, e);
} else {
this._logService.trace(`[AgentHost:${sessionStr}] tool_complete for unknown toolCallId: ${e.toolCallId}`);
}
break;
}
case 'idle':
this._logService.trace(`[AgentHost:${sessionStr}] idle, finishing`);
if (!finished) {
finish();
break;
case 'error':
this._logService.error(`[AgentHost:${sessionStr}] error: (${e.errorType}) ${e.message}`);
progress([{ kind: 'markdownContent', content: new MarkdownString(`\n\nError: (${e.errorType}) ${e.message}`) }]);
finish();
break;
case 'usage':
this._logService.trace(`[AgentHost:${sessionStr}] usage: model=${e.model}, in=${e.inputTokens ?? '?'}, out=${e.outputTokens ?? '?'}`);
break;
case 'title_changed':
this._logService.trace(`[AgentHost:${sessionStr}] title changed: ${e.title}`);
break;
case 'reasoning':
this._logService.trace(`[AgentHost:${sessionStr}] reasoning: ${e.content.length} chars`);
progress([{ kind: 'thinking', value: e.content }]);
break;
case 'permission_request': {
this._logService.info(`[AgentHost:${sessionStr}] permission_request: kind=${e.permissionKind}, requestId=${e.requestId}, path=${e.path ?? '(none)'}`);
const confirmInvocation = this._createPermissionConfirmation(e);
progress([confirmInvocation]);
IChatToolInvocation.awaitConfirmation(confirmInvocation, cancellationToken).then(reason => {
const approved = reason.type !== ToolConfirmKind.Denied && reason.type !== ToolConfirmKind.Skipped;
this._logService.info(`[AgentHost:${sessionStr}] permission response: requestId=${e.requestId}, approved=${approved} (kind=${reason.type})`);
this._agentHostService.respondToPermissionRequest(e.requestId, approved);
if (approved) {
confirmInvocation.didExecuteTool(undefined);
} else {
confirmInvocation.didExecuteTool({ content: [], toolResultError: 'User denied' });
}
});
break;
}
default:
this._logService.trace(`[AgentHost:${sessionStr}] unhandled event type: ${(e as IAgentProgressEvent).type}`);
break;
return;
}
});
const cancelListener = cancellationToken.onCancellationRequested(() => {
this._logService.info(`[AgentHost] Cancellation requested for ${sessionStr}, aborting...`);
this._agentHostService.abortSession(session).catch(err => {
this._logService.error(`[AgentHost] abortSession failed`, err);
});
finish();
cancelListener.dispose();
});
// Stream text deltas
if (activeTurn.streamingText.length > lastStreamedTextLen) {
const delta = activeTurn.streamingText.substring(lastStreamedTextLen);
lastStreamedTextLen = activeTurn.streamingText.length;
progress([{ kind: 'markdownContent', content: new MarkdownString(delta) }]);
}
try {
this._logService.info(`[AgentHost] Sending message to session ${session.toString()} (${attachments.length} attachments)`);
await this._agentHostService.sendMessage(session, message, attachments.length > 0 ? attachments : undefined);
this._logService.info(`[AgentHost] sendMessage returned for session ${session.toString()}`);
} catch (err) {
this._logService.error(`[AgentHost] [${session.toString()}] sendMessage failed`, err);
// Stream reasoning deltas
if (activeTurn.reasoning.length > lastReasoningLen) {
const delta = activeTurn.reasoning.substring(lastReasoningLen);
lastReasoningLen = activeTurn.reasoning.length;
progress([{ kind: 'thinking', value: delta }]);
}
// Handle tool calls — create/finalize ChatToolInvocations
for (const [toolCallId, tc] of activeTurn.toolCalls) {
const existing = activeToolInvocations.get(toolCallId);
if (!existing) {
if (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.PendingPermission) {
const invocation = toolCallStateToInvocation(tc);
activeToolInvocations.set(toolCallId, invocation);
progress([invocation]);
}
} else if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Failed) {
activeToolInvocations.delete(toolCallId);
finalizeToolInvocation(existing, tc);
}
}
// Handle permission requests
for (const [requestId, perm] of activeTurn.pendingPermissions) {
if (activePermissions.has(requestId)) {
continue;
}
const confirmInvocation = permissionToConfirmation(perm);
activePermissions.set(requestId, confirmInvocation);
progress([confirmInvocation]);
IChatToolInvocation.awaitConfirmation(confirmInvocation, cancellationToken).then(reason => {
const approved = reason.type !== ToolConfirmKind.Denied && reason.type !== ToolConfirmKind.Skipped;
this._logService.info(`[AgentHost] Permission response: requestId=${requestId}, approved=${approved}`);
const resolveAction = {
type: 'session/permissionResolved' as const,
session,
turnId,
requestId,
approved,
};
const seq = this._clientState.applyOptimistic(resolveAction);
this._agentHostService.dispatchAction(resolveAction, this._clientState.clientId, seq);
if (approved) {
confirmInvocation.didExecuteTool(undefined);
} else {
confirmInvocation.didExecuteTool({ content: [], toolResultError: 'User denied' });
}
}).catch(err => {
this._logService.warn(`[AgentHost] Permission confirmation failed for requestId=${requestId}`, err);
});
}
}));
turnDisposables.add(cancellationToken.onCancellationRequested(() => {
this._logService.info(`[AgentHost] Cancellation requested for ${session.toString()}, dispatching turnCancelled`);
const cancelAction = {
type: 'session/turnCancelled' as const,
session,
turnId,
};
const seq = this._clientState.applyOptimistic(cancelAction);
this._agentHostService.dispatchAction(cancelAction, this._clientState.clientId, seq);
finish();
}
}));
await done;
cancelListener.dispose();
}
private async _resolveSession(sessionResource: URI, model?: string): Promise<URI> {
if (sessionResource.scheme === this._config.sessionType && !sessionResource.path.startsWith('/untitled-')) {
// Convert UI resource scheme (e.g. agent-host) to provider URI scheme (e.g. copilot)
const rawId = sessionResource.path.substring(1);
const session = AgentSession.uri(this._config.provider, rawId);
this._logService.trace(`[AgentHost] Resolved existing session: ${sessionResource.toString()} -> ${session.toString()}`);
return session;
}
// ---- Session resolution -------------------------------------------------
const key = sessionResource.toString();
const existing = this._resourceToSession.get(key);
if (existing) {
this._logService.trace(`[AgentHost] Reusing mapped session: ${key} -> ${existing.toString()}`);
return existing;
}
/** Maps a UI session resource to a backend provider URI. */
private _resolveSessionUri(sessionResource: URI): URI {
const rawId = sessionResource.path.substring(1);
return AgentSession.uri(this._config.provider, rawId);
}
this._logService.trace(`[AgentHost] Creating new session for resource ${key}, model=${model ?? '(default)'}, provider=${this._config.provider}`);
/** Creates a new backend session and subscribes to its state. */
private async _createAndSubscribe(sessionResource: URI, modelId?: string): Promise<URI> {
const rawModelId = this._extractRawModelId(modelId);
const workspaceFolder = this._workspaceContextService.getWorkspace().folders[0];
this._logService.trace(`[AgentHost] Creating new session, model=${rawModelId ?? '(default)'}, provider=${this._config.provider}`);
const session = await this._agentHostService.createSession({
model,
model: rawModelId,
provider: this._config.provider,
workingDirectory: workspaceFolder?.uri.fsPath,
});
this._logService.trace(`[AgentHost] Created new session: ${session.toString()}`);
this._resourceToSession.set(key, session);
this._logService.trace(`[AgentHost] Created session: ${session.toString()}`);
// Subscribe to the new session's state
try {
const snapshot = await this._agentHostService.subscribe(session);
this._clientState.handleSnapshot(session, snapshot.state, snapshot.fromSeq);
} catch (err) {
this._logService.error(`[AgentHost] Failed to subscribe to new session: ${session.toString()}`, err);
}
return session;
}
private _createToolInvocation(event: IAgentToolStartEvent): ChatToolInvocation {
const toolData: IToolData = {
id: event.toolName,
source: ToolDataSource.Internal,
displayName: event.displayName,
modelDescription: event.toolName,
};
let parameters: unknown;
if (event.toolArguments) {
try {
parameters = JSON.parse(event.toolArguments);
} catch {
// malformed JSON
}
/**
* Extracts the raw model id from a language-model service identifier.
* E.g. "agent-host-copilot:claude-sonnet-4-20250514" → "claude-sonnet-4-20250514".
*/
private _extractRawModelId(languageModelIdentifier: string | undefined): string | undefined {
if (!languageModelIdentifier) {
return undefined;
}
const invocation = new ChatToolInvocation(undefined, toolData, event.toolCallId, undefined, parameters);
invocation.invocationMessage = new MarkdownString(event.invocationMessage);
if (event.toolKind === 'terminal' && event.toolInput) {
invocation.toolSpecificData = {
kind: 'terminal',
commandLine: { original: event.toolInput },
language: event.language ?? 'shellscript',
} satisfies IChatTerminalToolInvocationData;
const prefix = this._config.sessionType + ':';
if (languageModelIdentifier.startsWith(prefix)) {
return languageModelIdentifier.substring(prefix.length);
}
return invocation;
}
private _createPermissionConfirmation(event: import('../../../../../../platform/agentHost/common/agentService.js').IAgentPermissionRequestEvent): ChatToolInvocation {
let title: string;
let toolSpecificData: IChatTerminalToolInvocationData | IChatToolInputInvocationData | undefined;
switch (event.permissionKind) {
case 'shell': {
title = event.intention ?? 'Run command';
toolSpecificData = event.fullCommandText ? {
kind: 'terminal',
commandLine: { original: event.fullCommandText },
language: 'shellscript',
} : undefined;
break;
}
case 'write': {
title = event.path ? `Edit ${event.path}` : 'Edit file';
let rawInput: unknown;
try { rawInput = JSON.parse(event.rawRequest); } catch { rawInput = { path: event.path }; }
toolSpecificData = { kind: 'input', rawInput };
break;
}
case 'mcp': {
const toolTitle = event.toolName ?? 'MCP Tool';
title = event.serverName ? `${event.serverName}: ${toolTitle}` : toolTitle;
let rawInput: unknown;
try { rawInput = JSON.parse(event.rawRequest); } catch { rawInput = { serverName: event.serverName, toolName: event.toolName }; }
toolSpecificData = { kind: 'input', rawInput };
break;
}
case 'read': {
title = event.intention ?? 'Read file';
let rawInput: unknown;
try { rawInput = JSON.parse(event.rawRequest); } catch { rawInput = { path: event.path, intention: event.intention }; }
toolSpecificData = { kind: 'input', rawInput };
break;
}
default: {
title = 'Permission request';
let rawInput: unknown;
try { rawInput = JSON.parse(event.rawRequest); } catch { rawInput = {}; }
toolSpecificData = { kind: 'input', rawInput };
break;
}
}
const confirmationMessages: IToolConfirmationMessages = {
title: new MarkdownString(title),
message: new MarkdownString(''),
};
const toolData: IToolData = {
id: `permission_${event.permissionKind}`,
source: ToolDataSource.Internal,
displayName: title,
modelDescription: '',
};
const preparedInvocation: IPreparedToolInvocation = {
invocationMessage: new MarkdownString(title),
confirmationMessages,
presentation: ToolInvocationPresentation.HiddenAfterComplete,
toolSpecificData,
};
return new ChatToolInvocation(preparedInvocation, toolData, event.requestId, undefined, undefined);
return languageModelIdentifier;
}
private _convertVariablesToAttachments(request: IChatAgentRequest): IAgentAttachment[] {
@@ -542,34 +473,14 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
return attachments;
}
private _finalizeToolInvocation(invocation: ChatToolInvocation, event: IAgentToolCompleteEvent): void {
if (invocation.toolSpecificData?.kind === 'terminal') {
const terminalData = invocation.toolSpecificData as IChatTerminalToolInvocationData;
invocation.toolSpecificData = {
...terminalData,
terminalCommandOutput: event.toolOutput !== undefined ? { text: event.toolOutput } : undefined,
terminalCommandState: { exitCode: event.success ? 0 : 1 },
};
} else {
invocation.pastTenseMessage = new MarkdownString(event.pastTenseMessage);
}
invocation.didExecuteTool(!event.success ? { content: [], toolResultError: event.error?.message } : undefined);
}
private _finalizeOutstandingTools(activeToolInvocations: Map<string, ChatToolInvocation>): void {
for (const [id, invocation] of activeToolInvocations) {
invocation.didExecuteTool(undefined);
activeToolInvocations.delete(id);
}
}
// ---- Lifecycle ----------------------------------------------------------
override dispose(): void {
for (const [, session] of this._activeSessions) {
session.dispose();
}
this._activeSessions.clear();
this._resourceToSession.clear();
this._sessionToBackend.clear();
super.dispose();
}
}
@@ -3,18 +3,20 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js';
import { Emitter } from '../../../../../../base/common/event.js';
import { Disposable } from '../../../../../../base/common/lifecycle.js';
import { URI } from '../../../../../../base/common/uri.js';
import { IProductService } from '../../../../../../platform/product/common/productService.js';
import { IAgentHostService, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js';
import { isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
import { ChatSessionStatus, IChatSessionItem, IChatSessionItemController } from '../../../common/chatSessionsService.js';
import { getAgentHostIcon } from '../agentSessions.js';
/**
* Provides session list items for the chat sessions sidebar by querying
* active sessions from the agent host process.
* active sessions from the agent host process. Listens to protocol
* notifications for incremental updates.
*/
export class AgentHostSessionListController extends Disposable implements IChatSessionItemController {
@@ -30,6 +32,40 @@ export class AgentHostSessionListController extends Disposable implements IChatS
@IProductService private readonly _productService: IProductService,
) {
super();
// React to protocol notifications for session list changes
this._register(this._agentHostService.onDidNotification(n => {
if (n.type === 'notify/sessionAdded' && n.summary.provider === this._provider) {
const rawId = AgentSession.id(n.summary.resource);
this._items.push({
resource: URI.from({ scheme: this._sessionType, path: `/${rawId}` }),
label: n.summary.title ?? `Session ${rawId.substring(0, 8)}`,
iconPath: getAgentHostIcon(this._productService),
status: ChatSessionStatus.Completed,
timing: {
created: n.summary.createdAt,
lastRequestStarted: n.summary.modifiedAt,
lastRequestEnded: n.summary.modifiedAt,
},
});
this._onDidChangeChatSessionItems.fire();
} else if (n.type === 'notify/sessionRemoved') {
const removedId = AgentSession.id(n.session);
const idx = this._items.findIndex(item => item.resource.path === `/${removedId}`);
if (idx >= 0) {
this._items.splice(idx, 1);
this._onDidChangeChatSessionItems.fire();
}
}
}));
// Refresh on turnComplete actions for metadata updates (title, timing)
this._register(this._agentHostService.onDidAction(e => {
if (e.action.type === 'session/turnComplete' && isSessionAction(e.action) && AgentSession.provider(e.action.session) === this._provider) {
const cts = new CancellationTokenSource();
this.refresh(cts.token).finally(() => cts.dispose());
}
}));
}
get items(): readonly IChatSessionItem[] {
@@ -0,0 +1,199 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { MarkdownString } from '../../../../../../base/common/htmlContent.js';
import { ToolCallStatus, TurnState, type ICompletedToolCall, type IPermissionRequest, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { type IChatProgress, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js';
import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js';
import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js';
import { type IPreparedToolInvocation, type IToolConfirmationMessages, type IToolData, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js';
/**
* Converts completed turns from the protocol state into session history items.
*/
export function turnsToHistory(turns: readonly ITurn[], participantId: string): IChatSessionHistoryItem[] {
const history: IChatSessionHistoryItem[] = [];
for (const turn of turns) {
// Request
history.push({ type: 'request', prompt: turn.userMessage.text, participant: participantId });
// Response parts
const parts: IChatProgress[] = [];
// Assistant response text
if (turn.responseText) {
parts.push({ kind: 'markdownContent', content: new MarkdownString(turn.responseText) });
}
// Completed tool calls
for (const tc of turn.toolCalls) {
parts.push(completedToolCallToSerialized(tc));
}
// Error message for failed turns
if (turn.state === TurnState.Error && turn.error) {
parts.push({ kind: 'markdownContent', content: new MarkdownString(`\n\nError: (${turn.error.errorType}) ${turn.error.message}`) });
}
history.push({ type: 'response', parts, participant: participantId });
}
return history;
}
/**
* Converts a completed tool call from the protocol state into a serialized
* tool invocation suitable for history replay.
*/
function completedToolCallToSerialized(tc: ICompletedToolCall): IChatToolInvocationSerialized {
const isTerminal = tc.toolKind === 'terminal';
let toolSpecificData: IChatTerminalToolInvocationData | undefined;
if (isTerminal && tc.toolInput) {
toolSpecificData = {
kind: 'terminal',
commandLine: { original: tc.toolInput },
language: tc.language ?? 'shellscript',
terminalCommandOutput: tc.toolOutput !== undefined ? { text: tc.toolOutput } : undefined,
terminalCommandState: { exitCode: tc.success ? 0 : 1 },
};
}
return {
kind: 'toolInvocationSerialized',
toolCallId: tc.toolCallId,
toolId: tc.toolName,
source: ToolDataSource.Internal,
invocationMessage: new MarkdownString(tc.invocationMessage),
originMessage: undefined,
pastTenseMessage: isTerminal ? undefined : new MarkdownString(tc.pastTenseMessage),
isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded },
isComplete: true,
presentation: undefined,
toolSpecificData,
};
}
/**
* Creates a live {@link ChatToolInvocation} from the protocol's tool-call
* state. Used during active turns to represent running tool calls in the UI.
*/
export function toolCallStateToInvocation(tc: IToolCallState): ChatToolInvocation {
const toolData: IToolData = {
id: tc.toolName,
source: ToolDataSource.Internal,
displayName: tc.displayName,
modelDescription: tc.toolName,
};
let parameters: unknown;
if (tc.toolArguments) {
try { parameters = JSON.parse(tc.toolArguments); } catch { /* malformed JSON */ }
}
const invocation = new ChatToolInvocation(undefined, toolData, tc.toolCallId, undefined, parameters);
invocation.invocationMessage = new MarkdownString(tc.invocationMessage);
if (tc.toolKind === 'terminal' && tc.toolInput) {
invocation.toolSpecificData = {
kind: 'terminal',
commandLine: { original: tc.toolInput },
language: tc.language ?? 'shellscript',
} satisfies IChatTerminalToolInvocationData;
}
return invocation;
}
/**
* Creates a {@link ChatToolInvocation} with confirmation messages from a
* protocol permission request. The resulting invocation starts in the
* waiting-for-confirmation state.
*/
export function permissionToConfirmation(perm: IPermissionRequest): ChatToolInvocation {
let title: string;
let toolSpecificData: IChatTerminalToolInvocationData | IChatToolInputInvocationData | undefined;
switch (perm.permissionKind) {
case 'shell': {
title = perm.intention ?? 'Run command';
toolSpecificData = perm.fullCommandText ? {
kind: 'terminal',
commandLine: { original: perm.fullCommandText },
language: 'shellscript',
} : undefined;
break;
}
case 'write': {
title = perm.path ? `Edit ${perm.path}` : 'Edit file';
let rawInput: unknown;
try { rawInput = perm.rawRequest ? JSON.parse(perm.rawRequest) : { path: perm.path }; } catch { rawInput = { path: perm.path }; }
toolSpecificData = { kind: 'input', rawInput };
break;
}
case 'mcp': {
const toolTitle = perm.toolName ?? 'MCP Tool';
title = perm.serverName ? `${perm.serverName}: ${toolTitle}` : toolTitle;
let rawInput: unknown;
try { rawInput = perm.rawRequest ? JSON.parse(perm.rawRequest) : { serverName: perm.serverName, toolName: perm.toolName }; } catch { rawInput = { serverName: perm.serverName, toolName: perm.toolName }; }
toolSpecificData = { kind: 'input', rawInput };
break;
}
case 'read': {
title = perm.intention ?? 'Read file';
let rawInput: unknown;
try { rawInput = perm.rawRequest ? JSON.parse(perm.rawRequest) : { path: perm.path, intention: perm.intention }; } catch { rawInput = { path: perm.path, intention: perm.intention }; }
toolSpecificData = { kind: 'input', rawInput };
break;
}
default: {
title = 'Permission request';
let rawInput: unknown;
try { rawInput = perm.rawRequest ? JSON.parse(perm.rawRequest) : {}; } catch { rawInput = {}; }
toolSpecificData = { kind: 'input', rawInput };
break;
}
}
const confirmationMessages: IToolConfirmationMessages = {
title: new MarkdownString(title),
message: new MarkdownString(''),
};
const toolData: IToolData = {
id: `permission_${perm.permissionKind}`,
source: ToolDataSource.Internal,
displayName: title,
modelDescription: '',
};
const preparedInvocation: IPreparedToolInvocation = {
invocationMessage: new MarkdownString(title),
confirmationMessages,
presentation: ToolInvocationPresentation.HiddenAfterComplete,
toolSpecificData,
};
return new ChatToolInvocation(preparedInvocation, toolData, perm.requestId, undefined, undefined);
}
/**
* Updates a live {@link ChatToolInvocation} with completion data from the
* protocol's tool-call state, transitioning it to the completed state.
*/
export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: IToolCallState): void {
if (invocation.toolSpecificData?.kind === 'terminal') {
const terminalData = invocation.toolSpecificData as IChatTerminalToolInvocationData;
invocation.toolSpecificData = {
...terminalData,
terminalCommandOutput: tc.toolOutput !== undefined ? { text: tc.toolOutput } : undefined,
terminalCommandState: { exitCode: tc.status === ToolCallStatus.Completed ? 0 : 1 },
};
} else if (tc.pastTenseMessage) {
invocation.pastTenseMessage = new MarkdownString(tc.pastTenseMessage);
}
const isFailure = tc.status === ToolCallStatus.Failed;
invocation.didExecuteTool(isFailure ? { content: [], toolResultError: tc.error?.message } : undefined);
}
@@ -0,0 +1,298 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { ToolCallStatus, TurnState, type ICompletedToolCall, type IPermissionRequest, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { IChatToolInvocationSerialized, type IChatMarkdownContent } from '../../../common/chatService/chatService.js';
import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js';
import { turnsToHistory, toolCallStateToInvocation, permissionToConfirmation, finalizeToolInvocation } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js';
// ---- Helper factories -------------------------------------------------------
function createToolCallState(overrides?: Partial<IToolCallState>): IToolCallState {
return {
toolCallId: 'tc-1',
toolName: 'test_tool',
displayName: 'Test Tool',
invocationMessage: 'Running test tool...',
status: ToolCallStatus.Running,
...overrides,
};
}
function createCompletedToolCall(overrides?: Partial<ICompletedToolCall>): ICompletedToolCall {
return {
toolCallId: 'tc-1',
toolName: 'test_tool',
displayName: 'Test Tool',
invocationMessage: 'Running test tool...',
success: true,
pastTenseMessage: 'Ran test tool',
...overrides,
};
}
function createTurn(overrides?: Partial<ITurn>): ITurn {
return {
id: 'turn-1',
userMessage: { text: 'Hello' },
responseText: '',
responseParts: [],
toolCalls: [],
usage: undefined,
state: TurnState.Complete,
...overrides,
};
}
function createPermission(overrides?: Partial<IPermissionRequest>): IPermissionRequest {
return {
requestId: 'perm-1',
permissionKind: 'shell',
...overrides,
};
}
// ---- Tests ------------------------------------------------------------------
suite('stateToProgressAdapter', () => {
ensureNoDisposablesAreLeakedInTestSuite();
suite('turnsToHistory', () => {
test('empty turns produces empty history', () => {
const result = turnsToHistory([], 'p');
assert.deepStrictEqual(result, []);
});
test('single turn produces request + response pair', () => {
const turn = createTurn({
userMessage: { text: 'Do something' },
toolCalls: [createCompletedToolCall()],
});
const history = turnsToHistory([turn], 'participant-1');
assert.strictEqual(history.length, 2);
// Request
assert.strictEqual(history[0].type, 'request');
assert.strictEqual(history[0].prompt, 'Do something');
assert.strictEqual(history[0].participant, 'participant-1');
// Response
assert.strictEqual(history[1].type, 'response');
assert.strictEqual(history[1].participant, 'participant-1');
assert.strictEqual(history[1].parts.length, 1);
const serialized = history[1].parts[0] as IChatToolInvocationSerialized;
assert.strictEqual(serialized.kind, 'toolInvocationSerialized');
assert.strictEqual(serialized.toolCallId, 'tc-1');
assert.strictEqual(serialized.toolId, 'test_tool');
assert.strictEqual(serialized.isComplete, true);
});
test('terminal tool call in history has correct terminal data', () => {
const turn = createTurn({
toolCalls: [createCompletedToolCall({
toolKind: 'terminal',
toolInput: 'echo hello',
language: 'shellscript',
toolOutput: 'hello',
success: true,
})],
});
const history = turnsToHistory([turn], 'p');
const response = history[1];
assert.strictEqual(response.type, 'response');
if (response.type !== 'response') { return; }
const serialized = response.parts[0] as IChatToolInvocationSerialized;
assert.ok(serialized.toolSpecificData);
assert.strictEqual(serialized.toolSpecificData.kind, 'terminal');
const termData = serialized.toolSpecificData as { kind: 'terminal'; commandLine: { original: string }; terminalCommandOutput: { text: string }; terminalCommandState: { exitCode: number } };
assert.strictEqual(termData.commandLine.original, 'echo hello');
assert.strictEqual(termData.terminalCommandOutput.text, 'hello');
assert.strictEqual(termData.terminalCommandState.exitCode, 0);
});
test('turn with responseText produces markdown content in history', () => {
const turn = createTurn({
responseText: 'Hello world',
});
const history = turnsToHistory([turn], 'p');
assert.strictEqual(history.length, 2);
const response = history[1];
assert.strictEqual(response.type, 'response');
if (response.type !== 'response') { return; }
assert.strictEqual(response.parts.length, 1);
assert.strictEqual(response.parts[0].kind, 'markdownContent');
assert.strictEqual((response.parts[0] as IChatMarkdownContent).content.value, 'Hello world');
});
test('error turn produces error message in history', () => {
const turn = createTurn({
state: TurnState.Error,
error: { errorType: 'test', message: 'boom' },
});
const history = turnsToHistory([turn], 'p');
const response = history[1];
assert.strictEqual(response.type, 'response');
if (response.type !== 'response') { return; }
const errorPart = response.parts.find(p => p.kind === 'markdownContent' && (p as IChatMarkdownContent).content.value.includes('boom'));
assert.ok(errorPart, 'Should have a markdownContent part containing the error message');
});
test('failed tool in history has exitCode 1', () => {
const turn = createTurn({
toolCalls: [createCompletedToolCall({
toolKind: 'terminal',
toolInput: 'bad-command',
toolOutput: 'error',
success: false,
})],
});
const history = turnsToHistory([turn], 'p');
const response = history[1];
assert.strictEqual(response.type, 'response');
if (response.type !== 'response') { return; }
const serialized = response.parts[0] as IChatToolInvocationSerialized;
assert.ok(serialized.toolSpecificData);
assert.strictEqual(serialized.toolSpecificData.kind, 'terminal');
const termData = serialized.toolSpecificData as { kind: 'terminal'; terminalCommandState: { exitCode: number } };
assert.strictEqual(termData.terminalCommandState.exitCode, 1);
});
});
suite('toolCallStateToInvocation', () => {
test('creates ChatToolInvocation for running tool', () => {
const tc = createToolCallState({
toolCallId: 'tc-42',
toolName: 'my_tool',
displayName: 'My Tool',
invocationMessage: 'Doing stuff',
status: ToolCallStatus.Running,
});
const invocation = toolCallStateToInvocation(tc);
assert.strictEqual(invocation.toolCallId, 'tc-42');
assert.strictEqual(invocation.toolId, 'my_tool');
assert.strictEqual(invocation.source, ToolDataSource.Internal);
});
test('sets terminal toolSpecificData', () => {
const tc = createToolCallState({
toolKind: 'terminal',
toolInput: 'ls -la',
});
const invocation = toolCallStateToInvocation(tc);
assert.ok(invocation.toolSpecificData);
assert.strictEqual(invocation.toolSpecificData.kind, 'terminal');
const termData = invocation.toolSpecificData as { kind: 'terminal'; commandLine: { original: string } };
assert.strictEqual(termData.commandLine.original, 'ls -la');
});
test('parses toolArguments as parameters', () => {
const tc = createToolCallState({
toolArguments: '{"path":"test.ts"}',
});
const invocation = toolCallStateToInvocation(tc);
assert.deepStrictEqual(invocation.parameters, { path: 'test.ts' });
});
});
suite('permissionToConfirmation', () => {
test('shell permission has terminal data', () => {
const perm = createPermission({
permissionKind: 'shell',
fullCommandText: 'rm -rf /',
intention: 'Delete everything',
});
const invocation = permissionToConfirmation(perm);
assert.ok(invocation.toolSpecificData);
assert.strictEqual(invocation.toolSpecificData.kind, 'terminal');
const termData = invocation.toolSpecificData as { kind: 'terminal'; commandLine: { original: string } };
assert.strictEqual(termData.commandLine.original, 'rm -rf /');
});
test('mcp permission uses server + tool name as title', () => {
const perm = createPermission({
permissionKind: 'mcp',
serverName: 'My Server',
toolName: 'my_tool',
});
const invocation = permissionToConfirmation(perm);
const message = typeof invocation.invocationMessage === 'string' ? invocation.invocationMessage : invocation.invocationMessage.value;
assert.ok(message.includes('My Server: my_tool'));
});
test('write permission has input data', () => {
const perm = createPermission({
permissionKind: 'write',
path: '/test.ts',
rawRequest: '{"path":"/test.ts","content":"hello"}',
});
const invocation = permissionToConfirmation(perm);
assert.ok(invocation.toolSpecificData);
assert.strictEqual(invocation.toolSpecificData.kind, 'input');
});
});
suite('finalizeToolInvocation', () => {
test('finalizes terminal tool with output and exit code', () => {
const tc = createToolCallState({
toolKind: 'terminal',
toolInput: 'echo hi',
status: ToolCallStatus.Running,
});
const invocation = toolCallStateToInvocation(tc);
const completedTc = createToolCallState({
toolKind: 'terminal',
toolInput: 'echo hi',
status: ToolCallStatus.Completed,
toolOutput: 'output text',
});
finalizeToolInvocation(invocation, completedTc);
assert.ok(invocation.toolSpecificData);
assert.strictEqual(invocation.toolSpecificData.kind, 'terminal');
const termData = invocation.toolSpecificData as { kind: 'terminal'; terminalCommandOutput: { text: string }; terminalCommandState: { exitCode: number } };
assert.strictEqual(termData.terminalCommandOutput.text, 'output text');
assert.strictEqual(termData.terminalCommandState.exitCode, 0);
});
test('finalizes failed tool with error message', () => {
const tc = createToolCallState({
status: ToolCallStatus.Running,
});
const invocation = toolCallStateToInvocation(tc);
const failedTc = createToolCallState({
status: ToolCallStatus.Failed,
error: { message: 'timeout' },
});
// Should not throw
finalizeToolInvocation(invocation, failedTc);
});
});
});