Path: blob/main/src/vs/platform/agentHost/test/node/mockAgent.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { timeout } from '../../../../base/common/async.js';6import { Emitter } from '../../../../base/common/event.js';7import { observableValue } from '../../../../base/common/observable.js';8import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js';9import { URI } from '../../../../base/common/uri.js';10import { type ISyncedCustomization } from '../../common/agentPluginManager.js';11import { 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';12import { buildSubagentTurnsFromHistory, buildTurnsFromHistory, type IHistoryRecord } from './historyRecordFixtures.js';13import { ProtectedResourceMetadata, type ModelSelection } from '../../common/state/protocol/state.js';14import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';15import { ActionType } from '../../common/state/sessionActions.js';16import { CustomizationStatus, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, parseSubagentSessionUri, type CustomizationRef, type PendingMessage, type SessionCustomization, type StringOrMarkdown, type ToolCallResult, type Turn } from '../../common/state/sessionState.js';17import { hasKey } from '../../../../base/common/types.js';1819/** Well-known auto-generated title used by the 'with-title' prompt. */20export const MOCK_AUTO_TITLE = 'Automatically generated title';2122function uriKey(session: URI): string {23// Build a stable key from raw URI fields without invoking `toString()`,24// which would mutate the URI's `_formatted` cache and break25// `assert.deepStrictEqual` comparisons in tests that capture the URI26// before it is observed elsewhere.27return `${session.scheme}://${session.authority}${session.path}${session.query ? '?' + session.query : ''}${session.fragment ? '#' + session.fragment : ''}`;28}2930function mockProject(provider: AgentProvider) {31return { uri: URI.from({ scheme: 'mock-project', path: `/${provider}` }), displayName: `Agent ${provider}` };32}3334/**35* General-purpose mock agent for unit tests. Tracks all method calls36* for assertion and exposes {@link fireProgress} to inject progress events.37*/38export class MockAgent implements IAgent {39private readonly _onDidSessionProgress = new Emitter<AgentSignal>();40readonly onDidSessionProgress = this._onDidSessionProgress.event;41private readonly _models = observableValue<readonly IAgentModelInfo[]>(this, []);42readonly models = this._models;4344private readonly _sessions = new Map<string, URI>();45private _nextId = 1;46/** Active turn IDs per session, captured from sendMessage(). */47private readonly _activeTurnIds = new Map<string, string>();484950readonly sendMessageCalls: { session: URI; prompt: string; attachments?: readonly IAgentAttachment[] }[] = [];51readonly setPendingMessagesCalls: { session: URI; steeringMessage: PendingMessage | undefined; queuedMessages: readonly PendingMessage[] }[] = [];52readonly disposeSessionCalls: URI[] = [];53readonly abortSessionCalls: URI[] = [];54readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = [];55readonly changeModelCalls: { session: URI; model: ModelSelection }[] = [];56readonly authenticateCalls: { resource: string; token: string }[] = [];57readonly setClientCustomizationsCalls: { clientId: string; customizations: CustomizationRef[] }[] = [];58readonly setCustomizationEnabledCalls: { uri: string; enabled: boolean }[] = [];59/** Configurable return value for getCustomizations. */60customizations: CustomizationRef[] = [];61private readonly _onDidCustomizationsChange = new Emitter<void>();62readonly onDidCustomizationsChange = this._onDidCustomizationsChange.event;63getSessionCustomizations?: (session: URI) => Promise<readonly SessionCustomization[]>;6465/**66* Configurable session history. Tests construct {@link IHistoryRecord}67* entries (the agent-internal intermediate shape) and the mock converts68* them to {@link Turn}s on demand. Subagent URIs are routed to filtered69* subagent turns via {@link buildSubagentTurnsFromHistory}.70*/71sessionMessages: IHistoryRecord[] = [];7273/** Optional overrides applied to session metadata from listSessions. */74sessionMetadataOverrides: Partial<Omit<IAgentSessionMetadata, 'session'>> = {};7576constructor(readonly id: AgentProvider = 'mock') { }7778getDescriptor(): IAgentDescriptor {79return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent` };80}8182getProtectedResources(): ProtectedResourceMetadata[] {83if (this.id === 'copilot') {84return [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'], required: true }];85}86return [];87}8889setModels(models: readonly IAgentModelInfo[]): void {90this._models.set(models, undefined);91}9293async listSessions(): Promise<IAgentSessionMetadata[]> {94return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now(), project: mockProject(this.id), ...this.sessionMetadataOverrides }));95}9697/** Optional override for the working directory returned by createSession. */98resolvedWorkingDirectory: URI | undefined;99100async createSession(config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult> {101const session = config?.session ?? AgentSession.uri(this.id, `${this.id}-session-${this._nextId++}`);102const rawId = AgentSession.id(session);103this._sessions.set(rawId, session);104return { session, project: mockProject(this.id), workingDirectory: this.resolvedWorkingDirectory };105}106107async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> {108return { schema: { type: 'object', properties: {} }, values: params.config ?? {} };109}110111async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise<SessionConfigCompletionsResult> {112return { items: [] };113}114115async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise<void> {116this.sendMessageCalls.push({ session, prompt, attachments });117if (turnId) {118this._activeTurnIds.set(uriKey(session), turnId);119}120}121122setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[]): void {123this.setPendingMessagesCalls.push({ session, steeringMessage, queuedMessages });124}125126async getSessionMessages(session: URI): Promise<readonly Turn[]> {127const subagentInfo = parseSubagentSessionUri(session.toString());128if (subagentInfo) {129return buildSubagentTurnsFromHistory(this.sessionMessages, subagentInfo.toolCallId, session.toString());130}131return buildTurnsFromHistory(this.sessionMessages);132}133134async disposeSession(session: URI): Promise<void> {135this.disposeSessionCalls.push(session);136this._sessions.delete(AgentSession.id(session));137}138139async abortSession(session: URI): Promise<void> {140this.abortSessionCalls.push(session);141}142143respondToPermissionRequest(requestId: string, approved: boolean): void {144this.respondToPermissionCalls.push({ requestId, approved });145}146147respondToUserInputRequest(): void {148// no-op for tests149}150151async changeModel(session: URI, model: ModelSelection): Promise<void> {152this.changeModelCalls.push({ session, model });153}154155async authenticate(resource: string, token: string): Promise<boolean> {156this.authenticateCalls.push({ resource, token });157return true;158}159160getCustomizations(): CustomizationRef[] {161return this.customizations;162}163164async setClientCustomizations(clientId: string, customizations: CustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise<ISyncedCustomization[]> {165this.setClientCustomizationsCalls.push({ clientId, customizations });166const results: ISyncedCustomization[] = customizations.map(c => ({167customization: {168customization: c,169enabled: true,170status: CustomizationStatus.Loaded,171},172}));173progress?.(results);174return results;175}176177setCustomizationEnabled(uri: string, enabled: boolean): void {178this.setCustomizationEnabledCalls.push({ uri, enabled });179}180181setClientTools(): void { }182183onClientToolCallComplete(): void { }184185async shutdown(): Promise<void> { }186187/**188* Fires an {@link AgentSignal} on this agent.189*/190fireProgress(signal: AgentSignal): void {191this._onDidSessionProgress.fire(signal);192}193194/**195* Looks up the active turn id captured from the most recent196* {@link sendMessage} call for a given session. Returns `undefined` if197* the session has no active turn yet (e.g. tests that fire progress198* without first calling sendMessage).199*/200getActiveTurnId(session: URI): string | undefined {201return this._activeTurnIds.get(uriKey(session));202}203204fireCustomizationsChange(): void {205this._onDidCustomizationsChange.fire();206}207208dispose(): void {209this._onDidSessionProgress.dispose();210this._onDidCustomizationsChange.dispose();211}212}213214/**215* Well-known URI of a pre-existing session seeded in {@link ScriptedMockAgent}.216* This session appears in `listSessions()` and has message history via217* `getSessionMessages()`, but was never created through the server's218* `handleCreateSession`. It simulates a session from a previous server219* lifetime for testing the restore-on-subscribe path.220*/221export const PRE_EXISTING_SESSION_URI = AgentSession.uri('mock', 'pre-existing-session');222223export class ScriptedMockAgent implements IAgent {224readonly id: AgentProvider = 'mock';225226private readonly _onDidSessionProgress = new Emitter<AgentSignal>();227readonly onDidSessionProgress = this._onDidSessionProgress.event;228private readonly _models = observableValue<readonly IAgentModelInfo[]>(this, [{ provider: 'mock', id: 'mock-model', name: 'Mock Model', maxContextWindow: 128000, supportsVision: false }]);229readonly models = this._models;230231private readonly _sessions = new Map<string, URI>();232private _nextId = 1;233234/**235* Message history for the pre-existing session: a single user→assistant236* turn with a tool call.237*/238private readonly _preExistingMessages: IHistoryRecord[] = [239{ type: 'message', role: 'user', session: PRE_EXISTING_SESSION_URI, messageId: 'h-msg-1', content: 'What files are here?' },240{ type: 'tool_start', session: PRE_EXISTING_SESSION_URI, toolCallId: 'h-tc-1', toolName: 'list_files', displayName: 'List Files', invocationMessage: 'Listing files...' },241{ 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 },242{ type: 'message', role: 'assistant', session: PRE_EXISTING_SESSION_URI, messageId: 'h-msg-2', content: 'Here are the files: file1.ts and file2.ts' },243];244245// Track pending permission requests246private readonly _pendingPermissions = new Map<string, (approved: boolean) => void>();247// Track the active turn ID per session, captured from sendMessage().248private readonly _activeTurnIds = new Map<string, string>();249// Track pending abort callbacks for slow responses250private readonly _pendingAborts = new Map<string, () => void>();251252constructor() {253// Seed the pre-existing session so it appears in listSessions()254this._sessions.set(AgentSession.id(PRE_EXISTING_SESSION_URI), PRE_EXISTING_SESSION_URI);255256// Allow integration tests to seed additional pre-existing sessions across257// server restarts via env var. The value is a comma-separated list of258// session URIs (e.g. `mock://pre-1,mock://pre-2`).259const seeded = process.env['VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS'];260if (seeded) {261for (const raw of seeded.split(',')) {262const trimmed = raw.trim();263if (!trimmed) {264continue;265}266const uri = URI.parse(trimmed);267this._sessions.set(AgentSession.id(uri), uri);268}269}270}271272getDescriptor(): IAgentDescriptor {273return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent' };274}275276getProtectedResources(): IAuthorizationProtectedResourceMetadata[] {277return [];278}279280async listSessions(): Promise<IAgentSessionMetadata[]> {281return [...this._sessions.values()].map(s => ({282session: s,283startTime: Date.now(),284modifiedTime: Date.now(),285project: mockProject(this.id),286summary: s.toString() === PRE_EXISTING_SESSION_URI.toString() ? 'Pre-existing session' : undefined,287}));288}289290async createSession(config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult> {291const session = config?.session ?? AgentSession.uri('mock', `mock-session-${this._nextId++}`);292const rawId = AgentSession.id(session);293this._sessions.set(rawId, session);294return { session, project: mockProject(this.id) };295}296297async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> {298const isolation = params.config?.isolation === 'folder' || params.config?.isolation === 'worktree' ? params.config.isolation : 'worktree';299const branch = isolation === 'worktree' && typeof params.config?.branch === 'string' ? params.config.branch : 'main';300return {301schema: {302type: 'object',303properties: {304isolation: {305type: 'string',306title: 'Isolation',307description: 'Where the mock agent should make changes',308enum: ['folder', 'worktree'],309enumLabels: ['Folder', 'Worktree'],310default: 'worktree',311},312branch: {313type: 'string',314title: 'Branch',315description: 'Base branch to work from',316enum: ['main'],317enumLabels: ['main'],318default: 'main',319enumDynamic: isolation === 'worktree',320readOnly: isolation === 'folder',321},322},323},324values: { isolation, branch },325};326}327328async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<SessionConfigCompletionsResult> {329if (params.property !== 'branch') {330return { items: [] };331}332const query = params.query?.toLowerCase() ?? '';333const branches = ['main', 'feature/config', 'release'].filter(branch => branch.toLowerCase().includes(query));334return { items: branches.map(branch => ({ value: branch, label: branch })) };335}336337async sendMessage(session: URI, prompt: string, _attachments?: IAgentAttachment[], turnId?: string): Promise<void> {338if (turnId) {339this._activeTurnIds.set(uriKey(session), turnId);340}341const { sessionStr, turnId: tid } = this._ctx(session);342switch (prompt) {343case 'hello':344this._fireSequence([345_markdown(session, sessionStr, tid, 'Hello, world!'),346_idle(session, sessionStr, tid),347]);348break;349350case 'use-tool':351this._fireSequence([352..._toolStart(session, sessionStr, tid, 'tc-1', 'echo_tool', 'Echo Tool', 'Running echo tool...'),353_toolComplete(session, sessionStr, tid, 'tc-1', { pastTenseMessage: 'Ran echo tool', content: [{ type: ToolResultContentType.Text, text: 'echoed' }], success: true }),354_markdown(session, sessionStr, tid, 'Tool done.'),355_idle(session, sessionStr, tid),356]);357break;358359case 'error':360this._fireSequence([361_error(session, sessionStr, tid, 'test_error', 'Something went wrong'),362]);363break;364365case 'permission': {366// Fire tool_start to create the tool, then pending_confirmation to request confirmation367(async () => {368await timeout(10);369for (const s of _toolStart(session, sessionStr, tid, 'tc-perm-1', 'shell', 'Shell', 'Run a test command')) {370this._onDidSessionProgress.fire(s);371}372await timeout(5);373this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-perm-1', 'Run a test command', { toolInput: 'echo test', confirmationTitle: 'Run a test command' }));374})();375this._pendingPermissions.set('tc-perm-1', (approved) => {376if (approved) {377this._fireSequence([378_markdown(session, sessionStr, tid, 'Allowed.'),379_idle(session, sessionStr, tid),380]);381}382});383break;384}385386case 'write-file': {387// Fire tool_start + pending_confirmation with write permission for a regular file (should be auto-approved)388(async () => {389await timeout(10);390for (const s of _toolStart(session, sessionStr, tid, 'tc-write-1', 'create', 'Create File', 'Create file')) {391this._onDidSessionProgress.fire(s);392}393await timeout(5);394this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-write-1', 'Write src/app.ts', { permissionKind: 'write', permissionPath: '/workspace/src/app.ts' }));395// Auto-approved writes resolve immediately — complete the tool and turn396await timeout(10);397this._fireSequence([398_toolComplete(session, sessionStr, tid, 'tc-write-1', { pastTenseMessage: 'Wrote file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }),399_idle(session, sessionStr, tid),400]);401})();402break;403}404405case 'write-env': {406// Fire tool_start + pending_confirmation with write permission for .env (should be blocked)407(async () => {408await timeout(10);409for (const s of _toolStart(session, sessionStr, tid, 'tc-write-env-1', 'create', 'Create File', 'Create file')) {410this._onDidSessionProgress.fire(s);411}412await timeout(5);413this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-write-env-1', 'Write .env', { permissionKind: 'write', permissionPath: '/workspace/.env', confirmationTitle: 'Write .env' }));414})();415this._pendingPermissions.set('tc-write-env-1', (approved) => {416if (approved) {417this._fireSequence([418_toolComplete(session, sessionStr, tid, 'tc-write-env-1', { pastTenseMessage: 'Wrote .env', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }),419_idle(session, sessionStr, tid),420]);421}422});423break;424}425426case 'run-safe-command': {427// Fire tool_start + pending_confirmation with shell permission for an allowed command (should be auto-approved)428(async () => {429await timeout(10);430for (const s of _toolStart(session, sessionStr, tid, 'tc-shell-1', 'bash', 'Run Command', 'Run command')) {431this._onDidSessionProgress.fire(s);432}433await timeout(5);434this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-shell-1', 'ls -la', { permissionKind: 'shell', toolInput: 'ls -la' }));435// Auto-approved shell commands resolve immediately436await timeout(10);437this._fireSequence([438_toolComplete(session, sessionStr, tid, 'tc-shell-1', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true }),439_idle(session, sessionStr, tid),440]);441})();442break;443}444445case 'run-dangerous-command': {446// Fire tool_start + pending_confirmation with shell permission for a denied command (should require confirmation)447(async () => {448await timeout(10);449for (const s of _toolStart(session, sessionStr, tid, 'tc-shell-deny-1', 'bash', 'Run Command', 'Run command')) {450this._onDidSessionProgress.fire(s);451}452await timeout(5);453this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-shell-deny-1', 'rm -rf /', { permissionKind: 'shell', toolInput: 'rm -rf /', confirmationTitle: 'Run in terminal' }));454})();455this._pendingPermissions.set('tc-shell-deny-1', (approved) => {456if (approved) {457this._fireSequence([458_toolComplete(session, sessionStr, tid, 'tc-shell-deny-1', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: '' }], success: true }),459_idle(session, sessionStr, tid),460]);461}462});463break;464}465466case 'with-usage':467this._fireSequence([468_markdown(session, sessionStr, tid, 'Usage response.'),469_usage(session, sessionStr, tid, { inputTokens: 100, outputTokens: 50, model: 'mock-model' }),470_idle(session, sessionStr, tid),471]);472break;473474case 'with-reasoning': {475const initialReasoning = _reasoning(session, sessionStr, tid, 'Let me think');476const partId = initialReasoning.action.type === ActionType.SessionResponsePart477&& hasKey(initialReasoning.action.part, { id: true })478? initialReasoning.action.part.id479: '';480this._fireSequence([481initialReasoning,482_action(session, {483type: ActionType.SessionReasoning,484session: sessionStr,485turnId: tid,486partId,487content: ' about this...',488}),489_markdown(session, sessionStr, tid, 'Reasoned response.'),490_idle(session, sessionStr, tid),491]);492break;493}494495case 'with-title':496this._fireSequence([497_markdown(session, sessionStr, tid, 'Title response.'),498_titleChanged(session, sessionStr, MOCK_AUTO_TITLE),499_idle(session, sessionStr, tid),500]);501break;502503case 'slow': {504// Slow response for cancel testing — fires delta after a long delay505const timer = setTimeout(() => {506const ctx = this._ctx(session);507this._fireSequence([508_markdown(session, ctx.sessionStr, ctx.turnId, 'Slow response.'),509_idle(session, ctx.sessionStr, ctx.turnId),510]);511}, 5000);512this._pendingAborts.set(session.toString(), () => clearTimeout(timer));513break;514}515516case 'client-tool': {517// Fires tool_start with toolClientId followed by pending_confirmation518// (without confirmationTitle) to simulate a client-provided tool519// that is ready for execution. The real SDK handler fires520// tool_ready once its deferred is in place.521(async () => {522await timeout(10);523// Client tools don't get auto-ready — toolStart with toolClientId only emits tool_start524this._onDidSessionProgress.fire(_action(session, {525type: ActionType.SessionToolCallStart,526session: sessionStr,527turnId: tid,528toolCallId: 'tc-client-1',529toolName: 'runTests',530displayName: 'Run Tests',531toolClientId: 'test-client-tool',532}));533await timeout(5);534this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-client-1', 'Running tests...', { toolInput: '{}' }));535})();536// The tool stays pending — the client is responsible for dispatching toolCallComplete.537// Once complete, fire a response delta and idle.538this._pendingPermissions.set('tc-client-1', () => {539this._fireSequence([540_markdown(session, sessionStr, tid, 'Client tool done.'),541_idle(session, sessionStr, tid),542]);543});544break;545}546547case 'client-tool-with-permission': {548// Fires tool_start with toolClientId followed by a permission request.549(async () => {550await timeout(10);551this._onDidSessionProgress.fire(_action(session, {552type: ActionType.SessionToolCallStart,553session: sessionStr,554turnId: tid,555toolCallId: 'tc-client-perm-1',556toolName: 'runTests',557displayName: 'Run Tests',558toolClientId: 'test-client-tool',559}));560await timeout(5);561this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-client-perm-1', 'Run tests on project', { confirmationTitle: 'Allow Run Tests?' }));562})();563this._pendingPermissions.set('tc-client-perm-1', (approved) => {564if (approved) {565this._fireSequence([566_toolComplete(session, sessionStr, tid, 'tc-client-perm-1', { pastTenseMessage: 'Ran tests', content: [{ type: ToolResultContentType.Text, text: 'all passed' }], success: true }),567_markdown(session, sessionStr, tid, 'Permission granted, tool done.'),568_idle(session, sessionStr, tid),569]);570}571});572break;573}574575case 'subagent': {576// Spawns a subagent: parent `task` tool starts (emits start +577// auto-ready as a pair), then `subagent_started` creates the578// child session, then an inner tool runs in the child session579// (routed via `parentToolCallId`).580this._fireSequence([581..._toolStart(session, sessionStr, tid, 'tc-task-1', 'task', 'Task', 'Spawning subagent', { toolKind: 'subagent', subagentAgentName: 'explore', subagentDescription: 'Explore' }),582{ kind: 'subagent_started', session, toolCallId: 'tc-task-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Exploration helper' },583..._toolStart(session, sessionStr, tid, 'tc-inner-1', 'echo_tool', 'Echo Tool', 'Inner tool running...', { parentToolCallId: 'tc-task-1' }),584_toolComplete(session, sessionStr, tid, 'tc-inner-1', { pastTenseMessage: 'Ran inner tool', content: [{ type: ToolResultContentType.Text, text: 'inner-ok' }], success: true }, 'tc-task-1'),585_toolComplete(session, sessionStr, tid, 'tc-task-1', { pastTenseMessage: 'Subagent done', content: [{ type: ToolResultContentType.Text, text: 'task-ok' }], success: true }),586_markdown(session, sessionStr, tid, 'Subagent finished.'),587_idle(session, sessionStr, tid),588]);589break;590}591592default:593if (prompt.startsWith('terminal-edit:')) {594// Test prompt: simulate a terminal command that edits a file on disk595// without emitting any ToolResultFileEditContent. The test relies on the596// git-driven diff path to pick this up. Format: `terminal-edit:<absPath>`.597const filePath = prompt.slice('terminal-edit:'.length);598void (async () => {599for (const s of _toolStart(session, sessionStr, tid, 'tc-term-edit-1', 'bash', 'Run Command', 'Edit file via shell')) {600this._onDidSessionProgress.fire(s);601}602const fs = await import('fs/promises');603await fs.writeFile(filePath, 'edited-from-terminal\n');604this._fireSequence([605_toolComplete(session, sessionStr, tid, 'tc-term-edit-1', { pastTenseMessage: 'Edited file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }),606_idle(session, sessionStr, tid),607]);608})().catch(err => {609// Surface failures deterministically — an unhandled rejection610// would make the test suite flaky.611this._fireSequence([612_markdown(session, sessionStr, tid, 'terminal-edit failed: ' + (err instanceof Error ? err.message : String(err))),613_idle(session, sessionStr, tid),614]);615});616break;617}618this._fireSequence([619_markdown(session, sessionStr, tid, 'Unknown prompt: ' + prompt),620_idle(session, sessionStr, tid),621]);622break;623}624}625626setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void {627// When steering is set, consume it on the next tick628if (steeringMessage) {629timeout(20).then(() => {630this._onDidSessionProgress.fire({ kind: 'steering_consumed', session, id: steeringMessage.id });631});632}633}634635async setClientCustomizations() {636return [];637}638639setCustomizationEnabled() {640641}642643setClientTools(): void { }644645private didCompleteToolCalls = new Set<string>();646647onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void {648const key = `${session.toString()}:${toolCallId}`;649if (this.didCompleteToolCalls.has(key)) {650return;651}652this.didCompleteToolCalls.add(key);653// Fire tool_complete action signal and resolve any pending callback.654const { sessionStr, turnId } = this._ctx(session);655this._onDidSessionProgress.fire(_toolComplete(session, sessionStr, turnId, toolCallId, result));656const callback = this._pendingPermissions.get(toolCallId);657if (callback) {658this._pendingPermissions.delete(toolCallId);659callback(true);660}661}662663async getSessionMessages(session: URI): Promise<readonly Turn[]> {664const subagentInfo = parseSubagentSessionUri(session.toString());665if (subagentInfo) {666return buildSubagentTurnsFromHistory(this._preExistingMessages, subagentInfo.toolCallId, session.toString());667}668if (session.toString() === PRE_EXISTING_SESSION_URI.toString()) {669return buildTurnsFromHistory(this._preExistingMessages);670}671return [];672}673674async disposeSession(session: URI): Promise<void> {675this._sessions.delete(AgentSession.id(session));676}677678async abortSession(session: URI): Promise<void> {679const callback = this._pendingAborts.get(session.toString());680if (callback) {681this._pendingAborts.delete(session.toString());682callback();683}684}685686async changeModel(_session: URI, _model: ModelSelection): Promise<void> {687// Mock agent doesn't track model state688}689690async truncateSession(_session: URI, _turnId?: string): Promise<void> {691// Mock agent accepts truncation without side effects692}693694respondToPermissionRequest(toolCallId: string, approved: boolean): void {695const callback = this._pendingPermissions.get(toolCallId);696if (callback) {697this._pendingPermissions.delete(toolCallId);698callback(approved);699}700}701702respondToUserInputRequest(): void {703// no-op for tests704}705706async authenticate(_resource: string, _token: string): Promise<boolean> {707return true;708}709710async shutdown(): Promise<void> { }711712dispose(): void {713this._onDidSessionProgress.dispose();714}715716/**717* Fires a sequence of {@link AgentSignal}s with staggered 10 ms delays718* so the state manager processes them in order.719*/720private _fireSequence(signals: AgentSignal[]): void {721let delay = 0;722for (const signal of signals) {723delay += 10;724setTimeout(() => this._onDidSessionProgress.fire(signal), delay);725}726}727728/** Builds the session-string + turnId context for signal construction. */729private _ctx(session: URI): { sessionStr: string; turnId: string } {730return {731sessionStr: session.toString(),732turnId: this._activeTurnIds.get(uriKey(session)) ?? 'mock-turn',733};734}735}736737// =============================================================================738// Test-event helpers739// =============================================================================740741// =============================================================================742// Signal factory helpers743// =============================================================================744745let _mockPartIdCounter = 0;746747/** Wraps a session action into an {@link IAgentActionSignal}. */748function _action(session: URI, action: import('../../common/state/sessionActions.js').SessionAction, parentToolCallId?: string): IAgentActionSignal {749return { kind: 'action', session, action, parentToolCallId };750}751752/** Creates a markdown {@link ResponsePartKind.Markdown} response part signal. */753function _markdown(session: URI, sessionStr: string, turnId: string, content: string, parentToolCallId?: string): IAgentActionSignal {754return _action(session, {755type: ActionType.SessionResponsePart,756session: sessionStr,757turnId,758part: { kind: ResponsePartKind.Markdown, id: `mock-md-${++_mockPartIdCounter}`, content },759}, parentToolCallId);760}761762/** Creates a reasoning {@link ResponsePartKind.Reasoning} response part signal. */763function _reasoning(session: URI, sessionStr: string, turnId: string, content: string): IAgentActionSignal {764return _action(session, {765type: ActionType.SessionResponsePart,766session: sessionStr,767turnId,768part: { kind: ResponsePartKind.Reasoning, id: `mock-rs-${++_mockPartIdCounter}`, content },769});770}771772/** Creates a {@link ActionType.SessionTurnComplete} signal. */773function _idle(session: URI, sessionStr: string, turnId: string): IAgentActionSignal {774return _action(session, { type: ActionType.SessionTurnComplete, session: sessionStr, turnId });775}776777/** Creates a {@link ActionType.SessionError} signal. */778function _error(session: URI, sessionStr: string, turnId: string, errorType: string, message: string, stack?: string): IAgentActionSignal {779return _action(session, { type: ActionType.SessionError, session: sessionStr, turnId, error: { errorType, message, stack } });780}781782/** Creates a {@link ActionType.SessionTitleChanged} signal. */783function _titleChanged(session: URI, sessionStr: string, title: string): IAgentActionSignal {784return _action(session, { type: ActionType.SessionTitleChanged, session: sessionStr, title });785}786787/** Creates a {@link ActionType.SessionUsage} signal. */788function _usage(session: URI, sessionStr: string, turnId: string, usage: { inputTokens?: number; outputTokens?: number; model?: string; cacheReadTokens?: number }): IAgentActionSignal {789return _action(session, { type: ActionType.SessionUsage, session: sessionStr, turnId, usage });790}791792/**793* Creates tool-start signals: a {@link ActionType.SessionToolCallStart} and,794* for non-client tools, an auto-ready {@link ActionType.SessionToolCallReady}.795*/796function _toolStart(session: URI, sessionStr: string, turnId: string, toolCallId: string, toolName: string, displayName: string, invocationMessage: StringOrMarkdown, opts?: {797toolInput?: string;798toolKind?: string;799toolClientId?: string;800subagentAgentName?: string;801subagentDescription?: string;802parentToolCallId?: string;803}): IAgentActionSignal[] {804const meta: Record<string, unknown> = {};805if (opts?.toolKind) {806meta.toolKind = opts.toolKind;807}808if (opts?.subagentAgentName) {809meta.subagentAgentName = opts.subagentAgentName;810}811if (opts?.subagentDescription) {812meta.subagentDescription = opts.subagentDescription;813}814const signals: IAgentActionSignal[] = [_action(session, {815type: ActionType.SessionToolCallStart,816session: sessionStr,817turnId,818toolCallId,819toolName,820displayName,821toolClientId: opts?.toolClientId,822_meta: Object.keys(meta).length ? meta : undefined,823}, opts?.parentToolCallId)];824if (!opts?.toolClientId) {825signals.push(_action(session, {826type: ActionType.SessionToolCallReady,827session: sessionStr,828turnId,829toolCallId,830invocationMessage,831toolInput: opts?.toolInput,832confirmed: ToolCallConfirmationReason.NotNeeded,833}, opts?.parentToolCallId));834}835return signals;836}837838/** Creates a {@link ActionType.SessionToolCallComplete} signal. */839function _toolComplete(session: URI, sessionStr: string, turnId: string, toolCallId: string, result: ToolCallResult, parentToolCallId?: string): IAgentActionSignal {840return _action(session, { type: ActionType.SessionToolCallComplete, session: sessionStr, turnId, toolCallId, result }, parentToolCallId);841}842843/** Creates a {@link IAgentToolPendingConfirmationSignal}. */844function _pendingConfirmation(session: URI, toolCallId: string, invocationMessage: StringOrMarkdown, opts?: {845toolInput?: string;846confirmationTitle?: StringOrMarkdown;847permissionKind?: IAgentToolPendingConfirmationSignal['permissionKind'];848permissionPath?: IAgentToolPendingConfirmationSignal['permissionPath'];849}): IAgentToolPendingConfirmationSignal {850return {851kind: 'pending_confirmation',852session,853state: {854status: ToolCallStatus.PendingConfirmation,855toolCallId,856toolName: '',857displayName: '',858invocationMessage,859toolInput: opts?.toolInput,860confirmationTitle: opts?.confirmationTitle,861},862permissionKind: opts?.permissionKind,863permissionPath: opts?.permissionPath,864};865}866867868