Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/node/agentSideEffects.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 { VSBuffer } from '../../../../base/common/buffer.js';
8
import { Event } from '../../../../base/common/event.js';
9
import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
10
import { Schemas } from '../../../../base/common/network.js';
11
import { observableValue } from '../../../../base/common/observable.js';
12
import { hasKey } from '../../../../base/common/types.js';
13
import { URI } from '../../../../base/common/uri.js';
14
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
15
import { FileService } from '../../../files/common/fileService.js';
16
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
17
import { InstantiationService } from '../../../instantiation/common/instantiationService.js';
18
import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js';
19
import { ILogService, NullLogService } from '../../../log/common/log.js';
20
import { AgentSession, IAgent } from '../../common/agentService.js';
21
import { ISessionDataService } from '../../common/sessionDataService.js';
22
import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js';
23
import { CustomizationStatus } from '../../common/state/protocol/state.js';
24
import { ActionType, ActionEnvelope, SessionAction } from '../../common/state/sessionActions.js';
25
import { AttachmentType, buildSubagentSessionUri, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType } from '../../common/state/sessionState.js';
26
import { IProductService } from '../../../product/common/productService.js';
27
import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js';
28
import { IAgentHostGitService } from '../../node/agentHostGitService.js';
29
import { AgentService } from '../../node/agentService.js';
30
import { AgentSideEffects, IAgentSideEffectsOptions } from '../../node/agentSideEffects.js';
31
import { SessionDatabase } from '../../node/sessionDatabase.js';
32
import { AgentHostStateManager } from '../../node/agentHostStateManager.js';
33
import { createNoopGitService, createNullSessionDataService, createSessionDataService } from '../common/sessionTestHelpers.js';
34
import { MockAgent } from './mockAgent.js';
35
36
// ---- Tests ------------------------------------------------------------------
37
38
/**
39
* Constructs an {@link AgentSideEffects} with a minimal local instantiation
40
* scope that satisfies its {@link IAgentConfigurationService} /
41
* {@link ILogService} / {@link IAgentHostGitService} dependencies.
42
*/
43
function createTestSideEffects(disposables: DisposableStore, stateManager: AgentHostStateManager, options: IAgentSideEffectsOptions, gitService?: IAgentHostGitService): AgentSideEffects {
44
const logService = new NullLogService();
45
const configService = disposables.add(new AgentConfigurationService(stateManager, logService));
46
const instantiationService = disposables.add(new InstantiationService(new ServiceCollection(
47
[ILogService, logService],
48
[IAgentConfigurationService, configService],
49
[IAgentHostGitService, gitService ?? createNoopGitService()],
50
), /*strict*/ true));
51
return disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, options));
52
}
53
54
suite('AgentSideEffects', () => {
55
56
const disposables = new DisposableStore();
57
let fileService: FileService;
58
let stateManager: AgentHostStateManager;
59
let agent: MockAgent;
60
let sideEffects: AgentSideEffects;
61
let agentList: ReturnType<typeof observableValue<readonly IAgent[]>>;
62
63
const sessionUri = AgentSession.uri('mock', 'session-1');
64
65
function setupSession(workingDirectory?: string): void {
66
stateManager.createSession({
67
resource: sessionUri.toString(),
68
provider: 'mock',
69
title: 'Test',
70
status: SessionStatus.Idle,
71
createdAt: Date.now(),
72
modifiedAt: Date.now(),
73
project: { uri: 'file:///test-project', displayName: 'Test Project' },
74
workingDirectory,
75
});
76
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() });
77
}
78
79
function startTurn(turnId: string): void {
80
stateManager.dispatchClientAction(
81
{ type: ActionType.SessionTurnStarted, session: sessionUri.toString(), turnId, userMessage: { text: 'hello' } },
82
{ clientId: 'test', clientSeq: 1 },
83
);
84
}
85
86
setup(async () => {
87
fileService = disposables.add(new FileService(new NullLogService()));
88
const memFs = disposables.add(new InMemoryFileSystemProvider());
89
disposables.add(fileService.registerProvider(Schemas.inMemory, memFs));
90
91
// Seed a file so the handleBrowseDirectory tests can distinguish files from dirs
92
const testDir = URI.from({ scheme: Schemas.inMemory, path: '/testDir' });
93
await fileService.createFolder(testDir);
94
await fileService.writeFile(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }), VSBuffer.fromString('hello'));
95
96
agent = new MockAgent();
97
disposables.add(toDisposable(() => agent.dispose()));
98
stateManager = disposables.add(new AgentHostStateManager(new NullLogService()));
99
agentList = observableValue<readonly IAgent[]>('agents', [agent]);
100
sideEffects = createTestSideEffects(disposables, stateManager, {
101
getAgent: () => agent,
102
agents: agentList,
103
sessionDataService: createNullSessionDataService(),
104
onTurnComplete: () => { },
105
});
106
});
107
108
teardown(() => {
109
disposables.clear();
110
});
111
ensureNoDisposablesAreLeakedInTestSuite();
112
113
// ---- handleAction: session/turnStarted ------------------------------
114
115
suite('handleAction — session/turnStarted', () => {
116
117
test('calls sendMessage on the agent', async () => {
118
setupSession();
119
const action: SessionAction = {
120
type: ActionType.SessionTurnStarted,
121
session: sessionUri.toString(),
122
turnId: 'turn-1',
123
userMessage: { text: 'hello world' },
124
};
125
sideEffects.handleAction(action);
126
127
// sendMessage is async but fire-and-forget; wait a tick
128
await new Promise(r => setTimeout(r, 10));
129
130
assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello world', attachments: undefined }]);
131
});
132
133
test('parses protocol attachment URI strings before passing them to the agent', () => {
134
setupSession();
135
const fileUri = URI.file('/workspace/test.ts');
136
const action: SessionAction = {
137
type: ActionType.SessionTurnStarted,
138
session: sessionUri.toString(),
139
turnId: 'turn-1',
140
userMessage: { text: 'hello world', attachments: [{ type: AttachmentType.File, uri: fileUri.toString(), displayName: 'test.ts' }] },
141
};
142
143
sideEffects.handleAction(action);
144
145
assert.deepStrictEqual(agent.sendMessageCalls, [{
146
session: URI.parse(sessionUri.toString()),
147
prompt: 'hello world',
148
attachments: [{ type: AttachmentType.File, uri: URI.parse(fileUri.toString()), displayName: 'test.ts' }],
149
}]);
150
});
151
152
test('dispatches session/error when no agent is found', async () => {
153
setupSession();
154
const emptyAgents = observableValue<readonly IAgent[]>('agents', []);
155
const noAgentSideEffects = createTestSideEffects(disposables, stateManager, {
156
getAgent: () => undefined,
157
agents: emptyAgents,
158
sessionDataService: {} as ISessionDataService,
159
onTurnComplete: () => { },
160
});
161
162
const envelopes: ActionEnvelope[] = [];
163
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
164
165
noAgentSideEffects.handleAction({
166
type: ActionType.SessionTurnStarted,
167
session: sessionUri.toString(),
168
turnId: 'turn-1',
169
userMessage: { text: 'hello' },
170
});
171
172
const errorAction = envelopes.find(e => e.action.type === ActionType.SessionError);
173
assert.ok(errorAction, 'should dispatch session/error');
174
});
175
});
176
177
// ---- immediate title on first turn -----------------------------------
178
179
suite('immediate title on first turn', () => {
180
181
function setupDefaultSession(): void {
182
stateManager.createSession({
183
resource: sessionUri.toString(),
184
provider: 'mock',
185
title: '',
186
status: SessionStatus.Idle,
187
createdAt: Date.now(),
188
modifiedAt: Date.now(),
189
project: { uri: 'file:///test-project', displayName: 'Test Project' },
190
});
191
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() });
192
}
193
194
test('dispatches titleChanged with user message on first turn', () => {
195
setupDefaultSession();
196
197
const envelopes: ActionEnvelope[] = [];
198
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
199
200
sideEffects.handleAction({
201
type: ActionType.SessionTurnStarted,
202
session: sessionUri.toString(),
203
turnId: 'turn-1',
204
userMessage: { text: 'Fix the login bug' },
205
});
206
207
const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);
208
assert.ok(titleAction, 'should dispatch session/titleChanged');
209
if (titleAction?.action.type === ActionType.SessionTitleChanged) {
210
assert.strictEqual(titleAction.action.title, 'Fix the login bug');
211
}
212
});
213
214
test('does not dispatch titleChanged when message is whitespace', () => {
215
setupDefaultSession();
216
217
const envelopes: ActionEnvelope[] = [];
218
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
219
220
sideEffects.handleAction({
221
type: ActionType.SessionTurnStarted,
222
session: sessionUri.toString(),
223
turnId: 'turn-1',
224
userMessage: { text: ' ' },
225
});
226
227
const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);
228
assert.strictEqual(titleAction, undefined, 'should not dispatch titleChanged for empty message');
229
});
230
231
test('normalizes whitespace and truncates long messages', () => {
232
setupDefaultSession();
233
234
const envelopes: ActionEnvelope[] = [];
235
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
236
237
const longMessage = 'Fix the bug\nin the login\tpage please ' + 'a'.repeat(250);
238
sideEffects.handleAction({
239
type: ActionType.SessionTurnStarted,
240
session: sessionUri.toString(),
241
turnId: 'turn-1',
242
userMessage: { text: longMessage },
243
});
244
245
const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);
246
assert.ok(titleAction, 'should dispatch session/titleChanged');
247
if (titleAction?.action.type === ActionType.SessionTitleChanged) {
248
assert.ok(!titleAction.action.title.includes('\n'), 'should not contain newlines');
249
assert.ok(!titleAction.action.title.includes('\t'), 'should not contain tabs');
250
assert.ok(!titleAction.action.title.includes(' '), 'should not contain double spaces');
251
assert.ok(titleAction.action.title.length <= 200, 'should be truncated to 200 chars');
252
}
253
});
254
255
test('does not dispatch titleChanged on second turn', () => {
256
setupDefaultSession();
257
startTurn('turn-1');
258
259
// Complete the first turn so turns.length becomes 1.
260
stateManager.dispatchServerAction({
261
type: ActionType.SessionTurnComplete,
262
session: sessionUri.toString(),
263
turnId: 'turn-1',
264
});
265
266
const envelopes: ActionEnvelope[] = [];
267
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
268
269
sideEffects.handleAction({
270
type: ActionType.SessionTurnStarted,
271
session: sessionUri.toString(),
272
turnId: 'turn-2',
273
userMessage: { text: 'second message' },
274
});
275
276
const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);
277
assert.strictEqual(titleAction, undefined, 'should not dispatch titleChanged on second turn');
278
});
279
280
test('does not dispatch titleChanged when title is already set', () => {
281
// Session has a non-empty title (e.g. user renamed before first message)
282
stateManager.createSession({
283
resource: sessionUri.toString(),
284
provider: 'mock',
285
title: 'User Renamed',
286
status: SessionStatus.Idle,
287
createdAt: Date.now(),
288
modifiedAt: Date.now(),
289
project: { uri: 'file:///test-project', displayName: 'Test Project' },
290
});
291
stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() });
292
293
const envelopes: ActionEnvelope[] = [];
294
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
295
296
sideEffects.handleAction({
297
type: ActionType.SessionTurnStarted,
298
session: sessionUri.toString(),
299
turnId: 'turn-1',
300
userMessage: { text: 'hello' },
301
});
302
303
const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);
304
assert.strictEqual(titleAction, undefined, 'should not clobber existing title');
305
});
306
});
307
308
suite('handleAction — session/turnCancelled', () => {
309
310
test('calls abortSession on the agent', async () => {
311
setupSession();
312
sideEffects.handleAction({
313
type: ActionType.SessionTurnCancelled,
314
session: sessionUri.toString(),
315
turnId: 'turn-1',
316
});
317
318
await new Promise(r => setTimeout(r, 10));
319
320
assert.deepStrictEqual(agent.abortSessionCalls, [URI.parse(sessionUri.toString())]);
321
});
322
});
323
324
// ---- handleAction: session/modelChanged -----------------------------
325
326
suite('handleAction — session/modelChanged', () => {
327
328
test('calls changeModel on the agent', async () => {
329
setupSession();
330
sideEffects.handleAction({
331
type: ActionType.SessionModelChanged,
332
session: sessionUri.toString(),
333
model: { id: 'gpt-5' },
334
});
335
336
await new Promise(r => setTimeout(r, 10));
337
338
assert.deepStrictEqual(agent.changeModelCalls, [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' } }]);
339
});
340
});
341
342
// ---- registerProgressListener ---------------------------------------
343
344
suite('registerProgressListener', () => {
345
346
test('maps agent progress events to state actions', () => {
347
setupSession();
348
startTurn('turn-1');
349
350
const envelopes: ActionEnvelope[] = [];
351
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
352
disposables.add(sideEffects.registerProgressListener(agent));
353
354
agent.fireProgress({
355
kind: 'action', session: sessionUri,
356
action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'hi' } },
357
});
358
359
// First delta creates a response part (not a delta action)
360
assert.ok(envelopes.some(e => e.action.type === ActionType.SessionResponsePart));
361
});
362
363
test('returns a disposable that stops listening', () => {
364
setupSession();
365
startTurn('turn-1');
366
367
const envelopes: ActionEnvelope[] = [];
368
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
369
const listener = sideEffects.registerProgressListener(agent);
370
371
agent.fireProgress({
372
kind: 'action', session: sessionUri,
373
action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'before' } },
374
});
375
assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionResponsePart).length, 1);
376
377
listener.dispose();
378
agent.fireProgress({
379
kind: 'action', session: sessionUri,
380
action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-2', content: 'after' } },
381
});
382
assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionResponsePart).length, 1);
383
});
384
});
385
386
// ---- agents observable --------------------------------------------------
387
388
suite('agents observable', () => {
389
390
test('dispatches root/agentsChanged without fetching models when observable changes', async () => {
391
agentList.set([], undefined);
392
const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => {
393
if (e.action.type !== ActionType.RootAgentsChanged) {
394
return false;
395
}
396
return e.action.agents.length === 1;
397
}));
398
agentList.set([agent], undefined);
399
const { action } = await envelope;
400
assert.strictEqual(action.type, ActionType.RootAgentsChanged);
401
402
assert.deepStrictEqual(action.agents[0].models, []);
403
});
404
405
test('model observable update publishes models', async () => {
406
const envelopes: ActionEnvelope[] = [];
407
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
408
409
const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => {
410
if (e.action.type !== ActionType.RootAgentsChanged) {
411
return false;
412
}
413
return e.action.agents[0]?.models.length === 1;
414
}));
415
agent.setModels([{ provider: 'mock', id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false }]);
416
await envelope;
417
418
const actions = envelopes.map(e => e.action).filter(action => action.type === ActionType.RootAgentsChanged);
419
const action = actions[actions.length - 1];
420
assert.ok(action, 'should dispatch root/agentsChanged');
421
assert.deepStrictEqual(action.agents[0].models, [{
422
id: 'mock-model',
423
provider: 'mock',
424
name: 'mock Model',
425
maxContextWindow: 128000,
426
supportsVision: false,
427
policyState: undefined,
428
configSchema: undefined,
429
}]);
430
});
431
432
test('unchanged model observable update does not dispatch unchanged agent infos', async () => {
433
const envelopes: ActionEnvelope[] = [];
434
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
435
const models = [{ provider: 'mock' as const, id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false }];
436
437
const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => {
438
if (e.action.type !== ActionType.RootAgentsChanged) {
439
return false;
440
}
441
return e.action.agents[0]?.models.length === 1;
442
}));
443
agent.setModels(models);
444
await envelope;
445
envelopes.length = 0;
446
agent.setModels([...models]);
447
await Promise.resolve();
448
await Promise.resolve();
449
450
assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.RootAgentsChanged).length, 0);
451
});
452
});
453
454
// ---- Pending message sync -----------------------------------------------
455
456
suite('pending message sync', () => {
457
458
test('syncs steering message to agent on SessionPendingMessageSet', () => {
459
setupSession();
460
461
const action = {
462
type: ActionType.SessionPendingMessageSet as const,
463
session: sessionUri.toString(),
464
kind: PendingMessageKind.Steering,
465
id: 'steer-1',
466
userMessage: { text: 'focus on tests' },
467
};
468
stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 });
469
sideEffects.handleAction(action);
470
471
assert.strictEqual(agent.setPendingMessagesCalls.length, 1);
472
assert.deepStrictEqual(agent.setPendingMessagesCalls[0].steeringMessage, { id: 'steer-1', userMessage: { text: 'focus on tests' } });
473
assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []);
474
});
475
476
test('syncs queued message to agent on SessionPendingMessageSet', () => {
477
setupSession();
478
479
const action = {
480
type: ActionType.SessionPendingMessageSet as const,
481
session: sessionUri.toString(),
482
kind: PendingMessageKind.Queued,
483
id: 'q-1',
484
userMessage: { text: 'queued message' },
485
};
486
stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 });
487
sideEffects.handleAction(action);
488
489
// Queued messages are not forwarded to the agent; the server controls consumption
490
assert.strictEqual(agent.setPendingMessagesCalls.length, 1);
491
assert.strictEqual(agent.setPendingMessagesCalls[0].steeringMessage, undefined);
492
assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []);
493
494
// Session was idle, so the queued message is consumed immediately
495
assert.strictEqual(agent.sendMessageCalls.length, 1);
496
assert.strictEqual(agent.sendMessageCalls[0].prompt, 'queued message');
497
});
498
499
test('parses queued protocol attachment URI strings before passing them to the agent', () => {
500
setupSession();
501
const fileUri = URI.file('/workspace/queued.ts');
502
const action: SessionAction = {
503
type: ActionType.SessionPendingMessageSet as const,
504
session: sessionUri.toString(),
505
kind: PendingMessageKind.Queued,
506
id: 'q-uri',
507
userMessage: { text: 'queued message', attachments: [{ type: AttachmentType.File, uri: fileUri.toString(), displayName: 'queued.ts' }] },
508
};
509
510
stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 });
511
sideEffects.handleAction(action);
512
513
assert.deepStrictEqual(agent.sendMessageCalls, [{
514
session: URI.parse(sessionUri.toString()),
515
prompt: 'queued message',
516
attachments: [{ type: AttachmentType.File, uri: URI.parse(fileUri.toString()), displayName: 'queued.ts' }],
517
}]);
518
});
519
520
test('syncs on SessionPendingMessageRemoved', () => {
521
setupSession();
522
523
// Add a queued message
524
const setAction = {
525
type: ActionType.SessionPendingMessageSet as const,
526
session: sessionUri.toString(),
527
kind: PendingMessageKind.Queued,
528
id: 'q-rm',
529
userMessage: { text: 'will be removed' },
530
};
531
stateManager.dispatchClientAction(setAction, { clientId: 'test', clientSeq: 1 });
532
sideEffects.handleAction(setAction);
533
534
agent.setPendingMessagesCalls.length = 0;
535
536
// Remove
537
const removeAction = {
538
type: ActionType.SessionPendingMessageRemoved as const,
539
session: sessionUri.toString(),
540
kind: PendingMessageKind.Queued,
541
id: 'q-rm',
542
};
543
stateManager.dispatchClientAction(removeAction, { clientId: 'test', clientSeq: 2 });
544
sideEffects.handleAction(removeAction);
545
546
assert.strictEqual(agent.setPendingMessagesCalls.length, 1);
547
assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []);
548
});
549
550
test('syncs on SessionQueuedMessagesReordered', () => {
551
setupSession();
552
553
// Add two queued messages
554
const setA = { type: ActionType.SessionPendingMessageSet as const, session: sessionUri.toString(), kind: PendingMessageKind.Queued, id: 'q-a', userMessage: { text: 'A' } };
555
stateManager.dispatchClientAction(setA, { clientId: 'test', clientSeq: 1 });
556
sideEffects.handleAction(setA);
557
558
const setB = { type: ActionType.SessionPendingMessageSet as const, session: sessionUri.toString(), kind: PendingMessageKind.Queued, id: 'q-b', userMessage: { text: 'B' } };
559
stateManager.dispatchClientAction(setB, { clientId: 'test', clientSeq: 2 });
560
sideEffects.handleAction(setB);
561
562
agent.setPendingMessagesCalls.length = 0;
563
564
// Reorder
565
const reorderAction = { type: ActionType.SessionQueuedMessagesReordered as const, session: sessionUri.toString(), order: ['q-b', 'q-a'] };
566
stateManager.dispatchClientAction(reorderAction, { clientId: 'test', clientSeq: 3 });
567
sideEffects.handleAction(reorderAction);
568
569
assert.strictEqual(agent.setPendingMessagesCalls.length, 1);
570
assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []);
571
});
572
});
573
574
// ---- Queued message consumption -----------------------------------------
575
576
suite('queued message consumption', () => {
577
578
test('auto-starts turn from queued message on idle', () => {
579
setupSession();
580
disposables.add(sideEffects.registerProgressListener(agent));
581
582
// Queue a message while a turn is active
583
startTurn('turn-1');
584
const setAction = {
585
type: ActionType.SessionPendingMessageSet as const,
586
session: sessionUri.toString(),
587
kind: PendingMessageKind.Queued,
588
id: 'q-auto',
589
userMessage: { text: 'auto queued' },
590
};
591
stateManager.dispatchClientAction(setAction, { clientId: 'test', clientSeq: 1 });
592
sideEffects.handleAction(setAction);
593
594
// Message should NOT be consumed yet (turn is active)
595
assert.strictEqual(agent.sendMessageCalls.length, 0);
596
597
const envelopes: ActionEnvelope[] = [];
598
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
599
600
// Fire idle → turn completes → queued message should be consumed
601
agent.fireProgress({
602
kind: 'action', session: sessionUri,
603
action: { type: ActionType.SessionTurnComplete, session: sessionUri.toString(), turnId: 'turn-1' },
604
});
605
606
const turnComplete = envelopes.find(e => e.action.type === ActionType.SessionTurnComplete);
607
assert.ok(turnComplete, 'should dispatch session/turnComplete');
608
609
const turnStarted = envelopes.find(e => e.action.type === ActionType.SessionTurnStarted);
610
assert.ok(turnStarted, 'should dispatch session/turnStarted for queued message');
611
assert.strictEqual((turnStarted!.action as { queuedMessageId?: string }).queuedMessageId, 'q-auto');
612
613
assert.strictEqual(agent.sendMessageCalls.length, 1);
614
assert.strictEqual(agent.sendMessageCalls[0].prompt, 'auto queued');
615
616
// Queued message should be removed from state
617
const state = stateManager.getSessionState(sessionUri.toString());
618
assert.strictEqual(state?.queuedMessages, undefined);
619
});
620
621
test('does not consume queued message while a turn is active', () => {
622
setupSession();
623
startTurn('turn-1');
624
625
const envelopes: ActionEnvelope[] = [];
626
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
627
628
const setAction = {
629
type: ActionType.SessionPendingMessageSet as const,
630
session: sessionUri.toString(),
631
kind: PendingMessageKind.Queued,
632
id: 'q-wait',
633
userMessage: { text: 'should wait' },
634
};
635
stateManager.dispatchClientAction(setAction, { clientId: 'test', clientSeq: 1 });
636
sideEffects.handleAction(setAction);
637
638
// No turn started for the queued message
639
const turnStarted = envelopes.find(e => e.action.type === ActionType.SessionTurnStarted);
640
assert.strictEqual(turnStarted, undefined, 'should not start a turn while one is active');
641
assert.strictEqual(agent.sendMessageCalls.length, 0);
642
643
// Queued message still in state
644
const state = stateManager.getSessionState(sessionUri.toString());
645
assert.strictEqual(state?.queuedMessages?.length, 1);
646
assert.strictEqual(state?.queuedMessages?.[0].id, 'q-wait');
647
});
648
649
test('dispatches SessionPendingMessageRemoved for steering messages on steering_consumed', () => {
650
setupSession();
651
disposables.add(sideEffects.registerProgressListener(agent));
652
653
const envelopes: ActionEnvelope[] = [];
654
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
655
656
const action = {
657
type: ActionType.SessionPendingMessageSet as const,
658
session: sessionUri.toString(),
659
kind: PendingMessageKind.Steering,
660
id: 'steer-rm',
661
userMessage: { text: 'steer me' },
662
};
663
stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 });
664
sideEffects.handleAction(action);
665
666
// Removal is not dispatched synchronously; it waits for the agent
667
let removal = envelopes.find(e =>
668
e.action.type === ActionType.SessionPendingMessageRemoved &&
669
(e.action as { kind: PendingMessageKind }).kind === PendingMessageKind.Steering
670
);
671
assert.strictEqual(removal, undefined, 'should not dispatch removal until steering_consumed');
672
673
// Simulate the agent consuming the steering message
674
agent.fireProgress({
675
kind: 'steering_consumed',
676
session: sessionUri,
677
id: 'steer-rm',
678
});
679
680
removal = envelopes.find(e =>
681
e.action.type === ActionType.SessionPendingMessageRemoved &&
682
(e.action as { kind: PendingMessageKind }).kind === PendingMessageKind.Steering
683
);
684
assert.ok(removal, 'should dispatch SessionPendingMessageRemoved for steering');
685
assert.strictEqual((removal!.action as { id: string }).id, 'steer-rm');
686
687
// Steering message should be removed from state
688
const state = stateManager.getSessionState(sessionUri.toString());
689
assert.strictEqual(state?.steeringMessage, undefined);
690
});
691
});
692
693
// ---- handleAction: session/activeClientChanged ----------------------
694
695
suite('handleAction — session/activeClientChanged', () => {
696
697
test('calls setClientCustomizations and dispatches customizationsChanged', async () => {
698
setupSession();
699
agent.getSessionCustomizations = async () => [
700
{
701
customization: { uri: 'file:///plugin-a', displayName: 'Plugin A' },
702
enabled: true,
703
status: CustomizationStatus.Loaded,
704
},
705
{
706
customization: { uri: 'file:///plugin-b', displayName: 'Plugin B' },
707
enabled: true,
708
status: CustomizationStatus.Loaded,
709
},
710
];
711
712
const envelopes: ActionEnvelope[] = [];
713
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
714
715
const action: SessionAction = {
716
type: ActionType.SessionActiveClientChanged,
717
session: sessionUri.toString(),
718
activeClient: {
719
clientId: 'test-client',
720
tools: [],
721
customizations: [
722
{ uri: 'file:///plugin-a', displayName: 'Plugin A' },
723
{ uri: 'file:///plugin-b', displayName: 'Plugin B' },
724
],
725
},
726
};
727
sideEffects.handleAction(action);
728
729
// Wait for async setClientCustomizations
730
await new Promise(r => setTimeout(r, 50));
731
732
assert.deepStrictEqual(agent.setClientCustomizationsCalls, [{
733
clientId: 'test-client',
734
customizations: [
735
{ uri: 'file:///plugin-a', displayName: 'Plugin A' },
736
{ uri: 'file:///plugin-b', displayName: 'Plugin B' },
737
],
738
}]);
739
740
const customizationActions = envelopes
741
.filter(e => e.action.type === ActionType.SessionCustomizationsChanged);
742
assert.ok(customizationActions.length >= 1, 'should dispatch at least one customizationsChanged');
743
});
744
745
test('clears client customizations when activeClient has no customizations', () => {
746
setupSession();
747
748
const envelopes: ActionEnvelope[] = [];
749
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
750
751
const action: SessionAction = {
752
type: ActionType.SessionActiveClientChanged,
753
session: sessionUri.toString(),
754
activeClient: {
755
clientId: 'test-client',
756
tools: [],
757
},
758
};
759
sideEffects.handleAction(action);
760
761
assert.deepStrictEqual(agent.setClientCustomizationsCalls, [{
762
clientId: 'test-client',
763
customizations: [],
764
}]);
765
const customizationActions = envelopes
766
.filter(e => e.action.type === ActionType.SessionCustomizationsChanged);
767
assert.strictEqual(customizationActions.length, 0);
768
});
769
770
test('clears client customizations when activeClient is null', () => {
771
setupSession();
772
773
const action: SessionAction = {
774
type: ActionType.SessionActiveClientChanged,
775
session: sessionUri.toString(),
776
activeClient: null,
777
};
778
sideEffects.handleAction(action);
779
780
assert.deepStrictEqual(agent.setClientCustomizationsCalls, [{
781
clientId: '',
782
customizations: [],
783
}]);
784
});
785
});
786
787
// ---- handleAction: root/configChanged --------------------------------
788
789
suite('handleAction - root/configChanged', () => {
790
791
test('republishes agent and session customizations for existing sessions', async () => {
792
setupSession('file:///workspace');
793
const customization = { uri: 'file:///plugin-a', displayName: 'Plugin A' };
794
agent.customizations = [customization];
795
agent.getSessionCustomizations = async () => [{
796
customization,
797
enabled: true,
798
status: CustomizationStatus.Loaded,
799
}];
800
801
const envelopes: ActionEnvelope[] = [];
802
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
803
804
const action: RootConfigChangedAction = {
805
type: ActionType.RootConfigChanged,
806
config: { customizations: [customization] },
807
};
808
809
stateManager.dispatchServerAction(action);
810
sideEffects.handleAction(action);
811
await new Promise(resolve => setTimeout(resolve, 10));
812
813
const agentInfoAction = envelopes.filter(e => e.action.type === ActionType.RootAgentsChanged).at(-1);
814
assert.ok(agentInfoAction && hasKey(agentInfoAction.action, { agents: true }));
815
assert.deepStrictEqual(agentInfoAction.action.agents[0]?.customizations, [customization]);
816
817
const sessionCustomizationAction = envelopes.filter(e => e.action.type === ActionType.SessionCustomizationsChanged).at(-1);
818
assert.ok(sessionCustomizationAction && hasKey(sessionCustomizationAction.action, { customizations: true }));
819
assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [{
820
customization,
821
enabled: true,
822
status: CustomizationStatus.Loaded,
823
}]);
824
});
825
});
826
827
// ---- onDidCustomizationsChange integration --------------------------
828
829
suite('onDidCustomizationsChange', () => {
830
831
test('republishes agent info and session customizations when agent fires onDidCustomizationsChange', async () => {
832
disposables.add(sideEffects.registerProgressListener(agent));
833
setupSession('file:///workspace');
834
835
const customization = { uri: 'file:///plugin-b', displayName: 'Plugin B' };
836
agent.customizations = [customization];
837
agent.getSessionCustomizations = async () => [{
838
customization,
839
enabled: true,
840
status: CustomizationStatus.Loaded,
841
}];
842
843
const envelopes: ActionEnvelope[] = [];
844
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
845
846
agent.fireCustomizationsChange();
847
await new Promise(resolve => setTimeout(resolve, 10));
848
849
const agentInfoAction = envelopes.find(e => e.action.type === ActionType.RootAgentsChanged);
850
assert.ok(agentInfoAction && hasKey(agentInfoAction.action, { agents: true }));
851
assert.deepStrictEqual(agentInfoAction.action.agents[0]?.customizations, [customization]);
852
853
const sessionCustomizationAction = envelopes.find(e => e.action.type === ActionType.SessionCustomizationsChanged);
854
assert.ok(sessionCustomizationAction && hasKey(sessionCustomizationAction.action, { customizations: true }));
855
assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [{
856
customization,
857
enabled: true,
858
status: CustomizationStatus.Loaded,
859
}]);
860
});
861
862
test('does not republish when registerProgressListener is disposed', async () => {
863
const listener = sideEffects.registerProgressListener(agent);
864
setupSession('file:///workspace');
865
866
agent.customizations = [{ uri: 'file:///plugin-c', displayName: 'Plugin C' }];
867
868
const envelopes: ActionEnvelope[] = [];
869
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
870
871
listener.dispose();
872
agent.fireCustomizationsChange();
873
await new Promise(resolve => setTimeout(resolve, 10));
874
875
assert.strictEqual(
876
envelopes.filter(e => e.action.type === ActionType.SessionCustomizationsChanged).length,
877
0,
878
'should not republish session customizations after listener disposed',
879
);
880
});
881
});
882
883
// ---- handleAction: session/customizationToggled ---------------------
884
885
suite('handleAction — session/customizationToggled', () => {
886
887
test('calls setCustomizationEnabled on the agent', () => {
888
setupSession();
889
890
const action: SessionAction = {
891
type: ActionType.SessionCustomizationToggled,
892
session: sessionUri.toString(),
893
uri: 'file:///plugin-a',
894
enabled: false,
895
};
896
sideEffects.handleAction(action);
897
898
assert.deepStrictEqual(agent.setCustomizationEnabledCalls, [
899
{ uri: 'file:///plugin-a', enabled: false },
900
]);
901
});
902
});
903
904
// ---- handleAction: session/toolCallConfirmed ------------------------
905
906
suite('handleAction — session/toolCallConfirmed', () => {
907
908
test('routes confirmation to correct agent via _toolCallAgents', () => {
909
setupSession();
910
startTurn('turn-1');
911
disposables.add(sideEffects.registerProgressListener(agent));
912
913
// Fire tool_start to register the tool call
914
agent.fireProgress({
915
kind: 'action', session: sessionUri,
916
action: {
917
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
918
toolCallId: 'tc-conf-1', toolName: 'read', displayName: 'Read File', toolClientId: undefined,
919
_meta: { toolKind: undefined, language: undefined },
920
},
921
});
922
agent.fireProgress({
923
kind: 'action', session: sessionUri,
924
action: {
925
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
926
toolCallId: 'tc-conf-1', invocationMessage: 'Reading file', toolInput: undefined,
927
confirmed: ToolCallConfirmationReason.NotNeeded,
928
},
929
});
930
931
// Fire tool_ready asking for permission (non-write, so not auto-approved)
932
agent.fireProgress({
933
kind: 'pending_confirmation', session: sessionUri,
934
state: {
935
status: ToolCallStatus.PendingConfirmation,
936
toolCallId: 'tc-conf-1', toolName: '', displayName: '',
937
invocationMessage: 'Read file.txt', toolInput: undefined,
938
confirmationTitle: 'Read file.txt', edits: undefined,
939
},
940
permissionKind: undefined, permissionPath: undefined,
941
});
942
943
// Now confirm the tool call
944
sideEffects.handleAction({
945
type: ActionType.SessionToolCallConfirmed,
946
session: sessionUri.toString(),
947
turnId: 'turn-1',
948
toolCallId: 'tc-conf-1',
949
approved: true,
950
confirmed: 'user-action' as const,
951
} as SessionAction);
952
953
assert.deepStrictEqual(agent.respondToPermissionCalls, [
954
{ requestId: 'tc-conf-1', approved: true },
955
]);
956
});
957
958
test('handles denial of tool call', () => {
959
setupSession();
960
startTurn('turn-1');
961
disposables.add(sideEffects.registerProgressListener(agent));
962
963
agent.fireProgress({
964
kind: 'action', session: sessionUri,
965
action: {
966
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
967
toolCallId: 'tc-deny-1', toolName: 'shell', displayName: 'Shell', toolClientId: undefined,
968
_meta: { toolKind: undefined, language: undefined },
969
},
970
});
971
agent.fireProgress({
972
kind: 'action', session: sessionUri,
973
action: {
974
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
975
toolCallId: 'tc-deny-1', invocationMessage: 'Running command', toolInput: undefined,
976
confirmed: ToolCallConfirmationReason.NotNeeded,
977
},
978
});
979
980
sideEffects.handleAction({
981
type: ActionType.SessionToolCallConfirmed,
982
session: sessionUri.toString(),
983
turnId: 'turn-1',
984
toolCallId: 'tc-deny-1',
985
approved: false,
986
reason: 'denied' as const,
987
} as SessionAction);
988
989
assert.deepStrictEqual(agent.respondToPermissionCalls, [
990
{ requestId: 'tc-deny-1', approved: false },
991
]);
992
});
993
});
994
995
// ---- tool_ready progress dispatch -----------------------------------
996
997
suite('tool_ready dispatches progress actions to advance tool call state', () => {
998
999
test('tool_ready for a non-permission tool dispatches SessionToolCallReady and advances state from Streaming to Running', () => {
1000
setupSession();
1001
startTurn('turn-1');
1002
disposables.add(sideEffects.registerProgressListener(agent));
1003
1004
// tool_start puts the tool call into Streaming state
1005
agent.fireProgress({
1006
kind: 'action', session: sessionUri,
1007
action: {
1008
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1009
toolCallId: 'tc-ready-1', toolName: 'runTask', displayName: 'Run Task', toolClientId: 'test-client',
1010
_meta: { toolKind: undefined, language: undefined },
1011
},
1012
});
1013
1014
const stateAfterStart = stateManager.getSessionState(sessionUri.toString());
1015
const partAfterStart = stateAfterStart?.activeTurn?.responseParts[0];
1016
assert.strictEqual(partAfterStart?.kind, ResponsePartKind.ToolCall);
1017
assert.strictEqual(partAfterStart?.kind === ResponsePartKind.ToolCall ? partAfterStart.toolCall.status : undefined, ToolCallStatus.Streaming);
1018
1019
// tool_ready without confirmationTitle should dispatch the ready
1020
// action and advance the tool call to Running
1021
agent.fireProgress({
1022
kind: 'pending_confirmation', session: sessionUri,
1023
state: {
1024
status: ToolCallStatus.PendingConfirmation,
1025
toolCallId: 'tc-ready-1', toolName: '', displayName: '',
1026
invocationMessage: 'Run Task', toolInput: '{"task":"build"}',
1027
confirmationTitle: undefined, edits: undefined,
1028
},
1029
permissionKind: undefined, permissionPath: undefined,
1030
});
1031
1032
const stateAfterReady = stateManager.getSessionState(sessionUri.toString());
1033
const partAfterReady = stateAfterReady?.activeTurn?.responseParts[0];
1034
assert.strictEqual(partAfterReady?.kind, ResponsePartKind.ToolCall);
1035
assert.strictEqual(partAfterReady?.kind === ResponsePartKind.ToolCall ? partAfterReady.toolCall.status : undefined, ToolCallStatus.Running,
1036
'tool call should advance from Streaming to Running after tool_ready');
1037
});
1038
1039
test('tool_ready for a permission-gated tool dispatches SessionToolCallReady and advances state to PendingConfirmation', () => {
1040
setupSession();
1041
startTurn('turn-1');
1042
disposables.add(sideEffects.registerProgressListener(agent));
1043
1044
agent.fireProgress({
1045
kind: 'action', session: sessionUri,
1046
action: {
1047
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1048
toolCallId: 'tc-perm-1', toolName: 'write', displayName: 'Write File', toolClientId: 'test-client',
1049
_meta: { toolKind: undefined, language: undefined },
1050
},
1051
});
1052
1053
// tool_ready with confirmationTitle should dispatch the ready
1054
// action and advance the tool call to PendingConfirmation
1055
agent.fireProgress({
1056
kind: 'pending_confirmation', session: sessionUri,
1057
state: {
1058
status: ToolCallStatus.PendingConfirmation,
1059
toolCallId: 'tc-perm-1', toolName: '', displayName: '',
1060
invocationMessage: 'Write .env', toolInput: '{"path":".env"}',
1061
confirmationTitle: 'Write .env', edits: undefined,
1062
},
1063
permissionKind: undefined, permissionPath: undefined,
1064
});
1065
1066
const state = stateManager.getSessionState(sessionUri.toString());
1067
const part = state?.activeTurn?.responseParts[0];
1068
assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);
1069
assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.PendingConfirmation,
1070
'tool call should advance to PendingConfirmation for permission-gated tool_ready');
1071
});
1072
1073
test('pending_confirmation for a tool inside a subagent routes to the subagent session', () => {
1074
// Regression: a `pending_confirmation` signal for a client tool
1075
// inside a subagent must dispatch SessionToolCallReady against
1076
// the subagent session, not the parent. Otherwise the parent
1077
// session sees a stray `session/toolCallReady` with no
1078
// preceding `session/toolCallStart`, which is illegal.
1079
setupSession();
1080
startTurn('turn-1');
1081
disposables.add(sideEffects.registerProgressListener(agent));
1082
1083
// Parent tool that delegates to a subagent.
1084
agent.fireProgress({
1085
kind: 'action', session: sessionUri,
1086
action: {
1087
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1088
toolCallId: 'tc-parent', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined,
1089
_meta: { toolKind: undefined, language: undefined },
1090
},
1091
});
1092
agent.fireProgress({
1093
kind: 'action', session: sessionUri,
1094
action: {
1095
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
1096
toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined,
1097
confirmed: ToolCallConfirmationReason.NotNeeded,
1098
},
1099
});
1100
agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper' });
1101
1102
// Inner client tool starts inside the subagent.
1103
agent.fireProgress({
1104
kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',
1105
action: {
1106
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1107
toolCallId: 'tc-inner', toolName: 'problems', displayName: 'Problems', toolClientId: 'client-tools',
1108
_meta: { toolKind: undefined, language: undefined },
1109
},
1110
});
1111
1112
// Permission flow fires `pending_confirmation` for the inner
1113
// client tool. The signal must be routed to the subagent
1114
// session — not to the parent — even though the signal carries
1115
// only the parent session URI.
1116
agent.fireProgress({
1117
kind: 'pending_confirmation', session: sessionUri, parentToolCallId: 'tc-parent',
1118
state: {
1119
status: ToolCallStatus.PendingConfirmation,
1120
toolCallId: 'tc-inner', toolName: 'problems', displayName: 'Problems',
1121
invocationMessage: 'Get problems', toolInput: '{}',
1122
confirmationTitle: undefined, edits: undefined,
1123
},
1124
permissionKind: 'custom-tool', permissionPath: undefined,
1125
});
1126
1127
// The subagent session must contain the SessionToolCallReady.
1128
const subagentUri = buildSubagentSessionUri(sessionUri.toString(), 'tc-parent');
1129
const subState = stateManager.getSessionState(subagentUri);
1130
const innerPart = subState?.activeTurn?.responseParts.find(
1131
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-inner'
1132
);
1133
assert.ok(innerPart, 'inner client tool call should exist on subagent session');
1134
assert.strictEqual(
1135
innerPart!.kind === ResponsePartKind.ToolCall ? innerPart.toolCall.status : undefined,
1136
ToolCallStatus.Running,
1137
'inner client tool call should advance to Running after pending_confirmation'
1138
);
1139
1140
// The parent session must NOT have a stray tool call for the
1141
// inner toolCallId — that would be a SessionToolCallReady
1142
// without a matching SessionToolCallStart.
1143
const parentState = stateManager.getSessionState(sessionUri.toString());
1144
const parentInner = parentState?.activeTurn?.responseParts.find(
1145
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-inner'
1146
);
1147
assert.strictEqual(parentInner, undefined, 'parent session must not contain the inner tool call');
1148
});
1149
});
1150
1151
// ---- Session-level auto-approve (config) ----------------------------
1152
1153
suite('session config auto-approve', () => {
1154
1155
function setupSessionWithConfig(autoApproveLevel: string): void {
1156
setupSession(URI.file('/workspace').toString());
1157
// Set config on the session state directly (as agentService.ts does)
1158
const state = stateManager.getSessionState(sessionUri.toString());
1159
if (state) {
1160
state.config = {
1161
schema: {
1162
type: 'object',
1163
properties: {
1164
autoApprove: {
1165
type: 'string',
1166
title: 'Approvals',
1167
enum: ['default', 'autoApprove', 'autopilot'],
1168
default: 'default',
1169
sessionMutable: true,
1170
},
1171
},
1172
},
1173
values: { autoApprove: autoApproveLevel },
1174
};
1175
}
1176
}
1177
1178
test('auto-approves all writes when autoApprove is set to bypass', () => {
1179
setupSessionWithConfig('autoApprove');
1180
startTurn('turn-1');
1181
disposables.add(sideEffects.registerProgressListener(agent));
1182
1183
agent.fireProgress({
1184
kind: 'action', session: sessionUri,
1185
action: {
1186
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1187
toolCallId: 'tc-bypass-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,
1188
_meta: { toolKind: undefined, language: undefined },
1189
},
1190
});
1191
agent.fireProgress({
1192
kind: 'action', session: sessionUri,
1193
action: {
1194
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
1195
toolCallId: 'tc-bypass-1', invocationMessage: 'Write .env', toolInput: undefined,
1196
confirmed: ToolCallConfirmationReason.NotNeeded,
1197
},
1198
});
1199
1200
agent.fireProgress({
1201
kind: 'pending_confirmation', session: sessionUri,
1202
state: {
1203
status: ToolCallStatus.PendingConfirmation,
1204
toolCallId: 'tc-bypass-1', toolName: '', displayName: '',
1205
invocationMessage: 'Write .env', toolInput: undefined,
1206
confirmationTitle: undefined, edits: undefined,
1207
},
1208
permissionKind: 'write', permissionPath: '/workspace/.env',
1209
});
1210
1211
// .env would normally be blocked, but session-level auto-approve overrides
1212
assert.deepStrictEqual(agent.respondToPermissionCalls, [
1213
{ requestId: 'tc-bypass-1', approved: true },
1214
]);
1215
});
1216
1217
test('auto-approves shell commands when autoApprove is set to autopilot', () => {
1218
setupSessionWithConfig('autopilot');
1219
startTurn('turn-1');
1220
disposables.add(sideEffects.registerProgressListener(agent));
1221
1222
agent.fireProgress({
1223
kind: 'action', session: sessionUri,
1224
action: {
1225
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1226
toolCallId: 'tc-ap-shell-1', toolName: 'shell', displayName: 'Shell', toolClientId: undefined,
1227
_meta: { toolKind: undefined, language: undefined },
1228
},
1229
});
1230
agent.fireProgress({
1231
kind: 'action', session: sessionUri,
1232
action: {
1233
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
1234
toolCallId: 'tc-ap-shell-1', invocationMessage: 'Run rm -rf /', toolInput: undefined,
1235
confirmed: ToolCallConfirmationReason.NotNeeded,
1236
},
1237
});
1238
1239
agent.fireProgress({
1240
kind: 'pending_confirmation', session: sessionUri,
1241
state: {
1242
status: ToolCallStatus.PendingConfirmation,
1243
toolCallId: 'tc-ap-shell-1', toolName: '', displayName: '',
1244
invocationMessage: 'Run rm -rf /', toolInput: 'rm -rf /',
1245
confirmationTitle: undefined, edits: undefined,
1246
},
1247
permissionKind: 'shell', permissionPath: undefined,
1248
});
1249
1250
// Dangerous command would normally be blocked, but session-level auto-approve overrides
1251
assert.deepStrictEqual(agent.respondToPermissionCalls, [
1252
{ requestId: 'tc-ap-shell-1', approved: true },
1253
]);
1254
});
1255
1256
test('does NOT auto-approve when autoApprove is default', () => {
1257
setupSessionWithConfig('default');
1258
startTurn('turn-1');
1259
disposables.add(sideEffects.registerProgressListener(agent));
1260
1261
agent.fireProgress({
1262
kind: 'action', session: sessionUri,
1263
action: {
1264
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1265
toolCallId: 'tc-default-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,
1266
_meta: { toolKind: undefined, language: undefined },
1267
},
1268
});
1269
agent.fireProgress({
1270
kind: 'action', session: sessionUri,
1271
action: {
1272
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
1273
toolCallId: 'tc-default-1', invocationMessage: 'Write .env', toolInput: undefined,
1274
confirmed: ToolCallConfirmationReason.NotNeeded,
1275
},
1276
});
1277
1278
agent.fireProgress({
1279
kind: 'pending_confirmation', session: sessionUri,
1280
state: {
1281
status: ToolCallStatus.PendingConfirmation,
1282
toolCallId: 'tc-default-1', toolName: '', displayName: '',
1283
invocationMessage: 'Write .env', toolInput: undefined,
1284
confirmationTitle: undefined, edits: undefined,
1285
},
1286
permissionKind: 'write', permissionPath: '/workspace/.env',
1287
});
1288
1289
// .env should still be blocked with default config
1290
assert.strictEqual(agent.respondToPermissionCalls.length, 0);
1291
});
1292
1293
test('respects mid-session config change via SessionConfigChanged', () => {
1294
setupSessionWithConfig('default');
1295
startTurn('turn-1');
1296
disposables.add(sideEffects.registerProgressListener(agent));
1297
1298
// Change to bypass mid-session
1299
stateManager.dispatchServerAction({
1300
type: ActionType.SessionConfigChanged,
1301
session: sessionUri.toString(),
1302
config: { autoApprove: 'autoApprove' },
1303
});
1304
1305
agent.fireProgress({
1306
kind: 'action', session: sessionUri,
1307
action: {
1308
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1309
toolCallId: 'tc-mid-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,
1310
_meta: { toolKind: undefined, language: undefined },
1311
},
1312
});
1313
agent.fireProgress({
1314
kind: 'action', session: sessionUri,
1315
action: {
1316
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
1317
toolCallId: 'tc-mid-1', invocationMessage: 'Write .env', toolInput: undefined,
1318
confirmed: ToolCallConfirmationReason.NotNeeded,
1319
},
1320
});
1321
1322
agent.fireProgress({
1323
kind: 'pending_confirmation', session: sessionUri,
1324
state: {
1325
status: ToolCallStatus.PendingConfirmation,
1326
toolCallId: 'tc-mid-1', toolName: '', displayName: '',
1327
invocationMessage: 'Write .env', toolInput: undefined,
1328
confirmationTitle: undefined, edits: undefined,
1329
},
1330
permissionKind: 'write', permissionPath: '/workspace/.env',
1331
});
1332
1333
// Should now be auto-approved after config change
1334
assert.deepStrictEqual(agent.respondToPermissionCalls, [
1335
{ requestId: 'tc-mid-1', approved: true },
1336
]);
1337
});
1338
});
1339
1340
// ---- Edit auto-approve ----------------------------------------------
1341
1342
suite('edit auto-approve', () => {
1343
1344
test('auto-approves writes to regular source files', async () => {
1345
setupSession(URI.file('/workspace').toString());
1346
startTurn('turn-1');
1347
disposables.add(sideEffects.registerProgressListener(agent));
1348
1349
agent.fireProgress({
1350
kind: 'action', session: sessionUri,
1351
action: {
1352
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1353
toolCallId: 'tc-auto-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,
1354
_meta: { toolKind: undefined, language: undefined },
1355
},
1356
});
1357
agent.fireProgress({
1358
kind: 'action', session: sessionUri,
1359
action: {
1360
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
1361
toolCallId: 'tc-auto-1', invocationMessage: 'Write file', toolInput: undefined,
1362
confirmed: ToolCallConfirmationReason.NotNeeded,
1363
},
1364
});
1365
1366
agent.fireProgress({
1367
kind: 'pending_confirmation', session: sessionUri,
1368
state: {
1369
status: ToolCallStatus.PendingConfirmation,
1370
toolCallId: 'tc-auto-1', toolName: '', displayName: '',
1371
invocationMessage: 'Write src/app.ts', toolInput: undefined,
1372
confirmationTitle: undefined, edits: undefined,
1373
},
1374
permissionKind: 'write', permissionPath: '/workspace/src/app.ts',
1375
});
1376
1377
// Auto-approved writes call respondToPermissionRequest directly
1378
assert.deepStrictEqual(agent.respondToPermissionCalls, [
1379
{ requestId: 'tc-auto-1', approved: true },
1380
]);
1381
});
1382
1383
test('blocks writes to .env files', () => {
1384
setupSession(URI.file('/workspace').toString());
1385
startTurn('turn-1');
1386
disposables.add(sideEffects.registerProgressListener(agent));
1387
1388
const envelopes: ActionEnvelope[] = [];
1389
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
1390
1391
agent.fireProgress({
1392
kind: 'action', session: sessionUri,
1393
action: {
1394
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1395
toolCallId: 'tc-env-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,
1396
_meta: { toolKind: undefined, language: undefined },
1397
},
1398
});
1399
agent.fireProgress({
1400
kind: 'action', session: sessionUri,
1401
action: {
1402
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
1403
toolCallId: 'tc-env-1', invocationMessage: 'Write .env', toolInput: undefined,
1404
confirmed: ToolCallConfirmationReason.NotNeeded,
1405
},
1406
});
1407
1408
agent.fireProgress({
1409
kind: 'pending_confirmation', session: sessionUri,
1410
state: {
1411
status: ToolCallStatus.PendingConfirmation,
1412
toolCallId: 'tc-env-1', toolName: '', displayName: '',
1413
invocationMessage: 'Write .env', toolInput: undefined,
1414
confirmationTitle: 'Write .env', edits: undefined,
1415
},
1416
permissionKind: 'write', permissionPath: '/workspace/.env',
1417
});
1418
1419
// Should NOT auto-approve — .env is excluded
1420
assert.strictEqual(agent.respondToPermissionCalls.length, 0);
1421
1422
// Should dispatch a tool_ready action for the client to confirm
1423
const readyAction = envelopes.find(e => e.action.type === ActionType.SessionToolCallReady);
1424
assert.ok(readyAction, 'should dispatch tool_ready for blocked write');
1425
});
1426
1427
test('blocks writes to package.json', () => {
1428
setupSession(URI.file('/workspace').toString());
1429
startTurn('turn-1');
1430
disposables.add(sideEffects.registerProgressListener(agent));
1431
1432
agent.fireProgress({
1433
kind: 'action', session: sessionUri,
1434
action: {
1435
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1436
toolCallId: 'tc-pkg-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,
1437
_meta: { toolKind: undefined, language: undefined },
1438
},
1439
});
1440
agent.fireProgress({
1441
kind: 'action', session: sessionUri,
1442
action: {
1443
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
1444
toolCallId: 'tc-pkg-1', invocationMessage: 'Write package.json', toolInput: undefined,
1445
confirmed: ToolCallConfirmationReason.NotNeeded,
1446
},
1447
});
1448
1449
agent.fireProgress({
1450
kind: 'pending_confirmation', session: sessionUri,
1451
state: {
1452
status: ToolCallStatus.PendingConfirmation,
1453
toolCallId: 'tc-pkg-1', toolName: '', displayName: '',
1454
invocationMessage: 'Write package.json', toolInput: undefined,
1455
confirmationTitle: 'Write package.json', edits: undefined,
1456
},
1457
permissionKind: 'write', permissionPath: '/workspace/package.json',
1458
});
1459
1460
assert.strictEqual(agent.respondToPermissionCalls.length, 0);
1461
});
1462
1463
test('blocks writes to .lock files', () => {
1464
setupSession(URI.file('/workspace').toString());
1465
startTurn('turn-1');
1466
disposables.add(sideEffects.registerProgressListener(agent));
1467
1468
agent.fireProgress({
1469
kind: 'action', session: sessionUri,
1470
action: {
1471
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1472
toolCallId: 'tc-lock-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,
1473
_meta: { toolKind: undefined, language: undefined },
1474
},
1475
});
1476
agent.fireProgress({
1477
kind: 'action', session: sessionUri,
1478
action: {
1479
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
1480
toolCallId: 'tc-lock-1', invocationMessage: 'Write yarn.lock', toolInput: undefined,
1481
confirmed: ToolCallConfirmationReason.NotNeeded,
1482
},
1483
});
1484
1485
agent.fireProgress({
1486
kind: 'pending_confirmation', session: sessionUri,
1487
state: {
1488
status: ToolCallStatus.PendingConfirmation,
1489
toolCallId: 'tc-lock-1', toolName: '', displayName: '',
1490
invocationMessage: 'Write yarn.lock', toolInput: undefined,
1491
confirmationTitle: 'Write yarn.lock', edits: undefined,
1492
},
1493
permissionKind: 'write', permissionPath: '/workspace/yarn.lock',
1494
});
1495
1496
assert.strictEqual(agent.respondToPermissionCalls.length, 0);
1497
});
1498
1499
test('blocks writes to .git directory', () => {
1500
setupSession(URI.file('/workspace').toString());
1501
startTurn('turn-1');
1502
disposables.add(sideEffects.registerProgressListener(agent));
1503
1504
agent.fireProgress({
1505
kind: 'action', session: sessionUri,
1506
action: {
1507
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1508
toolCallId: 'tc-git-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,
1509
_meta: { toolKind: undefined, language: undefined },
1510
},
1511
});
1512
agent.fireProgress({
1513
kind: 'action', session: sessionUri,
1514
action: {
1515
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
1516
toolCallId: 'tc-git-1', invocationMessage: 'Write .git/config', toolInput: undefined,
1517
confirmed: ToolCallConfirmationReason.NotNeeded,
1518
},
1519
});
1520
1521
agent.fireProgress({
1522
kind: 'pending_confirmation', session: sessionUri,
1523
state: {
1524
status: ToolCallStatus.PendingConfirmation,
1525
toolCallId: 'tc-git-1', toolName: '', displayName: '',
1526
invocationMessage: 'Write .git/config', toolInput: undefined,
1527
confirmationTitle: 'Write .git/config', edits: undefined,
1528
},
1529
permissionKind: 'write', permissionPath: '/workspace/.git/config',
1530
});
1531
1532
assert.strictEqual(agent.respondToPermissionCalls.length, 0);
1533
});
1534
});
1535
1536
// ---- Read auto-approve -------------------------------------------------
1537
1538
suite('read auto-approve', () => {
1539
1540
test('auto-approves reads inside working directory', () => {
1541
setupSession(URI.file('/workspace').toString());
1542
startTurn('turn-1');
1543
disposables.add(sideEffects.registerProgressListener(agent));
1544
1545
agent.fireProgress({
1546
kind: 'action', session: sessionUri,
1547
action: {
1548
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1549
toolCallId: 'tc-read-1', toolName: 'read', displayName: 'Read', toolClientId: undefined,
1550
_meta: { toolKind: undefined, language: undefined },
1551
},
1552
});
1553
agent.fireProgress({
1554
kind: 'action', session: sessionUri,
1555
action: {
1556
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
1557
toolCallId: 'tc-read-1', invocationMessage: 'Read file', toolInput: undefined,
1558
confirmed: ToolCallConfirmationReason.NotNeeded,
1559
},
1560
});
1561
1562
agent.fireProgress({
1563
kind: 'pending_confirmation', session: sessionUri,
1564
state: {
1565
status: ToolCallStatus.PendingConfirmation,
1566
toolCallId: 'tc-read-1', toolName: '', displayName: '',
1567
invocationMessage: 'Read src/app.ts', toolInput: undefined,
1568
confirmationTitle: undefined, edits: undefined,
1569
},
1570
permissionKind: 'read', permissionPath: '/workspace/src/app.ts',
1571
});
1572
1573
assert.deepStrictEqual(agent.respondToPermissionCalls, [
1574
{ requestId: 'tc-read-1', approved: true },
1575
]);
1576
});
1577
1578
test('does not auto-approve reads outside working directory', () => {
1579
setupSession(URI.file('/workspace').toString());
1580
startTurn('turn-1');
1581
disposables.add(sideEffects.registerProgressListener(agent));
1582
1583
const envelopes: ActionEnvelope[] = [];
1584
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
1585
1586
agent.fireProgress({
1587
kind: 'action', session: sessionUri,
1588
action: {
1589
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1590
toolCallId: 'tc-read-2', toolName: 'read', displayName: 'Read', toolClientId: undefined,
1591
_meta: { toolKind: undefined, language: undefined },
1592
},
1593
});
1594
agent.fireProgress({
1595
kind: 'action', session: sessionUri,
1596
action: {
1597
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
1598
toolCallId: 'tc-read-2', invocationMessage: 'Read file', toolInput: undefined,
1599
confirmed: ToolCallConfirmationReason.NotNeeded,
1600
},
1601
});
1602
1603
agent.fireProgress({
1604
kind: 'pending_confirmation', session: sessionUri,
1605
state: {
1606
status: ToolCallStatus.PendingConfirmation,
1607
toolCallId: 'tc-read-2', toolName: '', displayName: '',
1608
invocationMessage: 'Read /etc/passwd', toolInput: undefined,
1609
confirmationTitle: undefined, edits: undefined,
1610
},
1611
permissionKind: 'read', permissionPath: '/etc/passwd',
1612
});
1613
1614
assert.strictEqual(agent.respondToPermissionCalls.length, 0);
1615
1616
const readyAction = envelopes.find(e => e.action.type === ActionType.SessionToolCallReady);
1617
assert.ok(readyAction, 'should dispatch tool_ready for read outside working directory');
1618
});
1619
});
1620
1621
// ---- Title persistence --------------------------------------------------
1622
1623
suite('title persistence', () => {
1624
1625
let sessionDb: SessionDatabase;
1626
1627
setup(async () => {
1628
sessionDb = disposables.add(await SessionDatabase.open(':memory:'));
1629
});
1630
1631
teardown(async () => {
1632
await sessionDb.close();
1633
});
1634
1635
test('SessionTitleChanged persists to the database', async () => {
1636
const sessionDataService = createSessionDataService(sessionDb);
1637
const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService()));
1638
const localAgent = new MockAgent();
1639
disposables.add(toDisposable(() => localAgent.dispose()));
1640
const localSideEffects = createTestSideEffects(disposables, localStateManager, {
1641
getAgent: () => localAgent,
1642
agents: observableValue<readonly IAgent[]>('agents', [localAgent]),
1643
sessionDataService,
1644
onTurnComplete: () => { },
1645
});
1646
1647
localStateManager.createSession({
1648
resource: sessionUri.toString(),
1649
provider: 'mock',
1650
title: 'Initial',
1651
status: SessionStatus.Idle,
1652
createdAt: Date.now(),
1653
modifiedAt: Date.now(),
1654
project: { uri: 'file:///test-project', displayName: 'Test Project' },
1655
});
1656
1657
localSideEffects.handleAction({
1658
type: ActionType.SessionTitleChanged,
1659
session: sessionUri.toString(),
1660
title: 'Custom Title',
1661
});
1662
1663
// Wait for the async persistence
1664
await new Promise(r => setTimeout(r, 50));
1665
1666
assert.strictEqual(await sessionDb.getMetadata('customTitle'), 'Custom Title');
1667
});
1668
1669
test('handleListSessions returns persisted custom title', async () => {
1670
const sessionDataService = createSessionDataService(sessionDb);
1671
const localAgent = new MockAgent();
1672
disposables.add(toDisposable(() => localAgent.dispose()));
1673
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
1674
localService.registerProvider(localAgent);
1675
1676
// Create a session on the agent backend
1677
await localAgent.createSession();
1678
1679
// Persist a custom title in the DB
1680
await sessionDb.setMetadata('customTitle', 'My Custom Title');
1681
1682
const sessions = await localService.listSessions();
1683
assert.strictEqual(sessions.length, 1);
1684
// Custom title comes from the DB and is returned via the agent's listSessions
1685
// The mock agent summary is used; the service doesn't read the DB for list
1686
assert.ok(sessions[0].summary);
1687
});
1688
1689
test('handleRestoreSession uses persisted custom title', async () => {
1690
const sessionDataService = createSessionDataService(sessionDb);
1691
const localAgent = new MockAgent();
1692
disposables.add(toDisposable(() => localAgent.dispose()));
1693
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
1694
localService.registerProvider(localAgent);
1695
1696
// Create a session on the agent backend
1697
const { session } = await localAgent.createSession();
1698
const sessions = await localAgent.listSessions();
1699
const sessionResource = sessions[0].session;
1700
1701
// Persist a custom title in the DB
1702
await sessionDb.setMetadata('customTitle', 'Restored Title');
1703
1704
// Set up minimal messages for restore
1705
localAgent.sessionMessages = [
1706
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
1707
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
1708
];
1709
1710
await localService.restoreSession(sessionResource);
1711
1712
const state = localService.stateManager.getSessionState(sessionResource.toString());
1713
assert.ok(state);
1714
assert.strictEqual(state!.summary.title, 'Restored Title');
1715
});
1716
1717
test('SessionConfigChanged persists merged config values to the database', async () => {
1718
const sessionDataService = createSessionDataService(sessionDb);
1719
const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService()));
1720
const localAgent = new MockAgent();
1721
disposables.add(toDisposable(() => localAgent.dispose()));
1722
const localSideEffects = createTestSideEffects(disposables, localStateManager, {
1723
getAgent: () => localAgent,
1724
agents: observableValue<readonly IAgent[]>('agents', [localAgent]),
1725
sessionDataService,
1726
onTurnComplete: () => { },
1727
});
1728
1729
const session = localStateManager.createSession({
1730
resource: sessionUri.toString(),
1731
provider: 'mock',
1732
title: 'Initial',
1733
status: SessionStatus.Idle,
1734
createdAt: Date.now(),
1735
modifiedAt: Date.now(),
1736
project: { uri: 'file:///test-project', displayName: 'Test Project' },
1737
});
1738
session.config = { schema: { type: 'object', properties: {} }, values: { autoApprove: 'default' } };
1739
1740
// Mid-session change merges new values into existing.
1741
localStateManager.dispatchClientAction({
1742
type: ActionType.SessionConfigChanged,
1743
session: sessionUri.toString(),
1744
config: { autoApprove: 'autoApprove' },
1745
}, { clientId: 'test-client', clientSeq: 1 });
1746
localSideEffects.handleAction({
1747
type: ActionType.SessionConfigChanged,
1748
session: sessionUri.toString(),
1749
config: { autoApprove: 'autoApprove' },
1750
});
1751
1752
await new Promise(r => setTimeout(r, 50));
1753
1754
const persisted = await sessionDb.getMetadata('configValues');
1755
assert.ok(persisted);
1756
assert.deepStrictEqual(JSON.parse(persisted!), { autoApprove: 'autoApprove' });
1757
});
1758
});
1759
1760
// ---- Subagent sessions ----------------------------------------------
1761
1762
suite('subagent sessions', () => {
1763
1764
test('subagent_started creates a subagent session and dispatches content on parent tool call', () => {
1765
setupSession();
1766
startTurn('turn-1');
1767
disposables.add(sideEffects.registerProgressListener(agent));
1768
1769
// Start a parent tool call
1770
agent.fireProgress({
1771
kind: 'action', session: sessionUri,
1772
action: {
1773
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1774
toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined,
1775
_meta: { toolKind: undefined, language: undefined },
1776
},
1777
});
1778
agent.fireProgress({
1779
kind: 'action', session: sessionUri,
1780
action: {
1781
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
1782
toolCallId: 'tc-1', invocationMessage: 'Delegating task...', toolInput: undefined,
1783
confirmed: ToolCallConfirmationReason.NotNeeded,
1784
},
1785
});
1786
1787
// Fire subagent_started
1788
agent.fireProgress({
1789
kind: 'subagent_started', session: sessionUri,
1790
toolCallId: 'tc-1',
1791
agentName: 'code-reviewer',
1792
agentDisplayName: 'Code Reviewer',
1793
agentDescription: 'Reviews code',
1794
});
1795
1796
// Verify the subagent session was created
1797
const subagentUri = `${sessionUri.toString()}/subagent/tc-1`;
1798
const subState = stateManager.getSessionState(subagentUri);
1799
assert.ok(subState, 'subagent session should exist');
1800
assert.strictEqual(subState!.summary.title, 'Code Reviewer');
1801
assert.ok(subState!.activeTurn, 'subagent should have an active turn');
1802
1803
// Verify content was dispatched on the parent tool call
1804
const parentState = stateManager.getSessionState(sessionUri.toString());
1805
assert.ok(parentState?.activeTurn);
1806
const parentToolCall = parentState!.activeTurn!.responseParts.find(
1807
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1'
1808
);
1809
assert.ok(parentToolCall);
1810
if (parentToolCall?.kind === ResponsePartKind.ToolCall && parentToolCall.toolCall.status === ToolCallStatus.Running) {
1811
assert.ok(parentToolCall.toolCall.content);
1812
assert.strictEqual(parentToolCall.toolCall.content![0].type, ToolResultContentType.Subagent);
1813
}
1814
});
1815
1816
test('events with parentToolCallId route to subagent session', () => {
1817
setupSession();
1818
startTurn('turn-1');
1819
disposables.add(sideEffects.registerProgressListener(agent));
1820
1821
// Start parent tool + subagent
1822
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } });
1823
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
1824
agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });
1825
1826
// Fire an inner tool start with parentToolCallId
1827
agent.fireProgress({
1828
kind: 'action', session: sessionUri, parentToolCallId: 'tc-1',
1829
action: {
1830
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1831
toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', toolClientId: undefined,
1832
_meta: { toolKind: undefined, language: undefined },
1833
},
1834
});
1835
agent.fireProgress({
1836
kind: 'action', session: sessionUri, parentToolCallId: 'tc-1',
1837
action: {
1838
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
1839
toolCallId: 'inner-tc-1', invocationMessage: 'Reading file...', toolInput: undefined,
1840
confirmed: ToolCallConfirmationReason.NotNeeded,
1841
},
1842
});
1843
1844
// Verify the inner tool call is on the subagent session's turn, not the parent
1845
const subagentUri = `${sessionUri.toString()}/subagent/tc-1`;
1846
const subState = stateManager.getSessionState(subagentUri);
1847
assert.ok(subState?.activeTurn);
1848
const innerTool = subState!.activeTurn!.responseParts.find(
1849
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1'
1850
);
1851
assert.ok(innerTool, 'inner tool call should be in subagent session');
1852
1853
// Verify the parent session does NOT have the inner tool call
1854
const parentState = stateManager.getSessionState(sessionUri.toString());
1855
const parentInnerTool = parentState!.activeTurn!.responseParts.find(
1856
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1'
1857
);
1858
assert.strictEqual(parentInnerTool, undefined, 'inner tool call should NOT be in parent session');
1859
});
1860
1861
test('completeSubagentSession clears pending buffered events when subagent never started', () => {
1862
// Regression: if the parent tool completes (or fails) before any
1863
// `subagent_started` arrives, buffered inner events would
1864
// otherwise leak in `_pendingSubagentEvents` until session
1865
// disposal. After completion, a late `subagent_started` for the
1866
// same toolCallId must not replay stale events.
1867
setupSession();
1868
startTurn('turn-1');
1869
disposables.add(sideEffects.registerProgressListener(agent));
1870
1871
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } });
1872
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
1873
1874
// Inner event arrives but `subagent_started` never does.
1875
agent.fireProgress({
1876
kind: 'action', session: sessionUri, parentToolCallId: 'tc-1',
1877
action: {
1878
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
1879
toolCallId: 'inner-1', toolName: 'read', displayName: 'Read', toolClientId: undefined,
1880
_meta: { toolKind: undefined, language: undefined },
1881
},
1882
});
1883
agent.fireProgress({
1884
kind: 'action', session: sessionUri, parentToolCallId: 'tc-1',
1885
action: {
1886
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
1887
toolCallId: 'inner-1', invocationMessage: 'Reading...', toolInput: undefined,
1888
confirmed: ToolCallConfirmationReason.NotNeeded,
1889
},
1890
});
1891
1892
// Parent tool completes (e.g. it errored before delegating).
1893
agent.fireProgress({
1894
kind: 'action', session: sessionUri,
1895
action: {
1896
type: ActionType.SessionToolCallComplete, session: sessionUri.toString(), turnId: 'turn-1',
1897
toolCallId: 'tc-1',
1898
result: { success: false, pastTenseMessage: 'Failed' },
1899
},
1900
});
1901
1902
// Now a late `subagent_started` for the same toolCallId arrives.
1903
// This is unusual but possible after a reconnect/replay. The
1904
// drain must NOT replay the (cleared) buffered inner tool call.
1905
agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });
1906
1907
const subagentUri = `${sessionUri.toString()}/subagent/tc-1`;
1908
const subState = stateManager.getSessionState(subagentUri);
1909
assert.ok(subState, 'subagent session should still be created');
1910
const innerTool = subState!.activeTurn?.responseParts.find(
1911
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-1'
1912
);
1913
assert.strictEqual(innerTool, undefined, 'stale buffered inner tool call must not be replayed');
1914
});
1915
1916
test('completeSubagentSession completes the subagent turn when parent tool completes', () => {
1917
setupSession();
1918
startTurn('turn-1');
1919
disposables.add(sideEffects.registerProgressListener(agent));
1920
1921
// Start parent tool + subagent
1922
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } });
1923
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
1924
agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });
1925
1926
// Complete the parent tool call
1927
agent.fireProgress({
1928
kind: 'action', session: sessionUri,
1929
action: {
1930
type: ActionType.SessionToolCallComplete, session: sessionUri.toString(), turnId: 'turn-1',
1931
toolCallId: 'tc-1',
1932
result: { success: true, pastTenseMessage: 'Done' },
1933
},
1934
});
1935
1936
// Verify the subagent session's turn was completed
1937
const subagentUri = `${sessionUri.toString()}/subagent/tc-1`;
1938
const subState = stateManager.getSessionState(subagentUri);
1939
assert.ok(subState);
1940
assert.strictEqual(subState!.activeTurn, undefined, 'subagent turn should be completed');
1941
assert.strictEqual(subState!.turns.length, 1);
1942
});
1943
1944
test('cancelSubagentSessions cancels all subagent sessions', () => {
1945
setupSession();
1946
startTurn('turn-1');
1947
disposables.add(sideEffects.registerProgressListener(agent));
1948
1949
// Start two parent tool calls with subagents
1950
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } });
1951
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating 1...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
1952
agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'sub1', agentDisplayName: 'Sub 1', agentDescription: 'First' });
1953
1954
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-2', toolName: 'runSubagent', displayName: 'Sub 2', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } });
1955
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-2', invocationMessage: 'Delegating 2...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
1956
agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-2', agentName: 'sub2', agentDisplayName: 'Sub 2', agentDescription: 'Second' });
1957
1958
// Cancel via parent turn cancellation
1959
sideEffects.handleAction({
1960
type: ActionType.SessionTurnCancelled,
1961
session: sessionUri.toString(),
1962
turnId: 'turn-1',
1963
});
1964
1965
// Both subagent sessions should have their turns completed (cancelled)
1966
const sub1 = stateManager.getSessionState(`${sessionUri.toString()}/subagent/tc-1`);
1967
const sub2 = stateManager.getSessionState(`${sessionUri.toString()}/subagent/tc-2`);
1968
assert.strictEqual(sub1?.activeTurn, undefined, 'sub1 turn should be cancelled');
1969
assert.strictEqual(sub2?.activeTurn, undefined, 'sub2 turn should be cancelled');
1970
});
1971
1972
test('removeSubagentSessions removes all subagent sessions from state', () => {
1973
setupSession();
1974
startTurn('turn-1');
1975
disposables.add(sideEffects.registerProgressListener(agent));
1976
1977
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } });
1978
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
1979
agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'sub', agentDisplayName: 'Sub', agentDescription: 'Has subagent' });
1980
1981
const subagentUri = `${sessionUri.toString()}/subagent/tc-1`;
1982
assert.ok(stateManager.getSessionState(subagentUri));
1983
1984
sideEffects.removeSubagentSessions(sessionUri.toString());
1985
1986
assert.strictEqual(stateManager.getSessionState(subagentUri), undefined, 'subagent session should be removed');
1987
});
1988
1989
test('deltas with parentToolCallId route to subagent session', () => {
1990
setupSession();
1991
startTurn('turn-1');
1992
disposables.add(sideEffects.registerProgressListener(agent));
1993
1994
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } });
1995
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
1996
agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });
1997
1998
// Fire a delta with parentToolCallId
1999
agent.fireProgress({
2000
kind: 'action', session: sessionUri, parentToolCallId: 'tc-1',
2001
action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-sub', content: 'thinking...' } },
2002
});
2003
2004
// Verify the delta went to the subagent session
2005
const subagentUri = `${sessionUri.toString()}/subagent/tc-1`;
2006
const subState = stateManager.getSessionState(subagentUri);
2007
assert.ok(subState?.activeTurn);
2008
const markdownPart = subState!.activeTurn!.responseParts.find(
2009
rp => rp.kind === ResponsePartKind.Markdown
2010
);
2011
assert.ok(markdownPart, 'delta should create a markdown part in subagent session');
2012
});
2013
2014
test('tool_complete preserves subagent content in completed tool call', () => {
2015
setupSession();
2016
startTurn('turn-1');
2017
disposables.add(sideEffects.registerProgressListener(agent));
2018
2019
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'task', displayName: 'Task', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } });
2020
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
2021
agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' });
2022
2023
// Verify subagent content is on the running tool
2024
const runningState = stateManager.getSessionState(sessionUri.toString());
2025
const runningTool = runningState?.activeTurn?.responseParts.find(
2026
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1'
2027
);
2028
assert.ok(runningTool?.kind === ResponsePartKind.ToolCall);
2029
assert.strictEqual(runningTool.toolCall.status, ToolCallStatus.Running);
2030
2031
// Complete the tool — the SDK result has its own content
2032
agent.fireProgress({
2033
kind: 'action', session: sessionUri,
2034
action: {
2035
type: ActionType.SessionToolCallComplete, session: sessionUri.toString(), turnId: 'turn-1',
2036
toolCallId: 'tc-1',
2037
result: { success: true, pastTenseMessage: 'Delegated', content: [{ type: ToolResultContentType.Text, text: 'Done' }] },
2038
},
2039
});
2040
2041
// Verify the completed tool still has the subagent content entry
2042
const completedState = stateManager.getSessionState(sessionUri.toString());
2043
const completedTool = completedState?.activeTurn?.responseParts.find(
2044
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1'
2045
);
2046
assert.ok(completedTool?.kind === ResponsePartKind.ToolCall);
2047
assert.strictEqual(completedTool.toolCall.status, ToolCallStatus.Completed);
2048
const content = completedTool.toolCall.content ?? [];
2049
const subagentEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent);
2050
assert.ok(subagentEntry, 'Completed tool should preserve subagent content entry');
2051
const textEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Text);
2052
assert.ok(textEntry, 'Completed tool should also have the SDK result content');
2053
});
2054
2055
test('inner tool_start arriving BEFORE subagent_started routes to subagent (not parent)', () => {
2056
// Reproduces the regression where inner subagent tool calls show up
2057
// flat at the top level of the parent session because the SDK can
2058
// emit `tool_start` (with parentToolCallId) before `subagent_started`.
2059
setupSession();
2060
startTurn('turn-1');
2061
disposables.add(sideEffects.registerProgressListener(agent));
2062
2063
// 1. Parent tool starts (the `task` invocation).
2064
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } });
2065
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
2066
2067
// 2. Inner tool fires BEFORE subagent_started (race condition).
2068
agent.fireProgress({
2069
kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',
2070
action: {
2071
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
2072
toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', toolClientId: undefined,
2073
_meta: { toolKind: undefined, language: undefined },
2074
},
2075
});
2076
agent.fireProgress({
2077
kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',
2078
action: {
2079
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
2080
toolCallId: 'inner-tc-1', invocationMessage: 'Reading file...', toolInput: undefined,
2081
confirmed: ToolCallConfirmationReason.NotNeeded,
2082
},
2083
});
2084
2085
// 3. subagent_started arrives later.
2086
agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });
2087
2088
const subagentUri = buildSubagentSessionUri(sessionUri.toString(), 'tc-parent');
2089
const subState = stateManager.getSessionState(subagentUri);
2090
assert.ok(subState?.activeTurn, 'subagent session should exist');
2091
2092
const innerTool = subState!.activeTurn!.responseParts.find(
2093
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1'
2094
);
2095
assert.ok(innerTool, 'inner tool fired before subagent_started should still end up in the subagent session');
2096
2097
// Parent must NOT have the inner tool.
2098
const parentState = stateManager.getSessionState(sessionUri.toString());
2099
const parentInnerTool = parentState!.activeTurn!.responseParts.find(
2100
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1'
2101
);
2102
assert.strictEqual(parentInnerTool, undefined, 'inner tool must not leak into parent session');
2103
});
2104
2105
test('reads inside parent working directory are auto-approved for tools in subagent sessions', () => {
2106
// Subagent sessions don't carry their own workingDirectory or
2107
// autoApprove config. Without inheritance from the parent, every
2108
// tool call inside a subagent (even a read in the workspace) would
2109
// surface a confirmation dialog.
2110
setupSession(URI.file('/workspace').toString());
2111
startTurn('turn-1');
2112
disposables.add(sideEffects.registerProgressListener(agent));
2113
2114
// Parent task tool spawns a subagent.
2115
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } });
2116
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
2117
agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });
2118
2119
// Inner tool inside the subagent requests permission to read a file
2120
// inside the parent workspace.
2121
agent.fireProgress({
2122
kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',
2123
action: {
2124
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
2125
toolCallId: 'inner-read-1', toolName: 'read', displayName: 'Read', toolClientId: undefined,
2126
_meta: { toolKind: undefined, language: undefined },
2127
},
2128
});
2129
agent.fireProgress({
2130
kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',
2131
action: {
2132
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
2133
toolCallId: 'inner-read-1', invocationMessage: 'Read file', toolInput: undefined,
2134
confirmed: ToolCallConfirmationReason.NotNeeded,
2135
},
2136
});
2137
agent.fireProgress({
2138
kind: 'pending_confirmation', session: sessionUri,
2139
state: {
2140
status: ToolCallStatus.PendingConfirmation,
2141
toolCallId: 'inner-read-1', toolName: '', displayName: '',
2142
invocationMessage: 'Read src/app.ts', toolInput: undefined,
2143
confirmationTitle: undefined, edits: undefined,
2144
},
2145
permissionKind: 'read', permissionPath: '/workspace/src/app.ts',
2146
});
2147
2148
assert.deepStrictEqual(agent.respondToPermissionCalls, [
2149
{ requestId: 'inner-read-1', approved: true },
2150
]);
2151
});
2152
2153
test('session-level autoApprove on the parent is inherited by tools in subagent sessions', () => {
2154
setupSession(URI.file('/workspace').toString());
2155
startTurn('turn-1');
2156
disposables.add(sideEffects.registerProgressListener(agent));
2157
2158
// Set the parent session to "Bypass Approvals" via session config.
2159
const parentState = stateManager.getSessionState(sessionUri.toString());
2160
if (parentState) {
2161
parentState.config = {
2162
schema: {
2163
type: 'object',
2164
properties: {
2165
autoApprove: {
2166
type: 'string',
2167
title: 'Approvals',
2168
enum: ['default', 'autoApprove', 'autopilot'],
2169
default: 'default',
2170
sessionMutable: true,
2171
},
2172
},
2173
},
2174
values: { autoApprove: 'autoApprove' },
2175
};
2176
}
2177
2178
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } });
2179
agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
2180
agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });
2181
2182
// Inner write outside the workspace would normally NOT auto-approve,
2183
// but session-level autoApprove on the parent must apply.
2184
agent.fireProgress({
2185
kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',
2186
action: {
2187
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
2188
toolCallId: 'inner-write-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,
2189
_meta: { toolKind: undefined, language: undefined },
2190
},
2191
});
2192
agent.fireProgress({
2193
kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',
2194
action: {
2195
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
2196
toolCallId: 'inner-write-1', invocationMessage: 'Write file', toolInput: undefined,
2197
confirmed: ToolCallConfirmationReason.NotNeeded,
2198
},
2199
});
2200
agent.fireProgress({
2201
kind: 'pending_confirmation', session: sessionUri,
2202
state: {
2203
status: ToolCallStatus.PendingConfirmation,
2204
toolCallId: 'inner-write-1', toolName: '', displayName: '',
2205
invocationMessage: 'Write /tmp/foo', toolInput: undefined,
2206
confirmationTitle: undefined, edits: undefined,
2207
},
2208
permissionKind: 'write', permissionPath: '/tmp/foo',
2209
});
2210
2211
assert.deepStrictEqual(agent.respondToPermissionCalls, [
2212
{ requestId: 'inner-write-1', approved: true },
2213
]);
2214
});
2215
});
2216
2217
// ---- Session permissions ------------------------------------------------
2218
2219
suite('session permissions', () => {
2220
2221
test('tool_ready action includes confirmation options when confirmation is needed', () => {
2222
setupSession();
2223
startTurn('turn-1');
2224
disposables.add(sideEffects.registerProgressListener(agent));
2225
2226
agent.fireProgress({
2227
kind: 'action', session: sessionUri,
2228
action: {
2229
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
2230
toolCallId: 'tc-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined,
2231
_meta: { toolKind: undefined, language: undefined },
2232
},
2233
});
2234
agent.fireProgress({
2235
kind: 'action', session: sessionUri,
2236
action: {
2237
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
2238
toolCallId: 'tc-perm-1', invocationMessage: 'Running custom tool', toolInput: undefined,
2239
confirmed: ToolCallConfirmationReason.NotNeeded,
2240
},
2241
});
2242
2243
agent.fireProgress({
2244
kind: 'pending_confirmation', session: sessionUri,
2245
state: {
2246
status: ToolCallStatus.PendingConfirmation,
2247
toolCallId: 'tc-perm-1', toolName: '', displayName: '',
2248
invocationMessage: 'Run custom tool', toolInput: undefined,
2249
confirmationTitle: 'Run custom tool', edits: undefined,
2250
},
2251
permissionKind: 'custom-tool', permissionPath: undefined,
2252
});
2253
2254
const state = stateManager.getSessionState(sessionUri.toString());
2255
const tc = state!.activeTurn!.responseParts.find(
2256
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-perm-1'
2257
);
2258
assert.ok(tc && tc.kind === ResponsePartKind.ToolCall, 'tool call should exist');
2259
assert.strictEqual(tc.toolCall.status, ToolCallStatus.PendingConfirmation);
2260
assert.ok(Array.isArray(tc.toolCall.options), 'options should be an array');
2261
assert.deepStrictEqual(tc.toolCall.options!.map(o => o.id), ['allow-session', 'allow-once', 'skip']);
2262
});
2263
2264
test('SessionToolCallConfirmed with allow-session adds tool to session permissions', () => {
2265
setupSession();
2266
const state = stateManager.getSessionState(sessionUri.toString());
2267
if (state) {
2268
state.config = {
2269
schema: { type: 'object', properties: {} },
2270
values: {},
2271
};
2272
}
2273
startTurn('turn-1');
2274
disposables.add(sideEffects.registerProgressListener(agent));
2275
2276
agent.fireProgress({
2277
kind: 'action', session: sessionUri,
2278
action: {
2279
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
2280
toolCallId: 'tc-perm-2', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined,
2281
_meta: { toolKind: undefined, language: undefined },
2282
},
2283
});
2284
agent.fireProgress({
2285
kind: 'action', session: sessionUri,
2286
action: {
2287
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
2288
toolCallId: 'tc-perm-2', invocationMessage: 'Running custom tool', toolInput: undefined,
2289
confirmed: ToolCallConfirmationReason.NotNeeded,
2290
},
2291
});
2292
2293
agent.fireProgress({
2294
kind: 'pending_confirmation', session: sessionUri,
2295
state: {
2296
status: ToolCallStatus.PendingConfirmation,
2297
toolCallId: 'tc-perm-2', toolName: '', displayName: '',
2298
invocationMessage: 'Run custom tool', toolInput: undefined,
2299
confirmationTitle: 'Run custom tool', edits: undefined,
2300
},
2301
permissionKind: 'custom-tool', permissionPath: undefined,
2302
});
2303
2304
sideEffects.handleAction({
2305
type: ActionType.SessionToolCallConfirmed,
2306
session: sessionUri.toString(),
2307
turnId: 'turn-1',
2308
toolCallId: 'tc-perm-2',
2309
approved: true,
2310
confirmed: 'user-action' as const,
2311
selectedOptionId: 'allow-session',
2312
} as SessionAction);
2313
2314
const updatedState = stateManager.getSessionState(sessionUri.toString());
2315
assert.deepStrictEqual(
2316
updatedState!.config!.values.permissions,
2317
{ allow: ['CustomTool'], deny: [] },
2318
);
2319
});
2320
2321
test('subsequent tool_ready for same tool is auto-approved after allow-session permission', () => {
2322
setupSession();
2323
const state = stateManager.getSessionState(sessionUri.toString());
2324
if (state) {
2325
state.config = {
2326
schema: { type: 'object', properties: {} },
2327
values: { permissions: { allow: ['CustomTool'], deny: [] } },
2328
};
2329
}
2330
startTurn('turn-1');
2331
disposables.add(sideEffects.registerProgressListener(agent));
2332
2333
agent.fireProgress({
2334
kind: 'action', session: sessionUri,
2335
action: {
2336
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
2337
toolCallId: 'tc-perm-3', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined,
2338
_meta: { toolKind: undefined, language: undefined },
2339
},
2340
});
2341
agent.fireProgress({
2342
kind: 'action', session: sessionUri,
2343
action: {
2344
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
2345
toolCallId: 'tc-perm-3', invocationMessage: 'Running custom tool', toolInput: undefined,
2346
confirmed: ToolCallConfirmationReason.NotNeeded,
2347
},
2348
});
2349
2350
agent.fireProgress({
2351
kind: 'pending_confirmation', session: sessionUri,
2352
state: {
2353
status: ToolCallStatus.PendingConfirmation,
2354
toolCallId: 'tc-perm-3', toolName: '', displayName: '',
2355
invocationMessage: 'Run custom tool', toolInput: undefined,
2356
confirmationTitle: 'Run custom tool', edits: undefined,
2357
},
2358
permissionKind: 'custom-tool', permissionPath: undefined,
2359
});
2360
2361
assert.deepStrictEqual(agent.respondToPermissionCalls, [
2362
{ requestId: 'tc-perm-3', approved: true },
2363
]);
2364
});
2365
2366
test('subagent tool calls inherit parent session permissions', () => {
2367
setupSession();
2368
const state = stateManager.getSessionState(sessionUri.toString());
2369
if (state) {
2370
state.config = {
2371
schema: { type: 'object', properties: {} },
2372
values: { permissions: { allow: ['CustomTool'], deny: [] } },
2373
};
2374
}
2375
startTurn('turn-1');
2376
disposables.add(sideEffects.registerProgressListener(agent));
2377
2378
agent.fireProgress({
2379
kind: 'action', session: sessionUri,
2380
action: {
2381
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
2382
toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', toolClientId: undefined,
2383
_meta: { toolKind: undefined, language: undefined },
2384
},
2385
});
2386
agent.fireProgress({
2387
kind: 'action', session: sessionUri,
2388
action: {
2389
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
2390
toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined,
2391
confirmed: ToolCallConfirmationReason.NotNeeded,
2392
},
2393
});
2394
agent.fireProgress({
2395
kind: 'subagent_started', session: sessionUri,
2396
toolCallId: 'tc-parent',
2397
agentName: 'helper',
2398
agentDisplayName: 'Helper',
2399
agentDescription: 'Helps',
2400
});
2401
2402
agent.fireProgress({
2403
kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',
2404
action: {
2405
type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',
2406
toolCallId: 'inner-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined,
2407
_meta: { toolKind: undefined, language: undefined },
2408
},
2409
});
2410
agent.fireProgress({
2411
kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',
2412
action: {
2413
type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',
2414
toolCallId: 'inner-perm-1', invocationMessage: 'Running custom tool', toolInput: undefined,
2415
confirmed: ToolCallConfirmationReason.NotNeeded,
2416
},
2417
});
2418
2419
agent.fireProgress({
2420
kind: 'pending_confirmation', session: sessionUri,
2421
state: {
2422
status: ToolCallStatus.PendingConfirmation,
2423
toolCallId: 'inner-perm-1', toolName: '', displayName: '',
2424
invocationMessage: 'Run custom tool', toolInput: undefined,
2425
confirmationTitle: 'Run custom tool', edits: undefined,
2426
},
2427
permissionKind: 'custom-tool', permissionPath: undefined,
2428
});
2429
2430
assert.deepStrictEqual(agent.respondToPermissionCalls, [
2431
{ requestId: 'inner-perm-1', approved: true },
2432
]);
2433
});
2434
});
2435
2436
// ---- Session diff computation ----------------------------------------------
2437
2438
suite('session diff computation', () => {
2439
2440
test('git-driven path is preferred when a git service is provided and the working dir is a git work tree', async () => {
2441
const sessionDb = new SessionDatabase(':memory:');
2442
disposables.add(toDisposable(() => sessionDb.close()));
2443
const sessionDataService = createSessionDataService(sessionDb);
2444
const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService()));
2445
const localAgent = new MockAgent();
2446
disposables.add(toDisposable(() => localAgent.dispose()));
2447
2448
const gitDiffs = [{
2449
after: { uri: 'file:///wd/new.ts', content: { uri: 'file:///wd/new.ts' } },
2450
diff: { added: 1, removed: 0 },
2451
}];
2452
const computeCalls: { workingDirectory: string; sessionUri: string; baseBranch: string | undefined }[] = [];
2453
const stubGit = {
2454
computeSessionFileDiffs: async (wd: URI, opts: { sessionUri: string; baseBranch?: string }) => {
2455
computeCalls.push({ workingDirectory: wd.toString(), sessionUri: opts.sessionUri, baseBranch: opts.baseBranch });
2456
return gitDiffs;
2457
},
2458
} as unknown as import('../../node/agentHostGitService.js').IAgentHostGitService;
2459
2460
const localSideEffects = createTestSideEffects(disposables, localStateManager, {
2461
getAgent: () => localAgent,
2462
agents: observableValue<readonly IAgent[]>('agents', [localAgent]),
2463
sessionDataService,
2464
onTurnComplete: () => { },
2465
}, stubGit);
2466
2467
localStateManager.createSession({
2468
resource: sessionUri.toString(),
2469
provider: 'mock',
2470
title: 'Test',
2471
status: SessionStatus.Idle,
2472
createdAt: Date.now(),
2473
modifiedAt: Date.now(),
2474
workingDirectory: 'file:///wd',
2475
});
2476
await sessionDb.setMetadata('agentHost.diffBaseBranch', 'main');
2477
disposables.add(localSideEffects.registerProgressListener(localAgent));
2478
2479
const envelopes: ActionEnvelope[] = [];
2480
let resolveDiffs: (() => void) | undefined;
2481
const diffsEmitted = new Promise<void>(r => { resolveDiffs = r; });
2482
disposables.add(localStateManager.onDidEmitEnvelope(e => {
2483
envelopes.push(e);
2484
if (e.action.type === ActionType.SessionDiffsChanged) {
2485
resolveDiffs?.();
2486
}
2487
}));
2488
2489
// Trigger a turn-complete (which fires the immediate diff path).
2490
localSideEffects.handleAction({
2491
type: ActionType.SessionTurnStarted,
2492
session: sessionUri.toString(),
2493
turnId: 'turn-1',
2494
userMessage: { text: 'hi' },
2495
});
2496
localAgent.fireProgress({
2497
kind: 'action', session: URI.parse(sessionUri.toString()),
2498
action: { type: ActionType.SessionTurnComplete, session: sessionUri.toString(), turnId: 'turn-1' },
2499
});
2500
2501
// Wait deterministically for the SessionDiffsChanged envelope rather
2502
// than sleeping a fixed amount.
2503
await diffsEmitted;
2504
2505
assert.deepStrictEqual(computeCalls, [{ workingDirectory: 'file:///wd', sessionUri: sessionUri.toString(), baseBranch: 'main' }]);
2506
const diffsAction = envelopes.map(e => e.action).find(a => a.type === ActionType.SessionDiffsChanged);
2507
assert.ok(diffsAction, 'expected a SessionDiffsChanged action');
2508
assert.deepStrictEqual((diffsAction as { diffs: unknown }).diffs, gitDiffs);
2509
});
2510
2511
test('falls back to the edit-tracker aggregator when the git service returns undefined', async () => {
2512
const sessionDb = new SessionDatabase(':memory:');
2513
disposables.add(toDisposable(() => sessionDb.close()));
2514
const sessionDataService = createSessionDataService(sessionDb);
2515
const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService()));
2516
const localAgent = new MockAgent();
2517
disposables.add(toDisposable(() => localAgent.dispose()));
2518
2519
const stubGit = {
2520
computeSessionFileDiffs: async () => undefined,
2521
} as unknown as import('../../node/agentHostGitService.js').IAgentHostGitService;
2522
2523
const localSideEffects = createTestSideEffects(disposables, localStateManager, {
2524
getAgent: () => localAgent,
2525
agents: observableValue<readonly IAgent[]>('agents', [localAgent]),
2526
sessionDataService,
2527
onTurnComplete: () => { },
2528
}, stubGit);
2529
2530
localStateManager.createSession({
2531
resource: sessionUri.toString(),
2532
provider: 'mock',
2533
title: 'Test',
2534
status: SessionStatus.Idle,
2535
createdAt: Date.now(),
2536
modifiedAt: Date.now(),
2537
workingDirectory: 'file:///wd',
2538
});
2539
disposables.add(localSideEffects.registerProgressListener(localAgent));
2540
2541
const envelopes: ActionEnvelope[] = [];
2542
let resolveDiffs: (() => void) | undefined;
2543
const diffsEmitted = new Promise<void>(r => { resolveDiffs = r; });
2544
disposables.add(localStateManager.onDidEmitEnvelope(e => {
2545
envelopes.push(e);
2546
if (e.action.type === ActionType.SessionDiffsChanged) {
2547
resolveDiffs?.();
2548
}
2549
}));
2550
2551
localSideEffects.handleAction({
2552
type: ActionType.SessionTurnStarted,
2553
session: sessionUri.toString(),
2554
turnId: 'turn-1',
2555
userMessage: { text: 'hi' },
2556
});
2557
localAgent.fireProgress({
2558
kind: 'action', session: URI.parse(sessionUri.toString()),
2559
action: { type: ActionType.SessionTurnComplete, session: sessionUri.toString(), turnId: 'turn-1' },
2560
});
2561
2562
await diffsEmitted;
2563
2564
// With no recorded edits, the edit-tracker aggregator returns an empty array — the
2565
// important assertion is that we still produced a SessionDiffsChanged envelope, which
2566
// proves the fallback path executed without throwing.
2567
const diffsAction = envelopes.map(e => e.action).find(a => a.type === ActionType.SessionDiffsChanged);
2568
assert.ok(diffsAction, 'expected a SessionDiffsChanged action from the fallback path');
2569
assert.deepStrictEqual((diffsAction as { diffs: unknown[] }).diffs, []);
2570
});
2571
});
2572
});
2573
2574