Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { Emitter, Event } from '../../../../base/common/event.js';
8
import { DisposableStore } from '../../../../base/common/lifecycle.js';
9
import { URI } from '../../../../base/common/uri.js';
10
import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';
11
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
12
import { NullLogService } from '../../../log/common/log.js';
13
import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js';
14
import { ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
15
import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js';
16
import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js';
17
import { 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';
18
import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionSummary } from '../../common/state/sessionState.js';
19
import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js';
20
import { ProtocolServerHandler } from '../../node/protocolServerHandler.js';
21
import { AgentHostStateManager } from '../../node/agentHostStateManager.js';
22
import { AgentHostFileSystemProvider } from '../../common/agentHostFileSystemProvider.js';
23
24
// ---- Mock helpers -----------------------------------------------------------
25
26
class MockProtocolTransport implements IProtocolTransport {
27
private readonly _onMessage = new Emitter<ProtocolMessage>();
28
readonly onMessage = this._onMessage.event;
29
private readonly _onDidSend = new Emitter<ProtocolMessage>();
30
readonly onDidSend = this._onDidSend.event;
31
private readonly _onClose = new Emitter<void>();
32
readonly onClose = this._onClose.event;
33
34
readonly sent: ProtocolMessage[] = [];
35
36
send(message: ProtocolMessage): void {
37
this.sent.push(message);
38
this._onDidSend.fire(message);
39
}
40
41
simulateMessage(msg: ProtocolMessage): void {
42
this._onMessage.fire(msg);
43
}
44
45
simulateClose(): void {
46
this._onClose.fire();
47
}
48
49
dispose(): void {
50
this._onMessage.dispose();
51
this._onDidSend.dispose();
52
this._onClose.dispose();
53
}
54
}
55
56
class MockProtocolServer implements IProtocolServer {
57
private readonly _onConnection = new Emitter<IProtocolTransport>();
58
readonly onConnection = this._onConnection.event;
59
readonly address = 'mock://test';
60
61
simulateConnection(transport: IProtocolTransport): void {
62
this._onConnection.fire(transport);
63
}
64
65
dispose(): void {
66
this._onConnection.dispose();
67
}
68
}
69
70
class MockAgentService implements IAgentService {
71
declare readonly _serviceBrand: undefined;
72
readonly handledActions: (SessionAction | TerminalAction | IRootConfigChangedAction)[] = [];
73
readonly browsedUris: URI[] = [];
74
readonly browseErrors = new Map<string, Error>();
75
readonly listedSessions: IAgentSessionMetadata[] = [];
76
readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = [];
77
78
private readonly _onDidAction = new Emitter<import('../../common/state/sessionActions.js').ActionEnvelope>();
79
readonly onDidAction = this._onDidAction.event;
80
private readonly _onDidNotification = new Emitter<import('../../common/state/sessionActions.js').INotification>();
81
readonly onDidNotification = this._onDidNotification.event;
82
83
private _stateManager!: AgentHostStateManager;
84
85
/** Connect to the state manager so dispatchAction works correctly. */
86
setStateManager(sm: AgentHostStateManager): void {
87
this._stateManager = sm;
88
}
89
90
dispatchAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {
91
this.handledActions.push(action);
92
const origin = { clientId, clientSeq };
93
this._stateManager.dispatchClientAction(action, origin);
94
}
95
async createSession(config?: IAgentCreateSessionConfig): Promise<URI> {
96
this.createSessionConfigs.push(config);
97
const session = config?.session ?? URI.parse('copilot:///new-session');
98
this._stateManager.createSession({
99
resource: session.toString(),
100
provider: config?.provider ?? 'copilot',
101
title: '',
102
status: SessionStatus.Idle,
103
createdAt: Date.now(),
104
modifiedAt: Date.now(),
105
project: { uri: 'file:///created-project', displayName: 'Created Project' },
106
workingDirectory: config?.workingDirectory?.toString(),
107
});
108
return session;
109
}
110
111
async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> { return { schema: { type: 'object', properties: {} }, values: {} }; }
112
async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise<SessionConfigCompletionsResult> { return { items: [] }; }
113
async disposeSession(_session: URI): Promise<void> { }
114
async listSessions(): Promise<IAgentSessionMetadata[]> { return this.listedSessions; }
115
async subscribe(resource: URI): Promise<IStateSnapshot> {
116
const snapshot = this._stateManager.getSnapshot(resource.toString());
117
if (!snapshot) {
118
throw new Error(`Cannot subscribe to unknown resource: ${resource.toString()}`);
119
}
120
return snapshot;
121
}
122
unsubscribe(_resource: URI): void { }
123
async shutdown(): Promise<void> { }
124
async authenticate(_params: AuthenticateParams): Promise<AuthenticateResult> { return { authenticated: true }; }
125
async resourceWrite(_params: ResourceWriteParams): Promise<ResourceWriteResult> { return {}; }
126
async resourceList(uri: URI): Promise<ResourceListResult> {
127
this.browsedUris.push(uri);
128
const error = this.browseErrors.get(uri.toString());
129
if (error) {
130
throw error;
131
}
132
return {
133
entries: [
134
{ name: 'src', type: 'directory' },
135
{ name: 'README.md', type: 'file' },
136
],
137
};
138
}
139
async resourceRead(_uri: URI): Promise<ResourceReadResult> {
140
throw new Error('Not implemented');
141
}
142
async resourceCopy(): Promise<{}> { return {}; }
143
async resourceDelete(): Promise<{}> { return {}; }
144
async resourceMove(): Promise<{}> { return {}; }
145
async createTerminal(): Promise<void> { }
146
async disposeTerminal(): Promise<void> { }
147
148
dispose(): void {
149
this._onDidAction.dispose();
150
this._onDidNotification.dispose();
151
}
152
}
153
154
// ---- Helpers ----------------------------------------------------------------
155
156
function notification(method: string, params?: unknown): ProtocolMessage {
157
return { jsonrpc: '2.0', method, params } as ProtocolMessage;
158
}
159
160
function request(id: number, method: string, params?: unknown): ProtocolMessage {
161
return { jsonrpc: '2.0', id, method, params } as ProtocolMessage;
162
}
163
164
function findNotifications(sent: ProtocolMessage[], method: string): AhpNotification[] {
165
return sent.filter(isJsonRpcNotification) as AhpNotification[];
166
}
167
168
function findResponse(sent: ProtocolMessage[], id: number): ProtocolMessage | undefined {
169
return sent.find(isJsonRpcResponse) as ProtocolMessage | undefined;
170
}
171
172
function waitForResponse(transport: MockProtocolTransport, id: number): Promise<ProtocolMessage> {
173
return Event.toPromise(Event.filter(transport.onDidSend, message => isJsonRpcResponse(message) && message.id === id));
174
}
175
176
// ---- Tests ------------------------------------------------------------------
177
178
suite('ProtocolServerHandler', () => {
179
180
let disposables: DisposableStore;
181
let stateManager: AgentHostStateManager;
182
let server: MockProtocolServer;
183
let agentService: MockAgentService;
184
let handler: ProtocolServerHandler;
185
186
const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString();
187
188
function makeSessionSummary(resource?: string): SessionSummary {
189
return {
190
resource: resource ?? sessionUri,
191
provider: 'copilot',
192
title: 'Test',
193
status: SessionStatus.Idle,
194
createdAt: Date.now(),
195
modifiedAt: Date.now(),
196
project: { uri: 'file:///test-project', displayName: 'Test Project' },
197
};
198
}
199
200
function connectClient(clientId: string, initialSubscriptions?: readonly string[]): MockProtocolTransport {
201
const transport = new MockProtocolTransport();
202
server.simulateConnection(transport);
203
transport.simulateMessage(request(1, 'initialize', {
204
protocolVersion: PROTOCOL_VERSION,
205
clientId,
206
initialSubscriptions,
207
}));
208
return transport;
209
}
210
211
setup(() => {
212
disposables = new DisposableStore();
213
stateManager = disposables.add(new AgentHostStateManager(new NullLogService()));
214
server = disposables.add(new MockProtocolServer());
215
agentService = new MockAgentService();
216
agentService.setStateManager(stateManager);
217
disposables.add(agentService);
218
disposables.add(handler = new ProtocolServerHandler(
219
agentService,
220
stateManager,
221
server,
222
{ defaultDirectory: URI.file('/home/testuser').toString() },
223
disposables.add(new AgentHostFileSystemProvider()),
224
new NullLogService(),
225
));
226
});
227
228
teardown(() => {
229
disposables.dispose();
230
});
231
232
ensureNoDisposablesAreLeakedInTestSuite();
233
234
test('handshake returns initialize response', () => {
235
const transport = connectClient('client-1');
236
237
const resp = findResponse(transport.sent, 1);
238
assert.ok(resp, 'should have sent initialize response');
239
const result = (resp as { result: InitializeResult }).result;
240
assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION);
241
assert.strictEqual(result.serverSeq, stateManager.serverSeq);
242
});
243
244
test('handshake with initialSubscriptions returns snapshots', () => {
245
stateManager.createSession(makeSessionSummary());
246
247
const transport = connectClient('client-1', [sessionUri]);
248
249
const resp = findResponse(transport.sent, 1);
250
assert.ok(resp);
251
const result = (resp as { result: InitializeResult }).result;
252
assert.strictEqual(result.snapshots.length, 1);
253
assert.strictEqual(result.snapshots[0].resource.toString(), sessionUri.toString());
254
});
255
256
test('subscribe request returns snapshot', async () => {
257
stateManager.createSession(makeSessionSummary());
258
259
const transport = connectClient('client-1');
260
transport.sent.length = 0;
261
const responsePromise = waitForResponse(transport, 1);
262
263
transport.simulateMessage(request(1, 'subscribe', { resource: sessionUri }));
264
const resp = await responsePromise;
265
266
assert.ok(resp, 'should have sent response');
267
const result = (resp as unknown as { result: { snapshot: IStateSnapshot } }).result;
268
assert.strictEqual(result.snapshot.resource.toString(), sessionUri.toString());
269
});
270
271
test('client action is dispatched and echoed', () => {
272
stateManager.createSession(makeSessionSummary());
273
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
274
275
const transport = connectClient('client-1', [sessionUri]);
276
transport.sent.length = 0;
277
278
transport.simulateMessage(notification('dispatchAction', {
279
clientSeq: 1,
280
action: {
281
type: ActionType.SessionTurnStarted,
282
session: sessionUri,
283
turnId: 'turn-1',
284
userMessage: { text: 'hello' },
285
},
286
}));
287
288
const actionMsgs = findNotifications(transport.sent, 'action');
289
const turnStarted = actionMsgs.find(m => {
290
const envelope = m.params as unknown as { action: { type: string } };
291
return envelope.action.type === ActionType.SessionTurnStarted;
292
});
293
assert.ok(turnStarted, 'should have echoed turnStarted');
294
const envelope = turnStarted!.params as unknown as { origin: { clientId: string; clientSeq: number } };
295
assert.strictEqual(envelope.origin.clientId, 'client-1');
296
assert.strictEqual(envelope.origin.clientSeq, 1);
297
});
298
299
test('actions are scoped to subscribed sessions', () => {
300
stateManager.createSession(makeSessionSummary());
301
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
302
303
const transportA = connectClient('client-a', [sessionUri]);
304
const transportB = connectClient('client-b');
305
306
transportA.sent.length = 0;
307
transportB.sent.length = 0;
308
309
stateManager.dispatchServerAction({
310
type: ActionType.SessionTitleChanged,
311
session: sessionUri,
312
title: 'New Title',
313
});
314
315
assert.strictEqual(findNotifications(transportA.sent, 'action').length, 1);
316
assert.strictEqual(findNotifications(transportB.sent, 'action').length, 0);
317
});
318
319
test('notifications are broadcast to all clients', () => {
320
const transportA = connectClient('client-a');
321
const transportB = connectClient('client-b');
322
323
transportA.sent.length = 0;
324
transportB.sent.length = 0;
325
326
stateManager.createSession(makeSessionSummary());
327
328
assert.strictEqual(findNotifications(transportA.sent, 'notification').length, 1);
329
assert.strictEqual(findNotifications(transportB.sent, 'notification').length, 1);
330
});
331
332
test('listSessions includes project metadata', async () => {
333
agentService.listedSessions.push({
334
session: URI.parse(sessionUri),
335
startTime: 1000,
336
modifiedTime: 2000,
337
project: { uri: URI.file('/workspace/project'), displayName: 'Project' },
338
summary: 'Session Summary',
339
});
340
341
const transport = connectClient('client-list');
342
transport.sent.length = 0;
343
const responsePromise = waitForResponse(transport, 2);
344
345
transport.simulateMessage(request(2, 'listSessions'));
346
const resp = await responsePromise;
347
348
const result = (resp as unknown as { result: ListSessionsResult }).result;
349
assert.deepStrictEqual(result.items.map(item => item.project), [{ uri: URI.file('/workspace/project').toString(), displayName: 'Project' }]);
350
});
351
352
test('listSessions omits project metadata when absent', async () => {
353
agentService.listedSessions.push({
354
session: URI.parse(sessionUri),
355
startTime: 1000,
356
modifiedTime: 2000,
357
summary: 'Session Summary',
358
});
359
360
const transport = connectClient('client-list-no-project');
361
transport.sent.length = 0;
362
const responsePromise = waitForResponse(transport, 2);
363
364
transport.simulateMessage(request(2, 'listSessions'));
365
const resp = await responsePromise;
366
367
const result = (resp as unknown as { result: ListSessionsResult }).result;
368
assert.deepStrictEqual(result.items.map(item => item.project), [undefined]);
369
});
370
371
test('listSessions includes diffs with before/after URIs and content refs', async () => {
372
agentService.listedSessions.push({
373
session: URI.parse(sessionUri),
374
startTime: 1000,
375
modifiedTime: 2000,
376
summary: 'Session With Diffs',
377
diffs: [
378
{
379
before: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://before-ref' } },
380
after: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://after-ref' } },
381
diff: { added: 5, removed: 2 },
382
},
383
{
384
after: { uri: URI.file('/workspace/new-file.ts').toString(), content: { uri: 'content://new-ref' } },
385
},
386
{
387
before: { uri: URI.file('/workspace/deleted.ts').toString(), content: { uri: 'content://deleted-ref' } },
388
},
389
],
390
});
391
392
const transport = connectClient('client-list-diffs');
393
transport.sent.length = 0;
394
const responsePromise = waitForResponse(transport, 2);
395
396
transport.simulateMessage(request(2, 'listSessions'));
397
const resp = await responsePromise;
398
399
const result = (resp as unknown as { result: ListSessionsResult }).result;
400
assert.deepStrictEqual(result.items[0].diffs, [
401
{
402
before: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://before-ref' } },
403
after: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://after-ref' } },
404
diff: { added: 5, removed: 2 },
405
},
406
{
407
after: { uri: URI.file('/workspace/new-file.ts').toString(), content: { uri: 'content://new-ref' } },
408
},
409
{
410
before: { uri: URI.file('/workspace/deleted.ts').toString(), content: { uri: 'content://deleted-ref' } },
411
},
412
]);
413
});
414
415
test('createSession returns null and broadcasts project in sessionAdded summary', async () => {
416
const transport = connectClient('client-create');
417
transport.sent.length = 0;
418
const responsePromise = waitForResponse(transport, 2);
419
420
const newSession = URI.parse('copilot:///created-session').toString();
421
transport.simulateMessage(request(2, 'createSession', { session: newSession }));
422
const resp = await responsePromise;
423
424
const added = findNotifications(transport.sent, 'notification').find(message => {
425
const params = message.params as { notification: { type: string } };
426
return params.notification.type === 'notify/sessionAdded';
427
});
428
assert.deepStrictEqual({
429
result: (resp as { result: null }).result,
430
project: (added!.params as { notification: { summary: SessionSummary } }).notification.summary.project,
431
}, {
432
result: null,
433
project: { uri: 'file:///created-project', displayName: 'Created Project' },
434
});
435
});
436
437
test('reconnect replays missed actions', () => {
438
stateManager.createSession(makeSessionSummary());
439
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
440
441
const transport1 = connectClient('client-r', [sessionUri]);
442
const resp = findResponse(transport1.sent, 1);
443
const initSeq = (resp as { result: InitializeResult }).result.serverSeq;
444
transport1.simulateClose();
445
446
stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Title A' });
447
stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Title B' });
448
449
const transport2 = new MockProtocolTransport();
450
server.simulateConnection(transport2);
451
transport2.simulateMessage(request(1, 'reconnect', {
452
clientId: 'client-r',
453
lastSeenServerSeq: initSeq,
454
subscriptions: [sessionUri],
455
}));
456
457
const reconnectResp = findResponse(transport2.sent, 1);
458
assert.ok(reconnectResp, 'should have sent reconnect response');
459
const result = (reconnectResp as { result: ReconnectResult }).result;
460
assert.strictEqual(result.type, 'replay');
461
if (result.type === 'replay') {
462
assert.strictEqual(result.actions.length, 2);
463
}
464
});
465
466
test('reconnect sends fresh snapshots when gap too large', () => {
467
stateManager.createSession(makeSessionSummary());
468
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
469
470
const transport1 = connectClient('client-g', [sessionUri]);
471
transport1.simulateClose();
472
473
for (let i = 0; i < 1100; i++) {
474
stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: `Title ${i}` });
475
}
476
477
const transport2 = new MockProtocolTransport();
478
server.simulateConnection(transport2);
479
transport2.simulateMessage(request(1, 'reconnect', {
480
clientId: 'client-g',
481
lastSeenServerSeq: 0,
482
subscriptions: [sessionUri],
483
}));
484
485
const reconnectResp = findResponse(transport2.sent, 1);
486
assert.ok(reconnectResp, 'should have sent reconnect response');
487
const result = (reconnectResp as { result: ReconnectResult }).result;
488
assert.strictEqual(result.type, 'snapshot');
489
if (result.type === 'snapshot') {
490
assert.ok(result.snapshots.length > 0, 'should contain snapshots');
491
}
492
});
493
494
test('client disconnect cleans up', () => {
495
stateManager.createSession(makeSessionSummary());
496
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
497
498
const transport = connectClient('client-d', [sessionUri]);
499
transport.sent.length = 0;
500
501
transport.simulateClose();
502
503
stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'After Disconnect' });
504
505
assert.strictEqual(transport.sent.length, 0);
506
});
507
508
test('client disconnect clears active client and fails owned tool calls after grace period', () => {
509
return runWithFakedTimers({ useFakeTimers: true }, async () => {
510
stateManager.createSession(makeSessionSummary());
511
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
512
stateManager.dispatchServerAction({
513
type: ActionType.SessionActiveClientChanged,
514
session: sessionUri,
515
activeClient: {
516
clientId: 'client-tools',
517
tools: [{ name: 'runTask', description: 'Runs a task' }],
518
},
519
});
520
stateManager.dispatchServerAction({
521
type: ActionType.SessionTurnStarted,
522
session: sessionUri,
523
turnId: 'turn-1',
524
userMessage: { text: 'run it' },
525
});
526
stateManager.dispatchServerAction({
527
type: ActionType.SessionToolCallStart,
528
session: sessionUri,
529
turnId: 'turn-1',
530
toolCallId: 'tool-1',
531
toolName: 'runTask',
532
displayName: 'Run Task',
533
toolClientId: 'client-tools',
534
});
535
stateManager.dispatchServerAction({
536
type: ActionType.SessionToolCallReady,
537
session: sessionUri,
538
turnId: 'turn-1',
539
toolCallId: 'tool-1',
540
invocationMessage: 'Run Task',
541
toolInput: '{}',
542
confirmed: ToolCallConfirmationReason.NotNeeded,
543
});
544
545
const transport = connectClient('client-tools', [sessionUri]);
546
transport.simulateClose();
547
548
assert.strictEqual(stateManager.getSessionState(sessionUri)?.activeClient, undefined);
549
let part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0];
550
assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);
551
assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Running);
552
553
await new Promise(r => setTimeout(r, 30_001));
554
555
part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0];
556
assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);
557
assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? {
558
status: part.toolCall.status,
559
success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined,
560
error: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.error?.message : undefined,
561
} : undefined, {
562
status: ToolCallStatus.Completed,
563
success: false,
564
error: 'Client client-tools disconnected before completing Run Task',
565
});
566
});
567
});
568
569
test('client disconnect fails owned streaming tool calls after grace period', () => {
570
return runWithFakedTimers({ useFakeTimers: true }, async () => {
571
stateManager.createSession(makeSessionSummary());
572
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
573
stateManager.dispatchServerAction({
574
type: ActionType.SessionActiveClientChanged,
575
session: sessionUri,
576
activeClient: {
577
clientId: 'client-tools',
578
tools: [{ name: 'runTask', description: 'Runs a task' }],
579
},
580
});
581
stateManager.dispatchServerAction({
582
type: ActionType.SessionTurnStarted,
583
session: sessionUri,
584
turnId: 'turn-1',
585
userMessage: { text: 'run it' },
586
});
587
stateManager.dispatchServerAction({
588
type: ActionType.SessionToolCallStart,
589
session: sessionUri,
590
turnId: 'turn-1',
591
toolCallId: 'tool-1',
592
toolName: 'runTask',
593
displayName: 'Run Task',
594
toolClientId: 'client-tools',
595
});
596
597
const transport = connectClient('client-tools', [sessionUri]);
598
transport.simulateClose();
599
600
let part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0];
601
assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);
602
assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Streaming);
603
604
await new Promise(r => setTimeout(r, 30_001));
605
606
part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0];
607
assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);
608
assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? {
609
status: part.toolCall.status,
610
success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined,
611
error: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.error?.message : undefined,
612
} : undefined, {
613
status: ToolCallStatus.Completed,
614
success: false,
615
error: 'Client client-tools disconnected before completing Run Task',
616
});
617
});
618
});
619
620
test('client reconnect without session subscription does not clear tool call disconnect timeout', () => {
621
return runWithFakedTimers({ useFakeTimers: true }, async () => {
622
stateManager.createSession(makeSessionSummary());
623
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
624
stateManager.dispatchServerAction({
625
type: ActionType.SessionActiveClientChanged,
626
session: sessionUri,
627
activeClient: {
628
clientId: 'client-tools',
629
tools: [{ name: 'runTask', description: 'Runs a task' }],
630
},
631
});
632
stateManager.dispatchServerAction({
633
type: ActionType.SessionTurnStarted,
634
session: sessionUri,
635
turnId: 'turn-1',
636
userMessage: { text: 'run it' },
637
});
638
stateManager.dispatchServerAction({
639
type: ActionType.SessionToolCallStart,
640
session: sessionUri,
641
turnId: 'turn-1',
642
toolCallId: 'tool-1',
643
toolName: 'runTask',
644
displayName: 'Run Task',
645
toolClientId: 'client-tools',
646
});
647
stateManager.dispatchServerAction({
648
type: ActionType.SessionToolCallReady,
649
session: sessionUri,
650
turnId: 'turn-1',
651
toolCallId: 'tool-1',
652
invocationMessage: 'Run Task',
653
toolInput: '{}',
654
confirmed: ToolCallConfirmationReason.NotNeeded,
655
});
656
657
const transport = connectClient('client-tools', [sessionUri]);
658
transport.simulateClose();
659
660
const reconnectTransport = new MockProtocolTransport();
661
server.simulateConnection(reconnectTransport);
662
reconnectTransport.simulateMessage(request(1, 'reconnect', {
663
clientId: 'client-tools',
664
lastSeenServerSeq: stateManager.serverSeq,
665
subscriptions: [],
666
}));
667
668
await new Promise(r => setTimeout(r, 30_001));
669
670
const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0];
671
assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);
672
assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? {
673
status: part.toolCall.status,
674
success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined,
675
} : undefined, {
676
status: ToolCallStatus.Completed,
677
success: false,
678
});
679
});
680
});
681
682
test('client reconnect with session subscription clears tool call disconnect timeout for that session', () => {
683
return runWithFakedTimers({ useFakeTimers: true }, async () => {
684
stateManager.createSession(makeSessionSummary());
685
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
686
stateManager.dispatchServerAction({
687
type: ActionType.SessionActiveClientChanged,
688
session: sessionUri,
689
activeClient: {
690
clientId: 'client-tools',
691
tools: [{ name: 'runTask', description: 'Runs a task' }],
692
},
693
});
694
stateManager.dispatchServerAction({
695
type: ActionType.SessionTurnStarted,
696
session: sessionUri,
697
turnId: 'turn-1',
698
userMessage: { text: 'run it' },
699
});
700
stateManager.dispatchServerAction({
701
type: ActionType.SessionToolCallStart,
702
session: sessionUri,
703
turnId: 'turn-1',
704
toolCallId: 'tool-1',
705
toolName: 'runTask',
706
displayName: 'Run Task',
707
toolClientId: 'client-tools',
708
});
709
stateManager.dispatchServerAction({
710
type: ActionType.SessionToolCallReady,
711
session: sessionUri,
712
turnId: 'turn-1',
713
toolCallId: 'tool-1',
714
invocationMessage: 'Run Task',
715
toolInput: '{}',
716
confirmed: ToolCallConfirmationReason.NotNeeded,
717
});
718
719
const transport = connectClient('client-tools', [sessionUri]);
720
transport.simulateClose();
721
722
const reconnectTransport = new MockProtocolTransport();
723
server.simulateConnection(reconnectTransport);
724
reconnectTransport.simulateMessage(request(1, 'reconnect', {
725
clientId: 'client-tools',
726
lastSeenServerSeq: stateManager.serverSeq,
727
subscriptions: [sessionUri],
728
}));
729
730
await new Promise(r => setTimeout(r, 30_001));
731
732
const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0];
733
assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);
734
assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Running);
735
});
736
});
737
738
test('client tool timeout tells model it may retry when replacement active client provides the tool', () => {
739
return runWithFakedTimers({ useFakeTimers: true }, async () => {
740
stateManager.createSession(makeSessionSummary());
741
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
742
stateManager.dispatchServerAction({
743
type: ActionType.SessionActiveClientChanged,
744
session: sessionUri,
745
activeClient: {
746
clientId: 'client-tools',
747
tools: [{ name: 'runTask', description: 'Runs a task' }],
748
},
749
});
750
stateManager.dispatchServerAction({
751
type: ActionType.SessionTurnStarted,
752
session: sessionUri,
753
turnId: 'turn-1',
754
userMessage: { text: 'run it' },
755
});
756
stateManager.dispatchServerAction({
757
type: ActionType.SessionToolCallStart,
758
session: sessionUri,
759
turnId: 'turn-1',
760
toolCallId: 'tool-1',
761
toolName: 'runTask',
762
displayName: 'Run Task',
763
toolClientId: 'client-tools',
764
});
765
stateManager.dispatchServerAction({
766
type: ActionType.SessionToolCallReady,
767
session: sessionUri,
768
turnId: 'turn-1',
769
toolCallId: 'tool-1',
770
invocationMessage: 'Run Task',
771
toolInput: '{}',
772
confirmed: ToolCallConfirmationReason.NotNeeded,
773
});
774
775
const transport = connectClient('client-tools', [sessionUri]);
776
transport.simulateClose();
777
stateManager.dispatchServerAction({
778
type: ActionType.SessionActiveClientChanged,
779
session: sessionUri,
780
activeClient: {
781
clientId: 'client-replacement',
782
tools: [{ name: 'runTask', description: 'Runs a task' }],
783
},
784
});
785
786
await new Promise(r => setTimeout(r, 30_001));
787
788
const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0];
789
assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);
790
assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.Completed ? {
791
status: part.toolCall.status,
792
success: part.toolCall.success,
793
content: part.toolCall.content,
794
} : undefined, {
795
status: ToolCallStatus.Completed,
796
success: false,
797
content: [{ 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.' }],
798
});
799
});
800
});
801
802
test('handshake includes defaultDirectory from side effects', () => {
803
const transport = connectClient('client-home');
804
805
const resp = findResponse(transport.sent, 1);
806
assert.ok(resp);
807
const result = (resp as { result: InitializeResult }).result;
808
assert.strictEqual(URI.parse(result.defaultDirectory!).path, '/home/testuser');
809
});
810
811
test('resourceList routes to side effect handler', async () => {
812
const transport = connectClient('client-browse');
813
transport.sent.length = 0;
814
815
const dirUri = URI.file('/home/user/project').toString();
816
const responsePromise = waitForResponse(transport, 2);
817
transport.simulateMessage(request(2, 'resourceList', { uri: dirUri }));
818
const resp = await responsePromise;
819
820
assert.strictEqual(agentService.browsedUris.length, 1);
821
assert.strictEqual(agentService.browsedUris[0].path, '/home/user/project');
822
823
assert.ok(resp);
824
const result = (resp as unknown as { result: { entries: { name: string; uri: unknown; type: string }[] } }).result;
825
assert.strictEqual(result.entries.length, 2);
826
assert.strictEqual(result.entries[0].name, 'src');
827
assert.strictEqual(result.entries[0].type, 'directory');
828
assert.strictEqual(result.entries[1].name, 'README.md');
829
assert.strictEqual(result.entries[1].type, 'file');
830
});
831
832
test('resourceList returns a JSON-RPC error when the target is invalid', async () => {
833
const transport = connectClient('client-browse-error');
834
transport.sent.length = 0;
835
836
const dirUri = URI.file('/missing').toString();
837
agentService.browseErrors.set(URI.file('/missing').toString(), new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Directory not found: ${dirUri}`));
838
const responsePromise = waitForResponse(transport, 2);
839
transport.simulateMessage(request(2, 'resourceList', { uri: dirUri }));
840
const resp = await responsePromise as { error?: { code: number; message: string } };
841
842
assert.ok(resp?.error);
843
assert.strictEqual(resp.error!.code, JSON_RPC_INTERNAL_ERROR);
844
assert.match(resp.error!.message, /Directory not found/);
845
});
846
847
// ---- Extension methods: auth ----------------------------------------
848
849
test('authenticate returns result via typed request', async () => {
850
const transport = connectClient('client-auth');
851
transport.sent.length = 0;
852
853
const responsePromise = waitForResponse(transport, 2);
854
transport.simulateMessage(request(2, 'authenticate', { resource: 'https://api.github.com', token: 'test-token' }));
855
const resp = await responsePromise as { result?: Record<string, unknown>; error?: { code: number; message: string } };
856
857
assert.ok(!resp.error, `unexpected error: ${resp.error?.message}`);
858
assert.deepStrictEqual(resp.result, {});
859
});
860
861
test('extension request preserves ProtocolError code and data', async () => {
862
// Override authenticate to throw a ProtocolError with data
863
const origHandler = agentService.authenticate;
864
agentService.authenticate = async () => { throw new ProtocolError(-32007, 'Auth required', { hint: 'sign in' }); };
865
866
const transport = connectClient('client-auth-error');
867
transport.sent.length = 0;
868
869
const responsePromise = waitForResponse(transport, 2);
870
transport.simulateMessage(request(2, 'authenticate', { resource: 'test', token: 'bad' }));
871
const resp = await responsePromise as { error?: { code: number; message: string; data?: unknown } };
872
873
assert.ok(resp?.error);
874
assert.strictEqual(resp.error!.code, -32007);
875
assert.strictEqual(resp.error!.message, 'Auth required');
876
assert.deepStrictEqual(resp.error!.data, { hint: 'sign in' });
877
878
agentService.authenticate = origHandler;
879
});
880
881
// ---- Connection count event -----------------------------------------
882
883
test('onDidChangeConnectionCount fires on connect and disconnect', () => {
884
const counts: number[] = [];
885
disposables.add(handler.onDidChangeConnectionCount(c => counts.push(c)));
886
887
const transport = connectClient('client-count-1');
888
connectClient('client-count-2');
889
transport.simulateClose();
890
891
assert.deepStrictEqual(counts, [1, 2, 1]);
892
});
893
894
test('onDidChangeConnectionCount is not decremented by stale reconnect close', () => {
895
const counts: number[] = [];
896
disposables.add(handler.onDidChangeConnectionCount(c => counts.push(c)));
897
898
// Connect
899
const transport1 = connectClient('client-rc');
900
assert.deepStrictEqual(counts, [1]);
901
902
// Reconnect with same clientId (new transport)
903
const transport2 = new MockProtocolTransport();
904
server.simulateConnection(transport2);
905
transport2.simulateMessage(request(1, 'reconnect', {
906
clientId: 'client-rc',
907
lastSeenServerSeq: 0,
908
subscriptions: [],
909
}));
910
// Count is unchanged because same clientId was overwritten
911
assert.deepStrictEqual(counts, [1, 1]);
912
913
// Old transport closes - should NOT decrement since it's stale
914
transport1.simulateClose();
915
assert.deepStrictEqual(counts, [1, 1]);
916
917
// New transport closes - should decrement
918
transport2.simulateClose();
919
assert.deepStrictEqual(counts, [1, 1, 0]);
920
});
921
922
// ---- createSession activeClient -------------------------------------
923
924
suite('createSession activeClient', () => {
925
926
test('forwards activeClient to the agent service', async () => {
927
const newSession = URI.parse('copilot:///eager-session').toString();
928
929
const transport = connectClient('client-1');
930
transport.sent.length = 0;
931
932
const responsePromise = waitForResponse(transport, 2);
933
transport.simulateMessage(request(2, 'createSession', {
934
session: newSession,
935
provider: 'copilot',
936
activeClient: {
937
clientId: 'client-1',
938
tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }],
939
customizations: [{ uri: 'file:///plugin-a', displayName: 'A' }],
940
},
941
}));
942
const resp = await responsePromise as { result?: unknown; error?: unknown };
943
944
assert.strictEqual(resp.error, undefined, 'createSession should succeed');
945
const config = agentService.createSessionConfigs.at(-1);
946
assert.deepStrictEqual({
947
clientId: config?.activeClient?.clientId,
948
toolName: config?.activeClient?.tools[0]?.name,
949
customizationUri: config?.activeClient?.customizations?.[0].uri,
950
}, {
951
clientId: 'client-1',
952
toolName: 't1',
953
customizationUri: 'file:///plugin-a',
954
});
955
});
956
957
test('rejects createSession when activeClient.clientId mismatches', async () => {
958
const newSession = URI.parse('copilot:///mismatch-session').toString();
959
960
const transport = connectClient('client-1');
961
transport.sent.length = 0;
962
963
const responsePromise = waitForResponse(transport, 2);
964
transport.simulateMessage(request(2, 'createSession', {
965
session: newSession,
966
provider: 'copilot',
967
activeClient: {
968
clientId: 'other-client',
969
tools: [],
970
},
971
}));
972
const resp = await responsePromise as { result?: unknown; error?: { code: number; message: string } };
973
974
assert.ok(resp.error, 'response should be an error');
975
assert.strictEqual(resp.result, undefined);
976
assert.strictEqual(agentService.createSessionConfigs.length, 0, 'agent service should not have been called');
977
});
978
});
979
});
980
981