Path: blob/main/src/vs/platform/agentHost/test/node/agentSideEffects.test.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 assert from 'assert';6import { VSBuffer } from '../../../../base/common/buffer.js';7import { Event } from '../../../../base/common/event.js';8import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';9import { Schemas } from '../../../../base/common/network.js';10import { observableValue } from '../../../../base/common/observable.js';11import { hasKey } from '../../../../base/common/types.js';12import { URI } from '../../../../base/common/uri.js';13import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';14import { FileService } from '../../../files/common/fileService.js';15import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';16import { InstantiationService } from '../../../instantiation/common/instantiationService.js';17import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js';18import { ILogService, NullLogService } from '../../../log/common/log.js';19import { AgentSession, IAgent } from '../../common/agentService.js';20import { ISessionDataService } from '../../common/sessionDataService.js';21import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js';22import { CustomizationStatus } from '../../common/state/protocol/state.js';23import { ActionType, ActionEnvelope, SessionAction } from '../../common/state/sessionActions.js';24import { AttachmentType, buildSubagentSessionUri, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType } from '../../common/state/sessionState.js';25import { IProductService } from '../../../product/common/productService.js';26import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js';27import { IAgentHostGitService } from '../../node/agentHostGitService.js';28import { AgentService } from '../../node/agentService.js';29import { AgentSideEffects, IAgentSideEffectsOptions } from '../../node/agentSideEffects.js';30import { SessionDatabase } from '../../node/sessionDatabase.js';31import { AgentHostStateManager } from '../../node/agentHostStateManager.js';32import { createNoopGitService, createNullSessionDataService, createSessionDataService } from '../common/sessionTestHelpers.js';33import { MockAgent } from './mockAgent.js';3435// ---- Tests ------------------------------------------------------------------3637/**38* Constructs an {@link AgentSideEffects} with a minimal local instantiation39* scope that satisfies its {@link IAgentConfigurationService} /40* {@link ILogService} / {@link IAgentHostGitService} dependencies.41*/42function createTestSideEffects(disposables: DisposableStore, stateManager: AgentHostStateManager, options: IAgentSideEffectsOptions, gitService?: IAgentHostGitService): AgentSideEffects {43const logService = new NullLogService();44const configService = disposables.add(new AgentConfigurationService(stateManager, logService));45const instantiationService = disposables.add(new InstantiationService(new ServiceCollection(46[ILogService, logService],47[IAgentConfigurationService, configService],48[IAgentHostGitService, gitService ?? createNoopGitService()],49), /*strict*/ true));50return disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, options));51}5253suite('AgentSideEffects', () => {5455const disposables = new DisposableStore();56let fileService: FileService;57let stateManager: AgentHostStateManager;58let agent: MockAgent;59let sideEffects: AgentSideEffects;60let agentList: ReturnType<typeof observableValue<readonly IAgent[]>>;6162const sessionUri = AgentSession.uri('mock', 'session-1');6364function setupSession(workingDirectory?: string): void {65stateManager.createSession({66resource: sessionUri.toString(),67provider: 'mock',68title: 'Test',69status: SessionStatus.Idle,70createdAt: Date.now(),71modifiedAt: Date.now(),72project: { uri: 'file:///test-project', displayName: 'Test Project' },73workingDirectory,74});75stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() });76}7778function startTurn(turnId: string): void {79stateManager.dispatchClientAction(80{ type: ActionType.SessionTurnStarted, session: sessionUri.toString(), turnId, userMessage: { text: 'hello' } },81{ clientId: 'test', clientSeq: 1 },82);83}8485setup(async () => {86fileService = disposables.add(new FileService(new NullLogService()));87const memFs = disposables.add(new InMemoryFileSystemProvider());88disposables.add(fileService.registerProvider(Schemas.inMemory, memFs));8990// Seed a file so the handleBrowseDirectory tests can distinguish files from dirs91const testDir = URI.from({ scheme: Schemas.inMemory, path: '/testDir' });92await fileService.createFolder(testDir);93await fileService.writeFile(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }), VSBuffer.fromString('hello'));9495agent = new MockAgent();96disposables.add(toDisposable(() => agent.dispose()));97stateManager = disposables.add(new AgentHostStateManager(new NullLogService()));98agentList = observableValue<readonly IAgent[]>('agents', [agent]);99sideEffects = createTestSideEffects(disposables, stateManager, {100getAgent: () => agent,101agents: agentList,102sessionDataService: createNullSessionDataService(),103onTurnComplete: () => { },104});105});106107teardown(() => {108disposables.clear();109});110ensureNoDisposablesAreLeakedInTestSuite();111112// ---- handleAction: session/turnStarted ------------------------------113114suite('handleAction — session/turnStarted', () => {115116test('calls sendMessage on the agent', async () => {117setupSession();118const action: SessionAction = {119type: ActionType.SessionTurnStarted,120session: sessionUri.toString(),121turnId: 'turn-1',122userMessage: { text: 'hello world' },123};124sideEffects.handleAction(action);125126// sendMessage is async but fire-and-forget; wait a tick127await new Promise(r => setTimeout(r, 10));128129assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello world', attachments: undefined }]);130});131132test('parses protocol attachment URI strings before passing them to the agent', () => {133setupSession();134const fileUri = URI.file('/workspace/test.ts');135const action: SessionAction = {136type: ActionType.SessionTurnStarted,137session: sessionUri.toString(),138turnId: 'turn-1',139userMessage: { text: 'hello world', attachments: [{ type: AttachmentType.File, uri: fileUri.toString(), displayName: 'test.ts' }] },140};141142sideEffects.handleAction(action);143144assert.deepStrictEqual(agent.sendMessageCalls, [{145session: URI.parse(sessionUri.toString()),146prompt: 'hello world',147attachments: [{ type: AttachmentType.File, uri: URI.parse(fileUri.toString()), displayName: 'test.ts' }],148}]);149});150151test('dispatches session/error when no agent is found', async () => {152setupSession();153const emptyAgents = observableValue<readonly IAgent[]>('agents', []);154const noAgentSideEffects = createTestSideEffects(disposables, stateManager, {155getAgent: () => undefined,156agents: emptyAgents,157sessionDataService: {} as ISessionDataService,158onTurnComplete: () => { },159});160161const envelopes: ActionEnvelope[] = [];162disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));163164noAgentSideEffects.handleAction({165type: ActionType.SessionTurnStarted,166session: sessionUri.toString(),167turnId: 'turn-1',168userMessage: { text: 'hello' },169});170171const errorAction = envelopes.find(e => e.action.type === ActionType.SessionError);172assert.ok(errorAction, 'should dispatch session/error');173});174});175176// ---- immediate title on first turn -----------------------------------177178suite('immediate title on first turn', () => {179180function setupDefaultSession(): void {181stateManager.createSession({182resource: sessionUri.toString(),183provider: 'mock',184title: '',185status: SessionStatus.Idle,186createdAt: Date.now(),187modifiedAt: Date.now(),188project: { uri: 'file:///test-project', displayName: 'Test Project' },189});190stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() });191}192193test('dispatches titleChanged with user message on first turn', () => {194setupDefaultSession();195196const envelopes: ActionEnvelope[] = [];197disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));198199sideEffects.handleAction({200type: ActionType.SessionTurnStarted,201session: sessionUri.toString(),202turnId: 'turn-1',203userMessage: { text: 'Fix the login bug' },204});205206const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);207assert.ok(titleAction, 'should dispatch session/titleChanged');208if (titleAction?.action.type === ActionType.SessionTitleChanged) {209assert.strictEqual(titleAction.action.title, 'Fix the login bug');210}211});212213test('does not dispatch titleChanged when message is whitespace', () => {214setupDefaultSession();215216const envelopes: ActionEnvelope[] = [];217disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));218219sideEffects.handleAction({220type: ActionType.SessionTurnStarted,221session: sessionUri.toString(),222turnId: 'turn-1',223userMessage: { text: ' ' },224});225226const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);227assert.strictEqual(titleAction, undefined, 'should not dispatch titleChanged for empty message');228});229230test('normalizes whitespace and truncates long messages', () => {231setupDefaultSession();232233const envelopes: ActionEnvelope[] = [];234disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));235236const longMessage = 'Fix the bug\nin the login\tpage please ' + 'a'.repeat(250);237sideEffects.handleAction({238type: ActionType.SessionTurnStarted,239session: sessionUri.toString(),240turnId: 'turn-1',241userMessage: { text: longMessage },242});243244const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);245assert.ok(titleAction, 'should dispatch session/titleChanged');246if (titleAction?.action.type === ActionType.SessionTitleChanged) {247assert.ok(!titleAction.action.title.includes('\n'), 'should not contain newlines');248assert.ok(!titleAction.action.title.includes('\t'), 'should not contain tabs');249assert.ok(!titleAction.action.title.includes(' '), 'should not contain double spaces');250assert.ok(titleAction.action.title.length <= 200, 'should be truncated to 200 chars');251}252});253254test('does not dispatch titleChanged on second turn', () => {255setupDefaultSession();256startTurn('turn-1');257258// Complete the first turn so turns.length becomes 1.259stateManager.dispatchServerAction({260type: ActionType.SessionTurnComplete,261session: sessionUri.toString(),262turnId: 'turn-1',263});264265const envelopes: ActionEnvelope[] = [];266disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));267268sideEffects.handleAction({269type: ActionType.SessionTurnStarted,270session: sessionUri.toString(),271turnId: 'turn-2',272userMessage: { text: 'second message' },273});274275const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);276assert.strictEqual(titleAction, undefined, 'should not dispatch titleChanged on second turn');277});278279test('does not dispatch titleChanged when title is already set', () => {280// Session has a non-empty title (e.g. user renamed before first message)281stateManager.createSession({282resource: sessionUri.toString(),283provider: 'mock',284title: 'User Renamed',285status: SessionStatus.Idle,286createdAt: Date.now(),287modifiedAt: Date.now(),288project: { uri: 'file:///test-project', displayName: 'Test Project' },289});290stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() });291292const envelopes: ActionEnvelope[] = [];293disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));294295sideEffects.handleAction({296type: ActionType.SessionTurnStarted,297session: sessionUri.toString(),298turnId: 'turn-1',299userMessage: { text: 'hello' },300});301302const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);303assert.strictEqual(titleAction, undefined, 'should not clobber existing title');304});305});306307suite('handleAction — session/turnCancelled', () => {308309test('calls abortSession on the agent', async () => {310setupSession();311sideEffects.handleAction({312type: ActionType.SessionTurnCancelled,313session: sessionUri.toString(),314turnId: 'turn-1',315});316317await new Promise(r => setTimeout(r, 10));318319assert.deepStrictEqual(agent.abortSessionCalls, [URI.parse(sessionUri.toString())]);320});321});322323// ---- handleAction: session/modelChanged -----------------------------324325suite('handleAction — session/modelChanged', () => {326327test('calls changeModel on the agent', async () => {328setupSession();329sideEffects.handleAction({330type: ActionType.SessionModelChanged,331session: sessionUri.toString(),332model: { id: 'gpt-5' },333});334335await new Promise(r => setTimeout(r, 10));336337assert.deepStrictEqual(agent.changeModelCalls, [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' } }]);338});339});340341// ---- registerProgressListener ---------------------------------------342343suite('registerProgressListener', () => {344345test('maps agent progress events to state actions', () => {346setupSession();347startTurn('turn-1');348349const envelopes: ActionEnvelope[] = [];350disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));351disposables.add(sideEffects.registerProgressListener(agent));352353agent.fireProgress({354kind: 'action', session: sessionUri,355action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'hi' } },356});357358// First delta creates a response part (not a delta action)359assert.ok(envelopes.some(e => e.action.type === ActionType.SessionResponsePart));360});361362test('returns a disposable that stops listening', () => {363setupSession();364startTurn('turn-1');365366const envelopes: ActionEnvelope[] = [];367disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));368const listener = sideEffects.registerProgressListener(agent);369370agent.fireProgress({371kind: 'action', session: sessionUri,372action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'before' } },373});374assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionResponsePart).length, 1);375376listener.dispose();377agent.fireProgress({378kind: 'action', session: sessionUri,379action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-2', content: 'after' } },380});381assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionResponsePart).length, 1);382});383});384385// ---- agents observable --------------------------------------------------386387suite('agents observable', () => {388389test('dispatches root/agentsChanged without fetching models when observable changes', async () => {390agentList.set([], undefined);391const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => {392if (e.action.type !== ActionType.RootAgentsChanged) {393return false;394}395return e.action.agents.length === 1;396}));397agentList.set([agent], undefined);398const { action } = await envelope;399assert.strictEqual(action.type, ActionType.RootAgentsChanged);400401assert.deepStrictEqual(action.agents[0].models, []);402});403404test('model observable update publishes models', async () => {405const envelopes: ActionEnvelope[] = [];406disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));407408const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => {409if (e.action.type !== ActionType.RootAgentsChanged) {410return false;411}412return e.action.agents[0]?.models.length === 1;413}));414agent.setModels([{ provider: 'mock', id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false }]);415await envelope;416417const actions = envelopes.map(e => e.action).filter(action => action.type === ActionType.RootAgentsChanged);418const action = actions[actions.length - 1];419assert.ok(action, 'should dispatch root/agentsChanged');420assert.deepStrictEqual(action.agents[0].models, [{421id: 'mock-model',422provider: 'mock',423name: 'mock Model',424maxContextWindow: 128000,425supportsVision: false,426policyState: undefined,427configSchema: undefined,428}]);429});430431test('unchanged model observable update does not dispatch unchanged agent infos', async () => {432const envelopes: ActionEnvelope[] = [];433disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));434const models = [{ provider: 'mock' as const, id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false }];435436const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => {437if (e.action.type !== ActionType.RootAgentsChanged) {438return false;439}440return e.action.agents[0]?.models.length === 1;441}));442agent.setModels(models);443await envelope;444envelopes.length = 0;445agent.setModels([...models]);446await Promise.resolve();447await Promise.resolve();448449assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.RootAgentsChanged).length, 0);450});451});452453// ---- Pending message sync -----------------------------------------------454455suite('pending message sync', () => {456457test('syncs steering message to agent on SessionPendingMessageSet', () => {458setupSession();459460const action = {461type: ActionType.SessionPendingMessageSet as const,462session: sessionUri.toString(),463kind: PendingMessageKind.Steering,464id: 'steer-1',465userMessage: { text: 'focus on tests' },466};467stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 });468sideEffects.handleAction(action);469470assert.strictEqual(agent.setPendingMessagesCalls.length, 1);471assert.deepStrictEqual(agent.setPendingMessagesCalls[0].steeringMessage, { id: 'steer-1', userMessage: { text: 'focus on tests' } });472assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []);473});474475test('syncs queued message to agent on SessionPendingMessageSet', () => {476setupSession();477478const action = {479type: ActionType.SessionPendingMessageSet as const,480session: sessionUri.toString(),481kind: PendingMessageKind.Queued,482id: 'q-1',483userMessage: { text: 'queued message' },484};485stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 });486sideEffects.handleAction(action);487488// Queued messages are not forwarded to the agent; the server controls consumption489assert.strictEqual(agent.setPendingMessagesCalls.length, 1);490assert.strictEqual(agent.setPendingMessagesCalls[0].steeringMessage, undefined);491assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []);492493// Session was idle, so the queued message is consumed immediately494assert.strictEqual(agent.sendMessageCalls.length, 1);495assert.strictEqual(agent.sendMessageCalls[0].prompt, 'queued message');496});497498test('parses queued protocol attachment URI strings before passing them to the agent', () => {499setupSession();500const fileUri = URI.file('/workspace/queued.ts');501const action: SessionAction = {502type: ActionType.SessionPendingMessageSet as const,503session: sessionUri.toString(),504kind: PendingMessageKind.Queued,505id: 'q-uri',506userMessage: { text: 'queued message', attachments: [{ type: AttachmentType.File, uri: fileUri.toString(), displayName: 'queued.ts' }] },507};508509stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 });510sideEffects.handleAction(action);511512assert.deepStrictEqual(agent.sendMessageCalls, [{513session: URI.parse(sessionUri.toString()),514prompt: 'queued message',515attachments: [{ type: AttachmentType.File, uri: URI.parse(fileUri.toString()), displayName: 'queued.ts' }],516}]);517});518519test('syncs on SessionPendingMessageRemoved', () => {520setupSession();521522// Add a queued message523const setAction = {524type: ActionType.SessionPendingMessageSet as const,525session: sessionUri.toString(),526kind: PendingMessageKind.Queued,527id: 'q-rm',528userMessage: { text: 'will be removed' },529};530stateManager.dispatchClientAction(setAction, { clientId: 'test', clientSeq: 1 });531sideEffects.handleAction(setAction);532533agent.setPendingMessagesCalls.length = 0;534535// Remove536const removeAction = {537type: ActionType.SessionPendingMessageRemoved as const,538session: sessionUri.toString(),539kind: PendingMessageKind.Queued,540id: 'q-rm',541};542stateManager.dispatchClientAction(removeAction, { clientId: 'test', clientSeq: 2 });543sideEffects.handleAction(removeAction);544545assert.strictEqual(agent.setPendingMessagesCalls.length, 1);546assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []);547});548549test('syncs on SessionQueuedMessagesReordered', () => {550setupSession();551552// Add two queued messages553const setA = { type: ActionType.SessionPendingMessageSet as const, session: sessionUri.toString(), kind: PendingMessageKind.Queued, id: 'q-a', userMessage: { text: 'A' } };554stateManager.dispatchClientAction(setA, { clientId: 'test', clientSeq: 1 });555sideEffects.handleAction(setA);556557const setB = { type: ActionType.SessionPendingMessageSet as const, session: sessionUri.toString(), kind: PendingMessageKind.Queued, id: 'q-b', userMessage: { text: 'B' } };558stateManager.dispatchClientAction(setB, { clientId: 'test', clientSeq: 2 });559sideEffects.handleAction(setB);560561agent.setPendingMessagesCalls.length = 0;562563// Reorder564const reorderAction = { type: ActionType.SessionQueuedMessagesReordered as const, session: sessionUri.toString(), order: ['q-b', 'q-a'] };565stateManager.dispatchClientAction(reorderAction, { clientId: 'test', clientSeq: 3 });566sideEffects.handleAction(reorderAction);567568assert.strictEqual(agent.setPendingMessagesCalls.length, 1);569assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []);570});571});572573// ---- Queued message consumption -----------------------------------------574575suite('queued message consumption', () => {576577test('auto-starts turn from queued message on idle', () => {578setupSession();579disposables.add(sideEffects.registerProgressListener(agent));580581// Queue a message while a turn is active582startTurn('turn-1');583const setAction = {584type: ActionType.SessionPendingMessageSet as const,585session: sessionUri.toString(),586kind: PendingMessageKind.Queued,587id: 'q-auto',588userMessage: { text: 'auto queued' },589};590stateManager.dispatchClientAction(setAction, { clientId: 'test', clientSeq: 1 });591sideEffects.handleAction(setAction);592593// Message should NOT be consumed yet (turn is active)594assert.strictEqual(agent.sendMessageCalls.length, 0);595596const envelopes: ActionEnvelope[] = [];597disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));598599// Fire idle → turn completes → queued message should be consumed600agent.fireProgress({601kind: 'action', session: sessionUri,602action: { type: ActionType.SessionTurnComplete, session: sessionUri.toString(), turnId: 'turn-1' },603});604605const turnComplete = envelopes.find(e => e.action.type === ActionType.SessionTurnComplete);606assert.ok(turnComplete, 'should dispatch session/turnComplete');607608const turnStarted = envelopes.find(e => e.action.type === ActionType.SessionTurnStarted);609assert.ok(turnStarted, 'should dispatch session/turnStarted for queued message');610assert.strictEqual((turnStarted!.action as { queuedMessageId?: string }).queuedMessageId, 'q-auto');611612assert.strictEqual(agent.sendMessageCalls.length, 1);613assert.strictEqual(agent.sendMessageCalls[0].prompt, 'auto queued');614615// Queued message should be removed from state616const state = stateManager.getSessionState(sessionUri.toString());617assert.strictEqual(state?.queuedMessages, undefined);618});619620test('does not consume queued message while a turn is active', () => {621setupSession();622startTurn('turn-1');623624const envelopes: ActionEnvelope[] = [];625disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));626627const setAction = {628type: ActionType.SessionPendingMessageSet as const,629session: sessionUri.toString(),630kind: PendingMessageKind.Queued,631id: 'q-wait',632userMessage: { text: 'should wait' },633};634stateManager.dispatchClientAction(setAction, { clientId: 'test', clientSeq: 1 });635sideEffects.handleAction(setAction);636637// No turn started for the queued message638const turnStarted = envelopes.find(e => e.action.type === ActionType.SessionTurnStarted);639assert.strictEqual(turnStarted, undefined, 'should not start a turn while one is active');640assert.strictEqual(agent.sendMessageCalls.length, 0);641642// Queued message still in state643const state = stateManager.getSessionState(sessionUri.toString());644assert.strictEqual(state?.queuedMessages?.length, 1);645assert.strictEqual(state?.queuedMessages?.[0].id, 'q-wait');646});647648test('dispatches SessionPendingMessageRemoved for steering messages on steering_consumed', () => {649setupSession();650disposables.add(sideEffects.registerProgressListener(agent));651652const envelopes: ActionEnvelope[] = [];653disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));654655const action = {656type: ActionType.SessionPendingMessageSet as const,657session: sessionUri.toString(),658kind: PendingMessageKind.Steering,659id: 'steer-rm',660userMessage: { text: 'steer me' },661};662stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 });663sideEffects.handleAction(action);664665// Removal is not dispatched synchronously; it waits for the agent666let removal = envelopes.find(e =>667e.action.type === ActionType.SessionPendingMessageRemoved &&668(e.action as { kind: PendingMessageKind }).kind === PendingMessageKind.Steering669);670assert.strictEqual(removal, undefined, 'should not dispatch removal until steering_consumed');671672// Simulate the agent consuming the steering message673agent.fireProgress({674kind: 'steering_consumed',675session: sessionUri,676id: 'steer-rm',677});678679removal = envelopes.find(e =>680e.action.type === ActionType.SessionPendingMessageRemoved &&681(e.action as { kind: PendingMessageKind }).kind === PendingMessageKind.Steering682);683assert.ok(removal, 'should dispatch SessionPendingMessageRemoved for steering');684assert.strictEqual((removal!.action as { id: string }).id, 'steer-rm');685686// Steering message should be removed from state687const state = stateManager.getSessionState(sessionUri.toString());688assert.strictEqual(state?.steeringMessage, undefined);689});690});691692// ---- handleAction: session/activeClientChanged ----------------------693694suite('handleAction — session/activeClientChanged', () => {695696test('calls setClientCustomizations and dispatches customizationsChanged', async () => {697setupSession();698agent.getSessionCustomizations = async () => [699{700customization: { uri: 'file:///plugin-a', displayName: 'Plugin A' },701enabled: true,702status: CustomizationStatus.Loaded,703},704{705customization: { uri: 'file:///plugin-b', displayName: 'Plugin B' },706enabled: true,707status: CustomizationStatus.Loaded,708},709];710711const envelopes: ActionEnvelope[] = [];712disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));713714const action: SessionAction = {715type: ActionType.SessionActiveClientChanged,716session: sessionUri.toString(),717activeClient: {718clientId: 'test-client',719tools: [],720customizations: [721{ uri: 'file:///plugin-a', displayName: 'Plugin A' },722{ uri: 'file:///plugin-b', displayName: 'Plugin B' },723],724},725};726sideEffects.handleAction(action);727728// Wait for async setClientCustomizations729await new Promise(r => setTimeout(r, 50));730731assert.deepStrictEqual(agent.setClientCustomizationsCalls, [{732clientId: 'test-client',733customizations: [734{ uri: 'file:///plugin-a', displayName: 'Plugin A' },735{ uri: 'file:///plugin-b', displayName: 'Plugin B' },736],737}]);738739const customizationActions = envelopes740.filter(e => e.action.type === ActionType.SessionCustomizationsChanged);741assert.ok(customizationActions.length >= 1, 'should dispatch at least one customizationsChanged');742});743744test('clears client customizations when activeClient has no customizations', () => {745setupSession();746747const envelopes: ActionEnvelope[] = [];748disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));749750const action: SessionAction = {751type: ActionType.SessionActiveClientChanged,752session: sessionUri.toString(),753activeClient: {754clientId: 'test-client',755tools: [],756},757};758sideEffects.handleAction(action);759760assert.deepStrictEqual(agent.setClientCustomizationsCalls, [{761clientId: 'test-client',762customizations: [],763}]);764const customizationActions = envelopes765.filter(e => e.action.type === ActionType.SessionCustomizationsChanged);766assert.strictEqual(customizationActions.length, 0);767});768769test('clears client customizations when activeClient is null', () => {770setupSession();771772const action: SessionAction = {773type: ActionType.SessionActiveClientChanged,774session: sessionUri.toString(),775activeClient: null,776};777sideEffects.handleAction(action);778779assert.deepStrictEqual(agent.setClientCustomizationsCalls, [{780clientId: '',781customizations: [],782}]);783});784});785786// ---- handleAction: root/configChanged --------------------------------787788suite('handleAction - root/configChanged', () => {789790test('republishes agent and session customizations for existing sessions', async () => {791setupSession('file:///workspace');792const customization = { uri: 'file:///plugin-a', displayName: 'Plugin A' };793agent.customizations = [customization];794agent.getSessionCustomizations = async () => [{795customization,796enabled: true,797status: CustomizationStatus.Loaded,798}];799800const envelopes: ActionEnvelope[] = [];801disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));802803const action: RootConfigChangedAction = {804type: ActionType.RootConfigChanged,805config: { customizations: [customization] },806};807808stateManager.dispatchServerAction(action);809sideEffects.handleAction(action);810await new Promise(resolve => setTimeout(resolve, 10));811812const agentInfoAction = envelopes.filter(e => e.action.type === ActionType.RootAgentsChanged).at(-1);813assert.ok(agentInfoAction && hasKey(agentInfoAction.action, { agents: true }));814assert.deepStrictEqual(agentInfoAction.action.agents[0]?.customizations, [customization]);815816const sessionCustomizationAction = envelopes.filter(e => e.action.type === ActionType.SessionCustomizationsChanged).at(-1);817assert.ok(sessionCustomizationAction && hasKey(sessionCustomizationAction.action, { customizations: true }));818assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [{819customization,820enabled: true,821status: CustomizationStatus.Loaded,822}]);823});824});825826// ---- onDidCustomizationsChange integration --------------------------827828suite('onDidCustomizationsChange', () => {829830test('republishes agent info and session customizations when agent fires onDidCustomizationsChange', async () => {831disposables.add(sideEffects.registerProgressListener(agent));832setupSession('file:///workspace');833834const customization = { uri: 'file:///plugin-b', displayName: 'Plugin B' };835agent.customizations = [customization];836agent.getSessionCustomizations = async () => [{837customization,838enabled: true,839status: CustomizationStatus.Loaded,840}];841842const envelopes: ActionEnvelope[] = [];843disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));844845agent.fireCustomizationsChange();846await new Promise(resolve => setTimeout(resolve, 10));847848const agentInfoAction = envelopes.find(e => e.action.type === ActionType.RootAgentsChanged);849assert.ok(agentInfoAction && hasKey(agentInfoAction.action, { agents: true }));850assert.deepStrictEqual(agentInfoAction.action.agents[0]?.customizations, [customization]);851852const sessionCustomizationAction = envelopes.find(e => e.action.type === ActionType.SessionCustomizationsChanged);853assert.ok(sessionCustomizationAction && hasKey(sessionCustomizationAction.action, { customizations: true }));854assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [{855customization,856enabled: true,857status: CustomizationStatus.Loaded,858}]);859});860861test('does not republish when registerProgressListener is disposed', async () => {862const listener = sideEffects.registerProgressListener(agent);863setupSession('file:///workspace');864865agent.customizations = [{ uri: 'file:///plugin-c', displayName: 'Plugin C' }];866867const envelopes: ActionEnvelope[] = [];868disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));869870listener.dispose();871agent.fireCustomizationsChange();872await new Promise(resolve => setTimeout(resolve, 10));873874assert.strictEqual(875envelopes.filter(e => e.action.type === ActionType.SessionCustomizationsChanged).length,8760,877'should not republish session customizations after listener disposed',878);879});880});881882// ---- handleAction: session/customizationToggled ---------------------883884suite('handleAction — session/customizationToggled', () => {885886test('calls setCustomizationEnabled on the agent', () => {887setupSession();888889const action: SessionAction = {890type: ActionType.SessionCustomizationToggled,891session: sessionUri.toString(),892uri: 'file:///plugin-a',893enabled: false,894};895sideEffects.handleAction(action);896897assert.deepStrictEqual(agent.setCustomizationEnabledCalls, [898{ uri: 'file:///plugin-a', enabled: false },899]);900});901});902903// ---- handleAction: session/toolCallConfirmed ------------------------904905suite('handleAction — session/toolCallConfirmed', () => {906907test('routes confirmation to correct agent via _toolCallAgents', () => {908setupSession();909startTurn('turn-1');910disposables.add(sideEffects.registerProgressListener(agent));911912// Fire tool_start to register the tool call913agent.fireProgress({914kind: 'action', session: sessionUri,915action: {916type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',917toolCallId: 'tc-conf-1', toolName: 'read', displayName: 'Read File', toolClientId: undefined,918_meta: { toolKind: undefined, language: undefined },919},920});921agent.fireProgress({922kind: 'action', session: sessionUri,923action: {924type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',925toolCallId: 'tc-conf-1', invocationMessage: 'Reading file', toolInput: undefined,926confirmed: ToolCallConfirmationReason.NotNeeded,927},928});929930// Fire tool_ready asking for permission (non-write, so not auto-approved)931agent.fireProgress({932kind: 'pending_confirmation', session: sessionUri,933state: {934status: ToolCallStatus.PendingConfirmation,935toolCallId: 'tc-conf-1', toolName: '', displayName: '',936invocationMessage: 'Read file.txt', toolInput: undefined,937confirmationTitle: 'Read file.txt', edits: undefined,938},939permissionKind: undefined, permissionPath: undefined,940});941942// Now confirm the tool call943sideEffects.handleAction({944type: ActionType.SessionToolCallConfirmed,945session: sessionUri.toString(),946turnId: 'turn-1',947toolCallId: 'tc-conf-1',948approved: true,949confirmed: 'user-action' as const,950} as SessionAction);951952assert.deepStrictEqual(agent.respondToPermissionCalls, [953{ requestId: 'tc-conf-1', approved: true },954]);955});956957test('handles denial of tool call', () => {958setupSession();959startTurn('turn-1');960disposables.add(sideEffects.registerProgressListener(agent));961962agent.fireProgress({963kind: 'action', session: sessionUri,964action: {965type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',966toolCallId: 'tc-deny-1', toolName: 'shell', displayName: 'Shell', toolClientId: undefined,967_meta: { toolKind: undefined, language: undefined },968},969});970agent.fireProgress({971kind: 'action', session: sessionUri,972action: {973type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',974toolCallId: 'tc-deny-1', invocationMessage: 'Running command', toolInput: undefined,975confirmed: ToolCallConfirmationReason.NotNeeded,976},977});978979sideEffects.handleAction({980type: ActionType.SessionToolCallConfirmed,981session: sessionUri.toString(),982turnId: 'turn-1',983toolCallId: 'tc-deny-1',984approved: false,985reason: 'denied' as const,986} as SessionAction);987988assert.deepStrictEqual(agent.respondToPermissionCalls, [989{ requestId: 'tc-deny-1', approved: false },990]);991});992});993994// ---- tool_ready progress dispatch -----------------------------------995996suite('tool_ready dispatches progress actions to advance tool call state', () => {997998test('tool_ready for a non-permission tool dispatches SessionToolCallReady and advances state from Streaming to Running', () => {999setupSession();1000startTurn('turn-1');1001disposables.add(sideEffects.registerProgressListener(agent));10021003// tool_start puts the tool call into Streaming state1004agent.fireProgress({1005kind: 'action', session: sessionUri,1006action: {1007type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1008toolCallId: 'tc-ready-1', toolName: 'runTask', displayName: 'Run Task', toolClientId: 'test-client',1009_meta: { toolKind: undefined, language: undefined },1010},1011});10121013const stateAfterStart = stateManager.getSessionState(sessionUri.toString());1014const partAfterStart = stateAfterStart?.activeTurn?.responseParts[0];1015assert.strictEqual(partAfterStart?.kind, ResponsePartKind.ToolCall);1016assert.strictEqual(partAfterStart?.kind === ResponsePartKind.ToolCall ? partAfterStart.toolCall.status : undefined, ToolCallStatus.Streaming);10171018// tool_ready without confirmationTitle should dispatch the ready1019// action and advance the tool call to Running1020agent.fireProgress({1021kind: 'pending_confirmation', session: sessionUri,1022state: {1023status: ToolCallStatus.PendingConfirmation,1024toolCallId: 'tc-ready-1', toolName: '', displayName: '',1025invocationMessage: 'Run Task', toolInput: '{"task":"build"}',1026confirmationTitle: undefined, edits: undefined,1027},1028permissionKind: undefined, permissionPath: undefined,1029});10301031const stateAfterReady = stateManager.getSessionState(sessionUri.toString());1032const partAfterReady = stateAfterReady?.activeTurn?.responseParts[0];1033assert.strictEqual(partAfterReady?.kind, ResponsePartKind.ToolCall);1034assert.strictEqual(partAfterReady?.kind === ResponsePartKind.ToolCall ? partAfterReady.toolCall.status : undefined, ToolCallStatus.Running,1035'tool call should advance from Streaming to Running after tool_ready');1036});10371038test('tool_ready for a permission-gated tool dispatches SessionToolCallReady and advances state to PendingConfirmation', () => {1039setupSession();1040startTurn('turn-1');1041disposables.add(sideEffects.registerProgressListener(agent));10421043agent.fireProgress({1044kind: 'action', session: sessionUri,1045action: {1046type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1047toolCallId: 'tc-perm-1', toolName: 'write', displayName: 'Write File', toolClientId: 'test-client',1048_meta: { toolKind: undefined, language: undefined },1049},1050});10511052// tool_ready with confirmationTitle should dispatch the ready1053// action and advance the tool call to PendingConfirmation1054agent.fireProgress({1055kind: 'pending_confirmation', session: sessionUri,1056state: {1057status: ToolCallStatus.PendingConfirmation,1058toolCallId: 'tc-perm-1', toolName: '', displayName: '',1059invocationMessage: 'Write .env', toolInput: '{"path":".env"}',1060confirmationTitle: 'Write .env', edits: undefined,1061},1062permissionKind: undefined, permissionPath: undefined,1063});10641065const state = stateManager.getSessionState(sessionUri.toString());1066const part = state?.activeTurn?.responseParts[0];1067assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);1068assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.PendingConfirmation,1069'tool call should advance to PendingConfirmation for permission-gated tool_ready');1070});10711072test('pending_confirmation for a tool inside a subagent routes to the subagent session', () => {1073// Regression: a `pending_confirmation` signal for a client tool1074// inside a subagent must dispatch SessionToolCallReady against1075// the subagent session, not the parent. Otherwise the parent1076// session sees a stray `session/toolCallReady` with no1077// preceding `session/toolCallStart`, which is illegal.1078setupSession();1079startTurn('turn-1');1080disposables.add(sideEffects.registerProgressListener(agent));10811082// Parent tool that delegates to a subagent.1083agent.fireProgress({1084kind: 'action', session: sessionUri,1085action: {1086type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1087toolCallId: 'tc-parent', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined,1088_meta: { toolKind: undefined, language: undefined },1089},1090});1091agent.fireProgress({1092kind: 'action', session: sessionUri,1093action: {1094type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',1095toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined,1096confirmed: ToolCallConfirmationReason.NotNeeded,1097},1098});1099agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper' });11001101// Inner client tool starts inside the subagent.1102agent.fireProgress({1103kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',1104action: {1105type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1106toolCallId: 'tc-inner', toolName: 'problems', displayName: 'Problems', toolClientId: 'client-tools',1107_meta: { toolKind: undefined, language: undefined },1108},1109});11101111// Permission flow fires `pending_confirmation` for the inner1112// client tool. The signal must be routed to the subagent1113// session — not to the parent — even though the signal carries1114// only the parent session URI.1115agent.fireProgress({1116kind: 'pending_confirmation', session: sessionUri, parentToolCallId: 'tc-parent',1117state: {1118status: ToolCallStatus.PendingConfirmation,1119toolCallId: 'tc-inner', toolName: 'problems', displayName: 'Problems',1120invocationMessage: 'Get problems', toolInput: '{}',1121confirmationTitle: undefined, edits: undefined,1122},1123permissionKind: 'custom-tool', permissionPath: undefined,1124});11251126// The subagent session must contain the SessionToolCallReady.1127const subagentUri = buildSubagentSessionUri(sessionUri.toString(), 'tc-parent');1128const subState = stateManager.getSessionState(subagentUri);1129const innerPart = subState?.activeTurn?.responseParts.find(1130rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-inner'1131);1132assert.ok(innerPart, 'inner client tool call should exist on subagent session');1133assert.strictEqual(1134innerPart!.kind === ResponsePartKind.ToolCall ? innerPart.toolCall.status : undefined,1135ToolCallStatus.Running,1136'inner client tool call should advance to Running after pending_confirmation'1137);11381139// The parent session must NOT have a stray tool call for the1140// inner toolCallId — that would be a SessionToolCallReady1141// without a matching SessionToolCallStart.1142const parentState = stateManager.getSessionState(sessionUri.toString());1143const parentInner = parentState?.activeTurn?.responseParts.find(1144rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-inner'1145);1146assert.strictEqual(parentInner, undefined, 'parent session must not contain the inner tool call');1147});1148});11491150// ---- Session-level auto-approve (config) ----------------------------11511152suite('session config auto-approve', () => {11531154function setupSessionWithConfig(autoApproveLevel: string): void {1155setupSession(URI.file('/workspace').toString());1156// Set config on the session state directly (as agentService.ts does)1157const state = stateManager.getSessionState(sessionUri.toString());1158if (state) {1159state.config = {1160schema: {1161type: 'object',1162properties: {1163autoApprove: {1164type: 'string',1165title: 'Approvals',1166enum: ['default', 'autoApprove', 'autopilot'],1167default: 'default',1168sessionMutable: true,1169},1170},1171},1172values: { autoApprove: autoApproveLevel },1173};1174}1175}11761177test('auto-approves all writes when autoApprove is set to bypass', () => {1178setupSessionWithConfig('autoApprove');1179startTurn('turn-1');1180disposables.add(sideEffects.registerProgressListener(agent));11811182agent.fireProgress({1183kind: 'action', session: sessionUri,1184action: {1185type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1186toolCallId: 'tc-bypass-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,1187_meta: { toolKind: undefined, language: undefined },1188},1189});1190agent.fireProgress({1191kind: 'action', session: sessionUri,1192action: {1193type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',1194toolCallId: 'tc-bypass-1', invocationMessage: 'Write .env', toolInput: undefined,1195confirmed: ToolCallConfirmationReason.NotNeeded,1196},1197});11981199agent.fireProgress({1200kind: 'pending_confirmation', session: sessionUri,1201state: {1202status: ToolCallStatus.PendingConfirmation,1203toolCallId: 'tc-bypass-1', toolName: '', displayName: '',1204invocationMessage: 'Write .env', toolInput: undefined,1205confirmationTitle: undefined, edits: undefined,1206},1207permissionKind: 'write', permissionPath: '/workspace/.env',1208});12091210// .env would normally be blocked, but session-level auto-approve overrides1211assert.deepStrictEqual(agent.respondToPermissionCalls, [1212{ requestId: 'tc-bypass-1', approved: true },1213]);1214});12151216test('auto-approves shell commands when autoApprove is set to autopilot', () => {1217setupSessionWithConfig('autopilot');1218startTurn('turn-1');1219disposables.add(sideEffects.registerProgressListener(agent));12201221agent.fireProgress({1222kind: 'action', session: sessionUri,1223action: {1224type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1225toolCallId: 'tc-ap-shell-1', toolName: 'shell', displayName: 'Shell', toolClientId: undefined,1226_meta: { toolKind: undefined, language: undefined },1227},1228});1229agent.fireProgress({1230kind: 'action', session: sessionUri,1231action: {1232type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',1233toolCallId: 'tc-ap-shell-1', invocationMessage: 'Run rm -rf /', toolInput: undefined,1234confirmed: ToolCallConfirmationReason.NotNeeded,1235},1236});12371238agent.fireProgress({1239kind: 'pending_confirmation', session: sessionUri,1240state: {1241status: ToolCallStatus.PendingConfirmation,1242toolCallId: 'tc-ap-shell-1', toolName: '', displayName: '',1243invocationMessage: 'Run rm -rf /', toolInput: 'rm -rf /',1244confirmationTitle: undefined, edits: undefined,1245},1246permissionKind: 'shell', permissionPath: undefined,1247});12481249// Dangerous command would normally be blocked, but session-level auto-approve overrides1250assert.deepStrictEqual(agent.respondToPermissionCalls, [1251{ requestId: 'tc-ap-shell-1', approved: true },1252]);1253});12541255test('does NOT auto-approve when autoApprove is default', () => {1256setupSessionWithConfig('default');1257startTurn('turn-1');1258disposables.add(sideEffects.registerProgressListener(agent));12591260agent.fireProgress({1261kind: 'action', session: sessionUri,1262action: {1263type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1264toolCallId: 'tc-default-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,1265_meta: { toolKind: undefined, language: undefined },1266},1267});1268agent.fireProgress({1269kind: 'action', session: sessionUri,1270action: {1271type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',1272toolCallId: 'tc-default-1', invocationMessage: 'Write .env', toolInput: undefined,1273confirmed: ToolCallConfirmationReason.NotNeeded,1274},1275});12761277agent.fireProgress({1278kind: 'pending_confirmation', session: sessionUri,1279state: {1280status: ToolCallStatus.PendingConfirmation,1281toolCallId: 'tc-default-1', toolName: '', displayName: '',1282invocationMessage: 'Write .env', toolInput: undefined,1283confirmationTitle: undefined, edits: undefined,1284},1285permissionKind: 'write', permissionPath: '/workspace/.env',1286});12871288// .env should still be blocked with default config1289assert.strictEqual(agent.respondToPermissionCalls.length, 0);1290});12911292test('respects mid-session config change via SessionConfigChanged', () => {1293setupSessionWithConfig('default');1294startTurn('turn-1');1295disposables.add(sideEffects.registerProgressListener(agent));12961297// Change to bypass mid-session1298stateManager.dispatchServerAction({1299type: ActionType.SessionConfigChanged,1300session: sessionUri.toString(),1301config: { autoApprove: 'autoApprove' },1302});13031304agent.fireProgress({1305kind: 'action', session: sessionUri,1306action: {1307type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1308toolCallId: 'tc-mid-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,1309_meta: { toolKind: undefined, language: undefined },1310},1311});1312agent.fireProgress({1313kind: 'action', session: sessionUri,1314action: {1315type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',1316toolCallId: 'tc-mid-1', invocationMessage: 'Write .env', toolInput: undefined,1317confirmed: ToolCallConfirmationReason.NotNeeded,1318},1319});13201321agent.fireProgress({1322kind: 'pending_confirmation', session: sessionUri,1323state: {1324status: ToolCallStatus.PendingConfirmation,1325toolCallId: 'tc-mid-1', toolName: '', displayName: '',1326invocationMessage: 'Write .env', toolInput: undefined,1327confirmationTitle: undefined, edits: undefined,1328},1329permissionKind: 'write', permissionPath: '/workspace/.env',1330});13311332// Should now be auto-approved after config change1333assert.deepStrictEqual(agent.respondToPermissionCalls, [1334{ requestId: 'tc-mid-1', approved: true },1335]);1336});1337});13381339// ---- Edit auto-approve ----------------------------------------------13401341suite('edit auto-approve', () => {13421343test('auto-approves writes to regular source files', async () => {1344setupSession(URI.file('/workspace').toString());1345startTurn('turn-1');1346disposables.add(sideEffects.registerProgressListener(agent));13471348agent.fireProgress({1349kind: 'action', session: sessionUri,1350action: {1351type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1352toolCallId: 'tc-auto-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,1353_meta: { toolKind: undefined, language: undefined },1354},1355});1356agent.fireProgress({1357kind: 'action', session: sessionUri,1358action: {1359type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',1360toolCallId: 'tc-auto-1', invocationMessage: 'Write file', toolInput: undefined,1361confirmed: ToolCallConfirmationReason.NotNeeded,1362},1363});13641365agent.fireProgress({1366kind: 'pending_confirmation', session: sessionUri,1367state: {1368status: ToolCallStatus.PendingConfirmation,1369toolCallId: 'tc-auto-1', toolName: '', displayName: '',1370invocationMessage: 'Write src/app.ts', toolInput: undefined,1371confirmationTitle: undefined, edits: undefined,1372},1373permissionKind: 'write', permissionPath: '/workspace/src/app.ts',1374});13751376// Auto-approved writes call respondToPermissionRequest directly1377assert.deepStrictEqual(agent.respondToPermissionCalls, [1378{ requestId: 'tc-auto-1', approved: true },1379]);1380});13811382test('blocks writes to .env files', () => {1383setupSession(URI.file('/workspace').toString());1384startTurn('turn-1');1385disposables.add(sideEffects.registerProgressListener(agent));13861387const envelopes: ActionEnvelope[] = [];1388disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));13891390agent.fireProgress({1391kind: 'action', session: sessionUri,1392action: {1393type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1394toolCallId: 'tc-env-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,1395_meta: { toolKind: undefined, language: undefined },1396},1397});1398agent.fireProgress({1399kind: 'action', session: sessionUri,1400action: {1401type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',1402toolCallId: 'tc-env-1', invocationMessage: 'Write .env', toolInput: undefined,1403confirmed: ToolCallConfirmationReason.NotNeeded,1404},1405});14061407agent.fireProgress({1408kind: 'pending_confirmation', session: sessionUri,1409state: {1410status: ToolCallStatus.PendingConfirmation,1411toolCallId: 'tc-env-1', toolName: '', displayName: '',1412invocationMessage: 'Write .env', toolInput: undefined,1413confirmationTitle: 'Write .env', edits: undefined,1414},1415permissionKind: 'write', permissionPath: '/workspace/.env',1416});14171418// Should NOT auto-approve — .env is excluded1419assert.strictEqual(agent.respondToPermissionCalls.length, 0);14201421// Should dispatch a tool_ready action for the client to confirm1422const readyAction = envelopes.find(e => e.action.type === ActionType.SessionToolCallReady);1423assert.ok(readyAction, 'should dispatch tool_ready for blocked write');1424});14251426test('blocks writes to package.json', () => {1427setupSession(URI.file('/workspace').toString());1428startTurn('turn-1');1429disposables.add(sideEffects.registerProgressListener(agent));14301431agent.fireProgress({1432kind: 'action', session: sessionUri,1433action: {1434type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1435toolCallId: 'tc-pkg-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,1436_meta: { toolKind: undefined, language: undefined },1437},1438});1439agent.fireProgress({1440kind: 'action', session: sessionUri,1441action: {1442type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',1443toolCallId: 'tc-pkg-1', invocationMessage: 'Write package.json', toolInput: undefined,1444confirmed: ToolCallConfirmationReason.NotNeeded,1445},1446});14471448agent.fireProgress({1449kind: 'pending_confirmation', session: sessionUri,1450state: {1451status: ToolCallStatus.PendingConfirmation,1452toolCallId: 'tc-pkg-1', toolName: '', displayName: '',1453invocationMessage: 'Write package.json', toolInput: undefined,1454confirmationTitle: 'Write package.json', edits: undefined,1455},1456permissionKind: 'write', permissionPath: '/workspace/package.json',1457});14581459assert.strictEqual(agent.respondToPermissionCalls.length, 0);1460});14611462test('blocks writes to .lock files', () => {1463setupSession(URI.file('/workspace').toString());1464startTurn('turn-1');1465disposables.add(sideEffects.registerProgressListener(agent));14661467agent.fireProgress({1468kind: 'action', session: sessionUri,1469action: {1470type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1471toolCallId: 'tc-lock-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,1472_meta: { toolKind: undefined, language: undefined },1473},1474});1475agent.fireProgress({1476kind: 'action', session: sessionUri,1477action: {1478type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',1479toolCallId: 'tc-lock-1', invocationMessage: 'Write yarn.lock', toolInput: undefined,1480confirmed: ToolCallConfirmationReason.NotNeeded,1481},1482});14831484agent.fireProgress({1485kind: 'pending_confirmation', session: sessionUri,1486state: {1487status: ToolCallStatus.PendingConfirmation,1488toolCallId: 'tc-lock-1', toolName: '', displayName: '',1489invocationMessage: 'Write yarn.lock', toolInput: undefined,1490confirmationTitle: 'Write yarn.lock', edits: undefined,1491},1492permissionKind: 'write', permissionPath: '/workspace/yarn.lock',1493});14941495assert.strictEqual(agent.respondToPermissionCalls.length, 0);1496});14971498test('blocks writes to .git directory', () => {1499setupSession(URI.file('/workspace').toString());1500startTurn('turn-1');1501disposables.add(sideEffects.registerProgressListener(agent));15021503agent.fireProgress({1504kind: 'action', session: sessionUri,1505action: {1506type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1507toolCallId: 'tc-git-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,1508_meta: { toolKind: undefined, language: undefined },1509},1510});1511agent.fireProgress({1512kind: 'action', session: sessionUri,1513action: {1514type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',1515toolCallId: 'tc-git-1', invocationMessage: 'Write .git/config', toolInput: undefined,1516confirmed: ToolCallConfirmationReason.NotNeeded,1517},1518});15191520agent.fireProgress({1521kind: 'pending_confirmation', session: sessionUri,1522state: {1523status: ToolCallStatus.PendingConfirmation,1524toolCallId: 'tc-git-1', toolName: '', displayName: '',1525invocationMessage: 'Write .git/config', toolInput: undefined,1526confirmationTitle: 'Write .git/config', edits: undefined,1527},1528permissionKind: 'write', permissionPath: '/workspace/.git/config',1529});15301531assert.strictEqual(agent.respondToPermissionCalls.length, 0);1532});1533});15341535// ---- Read auto-approve -------------------------------------------------15361537suite('read auto-approve', () => {15381539test('auto-approves reads inside working directory', () => {1540setupSession(URI.file('/workspace').toString());1541startTurn('turn-1');1542disposables.add(sideEffects.registerProgressListener(agent));15431544agent.fireProgress({1545kind: 'action', session: sessionUri,1546action: {1547type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1548toolCallId: 'tc-read-1', toolName: 'read', displayName: 'Read', toolClientId: undefined,1549_meta: { toolKind: undefined, language: undefined },1550},1551});1552agent.fireProgress({1553kind: 'action', session: sessionUri,1554action: {1555type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',1556toolCallId: 'tc-read-1', invocationMessage: 'Read file', toolInput: undefined,1557confirmed: ToolCallConfirmationReason.NotNeeded,1558},1559});15601561agent.fireProgress({1562kind: 'pending_confirmation', session: sessionUri,1563state: {1564status: ToolCallStatus.PendingConfirmation,1565toolCallId: 'tc-read-1', toolName: '', displayName: '',1566invocationMessage: 'Read src/app.ts', toolInput: undefined,1567confirmationTitle: undefined, edits: undefined,1568},1569permissionKind: 'read', permissionPath: '/workspace/src/app.ts',1570});15711572assert.deepStrictEqual(agent.respondToPermissionCalls, [1573{ requestId: 'tc-read-1', approved: true },1574]);1575});15761577test('does not auto-approve reads outside working directory', () => {1578setupSession(URI.file('/workspace').toString());1579startTurn('turn-1');1580disposables.add(sideEffects.registerProgressListener(agent));15811582const envelopes: ActionEnvelope[] = [];1583disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));15841585agent.fireProgress({1586kind: 'action', session: sessionUri,1587action: {1588type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1589toolCallId: 'tc-read-2', toolName: 'read', displayName: 'Read', toolClientId: undefined,1590_meta: { toolKind: undefined, language: undefined },1591},1592});1593agent.fireProgress({1594kind: 'action', session: sessionUri,1595action: {1596type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',1597toolCallId: 'tc-read-2', invocationMessage: 'Read file', toolInput: undefined,1598confirmed: ToolCallConfirmationReason.NotNeeded,1599},1600});16011602agent.fireProgress({1603kind: 'pending_confirmation', session: sessionUri,1604state: {1605status: ToolCallStatus.PendingConfirmation,1606toolCallId: 'tc-read-2', toolName: '', displayName: '',1607invocationMessage: 'Read /etc/passwd', toolInput: undefined,1608confirmationTitle: undefined, edits: undefined,1609},1610permissionKind: 'read', permissionPath: '/etc/passwd',1611});16121613assert.strictEqual(agent.respondToPermissionCalls.length, 0);16141615const readyAction = envelopes.find(e => e.action.type === ActionType.SessionToolCallReady);1616assert.ok(readyAction, 'should dispatch tool_ready for read outside working directory');1617});1618});16191620// ---- Title persistence --------------------------------------------------16211622suite('title persistence', () => {16231624let sessionDb: SessionDatabase;16251626setup(async () => {1627sessionDb = disposables.add(await SessionDatabase.open(':memory:'));1628});16291630teardown(async () => {1631await sessionDb.close();1632});16331634test('SessionTitleChanged persists to the database', async () => {1635const sessionDataService = createSessionDataService(sessionDb);1636const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService()));1637const localAgent = new MockAgent();1638disposables.add(toDisposable(() => localAgent.dispose()));1639const localSideEffects = createTestSideEffects(disposables, localStateManager, {1640getAgent: () => localAgent,1641agents: observableValue<readonly IAgent[]>('agents', [localAgent]),1642sessionDataService,1643onTurnComplete: () => { },1644});16451646localStateManager.createSession({1647resource: sessionUri.toString(),1648provider: 'mock',1649title: 'Initial',1650status: SessionStatus.Idle,1651createdAt: Date.now(),1652modifiedAt: Date.now(),1653project: { uri: 'file:///test-project', displayName: 'Test Project' },1654});16551656localSideEffects.handleAction({1657type: ActionType.SessionTitleChanged,1658session: sessionUri.toString(),1659title: 'Custom Title',1660});16611662// Wait for the async persistence1663await new Promise(r => setTimeout(r, 50));16641665assert.strictEqual(await sessionDb.getMetadata('customTitle'), 'Custom Title');1666});16671668test('handleListSessions returns persisted custom title', async () => {1669const sessionDataService = createSessionDataService(sessionDb);1670const localAgent = new MockAgent();1671disposables.add(toDisposable(() => localAgent.dispose()));1672const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));1673localService.registerProvider(localAgent);16741675// Create a session on the agent backend1676await localAgent.createSession();16771678// Persist a custom title in the DB1679await sessionDb.setMetadata('customTitle', 'My Custom Title');16801681const sessions = await localService.listSessions();1682assert.strictEqual(sessions.length, 1);1683// Custom title comes from the DB and is returned via the agent's listSessions1684// The mock agent summary is used; the service doesn't read the DB for list1685assert.ok(sessions[0].summary);1686});16871688test('handleRestoreSession uses persisted custom title', async () => {1689const sessionDataService = createSessionDataService(sessionDb);1690const localAgent = new MockAgent();1691disposables.add(toDisposable(() => localAgent.dispose()));1692const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));1693localService.registerProvider(localAgent);16941695// Create a session on the agent backend1696const { session } = await localAgent.createSession();1697const sessions = await localAgent.listSessions();1698const sessionResource = sessions[0].session;16991700// Persist a custom title in the DB1701await sessionDb.setMetadata('customTitle', 'Restored Title');17021703// Set up minimal messages for restore1704localAgent.sessionMessages = [1705{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },1706{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },1707];17081709await localService.restoreSession(sessionResource);17101711const state = localService.stateManager.getSessionState(sessionResource.toString());1712assert.ok(state);1713assert.strictEqual(state!.summary.title, 'Restored Title');1714});17151716test('SessionConfigChanged persists merged config values to the database', async () => {1717const sessionDataService = createSessionDataService(sessionDb);1718const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService()));1719const localAgent = new MockAgent();1720disposables.add(toDisposable(() => localAgent.dispose()));1721const localSideEffects = createTestSideEffects(disposables, localStateManager, {1722getAgent: () => localAgent,1723agents: observableValue<readonly IAgent[]>('agents', [localAgent]),1724sessionDataService,1725onTurnComplete: () => { },1726});17271728const session = localStateManager.createSession({1729resource: sessionUri.toString(),1730provider: 'mock',1731title: 'Initial',1732status: SessionStatus.Idle,1733createdAt: Date.now(),1734modifiedAt: Date.now(),1735project: { uri: 'file:///test-project', displayName: 'Test Project' },1736});1737session.config = { schema: { type: 'object', properties: {} }, values: { autoApprove: 'default' } };17381739// Mid-session change merges new values into existing.1740localStateManager.dispatchClientAction({1741type: ActionType.SessionConfigChanged,1742session: sessionUri.toString(),1743config: { autoApprove: 'autoApprove' },1744}, { clientId: 'test-client', clientSeq: 1 });1745localSideEffects.handleAction({1746type: ActionType.SessionConfigChanged,1747session: sessionUri.toString(),1748config: { autoApprove: 'autoApprove' },1749});17501751await new Promise(r => setTimeout(r, 50));17521753const persisted = await sessionDb.getMetadata('configValues');1754assert.ok(persisted);1755assert.deepStrictEqual(JSON.parse(persisted!), { autoApprove: 'autoApprove' });1756});1757});17581759// ---- Subagent sessions ----------------------------------------------17601761suite('subagent sessions', () => {17621763test('subagent_started creates a subagent session and dispatches content on parent tool call', () => {1764setupSession();1765startTurn('turn-1');1766disposables.add(sideEffects.registerProgressListener(agent));17671768// Start a parent tool call1769agent.fireProgress({1770kind: 'action', session: sessionUri,1771action: {1772type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1773toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined,1774_meta: { toolKind: undefined, language: undefined },1775},1776});1777agent.fireProgress({1778kind: 'action', session: sessionUri,1779action: {1780type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',1781toolCallId: 'tc-1', invocationMessage: 'Delegating task...', toolInput: undefined,1782confirmed: ToolCallConfirmationReason.NotNeeded,1783},1784});17851786// Fire subagent_started1787agent.fireProgress({1788kind: 'subagent_started', session: sessionUri,1789toolCallId: 'tc-1',1790agentName: 'code-reviewer',1791agentDisplayName: 'Code Reviewer',1792agentDescription: 'Reviews code',1793});17941795// Verify the subagent session was created1796const subagentUri = `${sessionUri.toString()}/subagent/tc-1`;1797const subState = stateManager.getSessionState(subagentUri);1798assert.ok(subState, 'subagent session should exist');1799assert.strictEqual(subState!.summary.title, 'Code Reviewer');1800assert.ok(subState!.activeTurn, 'subagent should have an active turn');18011802// Verify content was dispatched on the parent tool call1803const parentState = stateManager.getSessionState(sessionUri.toString());1804assert.ok(parentState?.activeTurn);1805const parentToolCall = parentState!.activeTurn!.responseParts.find(1806rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1'1807);1808assert.ok(parentToolCall);1809if (parentToolCall?.kind === ResponsePartKind.ToolCall && parentToolCall.toolCall.status === ToolCallStatus.Running) {1810assert.ok(parentToolCall.toolCall.content);1811assert.strictEqual(parentToolCall.toolCall.content![0].type, ToolResultContentType.Subagent);1812}1813});18141815test('events with parentToolCallId route to subagent session', () => {1816setupSession();1817startTurn('turn-1');1818disposables.add(sideEffects.registerProgressListener(agent));18191820// Start parent tool + subagent1821agent.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 } } });1822agent.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 } });1823agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });18241825// Fire an inner tool start with parentToolCallId1826agent.fireProgress({1827kind: 'action', session: sessionUri, parentToolCallId: 'tc-1',1828action: {1829type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1830toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', toolClientId: undefined,1831_meta: { toolKind: undefined, language: undefined },1832},1833});1834agent.fireProgress({1835kind: 'action', session: sessionUri, parentToolCallId: 'tc-1',1836action: {1837type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',1838toolCallId: 'inner-tc-1', invocationMessage: 'Reading file...', toolInput: undefined,1839confirmed: ToolCallConfirmationReason.NotNeeded,1840},1841});18421843// Verify the inner tool call is on the subagent session's turn, not the parent1844const subagentUri = `${sessionUri.toString()}/subagent/tc-1`;1845const subState = stateManager.getSessionState(subagentUri);1846assert.ok(subState?.activeTurn);1847const innerTool = subState!.activeTurn!.responseParts.find(1848rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1'1849);1850assert.ok(innerTool, 'inner tool call should be in subagent session');18511852// Verify the parent session does NOT have the inner tool call1853const parentState = stateManager.getSessionState(sessionUri.toString());1854const parentInnerTool = parentState!.activeTurn!.responseParts.find(1855rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1'1856);1857assert.strictEqual(parentInnerTool, undefined, 'inner tool call should NOT be in parent session');1858});18591860test('completeSubagentSession clears pending buffered events when subagent never started', () => {1861// Regression: if the parent tool completes (or fails) before any1862// `subagent_started` arrives, buffered inner events would1863// otherwise leak in `_pendingSubagentEvents` until session1864// disposal. After completion, a late `subagent_started` for the1865// same toolCallId must not replay stale events.1866setupSession();1867startTurn('turn-1');1868disposables.add(sideEffects.registerProgressListener(agent));18691870agent.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 } } });1871agent.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 } });18721873// Inner event arrives but `subagent_started` never does.1874agent.fireProgress({1875kind: 'action', session: sessionUri, parentToolCallId: 'tc-1',1876action: {1877type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',1878toolCallId: 'inner-1', toolName: 'read', displayName: 'Read', toolClientId: undefined,1879_meta: { toolKind: undefined, language: undefined },1880},1881});1882agent.fireProgress({1883kind: 'action', session: sessionUri, parentToolCallId: 'tc-1',1884action: {1885type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',1886toolCallId: 'inner-1', invocationMessage: 'Reading...', toolInput: undefined,1887confirmed: ToolCallConfirmationReason.NotNeeded,1888},1889});18901891// Parent tool completes (e.g. it errored before delegating).1892agent.fireProgress({1893kind: 'action', session: sessionUri,1894action: {1895type: ActionType.SessionToolCallComplete, session: sessionUri.toString(), turnId: 'turn-1',1896toolCallId: 'tc-1',1897result: { success: false, pastTenseMessage: 'Failed' },1898},1899});19001901// Now a late `subagent_started` for the same toolCallId arrives.1902// This is unusual but possible after a reconnect/replay. The1903// drain must NOT replay the (cleared) buffered inner tool call.1904agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });19051906const subagentUri = `${sessionUri.toString()}/subagent/tc-1`;1907const subState = stateManager.getSessionState(subagentUri);1908assert.ok(subState, 'subagent session should still be created');1909const innerTool = subState!.activeTurn?.responseParts.find(1910rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-1'1911);1912assert.strictEqual(innerTool, undefined, 'stale buffered inner tool call must not be replayed');1913});19141915test('completeSubagentSession completes the subagent turn when parent tool completes', () => {1916setupSession();1917startTurn('turn-1');1918disposables.add(sideEffects.registerProgressListener(agent));19191920// Start parent tool + subagent1921agent.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 } } });1922agent.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 } });1923agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });19241925// Complete the parent tool call1926agent.fireProgress({1927kind: 'action', session: sessionUri,1928action: {1929type: ActionType.SessionToolCallComplete, session: sessionUri.toString(), turnId: 'turn-1',1930toolCallId: 'tc-1',1931result: { success: true, pastTenseMessage: 'Done' },1932},1933});19341935// Verify the subagent session's turn was completed1936const subagentUri = `${sessionUri.toString()}/subagent/tc-1`;1937const subState = stateManager.getSessionState(subagentUri);1938assert.ok(subState);1939assert.strictEqual(subState!.activeTurn, undefined, 'subagent turn should be completed');1940assert.strictEqual(subState!.turns.length, 1);1941});19421943test('cancelSubagentSessions cancels all subagent sessions', () => {1944setupSession();1945startTurn('turn-1');1946disposables.add(sideEffects.registerProgressListener(agent));19471948// Start two parent tool calls with subagents1949agent.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 } } });1950agent.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 } });1951agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'sub1', agentDisplayName: 'Sub 1', agentDescription: 'First' });19521953agent.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 } } });1954agent.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 } });1955agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-2', agentName: 'sub2', agentDisplayName: 'Sub 2', agentDescription: 'Second' });19561957// Cancel via parent turn cancellation1958sideEffects.handleAction({1959type: ActionType.SessionTurnCancelled,1960session: sessionUri.toString(),1961turnId: 'turn-1',1962});19631964// Both subagent sessions should have their turns completed (cancelled)1965const sub1 = stateManager.getSessionState(`${sessionUri.toString()}/subagent/tc-1`);1966const sub2 = stateManager.getSessionState(`${sessionUri.toString()}/subagent/tc-2`);1967assert.strictEqual(sub1?.activeTurn, undefined, 'sub1 turn should be cancelled');1968assert.strictEqual(sub2?.activeTurn, undefined, 'sub2 turn should be cancelled');1969});19701971test('removeSubagentSessions removes all subagent sessions from state', () => {1972setupSession();1973startTurn('turn-1');1974disposables.add(sideEffects.registerProgressListener(agent));19751976agent.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 } } });1977agent.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 } });1978agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'sub', agentDisplayName: 'Sub', agentDescription: 'Has subagent' });19791980const subagentUri = `${sessionUri.toString()}/subagent/tc-1`;1981assert.ok(stateManager.getSessionState(subagentUri));19821983sideEffects.removeSubagentSessions(sessionUri.toString());19841985assert.strictEqual(stateManager.getSessionState(subagentUri), undefined, 'subagent session should be removed');1986});19871988test('deltas with parentToolCallId route to subagent session', () => {1989setupSession();1990startTurn('turn-1');1991disposables.add(sideEffects.registerProgressListener(agent));19921993agent.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 } } });1994agent.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 } });1995agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });19961997// Fire a delta with parentToolCallId1998agent.fireProgress({1999kind: 'action', session: sessionUri, parentToolCallId: 'tc-1',2000action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-sub', content: 'thinking...' } },2001});20022003// Verify the delta went to the subagent session2004const subagentUri = `${sessionUri.toString()}/subagent/tc-1`;2005const subState = stateManager.getSessionState(subagentUri);2006assert.ok(subState?.activeTurn);2007const markdownPart = subState!.activeTurn!.responseParts.find(2008rp => rp.kind === ResponsePartKind.Markdown2009);2010assert.ok(markdownPart, 'delta should create a markdown part in subagent session');2011});20122013test('tool_complete preserves subagent content in completed tool call', () => {2014setupSession();2015startTurn('turn-1');2016disposables.add(sideEffects.registerProgressListener(agent));20172018agent.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 } } });2019agent.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 } });2020agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' });20212022// Verify subagent content is on the running tool2023const runningState = stateManager.getSessionState(sessionUri.toString());2024const runningTool = runningState?.activeTurn?.responseParts.find(2025rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1'2026);2027assert.ok(runningTool?.kind === ResponsePartKind.ToolCall);2028assert.strictEqual(runningTool.toolCall.status, ToolCallStatus.Running);20292030// Complete the tool — the SDK result has its own content2031agent.fireProgress({2032kind: 'action', session: sessionUri,2033action: {2034type: ActionType.SessionToolCallComplete, session: sessionUri.toString(), turnId: 'turn-1',2035toolCallId: 'tc-1',2036result: { success: true, pastTenseMessage: 'Delegated', content: [{ type: ToolResultContentType.Text, text: 'Done' }] },2037},2038});20392040// Verify the completed tool still has the subagent content entry2041const completedState = stateManager.getSessionState(sessionUri.toString());2042const completedTool = completedState?.activeTurn?.responseParts.find(2043rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1'2044);2045assert.ok(completedTool?.kind === ResponsePartKind.ToolCall);2046assert.strictEqual(completedTool.toolCall.status, ToolCallStatus.Completed);2047const content = completedTool.toolCall.content ?? [];2048const subagentEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent);2049assert.ok(subagentEntry, 'Completed tool should preserve subagent content entry');2050const textEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Text);2051assert.ok(textEntry, 'Completed tool should also have the SDK result content');2052});20532054test('inner tool_start arriving BEFORE subagent_started routes to subagent (not parent)', () => {2055// Reproduces the regression where inner subagent tool calls show up2056// flat at the top level of the parent session because the SDK can2057// emit `tool_start` (with parentToolCallId) before `subagent_started`.2058setupSession();2059startTurn('turn-1');2060disposables.add(sideEffects.registerProgressListener(agent));20612062// 1. Parent tool starts (the `task` invocation).2063agent.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 } } });2064agent.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 } });20652066// 2. Inner tool fires BEFORE subagent_started (race condition).2067agent.fireProgress({2068kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',2069action: {2070type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',2071toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', toolClientId: undefined,2072_meta: { toolKind: undefined, language: undefined },2073},2074});2075agent.fireProgress({2076kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',2077action: {2078type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',2079toolCallId: 'inner-tc-1', invocationMessage: 'Reading file...', toolInput: undefined,2080confirmed: ToolCallConfirmationReason.NotNeeded,2081},2082});20832084// 3. subagent_started arrives later.2085agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });20862087const subagentUri = buildSubagentSessionUri(sessionUri.toString(), 'tc-parent');2088const subState = stateManager.getSessionState(subagentUri);2089assert.ok(subState?.activeTurn, 'subagent session should exist');20902091const innerTool = subState!.activeTurn!.responseParts.find(2092rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1'2093);2094assert.ok(innerTool, 'inner tool fired before subagent_started should still end up in the subagent session');20952096// Parent must NOT have the inner tool.2097const parentState = stateManager.getSessionState(sessionUri.toString());2098const parentInnerTool = parentState!.activeTurn!.responseParts.find(2099rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1'2100);2101assert.strictEqual(parentInnerTool, undefined, 'inner tool must not leak into parent session');2102});21032104test('reads inside parent working directory are auto-approved for tools in subagent sessions', () => {2105// Subagent sessions don't carry their own workingDirectory or2106// autoApprove config. Without inheritance from the parent, every2107// tool call inside a subagent (even a read in the workspace) would2108// surface a confirmation dialog.2109setupSession(URI.file('/workspace').toString());2110startTurn('turn-1');2111disposables.add(sideEffects.registerProgressListener(agent));21122113// Parent task tool spawns a subagent.2114agent.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 } } });2115agent.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 } });2116agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });21172118// Inner tool inside the subagent requests permission to read a file2119// inside the parent workspace.2120agent.fireProgress({2121kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',2122action: {2123type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',2124toolCallId: 'inner-read-1', toolName: 'read', displayName: 'Read', toolClientId: undefined,2125_meta: { toolKind: undefined, language: undefined },2126},2127});2128agent.fireProgress({2129kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',2130action: {2131type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',2132toolCallId: 'inner-read-1', invocationMessage: 'Read file', toolInput: undefined,2133confirmed: ToolCallConfirmationReason.NotNeeded,2134},2135});2136agent.fireProgress({2137kind: 'pending_confirmation', session: sessionUri,2138state: {2139status: ToolCallStatus.PendingConfirmation,2140toolCallId: 'inner-read-1', toolName: '', displayName: '',2141invocationMessage: 'Read src/app.ts', toolInput: undefined,2142confirmationTitle: undefined, edits: undefined,2143},2144permissionKind: 'read', permissionPath: '/workspace/src/app.ts',2145});21462147assert.deepStrictEqual(agent.respondToPermissionCalls, [2148{ requestId: 'inner-read-1', approved: true },2149]);2150});21512152test('session-level autoApprove on the parent is inherited by tools in subagent sessions', () => {2153setupSession(URI.file('/workspace').toString());2154startTurn('turn-1');2155disposables.add(sideEffects.registerProgressListener(agent));21562157// Set the parent session to "Bypass Approvals" via session config.2158const parentState = stateManager.getSessionState(sessionUri.toString());2159if (parentState) {2160parentState.config = {2161schema: {2162type: 'object',2163properties: {2164autoApprove: {2165type: 'string',2166title: 'Approvals',2167enum: ['default', 'autoApprove', 'autopilot'],2168default: 'default',2169sessionMutable: true,2170},2171},2172},2173values: { autoApprove: 'autoApprove' },2174};2175}21762177agent.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 } } });2178agent.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 } });2179agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });21802181// Inner write outside the workspace would normally NOT auto-approve,2182// but session-level autoApprove on the parent must apply.2183agent.fireProgress({2184kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',2185action: {2186type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',2187toolCallId: 'inner-write-1', toolName: 'write', displayName: 'Write', toolClientId: undefined,2188_meta: { toolKind: undefined, language: undefined },2189},2190});2191agent.fireProgress({2192kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',2193action: {2194type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',2195toolCallId: 'inner-write-1', invocationMessage: 'Write file', toolInput: undefined,2196confirmed: ToolCallConfirmationReason.NotNeeded,2197},2198});2199agent.fireProgress({2200kind: 'pending_confirmation', session: sessionUri,2201state: {2202status: ToolCallStatus.PendingConfirmation,2203toolCallId: 'inner-write-1', toolName: '', displayName: '',2204invocationMessage: 'Write /tmp/foo', toolInput: undefined,2205confirmationTitle: undefined, edits: undefined,2206},2207permissionKind: 'write', permissionPath: '/tmp/foo',2208});22092210assert.deepStrictEqual(agent.respondToPermissionCalls, [2211{ requestId: 'inner-write-1', approved: true },2212]);2213});2214});22152216// ---- Session permissions ------------------------------------------------22172218suite('session permissions', () => {22192220test('tool_ready action includes confirmation options when confirmation is needed', () => {2221setupSession();2222startTurn('turn-1');2223disposables.add(sideEffects.registerProgressListener(agent));22242225agent.fireProgress({2226kind: 'action', session: sessionUri,2227action: {2228type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',2229toolCallId: 'tc-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined,2230_meta: { toolKind: undefined, language: undefined },2231},2232});2233agent.fireProgress({2234kind: 'action', session: sessionUri,2235action: {2236type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',2237toolCallId: 'tc-perm-1', invocationMessage: 'Running custom tool', toolInput: undefined,2238confirmed: ToolCallConfirmationReason.NotNeeded,2239},2240});22412242agent.fireProgress({2243kind: 'pending_confirmation', session: sessionUri,2244state: {2245status: ToolCallStatus.PendingConfirmation,2246toolCallId: 'tc-perm-1', toolName: '', displayName: '',2247invocationMessage: 'Run custom tool', toolInput: undefined,2248confirmationTitle: 'Run custom tool', edits: undefined,2249},2250permissionKind: 'custom-tool', permissionPath: undefined,2251});22522253const state = stateManager.getSessionState(sessionUri.toString());2254const tc = state!.activeTurn!.responseParts.find(2255rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-perm-1'2256);2257assert.ok(tc && tc.kind === ResponsePartKind.ToolCall, 'tool call should exist');2258assert.strictEqual(tc.toolCall.status, ToolCallStatus.PendingConfirmation);2259assert.ok(Array.isArray(tc.toolCall.options), 'options should be an array');2260assert.deepStrictEqual(tc.toolCall.options!.map(o => o.id), ['allow-session', 'allow-once', 'skip']);2261});22622263test('SessionToolCallConfirmed with allow-session adds tool to session permissions', () => {2264setupSession();2265const state = stateManager.getSessionState(sessionUri.toString());2266if (state) {2267state.config = {2268schema: { type: 'object', properties: {} },2269values: {},2270};2271}2272startTurn('turn-1');2273disposables.add(sideEffects.registerProgressListener(agent));22742275agent.fireProgress({2276kind: 'action', session: sessionUri,2277action: {2278type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',2279toolCallId: 'tc-perm-2', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined,2280_meta: { toolKind: undefined, language: undefined },2281},2282});2283agent.fireProgress({2284kind: 'action', session: sessionUri,2285action: {2286type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',2287toolCallId: 'tc-perm-2', invocationMessage: 'Running custom tool', toolInput: undefined,2288confirmed: ToolCallConfirmationReason.NotNeeded,2289},2290});22912292agent.fireProgress({2293kind: 'pending_confirmation', session: sessionUri,2294state: {2295status: ToolCallStatus.PendingConfirmation,2296toolCallId: 'tc-perm-2', toolName: '', displayName: '',2297invocationMessage: 'Run custom tool', toolInput: undefined,2298confirmationTitle: 'Run custom tool', edits: undefined,2299},2300permissionKind: 'custom-tool', permissionPath: undefined,2301});23022303sideEffects.handleAction({2304type: ActionType.SessionToolCallConfirmed,2305session: sessionUri.toString(),2306turnId: 'turn-1',2307toolCallId: 'tc-perm-2',2308approved: true,2309confirmed: 'user-action' as const,2310selectedOptionId: 'allow-session',2311} as SessionAction);23122313const updatedState = stateManager.getSessionState(sessionUri.toString());2314assert.deepStrictEqual(2315updatedState!.config!.values.permissions,2316{ allow: ['CustomTool'], deny: [] },2317);2318});23192320test('subsequent tool_ready for same tool is auto-approved after allow-session permission', () => {2321setupSession();2322const state = stateManager.getSessionState(sessionUri.toString());2323if (state) {2324state.config = {2325schema: { type: 'object', properties: {} },2326values: { permissions: { allow: ['CustomTool'], deny: [] } },2327};2328}2329startTurn('turn-1');2330disposables.add(sideEffects.registerProgressListener(agent));23312332agent.fireProgress({2333kind: 'action', session: sessionUri,2334action: {2335type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',2336toolCallId: 'tc-perm-3', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined,2337_meta: { toolKind: undefined, language: undefined },2338},2339});2340agent.fireProgress({2341kind: 'action', session: sessionUri,2342action: {2343type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',2344toolCallId: 'tc-perm-3', invocationMessage: 'Running custom tool', toolInput: undefined,2345confirmed: ToolCallConfirmationReason.NotNeeded,2346},2347});23482349agent.fireProgress({2350kind: 'pending_confirmation', session: sessionUri,2351state: {2352status: ToolCallStatus.PendingConfirmation,2353toolCallId: 'tc-perm-3', toolName: '', displayName: '',2354invocationMessage: 'Run custom tool', toolInput: undefined,2355confirmationTitle: 'Run custom tool', edits: undefined,2356},2357permissionKind: 'custom-tool', permissionPath: undefined,2358});23592360assert.deepStrictEqual(agent.respondToPermissionCalls, [2361{ requestId: 'tc-perm-3', approved: true },2362]);2363});23642365test('subagent tool calls inherit parent session permissions', () => {2366setupSession();2367const state = stateManager.getSessionState(sessionUri.toString());2368if (state) {2369state.config = {2370schema: { type: 'object', properties: {} },2371values: { permissions: { allow: ['CustomTool'], deny: [] } },2372};2373}2374startTurn('turn-1');2375disposables.add(sideEffects.registerProgressListener(agent));23762377agent.fireProgress({2378kind: 'action', session: sessionUri,2379action: {2380type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',2381toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', toolClientId: undefined,2382_meta: { toolKind: undefined, language: undefined },2383},2384});2385agent.fireProgress({2386kind: 'action', session: sessionUri,2387action: {2388type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',2389toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined,2390confirmed: ToolCallConfirmationReason.NotNeeded,2391},2392});2393agent.fireProgress({2394kind: 'subagent_started', session: sessionUri,2395toolCallId: 'tc-parent',2396agentName: 'helper',2397agentDisplayName: 'Helper',2398agentDescription: 'Helps',2399});24002401agent.fireProgress({2402kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',2403action: {2404type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1',2405toolCallId: 'inner-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined,2406_meta: { toolKind: undefined, language: undefined },2407},2408});2409agent.fireProgress({2410kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent',2411action: {2412type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1',2413toolCallId: 'inner-perm-1', invocationMessage: 'Running custom tool', toolInput: undefined,2414confirmed: ToolCallConfirmationReason.NotNeeded,2415},2416});24172418agent.fireProgress({2419kind: 'pending_confirmation', session: sessionUri,2420state: {2421status: ToolCallStatus.PendingConfirmation,2422toolCallId: 'inner-perm-1', toolName: '', displayName: '',2423invocationMessage: 'Run custom tool', toolInput: undefined,2424confirmationTitle: 'Run custom tool', edits: undefined,2425},2426permissionKind: 'custom-tool', permissionPath: undefined,2427});24282429assert.deepStrictEqual(agent.respondToPermissionCalls, [2430{ requestId: 'inner-perm-1', approved: true },2431]);2432});2433});24342435// ---- Session diff computation ----------------------------------------------24362437suite('session diff computation', () => {24382439test('git-driven path is preferred when a git service is provided and the working dir is a git work tree', async () => {2440const sessionDb = new SessionDatabase(':memory:');2441disposables.add(toDisposable(() => sessionDb.close()));2442const sessionDataService = createSessionDataService(sessionDb);2443const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService()));2444const localAgent = new MockAgent();2445disposables.add(toDisposable(() => localAgent.dispose()));24462447const gitDiffs = [{2448after: { uri: 'file:///wd/new.ts', content: { uri: 'file:///wd/new.ts' } },2449diff: { added: 1, removed: 0 },2450}];2451const computeCalls: { workingDirectory: string; sessionUri: string; baseBranch: string | undefined }[] = [];2452const stubGit = {2453computeSessionFileDiffs: async (wd: URI, opts: { sessionUri: string; baseBranch?: string }) => {2454computeCalls.push({ workingDirectory: wd.toString(), sessionUri: opts.sessionUri, baseBranch: opts.baseBranch });2455return gitDiffs;2456},2457} as unknown as import('../../node/agentHostGitService.js').IAgentHostGitService;24582459const localSideEffects = createTestSideEffects(disposables, localStateManager, {2460getAgent: () => localAgent,2461agents: observableValue<readonly IAgent[]>('agents', [localAgent]),2462sessionDataService,2463onTurnComplete: () => { },2464}, stubGit);24652466localStateManager.createSession({2467resource: sessionUri.toString(),2468provider: 'mock',2469title: 'Test',2470status: SessionStatus.Idle,2471createdAt: Date.now(),2472modifiedAt: Date.now(),2473workingDirectory: 'file:///wd',2474});2475await sessionDb.setMetadata('agentHost.diffBaseBranch', 'main');2476disposables.add(localSideEffects.registerProgressListener(localAgent));24772478const envelopes: ActionEnvelope[] = [];2479let resolveDiffs: (() => void) | undefined;2480const diffsEmitted = new Promise<void>(r => { resolveDiffs = r; });2481disposables.add(localStateManager.onDidEmitEnvelope(e => {2482envelopes.push(e);2483if (e.action.type === ActionType.SessionDiffsChanged) {2484resolveDiffs?.();2485}2486}));24872488// Trigger a turn-complete (which fires the immediate diff path).2489localSideEffects.handleAction({2490type: ActionType.SessionTurnStarted,2491session: sessionUri.toString(),2492turnId: 'turn-1',2493userMessage: { text: 'hi' },2494});2495localAgent.fireProgress({2496kind: 'action', session: URI.parse(sessionUri.toString()),2497action: { type: ActionType.SessionTurnComplete, session: sessionUri.toString(), turnId: 'turn-1' },2498});24992500// Wait deterministically for the SessionDiffsChanged envelope rather2501// than sleeping a fixed amount.2502await diffsEmitted;25032504assert.deepStrictEqual(computeCalls, [{ workingDirectory: 'file:///wd', sessionUri: sessionUri.toString(), baseBranch: 'main' }]);2505const diffsAction = envelopes.map(e => e.action).find(a => a.type === ActionType.SessionDiffsChanged);2506assert.ok(diffsAction, 'expected a SessionDiffsChanged action');2507assert.deepStrictEqual((diffsAction as { diffs: unknown }).diffs, gitDiffs);2508});25092510test('falls back to the edit-tracker aggregator when the git service returns undefined', async () => {2511const sessionDb = new SessionDatabase(':memory:');2512disposables.add(toDisposable(() => sessionDb.close()));2513const sessionDataService = createSessionDataService(sessionDb);2514const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService()));2515const localAgent = new MockAgent();2516disposables.add(toDisposable(() => localAgent.dispose()));25172518const stubGit = {2519computeSessionFileDiffs: async () => undefined,2520} as unknown as import('../../node/agentHostGitService.js').IAgentHostGitService;25212522const localSideEffects = createTestSideEffects(disposables, localStateManager, {2523getAgent: () => localAgent,2524agents: observableValue<readonly IAgent[]>('agents', [localAgent]),2525sessionDataService,2526onTurnComplete: () => { },2527}, stubGit);25282529localStateManager.createSession({2530resource: sessionUri.toString(),2531provider: 'mock',2532title: 'Test',2533status: SessionStatus.Idle,2534createdAt: Date.now(),2535modifiedAt: Date.now(),2536workingDirectory: 'file:///wd',2537});2538disposables.add(localSideEffects.registerProgressListener(localAgent));25392540const envelopes: ActionEnvelope[] = [];2541let resolveDiffs: (() => void) | undefined;2542const diffsEmitted = new Promise<void>(r => { resolveDiffs = r; });2543disposables.add(localStateManager.onDidEmitEnvelope(e => {2544envelopes.push(e);2545if (e.action.type === ActionType.SessionDiffsChanged) {2546resolveDiffs?.();2547}2548}));25492550localSideEffects.handleAction({2551type: ActionType.SessionTurnStarted,2552session: sessionUri.toString(),2553turnId: 'turn-1',2554userMessage: { text: 'hi' },2555});2556localAgent.fireProgress({2557kind: 'action', session: URI.parse(sessionUri.toString()),2558action: { type: ActionType.SessionTurnComplete, session: sessionUri.toString(), turnId: 'turn-1' },2559});25602561await diffsEmitted;25622563// With no recorded edits, the edit-tracker aggregator returns an empty array — the2564// important assertion is that we still produced a SessionDiffsChanged envelope, which2565// proves the fallback path executed without throwing.2566const diffsAction = envelopes.map(e => e.action).find(a => a.type === ActionType.SessionDiffsChanged);2567assert.ok(diffsAction, 'expected a SessionDiffsChanged action from the fallback path');2568assert.deepStrictEqual((diffsAction as { diffs: unknown[] }).diffs, []);2569});2570});2571});257225732574