Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/node/mockAgent.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 { timeout } from '../../../../base/common/async.js';
7
import { Emitter } from '../../../../base/common/event.js';
8
import { observableValue } from '../../../../base/common/observable.js';
9
import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js';
10
import { URI } from '../../../../base/common/uri.js';
11
import { type ISyncedCustomization } from '../../common/agentPluginManager.js';
12
import { AgentSession, type AgentProvider, type AgentSignal, type IAgent, type IAgentActionSignal, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentModelInfo, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js';
13
import { buildSubagentTurnsFromHistory, buildTurnsFromHistory, type IHistoryRecord } from './historyRecordFixtures.js';
14
import { ProtectedResourceMetadata, type ModelSelection } from '../../common/state/protocol/state.js';
15
import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
16
import { ActionType } from '../../common/state/sessionActions.js';
17
import { CustomizationStatus, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, parseSubagentSessionUri, type CustomizationRef, type PendingMessage, type SessionCustomization, type StringOrMarkdown, type ToolCallResult, type Turn } from '../../common/state/sessionState.js';
18
import { hasKey } from '../../../../base/common/types.js';
19
20
/** Well-known auto-generated title used by the 'with-title' prompt. */
21
export const MOCK_AUTO_TITLE = 'Automatically generated title';
22
23
function uriKey(session: URI): string {
24
// Build a stable key from raw URI fields without invoking `toString()`,
25
// which would mutate the URI's `_formatted` cache and break
26
// `assert.deepStrictEqual` comparisons in tests that capture the URI
27
// before it is observed elsewhere.
28
return `${session.scheme}://${session.authority}${session.path}${session.query ? '?' + session.query : ''}${session.fragment ? '#' + session.fragment : ''}`;
29
}
30
31
function mockProject(provider: AgentProvider) {
32
return { uri: URI.from({ scheme: 'mock-project', path: `/${provider}` }), displayName: `Agent ${provider}` };
33
}
34
35
/**
36
* General-purpose mock agent for unit tests. Tracks all method calls
37
* for assertion and exposes {@link fireProgress} to inject progress events.
38
*/
39
export class MockAgent implements IAgent {
40
private readonly _onDidSessionProgress = new Emitter<AgentSignal>();
41
readonly onDidSessionProgress = this._onDidSessionProgress.event;
42
private readonly _models = observableValue<readonly IAgentModelInfo[]>(this, []);
43
readonly models = this._models;
44
45
private readonly _sessions = new Map<string, URI>();
46
private _nextId = 1;
47
/** Active turn IDs per session, captured from sendMessage(). */
48
private readonly _activeTurnIds = new Map<string, string>();
49
50
51
readonly sendMessageCalls: { session: URI; prompt: string; attachments?: readonly IAgentAttachment[] }[] = [];
52
readonly setPendingMessagesCalls: { session: URI; steeringMessage: PendingMessage | undefined; queuedMessages: readonly PendingMessage[] }[] = [];
53
readonly disposeSessionCalls: URI[] = [];
54
readonly abortSessionCalls: URI[] = [];
55
readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = [];
56
readonly changeModelCalls: { session: URI; model: ModelSelection }[] = [];
57
readonly authenticateCalls: { resource: string; token: string }[] = [];
58
readonly setClientCustomizationsCalls: { clientId: string; customizations: CustomizationRef[] }[] = [];
59
readonly setCustomizationEnabledCalls: { uri: string; enabled: boolean }[] = [];
60
/** Configurable return value for getCustomizations. */
61
customizations: CustomizationRef[] = [];
62
private readonly _onDidCustomizationsChange = new Emitter<void>();
63
readonly onDidCustomizationsChange = this._onDidCustomizationsChange.event;
64
getSessionCustomizations?: (session: URI) => Promise<readonly SessionCustomization[]>;
65
66
/**
67
* Configurable session history. Tests construct {@link IHistoryRecord}
68
* entries (the agent-internal intermediate shape) and the mock converts
69
* them to {@link Turn}s on demand. Subagent URIs are routed to filtered
70
* subagent turns via {@link buildSubagentTurnsFromHistory}.
71
*/
72
sessionMessages: IHistoryRecord[] = [];
73
74
/** Optional overrides applied to session metadata from listSessions. */
75
sessionMetadataOverrides: Partial<Omit<IAgentSessionMetadata, 'session'>> = {};
76
77
constructor(readonly id: AgentProvider = 'mock') { }
78
79
getDescriptor(): IAgentDescriptor {
80
return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent` };
81
}
82
83
getProtectedResources(): ProtectedResourceMetadata[] {
84
if (this.id === 'copilot') {
85
return [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'], required: true }];
86
}
87
return [];
88
}
89
90
setModels(models: readonly IAgentModelInfo[]): void {
91
this._models.set(models, undefined);
92
}
93
94
async listSessions(): Promise<IAgentSessionMetadata[]> {
95
return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now(), project: mockProject(this.id), ...this.sessionMetadataOverrides }));
96
}
97
98
/** Optional override for the working directory returned by createSession. */
99
resolvedWorkingDirectory: URI | undefined;
100
101
async createSession(config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult> {
102
const session = config?.session ?? AgentSession.uri(this.id, `${this.id}-session-${this._nextId++}`);
103
const rawId = AgentSession.id(session);
104
this._sessions.set(rawId, session);
105
return { session, project: mockProject(this.id), workingDirectory: this.resolvedWorkingDirectory };
106
}
107
108
async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> {
109
return { schema: { type: 'object', properties: {} }, values: params.config ?? {} };
110
}
111
112
async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise<SessionConfigCompletionsResult> {
113
return { items: [] };
114
}
115
116
async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise<void> {
117
this.sendMessageCalls.push({ session, prompt, attachments });
118
if (turnId) {
119
this._activeTurnIds.set(uriKey(session), turnId);
120
}
121
}
122
123
setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[]): void {
124
this.setPendingMessagesCalls.push({ session, steeringMessage, queuedMessages });
125
}
126
127
async getSessionMessages(session: URI): Promise<readonly Turn[]> {
128
const subagentInfo = parseSubagentSessionUri(session.toString());
129
if (subagentInfo) {
130
return buildSubagentTurnsFromHistory(this.sessionMessages, subagentInfo.toolCallId, session.toString());
131
}
132
return buildTurnsFromHistory(this.sessionMessages);
133
}
134
135
async disposeSession(session: URI): Promise<void> {
136
this.disposeSessionCalls.push(session);
137
this._sessions.delete(AgentSession.id(session));
138
}
139
140
async abortSession(session: URI): Promise<void> {
141
this.abortSessionCalls.push(session);
142
}
143
144
respondToPermissionRequest(requestId: string, approved: boolean): void {
145
this.respondToPermissionCalls.push({ requestId, approved });
146
}
147
148
respondToUserInputRequest(): void {
149
// no-op for tests
150
}
151
152
async changeModel(session: URI, model: ModelSelection): Promise<void> {
153
this.changeModelCalls.push({ session, model });
154
}
155
156
async authenticate(resource: string, token: string): Promise<boolean> {
157
this.authenticateCalls.push({ resource, token });
158
return true;
159
}
160
161
getCustomizations(): CustomizationRef[] {
162
return this.customizations;
163
}
164
165
async setClientCustomizations(clientId: string, customizations: CustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise<ISyncedCustomization[]> {
166
this.setClientCustomizationsCalls.push({ clientId, customizations });
167
const results: ISyncedCustomization[] = customizations.map(c => ({
168
customization: {
169
customization: c,
170
enabled: true,
171
status: CustomizationStatus.Loaded,
172
},
173
}));
174
progress?.(results);
175
return results;
176
}
177
178
setCustomizationEnabled(uri: string, enabled: boolean): void {
179
this.setCustomizationEnabledCalls.push({ uri, enabled });
180
}
181
182
setClientTools(): void { }
183
184
onClientToolCallComplete(): void { }
185
186
async shutdown(): Promise<void> { }
187
188
/**
189
* Fires an {@link AgentSignal} on this agent.
190
*/
191
fireProgress(signal: AgentSignal): void {
192
this._onDidSessionProgress.fire(signal);
193
}
194
195
/**
196
* Looks up the active turn id captured from the most recent
197
* {@link sendMessage} call for a given session. Returns `undefined` if
198
* the session has no active turn yet (e.g. tests that fire progress
199
* without first calling sendMessage).
200
*/
201
getActiveTurnId(session: URI): string | undefined {
202
return this._activeTurnIds.get(uriKey(session));
203
}
204
205
fireCustomizationsChange(): void {
206
this._onDidCustomizationsChange.fire();
207
}
208
209
dispose(): void {
210
this._onDidSessionProgress.dispose();
211
this._onDidCustomizationsChange.dispose();
212
}
213
}
214
215
/**
216
* Well-known URI of a pre-existing session seeded in {@link ScriptedMockAgent}.
217
* This session appears in `listSessions()` and has message history via
218
* `getSessionMessages()`, but was never created through the server's
219
* `handleCreateSession`. It simulates a session from a previous server
220
* lifetime for testing the restore-on-subscribe path.
221
*/
222
export const PRE_EXISTING_SESSION_URI = AgentSession.uri('mock', 'pre-existing-session');
223
224
export class ScriptedMockAgent implements IAgent {
225
readonly id: AgentProvider = 'mock';
226
227
private readonly _onDidSessionProgress = new Emitter<AgentSignal>();
228
readonly onDidSessionProgress = this._onDidSessionProgress.event;
229
private readonly _models = observableValue<readonly IAgentModelInfo[]>(this, [{ provider: 'mock', id: 'mock-model', name: 'Mock Model', maxContextWindow: 128000, supportsVision: false }]);
230
readonly models = this._models;
231
232
private readonly _sessions = new Map<string, URI>();
233
private _nextId = 1;
234
235
/**
236
* Message history for the pre-existing session: a single user→assistant
237
* turn with a tool call.
238
*/
239
private readonly _preExistingMessages: IHistoryRecord[] = [
240
{ type: 'message', role: 'user', session: PRE_EXISTING_SESSION_URI, messageId: 'h-msg-1', content: 'What files are here?' },
241
{ type: 'tool_start', session: PRE_EXISTING_SESSION_URI, toolCallId: 'h-tc-1', toolName: 'list_files', displayName: 'List Files', invocationMessage: 'Listing files...' },
242
{ type: 'tool_complete', session: PRE_EXISTING_SESSION_URI, toolCallId: 'h-tc-1', result: { pastTenseMessage: 'Listed files', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true } satisfies ToolCallResult },
243
{ type: 'message', role: 'assistant', session: PRE_EXISTING_SESSION_URI, messageId: 'h-msg-2', content: 'Here are the files: file1.ts and file2.ts' },
244
];
245
246
// Track pending permission requests
247
private readonly _pendingPermissions = new Map<string, (approved: boolean) => void>();
248
// Track the active turn ID per session, captured from sendMessage().
249
private readonly _activeTurnIds = new Map<string, string>();
250
// Track pending abort callbacks for slow responses
251
private readonly _pendingAborts = new Map<string, () => void>();
252
253
constructor() {
254
// Seed the pre-existing session so it appears in listSessions()
255
this._sessions.set(AgentSession.id(PRE_EXISTING_SESSION_URI), PRE_EXISTING_SESSION_URI);
256
257
// Allow integration tests to seed additional pre-existing sessions across
258
// server restarts via env var. The value is a comma-separated list of
259
// session URIs (e.g. `mock://pre-1,mock://pre-2`).
260
const seeded = process.env['VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS'];
261
if (seeded) {
262
for (const raw of seeded.split(',')) {
263
const trimmed = raw.trim();
264
if (!trimmed) {
265
continue;
266
}
267
const uri = URI.parse(trimmed);
268
this._sessions.set(AgentSession.id(uri), uri);
269
}
270
}
271
}
272
273
getDescriptor(): IAgentDescriptor {
274
return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent' };
275
}
276
277
getProtectedResources(): IAuthorizationProtectedResourceMetadata[] {
278
return [];
279
}
280
281
async listSessions(): Promise<IAgentSessionMetadata[]> {
282
return [...this._sessions.values()].map(s => ({
283
session: s,
284
startTime: Date.now(),
285
modifiedTime: Date.now(),
286
project: mockProject(this.id),
287
summary: s.toString() === PRE_EXISTING_SESSION_URI.toString() ? 'Pre-existing session' : undefined,
288
}));
289
}
290
291
async createSession(config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult> {
292
const session = config?.session ?? AgentSession.uri('mock', `mock-session-${this._nextId++}`);
293
const rawId = AgentSession.id(session);
294
this._sessions.set(rawId, session);
295
return { session, project: mockProject(this.id) };
296
}
297
298
async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> {
299
const isolation = params.config?.isolation === 'folder' || params.config?.isolation === 'worktree' ? params.config.isolation : 'worktree';
300
const branch = isolation === 'worktree' && typeof params.config?.branch === 'string' ? params.config.branch : 'main';
301
return {
302
schema: {
303
type: 'object',
304
properties: {
305
isolation: {
306
type: 'string',
307
title: 'Isolation',
308
description: 'Where the mock agent should make changes',
309
enum: ['folder', 'worktree'],
310
enumLabels: ['Folder', 'Worktree'],
311
default: 'worktree',
312
},
313
branch: {
314
type: 'string',
315
title: 'Branch',
316
description: 'Base branch to work from',
317
enum: ['main'],
318
enumLabels: ['main'],
319
default: 'main',
320
enumDynamic: isolation === 'worktree',
321
readOnly: isolation === 'folder',
322
},
323
},
324
},
325
values: { isolation, branch },
326
};
327
}
328
329
async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<SessionConfigCompletionsResult> {
330
if (params.property !== 'branch') {
331
return { items: [] };
332
}
333
const query = params.query?.toLowerCase() ?? '';
334
const branches = ['main', 'feature/config', 'release'].filter(branch => branch.toLowerCase().includes(query));
335
return { items: branches.map(branch => ({ value: branch, label: branch })) };
336
}
337
338
async sendMessage(session: URI, prompt: string, _attachments?: IAgentAttachment[], turnId?: string): Promise<void> {
339
if (turnId) {
340
this._activeTurnIds.set(uriKey(session), turnId);
341
}
342
const { sessionStr, turnId: tid } = this._ctx(session);
343
switch (prompt) {
344
case 'hello':
345
this._fireSequence([
346
_markdown(session, sessionStr, tid, 'Hello, world!'),
347
_idle(session, sessionStr, tid),
348
]);
349
break;
350
351
case 'use-tool':
352
this._fireSequence([
353
..._toolStart(session, sessionStr, tid, 'tc-1', 'echo_tool', 'Echo Tool', 'Running echo tool...'),
354
_toolComplete(session, sessionStr, tid, 'tc-1', { pastTenseMessage: 'Ran echo tool', content: [{ type: ToolResultContentType.Text, text: 'echoed' }], success: true }),
355
_markdown(session, sessionStr, tid, 'Tool done.'),
356
_idle(session, sessionStr, tid),
357
]);
358
break;
359
360
case 'error':
361
this._fireSequence([
362
_error(session, sessionStr, tid, 'test_error', 'Something went wrong'),
363
]);
364
break;
365
366
case 'permission': {
367
// Fire tool_start to create the tool, then pending_confirmation to request confirmation
368
(async () => {
369
await timeout(10);
370
for (const s of _toolStart(session, sessionStr, tid, 'tc-perm-1', 'shell', 'Shell', 'Run a test command')) {
371
this._onDidSessionProgress.fire(s);
372
}
373
await timeout(5);
374
this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-perm-1', 'Run a test command', { toolInput: 'echo test', confirmationTitle: 'Run a test command' }));
375
})();
376
this._pendingPermissions.set('tc-perm-1', (approved) => {
377
if (approved) {
378
this._fireSequence([
379
_markdown(session, sessionStr, tid, 'Allowed.'),
380
_idle(session, sessionStr, tid),
381
]);
382
}
383
});
384
break;
385
}
386
387
case 'write-file': {
388
// Fire tool_start + pending_confirmation with write permission for a regular file (should be auto-approved)
389
(async () => {
390
await timeout(10);
391
for (const s of _toolStart(session, sessionStr, tid, 'tc-write-1', 'create', 'Create File', 'Create file')) {
392
this._onDidSessionProgress.fire(s);
393
}
394
await timeout(5);
395
this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-write-1', 'Write src/app.ts', { permissionKind: 'write', permissionPath: '/workspace/src/app.ts' }));
396
// Auto-approved writes resolve immediately — complete the tool and turn
397
await timeout(10);
398
this._fireSequence([
399
_toolComplete(session, sessionStr, tid, 'tc-write-1', { pastTenseMessage: 'Wrote file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }),
400
_idle(session, sessionStr, tid),
401
]);
402
})();
403
break;
404
}
405
406
case 'write-env': {
407
// Fire tool_start + pending_confirmation with write permission for .env (should be blocked)
408
(async () => {
409
await timeout(10);
410
for (const s of _toolStart(session, sessionStr, tid, 'tc-write-env-1', 'create', 'Create File', 'Create file')) {
411
this._onDidSessionProgress.fire(s);
412
}
413
await timeout(5);
414
this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-write-env-1', 'Write .env', { permissionKind: 'write', permissionPath: '/workspace/.env', confirmationTitle: 'Write .env' }));
415
})();
416
this._pendingPermissions.set('tc-write-env-1', (approved) => {
417
if (approved) {
418
this._fireSequence([
419
_toolComplete(session, sessionStr, tid, 'tc-write-env-1', { pastTenseMessage: 'Wrote .env', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }),
420
_idle(session, sessionStr, tid),
421
]);
422
}
423
});
424
break;
425
}
426
427
case 'run-safe-command': {
428
// Fire tool_start + pending_confirmation with shell permission for an allowed command (should be auto-approved)
429
(async () => {
430
await timeout(10);
431
for (const s of _toolStart(session, sessionStr, tid, 'tc-shell-1', 'bash', 'Run Command', 'Run command')) {
432
this._onDidSessionProgress.fire(s);
433
}
434
await timeout(5);
435
this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-shell-1', 'ls -la', { permissionKind: 'shell', toolInput: 'ls -la' }));
436
// Auto-approved shell commands resolve immediately
437
await timeout(10);
438
this._fireSequence([
439
_toolComplete(session, sessionStr, tid, 'tc-shell-1', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true }),
440
_idle(session, sessionStr, tid),
441
]);
442
})();
443
break;
444
}
445
446
case 'run-dangerous-command': {
447
// Fire tool_start + pending_confirmation with shell permission for a denied command (should require confirmation)
448
(async () => {
449
await timeout(10);
450
for (const s of _toolStart(session, sessionStr, tid, 'tc-shell-deny-1', 'bash', 'Run Command', 'Run command')) {
451
this._onDidSessionProgress.fire(s);
452
}
453
await timeout(5);
454
this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-shell-deny-1', 'rm -rf /', { permissionKind: 'shell', toolInput: 'rm -rf /', confirmationTitle: 'Run in terminal' }));
455
})();
456
this._pendingPermissions.set('tc-shell-deny-1', (approved) => {
457
if (approved) {
458
this._fireSequence([
459
_toolComplete(session, sessionStr, tid, 'tc-shell-deny-1', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: '' }], success: true }),
460
_idle(session, sessionStr, tid),
461
]);
462
}
463
});
464
break;
465
}
466
467
case 'with-usage':
468
this._fireSequence([
469
_markdown(session, sessionStr, tid, 'Usage response.'),
470
_usage(session, sessionStr, tid, { inputTokens: 100, outputTokens: 50, model: 'mock-model' }),
471
_idle(session, sessionStr, tid),
472
]);
473
break;
474
475
case 'with-reasoning': {
476
const initialReasoning = _reasoning(session, sessionStr, tid, 'Let me think');
477
const partId = initialReasoning.action.type === ActionType.SessionResponsePart
478
&& hasKey(initialReasoning.action.part, { id: true })
479
? initialReasoning.action.part.id
480
: '';
481
this._fireSequence([
482
initialReasoning,
483
_action(session, {
484
type: ActionType.SessionReasoning,
485
session: sessionStr,
486
turnId: tid,
487
partId,
488
content: ' about this...',
489
}),
490
_markdown(session, sessionStr, tid, 'Reasoned response.'),
491
_idle(session, sessionStr, tid),
492
]);
493
break;
494
}
495
496
case 'with-title':
497
this._fireSequence([
498
_markdown(session, sessionStr, tid, 'Title response.'),
499
_titleChanged(session, sessionStr, MOCK_AUTO_TITLE),
500
_idle(session, sessionStr, tid),
501
]);
502
break;
503
504
case 'slow': {
505
// Slow response for cancel testing — fires delta after a long delay
506
const timer = setTimeout(() => {
507
const ctx = this._ctx(session);
508
this._fireSequence([
509
_markdown(session, ctx.sessionStr, ctx.turnId, 'Slow response.'),
510
_idle(session, ctx.sessionStr, ctx.turnId),
511
]);
512
}, 5000);
513
this._pendingAborts.set(session.toString(), () => clearTimeout(timer));
514
break;
515
}
516
517
case 'client-tool': {
518
// Fires tool_start with toolClientId followed by pending_confirmation
519
// (without confirmationTitle) to simulate a client-provided tool
520
// that is ready for execution. The real SDK handler fires
521
// tool_ready once its deferred is in place.
522
(async () => {
523
await timeout(10);
524
// Client tools don't get auto-ready — toolStart with toolClientId only emits tool_start
525
this._onDidSessionProgress.fire(_action(session, {
526
type: ActionType.SessionToolCallStart,
527
session: sessionStr,
528
turnId: tid,
529
toolCallId: 'tc-client-1',
530
toolName: 'runTests',
531
displayName: 'Run Tests',
532
toolClientId: 'test-client-tool',
533
}));
534
await timeout(5);
535
this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-client-1', 'Running tests...', { toolInput: '{}' }));
536
})();
537
// The tool stays pending — the client is responsible for dispatching toolCallComplete.
538
// Once complete, fire a response delta and idle.
539
this._pendingPermissions.set('tc-client-1', () => {
540
this._fireSequence([
541
_markdown(session, sessionStr, tid, 'Client tool done.'),
542
_idle(session, sessionStr, tid),
543
]);
544
});
545
break;
546
}
547
548
case 'client-tool-with-permission': {
549
// Fires tool_start with toolClientId followed by a permission request.
550
(async () => {
551
await timeout(10);
552
this._onDidSessionProgress.fire(_action(session, {
553
type: ActionType.SessionToolCallStart,
554
session: sessionStr,
555
turnId: tid,
556
toolCallId: 'tc-client-perm-1',
557
toolName: 'runTests',
558
displayName: 'Run Tests',
559
toolClientId: 'test-client-tool',
560
}));
561
await timeout(5);
562
this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-client-perm-1', 'Run tests on project', { confirmationTitle: 'Allow Run Tests?' }));
563
})();
564
this._pendingPermissions.set('tc-client-perm-1', (approved) => {
565
if (approved) {
566
this._fireSequence([
567
_toolComplete(session, sessionStr, tid, 'tc-client-perm-1', { pastTenseMessage: 'Ran tests', content: [{ type: ToolResultContentType.Text, text: 'all passed' }], success: true }),
568
_markdown(session, sessionStr, tid, 'Permission granted, tool done.'),
569
_idle(session, sessionStr, tid),
570
]);
571
}
572
});
573
break;
574
}
575
576
case 'subagent': {
577
// Spawns a subagent: parent `task` tool starts (emits start +
578
// auto-ready as a pair), then `subagent_started` creates the
579
// child session, then an inner tool runs in the child session
580
// (routed via `parentToolCallId`).
581
this._fireSequence([
582
..._toolStart(session, sessionStr, tid, 'tc-task-1', 'task', 'Task', 'Spawning subagent', { toolKind: 'subagent', subagentAgentName: 'explore', subagentDescription: 'Explore' }),
583
{ kind: 'subagent_started', session, toolCallId: 'tc-task-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Exploration helper' },
584
..._toolStart(session, sessionStr, tid, 'tc-inner-1', 'echo_tool', 'Echo Tool', 'Inner tool running...', { parentToolCallId: 'tc-task-1' }),
585
_toolComplete(session, sessionStr, tid, 'tc-inner-1', { pastTenseMessage: 'Ran inner tool', content: [{ type: ToolResultContentType.Text, text: 'inner-ok' }], success: true }, 'tc-task-1'),
586
_toolComplete(session, sessionStr, tid, 'tc-task-1', { pastTenseMessage: 'Subagent done', content: [{ type: ToolResultContentType.Text, text: 'task-ok' }], success: true }),
587
_markdown(session, sessionStr, tid, 'Subagent finished.'),
588
_idle(session, sessionStr, tid),
589
]);
590
break;
591
}
592
593
default:
594
if (prompt.startsWith('terminal-edit:')) {
595
// Test prompt: simulate a terminal command that edits a file on disk
596
// without emitting any ToolResultFileEditContent. The test relies on the
597
// git-driven diff path to pick this up. Format: `terminal-edit:<absPath>`.
598
const filePath = prompt.slice('terminal-edit:'.length);
599
void (async () => {
600
for (const s of _toolStart(session, sessionStr, tid, 'tc-term-edit-1', 'bash', 'Run Command', 'Edit file via shell')) {
601
this._onDidSessionProgress.fire(s);
602
}
603
const fs = await import('fs/promises');
604
await fs.writeFile(filePath, 'edited-from-terminal\n');
605
this._fireSequence([
606
_toolComplete(session, sessionStr, tid, 'tc-term-edit-1', { pastTenseMessage: 'Edited file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }),
607
_idle(session, sessionStr, tid),
608
]);
609
})().catch(err => {
610
// Surface failures deterministically — an unhandled rejection
611
// would make the test suite flaky.
612
this._fireSequence([
613
_markdown(session, sessionStr, tid, 'terminal-edit failed: ' + (err instanceof Error ? err.message : String(err))),
614
_idle(session, sessionStr, tid),
615
]);
616
});
617
break;
618
}
619
this._fireSequence([
620
_markdown(session, sessionStr, tid, 'Unknown prompt: ' + prompt),
621
_idle(session, sessionStr, tid),
622
]);
623
break;
624
}
625
}
626
627
setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void {
628
// When steering is set, consume it on the next tick
629
if (steeringMessage) {
630
timeout(20).then(() => {
631
this._onDidSessionProgress.fire({ kind: 'steering_consumed', session, id: steeringMessage.id });
632
});
633
}
634
}
635
636
async setClientCustomizations() {
637
return [];
638
}
639
640
setCustomizationEnabled() {
641
642
}
643
644
setClientTools(): void { }
645
646
private didCompleteToolCalls = new Set<string>();
647
648
onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void {
649
const key = `${session.toString()}:${toolCallId}`;
650
if (this.didCompleteToolCalls.has(key)) {
651
return;
652
}
653
this.didCompleteToolCalls.add(key);
654
// Fire tool_complete action signal and resolve any pending callback.
655
const { sessionStr, turnId } = this._ctx(session);
656
this._onDidSessionProgress.fire(_toolComplete(session, sessionStr, turnId, toolCallId, result));
657
const callback = this._pendingPermissions.get(toolCallId);
658
if (callback) {
659
this._pendingPermissions.delete(toolCallId);
660
callback(true);
661
}
662
}
663
664
async getSessionMessages(session: URI): Promise<readonly Turn[]> {
665
const subagentInfo = parseSubagentSessionUri(session.toString());
666
if (subagentInfo) {
667
return buildSubagentTurnsFromHistory(this._preExistingMessages, subagentInfo.toolCallId, session.toString());
668
}
669
if (session.toString() === PRE_EXISTING_SESSION_URI.toString()) {
670
return buildTurnsFromHistory(this._preExistingMessages);
671
}
672
return [];
673
}
674
675
async disposeSession(session: URI): Promise<void> {
676
this._sessions.delete(AgentSession.id(session));
677
}
678
679
async abortSession(session: URI): Promise<void> {
680
const callback = this._pendingAborts.get(session.toString());
681
if (callback) {
682
this._pendingAborts.delete(session.toString());
683
callback();
684
}
685
}
686
687
async changeModel(_session: URI, _model: ModelSelection): Promise<void> {
688
// Mock agent doesn't track model state
689
}
690
691
async truncateSession(_session: URI, _turnId?: string): Promise<void> {
692
// Mock agent accepts truncation without side effects
693
}
694
695
respondToPermissionRequest(toolCallId: string, approved: boolean): void {
696
const callback = this._pendingPermissions.get(toolCallId);
697
if (callback) {
698
this._pendingPermissions.delete(toolCallId);
699
callback(approved);
700
}
701
}
702
703
respondToUserInputRequest(): void {
704
// no-op for tests
705
}
706
707
async authenticate(_resource: string, _token: string): Promise<boolean> {
708
return true;
709
}
710
711
async shutdown(): Promise<void> { }
712
713
dispose(): void {
714
this._onDidSessionProgress.dispose();
715
}
716
717
/**
718
* Fires a sequence of {@link AgentSignal}s with staggered 10 ms delays
719
* so the state manager processes them in order.
720
*/
721
private _fireSequence(signals: AgentSignal[]): void {
722
let delay = 0;
723
for (const signal of signals) {
724
delay += 10;
725
setTimeout(() => this._onDidSessionProgress.fire(signal), delay);
726
}
727
}
728
729
/** Builds the session-string + turnId context for signal construction. */
730
private _ctx(session: URI): { sessionStr: string; turnId: string } {
731
return {
732
sessionStr: session.toString(),
733
turnId: this._activeTurnIds.get(uriKey(session)) ?? 'mock-turn',
734
};
735
}
736
}
737
738
// =============================================================================
739
// Test-event helpers
740
// =============================================================================
741
742
// =============================================================================
743
// Signal factory helpers
744
// =============================================================================
745
746
let _mockPartIdCounter = 0;
747
748
/** Wraps a session action into an {@link IAgentActionSignal}. */
749
function _action(session: URI, action: import('../../common/state/sessionActions.js').SessionAction, parentToolCallId?: string): IAgentActionSignal {
750
return { kind: 'action', session, action, parentToolCallId };
751
}
752
753
/** Creates a markdown {@link ResponsePartKind.Markdown} response part signal. */
754
function _markdown(session: URI, sessionStr: string, turnId: string, content: string, parentToolCallId?: string): IAgentActionSignal {
755
return _action(session, {
756
type: ActionType.SessionResponsePart,
757
session: sessionStr,
758
turnId,
759
part: { kind: ResponsePartKind.Markdown, id: `mock-md-${++_mockPartIdCounter}`, content },
760
}, parentToolCallId);
761
}
762
763
/** Creates a reasoning {@link ResponsePartKind.Reasoning} response part signal. */
764
function _reasoning(session: URI, sessionStr: string, turnId: string, content: string): IAgentActionSignal {
765
return _action(session, {
766
type: ActionType.SessionResponsePart,
767
session: sessionStr,
768
turnId,
769
part: { kind: ResponsePartKind.Reasoning, id: `mock-rs-${++_mockPartIdCounter}`, content },
770
});
771
}
772
773
/** Creates a {@link ActionType.SessionTurnComplete} signal. */
774
function _idle(session: URI, sessionStr: string, turnId: string): IAgentActionSignal {
775
return _action(session, { type: ActionType.SessionTurnComplete, session: sessionStr, turnId });
776
}
777
778
/** Creates a {@link ActionType.SessionError} signal. */
779
function _error(session: URI, sessionStr: string, turnId: string, errorType: string, message: string, stack?: string): IAgentActionSignal {
780
return _action(session, { type: ActionType.SessionError, session: sessionStr, turnId, error: { errorType, message, stack } });
781
}
782
783
/** Creates a {@link ActionType.SessionTitleChanged} signal. */
784
function _titleChanged(session: URI, sessionStr: string, title: string): IAgentActionSignal {
785
return _action(session, { type: ActionType.SessionTitleChanged, session: sessionStr, title });
786
}
787
788
/** Creates a {@link ActionType.SessionUsage} signal. */
789
function _usage(session: URI, sessionStr: string, turnId: string, usage: { inputTokens?: number; outputTokens?: number; model?: string; cacheReadTokens?: number }): IAgentActionSignal {
790
return _action(session, { type: ActionType.SessionUsage, session: sessionStr, turnId, usage });
791
}
792
793
/**
794
* Creates tool-start signals: a {@link ActionType.SessionToolCallStart} and,
795
* for non-client tools, an auto-ready {@link ActionType.SessionToolCallReady}.
796
*/
797
function _toolStart(session: URI, sessionStr: string, turnId: string, toolCallId: string, toolName: string, displayName: string, invocationMessage: StringOrMarkdown, opts?: {
798
toolInput?: string;
799
toolKind?: string;
800
toolClientId?: string;
801
subagentAgentName?: string;
802
subagentDescription?: string;
803
parentToolCallId?: string;
804
}): IAgentActionSignal[] {
805
const meta: Record<string, unknown> = {};
806
if (opts?.toolKind) {
807
meta.toolKind = opts.toolKind;
808
}
809
if (opts?.subagentAgentName) {
810
meta.subagentAgentName = opts.subagentAgentName;
811
}
812
if (opts?.subagentDescription) {
813
meta.subagentDescription = opts.subagentDescription;
814
}
815
const signals: IAgentActionSignal[] = [_action(session, {
816
type: ActionType.SessionToolCallStart,
817
session: sessionStr,
818
turnId,
819
toolCallId,
820
toolName,
821
displayName,
822
toolClientId: opts?.toolClientId,
823
_meta: Object.keys(meta).length ? meta : undefined,
824
}, opts?.parentToolCallId)];
825
if (!opts?.toolClientId) {
826
signals.push(_action(session, {
827
type: ActionType.SessionToolCallReady,
828
session: sessionStr,
829
turnId,
830
toolCallId,
831
invocationMessage,
832
toolInput: opts?.toolInput,
833
confirmed: ToolCallConfirmationReason.NotNeeded,
834
}, opts?.parentToolCallId));
835
}
836
return signals;
837
}
838
839
/** Creates a {@link ActionType.SessionToolCallComplete} signal. */
840
function _toolComplete(session: URI, sessionStr: string, turnId: string, toolCallId: string, result: ToolCallResult, parentToolCallId?: string): IAgentActionSignal {
841
return _action(session, { type: ActionType.SessionToolCallComplete, session: sessionStr, turnId, toolCallId, result }, parentToolCallId);
842
}
843
844
/** Creates a {@link IAgentToolPendingConfirmationSignal}. */
845
function _pendingConfirmation(session: URI, toolCallId: string, invocationMessage: StringOrMarkdown, opts?: {
846
toolInput?: string;
847
confirmationTitle?: StringOrMarkdown;
848
permissionKind?: IAgentToolPendingConfirmationSignal['permissionKind'];
849
permissionPath?: IAgentToolPendingConfirmationSignal['permissionPath'];
850
}): IAgentToolPendingConfirmationSignal {
851
return {
852
kind: 'pending_confirmation',
853
session,
854
state: {
855
status: ToolCallStatus.PendingConfirmation,
856
toolCallId,
857
toolName: '',
858
displayName: '',
859
invocationMessage,
860
toolInput: opts?.toolInput,
861
confirmationTitle: opts?.confirmationTitle,
862
},
863
permissionKind: opts?.permissionKind,
864
permissionPath: opts?.permissionPath,
865
};
866
}
867
868