Files
vscode/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts
T
Justin ChenGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
de6ea36c9e chat: add timestamps and elapsed time (#325061)
* chat: add timestamps and elapsed time

* put behind setting!

* fix tests

* ah timestamp fixes and better animations!

* switch to use new protocol changes

* bump ahp version

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-14 17:18:52 +00:00

4224 lines
183 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { VSBuffer } from '../../../../base/common/buffer.js';
import { timeout } from '../../../../base/common/async.js';
import { Event } from '../../../../base/common/event.js';
import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../base/common/network.js';
import { observableValue } from '../../../../base/common/observable.js';
import { hasKey } from '../../../../base/common/types.js';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { FileService } from '../../../files/common/fileService.js';
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
import { InstantiationService } from '../../../instantiation/common/instantiationService.js';
import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js';
import { ILogService, NullLogService } from '../../../log/common/log.js';
import { AgentSession, IAgent, SubagentChatSignal } from '../../common/agentService.js';
import { buildDefaultChangesetCatalog } from '../../common/changesetUri.js';
import { ISessionDataService } from '../../common/sessionDataService.js';
import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js';
import { ChangesSummary, ChatOriginKind, CustomizationType, SessionInputRequestKind } from '../../common/state/protocol/state.js';
import { ActionType, ActionEnvelope, type ChatAction, type INotification, type SessionAction } from '../../common/state/sessionActions.js';
import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, ChatInteractivity, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, customizationId, type ClientPluginCustomization, type Customization, type PluginCustomization } from '../../common/state/sessionState.js';
import { IProductService } from '../../../product/common/productService.js';
import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js';
import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js';
import { AgentHostTelemetryLevelConfigKey, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js';
import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js';
import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js';
import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js';
import { IAgentHostChangesetService, StaticChangesetKind } from '../../common/agentHostChangesetService.js';
import { IAgentHostGitService } from '../../common/agentHostGitService.js';
import { AgentService } from '../../node/agentService.js';
import { AgentSideEffects, IAgentSideEffectsOptions } from '../../node/agentSideEffects.js';
import { AgentHostLocalTurns } from '../../node/agentHostLocalTurns.js';
import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js';
import { SessionDatabase } from '../../node/sessionDatabase.js';
import { AgentHostStateManager } from '../../node/agentHostStateManager.js';
import { createNoopGitService, createNullSessionDataService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js';
import { MockAgent } from './mockAgent.js';
import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js';
// ---- Tests ------------------------------------------------------------------
/** Spy `IAgentHostChangesetService` used to assert AgentSideEffects forwarding. */
class FakeChangesetService implements IAgentHostChangesetService {
declare readonly _serviceBrand: undefined;
readonly toolCallEdits: { session: string; turnId: string }[] = [];
readonly turnCompletes: { session: string; turnId: string | undefined }[] = [];
readonly truncates: string[] = [];
registerStaticChangesets(): void { /* no-op for routing tests */ }
restoreStaticChangeset(_session: string, _kind: StaticChangesetKind, _diffs: readonly unknown[]): void { /* no-op */ }
parsePersistedStaticChangesets(): { session?: undefined } { return {}; }
applyPersistedStaticChangesets(): void { /* no-op */ }
restorePersistedStaticChangesets(): { session?: undefined } { return {}; }
persistChangesSummary(session: string, changesSummary: ChangesSummary): void { /* no-op */ }
isStaticChangesetComputeActive(): boolean { return false; }
getListMetadataKeys(_sessionUri: string): Record<string, true> | undefined { return undefined; }
computeListEntryChanges(_sessionUri: string, _metadata: Record<string, string | undefined>): ChangesSummary | undefined { return undefined; }
refreshChangesetCatalog(session: string): void { /* no-op */ }
refreshBranchChangeset(): void { /* no-op */ }
refreshSessionChangeset(): void { /* no-op */ }
onWorkingDirectoryAvailable(): void { /* no-op */ }
recomputeSubscribedChangesets(): void { /* no-op */ }
onSessionDisposed(): void { /* no-op */ }
async computeUncommittedChangeset(session: string): Promise<string> { return `${session}/changeset/uncommitted`; }
async computeTurnChangeset(session: string): Promise<string> { return `${session}/changeset/turn/x`; }
async computeCompareTurnsChangeset(session: string, originalTurnId: string, modifiedTurnId: string): Promise<string> {
return `${session}/changeset/compare/${originalTurnId}/${modifiedTurnId}`;
}
onToolCallEditsApplied(session: string, turnId: string): void {
this.toolCallEdits.push({ session, turnId });
}
onTurnComplete(session: string, turnId: string | undefined): void {
this.turnCompletes.push({ session, turnId });
}
onSessionTruncated(session: string): void {
this.truncates.push(session);
}
}
/**
* Constructs an {@link AgentSideEffects} with a minimal local instantiation
* scope that satisfies its {@link IAgentConfigurationService} /
* {@link ILogService} / {@link IAgentHostChangesetService} dependencies.
* `gitService` is no longer required by `AgentSideEffects` itself (moved
* to {@link IAgentHostChangesetService}); it is kept here as a leftover
* for any future tests that need to override the no-op git service via
* the changeset fake's underlying implementation.
*/
function createTestSideEffects(
disposables: DisposableStore,
stateManager: AgentHostStateManager,
options: Omit<IAgentSideEffectsOptions, 'localTurns'> & { localTurns?: AgentHostLocalTurns },
_gitService?: IAgentHostGitService,
telemetryService: ITelemetryService = NullTelemetryService,
changesets: IAgentHostChangesetService = new FakeChangesetService(),
terminalManager: IAgentHostTerminalManager = disposables.add(new TestAgentHostTerminalManager()),
): AgentSideEffects {
const logService = new NullLogService();
const configService = disposables.add(new AgentConfigurationService(stateManager, logService));
const instantiationService = disposables.add(new InstantiationService(new ServiceCollection(
[ILogService, logService],
[IAgentConfigurationService, configService],
[IAgentHostChangesetService, changesets],
[IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE],
[ITelemetryService, telemetryService],
[IAgentHostTerminalManager, terminalManager],
[ISessionDataService, options.sessionDataService],
), /*strict*/ true));
const resolvedOptions: IAgentSideEffectsOptions = {
...options,
localTurns: options.localTurns ?? new AgentHostLocalTurns(options.sessionDataService, logService),
};
return disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, resolvedOptions));
}
class TestTelemetryService implements ITelemetryService {
declare readonly _serviceBrand: undefined;
readonly telemetryLevel = TelemetryLevel.USAGE;
readonly sessionId = 'test-session';
readonly machineId = 'test-machine';
readonly sqmId = 'test-sqm';
readonly devDeviceId = 'test-dev-device';
readonly firstSessionDate = 'test-first-session-date';
readonly sendErrorTelemetry = false;
readonly events: { eventName: string; data: unknown }[] = [];
publicLog(): void { }
publicLog2(eventName: string, data?: unknown): void {
this.events.push({ eventName, data });
}
publicLogError(): void { }
publicLogError2(): void { }
setExperimentProperty(): void { }
setCommonProperty(): void { }
}
suite('AgentSideEffects', () => {
const disposables = new DisposableStore();
let fileService: FileService;
let stateManager: AgentHostStateManager;
let agent: MockAgent;
let sideEffects: AgentSideEffects;
let agentList: ReturnType<typeof observableValue<readonly IAgent[]>>;
let telemetryService: TestTelemetryService;
const sessionUri = AgentSession.uri('mock', 'session-1');
const defaultChatUri = buildDefaultChatUri(sessionUri);
function setupSession(workingDirectory?: string): void {
stateManager.createSession({
resource: sessionUri.toString(),
provider: 'mock',
title: 'Test',
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
project: { uri: 'file:///test-project', displayName: 'Test Project' },
workingDirectory,
});
stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalog(sessionUri.toString()));
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, });
}
function startTurn(turnId: string, channel = defaultChatUri): void {
stateManager.dispatchClientAction(channel, { type: ActionType.ChatTurnStarted, turnId, startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } } },
{ clientId: 'test', clientSeq: 1 },
);
}
/**
* Resolves with the first non-`undefined` value returned by `match`,
* re-evaluating it immediately and after every envelope emitted by the
* state manager. Used to await the async tool-approval pipeline
* (`_handleToolReady` -> `getAutoApproval` -> `realpath`) deterministically
* instead of depending on a fixed settle delay.
*/
function waitForState<T>(manager: AgentHostStateManager, match: () => T | undefined): Promise<T> {
return new Promise<T>((resolve, reject) => {
const initial = match();
if (initial !== undefined) {
resolve(initial);
return;
}
const store = new DisposableStore();
const timer = setTimeout(() => {
store.dispose();
reject(new Error('waitForState: condition was not met'));
}, 5000);
store.add(toDisposable(() => clearTimeout(timer)));
store.add(manager.onDidEmitEnvelope(() => {
const value = match();
if (value !== undefined) {
store.dispose();
resolve(value);
}
}));
});
}
async function waitForSendMessageCalls(count: number): Promise<void> {
if (agent.sendMessageCalls.length >= count) {
return;
}
await Event.toPromise(Event.filter(agent.onDidSendMessage, () => agent.sendMessageCalls.length >= count));
}
setup(async () => {
fileService = disposables.add(new FileService(new NullLogService()));
const memFs = disposables.add(new InMemoryFileSystemProvider());
disposables.add(fileService.registerProvider(Schemas.inMemory, memFs));
// Seed a file so the handleBrowseDirectory tests can distinguish files from dirs
const testDir = URI.from({ scheme: Schemas.inMemory, path: '/testDir' });
await fileService.createFolder(testDir);
await fileService.writeFile(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }), VSBuffer.fromString('hello'));
agent = new MockAgent();
disposables.add(toDisposable(() => agent.dispose()));
stateManager = disposables.add(new AgentHostStateManager(new NullLogService()));
agentList = observableValue<readonly IAgent[]>('agents', [agent]);
telemetryService = new TestTelemetryService();
sideEffects = createTestSideEffects(disposables, stateManager, {
getAgent: () => agent,
agents: agentList,
sessionDataService: createNullSessionDataService(),
onTurnComplete: () => { },
}, undefined, disposables.add(new AgentHostTelemetryService(telemetryService)));
// Mimic the orchestrator's spawn channel: in production AgentService adds
// a subagent's chat to the catalog (via _onChatSpawned) before
// AgentSideEffects starts its turn. Registered here (ahead of each test's
// registerProgressListener) so the subagent chat exists first. addChat is
// idempotent, matching the real spawn-channel/side-effects overlap.
disposables.add(agent.onDidSessionProgress(signal => {
const spawn = SubagentChatSignal.toSpawnEvent(signal);
if (spawn) {
stateManager.addChat(spawn.session.toString(), spawn.chat.toString(), {
title: spawn.title,
origin: spawn.parent ? { kind: ChatOriginKind.Tool, chat: spawn.parent.chat.toString(), toolCallId: spawn.parent.toolCallId } : undefined,
});
}
}));
});
teardown(() => {
disposables.clear();
});
ensureNoDisposablesAreLeakedInTestSuite();
// ---- handleAction: session/turnStarted ------------------------------
suite('handleAction — session/turnStarted', () => {
test('calls sendMessage on the agent', async () => {
setupSession();
const action: ChatAction = {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'hello world', origin: { kind: MessageKind.User } },
};
sideEffects.handleAction(defaultChatUri, action);
await waitForSendMessageCalls(1);
assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello world', attachments: undefined, chat: URI.parse(defaultChatUri) }]);
});
test('passes the dispatching client id to sendMessage', async () => {
setupSession();
const action: ChatAction = {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'hello world', origin: { kind: MessageKind.User } },
};
sideEffects.handleAction(defaultChatUri, action, 'client-B');
await waitForSendMessageCalls(1);
assert.deepStrictEqual(agent.sendMessageCalls, [{
session: URI.parse(sessionUri.toString()),
prompt: 'hello world',
attachments: undefined,
chat: URI.parse(defaultChatUri),
senderClientId: 'client-B',
}]);
});
test('logs telemetry when sending a direct user message', () => {
setupSession();
const activeClientAction: SessionAction = {
type: ActionType.SessionActiveClientSet,
activeClient: {
clientId: 'test-client',
tools: [{ name: 'testTool', inputSchema: { type: 'object' } }],
customizations: [{ type: CustomizationType.Plugin, id: customizationId('file:///customizations/SKILL.md'), uri: 'file:///customizations/SKILL.md', name: 'Test Skill', enabled: true }]
},
};
stateManager.dispatchClientAction(sessionUri.toString(), activeClientAction, { clientId: 'test', clientSeq: 1 });
sideEffects.handleAction(sessionUri.toString(), activeClientAction);
const fileUri = URI.file('/workspace/direct.ts');
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'hello world', origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'direct.ts', displayKind: 'document' }] },
});
assert.deepStrictEqual(telemetryService.events, [{
eventName: 'agentHost.userMessageSent',
data: {
provider: 'mock',
agentSessionId: 'session-1',
source: 'direct',
isSubagentSession: false,
turnCount: 0,
activeClientId: 'test-client',
activeClientToolCount: 1,
activeClientCustomizationCount: 1,
attachmentCount: 1,
},
}]);
});
test('parses protocol attachment URI strings before passing them to the agent', async () => {
setupSession();
const fileUri = URI.file('/workspace/test.ts');
const action: ChatAction = {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'hello world', origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'test.ts', displayKind: 'document' }] },
};
sideEffects.handleAction(defaultChatUri, action);
await waitForSendMessageCalls(1);
assert.deepStrictEqual(agent.sendMessageCalls, [{
session: URI.parse(sessionUri.toString()),
prompt: 'hello world',
attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'test.ts', displayKind: 'document' }],
chat: URI.parse(defaultChatUri),
}]);
});
test('passes protocol selection attachment range straight through to the agent', async () => {
setupSession();
const fileUri = URI.file('/workspace/selection.ts');
const action: ChatAction = {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: {
text: 'hello world',
origin: { kind: MessageKind.User },
attachments: [{
type: MessageAttachmentKind.Resource,
uri: fileUri.toString(),
label: 'selection.ts',
displayKind: 'selection',
selection: {
range: {
start: { line: 2, character: 3 },
end: { line: 4, character: 5 }
}
}
}]
},
};
sideEffects.handleAction(defaultChatUri, action);
await waitForSendMessageCalls(1);
assert.deepStrictEqual(agent.sendMessageCalls, [{
session: URI.parse(sessionUri.toString()),
prompt: 'hello world',
attachments: [{
type: MessageAttachmentKind.Resource,
uri: fileUri.toString(),
label: 'selection.ts',
displayKind: 'selection',
selection: {
range: {
start: { line: 2, character: 3 },
end: { line: 4, character: 5 },
},
},
}],
chat: URI.parse(defaultChatUri),
}]);
});
test('dispatches session/error when no agent is found', async () => {
setupSession();
const emptyAgents = observableValue<readonly IAgent[]>('agents', []);
const noAgentSideEffects = createTestSideEffects(disposables, stateManager, {
getAgent: () => undefined,
agents: emptyAgents,
sessionDataService: {} as ISessionDataService,
onTurnComplete: () => { },
});
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
noAgentSideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'hello', origin: { kind: MessageKind.User } },
});
const errorAction = envelopes.find(e => e.action.type === ActionType.ChatError);
assert.ok(errorAction, 'should dispatch session/error');
});
test('rejects a turn on an archived session without calling the agent', () => {
setupSession();
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionIsArchivedChanged, isArchived: true });
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTurnStarted,
startedAt: '2025-01-01T00:00:00.000Z',
turnId: 'turn-1',
message: { text: 'hello', origin: { kind: MessageKind.User } },
});
const errorAction = envelopes.find(e => e.action.type === ActionType.ChatError);
assert.ok(errorAction, 'should dispatch a chat error for an archived session');
assert.deepStrictEqual(agent.sendMessageCalls, []);
});
test('rejects a turn on a read-only chat without calling the agent', () => {
setupSession();
// A read-only peer chat (e.g. a subagent worker) on a non-archived
// session — enforcement keys off the chat's interactivity, not archived.
const readOnlyChat = buildChatUri(sessionUri, 'peer-ro');
stateManager.addChat(sessionUri.toString(), readOnlyChat, { interactivity: ChatInteractivity.ReadOnly });
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
sideEffects.handleAction(readOnlyChat, {
type: ActionType.ChatTurnStarted,
startedAt: '2025-01-01T00:00:00.000Z',
turnId: 'turn-1',
message: { text: 'hello', origin: { kind: MessageKind.User } },
});
const errorAction = envelopes.find(e => e.action.type === ActionType.ChatError);
assert.ok(errorAction, 'should dispatch a chat error for a read-only chat');
assert.deepStrictEqual(agent.sendMessageCalls, []);
});
});
// ---- handleAction: first-turn materialization failure ---------------
suite('handleAction — first-turn materialization failure', () => {
/**
* Create a provisional (not-yet-materialized) session: no `SessionReady`
* (so lifecycle stays `Creating`) and a deferred `SessionAdded` — mirroring
* how the agent host creates a session whose worktree/SDK setup happens on
* the first `sendMessage`.
*/
function setupProvisionalSession(): void {
stateManager.createSession({
resource: sessionUri.toString(),
provider: 'mock',
title: 'Test',
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
}, { emitNotification: false });
}
test('surfaces a failed provisional first turn as a terminal creation failure', async () => {
setupProvisionalSession();
agent.sendMessageError = new Error('git -c exited with code 128: fatal: invalid reference: main');
// Reduce the turn start (as the client would) so the chat has an
// active turn for the subsequent ChatError to terminate, then invoke
// the side effects that drive `sendMessage`.
const turnStarted = {
type: ActionType.ChatTurnStarted,
startedAt: '2025-01-01T00:00:00.000Z',
turnId: 'turn-1',
message: { text: 'hello', origin: { kind: MessageKind.User } },
} as const;
stateManager.dispatchClientAction(defaultChatUri, turnStarted, { clientId: 'test', clientSeq: 1 });
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
const notifications: INotification[] = [];
disposables.add(stateManager.onDidEmitNotification(n => notifications.push(n)));
sideEffects.handleAction(defaultChatUri, turnStarted);
// Wait for the async send rejection + catch handling to run.
await waitForState(stateManager, () => envelopes.some(e => e.action.type === ActionType.SessionCreationFailed) || undefined);
const sessionAdded = notifications.find(n => n.type === 'root/sessionAdded');
assert.deepStrictEqual({
chatError: envelopes.some(e => e.action.type === ActionType.ChatError),
creationFailed: envelopes.some(e => e.action.type === ActionType.SessionCreationFailed),
lifecycle: stateManager.getSessionState(sessionUri.toString())?.lifecycle,
sessionAddedWithError: !!sessionAdded && (sessionAdded.summary.status & SessionStatus.Error) === SessionStatus.Error,
}, {
chatError: true,
creationFailed: true,
lifecycle: SessionLifecycle.CreationFailed,
sessionAddedWithError: true,
});
});
test('surfaces a working directory resolution failure without calling the agent', async () => {
setupProvisionalSession();
const resolutionError = new Error('The isolated worktree could not be restored');
const resolvingSideEffects = createTestSideEffects(disposables, stateManager, {
getAgent: () => agent,
agents: agentList,
sessionDataService: {} as ISessionDataService,
resolveWorkingDirectoryBeforeSend: async () => { throw resolutionError; },
onTurnComplete: () => { },
});
const turnStarted = {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'hello', origin: { kind: MessageKind.User } },
} as const;
stateManager.dispatchClientAction(defaultChatUri, turnStarted, { clientId: 'test', clientSeq: 1 });
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
resolvingSideEffects.handleAction(defaultChatUri, turnStarted);
await waitForState(stateManager, () => envelopes.some(e => e.action.type === ActionType.SessionCreationFailed) || undefined);
assert.deepStrictEqual({
chatError: envelopes.some(e => e.action.type === ActionType.ChatError),
creationFailed: envelopes.some(e => e.action.type === ActionType.SessionCreationFailed),
lifecycle: stateManager.getSessionState(sessionUri.toString())?.lifecycle,
sendMessageCalls: agent.sendMessageCalls,
}, {
chatError: true,
creationFailed: true,
lifecycle: SessionLifecycle.CreationFailed,
sendMessageCalls: [],
});
});
test('does not fail creation when an already-ready session send rejects', async () => {
setupSession(); // dispatches SessionReady -> lifecycle Ready
agent.sendMessageError = new Error('transient send failure');
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTurnStarted,
startedAt: '2025-01-01T00:00:00.000Z',
turnId: 'turn-1',
message: { text: 'hello', origin: { kind: MessageKind.User } },
});
await waitForState(stateManager, () => envelopes.some(e => e.action.type === ActionType.ChatError) || undefined);
assert.deepStrictEqual({
chatError: envelopes.some(e => e.action.type === ActionType.ChatError),
creationFailed: envelopes.some(e => e.action.type === ActionType.SessionCreationFailed),
lifecycle: stateManager.getSessionState(sessionUri.toString())?.lifecycle,
}, {
chatError: true,
creationFailed: false,
lifecycle: SessionLifecycle.Ready,
});
});
});
// ---- handleAction: generic /rename slash command ---------------------
suite('handleAction — /rename slash command', () => {
// `/rename` persists the new title, so these tests need a session data
// service whose `openDatabase` actually returns a database (the default
// null service throws).
function createRenameSideEffects(): AgentSideEffects {
return createTestSideEffects(disposables, stateManager, {
getAgent: () => agent,
agents: agentList,
sessionDataService: createSessionDataService(),
onTurnComplete: () => { },
});
}
test('redirects /rename to a title change and completes the turn without calling the agent', async () => {
setupSession();
const renameSideEffects = createRenameSideEffects();
const action: ChatAction = {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: '/rename Renamed Session', origin: { kind: MessageKind.User } },
};
// Mirror production: the reducer applies the turn, then side effects run.
stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 });
renameSideEffects.handleAction(defaultChatUri, action);
await new Promise(r => setTimeout(r, 10));
assert.deepStrictEqual(agent.sendMessageCalls, []);
const state = stateManager.getSessionState(sessionUri.toString());
assert.strictEqual(state?.title, 'Renamed Session');
assert.strictEqual(stateManager.getActiveTurnId(sessionUri.toString()), undefined);
const part = state?.turns.at(-1)?.responseParts[0];
assert.strictEqual(part?.kind, ResponsePartKind.Markdown);
assert.strictEqual(part?.kind === ResponsePartKind.Markdown ? part.content : undefined, 'Renamed: Renamed Session');
});
test('/rename without a title completes the turn and leaves the title unchanged', async () => {
setupSession();
const renameSideEffects = createRenameSideEffects();
const action: ChatAction = {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: '/rename', origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 });
renameSideEffects.handleAction(defaultChatUri, action);
await new Promise(r => setTimeout(r, 10));
assert.deepStrictEqual(agent.sendMessageCalls, []);
const state = stateManager.getSessionState(sessionUri.toString());
assert.strictEqual(state?.title, 'Test');
assert.strictEqual(stateManager.getActiveTurnId(sessionUri.toString()), undefined);
});
test('a message that merely starts with /rename text (no separator) is sent to the agent', async () => {
setupSession();
const renameSideEffects = createRenameSideEffects();
const action: ChatAction = {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: '/renamed thing', origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 });
renameSideEffects.handleAction(defaultChatUri, action);
await new Promise(r => setTimeout(r, 10));
assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), chat: URI.parse(defaultChatUri), prompt: '/renamed thing', attachments: undefined }]);
});
});
// ---- handleAction: generic ! terminal command ------------------------
suite('handleAction — ! terminal command', () => {
function createBangSideEffects(terminalManager: TestAgentHostTerminalManager): AgentSideEffects {
return createTestSideEffects(disposables, stateManager, {
getAgent: () => agent,
agents: agentList,
sessionDataService: createNullSessionDataService(),
onTurnComplete: () => { },
}, undefined, undefined, undefined, terminalManager);
}
test('runs a ! message as a terminal command and completes the turn without calling the agent', async () => {
setupSession('file:///work');
const terminalManager = disposables.add(new TestAgentHostTerminalManager());
const bangSideEffects = createBangSideEffects(terminalManager);
const action: ChatAction = {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: '!echo hi', origin: { kind: MessageKind.User } },
};
// Mirror production: the reducer opens the turn, then side effects run.
stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 });
bangSideEffects.handleAction(defaultChatUri, action);
// Wait until the command is running (its completion listener is
// registered), then signal that the command finished.
await terminalManager.commandFinishedListenerRegistered.p;
const terminalUri = terminalManager.created[0].channel;
terminalManager.fireCommandFinished({ commandId: '1', command: 'echo hi', exitCode: 0, output: 'hi\n' });
// Wait for the turn to be closed out.
await waitForState(stateManager, () => stateManager.getActiveTurnId(sessionUri.toString()) === undefined ? true : undefined);
assert.deepStrictEqual(agent.sendMessageCalls, []);
const state = stateManager.getSessionState(sessionUri.toString());
const part = state?.turns.at(-1)?.responseParts[0];
assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);
const toolCall = part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined;
assert.strictEqual(toolCall?.status, ToolCallStatus.Completed);
assert.strictEqual(toolCall?.status === ToolCallStatus.Completed ? toolCall.success : undefined, true);
assert.ok(toolCall?.status === ToolCallStatus.Completed
&& toolCall.content?.some(c => c.type === ToolResultContentType.Terminal && c.resource === terminalUri));
assert.strictEqual(terminalManager.created.length, 1);
assert.ok(terminalManager.sentTexts.some(s => s.data.includes('echo hi')));
});
test('a lone ! is forwarded to the agent instead of running a command', async () => {
setupSession();
const terminalManager = disposables.add(new TestAgentHostTerminalManager());
const bangSideEffects = createBangSideEffects(terminalManager);
const action: ChatAction = {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: '!', origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 });
bangSideEffects.handleAction(defaultChatUri, action);
await waitForSendMessageCalls(1);
assert.strictEqual(agent.sendMessageCalls[0].prompt, '!');
assert.strictEqual(terminalManager.created.length, 0);
});
test('records the completed bang turn as a local turn, stripped of the live terminal reference', async () => {
setupSession('file:///work');
const db = new TestSessionDatabase();
const localTurns = new AgentHostLocalTurns(createSessionDataService(db), new NullLogService());
const terminalManager = disposables.add(new TestAgentHostTerminalManager());
const bangSideEffects = createTestSideEffects(disposables, stateManager, {
getAgent: () => agent,
agents: agentList,
sessionDataService: createSessionDataService(db),
localTurns,
onTurnComplete: () => { },
}, undefined, undefined, undefined, terminalManager);
const action: ChatAction = {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: '!echo hi', origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 });
bangSideEffects.handleAction(defaultChatUri, action);
await terminalManager.commandFinishedListenerRegistered.p;
terminalManager.fireCommandFinished({ commandId: '1', command: 'echo hi', exitCode: 0, output: 'hi\n' });
await waitForState(stateManager, () => stateManager.getActiveTurnId(sessionUri.toString()) === undefined ? true : undefined);
// The turn with no preceding real turn has no anchor.
assert.strictEqual(localTurns.resolveConcreteTurnId(defaultChatUri, 'turn-1'), undefined);
const persisted = await db.getLocalTurns();
assert.strictEqual(persisted.length, 1);
const payload = JSON.parse(persisted[0].payload) as { responseParts: { kind: string; toolCall?: { content?: { type: string }[] } }[] };
const toolCallPart = payload.responseParts.find(p => p.kind === ResponsePartKind.ToolCall);
// Live terminal reference is stripped; text output is retained.
assert.ok(toolCallPart?.toolCall?.content?.every(c => c.type !== ToolResultContentType.Terminal));
assert.ok(toolCallPart?.toolCall?.content?.some(c => c.type === ToolResultContentType.Text));
});
});
// ---- local turn persistence: anchoring + truncate resolution ---------
suite('local turn persistence', () => {
let clientSeq: number;
setup(() => {
clientSeq = 0;
});
/** Drives a normal (SDK-backed) turn into `turns[]` via the reducer. */
function seedRealTurn(turnId: string, text: string): void {
stateManager.dispatchClientAction(defaultChatUri, {
type: ActionType.ChatTurnStarted, turnId, startedAt: '2025-01-01T00:00:00.000Z', message: { text, origin: { kind: MessageKind.User } },
}, { clientId: 'test', clientSeq: ++clientSeq });
stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnComplete, turnId, duration: 1000 });
}
async function runBang(se: AgentSideEffects, terminalManager: TestAgentHostTerminalManager, turnId: string): Promise<void> {
const action: ChatAction = {
type: ActionType.ChatTurnStarted, turnId, startedAt: '2025-01-01T00:00:00.000Z', message: { text: '!echo hi', origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: ++clientSeq });
se.handleAction(defaultChatUri, action);
await terminalManager.commandFinishedListenerRegistered.p;
terminalManager.fireCommandFinished({ commandId: turnId, command: 'echo hi', exitCode: 0, output: 'hi\n' });
await waitForState(stateManager, () => stateManager.getActiveTurnId(sessionUri.toString()) === undefined ? true : undefined);
}
let localTurns: AgentHostLocalTurns;
function createLocalTurnSideEffects(db: TestSessionDatabase, terminalManager: TestAgentHostTerminalManager): AgentSideEffects {
const sessionDataService = createSessionDataService(db);
localTurns = new AgentHostLocalTurns(sessionDataService, new NullLogService());
return createTestSideEffects(disposables, stateManager, {
getAgent: () => agent,
agents: agentList,
sessionDataService,
localTurns,
onTurnComplete: () => { },
}, undefined, undefined, undefined, terminalManager);
}
test('anchors a bang turn to the preceding concrete turn', async () => {
setupSession('file:///work');
const db = new TestSessionDatabase();
const terminalManager = disposables.add(new TestAgentHostTerminalManager());
const se = createLocalTurnSideEffects(db, terminalManager);
seedRealTurn('real-1', 'hello');
await runBang(se, terminalManager, 'local-1');
assert.strictEqual(localTurns.resolveConcreteTurnId(defaultChatUri, 'local-1'), 'real-1');
const persisted = await db.getLocalTurns();
assert.deepStrictEqual(persisted.map(r => ({ turnId: r.turnId, chatUri: r.chatUri, anchorTurnId: r.anchorTurnId })), [
{ turnId: 'local-1', chatUri: defaultChatUri, anchorTurnId: 'real-1' },
]);
});
test('truncating at a local turn redirects the SDK truncation to the concrete anchor', async () => {
setupSession('file:///work');
const db = new TestSessionDatabase();
const terminalManager = disposables.add(new TestAgentHostTerminalManager());
const se = createLocalTurnSideEffects(db, terminalManager);
seedRealTurn('real-1', 'hello');
await runBang(se, terminalManager, 'local-1');
// Truncate at the local turn (keep it). Reducer keeps [real-1, local-1].
stateManager.dispatchClientAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'local-1' }, { clientId: 'test', clientSeq: ++clientSeq });
se.handleAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'local-1' });
// The SDK is told to keep up to the concrete turn before the local one.
const truncateCall = agent.truncateSessionCalls.at(-1);
assert.strictEqual(truncateCall?.session.toString(), sessionUri.toString());
assert.strictEqual(truncateCall?.turnId, 'real-1');
});
test('truncating at a real turn drops the trailing local turn', async () => {
setupSession('file:///work');
const db = new TestSessionDatabase();
const terminalManager = disposables.add(new TestAgentHostTerminalManager());
const se = createLocalTurnSideEffects(db, terminalManager);
seedRealTurn('real-1', 'hello');
await runBang(se, terminalManager, 'local-1');
// Truncate at the real turn (drop the local turn after it).
stateManager.dispatchClientAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'real-1' }, { clientId: 'test', clientSeq: ++clientSeq });
se.handleAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'real-1' });
assert.strictEqual(agent.truncateSessionCalls.at(-1)?.turnId, 'real-1');
// The local turn is dropped from memory synchronously and from the DB async.
assert.strictEqual(localTurns.isLocal(defaultChatUri, 'local-1'), false);
await new Promise(r => setTimeout(r, 10));
assert.deepStrictEqual(await db.getLocalTurns(), []);
});
});
// ---- immediate title on first turn -----------------------------------
suite('immediate title on first turn', () => {
function setupDefaultSession(): void {
stateManager.createSession({
resource: sessionUri.toString(),
provider: 'mock',
title: '',
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
project: { uri: 'file:///test-project', displayName: 'Test Project' },
});
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, });
}
test('dispatches titleChanged with user message on first turn', () => {
setupDefaultSession();
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'Fix the login bug', origin: { kind: MessageKind.User } },
});
const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);
assert.ok(titleAction, 'should dispatch session/titleChanged');
if (titleAction?.action.type === ActionType.SessionTitleChanged) {
assert.strictEqual(titleAction.action.title, 'Fix the login bug');
}
});
test('does not dispatch titleChanged when message is whitespace', () => {
setupDefaultSession();
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: ' ', origin: { kind: MessageKind.User } },
});
const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);
assert.strictEqual(titleAction, undefined, 'should not dispatch titleChanged for empty message');
});
test('normalizes whitespace and truncates long messages', () => {
setupDefaultSession();
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
const longMessage = 'Fix the bug\nin the login\tpage please ' + 'a'.repeat(250);
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: longMessage, origin: { kind: MessageKind.User } },
});
const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);
assert.ok(titleAction, 'should dispatch session/titleChanged');
if (titleAction?.action.type === ActionType.SessionTitleChanged) {
assert.ok(!titleAction.action.title.includes('\n'), 'should not contain newlines');
assert.ok(!titleAction.action.title.includes('\t'), 'should not contain tabs');
assert.ok(!titleAction.action.title.includes(' '), 'should not contain double spaces');
assert.ok(titleAction.action.title.length <= 200, 'should be truncated to 200 chars');
}
});
test('does not dispatch titleChanged on second turn', () => {
setupDefaultSession();
startTurn('turn-1');
// Complete the first turn so turns.length becomes 1.
stateManager.dispatchServerAction(defaultChatUri, {
type: ActionType.ChatTurnComplete,
turnId: 'turn-1',
duration: 1000,
});
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-2',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'second message', origin: { kind: MessageKind.User } },
});
const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);
assert.strictEqual(titleAction, undefined, 'should not dispatch titleChanged on second turn');
});
test('does not dispatch titleChanged when title is already set', () => {
// Session has a non-empty title (e.g. user renamed before first message)
stateManager.createSession({
resource: sessionUri.toString(),
provider: 'mock',
title: 'User Renamed',
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
project: { uri: 'file:///test-project', displayName: 'Test Project' },
});
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, });
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'hello', origin: { kind: MessageKind.User } },
});
const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged);
assert.strictEqual(titleAction, undefined, 'should not clobber existing title');
});
});
suite('handleAction — session/turnCancelled', () => {
test('calls abortSession on the agent', async () => {
setupSession();
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTurnCancelled,
turnId: 'turn-1',
duration: 1000,
});
await new Promise(r => setTimeout(r, 10));
assert.deepStrictEqual(agent.abortSessionCalls, [URI.parse(sessionUri.toString())]);
});
});
// ---- handleAction: chat/turnStarted model selection --------------------
suite('handleAction — chat/turnStarted model selection', () => {
test('calls changeModel on the agent before sending the message', async () => {
setupSession();
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'gpt-5' } },
});
await new Promise(r => setTimeout(r, 10));
assert.deepStrictEqual(agent.changeModelCalls, [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' }, chat: URI.parse(defaultChatUri) }]);
});
test('waits for model selection before sending the message', async () => {
setupSession();
let resolveChangeModel!: () => void;
const changeModelSettled = new Promise<void>(resolve => { resolveChangeModel = resolve; });
let resolveSend!: () => void;
const sendStarted = new Promise<void>(resolve => { resolveSend = resolve; });
agent.changeModel = async (session, model, chat) => {
agent.changeModelCalls.push({ session, model, chat });
await changeModelSettled;
};
agent.sendMessage = async (session, chat, prompt, attachments) => {
agent.sendMessageCalls.push({ session, prompt, attachments, chat });
resolveSend();
};
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'gpt-5' } },
});
await Promise.resolve();
assert.deepStrictEqual({
changeModelCalls: agent.changeModelCalls,
sendMessageCalls: agent.sendMessageCalls,
}, {
changeModelCalls: [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' }, chat: URI.parse(defaultChatUri) }],
sendMessageCalls: [],
});
resolveChangeModel();
await sendStarted;
assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello', attachments: undefined, chat: URI.parse(defaultChatUri) }]);
});
test('forwards the chat channel for an additional (peer) chat', async () => {
setupSession();
const chatChannel = buildChatUri(sessionUri.toString(), 'peer-1');
sideEffects.handleAction(chatChannel, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'gpt-5' } },
});
await new Promise(r => setTimeout(r, 10));
assert.deepStrictEqual(agent.changeModelCalls, [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' }, chat: URI.parse(chatChannel) }]);
});
});
// ---- handleAction: chat/turnStarted agent selection --------------------
suite('handleAction — chat/turnStarted agent selection', () => {
test('calls changeAgent on the agent for the session default chat before sending the message', async () => {
setupSession();
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'hello', origin: { kind: MessageKind.User }, agent: { uri: 'file:///agents/reviewer.md' } },
});
await new Promise(r => setTimeout(r, 10));
assert.deepStrictEqual(agent.changeAgentCalls, [{ session: URI.parse(sessionUri.toString()), agent: { uri: 'file:///agents/reviewer.md' }, chat: URI.parse(defaultChatUri) }]);
});
test('forwards the chat channel for an additional (peer) chat', async () => {
setupSession();
const chatChannel = buildChatUri(sessionUri.toString(), 'peer-1');
sideEffects.handleAction(chatChannel, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'hello', origin: { kind: MessageKind.User }, agent: { uri: 'file:///agents/reviewer.md' } },
});
await new Promise(r => setTimeout(r, 10));
assert.deepStrictEqual(agent.changeAgentCalls, [{ session: URI.parse(sessionUri.toString()), agent: { uri: 'file:///agents/reviewer.md' }, chat: URI.parse(chatChannel) }]);
});
});
// ---- registerProgressListener ---------------------------------------
suite('registerProgressListener', () => {
test('maps agent progress events to state actions', () => {
setupSession();
startTurn('turn-1');
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: { type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'hi' } },
});
// First delta creates a response part (not a delta action)
assert.ok(envelopes.some(e => e.action.type === ActionType.ChatResponsePart));
});
test('returns a disposable that stops listening', () => {
setupSession();
startTurn('turn-1');
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
const listener = sideEffects.registerProgressListener(agent);
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: { type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'before' } },
});
assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.ChatResponsePart).length, 1);
listener.dispose();
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: { type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-2', content: 'after' } },
});
assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.ChatResponsePart).length, 1);
});
test('customizations change publishes once, then dedupes identical re-fetches', async () => {
setupSession();
// Return a freshly-built array of freshly-built objects on every
// fetch (matching real providers, which re-scan disk each time) so
// the dedup is proven to rely on structural equality, not reference
// identity.
const makeCustomizations = (): Customization[] => [
{ type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } },
];
let fetchCalls = 0;
agent.getSessionCustomizations = async () => { fetchCalls++; return makeCustomizations(); };
const changed: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => {
if (e.action.type === ActionType.SessionCustomizationsChanged) {
changed.push(e);
}
}));
disposables.add(sideEffects.registerProgressListener(agent));
// First change: fetch + publish.
agent.fireCustomizationsChange();
await waitForState(stateManager, () => changed.length >= 1 || undefined);
assert.strictEqual(changed.length, 1);
// Subsequent changes that resolve to structurally-equal customizations
// (e.g. the O(N^2) fan-out from a shared `~/.claude` edit) must not
// re-publish, even though each fetch returns a brand-new array.
agent.fireCustomizationsChange();
agent.fireCustomizationsChange();
const deadline = Date.now() + 5000;
while (fetchCalls < 3 && Date.now() < deadline) {
await timeout(5);
}
assert.strictEqual(changed.length, 1, 'identical customizations must not re-publish');
assert.ok(fetchCalls >= 3, 'each change still re-fetches to compare');
});
test('re-publishes after session eviction + restore even when customizations are unchanged', async () => {
setupSession();
const makeCustomizations = (): Customization[] => [
{ type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } },
];
agent.getSessionCustomizations = async () => makeCustomizations();
const changed: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => {
if (e.action.type === ActionType.SessionCustomizationsChanged) {
changed.push(e);
}
}));
disposables.add(sideEffects.registerProgressListener(agent));
// Initial publish populates the session state's customizations.
agent.fireCustomizationsChange();
await waitForState(stateManager, () => changed.length >= 1 || undefined);
assert.strictEqual(changed.length, 1);
// Idle-evict then restore the same session URI: the restored state
// starts without customizations. Because dedup compares against the
// authoritative session state (not a stale side cache), the next
// refresh must publish again even though the resolved set is
// structurally identical to the prior incarnation's.
stateManager.removeSession(sessionUri.toString());
setupSession();
agent.fireCustomizationsChange();
await waitForState(stateManager, () => changed.length >= 2 || undefined);
assert.strictEqual(changed.length, 2, 'restored session must receive its customizations');
});
});
// ---- agents observable --------------------------------------------------
suite('agents observable', () => {
test('dispatches root/agentsChanged without fetching models when observable changes', async () => {
agentList.set([], undefined);
const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => {
if (e.action.type !== ActionType.RootAgentsChanged) {
return false;
}
return e.action.agents.length === 1;
}));
agentList.set([agent], undefined);
const { action } = await envelope;
assert.strictEqual(action.type, ActionType.RootAgentsChanged);
assert.deepStrictEqual(action.agents[0].models, []);
});
test('model observable update publishes models', async () => {
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => {
if (e.action.type !== ActionType.RootAgentsChanged) {
return false;
}
return e.action.agents[0]?.models.length === 1;
}));
agent.setModels([{ provider: 'mock', id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, maxOutputTokens: 16000, maxPromptTokens: 112000, supportsVision: false }]);
await envelope;
const actions = envelopes.map(e => e.action).filter(action => action.type === ActionType.RootAgentsChanged);
const action = actions[actions.length - 1];
assert.ok(action, 'should dispatch root/agentsChanged');
assert.deepStrictEqual(action.agents[0].models, [{
id: 'mock-model',
provider: 'mock',
name: 'mock Model',
maxContextWindow: 128000,
maxOutputTokens: 16000,
maxPromptTokens: 112000,
supportsVision: false,
policyState: undefined,
configSchema: undefined,
_meta: undefined,
}]);
});
test('model observable update publishes model metadata', async () => {
const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => {
if (e.action.type !== ActionType.RootAgentsChanged) {
return false;
}
return e.action.agents[0]?.models.length === 1;
}));
agent.setModels([{ provider: 'mock', id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false, _meta: { multiplierNumeric: 2 } }]);
const { action } = await envelope;
assert.strictEqual(action.type, ActionType.RootAgentsChanged);
assert.deepStrictEqual(action.agents[0].models[0]._meta, { multiplierNumeric: 2 });
});
test('unchanged model observable update does not dispatch unchanged agent infos', async () => {
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
const models = [{ provider: 'mock' as const, id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false }];
const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => {
if (e.action.type !== ActionType.RootAgentsChanged) {
return false;
}
return e.action.agents[0]?.models.length === 1;
}));
agent.setModels(models);
await envelope;
envelopes.length = 0;
agent.setModels([...models]);
await Promise.resolve();
await Promise.resolve();
assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.RootAgentsChanged).length, 0);
});
});
// ---- Pending message sync -----------------------------------------------
suite('pending message sync', () => {
test('syncs steering message to agent on ChatPendingMessageSet', () => {
setupSession();
const action = {
type: ActionType.ChatPendingMessageSet as const,
kind: PendingMessageKind.Steering,
id: 'steer-1',
message: { text: 'focus on tests', origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 });
sideEffects.handleAction(defaultChatUri, action);
assert.strictEqual(agent.setPendingMessagesCalls.length, 1);
assert.deepStrictEqual(agent.setPendingMessagesCalls[0].steeringMessage, { id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } });
assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []);
// Default chat: no `chat` arg, so the agent routes to the session's default chat.
assert.strictEqual(agent.setPendingMessagesCalls[0].chat, undefined);
});
test('syncs a peer chat steering message with the peer chat URI as the `chat` arg', () => {
setupSession();
const peerChatUri = URI.parse(buildChatUri(sessionUri.toString(), 'peer-steer'));
stateManager.addChat(sessionUri.toString(), peerChatUri.toString());
const action = {
type: ActionType.ChatPendingMessageSet as const,
kind: PendingMessageKind.Steering,
id: 'steer-peer',
message: { text: 'steer the peer', origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(peerChatUri.toString(), action, { clientId: 'test', clientSeq: 1 });
sideEffects.handleAction(peerChatUri.toString(), action);
assert.strictEqual(agent.setPendingMessagesCalls.length, 1);
assert.deepStrictEqual({
session: agent.setPendingMessagesCalls[0].session.toString(),
chat: agent.setPendingMessagesCalls[0].chat?.toString(),
steeringId: agent.setPendingMessagesCalls[0].steeringMessage?.id,
}, {
session: sessionUri.toString(),
chat: peerChatUri.toString(),
steeringId: 'steer-peer',
});
});
test('syncs queued message to agent on ChatPendingMessageSet', async () => {
setupSession();
const action = {
type: ActionType.ChatPendingMessageSet as const,
kind: PendingMessageKind.Queued,
id: 'q-1',
message: { text: 'queued message', origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 });
sideEffects.handleAction(defaultChatUri, action);
// Queued messages are not forwarded to the agent; the server controls consumption
assert.strictEqual(agent.setPendingMessagesCalls.length, 1);
assert.strictEqual(agent.setPendingMessagesCalls[0].steeringMessage, undefined);
assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []);
// Session was idle, so the queued message is consumed immediately
await waitForSendMessageCalls(1);
assert.strictEqual(agent.sendMessageCalls.length, 1);
assert.strictEqual(agent.sendMessageCalls[0].prompt, 'queued message');
});
test('parses queued protocol attachment URI strings before passing them to the agent', async () => {
setupSession();
const fileUri = URI.file('/workspace/queued.ts');
const action: ChatAction = {
type: ActionType.ChatPendingMessageSet as const,
kind: PendingMessageKind.Queued,
id: 'q-uri',
message: { text: 'queued message', origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'queued.ts', displayKind: 'document' }] },
};
stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 });
sideEffects.handleAction(defaultChatUri, action);
await waitForSendMessageCalls(1);
assert.deepStrictEqual(agent.sendMessageCalls, [{
session: URI.parse(sessionUri.toString()),
chat: URI.parse(defaultChatUri),
prompt: 'queued message',
attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'queued.ts', displayKind: 'document' }],
}]);
});
test('logs telemetry when sending a queued user message', () => {
setupSession();
const action = {
type: ActionType.ChatPendingMessageSet as const,
kind: PendingMessageKind.Queued,
id: 'q-telemetry',
message: { text: 'queued message', origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 });
sideEffects.handleAction(defaultChatUri, action);
assert.deepStrictEqual(telemetryService.events, [{
eventName: 'agentHost.userMessageSent',
data: {
provider: 'mock',
agentSessionId: 'session-1',
source: 'queued',
isSubagentSession: false,
turnCount: 0,
attachmentCount: 0,
},
}]);
});
test('syncs on ChatPendingMessageRemoved', () => {
setupSession();
// Add a queued message
const setAction = {
type: ActionType.ChatPendingMessageSet as const,
kind: PendingMessageKind.Queued,
id: 'q-rm',
message: { text: 'will be removed', origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 });
sideEffects.handleAction(defaultChatUri, setAction);
agent.setPendingMessagesCalls.length = 0;
// Remove
const removeAction = {
type: ActionType.ChatPendingMessageRemoved as const,
kind: PendingMessageKind.Queued,
id: 'q-rm',
};
stateManager.dispatchClientAction(defaultChatUri, removeAction, { clientId: 'test', clientSeq: 2 });
sideEffects.handleAction(defaultChatUri, removeAction);
assert.strictEqual(agent.setPendingMessagesCalls.length, 1);
assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []);
});
test('syncs on ChatQueuedMessagesReordered', () => {
setupSession();
// Add two queued messages
const setA = { type: ActionType.ChatPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-a', message: { text: 'A', origin: { kind: MessageKind.User } } };
stateManager.dispatchClientAction(defaultChatUri, setA, { clientId: 'test', clientSeq: 1 });
sideEffects.handleAction(defaultChatUri, setA);
const setB = { type: ActionType.ChatPendingMessageSet as const, kind: PendingMessageKind.Queued, id: 'q-b', message: { text: 'B', origin: { kind: MessageKind.User } } };
stateManager.dispatchClientAction(defaultChatUri, setB, { clientId: 'test', clientSeq: 2 });
sideEffects.handleAction(defaultChatUri, setB);
agent.setPendingMessagesCalls.length = 0;
// Reorder
const reorderAction = { type: ActionType.ChatQueuedMessagesReordered as const, order: ['q-b', 'q-a'] };
stateManager.dispatchClientAction(defaultChatUri, reorderAction, { clientId: 'test', clientSeq: 3 });
sideEffects.handleAction(defaultChatUri, reorderAction);
assert.strictEqual(agent.setPendingMessagesCalls.length, 1);
assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []);
});
});
// ---- Queued message consumption -----------------------------------------
suite('queued message consumption', () => {
test('auto-starts turn from queued message on idle', async () => {
setupSession();
disposables.add(sideEffects.registerProgressListener(agent));
// Queue a message while a turn is active
startTurn('turn-1');
const setAction = {
type: ActionType.ChatPendingMessageSet as const,
kind: PendingMessageKind.Queued,
id: 'q-auto',
message: { text: 'auto queued', origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 });
sideEffects.handleAction(defaultChatUri, setAction);
// Message should NOT be consumed yet (turn is active)
assert.strictEqual(agent.sendMessageCalls.length, 0);
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
// Fire idle → turn completes → queued message should be consumed
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 },
});
const turnComplete = envelopes.find(e => e.action.type === ActionType.ChatTurnComplete);
assert.ok(turnComplete, 'should dispatch session/turnComplete');
const turnStarted = envelopes.find(e => e.action.type === ActionType.ChatTurnStarted);
assert.ok(turnStarted, 'should dispatch session/turnStarted for queued message');
assert.strictEqual((turnStarted!.action as { queuedMessageId?: string }).queuedMessageId, 'q-auto');
await waitForSendMessageCalls(1);
assert.strictEqual(agent.sendMessageCalls.length, 1);
assert.strictEqual(agent.sendMessageCalls[0].prompt, 'auto queued');
// Queued message should be removed from state
const state = stateManager.getSessionState(sessionUri.toString());
assert.strictEqual(state?.queuedMessages, undefined);
});
test('does not drain queued messages when the active turn is cancelled', () => {
// Cancelling a turn means "stop": messages queued behind it must stay
// queued for the user to dequeue/run manually, not auto-start. (A
// message the user sends *after* the abort is consumed separately via
// the ChatPendingMessageSet path once cancellation clears the turn.)
setupSession();
disposables.add(sideEffects.registerProgressListener(agent));
// Queue a message while a turn is active.
startTurn('turn-1');
const setAction = {
type: ActionType.ChatPendingMessageSet as const,
kind: PendingMessageKind.Queued,
id: 'q-after-abort',
message: { text: 'queued behind abort', origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 });
sideEffects.handleAction(defaultChatUri, setAction);
// Not consumed yet — the turn is still active.
assert.strictEqual(agent.sendMessageCalls.length, 0);
// Cancel the active turn (client abort).
const cancelAction = { type: ActionType.ChatTurnCancelled as const, turnId: 'turn-1', duration: 1000 };
stateManager.dispatchClientAction(defaultChatUri, cancelAction, { clientId: 'test', clientSeq: 2 });
sideEffects.handleAction(defaultChatUri, cancelAction);
// The queued message must NOT auto-start, and must remain queued.
assert.strictEqual(agent.sendMessageCalls.length, 0, 'cancelling must not drain queued messages');
const state = stateManager.getSessionState(sessionUri.toString());
assert.strictEqual(state?.queuedMessages?.length, 1, 'queued message should remain for manual dequeue');
assert.strictEqual(state?.queuedMessages?.[0].id, 'q-after-abort');
});
test('intercepts queued /rename and drains the message queued behind it', async () => {
setupSession();
// `/rename` persists the new title, so use a side effects instance
// whose `openDatabase` returns a real database (the suite default
// throws).
const renameSideEffects = createTestSideEffects(disposables, stateManager, {
getAgent: () => agent,
agents: agentList,
sessionDataService: createSessionDataService(),
onTurnComplete: () => { },
});
disposables.add(renameSideEffects.registerProgressListener(agent));
// Queue a `/rename` followed by a normal message while a turn is active
startTurn('turn-1');
for (const msg of [
{ id: 'q-rename', text: '/rename Queued Title' },
{ id: 'q-after', text: 'after rename' },
]) {
const setAction = {
type: ActionType.ChatPendingMessageSet as const,
kind: PendingMessageKind.Queued,
id: msg.id,
message: { text: msg.text, origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 });
renameSideEffects.handleAction(defaultChatUri, setAction);
}
// Fire idle → turn completes → `/rename` is consumed and intercepted,
// then the message queued behind it must be drained to the agent.
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 },
});
// The `/rename` must not reach the agent; only the message behind it does
await waitForSendMessageCalls(1);
assert.strictEqual(agent.sendMessageCalls.length, 1);
assert.strictEqual(agent.sendMessageCalls[0].prompt, 'after rename');
// Both queued messages should be drained from state
const state = stateManager.getSessionState(sessionUri.toString());
assert.strictEqual(state?.queuedMessages, undefined);
assert.strictEqual(state?.title, 'Queued Title');
});
test('drains a peer chat queued message to the owning session with the chat arg', async () => {
setupSession();
const chatUri = URI.parse(buildChatUri(sessionUri, 'peer-q'));
stateManager.addChat(sessionUri.toString(), chatUri.toString());
disposables.add(sideEffects.registerProgressListener(agent));
// Start a turn on the peer chat, then queue a message behind it.
stateManager.dispatchClientAction(chatUri.toString(),
{ type: ActionType.ChatTurnStarted, turnId: 'pturn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hi', origin: { kind: MessageKind.User } } },
{ clientId: 'test', clientSeq: 1 });
const setAction = {
type: ActionType.ChatPendingMessageSet as const,
kind: PendingMessageKind.Queued,
id: 'pq-1',
message: { text: 'peer queued', origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(chatUri.toString(), setAction, { clientId: 'test', clientSeq: 2 });
sideEffects.handleAction(chatUri.toString(), setAction);
assert.strictEqual(agent.sendMessageCalls.length, 0);
// Idle on the peer chat → the queued message drains to the parent
// session URI with the chat channel passed as the `chat` argument
// so the harness routes it to the right peer SDK chat.
agent.fireProgress({
kind: 'action', resource: chatUri,
action: { type: ActionType.ChatTurnComplete, turnId: 'pturn-1', duration: 1000 },
});
await waitForSendMessageCalls(1);
assert.deepStrictEqual(agent.sendMessageCalls, [{
session: URI.parse(sessionUri.toString()),
prompt: 'peer queued',
attachments: undefined,
chat: URI.parse(chatUri.toString()),
}]);
});
test('does not consume queued message while a turn is active', () => {
setupSession();
startTurn('turn-1');
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
const setAction = {
type: ActionType.ChatPendingMessageSet as const,
kind: PendingMessageKind.Queued,
id: 'q-wait',
message: { text: 'should wait', origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 });
sideEffects.handleAction(defaultChatUri, setAction);
// No turn started for the queued message
const turnStarted = envelopes.find(e => e.action.type === ActionType.ChatTurnStarted);
assert.strictEqual(turnStarted, undefined, 'should not start a turn while one is active');
assert.strictEqual(agent.sendMessageCalls.length, 0);
// Queued message still in state
const state = stateManager.getSessionState(sessionUri.toString());
assert.strictEqual(state?.queuedMessages?.length, 1);
assert.strictEqual(state?.queuedMessages?.[0].id, 'q-wait');
});
test('dispatches ChatPendingMessageRemoved for steering messages on steering_consumed', () => {
setupSession();
disposables.add(sideEffects.registerProgressListener(agent));
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
const action = {
type: ActionType.ChatPendingMessageSet as const,
kind: PendingMessageKind.Steering,
id: 'steer-rm',
message: { text: 'steer me', origin: { kind: MessageKind.User } },
};
stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 });
sideEffects.handleAction(defaultChatUri, action);
// Removal is not dispatched synchronously; it waits for the agent
let removal = envelopes.find(e =>
e.action.type === ActionType.ChatPendingMessageRemoved &&
(e.action as { kind: PendingMessageKind }).kind === PendingMessageKind.Steering
);
assert.strictEqual(removal, undefined, 'should not dispatch removal until steering_consumed');
// Simulate the agent consuming the steering message
agent.fireProgress({
kind: 'steering_consumed',
chat: URI.parse(defaultChatUri),
id: 'steer-rm',
});
removal = envelopes.find(e =>
e.action.type === ActionType.ChatPendingMessageRemoved &&
(e.action as { kind: PendingMessageKind }).kind === PendingMessageKind.Steering
);
assert.ok(removal, 'should dispatch ChatPendingMessageRemoved for steering');
assert.strictEqual((removal!.action as { id: string }).id, 'steer-rm');
// Steering message should be removed from state
const state = stateManager.getSessionState(sessionUri.toString());
assert.strictEqual(state?.steeringMessage, undefined);
});
});
// ---- handleAction: session/activeClientSet ----------------------
suite('handleAction — session/activeClientSet', () => {
setup(() => {
disposables.add(sideEffects.registerProgressListener(agent));
});
test('calls setClientCustomizations and dispatches customizationsChanged once', async () => {
setupSession();
const pluginA: Customization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } };
const pluginB: Customization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-b'), uri: 'file:///plugin-b', name: 'Plugin B', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } };
const pluginAClient: ClientPluginCustomization = { type: CustomizationType.Plugin, id: pluginA.id, uri: pluginA.uri, name: pluginA.name, enabled: true };
const pluginBClient: ClientPluginCustomization = { type: CustomizationType.Plugin, id: pluginB.id, uri: pluginB.uri, name: pluginB.name, enabled: true };
agent.getSessionCustomizations = async () => [pluginA, pluginB];
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
const action: SessionAction = {
type: ActionType.SessionActiveClientSet,
activeClient: {
clientId: 'test-client',
tools: [],
customizations: [pluginAClient, pluginBClient]
},
};
sideEffects.handleAction(sessionUri.toString(), action);
// Wait for async setClientCustomizations
await new Promise(r => setTimeout(r, 50));
assert.deepStrictEqual(agent.setClientCustomizationsCalls, [{
clientId: 'test-client',
customizations: [pluginAClient, pluginBClient],
}]);
const customizationActions = envelopes
.filter(e => e.action.type === ActionType.SessionCustomizationsChanged);
assert.strictEqual(customizationActions.length, 1, 'should dispatch one full customizationsChanged replacement');
assert.strictEqual(
envelopes.filter(e => e.action.type === ActionType.SessionCustomizationUpdated).length,
0,
'should not dispatch customizationUpdated when progress matches the final state',
);
});
test('dispatches customizationUpdated for sync progress after initial replacement', async () => {
setupSession();
const pluginAClient: ClientPluginCustomization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true };
let currentCustomizations: readonly Customization[] = [];
agent.getSessionCustomizations = async () => currentCustomizations;
agent.syncClientCustomizations = (session, clientId, customizations) => {
agent.setClientCustomizationsCalls.push({ clientId, customizations });
const loading: PluginCustomization = { ...pluginAClient, load: { kind: CustomizationLoadStatus.Loading } };
currentCustomizations = [loading];
agent.fireProgress({
kind: 'action',
resource: session,
action: {
type: ActionType.SessionCustomizationsChanged,
customizations: [...currentCustomizations],
},
});
void (async () => {
await new Promise(resolve => setTimeout(resolve, 0));
const loaded: PluginCustomization = { ...pluginAClient, load: { kind: CustomizationLoadStatus.Loaded } };
currentCustomizations = [loaded];
agent.fireProgress({
kind: 'action',
resource: session,
action: {
type: ActionType.SessionCustomizationUpdated,
customization: loaded,
},
});
})();
return currentCustomizations.map(customization => ({ customization: customization as PluginCustomization }));
};
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
sideEffects.handleAction(sessionUri.toString(), {
type: ActionType.SessionActiveClientSet,
activeClient: {
clientId: 'test-client',
tools: [],
customizations: [pluginAClient],
},
});
await new Promise(resolve => setTimeout(resolve, 50));
const customizationsChanged = envelopes.filter(e => e.action.type === ActionType.SessionCustomizationsChanged);
assert.strictEqual(customizationsChanged.length, 1);
const firstCustomizationsChanged = customizationsChanged[0].action;
assert.strictEqual(firstCustomizationsChanged.type, ActionType.SessionCustomizationsChanged);
assert.deepStrictEqual(firstCustomizationsChanged.customizations, [{
...pluginAClient,
load: { kind: CustomizationLoadStatus.Loading },
}]);
const customizationUpdated = envelopes.filter(e => e.action.type === ActionType.SessionCustomizationUpdated);
assert.deepStrictEqual(customizationUpdated.map(e => e.action), [{
type: ActionType.SessionCustomizationUpdated,
customization: { ...pluginAClient, load: { kind: CustomizationLoadStatus.Loaded } },
}]);
});
test('clears client customizations when activeClient has no customizations', () => {
setupSession();
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
const action: SessionAction = {
type: ActionType.SessionActiveClientSet,
activeClient: {
clientId: 'test-client',
tools: []
},
};
sideEffects.handleAction(sessionUri.toString(), action);
assert.deepStrictEqual(agent.setClientCustomizationsCalls, [{
clientId: 'test-client',
customizations: [],
}]);
const customizationActions = envelopes
.filter(e => e.action.type === ActionType.SessionCustomizationsChanged);
assert.strictEqual(customizationActions.length, 1);
assert.deepStrictEqual(customizationActions[0].action, {
type: ActionType.SessionCustomizationsChanged,
customizations: [],
});
});
test('removes the active client when it is removed', () => {
setupSession();
const action: SessionAction = {
type: ActionType.SessionActiveClientRemoved,
clientId: 'test-client',
};
sideEffects.handleAction(sessionUri.toString(), action);
assert.deepStrictEqual(agent.removeActiveClientCalls, [{
clientId: 'test-client',
}]);
});
});
// ---- handleAction: root/configChanged --------------------------------
suite('handleAction - root/configChanged', () => {
test('republishes agent and session customizations for existing sessions', async () => {
setupSession('file:///workspace');
const customization: Customization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } };
agent.customizations = [customization];
agent.getSessionCustomizations = async () => [customization];
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
const action: RootConfigChangedAction = {
type: ActionType.RootConfigChanged,
config: { customizations: [customization] },
};
stateManager.dispatchServerAction(sessionUri.toString(), action);
sideEffects.handleAction(sessionUri.toString(), action);
await new Promise(resolve => setTimeout(resolve, 10));
const agentInfoAction = envelopes.filter(e => e.action.type === ActionType.RootAgentsChanged).at(-1);
assert.ok(agentInfoAction && hasKey(agentInfoAction.action, { agents: true }));
assert.deepStrictEqual(agentInfoAction.action.agents[0]?.customizations, [customization]);
const sessionCustomizationAction = envelopes.filter(e => e.action.type === ActionType.SessionCustomizationsChanged).at(-1);
assert.ok(sessionCustomizationAction && hasKey(sessionCustomizationAction.action, { customizations: true }));
assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [customization]);
});
test('updates telemetry level from root config', () => {
setupSession();
const action: RootConfigChangedAction = {
type: ActionType.RootConfigChanged,
config: { [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(TelemetryLevel.NONE) },
};
sideEffects.handleAction(sessionUri.toString(), action);
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'hello world', origin: { kind: MessageKind.User } },
});
assert.deepStrictEqual(telemetryService.events, []);
});
});
// ---- onDidCustomizationsChange integration --------------------------
suite('onDidCustomizationsChange', () => {
test('republishes agent info and session customizations when agent fires onDidCustomizationsChange', async () => {
disposables.add(sideEffects.registerProgressListener(agent));
setupSession('file:///workspace');
const customization: Customization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-b'), uri: 'file:///plugin-b', name: 'Plugin B', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } };
agent.customizations = [customization];
agent.getSessionCustomizations = async () => [customization];
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
agent.fireCustomizationsChange();
await new Promise(resolve => setTimeout(resolve, 10));
const agentInfoAction = envelopes.find(e => e.action.type === ActionType.RootAgentsChanged);
assert.ok(agentInfoAction && hasKey(agentInfoAction.action, { agents: true }));
assert.deepStrictEqual(agentInfoAction.action.agents[0]?.customizations, [customization]);
const sessionCustomizationAction = envelopes.find(e => e.action.type === ActionType.SessionCustomizationsChanged);
assert.ok(sessionCustomizationAction && hasKey(sessionCustomizationAction.action, { customizations: true }));
assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [customization]);
});
test('does not republish when registerProgressListener is disposed', async () => {
const listener = sideEffects.registerProgressListener(agent);
setupSession('file:///workspace');
agent.customizations = [{ type: CustomizationType.Plugin, id: customizationId('file:///plugin-c'), uri: 'file:///plugin-c', name: 'Plugin C', enabled: true }];
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
listener.dispose();
agent.fireCustomizationsChange();
await new Promise(resolve => setTimeout(resolve, 10));
assert.strictEqual(
envelopes.filter(e => e.action.type === ActionType.SessionCustomizationsChanged).length,
0,
'should not republish session customizations after listener disposed',
);
});
});
// ---- handleAction: session/customizationToggled ---------------------
suite('handleAction — session/customizationToggled', () => {
test('calls setCustomizationEnabled on the agent', () => {
setupSession();
const action: SessionAction = {
type: ActionType.SessionCustomizationToggled,
id: 'file:///plugin-a',
enabled: false,
};
sideEffects.handleAction(sessionUri.toString(), action);
assert.deepStrictEqual(agent.setCustomizationEnabledCalls, [
{ id: 'file:///plugin-a', enabled: false },
]);
});
});
// ---- handleAction: session/toolCallConfirmed ------------------------
suite('handleAction — session/toolCallConfirmed', () => {
test('routes confirmation to correct agent via _toolCallAgents', () => {
setupSession();
startTurn('turn-1', defaultChatUri);
disposables.add(sideEffects.registerProgressListener(agent));
// Fire tool_start to register the tool call
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-conf-1', toolName: 'read', displayName: 'Read File', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-conf-1', invocationMessage: 'Reading file', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
// Fire tool_ready asking for permission (non-write, so not auto-approved)
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-conf-1', toolName: '', displayName: '',
invocationMessage: 'Read file.txt', toolInput: undefined,
confirmationTitle: 'Read file.txt', edits: undefined,
},
permissionKind: undefined, permissionPath: undefined,
});
// Now confirm the tool call
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatToolCallConfirmed,
turnId: 'turn-1',
toolCallId: 'tc-conf-1',
approved: true,
confirmed: 'user-action' as const,
} as ChatAction);
assert.deepStrictEqual(agent.respondToPermissionCalls, [
{ requestId: 'tc-conf-1', approved: true },
]);
});
test('handles denial of tool call', () => {
setupSession();
startTurn('turn-1', defaultChatUri);
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-deny-1', toolName: 'shell', displayName: 'Shell', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-deny-1', invocationMessage: 'Running command', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatToolCallConfirmed,
turnId: 'turn-1',
toolCallId: 'tc-deny-1',
approved: false,
reason: 'denied' as const,
} as ChatAction);
assert.deepStrictEqual(agent.respondToPermissionCalls, [
{ requestId: 'tc-deny-1', approved: false },
]);
});
});
// ---- tool_ready progress dispatch -----------------------------------
suite('tool_ready dispatches progress actions to advance tool call state', () => {
test('tool_ready for a non-permission tool dispatches ChatToolCallReady and advances state from Streaming to Running', async () => {
setupSession();
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
// tool_start puts the tool call into Streaming state
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-ready-1', toolName: 'runTask', displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client' },
_meta: { toolKind: undefined, language: undefined },
},
});
const stateAfterStart = stateManager.getSessionState(sessionUri.toString());
const partAfterStart = stateAfterStart?.activeTurn?.responseParts[0];
assert.strictEqual(partAfterStart?.kind, ResponsePartKind.ToolCall);
assert.strictEqual(partAfterStart?.kind === ResponsePartKind.ToolCall ? partAfterStart.toolCall.status : undefined, ToolCallStatus.Streaming);
// tool_ready without confirmationTitle should dispatch the ready
// action and advance the tool call to Running
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-ready-1', toolName: '', displayName: '',
invocationMessage: 'Run Task', toolInput: '{"task":"build"}',
confirmationTitle: undefined, edits: undefined,
},
permissionKind: undefined, permissionPath: undefined,
});
const stateAfterReady = await waitForState(stateManager, () => {
const s = stateManager.getSessionState(sessionUri.toString());
const p = s?.activeTurn?.responseParts[0];
return p?.kind === ResponsePartKind.ToolCall && p.toolCall.status === ToolCallStatus.Running ? s : undefined;
});
const partAfterReady = stateAfterReady?.activeTurn?.responseParts[0];
assert.strictEqual(partAfterReady?.kind, ResponsePartKind.ToolCall);
assert.strictEqual(partAfterReady?.kind === ResponsePartKind.ToolCall ? partAfterReady.toolCall.status : undefined, ToolCallStatus.Running,
'tool call should advance from Streaming to Running after tool_ready');
});
test('tool_ready for a permission-gated tool dispatches ChatToolCallReady and advances state to PendingConfirmation', async () => {
setupSession();
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-perm-1', toolName: 'write', displayName: 'Write File', contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client' },
_meta: { toolKind: undefined, language: undefined },
},
});
// tool_ready with confirmationTitle should dispatch the ready
// action and advance the tool call to PendingConfirmation
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-perm-1', toolName: '', displayName: '',
invocationMessage: 'Write .env', toolInput: '{"path":".env"}',
confirmationTitle: 'Write .env', edits: undefined,
},
permissionKind: undefined, permissionPath: undefined,
});
const state = await waitForState(stateManager, () => {
const s = stateManager.getSessionState(sessionUri.toString());
const p = s?.activeTurn?.responseParts[0];
return p?.kind === ResponsePartKind.ToolCall && p.toolCall.status === ToolCallStatus.PendingConfirmation ? s : undefined;
});
const part = state?.activeTurn?.responseParts[0];
assert.strictEqual(part?.kind, ResponsePartKind.ToolCall);
assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.PendingConfirmation,
'tool call should advance to PendingConfirmation for permission-gated tool_ready');
});
test('tool_ready for an additional chat is emitted on that chat channel', async () => {
setupSession();
const chatUri = buildChatUri(sessionUri.toString(), 'peer');
stateManager.addChat(sessionUri.toString(), chatUri);
stateManager.setSessionConfig(sessionUri.toString(), { schema: { type: 'object', properties: {} }, values: { [SessionConfigKey.Permissions]: { allow: [], deny: [] } } });
startTurn('turn-peer', chatUri);
disposables.add(sideEffects.registerProgressListener(agent));
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
agent.fireProgress({
kind: 'action', resource: URI.parse(chatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-peer',
toolCallId: 'tc-peer-perm', toolName: 'write', displayName: 'Write File', contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client' },
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(chatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-peer-perm', toolName: '', displayName: '',
invocationMessage: 'Write .env', toolInput: '{"path":".env"}',
confirmationTitle: 'Write .env', edits: undefined,
},
permissionKind: undefined, permissionPath: undefined,
});
const chatState = await waitForState(stateManager, () => {
const s = stateManager.getChatState(chatUri);
const p = s?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-peer-perm');
return p?.kind === ResponsePartKind.ToolCall && p.toolCall.status === ToolCallStatus.PendingConfirmation ? s : undefined;
});
const defaultState = stateManager.getSessionState(sessionUri.toString());
const defaultPart = defaultState?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-peer-perm');
const peerPart = chatState.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-peer-perm');
const readyEnvelope = envelopes.find(e => e.action.type === ActionType.ChatToolCallReady && hasKey(e.action, { toolCallId: true }) && e.action.toolCallId === 'tc-peer-perm');
assert.deepStrictEqual({
peerToolStatus: peerPart?.kind === ResponsePartKind.ToolCall
? peerPart.toolCall.status
: undefined,
defaultHasTool: defaultPart !== undefined,
readyEnvelopeChannel: readyEnvelope?.channel,
}, {
peerToolStatus: ToolCallStatus.PendingConfirmation,
defaultHasTool: false,
readyEnvelopeChannel: chatUri,
});
sideEffects.handleAction(chatUri, {
type: ActionType.ChatToolCallConfirmed,
turnId: 'turn-peer',
toolCallId: 'tc-peer-perm',
approved: true,
confirmed: 'user-action' as const,
selectedOptionId: 'allow-session',
} as ChatAction);
assert.deepStrictEqual(agent.respondToPermissionCalls, [
{ requestId: 'tc-peer-perm', approved: true },
]);
assert.deepStrictEqual(stateManager.getSessionState(sessionUri.toString())?.config?.values[SessionConfigKey.Permissions], { allow: ['write'], deny: [] });
});
test('pending_confirmation for a tool inside a subagent routes to the subagent session', async () => {
// Regression: a `pending_confirmation` signal for a client tool
// inside a subagent must dispatch ChatToolCallReady against
// the subagent session, not the parent. Otherwise the parent
// session sees a stray `session/toolCallReady` with no
// preceding `session/toolCallStart`, which is illegal.
setupSession();
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
// Parent tool that delegates to a subagent.
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-parent', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper' });
// Inner client tool starts inside the subagent.
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent',
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-inner', toolName: 'problems', displayName: 'Problems', contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-tools' },
_meta: { toolKind: undefined, language: undefined },
},
});
// Permission flow fires `pending_confirmation` for the inner
// client tool. The signal must be routed to the subagent
// chat — not to the parent — when the signal carries the parent
// chat URI and parentToolCallId.
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent',
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-inner', toolName: 'problems', displayName: 'Problems',
invocationMessage: 'Get problems', toolInput: '{}',
confirmationTitle: undefined, edits: undefined,
},
permissionKind: 'custom-tool', permissionPath: undefined,
});
// The subagent chat must contain the ChatToolCallReady.
const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-parent');
const subState = await waitForState(stateManager, () => {
const s = stateManager.getSessionState(subagentUri);
const inner = s?.activeTurn?.responseParts.find(
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-inner'
);
return inner?.kind === ResponsePartKind.ToolCall && inner.toolCall.status === ToolCallStatus.Running ? s : undefined;
});
const innerPart = subState?.activeTurn?.responseParts.find(
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-inner'
);
assert.ok(innerPart, 'inner client tool call should exist on subagent session');
assert.strictEqual(
innerPart!.kind === ResponsePartKind.ToolCall ? innerPart.toolCall.status : undefined,
ToolCallStatus.Running,
'inner client tool call should advance to Running after pending_confirmation'
);
// The parent session must NOT have a stray tool call for the
// inner toolCallId — that would be a ChatToolCallReady
// without a matching ChatToolCallStart.
const parentState = stateManager.getSessionState(sessionUri.toString());
const parentInner = parentState?.activeTurn?.responseParts.find(
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-inner'
);
assert.strictEqual(parentInner, undefined, 'parent session must not contain the inner tool call');
});
test('pending_confirmation without an active turn still dispatches (does not hang)', async () => {
// Regression: when a hook-triggered continuation runs after
// the protocol turn has completed, the state manager has no
// active turn. Action signals go through a fallback path, but
// pending_confirmation was silently dropped — causing the
// permission deferred to never resolve and the session to hang.
setupSession(URI.file('/workspace').toString());
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
// Start a tool in the active turn
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-noop', toolName: 'view', displayName: 'Read',
contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
// Complete the turn — state manager no longer has an active turn
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallComplete, turnId: 'turn-1',
toolCallId: 'tc-noop', result: { success: true, pastTenseMessage: 'Read file' },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 },
});
// Verify no active turn
assert.strictEqual(stateManager.getActiveTurnId(sessionUri.toString()), undefined);
// Simulate the hook-triggered continuation: tool actions
// arrive without a new protocol turn being started
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: '',
toolCallId: 'tc-orphan', toolName: 'view', displayName: 'Read',
contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
// Now the pending_confirmation arrives — this must NOT be dropped
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-orphan', toolName: 'view', displayName: 'Read',
invocationMessage: 'Reading file.ts', toolInput: '{"path":"file.ts"}',
confirmationTitle: undefined, edits: undefined,
},
permissionKind: 'read', permissionPath: '/workspace/file.ts',
});
// The respondToPermissionRequest should have been called
// (auto-approved because read is inside the working directory).
// _handleToolReady is async (awaits getAutoApproval -> realpath),
// so wait for the approval to settle deterministically.
await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined);
assert.deepStrictEqual(agent.respondToPermissionCalls, [
{ requestId: 'tc-orphan', approved: true },
], 'pending_confirmation without active turn should still be processed and auto-approved');
});
});
// ---- ChatToolCallComplete routing -----------------------------------
suite('handleAction — chat/toolCallComplete routing', () => {
test('forwards session + default chat URI for a default-chat completion', () => {
// Regression: agents key their sessions by session id, but the
// chat URI's path is a base64 blob. The session URI must be passed
// so the lookup resolves instead of silently dropping the call.
setupSession();
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatToolCallComplete,
turnId: 'turn-1',
toolCallId: 'tc-default',
result: { success: true, pastTenseMessage: 'done' },
});
assert.deepStrictEqual(
agent.clientToolCallCompleteCalls.map(c => ({ session: c.session.toString(), chat: c.chat?.toString(), toolCallId: c.toolCallId })),
[{ session: sessionUri.toString(), chat: defaultChatUri, toolCallId: 'tc-default' }],
);
});
test('forwards owning session + chat URI for an additional-chat completion', () => {
setupSession();
const peerChatUri = buildChatUri(sessionUri.toString(), 'peer-1');
sideEffects.handleAction(peerChatUri, {
type: ActionType.ChatToolCallComplete,
turnId: 'turn-1',
toolCallId: 'tc-peer',
result: { success: true, pastTenseMessage: 'done' },
});
assert.deepStrictEqual(
agent.clientToolCallCompleteCalls.map(c => ({ session: c.session.toString(), chat: c.chat?.toString(), toolCallId: c.toolCallId })),
[{ session: sessionUri.toString(), chat: peerChatUri, toolCallId: 'tc-peer' }],
);
});
test('forwards parent peer chat URI for a subagent-chat completion', () => {
setupSession();
const peerChatUri = buildChatUri(sessionUri.toString(), 'peer-subagent-parent');
stateManager.addChat(sessionUri.toString(), peerChatUri);
startTurn('turn-peer', peerChatUri);
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'subagent_started',
chat: URI.parse(peerChatUri),
toolCallId: 'tc-parent',
agentName: 'explore',
agentDisplayName: 'Explore',
});
const subagentChatUri = buildSubagentChatUri(sessionUri.toString(), 'tc-parent');
sideEffects.handleAction(subagentChatUri, {
type: ActionType.ChatToolCallComplete,
turnId: 'turn-subagent',
toolCallId: 'tc-inner',
result: { success: true, pastTenseMessage: 'done' },
});
assert.deepStrictEqual(
agent.clientToolCallCompleteCalls.map(c => ({ session: c.session.toString(), chat: c.chat?.toString(), toolCallId: c.toolCallId })),
[{ session: sessionUri.toString(), chat: peerChatUri, toolCallId: 'tc-inner' }],
);
});
});
// ---- Session-level auto-approve (config) ----------------------------
suite('session config auto-approve', () => {
function setupSessionWithConfig(autoApproveLevel: string): void {
setupSession(URI.file('/workspace').toString());
// Set config on the session state directly (as agentService.ts does)
stateManager.setSessionConfig(sessionUri.toString(), {
schema: {
type: 'object',
properties: {
autoApprove: {
type: 'string',
title: 'Approvals',
enum: ['default', 'autoApprove', 'autopilot'],
default: 'default',
sessionMutable: true,
},
},
},
values: { autoApprove: autoApproveLevel },
});
}
test('auto-approves all writes when autoApprove is set to bypass', async () => {
setupSessionWithConfig('autoApprove');
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-bypass-1', toolName: 'write', displayName: 'Write', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-bypass-1', invocationMessage: 'Write .env', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-bypass-1', toolName: '', displayName: '',
invocationMessage: 'Write .env', toolInput: undefined,
confirmationTitle: undefined, edits: undefined,
},
permissionKind: 'write', permissionPath: '/workspace/.env',
});
await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined);
// .env would normally be blocked, but session-level auto-approve overrides
assert.deepStrictEqual(agent.respondToPermissionCalls, [
{ requestId: 'tc-bypass-1', approved: true },
]);
});
test('auto-approves shell commands when autoApprove is set to bypass', async () => {
setupSessionWithConfig('autoApprove');
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-bypass-shell-1', toolName: 'shell', displayName: 'Shell', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-bypass-shell-1', invocationMessage: 'Run rm -rf /', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-bypass-shell-1', toolName: '', displayName: '',
invocationMessage: 'Run rm -rf /', toolInput: 'rm -rf /',
confirmationTitle: undefined, edits: undefined,
},
permissionKind: 'shell', permissionPath: undefined,
});
await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined);
// Dangerous command would normally be blocked, but session-level
// bypass auto-approve overrides.
assert.deepStrictEqual(agent.respondToPermissionCalls, [
{ requestId: 'tc-bypass-shell-1', approved: true },
]);
});
test('does NOT auto-approve a shell command that opted out of the sandbox, even in bypass mode', () => {
setupSessionWithConfig('autoApprove');
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-sandboxbypass-1', toolName: 'shell', displayName: 'Shell', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-sandboxbypass-1', toolName: '', displayName: '',
invocationMessage: 'Run cat ~/something.txt', toolInput: 'cat ~/something.txt',
confirmationTitle: 'Run command', edits: undefined,
},
permissionKind: 'shell', permissionPath: undefined,
requestSandboxBypass: true,
});
// A read-only command like `cat` (or even session-level bypass)
// would normally auto-approve, but opting out of the sandbox is an
// elevation of privilege the user must confirm, so no auto-approval
// response is sent.
assert.deepStrictEqual(agent.respondToPermissionCalls, []);
});
test('marks pending client tool approval for client-side auto-approval in bypass mode', async () => {
setupSessionWithConfig('autoApprove');
startTurn('turn-1', defaultChatUri);
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-client-approve-1', toolName: 'runTask', displayName: 'Run Task', contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client' },
_meta: { toolKind: 'terminal' },
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-client-approve-1', toolName: 'runTask', displayName: 'Run Task',
invocationMessage: 'Run task', toolInput: '{"task":"build"}',
confirmationTitle: 'Run task', edits: undefined,
},
permissionKind: 'custom-tool', permissionPath: undefined,
});
const state = await waitForState(stateManager, () => {
const s = stateManager.getSessionState(sessionUri.toString());
const p = s?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-client-approve-1');
return p?.kind === ResponsePartKind.ToolCall && p.toolCall.status === ToolCallStatus.PendingConfirmation ? s : undefined;
});
const part = state?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-client-approve-1');
assert.ok(part?.kind === ResponsePartKind.ToolCall);
assert.deepStrictEqual({
status: part.toolCall.status,
meta: part.toolCall._meta,
permissionCalls: agent.respondToPermissionCalls,
}, {
status: ToolCallStatus.PendingConfirmation,
meta: { toolKind: 'terminal', autoApproveBySetting: true },
permissionCalls: [],
});
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatToolCallConfirmed,
turnId: 'turn-1',
toolCallId: 'tc-client-approve-1',
approved: true,
confirmed: ToolCallConfirmationReason.Setting,
});
assert.deepStrictEqual(agent.respondToPermissionCalls, [
{ requestId: 'tc-client-approve-1', approved: true },
]);
});
test('does NOT auto-approve when autoApprove is default', () => {
setupSessionWithConfig('default');
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-default-1', toolName: 'write', displayName: 'Write', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-default-1', invocationMessage: 'Write .env', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-default-1', toolName: '', displayName: '',
invocationMessage: 'Write .env', toolInput: undefined,
confirmationTitle: undefined, edits: undefined,
},
permissionKind: 'write', permissionPath: '/workspace/.env',
});
// .env should still be blocked with default config
assert.strictEqual(agent.respondToPermissionCalls.length, 0);
});
test('respects mid-session config change via SessionConfigChanged', async () => {
setupSessionWithConfig('default');
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
// Change to bypass mid-session
stateManager.dispatchServerAction(sessionUri.toString(), {
type: ActionType.SessionConfigChanged,
config: { autoApprove: 'autoApprove' },
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-mid-1', toolName: 'write', displayName: 'Write', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-mid-1', invocationMessage: 'Write .env', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-mid-1', toolName: '', displayName: '',
invocationMessage: 'Write .env', toolInput: undefined,
confirmationTitle: undefined, edits: undefined,
},
permissionKind: 'write', permissionPath: '/workspace/.env',
});
await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined);
// Should now be auto-approved after config change
assert.deepStrictEqual(agent.respondToPermissionCalls, [
{ requestId: 'tc-mid-1', approved: true },
]);
});
});
// ---- Edit auto-approve ----------------------------------------------
suite('edit auto-approve', () => {
test('auto-approves writes to regular source files', async () => {
setupSession(URI.file('/workspace').toString());
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-auto-1', toolName: 'write', displayName: 'Write', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-auto-1', invocationMessage: 'Write file', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-auto-1', toolName: '', displayName: '',
invocationMessage: 'Write src/app.ts', toolInput: undefined,
confirmationTitle: undefined, edits: undefined,
},
permissionKind: 'write', permissionPath: '/workspace/src/app.ts',
});
await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined);
// Auto-approved writes call respondToPermissionRequest directly
assert.deepStrictEqual(agent.respondToPermissionCalls, [
{ requestId: 'tc-auto-1', approved: true },
]);
});
test('blocks writes to .env files', () => {
setupSession(URI.file('/workspace').toString());
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-env-1', toolName: 'write', displayName: 'Write', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-env-1', invocationMessage: 'Write .env', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-env-1', toolName: '', displayName: '',
invocationMessage: 'Write .env', toolInput: undefined,
confirmationTitle: 'Write .env', edits: undefined,
},
permissionKind: 'write', permissionPath: '/workspace/.env',
});
// Should NOT auto-approve — .env is excluded
assert.strictEqual(agent.respondToPermissionCalls.length, 0);
// Should dispatch a tool_ready action for the client to confirm
const readyAction = envelopes.find(e => e.action.type === ActionType.ChatToolCallReady);
assert.ok(readyAction, 'should dispatch tool_ready for blocked write');
});
test('blocks writes to package.json', () => {
setupSession(URI.file('/workspace').toString());
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-pkg-1', toolName: 'write', displayName: 'Write', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-pkg-1', invocationMessage: 'Write package.json', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-pkg-1', toolName: '', displayName: '',
invocationMessage: 'Write package.json', toolInput: undefined,
confirmationTitle: 'Write package.json', edits: undefined,
},
permissionKind: 'write', permissionPath: '/workspace/package.json',
});
assert.strictEqual(agent.respondToPermissionCalls.length, 0);
});
test('blocks writes to .lock files', () => {
setupSession(URI.file('/workspace').toString());
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-lock-1', toolName: 'write', displayName: 'Write', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-lock-1', invocationMessage: 'Write yarn.lock', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-lock-1', toolName: '', displayName: '',
invocationMessage: 'Write yarn.lock', toolInput: undefined,
confirmationTitle: 'Write yarn.lock', edits: undefined,
},
permissionKind: 'write', permissionPath: '/workspace/yarn.lock',
});
assert.strictEqual(agent.respondToPermissionCalls.length, 0);
});
test('blocks writes to .git directory', () => {
setupSession(URI.file('/workspace').toString());
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-git-1', toolName: 'write', displayName: 'Write', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-git-1', invocationMessage: 'Write .git/config', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-git-1', toolName: '', displayName: '',
invocationMessage: 'Write .git/config', toolInput: undefined,
confirmationTitle: 'Write .git/config', edits: undefined,
},
permissionKind: 'write', permissionPath: '/workspace/.git/config',
});
assert.strictEqual(agent.respondToPermissionCalls.length, 0);
});
});
// ---- Read auto-approve -------------------------------------------------
suite('read auto-approve', () => {
test('auto-approves reads inside working directory', async () => {
setupSession(URI.file('/workspace').toString());
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-read-1', toolName: 'read', displayName: 'Read', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-read-1', invocationMessage: 'Read file', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-read-1', toolName: '', displayName: '',
invocationMessage: 'Read src/app.ts', toolInput: undefined,
confirmationTitle: undefined, edits: undefined,
},
permissionKind: 'read', permissionPath: '/workspace/src/app.ts',
});
await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined);
assert.deepStrictEqual(agent.respondToPermissionCalls, [
{ requestId: 'tc-read-1', approved: true },
]);
});
test('does not auto-approve reads outside working directory', () => {
setupSession(URI.file('/workspace').toString());
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
const envelopes: ActionEnvelope[] = [];
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-read-2', toolName: 'read', displayName: 'Read', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-read-2', invocationMessage: 'Read file', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-read-2', toolName: '', displayName: '',
invocationMessage: 'Read /etc/passwd', toolInput: undefined,
confirmationTitle: undefined, edits: undefined,
},
permissionKind: 'read', permissionPath: '/etc/passwd',
});
assert.strictEqual(agent.respondToPermissionCalls.length, 0);
const readyAction = envelopes.find(e => e.action.type === ActionType.ChatToolCallReady);
assert.ok(readyAction, 'should dispatch tool_ready for read outside working directory');
});
});
// ---- Title persistence --------------------------------------------------
suite('title persistence', () => {
let sessionDb: SessionDatabase;
setup(async () => {
sessionDb = disposables.add(await SessionDatabase.open(':memory:'));
});
teardown(async () => {
await sessionDb.close();
});
test('SessionTitleChanged persists to the database', async () => {
const sessionDataService = createSessionDataService(sessionDb);
const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService()));
const localAgent = new MockAgent();
disposables.add(toDisposable(() => localAgent.dispose()));
const localSideEffects = createTestSideEffects(disposables, localStateManager, {
getAgent: () => localAgent,
agents: observableValue<readonly IAgent[]>('agents', [localAgent]),
sessionDataService,
onTurnComplete: () => { },
});
localStateManager.createSession({
resource: sessionUri.toString(),
provider: 'mock',
title: 'Initial',
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
project: { uri: 'file:///test-project', displayName: 'Test Project' },
});
localSideEffects.handleAction(sessionUri.toString(), {
type: ActionType.SessionTitleChanged,
title: 'Custom Title',
});
// Wait for the async persistence
await new Promise(r => setTimeout(r, 50));
assert.strictEqual(await sessionDb.getMetadata('customTitle'), 'Custom Title');
});
test('handleListSessions returns persisted custom title', async () => {
const sessionDataService = createSessionDataService(sessionDb);
const localAgent = new MockAgent();
disposables.add(toDisposable(() => localAgent.dispose()));
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(localAgent);
// Create a session on the agent backend
await localAgent.createSession();
// Persist a custom title in the DB
await sessionDb.setMetadata('customTitle', 'My Custom Title');
const sessions = await localService.listSessions();
assert.strictEqual(sessions.length, 1);
// Custom title comes from the DB and is returned via the agent's listSessions
// The mock agent summary is used; the service doesn't read the DB for list
assert.ok(sessions[0].summary);
});
test('handleRestoreSession uses persisted custom title', async () => {
const sessionDataService = createSessionDataService(sessionDb);
const localAgent = new MockAgent();
disposables.add(toDisposable(() => localAgent.dispose()));
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(localAgent);
// Create a session on the agent backend
const { session } = await localAgent.createSession();
const sessions = await localAgent.listSessions();
const sessionResource = sessions[0].session;
// Persist a custom title in the DB
await sessionDb.setMetadata('customTitle', 'Restored Title');
// Set up minimal messages for restore
localAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
];
await localService.restoreSession(sessionResource);
const state = localService.stateManager.getSessionState(sessionResource.toString());
assert.ok(state);
assert.strictEqual(state!.title, 'Restored Title');
});
test('restore interleaves a persisted local turn after its anchor', async () => {
const sessionDataService = createSessionDataService(sessionDb);
const localAgent = new MockAgent();
disposables.add(toDisposable(() => localAgent.dispose()));
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(localAgent);
const { session } = await localAgent.createSession();
const sessions = await localAgent.listSessions();
const sessionResource = sessions[0].session;
// The SDK transcript yields a single real turn keyed by the first
// user message id (`buildTurnsFromHistory`).
localAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'real-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'a-1', content: 'Hi', toolRequests: [] },
];
// A host-injected local turn recorded against that real turn.
const localTurn = {
id: 'local-1',
message: { text: '!echo hi', origin: { kind: MessageKind.User } },
responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'ran' }],
usage: undefined,
state: 2, // TurnState.Complete
};
await sessionDb.insertLocalTurn({ turnId: 'local-1', chatUri: buildDefaultChatUri(sessionResource.toString()), anchorTurnId: 'real-1', seq: 1, payload: JSON.stringify(localTurn) });
await localService.restoreSession(sessionResource);
const state = localService.stateManager.getSessionState(sessionResource.toString());
assert.deepStrictEqual(state?.turns.map(t => t.id), ['real-1', 'local-1']);
});
test('SessionConfigChanged persists merged config values to the database', async () => {
const sessionDataService = createSessionDataService(sessionDb);
const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService()));
const localAgent = new MockAgent();
disposables.add(toDisposable(() => localAgent.dispose()));
const localSideEffects = createTestSideEffects(disposables, localStateManager, {
getAgent: () => localAgent,
agents: observableValue<readonly IAgent[]>('agents', [localAgent]),
sessionDataService,
onTurnComplete: () => { },
});
const session = localStateManager.createSession({
resource: sessionUri.toString(),
provider: 'mock',
title: 'Initial',
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
project: { uri: 'file:///test-project', displayName: 'Test Project' },
});
session.config = { schema: { type: 'object', properties: {} }, values: { autoApprove: 'default' } };
// Mid-session change merges new values into existing.
localStateManager.dispatchClientAction(sessionUri.toString(), {
type: ActionType.SessionConfigChanged,
config: { autoApprove: 'autoApprove' },
}, { clientId: 'test-client', clientSeq: 1 });
localSideEffects.handleAction(sessionUri.toString(), {
type: ActionType.SessionConfigChanged,
config: { autoApprove: 'autoApprove' },
});
await new Promise(r => setTimeout(r, 50));
const persisted = await sessionDb.getMetadata('configValues');
assert.ok(persisted);
assert.deepStrictEqual(JSON.parse(persisted!), { autoApprove: 'autoApprove' });
});
});
// ---- Subagent sessions ----------------------------------------------
suite('subagent sessions', () => {
test('subagent_started creates a subagent chat and dispatches content on parent tool call', () => {
setupSession();
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
// Start a parent tool call
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-1', invocationMessage: 'Delegating task...', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
// Fire subagent_started
agent.fireProgress({
kind: 'subagent_started', chat: URI.parse(defaultChatUri),
toolCallId: 'tc-1',
agentName: 'code-reviewer',
agentDisplayName: 'Code Reviewer',
agentDescription: 'Reviews code',
});
// Verify the subagent chat was created
const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1');
const subState = stateManager.getSessionState(subagentUri);
assert.ok(subState, 'subagent chat should exist');
const subagentSummary = subState!.chats.find(c => c.resource === subagentUri);
assert.strictEqual(subagentSummary?.title, 'Code Reviewer');
assert.deepStrictEqual(subagentSummary?.origin, { kind: 'tool', chat: defaultChatUri, toolCallId: 'tc-1' });
assert.ok(subState!.activeTurn, 'subagent chat should have an active turn');
// Verify content was dispatched on the parent tool call
const parentState = stateManager.getSessionState(sessionUri.toString());
assert.ok(parentState?.activeTurn);
const parentToolCall = parentState!.activeTurn!.responseParts.find(
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1'
);
assert.ok(parentToolCall);
if (parentToolCall?.kind === ResponsePartKind.ToolCall && parentToolCall.toolCall.status === ToolCallStatus.Running) {
assert.ok(parentToolCall.toolCall.content);
assert.strictEqual(parentToolCall.toolCall.content![0].type, ToolResultContentType.Subagent);
}
});
test('nested subagent_started routes its discovery content block to the immediate parent chat (arbitrary depth)', () => {
// Regression: for a subagent spawned by another subagent, the
// `subagent_started` signal's `chat` is the top-level chat, but
// its spawning tool call lives in the immediate parent's subagent
// chat. The discovery `ChatToolCallContentChanged` must land there
// (resolved via `parentToolCallId`) — dispatching it on the
// top-level chat is a no-op, leaving the nested subagent
// undiscoverable and hanging any client tool it runs. Driven three
// levels deep to prove the resolution is not capped at two: each
// level's block lands on its immediate parent chat via a single
// flat-map lookup, independent of depth.
setupSession();
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
// Level-1 subagent spawned from the default chat.
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-l1', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: 'subagent', language: undefined } } });
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-l1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-l1', agentName: 'l1', agentDisplayName: 'L1', agentDescription: 'first' });
// Level-2 subagent's spawning tool runs INSIDE the level-1
// subagent (parentToolCallId = tc-l1), so it lands on the level-1
// subagent chat rather than the default chat.
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-l1', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-l2', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: 'subagent', language: undefined } } });
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-l1', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-l2', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
// Level-2 subagent starts. Its spawning tool (tc-l2) lives in the
// level-1 subagent chat, so the signal carries parentToolCallId = tc-l1.
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-l2', agentName: 'l2', agentDisplayName: 'L2', agentDescription: 'second', parentToolCallId: 'tc-l1' });
// Level-3 subagent's spawning tool runs INSIDE the level-2
// subagent (parentToolCallId = tc-l2), landing on the level-2 chat.
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-l2', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-l3', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: 'subagent', language: undefined } } });
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-l2', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-l3', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
// Level-3 subagent starts. Its spawning tool (tc-l3) lives in the
// level-2 subagent chat, so the signal carries parentToolCallId = tc-l2.
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-l3', agentName: 'l3', agentDisplayName: 'L3', agentDescription: 'third', parentToolCallId: 'tc-l2' });
const l1ChatUri = buildSubagentChatUri(sessionUri.toString(), 'tc-l1');
const l2ChatUri = buildSubagentChatUri(sessionUri.toString(), 'tc-l2');
const l3ChatUri = buildSubagentChatUri(sessionUri.toString(), 'tc-l3');
assert.ok(stateManager.getSessionState(l2ChatUri), 'level-2 subagent chat should exist');
assert.ok(stateManager.getSessionState(l3ChatUri), 'level-3 subagent chat should exist');
// Asserts a subagent's discovery block landed on `parentChatUri`'s
// `spawningToolId` tool call, pointing at `childChatUri`.
const assertDiscoveryBlock = (parentChatUri: string, spawningToolId: string, childChatUri: string, label: string) => {
const parentState = stateManager.getSessionState(parentChatUri);
const spawningTool = parentState?.activeTurn?.responseParts.find(rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === spawningToolId);
assert.ok(spawningTool && spawningTool.kind === ResponsePartKind.ToolCall, `${spawningToolId} should live in ${label}`);
const tc = spawningTool.toolCall;
// `content` only exists on the running/completed variants of the
// ToolCallState union; the spawning tool is running here.
assert.strictEqual(tc.status, ToolCallStatus.Running, `${spawningToolId} should be running in ${label}`);
if (tc.status !== ToolCallStatus.Running) {
return;
}
const block = tc.content?.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent);
assert.ok(block, `the discovery block for ${spawningToolId} must land on ${label}`);
assert.strictEqual((block as { resource: string }).resource, childChatUri);
};
// Each level's discovery block lands on its immediate parent chat.
assertDiscoveryBlock(l1ChatUri, 'tc-l2', l2ChatUri, 'the level-1 chat');
assertDiscoveryBlock(l2ChatUri, 'tc-l3', l3ChatUri, 'the level-2 chat');
// Nested spawning tools must NOT be misrouted to the top-level
// default chat, where they do not exist.
const defaultState = stateManager.getSessionState(sessionUri.toString());
const l2ToolInDefault = defaultState?.activeTurn?.responseParts.find(rp => rp.kind === ResponsePartKind.ToolCall && (rp.toolCall.toolCallId === 'tc-l2' || rp.toolCall.toolCallId === 'tc-l3'));
assert.strictEqual(l2ToolInDefault, undefined, 'nested spawning tools must not appear in the top-level chat');
});
test('events with parentToolCallId route to subagent session', () => {
setupSession();
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
// Start parent tool + subagent
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } });
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });
// Fire an inner tool start with parentToolCallId
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-1',
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-1',
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'inner-tc-1', invocationMessage: 'Reading file...', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
// Verify the inner tool call is on the subagent chat's turn, not the parent
const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1');
const subState = stateManager.getSessionState(subagentUri);
assert.ok(subState?.activeTurn);
const innerTool = subState!.activeTurn!.responseParts.find(
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1'
);
assert.ok(innerTool, 'inner tool call should be in subagent chat');
// Verify the parent session does NOT have the inner tool call
const parentState = stateManager.getSessionState(sessionUri.toString());
const parentInnerTool = parentState!.activeTurn!.responseParts.find(
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1'
);
assert.strictEqual(parentInnerTool, undefined, 'inner tool call should NOT be in parent session');
});
test('completeSubagentSession clears pending buffered events when subagent never started', () => {
// Regression: if the parent tool completes (or fails) before any
// `subagent_started` arrives, buffered inner events would
// otherwise leak in `_pendingSubagentEvents` until session
// disposal. After completion, a late `subagent_started` for the
// same toolCallId must not replay stale events.
setupSession();
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } });
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
// Inner event arrives but `subagent_started` never does.
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-1',
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'inner-1', toolName: 'read', displayName: 'Read', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-1',
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'inner-1', invocationMessage: 'Reading...', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
// Parent tool completes (e.g. it errored before delegating).
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallComplete, turnId: 'turn-1',
toolCallId: 'tc-1',
result: { success: false, pastTenseMessage: 'Failed' },
},
});
// Now a late `subagent_started` for the same toolCallId arrives.
// This is unusual but possible after a reconnect/replay. The
// drain must NOT replay the (cleared) buffered inner tool call.
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });
const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1');
const subState = stateManager.getSessionState(subagentUri);
assert.ok(subState, 'subagent session should still be created');
const innerTool = subState!.activeTurn?.responseParts.find(
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-1'
);
assert.strictEqual(innerTool, undefined, 'stale buffered inner tool call must not be replayed');
});
test('subagent_completed signal completes the subagent turn', () => {
setupSession();
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
// Start parent tool + subagent
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } });
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });
// Completing the parent tool call must NOT tear down the
// subagent session — background subagents keep running after
// their parent tool call returns.
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallComplete, turnId: 'turn-1',
toolCallId: 'tc-1',
result: { success: true, pastTenseMessage: 'Started in background' },
},
});
const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1');
let subState = stateManager.getSessionState(subagentUri);
assert.ok(subState);
assert.ok(subState!.activeTurn, 'subagent turn should still be active after parent tool completes');
// The SDK's `subagent.completed`/`subagent.failed` event is what
// actually closes the subagent session.
agent.fireProgress({ kind: 'subagent_completed', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1' });
subState = stateManager.getSessionState(subagentUri);
assert.strictEqual(subState!.activeTurn, undefined, 'subagent turn should be completed');
assert.strictEqual(subState!.turns.length, 1);
});
test('cancelSubagentSessions cancels all subagent chats', () => {
setupSession();
startTurn('turn-1', defaultChatUri);
disposables.add(sideEffects.registerProgressListener(agent));
// Start two parent tool calls with subagents
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } });
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating 1...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'sub1', agentDisplayName: 'Sub 1', agentDescription: 'First' });
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-2', toolName: 'runSubagent', displayName: 'Sub 2', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } });
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-2', invocationMessage: 'Delegating 2...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-2', agentName: 'sub2', agentDisplayName: 'Sub 2', agentDescription: 'Second' });
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTurnCancelled,
turnId: 'turn-1',
duration: 1000,
});
// Both subagent chats should have their turns completed (cancelled)
const sub1 = stateManager.getSessionState(buildSubagentChatUri(sessionUri.toString(), 'tc-1'));
const sub2 = stateManager.getSessionState(buildSubagentChatUri(sessionUri.toString(), 'tc-2'));
assert.strictEqual(sub1?.activeTurn, undefined, 'sub1 turn should be cancelled');
assert.strictEqual(sub2?.activeTurn, undefined, 'sub2 turn should be cancelled');
});
test('removeSubagentSessions removes all subagent chats from state', () => {
setupSession();
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } });
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'sub', agentDisplayName: 'Sub', agentDescription: 'Has subagent' });
const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1');
assert.ok(stateManager.getChatState(subagentUri));
sideEffects.removeSubagentSessions(sessionUri.toString());
assert.strictEqual(stateManager.getChatState(subagentUri), undefined, 'subagent chat should be removed');
});
test('deltas with parentToolCallId route to subagent session', () => {
setupSession();
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } });
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });
// Fire a delta with parentToolCallId
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-1',
action: { type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-sub', content: 'thinking...' } },
});
// Verify the delta went to the subagent session
const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1');
const subState = stateManager.getSessionState(subagentUri);
assert.ok(subState?.activeTurn);
const markdownPart = subState!.activeTurn!.responseParts.find(
rp => rp.kind === ResponsePartKind.Markdown
);
assert.ok(markdownPart, 'delta should create a markdown part in subagent session');
});
test('tool_complete preserves subagent content in completed tool call', () => {
setupSession();
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } });
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' });
// Verify subagent content is on the running tool
const runningState = stateManager.getSessionState(sessionUri.toString());
const runningTool = runningState?.activeTurn?.responseParts.find(
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1'
);
assert.ok(runningTool?.kind === ResponsePartKind.ToolCall);
assert.strictEqual(runningTool.toolCall.status, ToolCallStatus.Running);
// Complete the tool — the SDK result has its own content
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallComplete, turnId: 'turn-1',
toolCallId: 'tc-1',
result: { success: true, pastTenseMessage: 'Delegated', content: [{ type: ToolResultContentType.Text, text: 'Done' }] },
},
});
// Verify the completed tool still has the subagent content entry
const completedState = stateManager.getSessionState(sessionUri.toString());
const completedTool = completedState?.activeTurn?.responseParts.find(
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1'
);
assert.ok(completedTool?.kind === ResponsePartKind.ToolCall);
assert.strictEqual(completedTool.toolCall.status, ToolCallStatus.Completed);
const content = completedTool.toolCall.content ?? [];
const subagentEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent);
assert.ok(subagentEntry, 'Completed tool should preserve subagent content entry');
const textEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Text);
assert.ok(textEntry, 'Completed tool should also have the SDK result content');
});
test('inner tool_start arriving BEFORE subagent_started routes to subagent (not parent)', () => {
// Reproduces the regression where inner subagent tool calls show up
// flat at the top level of the parent session because the SDK can
// emit `tool_start` (with parentToolCallId) before `subagent_started`.
setupSession();
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
// 1. Parent tool starts (the `task` invocation).
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } });
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
// 2. Inner tool fires BEFORE subagent_started (race condition).
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent',
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent',
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'inner-tc-1', invocationMessage: 'Reading file...', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
// 3. subagent_started arrives later.
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });
const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-parent');
const subState = stateManager.getSessionState(subagentUri);
assert.ok(subState?.activeTurn, 'subagent session should exist');
const innerTool = subState!.activeTurn!.responseParts.find(
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1'
);
assert.ok(innerTool, 'inner tool fired before subagent_started should still end up in the subagent session');
// Parent must NOT have the inner tool.
const parentState = stateManager.getSessionState(sessionUri.toString());
const parentInnerTool = parentState!.activeTurn!.responseParts.find(
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1'
);
assert.strictEqual(parentInnerTool, undefined, 'inner tool must not leak into parent session');
});
test('reads inside parent working directory are auto-approved for tools in subagent sessions', async () => {
// Subagent sessions don't carry their own workingDirectory or
// autoApprove config. Without inheritance from the parent, every
// tool call inside a subagent (even a read in the workspace) would
// surface a confirmation dialog.
setupSession(URI.file('/workspace').toString());
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
// Parent task tool spawns a subagent.
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } });
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });
// Inner tool inside the subagent requests permission to read a file
// inside the parent workspace.
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent',
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'inner-read-1', toolName: 'read', displayName: 'Read', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent',
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'inner-read-1', invocationMessage: 'Read file', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'inner-read-1', toolName: '', displayName: '',
invocationMessage: 'Read src/app.ts', toolInput: undefined,
confirmationTitle: undefined, edits: undefined,
},
permissionKind: 'read', permissionPath: '/workspace/src/app.ts',
});
await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined);
assert.deepStrictEqual(agent.respondToPermissionCalls, [
{ requestId: 'inner-read-1', approved: true },
]);
});
test('session-level autoApprove on the parent is inherited by tools in subagent sessions', async () => {
setupSession(URI.file('/workspace').toString());
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
// Set the parent session to "Bypass Approvals" via session config.
stateManager.setSessionConfig(sessionUri.toString(), {
schema: {
type: 'object',
properties: {
autoApprove: {
type: 'string',
title: 'Approvals',
enum: ['default', 'autoApprove', 'autopilot'],
default: 'default',
sessionMutable: true,
},
},
},
values: { autoApprove: 'autoApprove' },
});
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } });
agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });
// Inner write outside the workspace would normally NOT auto-approve,
// but session-level autoApprove on the parent must apply.
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent',
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'inner-write-1', toolName: 'write', displayName: 'Write', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent',
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'inner-write-1', invocationMessage: 'Write file', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'inner-write-1', toolName: '', displayName: '',
invocationMessage: 'Write /tmp/foo', toolInput: undefined,
confirmationTitle: undefined, edits: undefined,
},
permissionKind: 'write', permissionPath: '/tmp/foo',
});
await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined);
assert.deepStrictEqual(agent.respondToPermissionCalls, [
{ requestId: 'inner-write-1', approved: true },
]);
});
});
// ---- Session inputNeeded production -------------------------------------
suite('session inputNeeded production', () => {
function sessionInputNeeded() {
return stateManager.getSessionState(sessionUri.toString())?.inputNeeded ?? [];
}
test('chat input request is produced and removed on completion', () => {
setupSession();
startTurn('turn-1');
stateManager.dispatchServerAction(defaultChatUri, {
type: ActionType.ChatInputRequested,
request: { id: 'req-1', questions: [] },
});
const produced = sessionInputNeeded();
assert.deepStrictEqual(produced.map(r => ({ kind: r.kind, chat: r.chat })), [
{ kind: SessionInputRequestKind.ChatInput, chat: defaultChatUri },
]);
stateManager.dispatchServerAction(defaultChatUri, {
type: ActionType.ChatInputCompleted,
requestId: 'req-1',
response: SessionInputResponseKind.Accept,
});
assert.deepStrictEqual(sessionInputNeeded(), []);
});
test('tool confirmation is produced while pending and removed once confirmed', () => {
setupSession();
startTurn('turn-1');
stateManager.dispatchServerAction(defaultChatUri, {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-1', toolName: 'write', displayName: 'Write',
});
stateManager.dispatchServerAction(defaultChatUri, {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-1', invocationMessage: 'Write file', confirmationTitle: 'Write file',
});
const pending = sessionInputNeeded();
assert.deepStrictEqual(
pending.map(r => ({ kind: r.kind, chat: r.chat, toolCallId: r.kind === SessionInputRequestKind.ToolConfirmation ? r.toolCall.toolCallId : undefined })),
[{ kind: SessionInputRequestKind.ToolConfirmation, chat: defaultChatUri, toolCallId: 'tc-1' }],
);
stateManager.dispatchServerAction(defaultChatUri, {
type: ActionType.ChatToolCallConfirmed, turnId: 'turn-1',
toolCallId: 'tc-1', approved: true, confirmed: ToolCallConfirmationReason.UserAction,
});
assert.deepStrictEqual(sessionInputNeeded(), []);
});
test('client tool execution is produced while running and removed once complete', () => {
setupSession();
startTurn('turn-1');
stateManager.dispatchServerAction(defaultChatUri, {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-client', toolName: 'toolSearch', displayName: 'Search for Tools',
contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-1' },
});
stateManager.dispatchServerAction(defaultChatUri, {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-client', invocationMessage: 'Searching', confirmed: ToolCallConfirmationReason.NotNeeded,
});
const running = sessionInputNeeded();
assert.deepStrictEqual(
running.map(r => ({ kind: r.kind, chat: r.chat, clientId: r.kind === SessionInputRequestKind.ToolClientExecution ? r.clientId : undefined })),
[{ kind: SessionInputRequestKind.ToolClientExecution, chat: defaultChatUri, clientId: 'client-1' }],
);
stateManager.dispatchServerAction(defaultChatUri, {
type: ActionType.ChatToolCallComplete, turnId: 'turn-1',
toolCallId: 'tc-client', result: { success: true, pastTenseMessage: 'Searched' },
});
assert.deepStrictEqual(sessionInputNeeded(), []);
});
test('ending the turn clears the chat\'s outstanding requests', () => {
setupSession();
startTurn('turn-1');
stateManager.dispatchServerAction(defaultChatUri, {
type: ActionType.ChatInputRequested,
request: { id: 'req-1', questions: [] },
});
assert.strictEqual(sessionInputNeeded().length, 1);
stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', duration: 1000 });
assert.deepStrictEqual(sessionInputNeeded(), []);
});
test('a blocker inside a subagent is produced against the subagent chat', async () => {
setupSession();
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Delegate Task' },
});
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper' });
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent',
action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-inner', toolName: 'write', displayName: 'Write' },
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent',
action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-inner', invocationMessage: 'Write file', confirmationTitle: 'Write file' },
});
const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-parent');
const produced = await waitForState(stateManager, () => {
const entry = sessionInputNeeded().find(r => r.kind === SessionInputRequestKind.ToolConfirmation);
return entry?.kind === SessionInputRequestKind.ToolConfirmation ? entry : undefined;
});
assert.deepStrictEqual({ chat: produced.chat, toolCallId: produced.toolCall.toolCallId }, { chat: subagentUri, toolCallId: 'tc-inner' });
});
});
// ---- Session permissions ------------------------------------------------
suite('session permissions', () => {
test('tool_ready action includes confirmation options when confirmation is needed', async () => {
setupSession();
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-perm-1', invocationMessage: 'Running custom tool', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-perm-1', toolName: '', displayName: '',
invocationMessage: 'Run custom tool', toolInput: undefined,
confirmationTitle: 'Run custom tool', edits: undefined,
},
permissionKind: 'custom-tool', permissionPath: undefined,
});
const state = await waitForState(stateManager, () => {
const s = stateManager.getSessionState(sessionUri.toString());
const found = s?.activeTurn?.responseParts.find(
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-perm-1'
);
return found?.kind === ResponsePartKind.ToolCall && found.toolCall.status === ToolCallStatus.PendingConfirmation ? s : undefined;
});
const tc = state!.activeTurn!.responseParts.find(
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-perm-1'
);
assert.ok(tc && tc.kind === ResponsePartKind.ToolCall, 'tool call should exist');
assert.strictEqual(tc.toolCall.status, ToolCallStatus.PendingConfirmation);
assert.ok(Array.isArray(tc.toolCall.options), 'options should be an array');
assert.deepStrictEqual(tc.toolCall.options!.map(o => o.id), ['allow-session', 'allow-once', 'skip']);
});
test('ChatToolCallConfirmed with allow-session adds tool to session permissions', () => {
setupSession();
stateManager.setSessionConfig(sessionUri.toString(), {
schema: { type: 'object', properties: {} },
values: {},
});
startTurn('turn-1', defaultChatUri);
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-perm-2', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-perm-2', invocationMessage: 'Running custom tool', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-perm-2', toolName: '', displayName: '',
invocationMessage: 'Run custom tool', toolInput: undefined,
confirmationTitle: 'Run custom tool', edits: undefined,
},
permissionKind: 'custom-tool', permissionPath: undefined,
});
sideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatToolCallConfirmed,
turnId: 'turn-1',
toolCallId: 'tc-perm-2',
approved: true,
confirmed: 'user-action' as const,
selectedOptionId: 'allow-session',
} as ChatAction);
const updatedState = stateManager.getSessionState(sessionUri.toString());
assert.deepStrictEqual(
updatedState!.config!.values.permissions,
{ allow: ['CustomTool'], deny: [] },
);
});
test('subsequent tool_ready for same tool is auto-approved after allow-session permission', async () => {
setupSession();
stateManager.setSessionConfig(sessionUri.toString(), {
schema: { type: 'object', properties: {} },
values: { permissions: { allow: ['CustomTool'], deny: [] } },
});
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-perm-3', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-perm-3', invocationMessage: 'Running custom tool', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'tc-perm-3', toolName: '', displayName: '',
invocationMessage: 'Run custom tool', toolInput: undefined,
confirmationTitle: 'Run custom tool', edits: undefined,
},
permissionKind: 'custom-tool', permissionPath: undefined,
});
await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined);
assert.deepStrictEqual(agent.respondToPermissionCalls, [
{ requestId: 'tc-perm-3', approved: true },
]);
});
test('subagent tool calls inherit parent session permissions', async () => {
setupSession();
stateManager.setSessionConfig(sessionUri.toString(), {
schema: { type: 'object', properties: {} },
values: { permissions: { allow: ['CustomTool'], deny: [] } },
});
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'subagent_started', chat: URI.parse(defaultChatUri),
toolCallId: 'tc-parent',
agentName: 'helper',
agentDisplayName: 'Helper',
agentDescription: 'Helps',
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent',
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'inner-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent',
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'inner-perm-1', invocationMessage: 'Running custom tool', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
state: {
status: ToolCallStatus.PendingConfirmation,
toolCallId: 'inner-perm-1', toolName: '', displayName: '',
invocationMessage: 'Run custom tool', toolInput: undefined,
confirmationTitle: 'Run custom tool', edits: undefined,
},
permissionKind: 'custom-tool', permissionPath: undefined,
});
await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined);
assert.deepStrictEqual(agent.respondToPermissionCalls, [
{ requestId: 'inner-perm-1', approved: true },
]);
});
});
// ---- Forwarding into IAgentHostChangesetService ------------------------
suite('changeset forwarders', () => {
test('post-toolCallComplete edits fire onToolCallEditsApplied once', () => {
setupSession();
startTurn('turn-1');
disposables.add(sideEffects.registerProgressListener(agent));
const changesets = new FakeChangesetService();
const localSideEffects = createTestSideEffects(disposables, stateManager, {
getAgent: () => agent,
agents: agentList,
sessionDataService: createNullSessionDataService(),
onTurnComplete: () => { },
}, undefined, NullTelemetryService, changesets);
disposables.add(localSideEffects.registerProgressListener(agent));
// tool_start + tool_ready + tool_complete with a recorded file edit.
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
toolCallId: 'tc-edit-1', toolName: 'write', displayName: 'Write', contributor: undefined,
_meta: { toolKind: undefined, language: undefined },
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
toolCallId: 'tc-edit-1', invocationMessage: 'Write file', toolInput: undefined,
confirmed: ToolCallConfirmationReason.NotNeeded,
},
});
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: {
type: ActionType.ChatToolCallComplete, turnId: 'turn-1',
toolCallId: 'tc-edit-1',
result: {
success: true,
pastTenseMessage: 'wrote',
content: [{
type: ToolResultContentType.FileEdit,
after: { uri: 'file:///wd/a.ts', content: { uri: 'file:///wd/a.ts' } },
diff: { added: 1, removed: 0 }
}]
},
},
});
assert.deepStrictEqual(changesets.toolCallEdits, [{ session: sessionUri.toString(), turnId: 'turn-1' }]);
});
test('turn complete fires onTurnComplete once with the right turn id', async () => {
setupSession();
startTurn('turn-1');
const changesets = new FakeChangesetService();
const localSideEffects = createTestSideEffects(disposables, stateManager, {
getAgent: () => agent,
agents: agentList,
sessionDataService: createNullSessionDataService(),
onTurnComplete: () => { },
}, undefined, NullTelemetryService, changesets);
disposables.add(localSideEffects.registerProgressListener(agent));
agent.fireProgress({
kind: 'action', resource: URI.parse(defaultChatUri),
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 },
});
// `_runTurnCompleteSideEffects` now defers the
// `changesets.onTurnComplete` call behind the checkpoint capture
// promise (`captureTurnCheckpoint(...).then(...)`). Yield a
// microtask so the resolved promise's `.then` continuation
// runs before we assert.
await Promise.resolve();
assert.deepStrictEqual(changesets.turnCompletes, [{ session: sessionUri.toString(), turnId: 'turn-1' }]);
});
test('ChatTruncated fires onSessionTruncated once', () => {
setupSession();
const changesets = new FakeChangesetService();
const localSideEffects = createTestSideEffects(disposables, stateManager, {
getAgent: () => agent,
agents: agentList,
sessionDataService: createNullSessionDataService(),
onTurnComplete: () => { },
}, undefined, NullTelemetryService, changesets);
localSideEffects.handleAction(defaultChatUri, {
type: ActionType.ChatTruncated,
turnId: 'turn-1',
});
assert.deepStrictEqual(changesets.truncates, [sessionUri.toString()]);
});
test('truncating a chat forwards that chat to the agent (default and peer)', () => {
setupSession();
const peerChatUri = buildChatUri(sessionUri.toString(), 'peer-1');
// Peer chat: the chat URI is forwarded so the agent targets that
// chat's own backing rather than the session's default chat.
sideEffects.handleAction(peerChatUri, { type: ActionType.ChatTruncated, turnId: 'turn-peer' });
const peerCall = agent.truncateSessionCalls.at(-1);
// Default chat: forwarded as the session's default chat URI.
sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'turn-default' });
const defaultCall = agent.truncateSessionCalls.at(-1);
assert.deepStrictEqual({
peerSession: peerCall?.session.toString(),
peerTurnId: peerCall?.turnId,
peerChat: peerCall?.chat?.toString(),
defaultTurnId: defaultCall?.turnId,
defaultChat: defaultCall?.chat?.toString(),
}, {
peerSession: sessionUri.toString(),
peerTurnId: 'turn-peer',
peerChat: peerChatUri,
defaultTurnId: 'turn-default',
defaultChat: defaultChatUri,
});
});
});
});