Path: blob/main/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { Emitter, Event } from '../../../../base/common/event.js';7import { DisposableStore } from '../../../../base/common/lifecycle.js';8import { URI } from '../../../../base/common/uri.js';9import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';10import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';11import { NullLogService } from '../../../log/common/log.js';12import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js';13import { ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';14import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js';15import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js';16import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js';17import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionSummary } from '../../common/state/sessionState.js';18import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js';19import { ProtocolServerHandler } from '../../node/protocolServerHandler.js';20import { AgentHostStateManager } from '../../node/agentHostStateManager.js';21import { AgentHostFileSystemProvider } from '../../common/agentHostFileSystemProvider.js';2223// ---- Mock helpers -----------------------------------------------------------2425class MockProtocolTransport implements IProtocolTransport {26private readonly _onMessage = new Emitter<ProtocolMessage>();27readonly onMessage = this._onMessage.event;28private readonly _onDidSend = new Emitter<ProtocolMessage>();29readonly onDidSend = this._onDidSend.event;30private readonly _onClose = new Emitter<void>();31readonly onClose = this._onClose.event;3233readonly sent: ProtocolMessage[] = [];3435send(message: ProtocolMessage): void {36this.sent.push(message);37this._onDidSend.fire(message);38}3940simulateMessage(msg: ProtocolMessage): void {41this._onMessage.fire(msg);42}4344simulateClose(): void {45this._onClose.fire();46}4748dispose(): void {49this._onMessage.dispose();50this._onDidSend.dispose();51this._onClose.dispose();52}53}5455class MockProtocolServer implements IProtocolServer {56private readonly _onConnection = new Emitter<IProtocolTransport>();57readonly onConnection = this._onConnection.event;58readonly address = 'mock://test';5960simulateConnection(transport: IProtocolTransport): void {61this._onConnection.fire(transport);62}6364dispose(): void {65this._onConnection.dispose();66}67}6869class MockAgentService implements IAgentService {70declare readonly _serviceBrand: undefined;71readonly handledActions: (SessionAction | TerminalAction | IRootConfigChangedAction)[] = [];72readonly browsedUris: URI[] = [];73readonly browseErrors = new Map<string, Error>();74readonly listedSessions: IAgentSessionMetadata[] = [];75readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = [];7677private readonly _onDidAction = new Emitter<import('../../common/state/sessionActions.js').ActionEnvelope>();78readonly onDidAction = this._onDidAction.event;79private readonly _onDidNotification = new Emitter<import('../../common/state/sessionActions.js').INotification>();80readonly onDidNotification = this._onDidNotification.event;8182private _stateManager!: AgentHostStateManager;8384/** Connect to the state manager so dispatchAction works correctly. */85setStateManager(sm: AgentHostStateManager): void {86this._stateManager = sm;87}8889dispatchAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {90this.handledActions.push(action);91const origin = { clientId, clientSeq };92this._stateManager.dispatchClientAction(action, origin);93}94async createSession(config?: IAgentCreateSessionConfig): Promise<URI> {95this.createSessionConfigs.push(config);96const session = config?.session ?? URI.parse('copilot:///new-session');97this._stateManager.createSession({98resource: session.toString(),99provider: config?.provider ?? 'copilot',100title: '',101status: SessionStatus.Idle,102createdAt: Date.now(),103modifiedAt: Date.now(),104project: { uri: 'file:///created-project', displayName: 'Created Project' },105workingDirectory: config?.workingDirectory?.toString(),106});107return session;108}109110async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> { return { schema: { type: 'object', properties: {} }, values: {} }; }111async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise<SessionConfigCompletionsResult> { return { items: [] }; }112async disposeSession(_session: URI): Promise<void> { }113async listSessions(): Promise<IAgentSessionMetadata[]> { return this.listedSessions; }114async subscribe(resource: URI): Promise<IStateSnapshot> {115const snapshot = this._stateManager.getSnapshot(resource.toString());116if (!snapshot) {117throw new Error(`Cannot subscribe to unknown resource: ${resource.toString()}`);118}119return snapshot;120}121unsubscribe(_resource: URI): void { }122async shutdown(): Promise<void> { }123async authenticate(_params: AuthenticateParams): Promise<AuthenticateResult> { return { authenticated: true }; }124async resourceWrite(_params: ResourceWriteParams): Promise<ResourceWriteResult> { return {}; }125async resourceList(uri: URI): Promise<ResourceListResult> {126this.browsedUris.push(uri);127const error = this.browseErrors.get(uri.toString());128if (error) {129throw error;130}131return {132entries: [133{ name: 'src', type: 'directory' },134{ name: 'README.md', type: 'file' },135],136};137}138async resourceRead(_uri: URI): Promise<ResourceReadResult> {139throw new Error('Not implemented');140}141async resourceCopy(): Promise<{}> { return {}; }142async resourceDelete(): Promise<{}> { return {}; }143async resourceMove(): Promise<{}> { return {}; }144async createTerminal(): Promise<void> { }145async disposeTerminal(): Promise<void> { }146147dispose(): void {148this._onDidAction.dispose();149this._onDidNotification.dispose();150}151}152153// ---- Helpers ----------------------------------------------------------------154155function notification(method: string, params?: unknown): ProtocolMessage {156return { jsonrpc: '2.0', method, params } as ProtocolMessage;157}158159function request(id: number, method: string, params?: unknown): ProtocolMessage {160return { jsonrpc: '2.0', id, method, params } as ProtocolMessage;161}162163function findNotifications(sent: ProtocolMessage[], method: string): AhpNotification[] {164return sent.filter(isJsonRpcNotification) as AhpNotification[];165}166167function findResponse(sent: ProtocolMessage[], id: number): ProtocolMessage | undefined {168return sent.find(isJsonRpcResponse) as ProtocolMessage | undefined;169}170171function waitForResponse(transport: MockProtocolTransport, id: number): Promise<ProtocolMessage> {172return Event.toPromise(Event.filter(transport.onDidSend, message => isJsonRpcResponse(message) && message.id === id));173}174175// ---- Tests ------------------------------------------------------------------176177suite('ProtocolServerHandler', () => {178179let disposables: DisposableStore;180let stateManager: AgentHostStateManager;181let server: MockProtocolServer;182let agentService: MockAgentService;183let handler: ProtocolServerHandler;184185const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString();186187function makeSessionSummary(resource?: string): SessionSummary {188return {189resource: resource ?? sessionUri,190provider: 'copilot',191title: 'Test',192status: SessionStatus.Idle,193createdAt: Date.now(),194modifiedAt: Date.now(),195project: { uri: 'file:///test-project', displayName: 'Test Project' },196};197}198199function connectClient(clientId: string, initialSubscriptions?: readonly string[]): MockProtocolTransport {200const transport = new MockProtocolTransport();201server.simulateConnection(transport);202transport.simulateMessage(request(1, 'initialize', {203protocolVersion: PROTOCOL_VERSION,204clientId,205initialSubscriptions,206}));207return transport;208}209210setup(() => {211disposables = new DisposableStore();212stateManager = disposables.add(new AgentHostStateManager(new NullLogService()));213server = disposables.add(new MockProtocolServer());214agentService = new MockAgentService();215agentService.setStateManager(stateManager);216disposables.add(agentService);217disposables.add(handler = new ProtocolServerHandler(218agentService,219stateManager,220server,221{ defaultDirectory: URI.file('/home/testuser').toString() },222disposables.add(new AgentHostFileSystemProvider()),223new NullLogService(),224));225});226227teardown(() => {228disposables.dispose();229});230231ensureNoDisposablesAreLeakedInTestSuite();232233test('handshake returns initialize response', () => {234const transport = connectClient('client-1');235236const resp = findResponse(transport.sent, 1);237assert.ok(resp, 'should have sent initialize response');238const result = (resp as { result: InitializeResult }).result;239assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION);240assert.strictEqual(result.serverSeq, stateManager.serverSeq);241});242243test('handshake with initialSubscriptions returns snapshots', () => {244stateManager.createSession(makeSessionSummary());245246const transport = connectClient('client-1', [sessionUri]);247248const resp = findResponse(transport.sent, 1);249assert.ok(resp);250const result = (resp as { result: InitializeResult }).result;251assert.strictEqual(result.snapshots.length, 1);252assert.strictEqual(result.snapshots[0].resource.toString(), sessionUri.toString());253});254255test('subscribe request returns snapshot', async () => {256stateManager.createSession(makeSessionSummary());257258const transport = connectClient('client-1');259transport.sent.length = 0;260const responsePromise = waitForResponse(transport, 1);261262transport.simulateMessage(request(1, 'subscribe', { resource: sessionUri }));263const resp = await responsePromise;264265assert.ok(resp, 'should have sent response');266const result = (resp as unknown as { result: { snapshot: IStateSnapshot } }).result;267assert.strictEqual(result.snapshot.resource.toString(), sessionUri.toString());268});269270test('client action is dispatched and echoed', () => {271stateManager.createSession(makeSessionSummary());272stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });273274const transport = connectClient('client-1', [sessionUri]);275transport.sent.length = 0;276277transport.simulateMessage(notification('dispatchAction', {278clientSeq: 1,279action: {280type: ActionType.SessionTurnStarted,281session: sessionUri,282turnId: 'turn-1',283userMessage: { text: 'hello' },284},285}));286287const actionMsgs = findNotifications(transport.sent, 'action');288const turnStarted = actionMsgs.find(m => {289const envelope = m.params as unknown as { action: { type: string } };290return envelope.action.type === ActionType.SessionTurnStarted;291});292assert.ok(turnStarted, 'should have echoed turnStarted');293const envelope = turnStarted!.params as unknown as { origin: { clientId: string; clientSeq: number } };294assert.strictEqual(envelope.origin.clientId, 'client-1');295assert.strictEqual(envelope.origin.clientSeq, 1);296});297298test('actions are scoped to subscribed sessions', () => {299stateManager.createSession(makeSessionSummary());300stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });301302const transportA = connectClient('client-a', [sessionUri]);303const transportB = connectClient('client-b');304305transportA.sent.length = 0;306transportB.sent.length = 0;307308stateManager.dispatchServerAction({309type: ActionType.SessionTitleChanged,310session: sessionUri,311title: 'New Title',312});313314assert.strictEqual(findNotifications(transportA.sent, 'action').length, 1);315assert.strictEqual(findNotifications(transportB.sent, 'action').length, 0);316});317318test('notifications are broadcast to all clients', () => {319const transportA = connectClient('client-a');320const transportB = connectClient('client-b');321322transportA.sent.length = 0;323transportB.sent.length = 0;324325stateManager.createSession(makeSessionSummary());326327assert.strictEqual(findNotifications(transportA.sent, 'notification').length, 1);328assert.strictEqual(findNotifications(transportB.sent, 'notification').length, 1);329});330331test('listSessions includes project metadata', async () => {332agentService.listedSessions.push({333session: URI.parse(sessionUri),334startTime: 1000,335modifiedTime: 2000,336project: { uri: URI.file('/workspace/project'), displayName: 'Project' },337summary: 'Session Summary',338});339340const transport = connectClient('client-list');341transport.sent.length = 0;342const responsePromise = waitForResponse(transport, 2);343344transport.simulateMessage(request(2, 'listSessions'));345const resp = await responsePromise;346347const result = (resp as unknown as { result: ListSessionsResult }).result;348assert.deepStrictEqual(result.items.map(item => item.project), [{ uri: URI.file('/workspace/project').toString(), displayName: 'Project' }]);349});350351test('listSessions omits project metadata when absent', async () => {352agentService.listedSessions.push({353session: URI.parse(sessionUri),354startTime: 1000,355modifiedTime: 2000,356summary: 'Session Summary',357});358359const transport = connectClient('client-list-no-project');360transport.sent.length = 0;361const responsePromise = waitForResponse(transport, 2);362363transport.simulateMessage(request(2, 'listSessions'));364const resp = await responsePromise;365366const result = (resp as unknown as { result: ListSessionsResult }).result;367assert.deepStrictEqual(result.items.map(item => item.project), [undefined]);368});369370test('listSessions includes diffs with before/after URIs and content refs', async () => {371agentService.listedSessions.push({372session: URI.parse(sessionUri),373startTime: 1000,374modifiedTime: 2000,375summary: 'Session With Diffs',376diffs: [377{378before: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://before-ref' } },379after: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://after-ref' } },380diff: { added: 5, removed: 2 },381},382{383after: { uri: URI.file('/workspace/new-file.ts').toString(), content: { uri: 'content://new-ref' } },384},385{386before: { uri: URI.file('/workspace/deleted.ts').toString(), content: { uri: 'content://deleted-ref' } },387},388],389});390391const transport = connectClient('client-list-diffs');392transport.sent.length = 0;393const responsePromise = waitForResponse(transport, 2);394395transport.simulateMessage(request(2, 'listSessions'));396const resp = await responsePromise;397398const result = (resp as unknown as { result: ListSessionsResult }).result;399assert.deepStrictEqual(result.items[0].diffs, [400{401before: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://before-ref' } },402after: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://after-ref' } },403diff: { added: 5, removed: 2 },404},405{406after: { uri: URI.file('/workspace/new-file.ts').toString(), content: { uri: 'content://new-ref' } },407},408{409before: { uri: URI.file('/workspace/deleted.ts').toString(), content: { uri: 'content://deleted-ref' } },410},411]);412});413414test('createSession returns null and broadcasts project in sessionAdded summary', async () => {415const transport = connectClient('client-create');416transport.sent.length = 0;417const responsePromise = waitForResponse(transport, 2);418419const newSession = URI.parse('copilot:///created-session').toString();420transport.simulateMessage(request(2, 'createSession', { session: newSession }));421const resp = await responsePromise;422423const added = findNotifications(transport.sent, 'notification').find(message => {424const params = message.params as { notification: { type: string } };425return params.notification.type === 'notify/sessionAdded';426});427assert.deepStrictEqual({428result: (resp as { result: null }).result,429project: (added!.params as { notification: { summary: SessionSummary } }).notification.summary.project,430}, {431result: null,432project: { uri: 'file:///created-project', displayName: 'Created Project' },433});434});435436test('reconnect replays missed actions', () => {437stateManager.createSession(makeSessionSummary());438stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });439440const transport1 = connectClient('client-r', [sessionUri]);441const resp = findResponse(transport1.sent, 1);442const initSeq = (resp as { result: InitializeResult }).result.serverSeq;443transport1.simulateClose();444445stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Title A' });446stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Title B' });447448const transport2 = new MockProtocolTransport();449server.simulateConnection(transport2);450transport2.simulateMessage(request(1, 'reconnect', {451clientId: 'client-r',452lastSeenServerSeq: initSeq,453subscriptions: [sessionUri],454}));455456const reconnectResp = findResponse(transport2.sent, 1);457assert.ok(reconnectResp, 'should have sent reconnect response');458const result = (reconnectResp as { result: ReconnectResult }).result;459assert.strictEqual(result.type, 'replay');460if (result.type === 'replay') {461assert.strictEqual(result.actions.length, 2);462}463});464465test('reconnect sends fresh snapshots when gap too large', () => {466stateManager.createSession(makeSessionSummary());467stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });468469const transport1 = connectClient('client-g', [sessionUri]);470transport1.simulateClose();471472for (let i = 0; i < 1100; i++) {473stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: `Title ${i}` });474}475476const transport2 = new MockProtocolTransport();477server.simulateConnection(transport2);478transport2.simulateMessage(request(1, 'reconnect', {479clientId: 'client-g',480lastSeenServerSeq: 0,481subscriptions: [sessionUri],482}));483484const reconnectResp = findResponse(transport2.sent, 1);485assert.ok(reconnectResp, 'should have sent reconnect response');486const result = (reconnectResp as { result: ReconnectResult }).result;487assert.strictEqual(result.type, 'snapshot');488if (result.type === 'snapshot') {489assert.ok(result.snapshots.length > 0, 'should contain snapshots');490}491});492493test('client disconnect cleans up', () => {494stateManager.createSession(makeSessionSummary());495stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });496497const transport = connectClient('client-d', [sessionUri]);498transport.sent.length = 0;499500transport.simulateClose();501502stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'After Disconnect' });503504assert.strictEqual(transport.sent.length, 0);505});506507test('client disconnect clears active client and fails owned tool calls after grace period', () => {508return runWithFakedTimers({ useFakeTimers: true }, async () => {509stateManager.createSession(makeSessionSummary());510stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });511stateManager.dispatchServerAction({512type: ActionType.SessionActiveClientChanged,513session: sessionUri,514activeClient: {515clientId: 'client-tools',516tools: [{ name: 'runTask', description: 'Runs a task' }],517},518});519stateManager.dispatchServerAction({520type: ActionType.SessionTurnStarted,521session: sessionUri,522turnId: 'turn-1',523userMessage: { text: 'run it' },524});525stateManager.dispatchServerAction({526type: ActionType.SessionToolCallStart,527session: sessionUri,528turnId: 'turn-1',529toolCallId: 'tool-1',530toolName: 'runTask',531displayName: 'Run Task',532toolClientId: 'client-tools',533});534stateManager.dispatchServerAction({535type: ActionType.SessionToolCallReady,536session: sessionUri,537turnId: 'turn-1',538toolCallId: 'tool-1',539invocationMessage: 'Run Task',540toolInput: '{}',541confirmed: ToolCallConfirmationReason.NotNeeded,542});543544const transport = connectClient('client-tools', [sessionUri]);545transport.simulateClose();546547assert.strictEqual(stateManager.getSessionState(sessionUri)?.activeClient, undefined);548let part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0];549assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);550assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Running);551552await new Promise(r => setTimeout(r, 30_001));553554part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0];555assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);556assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? {557status: part.toolCall.status,558success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined,559error: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.error?.message : undefined,560} : undefined, {561status: ToolCallStatus.Completed,562success: false,563error: 'Client client-tools disconnected before completing Run Task',564});565});566});567568test('client disconnect fails owned streaming tool calls after grace period', () => {569return runWithFakedTimers({ useFakeTimers: true }, async () => {570stateManager.createSession(makeSessionSummary());571stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });572stateManager.dispatchServerAction({573type: ActionType.SessionActiveClientChanged,574session: sessionUri,575activeClient: {576clientId: 'client-tools',577tools: [{ name: 'runTask', description: 'Runs a task' }],578},579});580stateManager.dispatchServerAction({581type: ActionType.SessionTurnStarted,582session: sessionUri,583turnId: 'turn-1',584userMessage: { text: 'run it' },585});586stateManager.dispatchServerAction({587type: ActionType.SessionToolCallStart,588session: sessionUri,589turnId: 'turn-1',590toolCallId: 'tool-1',591toolName: 'runTask',592displayName: 'Run Task',593toolClientId: 'client-tools',594});595596const transport = connectClient('client-tools', [sessionUri]);597transport.simulateClose();598599let part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0];600assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);601assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Streaming);602603await new Promise(r => setTimeout(r, 30_001));604605part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0];606assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);607assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? {608status: part.toolCall.status,609success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined,610error: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.error?.message : undefined,611} : undefined, {612status: ToolCallStatus.Completed,613success: false,614error: 'Client client-tools disconnected before completing Run Task',615});616});617});618619test('client reconnect without session subscription does not clear tool call disconnect timeout', () => {620return runWithFakedTimers({ useFakeTimers: true }, async () => {621stateManager.createSession(makeSessionSummary());622stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });623stateManager.dispatchServerAction({624type: ActionType.SessionActiveClientChanged,625session: sessionUri,626activeClient: {627clientId: 'client-tools',628tools: [{ name: 'runTask', description: 'Runs a task' }],629},630});631stateManager.dispatchServerAction({632type: ActionType.SessionTurnStarted,633session: sessionUri,634turnId: 'turn-1',635userMessage: { text: 'run it' },636});637stateManager.dispatchServerAction({638type: ActionType.SessionToolCallStart,639session: sessionUri,640turnId: 'turn-1',641toolCallId: 'tool-1',642toolName: 'runTask',643displayName: 'Run Task',644toolClientId: 'client-tools',645});646stateManager.dispatchServerAction({647type: ActionType.SessionToolCallReady,648session: sessionUri,649turnId: 'turn-1',650toolCallId: 'tool-1',651invocationMessage: 'Run Task',652toolInput: '{}',653confirmed: ToolCallConfirmationReason.NotNeeded,654});655656const transport = connectClient('client-tools', [sessionUri]);657transport.simulateClose();658659const reconnectTransport = new MockProtocolTransport();660server.simulateConnection(reconnectTransport);661reconnectTransport.simulateMessage(request(1, 'reconnect', {662clientId: 'client-tools',663lastSeenServerSeq: stateManager.serverSeq,664subscriptions: [],665}));666667await new Promise(r => setTimeout(r, 30_001));668669const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0];670assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);671assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? {672status: part.toolCall.status,673success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined,674} : undefined, {675status: ToolCallStatus.Completed,676success: false,677});678});679});680681test('client reconnect with session subscription clears tool call disconnect timeout for that session', () => {682return runWithFakedTimers({ useFakeTimers: true }, async () => {683stateManager.createSession(makeSessionSummary());684stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });685stateManager.dispatchServerAction({686type: ActionType.SessionActiveClientChanged,687session: sessionUri,688activeClient: {689clientId: 'client-tools',690tools: [{ name: 'runTask', description: 'Runs a task' }],691},692});693stateManager.dispatchServerAction({694type: ActionType.SessionTurnStarted,695session: sessionUri,696turnId: 'turn-1',697userMessage: { text: 'run it' },698});699stateManager.dispatchServerAction({700type: ActionType.SessionToolCallStart,701session: sessionUri,702turnId: 'turn-1',703toolCallId: 'tool-1',704toolName: 'runTask',705displayName: 'Run Task',706toolClientId: 'client-tools',707});708stateManager.dispatchServerAction({709type: ActionType.SessionToolCallReady,710session: sessionUri,711turnId: 'turn-1',712toolCallId: 'tool-1',713invocationMessage: 'Run Task',714toolInput: '{}',715confirmed: ToolCallConfirmationReason.NotNeeded,716});717718const transport = connectClient('client-tools', [sessionUri]);719transport.simulateClose();720721const reconnectTransport = new MockProtocolTransport();722server.simulateConnection(reconnectTransport);723reconnectTransport.simulateMessage(request(1, 'reconnect', {724clientId: 'client-tools',725lastSeenServerSeq: stateManager.serverSeq,726subscriptions: [sessionUri],727}));728729await new Promise(r => setTimeout(r, 30_001));730731const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0];732assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);733assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Running);734});735});736737test('client tool timeout tells model it may retry when replacement active client provides the tool', () => {738return runWithFakedTimers({ useFakeTimers: true }, async () => {739stateManager.createSession(makeSessionSummary());740stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });741stateManager.dispatchServerAction({742type: ActionType.SessionActiveClientChanged,743session: sessionUri,744activeClient: {745clientId: 'client-tools',746tools: [{ name: 'runTask', description: 'Runs a task' }],747},748});749stateManager.dispatchServerAction({750type: ActionType.SessionTurnStarted,751session: sessionUri,752turnId: 'turn-1',753userMessage: { text: 'run it' },754});755stateManager.dispatchServerAction({756type: ActionType.SessionToolCallStart,757session: sessionUri,758turnId: 'turn-1',759toolCallId: 'tool-1',760toolName: 'runTask',761displayName: 'Run Task',762toolClientId: 'client-tools',763});764stateManager.dispatchServerAction({765type: ActionType.SessionToolCallReady,766session: sessionUri,767turnId: 'turn-1',768toolCallId: 'tool-1',769invocationMessage: 'Run Task',770toolInput: '{}',771confirmed: ToolCallConfirmationReason.NotNeeded,772});773774const transport = connectClient('client-tools', [sessionUri]);775transport.simulateClose();776stateManager.dispatchServerAction({777type: ActionType.SessionActiveClientChanged,778session: sessionUri,779activeClient: {780clientId: 'client-replacement',781tools: [{ name: 'runTask', description: 'Runs a task' }],782},783});784785await new Promise(r => setTimeout(r, 30_001));786787const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0];788assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);789assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.Completed ? {790status: part.toolCall.status,791success: part.toolCall.success,792content: part.toolCall.content,793} : undefined, {794status: ToolCallStatus.Completed,795success: false,796content: [{ type: ToolResultContentType.Text, text: 'The client that was running Run Task disconnected, but another active client now provides Run Task. You may try calling the tool again.' }],797});798});799});800801test('handshake includes defaultDirectory from side effects', () => {802const transport = connectClient('client-home');803804const resp = findResponse(transport.sent, 1);805assert.ok(resp);806const result = (resp as { result: InitializeResult }).result;807assert.strictEqual(URI.parse(result.defaultDirectory!).path, '/home/testuser');808});809810test('resourceList routes to side effect handler', async () => {811const transport = connectClient('client-browse');812transport.sent.length = 0;813814const dirUri = URI.file('/home/user/project').toString();815const responsePromise = waitForResponse(transport, 2);816transport.simulateMessage(request(2, 'resourceList', { uri: dirUri }));817const resp = await responsePromise;818819assert.strictEqual(agentService.browsedUris.length, 1);820assert.strictEqual(agentService.browsedUris[0].path, '/home/user/project');821822assert.ok(resp);823const result = (resp as unknown as { result: { entries: { name: string; uri: unknown; type: string }[] } }).result;824assert.strictEqual(result.entries.length, 2);825assert.strictEqual(result.entries[0].name, 'src');826assert.strictEqual(result.entries[0].type, 'directory');827assert.strictEqual(result.entries[1].name, 'README.md');828assert.strictEqual(result.entries[1].type, 'file');829});830831test('resourceList returns a JSON-RPC error when the target is invalid', async () => {832const transport = connectClient('client-browse-error');833transport.sent.length = 0;834835const dirUri = URI.file('/missing').toString();836agentService.browseErrors.set(URI.file('/missing').toString(), new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Directory not found: ${dirUri}`));837const responsePromise = waitForResponse(transport, 2);838transport.simulateMessage(request(2, 'resourceList', { uri: dirUri }));839const resp = await responsePromise as { error?: { code: number; message: string } };840841assert.ok(resp?.error);842assert.strictEqual(resp.error!.code, JSON_RPC_INTERNAL_ERROR);843assert.match(resp.error!.message, /Directory not found/);844});845846// ---- Extension methods: auth ----------------------------------------847848test('authenticate returns result via typed request', async () => {849const transport = connectClient('client-auth');850transport.sent.length = 0;851852const responsePromise = waitForResponse(transport, 2);853transport.simulateMessage(request(2, 'authenticate', { resource: 'https://api.github.com', token: 'test-token' }));854const resp = await responsePromise as { result?: Record<string, unknown>; error?: { code: number; message: string } };855856assert.ok(!resp.error, `unexpected error: ${resp.error?.message}`);857assert.deepStrictEqual(resp.result, {});858});859860test('extension request preserves ProtocolError code and data', async () => {861// Override authenticate to throw a ProtocolError with data862const origHandler = agentService.authenticate;863agentService.authenticate = async () => { throw new ProtocolError(-32007, 'Auth required', { hint: 'sign in' }); };864865const transport = connectClient('client-auth-error');866transport.sent.length = 0;867868const responsePromise = waitForResponse(transport, 2);869transport.simulateMessage(request(2, 'authenticate', { resource: 'test', token: 'bad' }));870const resp = await responsePromise as { error?: { code: number; message: string; data?: unknown } };871872assert.ok(resp?.error);873assert.strictEqual(resp.error!.code, -32007);874assert.strictEqual(resp.error!.message, 'Auth required');875assert.deepStrictEqual(resp.error!.data, { hint: 'sign in' });876877agentService.authenticate = origHandler;878});879880// ---- Connection count event -----------------------------------------881882test('onDidChangeConnectionCount fires on connect and disconnect', () => {883const counts: number[] = [];884disposables.add(handler.onDidChangeConnectionCount(c => counts.push(c)));885886const transport = connectClient('client-count-1');887connectClient('client-count-2');888transport.simulateClose();889890assert.deepStrictEqual(counts, [1, 2, 1]);891});892893test('onDidChangeConnectionCount is not decremented by stale reconnect close', () => {894const counts: number[] = [];895disposables.add(handler.onDidChangeConnectionCount(c => counts.push(c)));896897// Connect898const transport1 = connectClient('client-rc');899assert.deepStrictEqual(counts, [1]);900901// Reconnect with same clientId (new transport)902const transport2 = new MockProtocolTransport();903server.simulateConnection(transport2);904transport2.simulateMessage(request(1, 'reconnect', {905clientId: 'client-rc',906lastSeenServerSeq: 0,907subscriptions: [],908}));909// Count is unchanged because same clientId was overwritten910assert.deepStrictEqual(counts, [1, 1]);911912// Old transport closes - should NOT decrement since it's stale913transport1.simulateClose();914assert.deepStrictEqual(counts, [1, 1]);915916// New transport closes - should decrement917transport2.simulateClose();918assert.deepStrictEqual(counts, [1, 1, 0]);919});920921// ---- createSession activeClient -------------------------------------922923suite('createSession activeClient', () => {924925test('forwards activeClient to the agent service', async () => {926const newSession = URI.parse('copilot:///eager-session').toString();927928const transport = connectClient('client-1');929transport.sent.length = 0;930931const responsePromise = waitForResponse(transport, 2);932transport.simulateMessage(request(2, 'createSession', {933session: newSession,934provider: 'copilot',935activeClient: {936clientId: 'client-1',937tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }],938customizations: [{ uri: 'file:///plugin-a', displayName: 'A' }],939},940}));941const resp = await responsePromise as { result?: unknown; error?: unknown };942943assert.strictEqual(resp.error, undefined, 'createSession should succeed');944const config = agentService.createSessionConfigs.at(-1);945assert.deepStrictEqual({946clientId: config?.activeClient?.clientId,947toolName: config?.activeClient?.tools[0]?.name,948customizationUri: config?.activeClient?.customizations?.[0].uri,949}, {950clientId: 'client-1',951toolName: 't1',952customizationUri: 'file:///plugin-a',953});954});955956test('rejects createSession when activeClient.clientId mismatches', async () => {957const newSession = URI.parse('copilot:///mismatch-session').toString();958959const transport = connectClient('client-1');960transport.sent.length = 0;961962const responsePromise = waitForResponse(transport, 2);963transport.simulateMessage(request(2, 'createSession', {964session: newSession,965provider: 'copilot',966activeClient: {967clientId: 'other-client',968tools: [],969},970}));971const resp = await responsePromise as { result?: unknown; error?: { code: number; message: string } };972973assert.ok(resp.error, 'response should be an error');974assert.strictEqual(resp.result, undefined);975assert.strictEqual(agentService.createSessionConfigs.length, 0, 'agent service should not have been called');976});977});978});979980981