Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/node/agentService.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 { mkdtempSync, readFileSync, rmSync } from 'fs';
8
import { tmpdir } from 'os';
9
import { fileURLToPath } from 'url';
10
import { VSBuffer } from '../../../../base/common/buffer.js';
11
import { DisposableStore, IReference, toDisposable } from '../../../../base/common/lifecycle.js';
12
import { Schemas } from '../../../../base/common/network.js';
13
import { joinPath } from '../../../../base/common/resources.js';
14
import { URI } from '../../../../base/common/uri.js';
15
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
16
import { hasKey } from '../../../../base/common/types.js';
17
import { NullLogService } from '../../../log/common/log.js';
18
import { FileService } from '../../../files/common/fileService.js';
19
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
20
import { AgentSession } from '../../common/agentService.js';
21
import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
22
import { SessionDatabase } from '../../node/sessionDatabase.js';
23
import { ActionType, ActionEnvelope } from '../../common/state/sessionActions.js';
24
import { SessionActiveClient, ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart } from '../../common/state/sessionState.js';
25
import { IProductService } from '../../../product/common/productService.js';
26
import { AgentService } from '../../node/agentService.js';
27
import { MockAgent, ScriptedMockAgent } from './mockAgent.js';
28
import { mapSessionEventsToHistoryRecords } from './historyRecordFixtures.js';
29
import { type ISessionEvent } from '../../node/copilot/mapSessionEvents.js';
30
import { createNoopGitService, createSessionDataService } from '../common/sessionTestHelpers.js';
31
32
/**
33
* Loads a JSONL fixture of raw Copilot SDK events, runs them through
34
* {@link mapSessionEventsToHistoryRecords}, and returns the result
35
* suitable for setting on {@link MockAgent.sessionMessages}. Tests the
36
* full pipeline: SDK events → IHistoryRecord → buildTurnsFromHistory →
37
* Turn[].
38
*
39
* Fixture files live in `test-cases/` and are sanitized copies of real
40
* `events.jsonl` files from `~/.copilot/session-state/`.
41
*/
42
async function loadFixtureMessages(fixtureName: string, session: URI) {
43
// Resolve the fixture from the source tree (test-cases/ is not compiled to out/)
44
const thisFile = fileURLToPath(import.meta.url);
45
// Navigate from out/vs/... to src/vs/... by replacing the out/ prefix.
46
// Use a regex that handles both / and \ separators for Windows compat.
47
const srcFile = thisFile.replace(/[/\\]out[/\\]/, (m) => m.replace('out', 'src'));
48
const lastSep = Math.max(srcFile.lastIndexOf('/'), srcFile.lastIndexOf('\\'));
49
const fixtureDir = srcFile.substring(0, lastSep);
50
const sep = srcFile.includes('\\') ? '\\' : '/';
51
const raw = readFileSync(`${fixtureDir}${sep}test-cases${sep}${fixtureName}`, 'utf-8');
52
const events: ISessionEvent[] = raw.trim().split('\n').map(line => JSON.parse(line));
53
return mapSessionEventsToHistoryRecords(session, undefined, events);
54
}
55
56
suite('AgentService (node dispatcher)', () => {
57
58
const disposables = new DisposableStore();
59
let service: AgentService;
60
let copilotAgent: MockAgent;
61
let fileService: FileService;
62
let nullSessionDataService: ISessionDataService;
63
64
setup(async () => {
65
nullSessionDataService = {
66
_serviceBrand: undefined,
67
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
68
getSessionDataDirById: () => URI.parse('inmemory:/session-data'),
69
openDatabase: () => { throw new Error('not implemented'); },
70
tryOpenDatabase: async () => undefined,
71
deleteSessionData: async () => { },
72
cleanupOrphanedData: async () => { },
73
whenIdle: async () => { },
74
};
75
76
fileService = disposables.add(new FileService(new NullLogService()));
77
disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider())));
78
79
// Seed a directory for browseDirectory tests
80
await fileService.createFolder(URI.from({ scheme: Schemas.inMemory, path: '/testDir' }));
81
await fileService.writeFile(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }), VSBuffer.fromString('hello'));
82
83
service = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
84
copilotAgent = new MockAgent('copilot');
85
disposables.add(toDisposable(() => copilotAgent.dispose()));
86
});
87
88
teardown(() => disposables.clear());
89
ensureNoDisposablesAreLeakedInTestSuite();
90
91
// ---- Provider registration ------------------------------------------
92
93
suite('registerProvider', () => {
94
95
test('registers a provider successfully', () => {
96
service.registerProvider(copilotAgent);
97
// No throw - success
98
});
99
100
test('throws on duplicate provider registration', () => {
101
service.registerProvider(copilotAgent);
102
const duplicate = new MockAgent('copilot');
103
disposables.add(toDisposable(() => duplicate.dispose()));
104
assert.throws(() => service.registerProvider(duplicate), /already registered/);
105
});
106
107
test('maps progress events to protocol actions via onDidAction', async () => {
108
service.registerProvider(copilotAgent);
109
const session = await service.createSession({ provider: 'copilot' });
110
111
// Start a turn so there's an active turn to map events to
112
service.dispatchAction(
113
{ type: ActionType.SessionTurnStarted, session: session.toString(), turnId: 'turn-1', userMessage: { text: 'hello' } },
114
'test-client', 1,
115
);
116
117
const envelopes: ActionEnvelope[] = [];
118
disposables.add(service.onDidAction(e => envelopes.push(e)));
119
120
copilotAgent.fireProgress({
121
kind: 'action', session,
122
action: { type: ActionType.SessionResponsePart, session: session.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'hello' } },
123
});
124
assert.ok(envelopes.some(e => e.action.type === ActionType.SessionResponsePart));
125
});
126
});
127
128
// ---- createSession --------------------------------------------------
129
130
suite('dispatchAction', () => {
131
132
test('applies and persists root config changes from clients', async () => {
133
const tempDir = URI.file(mkdtempSync(`${tmpdir()}/agent-host-config-`));
134
try {
135
const rootConfigResource = joinPath(tempDir, 'agent-host-config.json');
136
const svc = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), rootConfigResource));
137
const agent = new MockAgent('copilot');
138
disposables.add(toDisposable(() => agent.dispose()));
139
svc.registerProvider(agent);
140
141
const customization = { uri: 'file:///plugin-a', displayName: 'Plugin A' };
142
svc.dispatchAction({
143
type: ActionType.RootConfigChanged,
144
config: { customizations: [customization] },
145
}, 'test-client', 1);
146
147
let persisted = false;
148
for (let attempt = 0; attempt < 20; attempt++) {
149
try {
150
const parsed = JSON.parse(readFileSync(rootConfigResource.fsPath, 'utf8'));
151
assert.deepStrictEqual(
152
parsed.customizations,
153
[customization],
154
);
155
persisted = true;
156
break;
157
} catch {
158
// Wait for the serialized root-config write to complete.
159
}
160
if (attempt === 19) {
161
break;
162
}
163
await new Promise(resolve => setTimeout(resolve, 5));
164
}
165
166
assert.ok(persisted, 'should persist the root config change');
167
} finally {
168
rmSync(tempDir.fsPath, { recursive: true, force: true });
169
}
170
});
171
});
172
173
suite('createSession', () => {
174
175
test('creates session via specified provider', async () => {
176
service.registerProvider(copilotAgent);
177
178
const session = await service.createSession({ provider: 'copilot' });
179
assert.strictEqual(AgentSession.provider(session), 'copilot');
180
});
181
182
test('honors requested session URI', async () => {
183
service.registerProvider(copilotAgent);
184
185
const requestedSession = AgentSession.uri('copilot', 'requested-session');
186
const session = await service.createSession({ provider: 'copilot', session: requestedSession });
187
assert.strictEqual(session.toString(), requestedSession.toString());
188
});
189
190
test('scripted mock agent honors requested session URI', async () => {
191
const agent = new ScriptedMockAgent();
192
disposables.add(toDisposable(() => agent.dispose()));
193
194
const requestedSession = AgentSession.uri('mock', 'requested-session');
195
const result = await agent.createSession({ session: requestedSession });
196
const sessions = await agent.listSessions();
197
198
assert.deepStrictEqual({
199
created: result.session.toString(),
200
listed: sessions.some(s => s.session.toString() === requestedSession.toString()),
201
}, {
202
created: requestedSession.toString(),
203
listed: true,
204
});
205
});
206
207
test('uses default provider when none specified', async () => {
208
service.registerProvider(copilotAgent);
209
210
const session = await service.createSession();
211
assert.strictEqual(AgentSession.provider(session), 'copilot');
212
});
213
214
test('throws when no providers are registered at all', async () => {
215
await assert.rejects(() => service.createSession(), /No agent provider/);
216
});
217
});
218
219
// ---- disposeSession -------------------------------------------------
220
221
suite('disposeSession', () => {
222
223
test('dispatches to the correct provider and cleans up tracking', async () => {
224
service.registerProvider(copilotAgent);
225
226
const session = await service.createSession({ provider: 'copilot' });
227
await service.disposeSession(session);
228
229
assert.strictEqual(copilotAgent.disposeSessionCalls.length, 1);
230
});
231
232
test('is a no-op for unknown sessions', async () => {
233
service.registerProvider(copilotAgent);
234
const unknownSession = URI.from({ scheme: 'unknown', path: '/nope' });
235
236
// Should not throw
237
await service.disposeSession(unknownSession);
238
});
239
});
240
241
// ---- listSessions / listModels --------------------------------------
242
243
suite('aggregation', () => {
244
245
test('listSessions aggregates sessions from all providers', async () => {
246
service.registerProvider(copilotAgent);
247
248
await service.createSession({ provider: 'copilot' });
249
250
const sessions = await service.listSessions();
251
assert.strictEqual(sessions.length, 1);
252
});
253
254
test('listSessions overlays custom title from session database', async () => {
255
// Pre-seed a custom title in an in-memory database
256
const db = disposables.add(await SessionDatabase.open(':memory:'));
257
await db.setMetadata('customTitle', 'My Custom Title');
258
259
const sessionId = 'test-session-abc';
260
const sessionUri = AgentSession.uri('copilot', sessionId);
261
262
const sessionDataService: ISessionDataService = {
263
_serviceBrand: undefined,
264
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
265
getSessionDataDirById: () => URI.parse('inmemory:/session-data'),
266
openDatabase: (): IReference<ISessionDatabase> => ({
267
object: db,
268
dispose: () => { },
269
}),
270
tryOpenDatabase: async (): Promise<IReference<ISessionDatabase> | undefined> => ({
271
object: db,
272
dispose: () => { },
273
}),
274
deleteSessionData: async () => { },
275
cleanupOrphanedData: async () => { },
276
whenIdle: async () => { },
277
};
278
279
// Create a mock that returns a session with that ID
280
const agent = new MockAgent('copilot');
281
disposables.add(toDisposable(() => agent.dispose()));
282
agent.sessionMetadataOverrides = { summary: 'SDK Title' };
283
// Manually add the session to the mock
284
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
285
286
const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
287
svc.registerProvider(agent);
288
289
const sessions = await svc.listSessions();
290
assert.strictEqual(sessions.length, 1);
291
assert.strictEqual(sessions[0].summary, 'My Custom Title');
292
});
293
294
test('listSessions uses SDK title when no custom title exists', async () => {
295
service.registerProvider(copilotAgent);
296
copilotAgent.sessionMetadataOverrides = { summary: 'Auto-generated Title' };
297
298
await service.createSession({ provider: 'copilot' });
299
300
const sessions = await service.listSessions();
301
assert.strictEqual(sessions.length, 1);
302
assert.strictEqual(sessions[0].summary, 'Auto-generated Title');
303
});
304
305
test('listSessions overlays live state manager title over SDK title', async () => {
306
service.registerProvider(copilotAgent);
307
308
const session = await service.createSession({ provider: 'copilot' });
309
310
// Simulate immediate title change via state manager
311
service.stateManager.dispatchServerAction({
312
type: ActionType.SessionTitleChanged,
313
session: session.toString(),
314
title: 'User first message',
315
});
316
317
const sessions = await service.listSessions();
318
assert.strictEqual(sessions.length, 1);
319
assert.strictEqual(sessions[0].summary, 'User first message');
320
});
321
322
test('createSession attaches git state into state _meta when working directory is present', async () => {
323
const workingDirectory = URI.file('/workspace/repo');
324
const gitState = {
325
hasGitHubRemote: true,
326
branchName: 'feature/x',
327
baseBranchName: 'main',
328
upstreamBranchName: 'origin/feature/x',
329
incomingChanges: 1,
330
outgoingChanges: 2,
331
uncommittedChanges: 3,
332
};
333
const calls: string[] = [];
334
const gitService = {
335
_serviceBrand: undefined,
336
isInsideWorkTree: async () => true,
337
getCurrentBranch: async () => undefined,
338
getDefaultBranch: async () => undefined,
339
getBranches: async () => [],
340
getRepositoryRoot: async () => undefined,
341
getWorktreeRoots: async () => [],
342
addWorktree: async () => { },
343
addExistingWorktree: async () => { },
344
removeWorktree: async () => { },
345
branchExists: async () => false,
346
hasUncommittedChanges: async () => false,
347
getSessionGitState: async (uri: URI) => { calls.push(uri.fsPath); return gitState; },
348
computeSessionFileDiffs: async () => undefined,
349
showBlob: async () => undefined,
350
};
351
const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
352
const agent = new MockAgent('copilot');
353
disposables.add(toDisposable(() => agent.dispose()));
354
agent.resolvedWorkingDirectory = workingDirectory;
355
agent.sessionMetadataOverrides = { workingDirectory };
356
localService.registerProvider(agent);
357
358
const session = await localService.createSession({ provider: 'copilot' });
359
360
// _attachGitState is fire-and-forget; drain microtasks until the
361
// git service's promise has resolved and setSessionMeta has run.
362
for (let i = 0; i < 5; i++) {
363
await Promise.resolve();
364
}
365
366
const sessions = await localService.listSessions();
367
assert.strictEqual(sessions.length, 1);
368
assert.deepStrictEqual(calls, [workingDirectory.fsPath]);
369
assert.deepStrictEqual(
370
localService.stateManager.getSessionState(session.toString())?._meta,
371
{ git: gitState },
372
);
373
});
374
375
test('createSession skips git overlay when no working directory or no git state', async () => {
376
const gitService = {
377
_serviceBrand: undefined,
378
isInsideWorkTree: async () => false,
379
getCurrentBranch: async () => undefined,
380
getDefaultBranch: async () => undefined,
381
getBranches: async () => [],
382
getRepositoryRoot: async () => undefined,
383
getWorktreeRoots: async () => [],
384
addWorktree: async () => { },
385
addExistingWorktree: async () => { },
386
removeWorktree: async () => { },
387
branchExists: async () => false,
388
hasUncommittedChanges: async () => false,
389
getSessionGitState: async () => undefined,
390
computeSessionFileDiffs: async () => undefined,
391
showBlob: async () => undefined,
392
};
393
const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
394
const agent = new MockAgent('copilot');
395
disposables.add(toDisposable(() => agent.dispose()));
396
// No resolvedWorkingDirectory set on the mock.
397
localService.registerProvider(agent);
398
399
const session = await localService.createSession({ provider: 'copilot' });
400
for (let i = 0; i < 5; i++) {
401
await Promise.resolve();
402
}
403
const sessions = await localService.listSessions();
404
405
assert.strictEqual(sessions.length, 1);
406
assert.strictEqual(localService.stateManager.getSessionState(session.toString())?._meta, undefined);
407
});
408
409
test('subscribe lazily attaches git state when an existing session has no _meta.git', async () => {
410
// Regression test: previously AgentService was constructed without
411
// a git service, so _attachGitState always bailed and `_meta.git`
412
// was never populated. This test ensures the lazy-fire path on
413
// subscribe() actually invokes the git service and writes git
414
// state into the session's `_meta`.
415
const workingDirectory = URI.file('/workspace/repo');
416
const gitState = {
417
hasGitHubRemote: false,
418
branchName: 'feature/lazy',
419
baseBranchName: 'main',
420
upstreamBranchName: undefined,
421
incomingChanges: 0,
422
outgoingChanges: 0,
423
uncommittedChanges: 0,
424
};
425
const calls: string[] = [];
426
const gitService = createNoopGitService();
427
gitService.getSessionGitState = async (uri: URI) => { calls.push(uri.fsPath); return gitState; };
428
const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
429
const agent = new MockAgent('copilot');
430
disposables.add(toDisposable(() => agent.dispose()));
431
agent.resolvedWorkingDirectory = workingDirectory;
432
agent.sessionMetadataOverrides = { workingDirectory };
433
localService.registerProvider(agent);
434
435
// Seed a session and clear its _meta so subscribe must lazily
436
// recompute git state.
437
const session = await localService.createSession({ provider: 'copilot' });
438
for (let i = 0; i < 5; i++) {
439
await Promise.resolve();
440
}
441
localService.stateManager.setSessionMeta(session.toString(), undefined);
442
calls.length = 0;
443
444
await localService.subscribe(session);
445
for (let i = 0; i < 5; i++) {
446
await Promise.resolve();
447
}
448
449
assert.deepStrictEqual(calls, [workingDirectory.fsPath]);
450
assert.deepStrictEqual(
451
localService.stateManager.getSessionState(session.toString())?._meta,
452
{ git: gitState },
453
);
454
});
455
456
test('createSession stores live session config', async () => {
457
service.registerProvider(copilotAgent);
458
459
const config = { isolation: 'worktree', branch: 'feature/config' };
460
const session = await service.createSession({ provider: 'copilot', config });
461
462
assert.deepStrictEqual(service.stateManager.getSessionState(session.toString())?.config?.values, config);
463
});
464
465
test('seeds activeClient into the initial session state when provided', async () => {
466
service.registerProvider(copilotAgent);
467
468
const envelopes: ActionEnvelope[] = [];
469
disposables.add(service.onDidAction(env => envelopes.push(env)));
470
471
const activeClient: SessionActiveClient = {
472
clientId: 'client-eager',
473
tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }],
474
customizations: [{ uri: 'file:///plugin-a', displayName: 'A' }],
475
};
476
const session = await service.createSession({ provider: 'copilot', activeClient });
477
478
assert.deepStrictEqual({
479
activeClient: service.stateManager.getSessionState(session.toString())?.activeClient,
480
dispatchedActiveClientChanged: envelopes.some(e => e.action.type === ActionType.SessionActiveClientChanged),
481
}, {
482
activeClient,
483
dispatchedActiveClientChanged: false,
484
});
485
});
486
487
test('omits activeClient from the initial session state when not provided', async () => {
488
service.registerProvider(copilotAgent);
489
490
const session = await service.createSession({ provider: 'copilot' });
491
492
assert.strictEqual(service.stateManager.getSessionState(session.toString())?.activeClient, undefined);
493
});
494
});
495
496
// ---- authenticate ---------------------------------------------------
497
498
suite('authenticate', () => {
499
500
test('routes token to provider matching the resource', async () => {
501
service.registerProvider(copilotAgent);
502
503
const result = await service.authenticate({ resource: 'https://api.github.com', token: 'ghp_test123' });
504
505
assert.deepStrictEqual(result, { authenticated: true });
506
assert.deepStrictEqual(copilotAgent.authenticateCalls, [{ resource: 'https://api.github.com', token: 'ghp_test123' }]);
507
});
508
509
test('returns not authenticated for unknown resource', async () => {
510
service.registerProvider(copilotAgent);
511
512
const result = await service.authenticate({ resource: 'https://unknown.example.com', token: 'tok' });
513
514
assert.deepStrictEqual(result, { authenticated: false });
515
assert.strictEqual(copilotAgent.authenticateCalls.length, 0);
516
});
517
});
518
519
// ---- shutdown -------------------------------------------------------
520
521
suite('shutdown', () => {
522
523
test('shuts down all providers', async () => {
524
let copilotShutdown = false;
525
copilotAgent.shutdown = async () => { copilotShutdown = true; };
526
527
service.registerProvider(copilotAgent);
528
529
await service.shutdown();
530
assert.ok(copilotShutdown);
531
});
532
});
533
534
// ---- restoreSession -------------------------------------------------
535
536
suite('restoreSession', () => {
537
538
test('restores a session with message history', async () => {
539
service.registerProvider(copilotAgent);
540
const { session } = await copilotAgent.createSession();
541
const sessions = await copilotAgent.listSessions();
542
const sessionResource = sessions[0].session;
543
544
copilotAgent.sessionMessages = [
545
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
546
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi there!', toolRequests: [] },
547
];
548
549
await service.restoreSession(sessionResource);
550
551
const state = service.stateManager.getSessionState(sessionResource.toString());
552
assert.ok(state, 'session should be in state manager');
553
assert.strictEqual(state!.lifecycle, SessionLifecycle.Ready);
554
assert.strictEqual(state!.turns.length, 1);
555
assert.strictEqual(state!.turns[0].userMessage.text, 'Hello');
556
const mdPart = state!.turns[0].responseParts.find((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown);
557
assert.ok(mdPart);
558
assert.strictEqual(mdPart.content, 'Hi there!');
559
assert.strictEqual(state!.turns[0].state, TurnState.Complete);
560
});
561
562
test('restores a session with tool calls', async () => {
563
service.registerProvider(copilotAgent);
564
const { session } = await copilotAgent.createSession();
565
const sessions = await copilotAgent.listSessions();
566
const sessionResource = sessions[0].session;
567
568
copilotAgent.sessionMessages = [
569
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Run a command', toolRequests: [] },
570
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'I will run a command.', toolRequests: [{ toolCallId: 'tc-1', name: 'shell' }] },
571
{ type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'shell', displayName: 'Shell', invocationMessage: 'Running command...' },
572
{ type: 'tool_complete', session, toolCallId: 'tc-1', result: { success: true, pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'output' }] } },
573
{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Done!', toolRequests: [] },
574
];
575
576
await service.restoreSession(sessionResource);
577
578
const state = service.stateManager.getSessionState(sessionResource.toString());
579
assert.ok(state);
580
const turn = state!.turns[0];
581
const toolCallParts = turn.responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);
582
assert.strictEqual(toolCallParts.length, 1);
583
const tc = toolCallParts[0].toolCall as ToolCallCompletedState;
584
assert.strictEqual(tc.status, ToolCallStatus.Completed);
585
assert.strictEqual(tc.toolCallId, 'tc-1');
586
assert.strictEqual(tc.confirmed, ToolCallConfirmationReason.NotNeeded);
587
});
588
589
test('interleaves reasoning, markdown, and tool calls in stream order on resume', async () => {
590
service.registerProvider(copilotAgent);
591
const { session } = await copilotAgent.createSession();
592
const sessions = await copilotAgent.listSessions();
593
const sessionResource = sessions[0].session;
594
595
copilotAgent.sessionMessages = [
596
{ type: 'message', session, role: 'user', messageId: 'u-1', content: 'Hello', toolRequests: [] },
597
{ type: 'message', session, role: 'assistant', messageId: 'a-1', content: 'Reply A', reasoningText: 'Thinking A', toolRequests: [{ toolCallId: 'tc-1', name: 'shell' }] },
598
{ type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'shell', displayName: 'Shell', invocationMessage: 'Running...' },
599
{ type: 'tool_complete', session, toolCallId: 'tc-1', result: { success: true, pastTenseMessage: 'Ran', content: [{ type: ToolResultContentType.Text, text: 'ok' }] } },
600
{ type: 'message', session, role: 'assistant', messageId: 'a-2', content: 'Reply B', reasoningText: 'Thinking B', toolRequests: [] },
601
];
602
603
await service.restoreSession(sessionResource);
604
605
const state = service.stateManager.getSessionState(sessionResource.toString());
606
assert.ok(state);
607
const turn = state!.turns[0];
608
const summary = turn.responseParts.map(p => {
609
if (p.kind === ResponsePartKind.Reasoning) { return ['reasoning', p.content]; }
610
if (p.kind === ResponsePartKind.Markdown) { return ['markdown', p.content]; }
611
if (p.kind === ResponsePartKind.ToolCall) { return ['toolCall', p.toolCall.toolCallId]; }
612
return ['other'];
613
});
614
assert.deepStrictEqual(summary, [
615
['reasoning', 'Thinking A'],
616
['markdown', 'Reply A'],
617
['toolCall', 'tc-1'],
618
['reasoning', 'Thinking B'],
619
['markdown', 'Reply B'],
620
]);
621
});
622
623
test('flushes interrupted turns', async () => {
624
service.registerProvider(copilotAgent);
625
const { session } = await copilotAgent.createSession();
626
const sessions = await copilotAgent.listSessions();
627
const sessionResource = sessions[0].session;
628
629
copilotAgent.sessionMessages = [
630
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Interrupted', toolRequests: [] },
631
{ type: 'message', session, role: 'user', messageId: 'msg-2', content: 'Retried', toolRequests: [] },
632
{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Answer', toolRequests: [] },
633
];
634
635
await service.restoreSession(sessionResource);
636
637
const state = service.stateManager.getSessionState(sessionResource.toString());
638
assert.ok(state);
639
assert.strictEqual(state!.turns.length, 2);
640
assert.strictEqual(state!.turns[0].state, TurnState.Cancelled);
641
assert.strictEqual(state!.turns[1].state, TurnState.Complete);
642
});
643
644
test('throws when session is not found on backend', async () => {
645
service.registerProvider(copilotAgent);
646
await assert.rejects(
647
() => service.restoreSession(AgentSession.uri('copilot', 'nonexistent')),
648
/Session not found on backend/,
649
);
650
});
651
652
test('restores a session with subagent tool calls', async () => {
653
service.registerProvider(copilotAgent);
654
const { session } = await copilotAgent.createSession();
655
const sessions = await copilotAgent.listSessions();
656
const sessionResource = sessions[0].session;
657
658
copilotAgent.sessionMessages = [
659
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Review this code', toolRequests: [] },
660
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: '', toolRequests: [{ toolCallId: 'tc-sub', name: 'task' }] },
661
{ type: 'tool_start', session, toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...', toolKind: 'subagent' as const, subagentDescription: 'Find related files', subagentAgentName: 'explore' },
662
{ type: 'subagent_started', session, toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores the codebase' },
663
// Inner tool calls from the subagent (have parentToolCallId)
664
{ type: 'tool_start', session, toolCallId: 'tc-inner-1', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running ls...', parentToolCallId: 'tc-sub' },
665
{ type: 'tool_complete', session, toolCallId: 'tc-inner-1', result: { success: true, pastTenseMessage: 'Ran ls', content: [{ type: ToolResultContentType.Text, text: 'file1.ts' }] }, parentToolCallId: 'tc-sub' },
666
{ type: 'tool_start', session, toolCallId: 'tc-inner-2', toolName: 'view', displayName: 'View File', invocationMessage: 'Reading file1.ts', parentToolCallId: 'tc-sub' },
667
{ type: 'tool_complete', session, toolCallId: 'tc-inner-2', result: { success: true, pastTenseMessage: 'Read file1.ts' }, parentToolCallId: 'tc-sub' },
668
// Parent tool completes
669
{ type: 'tool_complete', session, toolCallId: 'tc-sub', result: { success: true, pastTenseMessage: 'Delegated task', content: [{ type: ToolResultContentType.Text, text: 'Found 3 issues' }] } },
670
{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'The review found 3 issues.', toolRequests: [] },
671
];
672
673
await service.restoreSession(sessionResource);
674
675
const state = service.stateManager.getSessionState(sessionResource.toString());
676
assert.ok(state);
677
678
// Should produce exactly one turn
679
assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}`);
680
681
const turn = state!.turns[0];
682
assert.strictEqual(turn.userMessage.text, 'Review this code');
683
684
// The parent turn should only have the parent tool call — inner
685
// tool calls are excluded from the parent and belong to the
686
// child subagent session instead.
687
const toolCallParts = turn.responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);
688
assert.strictEqual(toolCallParts.length, 1, `Expected 1 tool call (parent only) but got ${toolCallParts.length}`);
689
690
// Parent subagent tool call
691
const parentTc = toolCallParts[0].toolCall as ToolCallCompletedState;
692
assert.strictEqual(parentTc.toolCallId, 'tc-sub');
693
assert.strictEqual(parentTc.status, ToolCallStatus.Completed);
694
assert.strictEqual(parentTc._meta?.toolKind, 'subagent');
695
assert.strictEqual(parentTc._meta?.subagentDescription, 'Find related files');
696
assert.strictEqual(parentTc._meta?.subagentAgentName, 'explore');
697
698
// Parent tool should have subagent content entry
699
const content = parentTc.content ?? [];
700
const subagentEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent);
701
assert.ok(subagentEntry, 'Completed tool call should have subagent content entry');
702
703
// Subscribing to the child session should restore it with inner tool calls
704
const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), 'tc-sub');
705
const snapshot = await service.subscribe(URI.parse(childSessionUri));
706
const childState = service.stateManager.getSessionState(childSessionUri);
707
assert.ok(snapshot?.state, 'Child session snapshot should exist');
708
assert.ok(childState, 'Child session state should exist');
709
assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn');
710
const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);
711
assert.strictEqual(childToolParts.length, 2, `Child session should have 2 inner tool calls but got ${childToolParts.length}`);
712
assert.ok(childToolParts.some(p => p.toolCall.toolCallId === 'tc-inner-1'), 'Should have tc-inner-1');
713
assert.ok(childToolParts.some(p => p.toolCall.toolCallId === 'tc-inner-2'), 'Should have tc-inner-2');
714
715
// The turn should also have the final markdown
716
const mdParts = turn.responseParts.filter((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown);
717
assert.ok(mdParts.some(p => p.content.includes('3 issues')), 'Should have the final markdown response');
718
});
719
720
test('inner assistant messages from subagent do not create extra turns (fixture)', async () => {
721
service.registerProvider(copilotAgent);
722
const { session } = await copilotAgent.createSession();
723
const sessions = await copilotAgent.listSessions();
724
const sessionResource = sessions[0].session;
725
726
// Load real SDK events from fixture (sanitized from ~/.copilot/session-state/)
727
copilotAgent.sessionMessages = await loadFixtureMessages('subagent-session.jsonl', session);
728
729
await service.restoreSession(sessionResource);
730
731
const state = service.stateManager.getSessionState(sessionResource.toString());
732
assert.ok(state);
733
assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}: ${state!.turns.map(t => `"${t.userMessage.text.substring(0, 40)}"`).join(', ')}`);
734
assert.strictEqual(state!.turns[0].userMessage.text, 'Run a sync subagent to do some searches, just testing subagent rendering');
735
assert.strictEqual(state!.turns[0].state, TurnState.Complete);
736
737
// Should have the parent subagent tool call with subagent content
738
const toolCallParts = state!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);
739
const parentTc = toolCallParts.find(p => p.toolCall.toolName === 'task');
740
assert.ok(parentTc, 'Should have a task tool call');
741
assert.strictEqual(parentTc!.toolCall._meta?.toolKind, 'subagent');
742
743
// Inner tool calls should NOT be in the parent turn — they belong
744
// to the child subagent session.
745
const parentToolCallId = parentTc!.toolCall.toolCallId;
746
const nonParentTools = toolCallParts.filter(p => p.toolCall.toolCallId !== parentToolCallId);
747
assert.strictEqual(nonParentTools.length, 0, `Parent turn should only contain the task tool call, but found ${nonParentTools.length} extra tool calls`);
748
749
// Subscribe to the child subagent session and verify inner tools
750
const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), parentToolCallId);
751
const snapshot = await service.subscribe(URI.parse(childSessionUri));
752
assert.ok(snapshot?.state, 'Child session snapshot should exist');
753
const childState = service.stateManager.getSessionState(childSessionUri);
754
assert.ok(childState, 'Child session state should exist');
755
assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn');
756
const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);
757
assert.ok(childToolParts.length > 0, `Child session should have inner tool calls but got ${childToolParts.length}`);
758
759
// Should have the final markdown
760
const mdParts = state!.turns[0].responseParts.filter((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown);
761
assert.ok(mdParts.length > 0, 'Should have markdown content');
762
});
763
});
764
765
// ---- session config persistence -------------------------------------
766
767
suite('session config persistence', () => {
768
769
test('createSession persists initial config values to the session DB', async () => {
770
const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));
771
const sessionDataService = createSessionDataService(sessionDb);
772
const localAgent = new MockAgent('copilot');
773
disposables.add(toDisposable(() => localAgent.dispose()));
774
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
775
localService.registerProvider(localAgent);
776
777
await localService.createSession({ provider: 'copilot', config: { autoApprove: 'autoApprove' } });
778
779
// Persistence is fire-and-forget; wait for it to flush
780
await new Promise(r => setTimeout(r, 50));
781
782
const persisted = await sessionDb.getMetadata('configValues');
783
assert.ok(persisted, 'configValues should be persisted');
784
assert.deepStrictEqual(JSON.parse(persisted!), { autoApprove: 'autoApprove' });
785
});
786
787
test('createSession does not write configValues when there are no values', async () => {
788
const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));
789
const sessionDataService = createSessionDataService(sessionDb);
790
const localAgent = new MockAgent('copilot');
791
disposables.add(toDisposable(() => localAgent.dispose()));
792
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
793
localService.registerProvider(localAgent);
794
795
await localService.createSession({ provider: 'copilot' });
796
797
await new Promise(r => setTimeout(r, 50));
798
799
const persisted = await sessionDb.getMetadata('configValues');
800
assert.strictEqual(persisted, undefined);
801
});
802
803
test('restoreSession overlays persisted config values onto the resolved config', async () => {
804
const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));
805
const sessionDataService = createSessionDataService(sessionDb);
806
const localAgent = new MockAgent('copilot');
807
disposables.add(toDisposable(() => localAgent.dispose()));
808
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
809
localService.registerProvider(localAgent);
810
811
// Create a session on the agent backend (no config) so listSessions can find it
812
const { session } = await localAgent.createSession();
813
const sessions = await localAgent.listSessions();
814
const sessionResource = sessions[0].session;
815
816
// Pre-seed persisted config values
817
await sessionDb.setMetadata('configValues', JSON.stringify({ autoApprove: 'autoApprove' }));
818
819
localAgent.sessionMessages = [
820
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
821
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
822
];
823
824
await localService.restoreSession(sessionResource);
825
826
const state = localService.stateManager.getSessionState(sessionResource.toString());
827
assert.ok(state);
828
// MockAgent.resolveSessionConfig echoes params.config back as values, so the
829
// persisted values are forwarded through and end up on state.config.values.
830
assert.deepStrictEqual(state!.config?.values, { autoApprove: 'autoApprove' });
831
});
832
833
test('createSession + restoreSession round-trip restores initial config without any mid-session changes', async () => {
834
// Regression test: when a session is created with initial config but no
835
// mid-session SessionConfigChanged actions are dispatched, restoring it
836
// must still rehydrate the initial values.
837
const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));
838
const sessionDataService = createSessionDataService(sessionDb);
839
const localAgent = new MockAgent('copilot');
840
disposables.add(toDisposable(() => localAgent.dispose()));
841
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
842
localService.registerProvider(localAgent);
843
844
const session = await localService.createSession({ provider: 'copilot', config: { autoApprove: 'autoApprove' } });
845
846
// Wait for the fire-and-forget persistence to flush
847
await new Promise(r => setTimeout(r, 50));
848
849
// Simulate a server restart: drop the in-memory state
850
localService.stateManager.removeSession(session.toString());
851
852
localAgent.sessionMessages = [
853
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
854
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
855
];
856
await localService.restoreSession(session);
857
858
const state = localService.stateManager.getSessionState(session.toString());
859
assert.ok(state);
860
assert.deepStrictEqual(state!.config?.values, { autoApprove: 'autoApprove' });
861
});
862
863
test('restoreSession ignores malformed persisted configValues', async () => {
864
const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));
865
const sessionDataService = createSessionDataService(sessionDb);
866
const localAgent = new MockAgent('copilot');
867
disposables.add(toDisposable(() => localAgent.dispose()));
868
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
869
localService.registerProvider(localAgent);
870
871
const { session } = await localAgent.createSession();
872
const sessions = await localAgent.listSessions();
873
const sessionResource = sessions[0].session;
874
875
await sessionDb.setMetadata('configValues', '{not json');
876
877
localAgent.sessionMessages = [
878
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
879
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
880
];
881
882
// Should not throw despite the malformed JSON
883
await localService.restoreSession(sessionResource);
884
885
const state = localService.stateManager.getSessionState(sessionResource.toString());
886
assert.ok(state);
887
// MockAgent has a workingDirectory? No — but the metadata supplies it as undefined.
888
// _resolveCreatedSessionConfig bails when both .config and .workingDirectory are
889
// missing, so state.config is undefined here. The key point is: no throw.
890
assert.strictEqual(state!.config, undefined);
891
});
892
});
893
894
// ---- resourceList ------------------------------------------------
895
896
suite('resourceList', () => {
897
898
test('throws when the directory does not exist', async () => {
899
await assert.rejects(
900
() => service.resourceList(URI.from({ scheme: Schemas.inMemory, path: '/nonexistent' })),
901
/Directory not found/,
902
);
903
});
904
905
test('throws when the target is not a directory', async () => {
906
await assert.rejects(
907
() => service.resourceList(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' })),
908
/Not a directory/,
909
);
910
});
911
});
912
913
// ---- worktree working directory -------------------------------------
914
915
suite('worktree working directory', () => {
916
917
test('createSession uses agent-resolved working directory in state', async () => {
918
// Simulate an agent that resolves a worktree path different from the input
919
const worktreeDir = URI.file('/source/repo.worktrees/agents-xyz');
920
copilotAgent.resolvedWorkingDirectory = worktreeDir;
921
service.registerProvider(copilotAgent);
922
923
const sourceDir = URI.file('/source/repo');
924
const session = await service.createSession({ provider: 'copilot', workingDirectory: sourceDir });
925
926
// The state manager should have the worktree path, not the source path
927
const state = service.stateManager.getSessionState(session.toString());
928
assert.strictEqual(state?.summary.workingDirectory, worktreeDir.toString());
929
});
930
931
test('createSession falls back to config working directory when agent does not resolve', async () => {
932
// Agent does not override the working directory (e.g. folder isolation)
933
copilotAgent.resolvedWorkingDirectory = undefined;
934
service.registerProvider(copilotAgent);
935
936
const sourceDir = URI.file('/source/repo');
937
const session = await service.createSession({ provider: 'copilot', workingDirectory: sourceDir });
938
939
const state = service.stateManager.getSessionState(session.toString());
940
assert.strictEqual(state?.summary.workingDirectory, sourceDir.toString());
941
});
942
943
test('restoreSession uses agent working directory in state', async () => {
944
// Agent returns the worktree path through listSessions
945
const worktreeDir = URI.file('/source/repo.worktrees/agents-xyz');
946
copilotAgent.sessionMetadataOverrides = { workingDirectory: worktreeDir };
947
service.registerProvider(copilotAgent);
948
949
const session = await service.createSession({ provider: 'copilot' });
950
951
// Delete from state to simulate a server restart
952
service.stateManager.deleteSession(session.toString());
953
assert.strictEqual(service.stateManager.getSessionState(session.toString()), undefined);
954
955
// Restore the session (simulates a client subscribing after restart)
956
await service.restoreSession(session);
957
958
const state = service.stateManager.getSessionState(session.toString());
959
assert.strictEqual(state?.summary.workingDirectory, worktreeDir.toString());
960
});
961
});
962
});
963
964