mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-07 16:14:56 +01:00
# Conflicts: # src/vs/platform/agentHost/node/copilot/copilotAgent.ts # src/vs/platform/agentHost/test/node/agentSideEffects.test.ts # src/vs/platform/agentHost/test/node/copilotAgent.test.ts # src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts
6608 lines
279 KiB
TypeScript
6608 lines
279 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 { DeferredPromise, 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 { generateUuid } from '../../../../base/common/uuid.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, AgentSignal, IAgent, resolveSubagentChatParent, SubagentChatSignal, type IAgentChatContext } from '../../common/agent.js';
|
|
import { buildDefaultChangesetCatalog } from '../../common/changesetUri.js';
|
|
import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.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, CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, SessionInputRequestKind } from '../../common/state/protocol/state.js';
|
|
import { ActionType, ActionEnvelope, AuthRequiredReason, type ChatAction, type INotification, type SessionAction } from '../../common/state/sessionActions.js';
|
|
import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInteractivity, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, customizationId, type ChatInputRequest, type ClientPluginCustomization, type Customization, type PluginCustomization, type Turn } 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 { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTelemetryLevelConfigKey, platformSessionSchema, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js';
|
|
import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js';
|
|
import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js';
|
|
import { AgentHostClientType } from '../../common/agentHostClientInfo.js';
|
|
import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.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 type { IAgentHostAskQuestionsToolInvokedEvent } from '../../node/agentHostTelemetryReporter.js';
|
|
import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js';
|
|
import { SessionDatabase } from '../../node/sessionDatabase.js';
|
|
import { AgentHostStateManager } from '../../node/agentHostStateManager.js';
|
|
import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from '../../node/agentHostCustomizationEnablementService.js';
|
|
import { AgentHostStorageService } from '../../node/agentHostStorageService.js';
|
|
import { applyMcpServerEnablement } from '../../node/shared/mcpCustomizationController.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);
|
|
}
|
|
}
|
|
|
|
function createNoopCustomizationEnablementService(): IAgentHostCustomizationEnablementService {
|
|
return {
|
|
_serviceBrand: undefined,
|
|
onDidChange: Event.None,
|
|
initializeSession: async () => { },
|
|
getWorkingDirectoryState: () => ({ kind: 'workspaceless' }),
|
|
resolve: () => ({ kind: 'resolved', enablement: [], enabled: true, workingDirectory: { kind: 'workspaceless' } }),
|
|
applyClientGlobalEnablement: () => ({ kind: 'resolved', enablement: [], enabled: true, workingDirectory: { kind: 'workspaceless' } }),
|
|
replaceEnablement: () => ({ kind: 'resolved', enablement: [], enabled: true, workingDirectory: { kind: 'workspaceless' } }),
|
|
setEnablement: () => ({ kind: 'resolved', enablement: [], enabled: true, workingDirectory: { kind: 'workspaceless' } }),
|
|
whenIdle: async () => { },
|
|
};
|
|
}
|
|
|
|
let customizationEnablementService = createNoopCustomizationEnablementService();
|
|
|
|
/**
|
|
* 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()),
|
|
checkpointService: IAgentHostCheckpointService = NULL_CHECKPOINT_SERVICE,
|
|
): 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, checkpointService],
|
|
[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, customizationEnablementService, resolvedOptions));
|
|
}
|
|
|
|
/**
|
|
* Provision a session directly on an agent through the exact-chat seam
|
|
* an initializing {@link IAgentChats.createChat} call, mirroring what
|
|
* `AgentService.createSession` does. Used by tests that need a session to
|
|
* exist on the agent backend without going through the orchestrator.
|
|
*/
|
|
async function createAgentSession(agent: IAgent): Promise<URI> {
|
|
const session = AgentSession.uri(agent.id, generateUuid());
|
|
const defaultChat = URI.parse(buildDefaultChatUri(session));
|
|
await agent.chats.createChat(defaultChat, session);
|
|
return session;
|
|
}
|
|
|
|
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' },
|
|
workingDirectories: workingDirectory ? [workingDirectory] : undefined,
|
|
});
|
|
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();
|
|
customizationEnablementService = createNoopCustomizationEnablementService();
|
|
sideEffects = createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService: createNullSessionDataService(),
|
|
hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess,
|
|
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.onDidChatProgress(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 ------------------------------
|
|
|
|
test('records customization toggles in the enablement service', () => {
|
|
const calls: { session: string; target: string; enablement: unknown }[] = [];
|
|
customizationEnablementService.replaceEnablement = (session, target, enablement) => {
|
|
calls.push({ session, target: target.owningPluginSource ? `${target.owningPluginSource}#mcp=${target.name}` : target.source.toString(), enablement });
|
|
return { kind: 'resolved', enablement: [], enabled: true, workingDirectory: { kind: 'workspaceless' } };
|
|
};
|
|
setupSession();
|
|
const plugin: PluginCustomization = {
|
|
type: CustomizationType.Plugin,
|
|
id: 'plugin',
|
|
uri: 'file:///plugin',
|
|
name: 'Plugin',
|
|
children: [{ type: CustomizationType.McpServer, id: 'server', uri: 'file:///plugin/.mcp.json', name: 'server', state: { kind: McpServerStatus.Starting } }],
|
|
};
|
|
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionCustomizationsChanged, customizations: [plugin] });
|
|
stateManager.dispatchServerAction(sessionUri.toString(), {
|
|
type: ActionType.SessionCustomizationToggled,
|
|
id: 'server',
|
|
enablement: [{ kind: CustomizationEnablementKind.Session, enabled: false }],
|
|
});
|
|
|
|
assert.deepStrictEqual(calls, [{
|
|
session: sessionUri.toString(),
|
|
target: 'file:///plugin#mcp=server',
|
|
enablement: [{ kind: CustomizationEnablementKind.Session, enabled: false }],
|
|
}]);
|
|
});
|
|
|
|
suite('customization enablement refresh', () => {
|
|
const plugin: PluginCustomization = {
|
|
type: CustomizationType.Plugin,
|
|
id: 'plugin',
|
|
uri: 'file:///plugin',
|
|
name: 'Plugin',
|
|
};
|
|
const target = {
|
|
id: plugin.id,
|
|
type: plugin.type,
|
|
name: plugin.name,
|
|
source: URI.parse(plugin.uri),
|
|
};
|
|
|
|
function setupAdditionalSession(session: URI, workingDirectory: string): void {
|
|
stateManager.createSession({
|
|
resource: session.toString(),
|
|
provider: 'mock',
|
|
title: 'Test',
|
|
status: SessionStatus.Idle,
|
|
createdAt: new Date().toISOString(),
|
|
modifiedAt: new Date().toISOString(),
|
|
project: { uri: workingDirectory, displayName: 'Test Project' },
|
|
workingDirectories: [workingDirectory],
|
|
});
|
|
stateManager.setSessionChangesets(session.toString(), buildDefaultChangesetCatalog(session.toString()));
|
|
stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionReady });
|
|
}
|
|
|
|
async function createRefreshHarness(sessions: readonly URI[], sessionDataService = createSessionDataService(), initialize = true): Promise<AgentHostCustomizationEnablementService> {
|
|
const enablementService = disposables.add(new AgentHostCustomizationEnablementService(
|
|
disposables.add(new AgentHostStorageService(undefined, new NullLogService())),
|
|
sessionDataService,
|
|
stateManager,
|
|
new NullLogService(),
|
|
));
|
|
customizationEnablementService = enablementService;
|
|
createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService: createNullSessionDataService(),
|
|
onTurnComplete: () => { },
|
|
});
|
|
if (initialize) {
|
|
await Promise.all(sessions.map(session => enablementService.initializeSession(session.toString())));
|
|
}
|
|
agent.getSessionCustomizations = async session => {
|
|
const resolution = enablementService.resolve(session.toString(), target);
|
|
return [{
|
|
...plugin,
|
|
...(resolution.kind === 'resolved' && resolution.enablement.length > 0 ? { enablement: [...resolution.enablement] } : {}),
|
|
}];
|
|
};
|
|
return enablementService;
|
|
}
|
|
|
|
async function publishInitialCustomizations(sessions: readonly URI[]): Promise<void> {
|
|
disposables.add(sideEffects.registerProgressListener(agent));
|
|
agent.fireCustomizationsChange();
|
|
await waitForState(stateManager, () => sessions.every(session => stateManager.getSessionState(session.toString())?.customizations !== undefined) ? true : undefined);
|
|
}
|
|
|
|
function customizationEnvelopes(envelopes: readonly ActionEnvelope[]): ActionEnvelope[] {
|
|
return envelopes.filter(envelope => envelope.action.type === ActionType.SessionCustomizationsChanged);
|
|
}
|
|
|
|
test('republishes customizations after a decision write and dedupes a redundant write', async () => {
|
|
const otherSession = AgentSession.uri('mock', 'session-2');
|
|
setupSession('file:///workspace');
|
|
setupAdditionalSession(otherSession, 'file:///workspace');
|
|
const enablementService = await createRefreshHarness([sessionUri, otherSession]);
|
|
await publishInitialCustomizations([sessionUri, otherSession]);
|
|
const envelopes: ActionEnvelope[] = [];
|
|
disposables.add(stateManager.onDidEmitEnvelope(envelope => envelopes.push(envelope)));
|
|
|
|
enablementService.setEnablement(sessionUri.toString(), target, CustomizationEnablementKind.Session, false);
|
|
await waitForState(stateManager, () => customizationEnvelopes(envelopes).length === 1 ? true : undefined);
|
|
enablementService.setEnablement(sessionUri.toString(), target, CustomizationEnablementKind.Session, false);
|
|
await timeout(10);
|
|
|
|
assert.deepStrictEqual(customizationEnvelopes(envelopes).map(envelope => ({
|
|
session: envelope.channel,
|
|
customizations: envelope.action.type === ActionType.SessionCustomizationsChanged ? envelope.action.customizations : undefined,
|
|
})), [{
|
|
session: sessionUri.toString(),
|
|
customizations: [{ ...plugin, enablement: [{ kind: CustomizationEnablementKind.Session, enabled: false }] }],
|
|
}]);
|
|
});
|
|
|
|
test('republishes every open session for a global decision once', async () => {
|
|
const otherSession = AgentSession.uri('mock', 'session-2');
|
|
setupSession('file:///workspace-a');
|
|
setupAdditionalSession(otherSession, 'file:///workspace-b');
|
|
const enablementService = await createRefreshHarness([sessionUri, otherSession]);
|
|
await publishInitialCustomizations([sessionUri, otherSession]);
|
|
const envelopes: ActionEnvelope[] = [];
|
|
disposables.add(stateManager.onDidEmitEnvelope(envelope => envelopes.push(envelope)));
|
|
|
|
enablementService.setEnablement(sessionUri.toString(), target, CustomizationEnablementKind.Global, false);
|
|
await waitForState(stateManager, () => customizationEnvelopes(envelopes).length === 2 ? true : undefined);
|
|
|
|
assert.deepStrictEqual(customizationEnvelopes(envelopes).map(envelope => envelope.channel).sort(), [sessionUri.toString(), otherSession.toString()].sort());
|
|
});
|
|
|
|
test('republishes only sessions sharing a workspace decision working directory', async () => {
|
|
const sameWorkspaceSession = AgentSession.uri('mock', 'session-2');
|
|
const otherWorkspaceSession = AgentSession.uri('mock', 'session-3');
|
|
setupSession('file:///workspace');
|
|
setupAdditionalSession(sameWorkspaceSession, 'file:///workspace');
|
|
setupAdditionalSession(otherWorkspaceSession, 'file:///other-workspace');
|
|
const enablementService = await createRefreshHarness([sessionUri, sameWorkspaceSession, otherWorkspaceSession]);
|
|
await publishInitialCustomizations([sessionUri, sameWorkspaceSession, otherWorkspaceSession]);
|
|
const envelopes: ActionEnvelope[] = [];
|
|
disposables.add(stateManager.onDidEmitEnvelope(envelope => envelopes.push(envelope)));
|
|
|
|
enablementService.setEnablement(sessionUri.toString(), target, CustomizationEnablementKind.Workspace, false);
|
|
await waitForState(stateManager, () => customizationEnvelopes(envelopes).length === 2 ? true : undefined);
|
|
|
|
assert.deepStrictEqual(customizationEnvelopes(envelopes).map(envelope => envelope.channel).sort(), [sessionUri.toString(), sameWorkspaceSession.toString()].sort());
|
|
});
|
|
|
|
test('republishes when session enablement finishes loading', async () => {
|
|
setupSession('file:///workspace');
|
|
const database = new TestSessionDatabase();
|
|
let resolveMetadata!: (value: string | undefined) => void;
|
|
database.getMetadata = async () => new Promise(resolve => { resolveMetadata = resolve; });
|
|
const enablementService = await createRefreshHarness([sessionUri], createSessionDataService(database), false);
|
|
await publishInitialCustomizations([sessionUri]);
|
|
const envelopes: ActionEnvelope[] = [];
|
|
disposables.add(stateManager.onDidEmitEnvelope(envelope => envelopes.push(envelope)));
|
|
|
|
const load = enablementService.initializeSession(sessionUri.toString());
|
|
resolveMetadata('{"plugin":false}');
|
|
await load;
|
|
await waitForState(stateManager, () => customizationEnvelopes(envelopes).length === 1 ? true : undefined);
|
|
|
|
assert.deepStrictEqual(customizationEnvelopes(envelopes).map(envelope => ({
|
|
session: envelope.channel,
|
|
customizations: envelope.action.type === ActionType.SessionCustomizationsChanged ? envelope.action.customizations : undefined,
|
|
})), [{
|
|
session: sessionUri.toString(),
|
|
customizations: [{ ...plugin, enablement: [{ kind: CustomizationEnablementKind.Session, enabled: false }] }],
|
|
}]);
|
|
});
|
|
|
|
test('republishes a settled MCP decision that an earlier pending publication omitted', async () => {
|
|
setupSession('file:///workspace');
|
|
const database = new TestSessionDatabase();
|
|
let resolveMetadata!: (value: string | undefined) => void;
|
|
database.getMetadata = async () => new Promise(resolve => { resolveMetadata = resolve; });
|
|
const enablementService = await createRefreshHarness([sessionUri], createSessionDataService(database), false);
|
|
const server = {
|
|
type: CustomizationType.McpServer,
|
|
id: 'azure',
|
|
uri: 'file:///plugin/mcp.json',
|
|
name: 'azure',
|
|
state: { kind: McpServerStatus.Stopped },
|
|
} as const;
|
|
const pluginWithServer: PluginCustomization = { ...plugin, children: [server] };
|
|
const serverTarget = {
|
|
id: server.id,
|
|
type: server.type,
|
|
name: server.name,
|
|
source: URI.parse(server.uri),
|
|
owningPluginSource: URI.parse(plugin.uri),
|
|
};
|
|
enablementService.setEnablement(sessionUri.toString(), serverTarget, CustomizationEnablementKind.Global, false);
|
|
agent.getSessionCustomizations = async session => {
|
|
const resolution = enablementService.resolve(session.toString(), serverTarget);
|
|
const customizations = [{
|
|
...pluginWithServer,
|
|
children: [{
|
|
...server,
|
|
...(resolution.kind === 'resolved' && resolution.enablement.length > 0 ? { enablement: [...resolution.enablement] } : {}),
|
|
}],
|
|
}];
|
|
return applyMcpServerEnablement(customizations, stateManager.getSessionState(session.toString())?.customizations ?? []);
|
|
};
|
|
await publishInitialCustomizations([sessionUri]);
|
|
const envelopes: ActionEnvelope[] = [];
|
|
disposables.add(stateManager.onDidEmitEnvelope(envelope => envelopes.push(envelope)));
|
|
|
|
const load = enablementService.initializeSession(sessionUri.toString());
|
|
resolveMetadata(undefined);
|
|
await load;
|
|
await waitForState(stateManager, () => customizationEnvelopes(envelopes).length === 1 ? true : undefined);
|
|
|
|
assert.deepStrictEqual(customizationEnvelopes(envelopes).map(envelope => ({
|
|
session: envelope.channel,
|
|
customizations: envelope.action.type === ActionType.SessionCustomizationsChanged ? envelope.action.customizations : undefined,
|
|
})), [{
|
|
session: sessionUri.toString(),
|
|
customizations: [{
|
|
...plugin,
|
|
children: [{
|
|
...server,
|
|
enablement: [{ kind: CustomizationEnablementKind.Global, enabled: false }],
|
|
}],
|
|
}],
|
|
}]);
|
|
});
|
|
|
|
test('republishes when a pending working directory becomes known', async () => {
|
|
setupSession();
|
|
const enablementService = await createRefreshHarness([sessionUri]);
|
|
await publishInitialCustomizations([sessionUri]);
|
|
enablementService.setEnablement(sessionUri.toString(), target, CustomizationEnablementKind.Global, false);
|
|
await timeout(10);
|
|
const envelopes: ActionEnvelope[] = [];
|
|
disposables.add(stateManager.onDidEmitEnvelope(envelope => envelopes.push(envelope)));
|
|
|
|
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionWorkingDirectorySet, directory: 'file:///workspace' });
|
|
await waitForState(stateManager, () => customizationEnvelopes(envelopes).length === 1 ? true : undefined);
|
|
|
|
assert.deepStrictEqual(customizationEnvelopes(envelopes).map(envelope => ({
|
|
session: envelope.channel,
|
|
customizations: envelope.action.type === ActionType.SessionCustomizationsChanged ? envelope.action.customizations : undefined,
|
|
})), [{
|
|
session: sessionUri.toString(),
|
|
customizations: [{ ...plugin, enablement: [{ kind: CustomizationEnablementKind.Global, enabled: false }] }],
|
|
}]);
|
|
});
|
|
|
|
test('retries an enablement refresh superseded by a direct customization update', async () => {
|
|
setupSession('file:///workspace');
|
|
const enablementService = await createRefreshHarness([sessionUri]);
|
|
await publishInitialCustomizations([sessionUri]);
|
|
let signalFetchStarted!: () => void;
|
|
const fetchStarted = new Promise<void>(resolve => { signalFetchStarted = resolve; });
|
|
let releaseFetch!: () => void;
|
|
let blockFirstFetch = true;
|
|
agent.getSessionCustomizations = async session => {
|
|
const resolution = enablementService.resolve(session.toString(), target);
|
|
if (blockFirstFetch) {
|
|
blockFirstFetch = false;
|
|
signalFetchStarted();
|
|
await new Promise<void>(resolve => { releaseFetch = resolve; });
|
|
}
|
|
return [{
|
|
...plugin,
|
|
...(resolution.kind === 'resolved' && resolution.enablement.length > 0 ? { enablement: [...resolution.enablement] } : {}),
|
|
}];
|
|
};
|
|
const envelopes: ActionEnvelope[] = [];
|
|
disposables.add(stateManager.onDidEmitEnvelope(envelope => envelopes.push(envelope)));
|
|
|
|
enablementService.setEnablement(sessionUri.toString(), target, CustomizationEnablementKind.Global, false);
|
|
await fetchStarted;
|
|
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionCustomizationsChanged, customizations: [plugin] });
|
|
releaseFetch();
|
|
await waitForState(stateManager, () => customizationEnvelopes(envelopes).some(envelope => {
|
|
const customization = envelope.action.type === ActionType.SessionCustomizationsChanged ? envelope.action.customizations[0] : undefined;
|
|
return customization?.type === CustomizationType.Plugin
|
|
&& customization.enablement?.some(entry => entry.kind === CustomizationEnablementKind.Global && entry.enabled === false);
|
|
}) ? true : undefined);
|
|
|
|
assert.deepStrictEqual(customizationEnvelopes(envelopes).map(envelope => ({
|
|
session: envelope.channel,
|
|
customizations: envelope.action.type === ActionType.SessionCustomizationsChanged ? envelope.action.customizations : undefined,
|
|
})), [
|
|
{ session: sessionUri.toString(), customizations: [plugin] },
|
|
{ session: sessionUri.toString(), customizations: [{ ...plugin, enablement: [{ kind: CustomizationEnablementKind.Global, enabled: false }] }] },
|
|
]);
|
|
});
|
|
});
|
|
|
|
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('stamps the exhaustive host chat context on the send boundary', async () => {
|
|
setupSession();
|
|
const hostCustomization: Customization = {
|
|
type: CustomizationType.Plugin,
|
|
id: customizationId('file:///send-plugin'),
|
|
uri: 'file:///send-plugin',
|
|
name: 'Send Plugin',
|
|
enablement: [{ kind: CustomizationEnablementKind.Global, enabled: true }],
|
|
load: { kind: CustomizationLoadStatus.Loaded },
|
|
};
|
|
stateManager.setSessionCustomizations(sessionUri.toString(), [hostCustomization]);
|
|
const peerChatUri = buildChatUri(sessionUri, 'peer-send');
|
|
stateManager.addChat(sessionUri.toString(), peerChatUri, {
|
|
origin: { kind: ChatOriginKind.Fork, chat: defaultChatUri, turnId: 'turn-0' },
|
|
});
|
|
|
|
sideEffects.handleAction(peerChatUri, {
|
|
type: ActionType.ChatTurnStarted,
|
|
turnId: 'turn-1',
|
|
startedAt: '2025-01-01T00:00:00.000Z',
|
|
message: { text: 'hello world', origin: { kind: MessageKind.User } },
|
|
});
|
|
await waitForSendMessageCalls(1);
|
|
|
|
const recorded = agent.chatContexts.filter(entry => entry.boundary === 'sendMessage');
|
|
assert.deepStrictEqual(recorded.map(entry => {
|
|
const context = entry.context as IAgentChatContext;
|
|
return {
|
|
chat: entry.chat.toString(),
|
|
configurationResource: context.configurationResource.toString(),
|
|
resource: context.resource.toString(),
|
|
origin: context.origin,
|
|
customizations: context.customizations?.map(c => c.id),
|
|
};
|
|
}), [{
|
|
chat: peerChatUri,
|
|
configurationResource: sessionUri.toString(),
|
|
resource: peerChatUri,
|
|
origin: { kind: ChatOriginKind.Fork, chat: defaultChatUri, turnId: 'turn-0' },
|
|
customizations: [hostCustomization.id],
|
|
}]);
|
|
});
|
|
|
|
test('passes the dispatching client id and type 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', AgentHostClientType.EditorWindow);
|
|
|
|
await waitForSendMessageCalls(1);
|
|
|
|
assert.deepStrictEqual(agent.sendMessageCalls, [{
|
|
session: URI.parse(sessionUri.toString()),
|
|
prompt: 'hello world',
|
|
attachments: undefined,
|
|
chat: URI.parse(defaultChatUri),
|
|
senderClientId: 'client-B',
|
|
clientType: 'editor_window',
|
|
}]);
|
|
});
|
|
|
|
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', }]
|
|
},
|
|
};
|
|
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' }] },
|
|
}, 'client-agents', {
|
|
clientType: AgentHostClientType.AgentsWindow,
|
|
connectionKind: AgentHostClientConnectionKind.DevTunnel,
|
|
transportKind: AgentHostTransportKind.WebSocket,
|
|
hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess,
|
|
});
|
|
|
|
assert.deepStrictEqual(telemetryService.events, [{
|
|
eventName: 'agentHost.userMessageSent',
|
|
data: {
|
|
provider: 'mock',
|
|
hostLaunchKind: 'vscode_main_process',
|
|
initiatorClientId: 'client-agents',
|
|
initiatorClientType: 'agents_window',
|
|
initiatorConnectionKind: 'dev_tunnel',
|
|
initiatorTransportKind: 'websocket',
|
|
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('resolves chat attachments that reference another session', async () => {
|
|
setupSession();
|
|
const otherSessionUri = AgentSession.uri('mock', 'session-2');
|
|
stateManager.createSession({
|
|
resource: otherSessionUri.toString(),
|
|
provider: 'mock',
|
|
title: 'Other',
|
|
status: SessionStatus.Idle,
|
|
createdAt: new Date().toISOString(),
|
|
modifiedAt: new Date().toISOString(),
|
|
});
|
|
stateManager.dispatchServerAction(otherSessionUri.toString(), { type: ActionType.SessionReady });
|
|
stateManager.seedDefaultChatTurns(otherSessionUri.toString(), [{
|
|
id: 'other-turn',
|
|
state: TurnState.Complete,
|
|
message: { text: 'Cross session memory', origin: { kind: MessageKind.User } },
|
|
responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response', content: 'Recalled across sessions' }],
|
|
usage: undefined,
|
|
}]);
|
|
|
|
sideEffects.handleAction(defaultChatUri, {
|
|
type: ActionType.ChatTurnStarted,
|
|
turnId: 'turn-1',
|
|
startedAt: '2025-01-01T00:00:00.000Z',
|
|
message: {
|
|
text: 'read another session',
|
|
origin: { kind: MessageKind.User },
|
|
attachments: [{
|
|
type: MessageAttachmentKind.Chat,
|
|
resource: otherSessionUri.toString(),
|
|
endTurn: 'other-turn',
|
|
label: 'Other session',
|
|
}],
|
|
},
|
|
});
|
|
|
|
await waitForSendMessageCalls(1);
|
|
const attachment = agent.sendMessageCalls[0].attachments?.[0];
|
|
assert.deepStrictEqual({
|
|
type: attachment?.type,
|
|
hasUser: attachment?.type === MessageAttachmentKind.Simple && attachment.modelRepresentation?.includes('User: Cross session memory'),
|
|
hasAssistant: attachment?.type === MessageAttachmentKind.Simple && attachment.modelRepresentation?.includes('Assistant: Recalled across sessions'),
|
|
}, {
|
|
type: MessageAttachmentKind.Simple,
|
|
hasUser: true,
|
|
hasAssistant: true,
|
|
});
|
|
});
|
|
|
|
test('degrades to a no-excerpt pointer when the referenced chat is unresolvable', async () => {
|
|
setupSession();
|
|
const missingSessionUri = AgentSession.uri('mock', 'missing');
|
|
const resolvingSideEffects = createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService: createNullSessionDataService(),
|
|
// Mirrors agentService._resolveChatAttachmentTurns throwing
|
|
// ProtocolError(AHP_SESSION_NOT_FOUND) for a cross-session
|
|
// reference this host cannot restore.
|
|
resolveChatAttachmentTurns: async () => { throw new Error('AHP_SESSION_NOT_FOUND'); },
|
|
onTurnComplete: () => { },
|
|
});
|
|
resolvingSideEffects.handleAction(defaultChatUri, {
|
|
type: ActionType.ChatTurnStarted,
|
|
turnId: 'turn-1',
|
|
startedAt: '2025-01-01T00:00:00.000Z',
|
|
message: {
|
|
text: 'read a stale reference',
|
|
origin: { kind: MessageKind.User },
|
|
attachments: [{
|
|
type: MessageAttachmentKind.Chat,
|
|
resource: missingSessionUri.toString(),
|
|
endTurn: 'gone-turn',
|
|
label: 'Stale chat',
|
|
}],
|
|
},
|
|
});
|
|
|
|
await waitForSendMessageCalls(1);
|
|
const attachment = agent.sendMessageCalls[0].attachments?.[0];
|
|
// A stale/unreachable reference must not fail the turn: it resolves to
|
|
// a pointer with no excerpt and the endTurn pin is dropped.
|
|
assert.deepStrictEqual({
|
|
type: attachment?.type,
|
|
label: attachment?.label,
|
|
noExcerpt: attachment?.type === MessageAttachmentKind.Simple && attachment.modelRepresentation?.includes('has no transcript content up to the selected turn'),
|
|
}, {
|
|
type: MessageAttachmentKind.Simple,
|
|
label: 'Stale chat',
|
|
noExcerpt: true,
|
|
});
|
|
});
|
|
|
|
test('awaits hydrated turns when resolving a chat attachment', async () => {
|
|
setupSession();
|
|
const sourceTurn: Turn = {
|
|
id: 'source-turn',
|
|
state: TurnState.Complete,
|
|
message: { text: 'Remember X', origin: { kind: MessageKind.User } },
|
|
responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response', content: 'Remembered' }],
|
|
usage: undefined,
|
|
};
|
|
const resolvingSideEffects = createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService: createNullSessionDataService(),
|
|
resolveChatAttachmentTurns: async () => [sourceTurn],
|
|
onTurnComplete: () => { },
|
|
});
|
|
resolvingSideEffects.handleAction(defaultChatUri, {
|
|
type: ActionType.ChatTurnStarted,
|
|
turnId: 'turn-1',
|
|
startedAt: '2025-01-01T00:00:00.000Z',
|
|
message: {
|
|
text: 'What was remembered?',
|
|
origin: { kind: MessageKind.User },
|
|
attachments: [{
|
|
type: MessageAttachmentKind.Chat,
|
|
resource: sessionUri.toString(),
|
|
endTurn: sourceTurn.id,
|
|
label: 'Earlier chat',
|
|
}],
|
|
},
|
|
});
|
|
|
|
await waitForSendMessageCalls(1);
|
|
const attachment = agent.sendMessageCalls[0].attachments?.[0];
|
|
assert.deepStrictEqual({
|
|
type: attachment?.type,
|
|
hasUser: attachment?.type === MessageAttachmentKind.Simple && attachment.modelRepresentation?.includes('User: Remember X'),
|
|
hasAssistant: attachment?.type === MessageAttachmentKind.Simple && attachment.modelRepresentation?.includes('Assistant: Remembered'),
|
|
}, {
|
|
type: MessageAttachmentKind.Simple,
|
|
hasUser: true,
|
|
hasAssistant: true,
|
|
});
|
|
});
|
|
|
|
test('pins the latest completed turn when a chat attachment omits endTurn', async () => {
|
|
setupSession();
|
|
const olderTurn: Turn = {
|
|
id: 'older-turn',
|
|
state: TurnState.Complete,
|
|
message: { text: 'Remember X', origin: { kind: MessageKind.User } },
|
|
responseParts: [{ kind: ResponsePartKind.Markdown, id: 'r1', content: 'Remembered X' }],
|
|
usage: undefined,
|
|
};
|
|
const latestTurn: Turn = {
|
|
id: 'latest-turn',
|
|
state: TurnState.Complete,
|
|
message: { text: 'Remember Z', origin: { kind: MessageKind.User } },
|
|
responseParts: [{ kind: ResponsePartKind.Markdown, id: 'r2', content: 'Remembered Z' }],
|
|
usage: undefined,
|
|
};
|
|
const resolvingSideEffects = createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService: createNullSessionDataService(),
|
|
resolveChatAttachmentTurns: async () => [olderTurn, latestTurn],
|
|
onTurnComplete: () => { },
|
|
});
|
|
resolvingSideEffects.handleAction(defaultChatUri, {
|
|
type: ActionType.ChatTurnStarted,
|
|
turnId: 'turn-1',
|
|
startedAt: '2025-01-01T00:00:00.000Z',
|
|
message: {
|
|
text: 'What was remembered?',
|
|
origin: { kind: MessageKind.User },
|
|
attachments: [{
|
|
type: MessageAttachmentKind.Chat,
|
|
resource: sessionUri.toString(),
|
|
label: 'Earlier chat',
|
|
}],
|
|
},
|
|
});
|
|
|
|
await waitForSendMessageCalls(1);
|
|
const attachment = agent.sendMessageCalls[0].attachments?.[0];
|
|
// No endTurn pin, so the whole retained transcript resolves — including
|
|
// the latest completed turn.
|
|
assert.deepStrictEqual({
|
|
type: attachment?.type,
|
|
hasOlder: attachment?.type === MessageAttachmentKind.Simple && attachment.modelRepresentation?.includes('Assistant: Remembered X'),
|
|
hasLatest: attachment?.type === MessageAttachmentKind.Simple && attachment.modelRepresentation?.includes('Assistant: Remembered Z'),
|
|
}, {
|
|
type: MessageAttachmentKind.Simple,
|
|
hasOlder: true,
|
|
hasLatest: true,
|
|
});
|
|
});
|
|
|
|
test('rejects chat attachments whose endTurn is missing from the retained transcript', async () => {
|
|
setupSession();
|
|
stateManager.seedDefaultChatTurns(sessionUri.toString(), [{
|
|
id: 'source-turn',
|
|
state: TurnState.Complete,
|
|
message: { text: 'Remember X', origin: { kind: MessageKind.User } },
|
|
responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response', content: 'Remembered' }],
|
|
usage: undefined,
|
|
}]);
|
|
|
|
const error = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, (envelope): envelope is ActionEnvelope =>
|
|
envelope.action.type === ActionType.ChatError && envelope.channel === defaultChatUri));
|
|
sideEffects.handleAction(defaultChatUri, {
|
|
type: ActionType.ChatTurnStarted,
|
|
turnId: 'turn-1',
|
|
startedAt: '2025-01-01T00:00:00.000Z',
|
|
message: {
|
|
text: 'What was remembered?',
|
|
origin: { kind: MessageKind.User },
|
|
attachments: [{
|
|
type: MessageAttachmentKind.Chat,
|
|
resource: sessionUri.toString(),
|
|
endTurn: 'missing-turn',
|
|
label: 'Earlier chat',
|
|
}],
|
|
},
|
|
});
|
|
|
|
const envelope = await error;
|
|
assert.deepStrictEqual({
|
|
sendMessageCalls: agent.sendMessageCalls.length,
|
|
errorType: envelope.action.type === ActionType.ChatError ? envelope.action.error.errorType : undefined,
|
|
}, {
|
|
sendMessageCalls: 0,
|
|
errorType: 'sendFailed',
|
|
});
|
|
});
|
|
|
|
test('rejects chat attachments whose endTurn is still active', async () => {
|
|
setupSession();
|
|
const peerChatUri = buildChatUri(sessionUri.toString(), 'peer-1');
|
|
stateManager.addChat(sessionUri.toString(), peerChatUri, { title: 'Peer' });
|
|
stateManager.dispatchClientAction(peerChatUri, {
|
|
type: ActionType.ChatTurnStarted,
|
|
turnId: 'active-turn',
|
|
startedAt: '2025-01-01T00:00:00.000Z',
|
|
message: { text: 'Remember X', origin: { kind: MessageKind.User } },
|
|
}, { clientId: 'test', clientSeq: 1 });
|
|
|
|
const error = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, (envelope): envelope is ActionEnvelope =>
|
|
envelope.action.type === ActionType.ChatError && envelope.channel === defaultChatUri));
|
|
sideEffects.handleAction(defaultChatUri, {
|
|
type: ActionType.ChatTurnStarted,
|
|
turnId: 'turn-1',
|
|
startedAt: '2025-01-01T00:00:00.000Z',
|
|
message: {
|
|
text: 'What was remembered?',
|
|
origin: { kind: MessageKind.User },
|
|
attachments: [{
|
|
type: MessageAttachmentKind.Chat,
|
|
resource: peerChatUri,
|
|
endTurn: 'active-turn',
|
|
label: 'Earlier chat',
|
|
}],
|
|
},
|
|
});
|
|
|
|
const envelope = await error;
|
|
assert.deepStrictEqual({
|
|
sendMessageCalls: agent.sendMessageCalls.length,
|
|
errorType: envelope.action.type === ActionType.ChatError ? envelope.action.error.errorType : undefined,
|
|
}, {
|
|
sendMessageCalls: 0,
|
|
errorType: 'sendFailed',
|
|
});
|
|
});
|
|
|
|
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('captures the turn start before sending the message', async () => {
|
|
setupProvisionalSession();
|
|
const workingDirectory = URI.file('/wd');
|
|
const captureStarted = new DeferredPromise<void>();
|
|
const releaseCapture = new DeferredPromise<void>();
|
|
const captures: Array<{ session: string; turnId: string; workingDirectories: readonly string[] | undefined }> = [];
|
|
const checkpoints: IAgentHostCheckpointService = {
|
|
...NULL_CHECKPOINT_SERVICE,
|
|
captureTurnStartCheckpoint: async (session, _chat, turnId, workingDirectories) => {
|
|
captures.push({ session: session.toString(), turnId, workingDirectories: workingDirectories?.map(uri => uri.toString()) });
|
|
captureStarted.complete();
|
|
await releaseCapture.p;
|
|
},
|
|
};
|
|
const localSideEffects = createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService: createNullSessionDataService(),
|
|
resolveWorkingDirectoryBeforeSend: async () => [workingDirectory],
|
|
onTurnComplete: () => { },
|
|
}, undefined, NullTelemetryService, undefined, undefined, checkpoints);
|
|
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 });
|
|
|
|
localSideEffects.handleAction(defaultChatUri, turnStarted);
|
|
await captureStarted.p;
|
|
assert.deepStrictEqual(agent.sendMessageCalls, []);
|
|
|
|
const didSendMessage = Event.toPromise(agent.onDidSendMessage);
|
|
releaseCapture.complete();
|
|
await didSendMessage;
|
|
|
|
assert.deepStrictEqual(captures, [{
|
|
session: sessionUri.toString(),
|
|
turnId: 'turn-1',
|
|
workingDirectories: [workingDirectory.toString()],
|
|
}]);
|
|
});
|
|
|
|
test('client cancellation discards the pending turn start', async () => {
|
|
setupProvisionalSession();
|
|
const discarded = new DeferredPromise<void>();
|
|
const checkpoints: IAgentHostCheckpointService = {
|
|
...NULL_CHECKPOINT_SERVICE,
|
|
discardTurnStartCheckpoint: async (session, chat, turnId) => {
|
|
assert.deepStrictEqual({ session: session.toString(), chat: chat.toString(), turnId }, {
|
|
session: sessionUri.toString(),
|
|
chat: defaultChatUri,
|
|
turnId: 'turn-1',
|
|
});
|
|
discarded.complete();
|
|
},
|
|
};
|
|
createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService: createNullSessionDataService(),
|
|
onTurnComplete: () => { },
|
|
}, undefined, NullTelemetryService, undefined, undefined, checkpoints);
|
|
|
|
stateManager.dispatchClientAction(defaultChatUri, {
|
|
type: ActionType.ChatTurnCancelled,
|
|
turnId: 'turn-1',
|
|
duration: 0,
|
|
}, { clientId: 'test', clientSeq: 1 });
|
|
await discarded.p;
|
|
});
|
|
|
|
test('cancellation before send skips turn-start capture', async () => {
|
|
setupProvisionalSession();
|
|
let captureCount = 0;
|
|
const checkpoints: IAgentHostCheckpointService = {
|
|
...NULL_CHECKPOINT_SERVICE,
|
|
captureTurnStartCheckpoint: async () => { captureCount++; },
|
|
};
|
|
const localSideEffects = createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService: createNullSessionDataService(),
|
|
resolveWorkingDirectoryBeforeSend: async () => [URI.file('/wd')],
|
|
onTurnComplete: () => { },
|
|
}, undefined, NullTelemetryService, undefined, undefined, checkpoints);
|
|
const cancelled = {
|
|
type: ActionType.ChatTurnCancelled,
|
|
turnId: 'turn-1',
|
|
duration: 0,
|
|
} as const;
|
|
stateManager.dispatchClientAction(defaultChatUri, cancelled, { clientId: 'test', clientSeq: 1 });
|
|
const started = {
|
|
type: ActionType.ChatTurnStarted,
|
|
startedAt: '2025-01-01T00:00:00.000Z',
|
|
turnId: 'turn-1',
|
|
message: { text: 'hello', origin: { kind: MessageKind.User } },
|
|
} as const;
|
|
|
|
localSideEffects.handleAction(defaultChatUri, started);
|
|
await timeout(0);
|
|
|
|
assert.deepStrictEqual({ captureCount, sendMessageCalls: agent.sendMessageCalls }, {
|
|
captureCount: 0,
|
|
sendMessageCalls: [],
|
|
});
|
|
});
|
|
|
|
test('AgentSideEffects owns exactly one ChatError 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({
|
|
chatErrors: envelopes.filter(e => e.action.type === ActionType.ChatError).length,
|
|
creationFailed: envelopes.some(e => e.action.type === ActionType.SessionCreationFailed),
|
|
lifecycle: stateManager.getSessionState(sessionUri.toString())?.lifecycle,
|
|
}, {
|
|
chatErrors: 1,
|
|
creationFailed: false,
|
|
lifecycle: SessionLifecycle.Ready,
|
|
});
|
|
});
|
|
|
|
test('does not duplicate a Codex provider-owned failure when sendMessage resolves', async () => {
|
|
setupSession();
|
|
disposables.add(sideEffects.registerProgressListener(agent));
|
|
const originalSendMessage = agent.sendMessage.bind(agent);
|
|
agent.sendMessage = async (...args) => {
|
|
await originalSendMessage(...args);
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 1, error: { errorType: 'CodexMaterializeFailed', message: 'workspace root rejected' } },
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1 },
|
|
});
|
|
};
|
|
|
|
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)));
|
|
|
|
sideEffects.handleAction(defaultChatUri, turnStarted);
|
|
await waitForState(stateManager, () => envelopes.some(e => e.action.type === ActionType.ChatTurnComplete) || undefined);
|
|
|
|
assert.deepStrictEqual(
|
|
envelopes
|
|
.filter(e => e.action.type === ActionType.ChatError || e.action.type === ActionType.ChatTurnComplete)
|
|
.map(e => e.action.type),
|
|
[ActionType.ChatError, ActionType.ChatTurnComplete],
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---- 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));
|
|
});
|
|
|
|
test('seeds the session title from the ! command when the session is untitled', async () => {
|
|
// A brand-new, untitled session: the bang command is the only thing
|
|
// we can title it with until a real request arrives.
|
|
stateManager.createSession({
|
|
resource: sessionUri.toString(),
|
|
provider: 'mock',
|
|
title: '',
|
|
status: SessionStatus.Idle,
|
|
createdAt: new Date().toISOString(),
|
|
modifiedAt: new Date().toISOString(),
|
|
});
|
|
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady });
|
|
const db = new TestSessionDatabase();
|
|
const terminalManager = disposables.add(new TestAgentHostTerminalManager());
|
|
const bangSideEffects = createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService: createSessionDataService(db),
|
|
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);
|
|
|
|
// The provisional title is applied synchronously, before the command runs.
|
|
assert.strictEqual(stateManager.getSessionState(sessionUri.toString())?.title, 'echo hi');
|
|
|
|
// Let the command finish so the turn closes cleanly.
|
|
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 provisional title is persisted so it survives reload.
|
|
assert.strictEqual(await db.getMetadata('customTitle'), 'echo hi');
|
|
});
|
|
});
|
|
|
|
// ---- 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.truncateChatCalls.at(-1);
|
|
assert.strictEqual(truncateCall?.chat.toString(), defaultChatUri);
|
|
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.truncateChatCalls.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(), []);
|
|
});
|
|
});
|
|
|
|
// ---- turn usage persistence -------------------------------------------
|
|
|
|
suite('turn usage persistence', () => {
|
|
|
|
const usage = { inputTokens: 100, outputTokens: 20, model: 'gpt-5', _meta: { copilotUsage: { totalNanoAiu: 5_000_000_000 } } };
|
|
|
|
function createUsageSideEffects(db: TestSessionDatabase): void {
|
|
const sessionDataService = createSessionDataService(db);
|
|
createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService,
|
|
localTurns: new AgentHostLocalTurns(sessionDataService, new NullLogService()),
|
|
onTurnComplete: () => { },
|
|
});
|
|
}
|
|
|
|
test('persists the latest usage of a turn, without waiting for the turn to end', async () => {
|
|
// Written eagerly rather than buffered until a terminal action: a turn
|
|
// cut short by a crash or disconnect must keep the usage it accrued,
|
|
// which is the class of loss this persistence exists to prevent.
|
|
setupSession('file:///work');
|
|
const db = new TestSessionDatabase();
|
|
createUsageSideEffects(db);
|
|
|
|
stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatUsage, turnId: 'turn-1', usage: { inputTokens: 1, outputTokens: 1 } });
|
|
stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatUsage, turnId: 'turn-1', usage });
|
|
|
|
// No ChatTurnComplete/Cancelled/Error — the rows are already durable.
|
|
await new Promise(r => setTimeout(r, 10));
|
|
assert.deepStrictEqual([...(await db.getTurnUsages()).entries()], [['turn-1', JSON.stringify(usage)]]);
|
|
});
|
|
|
|
test('does not persist usage reported on a subagent chat', async () => {
|
|
setupSession('file:///work');
|
|
const db = new TestSessionDatabase();
|
|
createUsageSideEffects(db);
|
|
|
|
const subagentChatUri = buildSubagentChatUri(sessionUri.toString(), 'tool-call-1');
|
|
stateManager.dispatchServerAction(subagentChatUri, { type: ActionType.ChatUsage, turnId: 'turn-1', usage });
|
|
stateManager.dispatchServerAction(subagentChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 10 });
|
|
|
|
await new Promise(r => setTimeout(r, 10));
|
|
assert.deepStrictEqual([...(await db.getTurnUsages()).entries()], []);
|
|
});
|
|
});
|
|
|
|
// ---- 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('turn completion — read/unread', () => {
|
|
|
|
function readChangesFrom(envelopes: readonly ActionEnvelope[]): boolean[] {
|
|
return envelopes
|
|
.filter(e => e.action.type === ActionType.SessionIsReadChanged)
|
|
.map(e => (e.action as { isRead: boolean }).isRead);
|
|
}
|
|
|
|
/**
|
|
* Turn completion persists the (un)read flag, so these tests need a real
|
|
* session database rather than the suite's null data service.
|
|
*/
|
|
function setupPersisting(): { sideEffects: AgentSideEffects; db: TestSessionDatabase } {
|
|
const db = new TestSessionDatabase();
|
|
const persisting = createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService: createSessionDataService(db),
|
|
onTurnComplete: () => { },
|
|
}, undefined, disposables.add(new AgentHostTelemetryService(telemetryService)));
|
|
return { sideEffects: persisting, db };
|
|
}
|
|
|
|
test('marks a read session unread when a turn completes', () => {
|
|
const { sideEffects: persisting } = setupPersisting();
|
|
setupSession();
|
|
// The session has been read (e.g. a client viewed it).
|
|
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionIsReadChanged, isRead: true });
|
|
disposables.add(persisting.registerProgressListener(agent));
|
|
startTurn('turn-1');
|
|
|
|
const envelopes: ActionEnvelope[] = [];
|
|
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
|
|
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 },
|
|
});
|
|
|
|
assert.deepStrictEqual({
|
|
readChanges: readChangesFrom(envelopes),
|
|
isReadBitSet: (stateManager.getSessionSummary(sessionUri.toString())!.status & SessionStatus.IsRead) !== 0,
|
|
}, {
|
|
readChanges: [false],
|
|
isReadBitSet: false,
|
|
});
|
|
});
|
|
|
|
test('does not re-mark an already-unread session on turn completion', () => {
|
|
const { sideEffects: persisting } = setupPersisting();
|
|
setupSession();
|
|
// No SessionIsReadChanged dispatched: the session starts unread.
|
|
disposables.add(persisting.registerProgressListener(agent));
|
|
startTurn('turn-1');
|
|
|
|
const envelopes: ActionEnvelope[] = [];
|
|
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
|
|
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 },
|
|
});
|
|
|
|
assert.deepStrictEqual(readChangesFrom(envelopes), []);
|
|
});
|
|
|
|
test('persists the unread flag so it survives a host restart', async () => {
|
|
const { sideEffects: persisting, db } = setupPersisting();
|
|
setupSession();
|
|
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionIsReadChanged, isRead: true });
|
|
disposables.add(persisting.registerProgressListener(agent));
|
|
startTurn('turn-1');
|
|
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 },
|
|
});
|
|
|
|
assert.strictEqual(await db.getMetadata('isRead'), '');
|
|
});
|
|
|
|
test('persists read state exactly once for client- and server-dispatched changes', () => {
|
|
const { db } = setupPersisting();
|
|
setupSession();
|
|
|
|
// A client marking the session read (e.g. the user opened it in the
|
|
// editor window or the agent window).
|
|
stateManager.dispatchClientAction(sessionUri.toString(), { type: ActionType.SessionIsReadChanged, isRead: true }, { clientId: 'client-1', clientSeq: 1 });
|
|
// The host marking it unread after background output.
|
|
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionIsReadChanged, isRead: false });
|
|
// A rejected client action never reached state and must not persist.
|
|
stateManager.rejectClientAction(sessionUri.toString(), { type: ActionType.SessionIsReadChanged, isRead: true }, { clientId: 'client-1', clientSeq: 2 }, 'nope');
|
|
|
|
assert.deepStrictEqual(db.setMetadataCalls.filter(c => c.key === 'isRead'), [
|
|
{ key: 'isRead', value: 'true' },
|
|
{ key: 'isRead', value: '' },
|
|
]);
|
|
});
|
|
|
|
test('marks the parent session unread when a subagent turn completes', () => {
|
|
const { sideEffects: persisting } = setupPersisting();
|
|
setupSession();
|
|
// The session has been read (e.g. a client viewed it after the parent
|
|
// turn already produced output). A background subagent then completes.
|
|
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionIsReadChanged, isRead: true });
|
|
disposables.add(persisting.registerProgressListener(agent));
|
|
startTurn('turn-1');
|
|
|
|
// Spawn a subagent chat off 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: 'subagent_started', chat: URI.parse(defaultChatUri),
|
|
toolCallId: 'tc-1', agentName: 'code-reviewer', agentDisplayName: 'Code Reviewer',
|
|
});
|
|
|
|
const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1');
|
|
const subagentTurnId = stateManager.getSessionState(subagentUri)!.activeTurn!.id;
|
|
|
|
const envelopes: ActionEnvelope[] = [];
|
|
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
|
|
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(subagentUri),
|
|
action: { type: ActionType.ChatTurnComplete, turnId: subagentTurnId, duration: 1000 },
|
|
});
|
|
|
|
assert.deepStrictEqual({
|
|
readChanges: readChangesFrom(envelopes),
|
|
isReadBitSet: (stateManager.getSessionSummary(sessionUri.toString())!.status & SessionStatus.IsRead) !== 0,
|
|
}, {
|
|
readChanges: [false],
|
|
isReadBitSet: false,
|
|
});
|
|
});
|
|
test('marks a read session unread when a turn is cancelled', () => {
|
|
const { sideEffects: persisting } = setupPersisting();
|
|
setupSession();
|
|
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionIsReadChanged, isRead: true });
|
|
disposables.add(persisting.registerProgressListener(agent));
|
|
startTurn('turn-1');
|
|
|
|
const envelopes: ActionEnvelope[] = [];
|
|
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
|
|
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', duration: 1000 },
|
|
});
|
|
|
|
assert.deepStrictEqual({
|
|
readChanges: readChangesFrom(envelopes),
|
|
isReadBitSet: (stateManager.getSessionSummary(sessionUri.toString())!.status & SessionStatus.IsRead) !== 0,
|
|
}, {
|
|
readChanges: [false],
|
|
isReadBitSet: false,
|
|
});
|
|
});
|
|
|
|
test('marks a read session unread when a turn errors', () => {
|
|
const { sideEffects: persisting } = setupPersisting();
|
|
setupSession();
|
|
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionIsReadChanged, isRead: true });
|
|
disposables.add(persisting.registerProgressListener(agent));
|
|
startTurn('turn-1');
|
|
|
|
const envelopes: ActionEnvelope[] = [];
|
|
disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e)));
|
|
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 1000, error: { errorType: 'Error', message: 'boom' } },
|
|
});
|
|
|
|
assert.deepStrictEqual({
|
|
readChanges: readChangesFrom(envelopes),
|
|
isReadBitSet: (stateManager.getSessionSummary(sessionUri.toString())!.status & SessionStatus.IsRead) !== 0,
|
|
}, {
|
|
readChanges: [false],
|
|
isReadBitSet: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
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.map(call => ({
|
|
session: call.session.toString(),
|
|
model: call.model,
|
|
chat: call.chat?.toString(),
|
|
})), [{ session: sessionUri.toString(), model: { id: 'gpt-5' }, chat: 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.map(call => ({
|
|
session: call.session.toString(),
|
|
agent: call.agent,
|
|
chat: call.chat?.toString(),
|
|
})), [{ session: sessionUri.toString(), agent: { uri: 'file:///agents/reviewer.md' }, chat: chatChannel }]);
|
|
});
|
|
});
|
|
|
|
// ---- registerProgressListener ---------------------------------------
|
|
|
|
suite('registerProgressListener', () => {
|
|
|
|
test('emits auth-required notifications when observable state becomes required', () => {
|
|
const notifications: INotification[] = [];
|
|
disposables.add(stateManager.onDidEmitNotification(notification => notifications.push(notification)));
|
|
disposables.add(sideEffects.registerProgressListener(agent));
|
|
const requirement = {
|
|
resource: {
|
|
resource: 'https://api.github.com',
|
|
authorization_servers: ['https://github.com/login/oauth'],
|
|
},
|
|
reason: AuthRequiredReason.Expired,
|
|
};
|
|
|
|
agent.setAuthenticationRequired(requirement);
|
|
agent.setAuthenticationRequired(undefined);
|
|
agent.setAuthenticationRequired(requirement);
|
|
|
|
assert.deepStrictEqual(notifications.filter(notification => notification.type === 'auth/required'), [
|
|
{ type: 'auth/required', channel: ROOT_STATE_URI, ...requirement },
|
|
{ type: 'auth/required', channel: ROOT_STATE_URI, ...requirement },
|
|
]);
|
|
});
|
|
|
|
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('does not route stale actions into a force-started turn', () => {
|
|
setupSession();
|
|
startTurn('turn-1');
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatTurnCancelled,
|
|
turnId: 'turn-1',
|
|
duration: 1000,
|
|
});
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatTurnStarted,
|
|
turnId: 'turn-2',
|
|
startedAt: '2025-01-01T00:01:00.000Z',
|
|
message: { text: 'continue', origin: { kind: MessageKind.User } },
|
|
});
|
|
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: 'stale-part', content: 'stale response' } },
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatUsage, turnId: 'turn-1', usage: { inputTokens: 100, outputTokens: 50, model: 'stale-model' } },
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 199029 },
|
|
});
|
|
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatResponsePart, turnId: 'turn-2', part: { kind: ResponsePartKind.Markdown, id: 'fresh-part', content: 'fresh' } },
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatDelta, turnId: 'turn-2', partId: 'fresh-part', content: ' response' },
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatUsage, turnId: 'turn-2', usage: { inputTokens: 20, outputTokens: 10, model: 'fresh-model' } },
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-2', duration: 2000 },
|
|
});
|
|
|
|
const state = stateManager.getSessionState(defaultChatUri);
|
|
assert.deepStrictEqual(state?.turns.map(turn => ({
|
|
id: turn.id,
|
|
state: turn.state,
|
|
duration: turn.duration,
|
|
message: turn.message.text,
|
|
markdown: turn.responseParts
|
|
.filter(part => part.kind === ResponsePartKind.Markdown)
|
|
.map(part => part.content)
|
|
.join(''),
|
|
usage: turn.usage,
|
|
})), [{
|
|
id: 'turn-1',
|
|
state: TurnState.Cancelled,
|
|
duration: 1000,
|
|
message: 'hello',
|
|
markdown: '',
|
|
usage: undefined,
|
|
}, {
|
|
id: 'turn-2',
|
|
state: TurnState.Complete,
|
|
duration: 2000,
|
|
message: 'continue',
|
|
markdown: 'fresh response',
|
|
usage: { inputTokens: 20, outputTokens: 10, model: 'fresh-model' },
|
|
}]);
|
|
});
|
|
|
|
test('preserves the turn id of a provider-initiated turn when idle', () => {
|
|
setupSession();
|
|
startTurn('turn-1');
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatTurnComplete,
|
|
turnId: 'turn-1',
|
|
duration: 1000,
|
|
});
|
|
disposables.add(sideEffects.registerProgressListener(agent));
|
|
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: {
|
|
type: ActionType.ChatTurnStarted,
|
|
turnId: 'provider-turn',
|
|
startedAt: '2025-01-01T00:01:00.000Z',
|
|
message: { text: 'provider notification', origin: { kind: MessageKind.SystemNotification } },
|
|
},
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatResponsePart, turnId: 'provider-turn', part: { kind: ResponsePartKind.Markdown, id: 'provider-part', content: 'provider response' } },
|
|
});
|
|
|
|
const state = stateManager.getSessionState(defaultChatUri);
|
|
assert.deepStrictEqual({
|
|
turnId: state?.activeTurn?.id,
|
|
message: state?.activeTurn?.message.text,
|
|
responseParts: state?.activeTurn?.responseParts,
|
|
}, {
|
|
turnId: 'provider-turn',
|
|
message: 'provider notification',
|
|
responseParts: [{ kind: ResponsePartKind.Markdown, id: 'provider-part', content: 'provider response' }],
|
|
});
|
|
});
|
|
|
|
test('does not replace an active turn with a stale turn start', () => {
|
|
setupSession();
|
|
startTurn('turn-2');
|
|
disposables.add(sideEffects.registerProgressListener(agent));
|
|
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: {
|
|
type: ActionType.ChatTurnStarted,
|
|
turnId: 'turn-1',
|
|
startedAt: '2025-01-01T00:00:00.000Z',
|
|
message: { text: 'stale request', origin: { kind: MessageKind.User } },
|
|
},
|
|
});
|
|
|
|
assert.deepStrictEqual({
|
|
turnId: stateManager.getSessionState(defaultChatUri)?.activeTurn?.id,
|
|
message: stateManager.getSessionState(defaultChatUri)?.activeTurn?.message.text,
|
|
}, {
|
|
turnId: 'turn-2',
|
|
message: 'hello',
|
|
});
|
|
});
|
|
|
|
test('stale completion does not clear active turn tool tracking', () => {
|
|
setupSession();
|
|
startTurn('turn-1');
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatTurnCancelled,
|
|
turnId: 'turn-1',
|
|
duration: 1000,
|
|
});
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatTurnStarted,
|
|
turnId: 'turn-2',
|
|
startedAt: '2025-01-01T00:01:00.000Z',
|
|
message: { text: 'continue', origin: { kind: MessageKind.User } },
|
|
});
|
|
disposables.add(sideEffects.registerProgressListener(agent));
|
|
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: {
|
|
type: ActionType.ChatToolCallStart, turnId: 'turn-2',
|
|
toolCallId: 'active-tool', toolName: 'read', displayName: 'Read', contributor: undefined,
|
|
_meta: { toolKind: undefined, language: undefined },
|
|
},
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 199029 },
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: {
|
|
type: ActionType.ChatToolCallComplete, turnId: 'turn-2',
|
|
toolCallId: 'active-tool',
|
|
result: { success: true, pastTenseMessage: 'Read file' },
|
|
},
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-2', duration: 1000 },
|
|
});
|
|
|
|
assert.deepStrictEqual(
|
|
telemetryService.events.filter(event => event.eventName === 'languageModelToolInvoked').map(event => event.eventName),
|
|
['languageModelToolInvoked'],
|
|
);
|
|
});
|
|
|
|
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', 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', 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, []);
|
|
// Steering is always addressed by a concrete chat channel URI.
|
|
assert.strictEqual(agent.setPendingMessagesCalls[0].chat.toString(), defaultChatUri);
|
|
});
|
|
|
|
test('syncs a peer chat steering message addressed by the peer chat URI', () => {
|
|
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({
|
|
chat: agent.setPendingMessagesCalls[0].chat.toString(),
|
|
steeringId: agent.setPendingMessagesCalls[0].steeringMessage?.id,
|
|
}, {
|
|
chat: peerChatUri.toString(),
|
|
steeringId: 'steer-peer',
|
|
});
|
|
});
|
|
|
|
test('syncs queued message and preserves the enqueuing client attribution', 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, 'client-editor', AgentHostClientType.EditorWindow);
|
|
|
|
// 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.deepStrictEqual(agent.sendMessageCalls[0], {
|
|
session: URI.parse(sessionUri.toString()),
|
|
chat: URI.parse(defaultChatUri),
|
|
prompt: 'queued message',
|
|
attachments: undefined,
|
|
senderClientId: 'client-editor',
|
|
clientType: 'editor_window',
|
|
});
|
|
});
|
|
|
|
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',
|
|
hostLaunchKind: 'vscode_main_process',
|
|
initiatorClientId: undefined,
|
|
initiatorClientType: 'unknown',
|
|
initiatorConnectionKind: 'unknown',
|
|
initiatorTransportKind: 'unknown',
|
|
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('waits for pending steering before consuming a queued message', async () => {
|
|
setupSession();
|
|
disposables.add(sideEffects.registerProgressListener(agent));
|
|
startTurn('turn-original');
|
|
|
|
const queuedAction = {
|
|
type: ActionType.ChatPendingMessageSet as const,
|
|
kind: PendingMessageKind.Queued,
|
|
id: 'queued-1',
|
|
message: { text: 'queued', origin: { kind: MessageKind.User } },
|
|
};
|
|
stateManager.dispatchClientAction(defaultChatUri, queuedAction, { clientId: 'test', clientSeq: 1 });
|
|
sideEffects.handleAction(defaultChatUri, queuedAction);
|
|
|
|
const steeringAction = {
|
|
type: ActionType.ChatPendingMessageSet as const,
|
|
kind: PendingMessageKind.Steering,
|
|
id: 'steering-1',
|
|
message: { text: 'steering', origin: { kind: MessageKind.User } },
|
|
};
|
|
stateManager.dispatchClientAction(defaultChatUri, steeringAction, { clientId: 'test', clientSeq: 2 });
|
|
sideEffects.handleAction(defaultChatUri, steeringAction);
|
|
|
|
agent.fireProgress({
|
|
kind: 'action',
|
|
resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-original', duration: 1000 },
|
|
});
|
|
assert.strictEqual(agent.sendMessageCalls.length, 0, 'queued message must wait for steering to start');
|
|
|
|
agent.fireProgress({
|
|
kind: 'action',
|
|
resource: URI.parse(defaultChatUri),
|
|
action: {
|
|
type: ActionType.ChatTurnStarted,
|
|
turnId: 'turn-steering',
|
|
startedAt: new Date().toISOString(),
|
|
message: steeringAction.message,
|
|
queuedMessageId: steeringAction.id,
|
|
},
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'action',
|
|
resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-steering', duration: 1000 },
|
|
});
|
|
|
|
await waitForSendMessageCalls(1);
|
|
assert.deepStrictEqual(agent.sendMessageCalls.map(call => call.prompt), ['queued']);
|
|
});
|
|
|
|
test('does not drain queued messages when the cancelled turn completes late', () => {
|
|
// 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);
|
|
|
|
const truncateAction = { type: ActionType.ChatTruncated as const };
|
|
stateManager.dispatchClientAction(defaultChatUri, truncateAction, { clientId: 'test', clientSeq: 3 });
|
|
sideEffects.handleAction(defaultChatUri, truncateAction);
|
|
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 2000 },
|
|
});
|
|
|
|
// 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?.turns.length, 0, 'the cancelled turn should no longer be retained in history');
|
|
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('replaces a queued bang command title with the following real message', async () => {
|
|
stateManager.createSession({
|
|
resource: sessionUri.toString(),
|
|
provider: 'mock',
|
|
title: '',
|
|
status: SessionStatus.Idle,
|
|
createdAt: new Date().toISOString(),
|
|
modifiedAt: new Date().toISOString(),
|
|
});
|
|
stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady });
|
|
const db = new TestSessionDatabase();
|
|
const terminalManager = disposables.add(new TestAgentHostTerminalManager());
|
|
const queuedSideEffects = createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService: createSessionDataService(db),
|
|
onTurnComplete: () => { },
|
|
}, undefined, undefined, undefined, terminalManager);
|
|
disposables.add(queuedSideEffects.registerProgressListener(agent));
|
|
|
|
startTurn('turn-1');
|
|
for (const [id, text] of [['q-command', '!echo hi'], ['q-request', 'Explain the build']] as const) {
|
|
const setAction = {
|
|
type: ActionType.ChatPendingMessageSet as const,
|
|
kind: PendingMessageKind.Queued,
|
|
id,
|
|
message: { text, origin: { kind: MessageKind.User } },
|
|
};
|
|
stateManager.dispatchClientAction(defaultChatUri, setAction, { clientId: 'test', clientSeq: 1 });
|
|
queuedSideEffects.handleAction(defaultChatUri, setAction);
|
|
}
|
|
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 },
|
|
});
|
|
await terminalManager.commandFinishedListenerRegistered.p;
|
|
terminalManager.fireCommandFinished({ commandId: '1', command: 'echo hi', exitCode: 0, output: 'hi\n' });
|
|
await waitForSendMessageCalls(1);
|
|
|
|
assert.deepStrictEqual({
|
|
prompt: agent.sendMessageCalls[0].prompt,
|
|
title: stateManager.getSessionState(sessionUri.toString())?.title,
|
|
persistedTitle: await db.getMetadata('customTitle'),
|
|
}, {
|
|
prompt: 'Explain the build',
|
|
title: 'Explain the build',
|
|
persistedTitle: 'Explain the build',
|
|
});
|
|
});
|
|
|
|
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.map(call => ({
|
|
...call,
|
|
session: call.session.toString(),
|
|
chat: call.chat?.toString(),
|
|
})), [{
|
|
session: sessionUri.toString(),
|
|
prompt: 'peer queued',
|
|
attachments: undefined,
|
|
chat: 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', load: { kind: CustomizationLoadStatus.Loaded } };
|
|
const pluginB: Customization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-b'), uri: 'file:///plugin-b', name: 'Plugin B', load: { kind: CustomizationLoadStatus.Loaded } };
|
|
const pluginAClient: ClientPluginCustomization = { type: CustomizationType.Plugin, id: pluginA.id, uri: pluginA.uri, name: pluginA.name, };
|
|
const pluginBClient: ClientPluginCustomization = { type: CustomizationType.Plugin, id: pluginB.id, uri: pluginB.uri, name: pluginB.name, };
|
|
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', };
|
|
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('rejects session actions emitted on a peer chat channel', () => {
|
|
setupSession();
|
|
const peerChatUri = URI.parse(buildChatUri(sessionUri, 'peer-customization'));
|
|
stateManager.addChat(sessionUri.toString(), peerChatUri.toString());
|
|
const customization: PluginCustomization = {
|
|
type: CustomizationType.Plugin,
|
|
id: customizationId('file:///peer-plugin'),
|
|
uri: 'file:///peer-plugin',
|
|
name: 'Peer Plugin',
|
|
enablement: [{ kind: CustomizationEnablementKind.Global, enabled: true }],
|
|
load: { kind: CustomizationLoadStatus.Loaded },
|
|
};
|
|
const handleAgentSignal: (agent: IAgent, signal: AgentSignal) => void = Reflect.get(Object.getPrototypeOf(sideEffects), '_handleAgentSignal');
|
|
assert.throws(() => handleAgentSignal.call(sideEffects, agent, {
|
|
kind: 'action',
|
|
resource: peerChatUri,
|
|
action: { type: ActionType.SessionCustomizationUpdated, customization },
|
|
}), /must not be dispatched on chat channel/);
|
|
assert.strictEqual(stateManager.getSessionState(sessionUri.toString())?.customizations, undefined);
|
|
});
|
|
|
|
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 peerChatUri = URI.parse(buildChatUri(sessionUri, 'peer-removal'));
|
|
stateManager.addChat(sessionUri.toString(), peerChatUri.toString());
|
|
|
|
const action: SessionAction = {
|
|
type: ActionType.SessionActiveClientRemoved,
|
|
clientId: 'test-client',
|
|
};
|
|
sideEffects.handleAction(sessionUri.toString(), action);
|
|
|
|
assert.deepStrictEqual(agent.removeActiveClientCalls.map(call => ({
|
|
chat: call.chat.toString(),
|
|
clientId: call.clientId,
|
|
})), [
|
|
{ chat: defaultChatUri, clientId: 'test-client' },
|
|
{ chat: peerChatUri.toString(), clientId: 'test-client' },
|
|
]);
|
|
});
|
|
|
|
test('Agent Host owns the exact chat fan-out and supplies host customizations', () => {
|
|
setupSession();
|
|
const hostCustomization: Customization = {
|
|
type: CustomizationType.Plugin,
|
|
id: customizationId('file:///host-plugin'),
|
|
uri: 'file:///host-plugin',
|
|
name: 'Host Plugin',
|
|
enablement: [{ kind: CustomizationEnablementKind.Global, enabled: true }],
|
|
load: { kind: CustomizationLoadStatus.Loaded },
|
|
};
|
|
stateManager.setSessionCustomizations(sessionUri.toString(), [hostCustomization]);
|
|
const peerChatUri = URI.parse(buildChatUri(sessionUri, 'peer-fanout'));
|
|
stateManager.addChat(sessionUri.toString(), peerChatUri.toString());
|
|
|
|
sideEffects.handleAction(sessionUri.toString(), {
|
|
type: ActionType.SessionActiveClientSet,
|
|
activeClient: { clientId: 'test-client', tools: [] },
|
|
});
|
|
|
|
assert.deepStrictEqual(agent.activeClientCalls.map(call => ({
|
|
chat: call.chat.toString(),
|
|
configurationResource: URI.isUri(call.context) ? call.context.toString() : call.context.configurationResource.toString(),
|
|
clientId: call.clientId,
|
|
hostCustomizations: call.hostCustomizations?.map(c => c.id),
|
|
})), [
|
|
{
|
|
chat: defaultChatUri,
|
|
configurationResource: sessionUri.toString(),
|
|
clientId: 'test-client',
|
|
hostCustomizations: [hostCustomization.id],
|
|
},
|
|
{
|
|
chat: peerChatUri.toString(),
|
|
configurationResource: sessionUri.toString(),
|
|
clientId: 'test-client',
|
|
hostCustomizations: [hostCustomization.id],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('skips the fan-out when the host has no state for the session', () => {
|
|
const unknownSession = URI.parse('mock:/never-created');
|
|
sideEffects.handleAction(unknownSession.toString(), {
|
|
type: ActionType.SessionActiveClientSet,
|
|
activeClient: { clientId: 'test-client', tools: [] },
|
|
});
|
|
|
|
// An absent session has no authoritative membership, so the provider
|
|
// is not handed an invented default-chat-only list.
|
|
assert.deepStrictEqual(agent.activeClientCalls, []);
|
|
});
|
|
|
|
test('re-fans-out every active client when a chat joins the catalog', () => {
|
|
setupSession();
|
|
const activeClientAction: SessionAction = {
|
|
type: ActionType.SessionActiveClientSet,
|
|
activeClient: { clientId: 'test-client', tools: [] },
|
|
};
|
|
// Record the contribution in session state (as the real dispatch
|
|
// pipeline does) and run its side effect.
|
|
stateManager.dispatchClientAction(sessionUri.toString(), activeClientAction, { clientId: 'test-client', clientSeq: 1 });
|
|
sideEffects.handleAction(sessionUri.toString(), activeClientAction);
|
|
|
|
const peerChatUri = buildChatUri(sessionUri, 'peer-added');
|
|
stateManager.addChat(sessionUri.toString(), peerChatUri);
|
|
|
|
assert.deepStrictEqual(agent.activeClientCalls.map(call => ({
|
|
clientId: call.clientId,
|
|
chat: call.chat.toString(),
|
|
})), [
|
|
{ clientId: 'test-client', chat: defaultChatUri },
|
|
{ clientId: 'test-client', chat: defaultChatUri },
|
|
{ clientId: 'test-client', chat: peerChatUri },
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ---- 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', 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', 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', }];
|
|
|
|
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/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 marks autoApproveRuleResolvable only for eligible shell confirmations', async () => {
|
|
setupSession();
|
|
startTurn('turn-1');
|
|
disposables.add(sideEffects.registerProgressListener(agent));
|
|
// Rule resolvability requires a successful tree-sitter parse.
|
|
await sideEffects.initialize();
|
|
|
|
const cases = [
|
|
['tc-shell-rules-1', { requestSandboxBypass: false, shellLanguage: 'bash' as const }],
|
|
['tc-shell-rules-2', { requestSandboxBypass: true, shellLanguage: 'bash' as const }],
|
|
['tc-shell-rules-3', { managedApprovalRequired: true, shellLanguage: 'bash' as const }],
|
|
] as const;
|
|
for (const [toolCallId, signalOverrides] of cases) {
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: {
|
|
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
|
|
toolCallId, toolName: 'shell', displayName: 'Shell', contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client' },
|
|
_meta: { toolKind: undefined, language: undefined },
|
|
},
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
|
|
state: {
|
|
status: ToolCallStatus.PendingConfirmation,
|
|
toolCallId, toolName: '', displayName: '',
|
|
invocationMessage: 'Run command', toolInput: 'foo --bar',
|
|
confirmationTitle: 'Run in terminal?', edits: undefined,
|
|
},
|
|
permissionKind: 'shell', permissionPath: undefined,
|
|
...signalOverrides,
|
|
});
|
|
}
|
|
|
|
const state = await waitForState(stateManager, () => {
|
|
const s = stateManager.getSessionState(sessionUri.toString());
|
|
const parts = s?.activeTurn?.responseParts;
|
|
return parts?.length === cases.length && parts.every(p => p.kind === ResponsePartKind.ToolCall && p.toolCall.status === ToolCallStatus.PendingConfirmation) ? s : undefined;
|
|
});
|
|
assert.deepStrictEqual(
|
|
state.activeTurn?.responseParts.map(p => p.kind === ResponsePartKind.ToolCall ? p.toolCall._meta?.['autoApproveRuleResolvable'] : undefined),
|
|
[true, undefined, undefined],
|
|
'only the rule-resolvable shell confirmation is marked; sandbox-bypass and managed confirmations are not');
|
|
});
|
|
|
|
test('tool_ready forwards the signal shell language into shell approval', async () => {
|
|
setupSession();
|
|
startTurn('turn-1');
|
|
disposables.add(sideEffects.registerProgressListener(agent));
|
|
await sideEffects.initialize();
|
|
|
|
// `get-childitem` only matches the default `Get-ChildItem` allow rule
|
|
// under PowerShell's case-insensitive matching. Missing language fails
|
|
// closed before rule analysis.
|
|
const cases = [
|
|
['tc-shell-lang-1', 'powershell'],
|
|
['tc-shell-lang-2', 'bash'],
|
|
['tc-shell-lang-3', undefined],
|
|
] as const;
|
|
for (const [toolCallId, shellLanguage] of cases) {
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: {
|
|
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
|
|
toolCallId, toolName: 'shell', displayName: 'Shell', contributor: { kind: ToolCallContributorKind.Client, clientId: 'test-client' },
|
|
_meta: { toolKind: undefined, language: undefined },
|
|
},
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
|
|
state: {
|
|
status: ToolCallStatus.PendingConfirmation,
|
|
toolCallId, toolName: '', displayName: '',
|
|
invocationMessage: 'Run command', toolInput: 'get-childitem',
|
|
confirmationTitle: 'Run in terminal?', edits: undefined,
|
|
},
|
|
permissionKind: 'shell', permissionPath: undefined,
|
|
shellLanguage,
|
|
});
|
|
}
|
|
|
|
const state = await waitForState(stateManager, () => {
|
|
const s = stateManager.getSessionState(sessionUri.toString());
|
|
const parts = s?.activeTurn?.responseParts;
|
|
return parts?.length === cases.length && parts.every(p => p.kind === ResponsePartKind.ToolCall && p.toolCall.status === ToolCallStatus.PendingConfirmation) ? s : undefined;
|
|
});
|
|
assert.deepStrictEqual(
|
|
state.activeTurn?.responseParts.map(p => p.kind === ResponsePartKind.ToolCall
|
|
? [p.toolCall._meta?.['autoApproveBySetting'], p.toolCall._meta?.['autoApproveRuleResolvable']]
|
|
: undefined),
|
|
[[true, undefined], [undefined, true], [undefined, undefined]],
|
|
'powershell auto-approves; bash stays rule-resolvable; missing language is neither');
|
|
});
|
|
|
|
test('tool_ready is dropped when the tool completes while permission lookup is pending', async () => {
|
|
setupSession();
|
|
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-stale-ready', toolName: 'vscodeAPI', displayName: 'Get VS Code API References',
|
|
contributor: { kind: ToolCallContributorKind.Client, clientId: 'disconnected-client' },
|
|
_meta: { toolKind: undefined, language: undefined },
|
|
},
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
|
|
state: {
|
|
status: ToolCallStatus.PendingConfirmation,
|
|
toolCallId: 'tc-stale-ready', toolName: 'vscodeAPI', displayName: 'Get VS Code API References',
|
|
invocationMessage: 'Get VS Code API References', toolInput: '{"query":"test"}',
|
|
confirmationTitle: 'Allow tool call?', edits: undefined,
|
|
},
|
|
permissionKind: 'custom-tool', permissionPath: undefined,
|
|
});
|
|
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatToolCallReady,
|
|
turnId: 'turn-1',
|
|
toolCallId: 'tc-stale-ready',
|
|
invocationMessage: 'Get VS Code API References',
|
|
confirmed: ToolCallConfirmationReason.NotNeeded,
|
|
});
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatToolCallComplete,
|
|
turnId: 'turn-1',
|
|
toolCallId: 'tc-stale-ready',
|
|
result: {
|
|
success: false,
|
|
pastTenseMessage: 'Get VS Code API References failed',
|
|
error: { message: 'Client disconnected' },
|
|
},
|
|
});
|
|
|
|
await Promise.resolve();
|
|
|
|
const toolCall = stateManager.getSessionState(sessionUri.toString())?.activeTurn?.responseParts
|
|
.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-stale-ready');
|
|
assert.deepStrictEqual({
|
|
status: toolCall?.kind === ResponsePartKind.ToolCall ? toolCall.toolCall.status : undefined,
|
|
readyActions: envelopes.filter(e => e.action.type === ActionType.ChatToolCallReady).length,
|
|
}, {
|
|
status: ToolCallStatus.Completed,
|
|
readyActions: 1,
|
|
});
|
|
});
|
|
|
|
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
|
|
// The exact default chat URI must be forwarded unchanged.
|
|
setupSession();
|
|
|
|
sideEffects.handleAction(defaultChatUri, {
|
|
type: ActionType.ChatToolCallComplete,
|
|
turnId: 'turn-1',
|
|
toolCallId: 'tc-default',
|
|
result: { success: true, pastTenseMessage: 'done' },
|
|
});
|
|
|
|
assert.deepStrictEqual(
|
|
agent.clientToolCallCompleteCalls.map(c => ({ chat: c.chat.toString(), toolCallId: c.toolCallId })),
|
|
[{ chat: defaultChatUri, toolCallId: 'tc-default' }],
|
|
);
|
|
});
|
|
|
|
test('forwards the exact additional chat URI for a 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 => ({ chat: c.chat.toString(), toolCallId: c.toolCallId })),
|
|
[{ 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 => ({
|
|
chat: c.chat.toString(),
|
|
toolCallId: c.toolCallId,
|
|
// `context` describes the *addressed* chat, so a provider can
|
|
// recover the spawning chat + tool call from it. Stamping the
|
|
// routing target here instead would make that unresolvable.
|
|
contextResource: c.context?.resource.toString(),
|
|
parent: resolveSubagentChatParent(c.context)?.chat.toString(),
|
|
parentToolCallId: resolveSubagentChatParent(c.context)?.toolCallId,
|
|
})),
|
|
[{
|
|
chat: peerChatUri,
|
|
toolCallId: 'tc-inner',
|
|
contextResource: subagentChatUri,
|
|
parent: peerChatUri,
|
|
parentToolCallId: 'tc-parent',
|
|
}],
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---- 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:'));
|
|
});
|
|
|
|
async function waitForMetadata(key: string): Promise<string> {
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
const value = await sessionDb.getMetadata(key);
|
|
if (value !== undefined) {
|
|
return value;
|
|
}
|
|
await timeout(10);
|
|
}
|
|
throw new Error(`Session metadata '${key}' was not persisted`);
|
|
}
|
|
|
|
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',
|
|
});
|
|
|
|
assert.strictEqual(await waitForMetadata('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);
|
|
|
|
await createAgentSession(localAgent);
|
|
|
|
// 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);
|
|
|
|
const session = await createAgentSession(localAgent);
|
|
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 createAgentSession(localAgent);
|
|
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' },
|
|
});
|
|
|
|
const persisted = await waitForMetadata('configValues');
|
|
assert.deepStrictEqual(JSON.parse(persisted), { autoApprove: 'autoApprove' });
|
|
});
|
|
|
|
test('server-dispatched 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()));
|
|
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: { mode: 'plan', autoApprove: 'default' } };
|
|
|
|
localStateManager.dispatchServerAction(sessionUri.toString(), {
|
|
type: ActionType.SessionConfigChanged,
|
|
config: { mode: 'interactive' },
|
|
});
|
|
|
|
const persisted = await waitForMetadata('configValues');
|
|
assert.deepStrictEqual(JSON.parse(persisted), { mode: 'interactive', autoApprove: 'default' });
|
|
});
|
|
|
|
test('SessionConfigChanged emits agentHost.executionModeChanged for effective mode transitions without duplicate echoes', () => {
|
|
setupSession();
|
|
stateManager.setSessionConfig(sessionUri.toString(), {
|
|
schema: platformSessionSchema.toProtocol(),
|
|
values: { mode: 'interactive' },
|
|
});
|
|
startTurn('turn-1');
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatTurnComplete,
|
|
turnId: 'turn-1',
|
|
duration: 1000,
|
|
});
|
|
|
|
stateManager.dispatchClientAction(sessionUri.toString(), {
|
|
type: ActionType.SessionConfigChanged,
|
|
config: { mode: 'plan' },
|
|
}, { clientId: 'test-client', clientSeq: 1 });
|
|
stateManager.dispatchServerAction(sessionUri.toString(), {
|
|
type: ActionType.SessionConfigChanged,
|
|
config: { mode: 'plan' },
|
|
});
|
|
stateManager.dispatchServerAction(sessionUri.toString(), {
|
|
type: ActionType.SessionConfigChanged,
|
|
config: { mode: 'autopilot' },
|
|
});
|
|
stateManager.dispatchServerAction(sessionUri.toString(), {
|
|
type: ActionType.SessionConfigChanged,
|
|
config: {},
|
|
replace: true,
|
|
});
|
|
|
|
assert.deepStrictEqual(telemetryService.events.filter(event => event.eventName === 'agentHost.executionModeChanged'), [{
|
|
eventName: 'agentHost.executionModeChanged',
|
|
data: {
|
|
provider: 'mock',
|
|
agentSessionId: 'session-1',
|
|
isSubagentSession: false,
|
|
previousMode: 'interactive',
|
|
newMode: 'plan',
|
|
turnCount: 1,
|
|
},
|
|
}, {
|
|
eventName: 'agentHost.executionModeChanged',
|
|
data: {
|
|
provider: 'mock',
|
|
agentSessionId: 'session-1',
|
|
isSubagentSession: false,
|
|
previousMode: 'plan',
|
|
newMode: 'autopilot',
|
|
turnCount: 1,
|
|
},
|
|
}, {
|
|
eventName: 'agentHost.executionModeChanged',
|
|
data: {
|
|
provider: 'mock',
|
|
agentSessionId: 'session-1',
|
|
isSubagentSession: false,
|
|
previousMode: 'autopilot',
|
|
newMode: 'interactive',
|
|
turnCount: 1,
|
|
},
|
|
}]);
|
|
});
|
|
|
|
});
|
|
|
|
// ---- 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',
|
|
taskPrompt: 'Review the auth module for security issues',
|
|
});
|
|
|
|
// 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');
|
|
assert.strictEqual(subState!.activeTurn!.message.text, 'Review the auth module for security issues', 'subagent turn should render the spawning tool call prompt as its request');
|
|
|
|
// 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('stamps _meta.subagentChatUri onto a subagent-spawning tool call as soon as toolKind is known', () => {
|
|
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: 'subagent', language: undefined },
|
|
},
|
|
});
|
|
|
|
const expectedUri = buildSubagentChatUri(sessionUri.toString(), 'tc-1');
|
|
const parentState = stateManager.getSessionState(sessionUri.toString());
|
|
const toolCall = parentState?.activeTurn?.responseParts.find(
|
|
rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1'
|
|
);
|
|
assert.ok(toolCall?.kind === ResponsePartKind.ToolCall);
|
|
assert.strictEqual(readToolCallMeta(toolCall.toolCall).subagentChatUri, expectedUri);
|
|
assert.strictEqual(stateManager.getSnapshot(expectedUri), undefined);
|
|
});
|
|
|
|
test('nested subagent_started routes discovery block and seeds each request prompt via 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', taskPrompt: 'l1 prompt' });
|
|
|
|
// 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', taskPrompt: 'l2 prompt', 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', taskPrompt: 'l3 prompt', 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');
|
|
|
|
// Each child chat's opening request is seeded from its own
|
|
// `subagent_started` signal's `taskPrompt`.
|
|
assert.deepStrictEqual(
|
|
[l1ChatUri, l2ChatUri, l3ChatUri].map(uri => stateManager.getSessionState(uri)?.activeTurn?.message.text),
|
|
['l1 prompt', 'l2 prompt', 'l3 prompt'],
|
|
);
|
|
|
|
// 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);
|
|
|
|
agent.fireProgress({
|
|
kind: 'subagent_resumed',
|
|
chat: URI.parse(defaultChatUri),
|
|
toolCallId: 'tc-1',
|
|
message: { text: 'Follow up', origin: { kind: MessageKind.User } },
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'action',
|
|
resource: URI.parse(defaultChatUri),
|
|
parentToolCallId: 'tc-1',
|
|
action: {
|
|
type: ActionType.ChatResponsePart,
|
|
turnId: 'parent-turn',
|
|
part: { kind: ResponsePartKind.Markdown, id: 'follow-up-part', content: 'Follow-up response' },
|
|
},
|
|
});
|
|
|
|
subState = stateManager.getSessionState(subagentUri);
|
|
assert.deepStrictEqual({
|
|
message: subState?.activeTurn?.message.text,
|
|
response: subState?.activeTurn?.responseParts[0],
|
|
completedTurns: subState?.turns.length,
|
|
}, {
|
|
message: 'Follow up',
|
|
response: { kind: ResponsePartKind.Markdown, id: 'follow-up-part', content: 'Follow-up response' },
|
|
completedTurns: 1,
|
|
});
|
|
});
|
|
|
|
test('permission requests for inactive and unroutable subagents are denied', () => {
|
|
setupSession();
|
|
startTurn('turn-1');
|
|
disposables.add(sideEffects.registerProgressListener(agent));
|
|
agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-inactive', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' });
|
|
agent.fireProgress({ kind: 'subagent_completed', chat: URI.parse(defaultChatUri), toolCallId: 'tc-inactive' });
|
|
|
|
agent.fireProgress({
|
|
kind: 'action',
|
|
resource: URI.parse(defaultChatUri),
|
|
parentToolCallId: 'tc-starting',
|
|
action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-starting-permission', toolName: 'shell', displayName: 'Shell' },
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'pending_confirmation',
|
|
chat: URI.parse(defaultChatUri),
|
|
parentToolCallId: 'tc-starting',
|
|
state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-starting-permission', toolName: 'shell', displayName: 'Shell', invocationMessage: 'Run command' },
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'pending_confirmation',
|
|
chat: URI.parse(defaultChatUri),
|
|
parentToolCallId: 'tc-inactive',
|
|
state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-inactive-permission', toolName: 'shell', displayName: 'Shell', invocationMessage: 'Run command' },
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'pending_confirmation',
|
|
chat: URI.parse(defaultChatUri),
|
|
parentToolCallId: 'tc-missing',
|
|
state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-missing-permission', toolName: 'shell', displayName: 'Shell', invocationMessage: 'Run command' },
|
|
});
|
|
|
|
assert.deepStrictEqual(agent.respondToPermissionCalls, [
|
|
{ requestId: 'tc-inactive-permission', approved: false },
|
|
{ requestId: 'tc-missing-permission', approved: false },
|
|
]);
|
|
});
|
|
|
|
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 ?? [];
|
|
}
|
|
|
|
function sessionStatus() {
|
|
return stateManager.getSessionState(sessionUri.toString())?.status;
|
|
}
|
|
|
|
test('chat input request mirrors its unresolved response part and is removed on completion', () => {
|
|
setupSession();
|
|
startTurn('turn-1');
|
|
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatInputRequested,
|
|
request: {
|
|
id: 'req-1',
|
|
questions: [{ kind: ChatInputQuestionKind.Text, id: 'question-1', message: 'Which value?' }],
|
|
},
|
|
});
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatInputAnswerChanged,
|
|
requestId: 'req-1',
|
|
questionId: 'question-1',
|
|
answer: {
|
|
state: ChatInputAnswerState.Draft,
|
|
value: { kind: ChatInputAnswerValueKind.Text, value: 'draft value' },
|
|
},
|
|
});
|
|
|
|
const produced = sessionInputNeeded();
|
|
assert.deepStrictEqual(produced.map(r => ({
|
|
kind: r.kind,
|
|
chat: r.chat,
|
|
request: r.kind === SessionInputRequestKind.ChatInput ? r.request : undefined,
|
|
})), [
|
|
{
|
|
kind: SessionInputRequestKind.ChatInput,
|
|
chat: defaultChatUri,
|
|
request: {
|
|
id: 'req-1',
|
|
questions: [{ kind: ChatInputQuestionKind.Text, id: 'question-1', message: 'Which value?' }],
|
|
answers: {
|
|
'question-1': {
|
|
state: ChatInputAnswerState.Draft,
|
|
value: { kind: ChatInputAnswerValueKind.Text, value: 'draft value' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatInputCompleted,
|
|
requestId: 'req-1',
|
|
response: SessionInputResponseKind.Accept,
|
|
});
|
|
|
|
assert.deepStrictEqual(sessionInputNeeded(), []);
|
|
});
|
|
|
|
test('accepted ask-user input emits telemetry from synchronized answer state', () => {
|
|
setupSession();
|
|
startTurn('turn-1');
|
|
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatInputRequested,
|
|
request: {
|
|
id: 'req-1',
|
|
purpose: ChatInputRequestPurpose.AskUser,
|
|
questions: [{ kind: ChatInputQuestionKind.Text, id: 'question-1', message: 'Which value?' }],
|
|
},
|
|
});
|
|
stateManager.dispatchClientAction(defaultChatUri, {
|
|
type: ActionType.ChatInputAnswerChanged,
|
|
requestId: 'req-1',
|
|
questionId: 'question-1',
|
|
answer: {
|
|
state: ChatInputAnswerState.Submitted,
|
|
value: { kind: ChatInputAnswerValueKind.Text, value: 'answer' },
|
|
},
|
|
}, { clientId: 'test', clientSeq: 2 });
|
|
stateManager.dispatchClientAction(defaultChatUri, {
|
|
type: ActionType.ChatInputCompleted,
|
|
requestId: 'req-1',
|
|
response: SessionInputResponseKind.Accept,
|
|
}, { clientId: 'test', clientSeq: 3 });
|
|
|
|
const event = telemetryService.events.find(event => event.eventName === 'askQuestionsToolInvoked');
|
|
const data = event?.data as IAgentHostAskQuestionsToolInvokedEvent | undefined;
|
|
assert.deepStrictEqual(event && {
|
|
eventName: event.eventName,
|
|
data: {
|
|
...data,
|
|
duration: typeof data?.duration,
|
|
},
|
|
}, {
|
|
eventName: 'askQuestionsToolInvoked',
|
|
data: {
|
|
requestId: 'turn-1',
|
|
questionCount: 1,
|
|
answeredCount: 1,
|
|
skippedCount: 0,
|
|
freeTextCount: 1,
|
|
recommendedAvailableCount: 0,
|
|
recommendedSelectedCount: 0,
|
|
duration: 'number',
|
|
provider: agent.id,
|
|
agentSessionId: AgentSession.id(sessionUri),
|
|
isSubagentSession: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
test('chat truncation clears pending ask-user telemetry', () => {
|
|
setupSession();
|
|
startTurn('turn-1');
|
|
|
|
const request: ChatInputRequest = {
|
|
id: 'req-1',
|
|
purpose: ChatInputRequestPurpose.AskUser,
|
|
questions: [{ kind: ChatInputQuestionKind.Text, id: 'question-1', message: 'Which value?' }],
|
|
};
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatInputRequested,
|
|
request,
|
|
});
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatTruncated,
|
|
});
|
|
|
|
startTurn('turn-2');
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatInputRequested,
|
|
request,
|
|
});
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatInputCompleted,
|
|
requestId: request.id,
|
|
response: SessionInputResponseKind.Accept,
|
|
});
|
|
|
|
const events = telemetryService.events.filter(event => event.eventName === 'askQuestionsToolInvoked');
|
|
assert.deepStrictEqual(events.map(event => (event.data as IAgentHostAskQuestionsToolInvokedEvent).requestId), ['turn-2']);
|
|
});
|
|
|
|
test('chat input request without an active turn is not mirrored', () => {
|
|
setupSession();
|
|
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatInputRequested,
|
|
request: { id: 'req-1', questions: [] },
|
|
});
|
|
|
|
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('auto-approved tool call still surfaces its client execution without flagging input needed', () => {
|
|
setupSession();
|
|
startTurn('turn-1');
|
|
|
|
// Auto-approved calls flow through PendingConfirmation then Running but never block the user.
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
|
|
toolCallId: 'tc-auto', toolName: 'browser_navigate', displayName: 'Navigate Browser',
|
|
contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-1' },
|
|
_meta: { autoApproveBySetting: true },
|
|
});
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
|
|
toolCallId: 'tc-auto', invocationMessage: 'Navigate', confirmationTitle: 'Navigate',
|
|
_meta: { autoApproveBySetting: true },
|
|
});
|
|
assert.deepStrictEqual(sessionInputNeeded(), [], 'no confirmation entry while PendingConfirmation');
|
|
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatToolCallConfirmed, turnId: 'turn-1',
|
|
toolCallId: 'tc-auto', approved: true, confirmed: ToolCallConfirmationReason.Setting,
|
|
});
|
|
|
|
// The client still has to run the call, so it must be discoverable
|
|
// from the session channel — but it is not a user prompt, so the
|
|
// session must not present as "input needed".
|
|
assert.deepStrictEqual(
|
|
sessionInputNeeded().map(r => ({ kind: r.kind, clientId: r.kind === SessionInputRequestKind.ToolClientExecution ? r.clientId : undefined })),
|
|
[{ kind: SessionInputRequestKind.ToolClientExecution, clientId: 'client-1' }],
|
|
);
|
|
assert.strictEqual(sessionStatus(), SessionStatus.InProgress, 'auto-approved client execution must not present as input needed');
|
|
});
|
|
|
|
test('auto-approved tool still surfaces a genuine result confirmation', () => {
|
|
setupSession();
|
|
startTurn('turn-1');
|
|
|
|
// The auto-approved parameter gate is suppressed, but a post-execution result gate is a genuine prompt.
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatToolCallStart, turnId: 'turn-1',
|
|
toolCallId: 'tc-auto-result', toolName: 'browser_navigate', displayName: 'Navigate Browser',
|
|
contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-1' },
|
|
_meta: { autoApproveBySetting: true },
|
|
});
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatToolCallReady, turnId: 'turn-1',
|
|
toolCallId: 'tc-auto-result', invocationMessage: 'Navigate', confirmed: ToolCallConfirmationReason.NotNeeded,
|
|
});
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatToolCallComplete, turnId: 'turn-1',
|
|
toolCallId: 'tc-auto-result', requiresResultConfirmation: true,
|
|
result: { success: true, pastTenseMessage: 'Navigated' },
|
|
});
|
|
|
|
assert.deepStrictEqual(
|
|
sessionInputNeeded().map(r => ({ kind: r.kind, toolCallId: r.kind === SessionInputRequestKind.ToolConfirmation ? r.toolCall.toolCallId : undefined })),
|
|
[{ kind: SessionInputRequestKind.ToolConfirmation, toolCallId: 'tc-auto-result' }],
|
|
);
|
|
});
|
|
|
|
test('MCP tool authentication is produced while auth is required and removed once resolved', () => {
|
|
setupSession();
|
|
startTurn('turn-1');
|
|
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatToolCallStart,
|
|
turnId: 'turn-1',
|
|
toolCallId: 'tc-mcp',
|
|
toolName: 'get_file',
|
|
displayName: 'Get File',
|
|
contributor: { kind: ToolCallContributorKind.MCP, customizationId: 'mcp-1' },
|
|
});
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatToolCallReady,
|
|
turnId: 'turn-1',
|
|
toolCallId: 'tc-mcp',
|
|
invocationMessage: 'Getting file',
|
|
confirmed: ToolCallConfirmationReason.NotNeeded,
|
|
});
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatToolCallAuthRequired,
|
|
turnId: 'turn-1',
|
|
toolCallId: 'tc-mcp',
|
|
auth: {
|
|
reason: McpAuthRequiredReason.InsufficientScope,
|
|
resource: {
|
|
resource: 'https://mcp.example.com',
|
|
authorization_servers: ['https://auth.example.com'],
|
|
},
|
|
requiredScopes: ['repo'],
|
|
},
|
|
});
|
|
|
|
const pending = sessionInputNeeded();
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatToolCallComplete,
|
|
turnId: 'turn-1',
|
|
toolCallId: 'tc-mcp',
|
|
result: {
|
|
success: false,
|
|
pastTenseMessage: 'Cancelled tool call',
|
|
error: { message: 'MCP authentication was cancelled', code: 'cancelled' },
|
|
},
|
|
});
|
|
|
|
assert.deepStrictEqual({
|
|
pending: pending.map(request => ({
|
|
kind: request.kind,
|
|
chat: request.chat,
|
|
toolCallId: request.kind === SessionInputRequestKind.ToolAuthentication ? request.toolCall.toolCallId : undefined,
|
|
})),
|
|
resolved: sessionInputNeeded(),
|
|
}, {
|
|
pending: [{
|
|
kind: SessionInputRequestKind.ToolAuthentication,
|
|
chat: defaultChatUri,
|
|
toolCallId: 'tc-mcp',
|
|
}],
|
|
resolved: [],
|
|
});
|
|
});
|
|
|
|
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('managed approval bypasses global, session, and per-tool auto-approval', async () => {
|
|
setupSession();
|
|
stateManager.dispatchServerAction(ROOT_STATE_URI, {
|
|
type: ActionType.RootConfigChanged,
|
|
config: { [AgentHostGlobalAutoApproveEnabledConfigKey]: true },
|
|
});
|
|
stateManager.setSessionConfig(sessionUri.toString(), {
|
|
schema: { type: 'object', properties: {} },
|
|
values: {
|
|
[SessionConfigKey.AutoApprove]: 'autoApprove',
|
|
[SessionConfigKey.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-managed', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined,
|
|
},
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
|
|
state: {
|
|
status: ToolCallStatus.PendingConfirmation,
|
|
toolCallId: 'tc-managed', toolName: 'CustomTool', displayName: 'Custom Tool',
|
|
invocationMessage: 'Run managed custom tool', toolInput: undefined,
|
|
confirmationTitle: 'Run managed custom tool', edits: undefined,
|
|
},
|
|
permissionKind: 'custom-tool',
|
|
managedApprovalRequired: true,
|
|
});
|
|
|
|
const toolCall = await waitForState(stateManager, () => {
|
|
const part = stateManager.getSessionState(sessionUri.toString())?.activeTurn?.responseParts.find(
|
|
responsePart => responsePart.kind === ResponsePartKind.ToolCall && responsePart.toolCall.toolCallId === 'tc-managed'
|
|
);
|
|
return part?.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.PendingConfirmation
|
|
? part.toolCall
|
|
: undefined;
|
|
});
|
|
|
|
assert.deepStrictEqual({
|
|
status: toolCall.status,
|
|
options: toolCall.options?.map(option => option.id),
|
|
responses: agent.respondToPermissionCalls,
|
|
}, {
|
|
status: ToolCallStatus.PendingConfirmation,
|
|
options: ['allow-once', 'skip'],
|
|
responses: [],
|
|
});
|
|
});
|
|
|
|
test('managed approval does not persist allow-session from the client', async () => {
|
|
setupSession();
|
|
stateManager.setSessionConfig(sessionUri.toString(), {
|
|
schema: { type: 'object', properties: {} },
|
|
values: { permissions: { allow: ['ExistingTool'], 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-managed', toolName: 'ManagedTool', displayName: 'Managed Tool', contributor: undefined,
|
|
},
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'pending_confirmation', chat: URI.parse(defaultChatUri),
|
|
state: {
|
|
status: ToolCallStatus.PendingConfirmation,
|
|
toolCallId: 'tc-managed', toolName: 'ManagedTool', displayName: 'Managed Tool',
|
|
invocationMessage: 'Run managed tool', toolInput: undefined,
|
|
confirmationTitle: 'Run managed tool', edits: undefined,
|
|
},
|
|
permissionKind: 'custom-tool',
|
|
managedApprovalRequired: true,
|
|
});
|
|
|
|
await waitForState(stateManager, () => {
|
|
const part = stateManager.getSessionState(sessionUri.toString())?.activeTurn?.responseParts.find(
|
|
responsePart => responsePart.kind === ResponsePartKind.ToolCall && responsePart.toolCall.toolCallId === 'tc-managed'
|
|
);
|
|
return part?.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.PendingConfirmation;
|
|
});
|
|
sideEffects.handleAction(defaultChatUri, {
|
|
type: ActionType.ChatToolCallConfirmed,
|
|
turnId: 'turn-1',
|
|
toolCallId: 'tc-managed',
|
|
approved: true,
|
|
confirmed: 'user-action',
|
|
selectedOptionId: 'allow-session',
|
|
} as ChatAction);
|
|
|
|
assert.deepStrictEqual(agent.respondToPermissionCalls, [
|
|
{ requestId: 'tc-managed', approved: true },
|
|
]);
|
|
assert.deepStrictEqual(
|
|
stateManager.getSessionState(sessionUri.toString())?.config?.values[SessionConfigKey.Permissions],
|
|
{ allow: ['ExistingTool'], deny: [] },
|
|
);
|
|
});
|
|
|
|
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('stale tool completion does not attribute edits to the active turn', () => {
|
|
setupSession();
|
|
startTurn('turn-1');
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatTurnCancelled,
|
|
turnId: 'turn-1',
|
|
duration: 1000,
|
|
});
|
|
stateManager.dispatchServerAction(defaultChatUri, {
|
|
type: ActionType.ChatTurnStarted,
|
|
turnId: 'turn-2',
|
|
startedAt: '2025-01-01T00:01:00.000Z',
|
|
message: { text: 'continue', origin: { kind: MessageKind.User } },
|
|
});
|
|
|
|
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.ChatToolCallComplete, turnId: 'turn-1',
|
|
toolCallId: 'stale-tool',
|
|
result: {
|
|
success: true,
|
|
pastTenseMessage: 'Wrote file',
|
|
content: [{
|
|
type: ToolResultContentType.FileEdit,
|
|
after: { uri: 'file:///wd/a.ts', content: { uri: 'file:///wd/a.ts' } },
|
|
diff: { added: 1, removed: 0 }
|
|
}]
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.deepStrictEqual({
|
|
toolCallEdits: changesets.toolCallEdits,
|
|
activeTurnId: stateManager.getSessionState(defaultChatUri)?.activeTurn?.id,
|
|
}, {
|
|
toolCallEdits: [],
|
|
activeTurnId: 'turn-2',
|
|
});
|
|
});
|
|
|
|
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('turn complete passes the resolved working directories to the checkpoint capture', async () => {
|
|
const workingDirectory = URI.file('/wd').toString();
|
|
setupSession(workingDirectory);
|
|
startTurn('turn-1');
|
|
|
|
const captures: { turnId: string; workingDirectories: readonly string[] | undefined }[] = [];
|
|
const checkpoints: IAgentHostCheckpointService = {
|
|
...NULL_CHECKPOINT_SERVICE,
|
|
captureTurnCheckpoint: async (_session, _chat, turnId, workingDirectories) => {
|
|
captures.push({ turnId, workingDirectories: workingDirectories?.map(w => w.toString()) });
|
|
},
|
|
};
|
|
const localSideEffects = createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService: createNullSessionDataService(),
|
|
onTurnComplete: () => { },
|
|
}, undefined, NullTelemetryService, new FakeChangesetService(), undefined, checkpoints);
|
|
disposables.add(localSideEffects.registerProgressListener(agent));
|
|
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 },
|
|
});
|
|
await Promise.resolve();
|
|
|
|
assert.deepStrictEqual(captures, [{ turnId: 'turn-1', workingDirectories: [workingDirectory] }]);
|
|
});
|
|
|
|
test('provider error keeps the turn start until completion capture', async () => {
|
|
setupSession();
|
|
startTurn('turn-1');
|
|
const captured = new DeferredPromise<void>();
|
|
let discardCount = 0;
|
|
const checkpoints: IAgentHostCheckpointService = {
|
|
...NULL_CHECKPOINT_SERVICE,
|
|
captureTurnCheckpoint: async () => { captured.complete(); },
|
|
discardTurnStartCheckpoint: async () => { discardCount++; },
|
|
};
|
|
const localSideEffects = createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService: createNullSessionDataService(),
|
|
onTurnComplete: () => { },
|
|
}, undefined, NullTelemetryService, new FakeChangesetService(), undefined, checkpoints);
|
|
disposables.add(localSideEffects.registerProgressListener(agent));
|
|
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 100, error: { errorType: 'test', message: 'failed' } },
|
|
});
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 100 },
|
|
});
|
|
await captured.p;
|
|
|
|
assert.strictEqual(discardCount, 0);
|
|
});
|
|
|
|
test('terminal provider error captures the end checkpoint without completion', async () => {
|
|
setupSession();
|
|
startTurn('turn-1');
|
|
const captured = new DeferredPromise<void>();
|
|
const checkpoints: IAgentHostCheckpointService = {
|
|
...NULL_CHECKPOINT_SERVICE,
|
|
captureTurnCheckpoint: async () => { captured.complete(); },
|
|
};
|
|
const localSideEffects = createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService: createNullSessionDataService(),
|
|
onTurnComplete: () => { },
|
|
}, undefined, NullTelemetryService, new FakeChangesetService(), undefined, checkpoints);
|
|
disposables.add(localSideEffects.registerProgressListener(agent));
|
|
|
|
agent.fireProgress({
|
|
kind: 'action', resource: URI.parse(defaultChatUri),
|
|
action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 100, error: { errorType: 'terminal', message: 'failed' } },
|
|
});
|
|
|
|
await captured.p;
|
|
});
|
|
|
|
test('chat truncation discards pending turn starts for that chat', async () => {
|
|
setupSession();
|
|
const discarded = new DeferredPromise<void>();
|
|
const checkpoints: IAgentHostCheckpointService = {
|
|
...NULL_CHECKPOINT_SERVICE,
|
|
discardChatTurnStartCheckpoints: async (session, chat) => {
|
|
assert.deepStrictEqual({ session: session.toString(), chat: chat.toString() }, {
|
|
session: sessionUri.toString(),
|
|
chat: defaultChatUri,
|
|
});
|
|
discarded.complete();
|
|
},
|
|
};
|
|
const localSideEffects = createTestSideEffects(disposables, stateManager, {
|
|
getAgent: () => agent,
|
|
agents: agentList,
|
|
sessionDataService: createNullSessionDataService(),
|
|
onTurnComplete: () => { },
|
|
}, undefined, NullTelemetryService, new FakeChangesetService(), undefined, checkpoints);
|
|
|
|
localSideEffects.handleAction(defaultChatUri, {
|
|
type: ActionType.ChatTruncated,
|
|
turnId: undefined,
|
|
});
|
|
await discarded.p;
|
|
});
|
|
|
|
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.truncateChatCalls.at(-1);
|
|
|
|
// Default chat: forwarded as the session's default chat URI.
|
|
sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'turn-default' });
|
|
const defaultCall = agent.truncateChatCalls.at(-1);
|
|
|
|
assert.deepStrictEqual({
|
|
peerTurnId: peerCall?.turnId,
|
|
peerChat: peerCall?.chat.toString(),
|
|
defaultTurnId: defaultCall?.turnId,
|
|
defaultChat: defaultCall?.chat.toString(),
|
|
}, {
|
|
peerTurnId: 'turn-peer',
|
|
peerChat: peerChatUri,
|
|
defaultTurnId: 'turn-default',
|
|
defaultChat: defaultChatUri,
|
|
});
|
|
});
|
|
});
|
|
|
|
});
|