Files
vscode/src/vs/platform/agentHost/test/node/agentService.test.ts
T
dce00340cb Support multi-root Agent Host session metadata (#328863)
* Support multi-root Agent Host session metadata

Persist Editor workspace provenance on Agent Host sessions and use it for authoritative multi-root session filtering. Remove the temporary workspace membership memento.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address multi-root session review feedback

Apply workspace-file filtering across folder-count transitions, sanitize provider metadata before database fallbacks, and exercise cache deserialization in tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 23:52:54 +00:00

6046 lines
282 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 type Anthropic from '@anthropic-ai/sdk';
import type { CCAModel } from '@vscode/copilot-api';
import { mkdtempSync, readFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { fileURLToPath } from 'url';
import { DeferredPromise, timeout } from '../../../../base/common/async.js';
import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { DisposableStore, IReference, toDisposable } from '../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../base/common/network.js';
import { joinPath } from '../../../../base/common/resources.js';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';
import { hasKey } from '../../../../base/common/types.js';
import { NullLogService } from '../../../log/common/log.js';
import { FileService } from '../../../files/common/fileService.js';
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, IConnectionTrackerService, IRestoredSubagentSession, SubagentChatSignal, type IAgent, type IAgentChatDataChange, type IAgentChats, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentLegacyChat, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agentService.js';
import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
import { SessionDatabase } from '../../node/sessionDatabase.js';
import { ActionType, ActionEnvelope } from '../../common/state/sessionActions.js';
import { ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionWithDefaultChat, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js';
import { type MessageResourceAttachment } from '../../common/state/protocol/state.js';
import { IProductService } from '../../../product/common/productService.js';
import { AgentService } from '../../node/agentService.js';
import { AgentHostManagementService } from '../../node/agentHostManagementService.js';
import { MockAgent, ScriptedMockAgent } from './mockAgent.js';
import { mapSessionEventsToHistoryRecords } from './historyRecordFixtures.js';
import { type ISessionEvent } from './copilotTestEvents.js';
import { createNoopGitService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js';
import { buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js';
import { type ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js';
import { getWorktreesRoot, WorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT } from '../../node/shared/worktreeIsolation.js';
import { AhpErrorCodes, JSON_RPC_INTERNAL_ERROR, ProtocolError } from '../../common/state/sessionProtocol.js';
import type { INetworkDiagnosticsService } from '../../node/networkDiagnosticsService.js';
/**
* Loads a JSONL fixture of raw Copilot SDK events, runs them through
* {@link mapSessionEventsToHistoryRecords}, and returns the result
* suitable for setting on {@link MockAgent.sessionMessages}. Tests the
* full pipeline: SDK events → IHistoryRecord → buildTurnsFromHistory →
* Turn[].
*
* Fixture files live in `test-cases/` and are sanitized copies of real
* `events.jsonl` files from `~/.copilot/session-state/`.
*/
async function loadFixtureMessages(fixtureName: string, session: URI) {
// Resolve the fixture from the source tree (test-cases/ is not compiled to out/)
const thisFile = fileURLToPath(import.meta.url);
// Navigate from out/vs/... to src/vs/... by replacing the out/ prefix.
// Use a regex that handles both / and \ separators for Windows compat.
const srcFile = thisFile.replace(/[/\\]out[/\\]/, (m) => m.replace('out', 'src'));
const lastSep = Math.max(srcFile.lastIndexOf('/'), srcFile.lastIndexOf('\\'));
const fixtureDir = srcFile.substring(0, lastSep);
const sep = srcFile.includes('\\') ? '\\' : '/';
const raw = readFileSync(`${fixtureDir}${sep}test-cases${sep}${fixtureName}`, 'utf-8');
const events: ISessionEvent[] = raw.trim().split('\n').map(line => JSON.parse(line));
return mapSessionEventsToHistoryRecords(session, undefined, events);
}
class TestCopilotApiService implements ICopilotApiService {
declare readonly _serviceBrand: undefined;
readonly utilityCalls: { token: string; request: ICopilotUtilityChatCompletionRequest; options?: ICopilotApiServiceRequestOptions }[] = [];
response = 'Generated session title';
responsePromise: Promise<string> | undefined;
error: Error | undefined;
messages(_githubToken: string, _request: Anthropic.MessageCreateParamsStreaming, _options?: ICopilotApiServiceRequestOptions): AsyncGenerator<Anthropic.MessageStreamEvent>;
messages(_githubToken: string, _request: Anthropic.MessageCreateParamsNonStreaming, _options?: ICopilotApiServiceRequestOptions): Promise<Anthropic.Message>;
messages(): AsyncGenerator<Anthropic.MessageStreamEvent> | Promise<Anthropic.Message> {
throw new Error('not used');
}
async countTokens(): Promise<Anthropic.MessageTokensCount> { throw new Error('not used'); }
async models(): Promise<CCAModel[]> { return []; }
async responses(): Promise<Response> { throw new Error('not used'); }
async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; }
async resolveApiEndpoint() { return undefined; }
async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise<string> {
this.utilityCalls.push({ token: githubToken, request, options });
if (this.error) {
throw this.error;
}
if (this.responsePromise) {
return this.responsePromise;
}
return this.response;
}
}
suite('AgentService (node dispatcher)', () => {
const disposables = new DisposableStore();
let service: AgentService;
let copilotAgent: MockAgent;
let fileService: FileService;
let nullSessionDataService: ISessionDataService;
setup(async () => {
nullSessionDataService = {
_serviceBrand: undefined,
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
getSessionDataDirById: () => URI.parse('inmemory:/session-data'),
openDatabase: () => { throw new Error('not implemented'); },
tryOpenDatabase: async () => undefined,
deleteSessionData: async () => { },
onWillDeleteSessionData: Event.None,
cleanupOrphanedData: async () => { },
whenIdle: async () => { },
};
fileService = disposables.add(new FileService(new NullLogService()));
disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider())));
// Seed a directory for browseDirectory tests
await fileService.createFolder(URI.from({ scheme: Schemas.inMemory, path: '/testDir' }));
await fileService.writeFile(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }), VSBuffer.fromString('hello'));
service = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
copilotAgent = new MockAgent('copilot');
disposables.add(toDisposable(() => copilotAgent.dispose()));
});
teardown(() => disposables.clear());
ensureNoDisposablesAreLeakedInTestSuite();
// ---- Provider registration ------------------------------------------
suite('registerProvider', () => {
test('registers a provider successfully', () => {
service.registerProvider(copilotAgent);
// No throw - success
});
test('throws on duplicate provider registration', () => {
service.registerProvider(copilotAgent);
const duplicate = new MockAgent('copilot');
disposables.add(toDisposable(() => duplicate.dispose()));
assert.throws(() => service.registerProvider(duplicate), /already registered/);
});
test('aggregates and deduplicates network diagnostics endpoints', async () => {
const providerA: IAgent = copilotAgent;
providerA.getNetworkDiagnosticsEndpoints = async () => [
{ name: 'First', url: 'https://example.com' },
{ name: 'Other', url: 'https://other.example.com' },
];
providerA.getNetworkDiagnosticsAccount = async () => 'octocat';
const providerB = new MockAgent('other');
disposables.add(toDisposable(() => providerB.dispose()));
const providerBContract: IAgent = providerB;
providerBContract.getNetworkDiagnosticsEndpoints = async () => [
{ name: 'Duplicate', url: 'https://example.com/' },
];
const failingProvider = new MockAgent('failing');
disposables.add(toDisposable(() => failingProvider.dispose()));
const failingProviderContract: IAgent = failingProvider;
failingProviderContract.getNetworkDiagnosticsEndpoints = async () => { throw new Error('unavailable'); };
const diagnostics: INetworkDiagnosticsService = {
_serviceBrand: undefined,
getInfo: async (endpoints, account) => ({ version: 'test', os: 'test', arch: 'test', account, proxySettings: {}, proxyEnv: {}, endpoints }),
fetch: async url => ({ url }),
};
service.setNetworkDiagnosticsService(diagnostics);
service.registerProvider(providerA);
service.registerProvider(providerB);
service.registerProvider(failingProvider);
const info = await service.getNetworkDiagnosticsInfo();
assert.deepStrictEqual({ account: info.account, endpoints: info.endpoints }, {
account: 'octocat',
endpoints: [
{ name: 'First', url: 'https://example.com' },
{ name: 'Other', url: 'https://other.example.com' },
],
});
});
test('aggregates managed-settings diagnostics from capable providers', async () => {
const provider: IAgent = copilotAgent;
provider.getManagedSettingsDiagnostics = async () => ({
source: 'device',
serverManaged: false,
deviceManaged: true,
failClosed: false,
bypassPermissionsDisabled: false,
managedKeys: ['permissions'],
settings: { permissions: { allow: ['Shell(echo *)'] } },
});
const unsupportedProvider = new MockAgent('other');
disposables.add(toDisposable(() => unsupportedProvider.dispose()));
const failingProvider = new MockAgent('failing');
disposables.add(toDisposable(() => failingProvider.dispose()));
const failingProviderContract: IAgent = failingProvider;
failingProviderContract.getManagedSettingsDiagnostics = async () => { throw new Error('unavailable'); };
service.registerProvider(provider);
service.registerProvider(unsupportedProvider);
service.registerProvider(failingProvider);
const diagnostics = await service.getManagedSettingsDiagnostics();
assert.deepStrictEqual(diagnostics, [
{
provider: 'copilot',
snapshot: {
source: 'device',
serverManaged: false,
deviceManaged: true,
failClosed: false,
bypassPermissionsDisabled: false,
managedKeys: ['permissions'],
settings: { permissions: { allow: ['Shell(echo *)'] } },
},
},
{ provider: 'failing', error: 'unavailable' },
]);
});
test('forwards managed-settings diagnostics through the local management service', async () => {
const provider: IAgent = copilotAgent;
provider.getManagedSettingsDiagnostics = async () => ({
source: 'device',
serverManaged: false,
deviceManaged: true,
failClosed: false,
bypassPermissionsDisabled: false,
managedKeys: ['permissions'],
});
service.registerProvider(provider);
const managementService = new AgentHostManagementService(service, {} as IConnectionTrackerService);
assert.deepStrictEqual(await managementService.getManagedSettingsDiagnostics(), [{
provider: 'copilot',
snapshot: {
source: 'device',
serverManaged: false,
deviceManaged: true,
failClosed: false,
bypassPermissionsDisabled: false,
managedKeys: ['permissions'],
},
}]);
});
test('maps progress events to protocol actions via onDidAction', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
// Start a turn so there's an active turn to map events to
service.dispatchAction(
buildDefaultChatUri(session.toString()),
{ type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } } },
'test-client', 1,
);
const envelopes: ActionEnvelope[] = [];
disposables.add(service.onDidAction(e => envelopes.push(e)));
copilotAgent.fireProgress({
kind: 'action', resource: URI.parse(buildDefaultChatUri(session.toString())),
action: { type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'hello' } },
});
assert.ok(envelopes.some(e => e.action.type === ActionType.ChatResponsePart));
});
});
test('resolveSessionConfig echoes host-owned worktree values across isolation modes', async () => {
const workingDirectory = URI.file('/workspace/repo');
const gitService = createNoopGitService();
gitService.getRepositoryRoot = async () => workingDirectory;
gitService.revParse = async () => 'head';
gitService.getCurrentBranch = async () => 'feature';
gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'main' });
const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
localService.setWorktreeIsolation(disposables.add(new WorktreeIsolation(
{ generateBranchName: async () => 'agents/test' },
gitService,
new TestCopilotApiService(),
nullSessionDataService,
new NullLogService(),
)));
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
localService.registerProvider(agent);
const includeFiles = ['.env', '.env.local', 'config/**'];
const worktree = await localService.resolveSessionConfig({
provider: 'copilot',
workingDirectory,
config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'feature', [SessionConfigKey.WorktreeIncludeFiles]: includeFiles },
});
const folder = await localService.resolveSessionConfig({
provider: 'copilot',
workingDirectory,
config: { [SessionConfigKey.Isolation]: 'folder', [SessionConfigKey.WorktreeIncludeFiles]: includeFiles },
});
assert.deepStrictEqual({
worktreeBranch: worktree.values[SessionConfigKey.Branch],
worktreeReadOnly: worktree.schema.properties[SessionConfigKey.WorktreeIncludeFiles]?.readOnly,
worktreeValue: worktree.values[SessionConfigKey.WorktreeIncludeFiles],
folderReadOnly: folder.schema.properties[SessionConfigKey.WorktreeIncludeFiles]?.readOnly,
folderValue: folder.values[SessionConfigKey.WorktreeIncludeFiles],
}, {
worktreeBranch: 'feature',
worktreeReadOnly: true,
worktreeValue: includeFiles,
folderReadOnly: true,
folderValue: includeFiles,
});
});
test('session config keeps host-owned values outside provider calls', async () => {
const workingDirectory = URI.file('/workspace/repo');
const gitService = createNoopGitService();
gitService.getRepositoryRoot = async () => workingDirectory;
gitService.revParse = async () => 'head';
gitService.getCurrentBranch = async () => 'feature';
gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'origin/main' });
const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
localService.setWorktreeIsolation(disposables.add(new WorktreeIsolation(
{ generateBranchName: async () => 'agents/test' },
gitService,
new TestCopilotApiService(),
nullSessionDataService,
new NullLogService(),
)));
const agent = new MockAgent('codex');
const providerResolveConfigs: Array<Record<string, unknown> | undefined> = [];
const providerCompletionConfigs: Array<Record<string, unknown> | undefined> = [];
agent.resolveSessionConfig = async params => {
providerResolveConfigs.push(params.config);
return {
schema: {
type: 'object',
properties: {
[SessionConfigKey.Isolation]: { type: 'string', title: 'Provider Isolation' },
[SessionConfigKey.Branch]: { type: 'string', title: 'Provider Branch' },
providerSetting: { type: 'string', title: 'Provider Setting' },
},
},
values: {
...params.config,
[SessionConfigKey.Isolation]: 'folder',
[SessionConfigKey.Branch]: 'provider-branch',
},
};
};
agent.sessionConfigCompletions = async params => {
providerCompletionConfigs.push(params.config);
return { items: [] };
};
disposables.add(toDisposable(() => agent.dispose()));
localService.registerProvider(agent);
const initial = await localService.resolveSessionConfig({
provider: 'codex',
workingDirectory,
config: { [SessionConfigKey.Isolation]: 'worktree', providerSetting: 'initial' },
});
const selected = await localService.resolveSessionConfig({
provider: 'codex',
workingDirectory,
config: {
[SessionConfigKey.Isolation]: 'worktree',
[SessionConfigKey.Branch]: 'feature/config',
[SessionConfigKey.WorktreeBranchPrefix]: 'users/test/',
[SessionConfigKey.WorktreeIncludeFiles]: ['.env'],
[SessionConfigKey.WorktreeBranchTrack]: false,
providerSetting: 'selected',
},
});
const folder = await localService.resolveSessionConfig({
provider: 'codex',
workingDirectory,
config: { [SessionConfigKey.Isolation]: 'folder', [SessionConfigKey.Branch]: 'feature/config', providerSetting: 'folder' },
});
await localService.sessionConfigCompletions({
provider: 'codex',
workingDirectory,
config: {
[SessionConfigKey.Isolation]: 'worktree',
[SessionConfigKey.Branch]: 'feature/config',
[SessionConfigKey.WorktreeBranchPrefix]: 'users/test/',
[SessionConfigKey.WorktreeIncludeFiles]: ['.env'],
[SessionConfigKey.WorktreeBranchTrack]: false,
providerSetting: 'completion',
},
property: 'providerSetting',
});
assert.deepStrictEqual({
providerResolveConfigs,
providerCompletionConfigs,
initial: {
isolation: initial.values[SessionConfigKey.Isolation],
branchDefault: initial.schema.properties[SessionConfigKey.Branch]?.default,
branch: initial.values[SessionConfigKey.Branch],
providerSetting: initial.values.providerSetting,
},
selected: {
isolation: selected.values[SessionConfigKey.Isolation],
branch: selected.values[SessionConfigKey.Branch],
branchPrefix: selected.values[SessionConfigKey.WorktreeBranchPrefix],
includeFiles: selected.values[SessionConfigKey.WorktreeIncludeFiles],
branchTrack: selected.values[SessionConfigKey.WorktreeBranchTrack],
providerSetting: selected.values.providerSetting,
},
folder: {
isolation: folder.values[SessionConfigKey.Isolation],
branch: folder.values[SessionConfigKey.Branch],
providerSetting: folder.values.providerSetting,
},
}, {
providerResolveConfigs: [
{ providerSetting: 'initial' },
{ providerSetting: 'selected' },
{ providerSetting: 'folder' },
],
providerCompletionConfigs: [{ providerSetting: 'completion' }],
initial: { isolation: 'worktree', branchDefault: 'main', branch: 'main', providerSetting: 'initial' },
selected: { isolation: 'worktree', branch: 'feature/config', branchPrefix: 'users/test/', includeFiles: ['.env'], branchTrack: false, providerSetting: 'selected' },
folder: { isolation: 'folder', branch: 'feature', providerSetting: 'folder' },
});
});
test('marks worktree isolation pending before a provisional provider can prewarm', async () => {
const session = AgentSession.uri('codex', 'pending-before-create');
const workingDirectory = URI.file('/workspace/repo');
const gitService = createNoopGitService();
gitService.getRepositoryRoot = async () => workingDirectory;
gitService.revParse = async () => 'head';
gitService.getCurrentBranch = async () => 'feature';
gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'main' });
const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
const isolation = disposables.add(new WorktreeIsolation(
{ generateBranchName: async () => 'agents/test' },
gitService,
new TestCopilotApiService(),
nullSessionDataService,
new NullLogService(),
));
localService.setWorktreeIsolation(isolation);
const pendingDuringCreate: boolean[] = [];
const providerCreateConfigs: Array<Record<string, unknown> | undefined> = [];
let failCreate = false;
class PrewarmingAgent extends MockAgent {
override async createSession(config?: import('../../common/agentService.js').IAgentCreateSessionConfig): Promise<import('../../common/agentService.js').IAgentCreateSessionResult> {
pendingDuringCreate.push(localService.configurationService.isWorkingDirectoryPending(config!.session!.toString()));
providerCreateConfigs.push(config?.config);
if (failCreate) {
throw new Error('create failed');
}
return { ...await super.createSession(config), provisional: true };
}
}
const agent = new PrewarmingAgent('codex');
disposables.add(toDisposable(() => agent.dispose()));
localService.registerProvider(agent);
await localService.createSession({
provider: 'codex',
session,
workingDirectories: workingDirectory ? [workingDirectory] : undefined,
config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' },
});
const failedSession = AgentSession.uri('codex', 'failed-before-create');
failCreate = true;
await assert.rejects(localService.createSession({
provider: 'codex',
session: failedSession,
workingDirectories: workingDirectory ? [workingDirectory] : undefined,
config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' },
}), /create failed/);
assert.deepStrictEqual({
pendingDuringCreate,
providerCreateConfigs,
pendingAfterCreate: localService.configurationService.isWorkingDirectoryPending(session.toString()),
pendingAfterFailure: localService.configurationService.isWorkingDirectoryPending(failedSession.toString()),
}, {
pendingDuringCreate: [true, true],
providerCreateConfigs: [{}, {}],
pendingAfterCreate: true,
pendingAfterFailure: false,
});
});
test('createSession validates, exposes, persists, and inherits multi-root metadata', async () => {
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
localService.registerProvider(agent);
const multiRoot = {
workspaceFile: 'vscode-remote://ssh-remote+host/work/demo.code-workspace',
name: 'Demo Workspace',
};
const session = await localService.createSession({
provider: agent.id,
workingDirectories: [URI.file('/workspace/one'), URI.file('/workspace/two')],
_meta: { multiRoot, ignored: 'client value' },
});
const sourceChat = buildDefaultChatUri(session.toString());
localService.dispatchAction(sourceChat, {
type: ActionType.ChatTurnStarted,
turnId: 'source-turn',
startedAt: new Date().toISOString(),
message: { text: 'hello', origin: { kind: MessageKind.User } },
}, 'test-client', 1);
localService.dispatchAction(sourceChat, {
type: ActionType.ChatTurnComplete,
turnId: 'source-turn',
duration: 0,
}, 'test-client', 2);
const inherited = await localService.createSession({
provider: agent.id,
_meta: { multiRoot: { workspaceFile: 'relative.code-workspace' } },
fork: { session, turnIndex: 0, turnId: 'source-turn' },
});
const override = {
workspaceFile: 'file:///work/override.code-workspace',
name: 'Override',
};
const overridden = await localService.createSession({
provider: agent.id,
_meta: { multiRoot: override },
fork: { session, turnIndex: 0, turnId: 'source-turn' },
});
assert.deepStrictEqual({
state: localService.stateManager.getSessionState(session.toString())?._meta,
persisted: await db.getMetadata(SESSION_META_MULTI_ROOT_KEY),
inherited: readSessionMultiRootMetadata(localService.stateManager.getSessionState(inherited.toString())?._meta),
overridden: readSessionMultiRootMetadata(localService.stateManager.getSessionState(overridden.toString())?._meta),
}, {
state: { multiRoot },
persisted: JSON.stringify(override),
inherited: multiRoot,
overridden: override,
});
});
test('provisional materialization preserves and persists multi-root metadata', async () => {
class ProvisionalAgent extends MockAgent {
private readonly _onDidMaterialize = new Emitter<{ session: URI; workingDirectories: readonly URI[] | undefined; project: undefined }>();
readonly onDidMaterializeSession = this._onDidMaterialize.event;
override async createSession(config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult> {
return { ...await super.createSession(config), provisional: true };
}
materialize(session: URI, workingDirectories: readonly URI[]): void {
this._onDidMaterialize.fire({ session, workingDirectories, project: undefined });
}
override dispose(): void {
this._onDidMaterialize.dispose();
super.dispose();
}
}
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = new ProvisionalAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
localService.registerProvider(agent);
const multiRoot = {
workspaceFile: 'file:///work/demo.code-workspace',
name: 'Demo Workspace',
};
const session = await localService.createSession({
provider: agent.id,
workingDirectories: [URI.file('/work/one'), URI.file('/work/two')],
_meta: { multiRoot },
});
const before = readSessionMultiRootMetadata(localService.stateManager.getSessionState(session.toString())?._meta);
const persistedBefore = await db.getMetadata(SESSION_META_MULTI_ROOT_KEY);
agent.materialize(session, [URI.file('/work/materialized'), URI.file('/work/two')]);
assert.deepStrictEqual({
before,
persistedBefore,
after: readSessionMultiRootMetadata(localService.stateManager.getSessionState(session.toString())?._meta),
persistedAfter: await db.getMetadata(SESSION_META_MULTI_ROOT_KEY),
}, {
before: multiRoot,
persistedBefore: undefined,
after: multiRoot,
persistedAfter: JSON.stringify(multiRoot),
});
});
test('reconciles pending worktree isolation when creating session config changes', async () => {
const gitService = createNoopGitService();
const sessionDataService = createSessionDataService(new TestSessionDatabase());
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
const isolation = disposables.add(new WorktreeIsolation(
{ generateBranchName: async () => 'agents/test' },
gitService,
new TestCopilotApiService(),
sessionDataService,
new NullLogService(),
));
localService.setWorktreeIsolation(isolation);
class ProvisionalAgent extends MockAgent {
override async createSession(config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult> {
return { ...await super.createSession(config), provisional: true };
}
}
const provisionalAgent = new ProvisionalAgent('codex');
const readyAgent = new MockAgent('copilot');
disposables.add(toDisposable(() => provisionalAgent.dispose()));
disposables.add(toDisposable(() => readyAgent.dispose()));
localService.registerProvider(provisionalAgent);
localService.registerProvider(readyAgent);
const creatingSession = await localService.createSession({
provider: 'codex',
workingDirectories: [URI.file('/workspace/repo')],
config: { [SessionConfigKey.Isolation]: 'folder' },
});
const readySession = await localService.createSession({
provider: 'copilot',
workingDirectories: [URI.file('/workspace/repo')],
config: { [SessionConfigKey.Isolation]: 'folder' },
});
const creatingInitially = localService.configurationService.isWorkingDirectoryPending(creatingSession.toString());
const readyInitially = localService.configurationService.isWorkingDirectoryPending(readySession.toString());
const creatingLifecycle = localService.stateManager.getSessionState(creatingSession.toString())?.lifecycle;
const readyLifecycle = localService.stateManager.getSessionState(readySession.toString())?.lifecycle;
localService.dispatchAction(creatingSession.toString(), {
type: ActionType.SessionConfigChanged,
config: { [SessionConfigKey.Isolation]: 'worktree' },
}, 'test-client', 1);
const creatingAfterWorktree = localService.configurationService.isWorkingDirectoryPending(creatingSession.toString());
localService.dispatchAction(creatingSession.toString(), {
type: ActionType.SessionConfigChanged,
config: { [SessionConfigKey.Isolation]: 'folder' },
}, 'test-client', 2);
const creatingAfterFolder = localService.configurationService.isWorkingDirectoryPending(creatingSession.toString());
localService.dispatchAction(readySession.toString(), {
type: ActionType.SessionConfigChanged,
config: { [SessionConfigKey.Isolation]: 'worktree' },
}, 'test-client', 3);
const readyAfterWorktree = localService.configurationService.isWorkingDirectoryPending(readySession.toString());
assert.deepStrictEqual({
creatingInitially,
readyInitially,
creatingLifecycle,
readyLifecycle,
creatingAfterWorktree,
creatingAfterFolder,
readyAfterWorktree,
}, {
creatingInitially: false,
readyInitially: false,
creatingLifecycle: SessionLifecycle.Creating,
readyLifecycle: SessionLifecycle.Ready,
creatingAfterWorktree: true,
creatingAfterFolder: false,
readyAfterWorktree: false,
});
});
suite('resourceRead', () => {
test('maps missing files to NotFound', async () => {
const uri = URI.from({ scheme: Schemas.inMemory, path: '/missing.txt' });
await assert.rejects(
() => service.resourceRead(uri),
(error: unknown) => error instanceof ProtocolError
&& error.code === AhpErrorCodes.NotFound
&& error.message === `Content not found: ${uri.toString()}`
);
});
test('does not map all read failures to NotFound', async () => {
const uri = URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' });
const originalReadFile = fileService.readFile.bind(fileService);
fileService.readFile = async resource => {
if (resource.toString() === uri.toString()) {
return Promise.reject('Injected unknown read failure');
}
return originalReadFile(resource);
};
disposables.add(toDisposable(() => fileService.readFile = originalReadFile));
await assert.rejects(
() => service.resourceRead(uri),
(error: unknown) => error instanceof ProtocolError
&& error.code === JSON_RPC_INTERNAL_ERROR
&& error.message === `Failed to read content: ${uri.toString()}: Injected unknown read failure`
);
});
});
// ---- createSession --------------------------------------------------
suite('dispatchAction', () => {
async function waitForCondition(predicate: () => boolean | Promise<boolean>, message: string): Promise<void> {
for (let i = 0; i < 20; i++) {
if (await predicate()) {
return;
}
await new Promise(resolve => setTimeout(resolve, 5));
}
assert.ok(await predicate(), message);
}
async function setupTitleGeneration(copilotApiService: TestCopilotApiService): Promise<{ svc: AgentService; agent: MockAgent; session: URI; db: TestSessionDatabase }> {
const db = new TestSessionDatabase();
const sessionDataService = createSessionDataService(db);
const svc = disposables.add(new AgentService(
new NullLogService(),
fileService,
sessionDataService,
{ _serviceBrand: undefined } as IProductService,
createNoopGitService(),
undefined,
undefined,
undefined,
copilotApiService,
));
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
svc.registerProvider(agent);
await svc.authenticate({
resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource,
scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported,
token: 'gh-token',
});
const session = await svc.createSession({ provider: 'copilot' });
return { svc, agent, session, db };
}
test('applies and persists root config changes from clients', async () => {
const tempDir = URI.file(mkdtempSync(`${tmpdir()}/agent-host-config-`));
// Use a local DisposableStore so that svc can be explicitly disposed
// before cleaning up the temp directory. On Windows, rmSync fails with
// EPERM if the AgentService (and its child AgentConfigurationService)
// still holds references while the directory is being deleted.
const localDisposables = new DisposableStore();
try {
const rootConfigResource = joinPath(tempDir, 'agent-host-config.json');
const svc = localDisposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), rootConfigResource));
const agent = new MockAgent('copilot');
localDisposables.add(toDisposable(() => agent.dispose()));
svc.registerProvider(agent);
const customization = { uri: 'file:///plugin-a', displayName: 'Plugin A' };
svc.dispatchAction(ROOT_STATE_URI, {
type: ActionType.RootConfigChanged,
config: { customizations: [customization] },
}, 'test-client', 1);
let persisted = false;
for (let attempt = 0; attempt < 20; attempt++) {
try {
const parsed = JSON.parse(readFileSync(rootConfigResource.fsPath, 'utf8'));
assert.deepStrictEqual(
parsed.customizations,
[customization],
);
persisted = true;
break;
} catch {
// Wait for the serialized root-config write to complete.
}
if (attempt === 19) {
break;
}
await new Promise(resolve => setTimeout(resolve, 5));
}
assert.ok(persisted, 'should persist the root config change');
// Drain any in-flight root-config write so its file handle is
// closed before we delete the temp directory.
await svc.configurationService.whenIdle();
} finally {
localDisposables.dispose();
rmSync(tempDir.fsPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
}
});
test('generates and persists an AI title after first-turn fallback title', async () => {
const copilotApiService = new TestCopilotApiService();
copilotApiService.response = '"Fix TypeScript compile errors."';
const { svc, session, db } = await setupTitleGeneration(copilotApiService);
const titleActions: string[] = [];
disposables.add(svc.onDidAction(e => {
if (e.action.type === ActionType.SessionTitleChanged) {
titleActions.push(e.action.title);
}
}));
svc.dispatchAction(
buildDefaultChatUri(session.toString()),
{ type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'Please help me fix the TypeScript compile errors', origin: { kind: MessageKind.User } } },
'test-client', 1,
);
await waitForCondition(() => svc.stateManager.getSessionState(session.toString())?.title === 'Fix TypeScript compile errors', 'generated title should be applied');
await waitForCondition(async () => await db.getMetadata('customTitle') !== undefined, 'generated title should be persisted');
assert.deepStrictEqual({
titles: titleActions,
token: copilotApiService.utilityCalls[0]?.token,
promptIncludesUserText: copilotApiService.utilityCalls[0]?.request.messages.some(message => message.content.includes('Please help me fix the TypeScript compile errors')),
persistedTitle: await db.getMetadata('customTitle'),
}, {
titles: ['Please help me fix the TypeScript compile errors', 'Fix TypeScript compile errors'],
token: 'gh-token',
promptIncludesUserText: true,
persistedTitle: 'Fix TypeScript compile errors',
});
});
test('leaves fallback title when AI title generation fails', async () => {
const copilotApiService = new TestCopilotApiService();
copilotApiService.error = new Error('title failed');
const { svc, session, db } = await setupTitleGeneration(copilotApiService);
svc.dispatchAction(
buildDefaultChatUri(session.toString()),
{ type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'Explain workspace search indexing', origin: { kind: MessageKind.User } } },
'test-client', 1,
);
await waitForCondition(() => copilotApiService.utilityCalls.length === 1, 'title generation should be attempted');
await Promise.resolve();
assert.deepStrictEqual({
title: svc.stateManager.getSessionState(session.toString())?.title,
persistedTitle: await db.getMetadata('customTitle'),
}, {
title: 'Explain workspace search indexing',
persistedTitle: undefined,
});
});
test('does not overwrite a manual rename with delayed AI title', async () => {
const copilotApiService = new TestCopilotApiService();
let resolveTitle!: (title: string) => void;
copilotApiService.responsePromise = new Promise(resolve => { resolveTitle = resolve; });
const { svc, session, db } = await setupTitleGeneration(copilotApiService);
svc.dispatchAction(
buildDefaultChatUri(session.toString()),
{ type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'Create tests for terminal persistence', origin: { kind: MessageKind.User } } },
'test-client', 1,
);
await waitForCondition(() => copilotApiService.utilityCalls.length === 1, 'title generation should be in flight');
svc.dispatchAction(
session.toString(),
{ type: ActionType.SessionTitleChanged, title: 'Manual title' },
'test-client', 2,
);
resolveTitle('Terminal persistence tests');
await waitForCondition(async () => await db.getMetadata('customTitle') === 'Manual title', 'manual title should be persisted');
assert.deepStrictEqual({
title: svc.stateManager.getSessionState(session.toString())?.title,
persistedTitle: await db.getMetadata('customTitle'),
}, {
title: 'Manual title',
persistedTitle: 'Manual title',
});
});
test('aborts pending AI title generation when session is disposed', async () => {
const copilotApiService = new TestCopilotApiService();
let resolveTitle!: (title: string) => void;
copilotApiService.responsePromise = new Promise(resolve => { resolveTitle = resolve; });
const { svc, session, db } = await setupTitleGeneration(copilotApiService);
svc.dispatchAction(
buildDefaultChatUri(session.toString()),
{ type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'Investigate flaky terminal tests', origin: { kind: MessageKind.User } } },
'test-client', 1,
);
await waitForCondition(() => copilotApiService.utilityCalls.length === 1, 'title generation should be in flight');
await svc.disposeSession(session);
resolveTitle('Flaky terminal tests');
await Promise.resolve();
assert.deepStrictEqual({
aborted: copilotApiService.utilityCalls[0].options?.signal?.aborted,
state: svc.stateManager.getSessionState(session.toString()),
persistedTitle: await db.getMetadata('customTitle'),
}, {
aborted: true,
state: undefined,
persistedTitle: undefined,
});
});
test('generates an AI title for forked sessions from the forked chat', async () => {
const copilotApiService = new TestCopilotApiService();
copilotApiService.response = 'Source generated title';
const { svc, session: sourceSession } = await setupTitleGeneration(copilotApiService);
svc.dispatchAction(
buildDefaultChatUri(sourceSession.toString()),
{ type: ActionType.ChatTurnStarted, turnId: 'source-turn', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'Seed fork title', origin: { kind: MessageKind.User } } },
'test-client', 1,
);
await waitForCondition(() => svc.stateManager.getSessionState(sourceSession.toString())?.title === 'Source generated title', 'source generated title should be applied');
svc.dispatchAction(
buildDefaultChatUri(sourceSession.toString()),
{ type: ActionType.ChatTurnComplete, turnId: 'source-turn', duration: 1000 },
'test-client', 2,
);
await waitForCondition(() => (svc.stateManager.getSessionState(sourceSession.toString())?.turns.length ?? 0) === 1, 'source turn should be complete before forking');
// The fork inherits a `Forked: …` placeholder, then regenerates a
// content-derived title from the copied chat.
copilotApiService.response = 'Forked branch title';
const forkedSession = await svc.createSession({
provider: 'copilot',
fork: {
session: sourceSession,
turnIndex: 0,
turnId: 'source-turn',
},
});
await waitForCondition(() => svc.stateManager.getSessionState(forkedSession.toString())?.title === 'Forked branch title', 'forked session should get a content-generated title');
const forkedCall = copilotApiService.utilityCalls[copilotApiService.utilityCalls.length - 1];
const userMessage = forkedCall.request.messages.find(message => message.role === 'user')?.content ?? '';
assert.deepStrictEqual({
title: svc.stateManager.getSessionState(forkedSession.toString())?.title,
utilityCalls: copilotApiService.utilityCalls.length,
includesForkedChat: userMessage.includes('Seed fork title'),
}, {
title: 'Forked branch title',
utilityCalls: 2,
includesForkedChat: true,
});
});
});
// ---- attachment rewriting ------------------------------------------
suite('user-message attachment rewriting', () => {
/**
* Sets up an {@link AgentService} backed by an in-memory file system
* and a {@link createSessionDataService} that points at a fixed
* directory. Returns the wired-up service and the URI under which
* snapshotted attachments should land.
*/
async function setup(): Promise<{
svc: AgentService;
agent: MockAgent;
session: URI;
attachmentsRoot: URI;
warnings: string[];
}> {
const sessionDataDir = URI.from({ scheme: Schemas.inMemory, path: '/session-data' });
const attachmentsRoot = joinPath(sessionDataDir, 'attachments');
await fileService.createFolder(attachmentsRoot);
const sessionDataService = createSessionDataService();
// Override getSessionDataDir so the rewriter writes under our
// in-memory file system instead of the helper's default path.
sessionDataService.getSessionDataDir = () => sessionDataDir;
const warnings: string[] = [];
const logService = new class extends NullLogService {
override warn(message: string): void { warnings.push(message); }
};
const svc = disposables.add(new AgentService(logService, fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
svc.registerProvider(agent);
const session = await svc.createSession({ provider: 'copilot' });
return { svc, agent, session, attachmentsRoot, warnings };
}
async function dispatchTurnAndWait(svc: AgentService, agent: MockAgent, session: URI, attachments: MessageResourceAttachment[] | { type: MessageAttachmentKind.EmbeddedResource; label: string; data: string; contentType: string; displayKind?: string }[]): Promise<void> {
svc.dispatchAction(
buildDefaultChatUri(session.toString()),
{
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'hello', origin: { kind: MessageKind.User }, attachments: attachments as never },
},
'test-client', 1,
);
// dispatchAction queues an async rewrite and the side-effect
// handler is invoked from the same continuation; poll until the
// agent has observed the (rewritten) sendMessage.
for (let i = 0; i < 20 && agent.sendMessageCalls.length === 0; i++) {
await new Promise(r => setTimeout(r, 5));
}
}
test('snapshots EmbeddedResource attachments to disk and rewrites to a Resource URI under the session attachments folder', async () => {
const { svc, agent, session, attachmentsRoot } = await setup();
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
await dispatchTurnAndWait(svc, agent, session, [{
type: MessageAttachmentKind.EmbeddedResource,
label: 'paste.png',
data: encodeBase64(VSBuffer.wrap(png)),
contentType: 'image/png',
displayKind: 'image',
} as never]);
assert.strictEqual(agent.sendMessageCalls.length, 1);
const rewritten = agent.sendMessageCalls[0].attachments;
assert.strictEqual(rewritten?.length, 1);
const a = rewritten[0];
assert.strictEqual(a.type, MessageAttachmentKind.Resource);
if (a.type !== MessageAttachmentKind.Resource) { return; }
assert.strictEqual(a.label, 'paste.png');
assert.strictEqual(a.displayKind, 'image');
assert.ok(a.uri.startsWith(attachmentsRoot.toString() + '/'), `attachment uri ${a.uri} should be under ${attachmentsRoot.toString()}/`);
// File on disk holds exactly the original bytes
const written = await fileService.readFile(URI.parse(a.uri));
assert.deepStrictEqual([...written.value.buffer], [...png]);
});
test('preserves existing displayKind / range / selection / _meta on rewrite', async () => {
const { svc, agent, session } = await setup();
const range = { start: { line: 1, character: 0 }, end: { line: 1, character: 4 } };
await dispatchTurnAndWait(svc, agent, session, [{
type: MessageAttachmentKind.EmbeddedResource,
label: 'note.txt',
data: encodeBase64(VSBuffer.fromString('alpha\nbeta\ngamma')),
contentType: 'text/plain',
// EmbeddedResource carries optional selection too
// (textual resources only); make sure the rewriter copies it.
displayKind: 'selection',
} as never]);
const rewritten = agent.sendMessageCalls[0].attachments![0];
assert.strictEqual(rewritten.type, MessageAttachmentKind.Resource);
if (rewritten.type !== MessageAttachmentKind.Resource) { return; }
// `displayKind` is preserved as-is from the original attachment.
assert.strictEqual(rewritten.displayKind, 'selection');
void range; // selection round-trip on EmbeddedResource is covered by the next test
});
test('snapshots Resource attachments by reading the original file and rewriting to a local snapshot', async () => {
const { svc, agent, session, attachmentsRoot, warnings } = await setup();
const sourceUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/source.txt' });
await fileService.writeFile(sourceUri, VSBuffer.fromString('hello world'));
await dispatchTurnAndWait(svc, agent, session, [{
type: MessageAttachmentKind.Resource,
uri: sourceUri.toString(),
label: 'source.txt',
displayKind: 'document',
}]);
const rewritten = agent.sendMessageCalls[0].attachments![0];
assert.strictEqual(rewritten.type, MessageAttachmentKind.Resource);
if (rewritten.type !== MessageAttachmentKind.Resource) { return; }
assert.notStrictEqual(rewritten.uri, sourceUri.toString(), `should be rewritten to the snapshot URI; warnings=${JSON.stringify(warnings)}; got ${rewritten.uri}`);
assert.ok(rewritten.uri.startsWith(attachmentsRoot.toString() + '/'));
assert.strictEqual(rewritten.label, 'source.txt');
assert.strictEqual(rewritten.displayKind, 'document');
const snapshot = await fileService.readFile(URI.parse(rewritten.uri));
assert.strictEqual(snapshot.value.toString(), 'hello world');
});
test('passes through existing file:// Resource attachments unchanged (#319314)', async () => {
const { svc, agent, session } = await setup();
// Register a file-scheme provider so the attachment URI resolves to
// an existing file on the agent host side.
disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider())));
const fileUri = URI.from({ scheme: Schemas.file, path: '/host/source.txt' });
await fileService.writeFile(fileUri, VSBuffer.fromString('on host'));
await dispatchTurnAndWait(svc, agent, session, [{
type: MessageAttachmentKind.Resource,
uri: fileUri.toString(),
label: 'source.txt',
displayKind: 'document',
}]);
assert.deepStrictEqual(agent.sendMessageCalls[0].attachments, [{
type: MessageAttachmentKind.Resource,
uri: fileUri.toString(),
label: 'source.txt',
displayKind: 'document',
}]);
});
test('preserves selection range on Resource rewrite', async () => {
const { svc, agent, session, attachmentsRoot } = await setup();
const sourceUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/sel.txt' });
await fileService.writeFile(sourceUri, VSBuffer.fromString('alpha\nbeta\ngamma'));
const range = { start: { line: 1, character: 0 }, end: { line: 1, character: 4 } };
await dispatchTurnAndWait(svc, agent, session, [{
type: MessageAttachmentKind.Resource,
uri: sourceUri.toString(),
label: 'sel.txt',
displayKind: 'selection',
selection: { range },
}]);
const rewritten = agent.sendMessageCalls[0].attachments![0];
assert.strictEqual(rewritten.type, MessageAttachmentKind.Resource);
if (rewritten.type !== MessageAttachmentKind.Resource) { return; }
assert.ok(rewritten.uri.startsWith(attachmentsRoot.toString() + '/'), 'should be rewritten to a snapshot URI');
assert.deepStrictEqual(rewritten.selection?.range, range);
assert.strictEqual(rewritten.displayKind, 'selection');
});
test('passes directory Resource attachments through unchanged', async () => {
const { svc, agent, session } = await setup();
const dirUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/dir' });
await dispatchTurnAndWait(svc, agent, session, [{
type: MessageAttachmentKind.Resource,
uri: dirUri.toString(),
label: 'dir',
displayKind: 'directory',
}]);
assert.deepStrictEqual(agent.sendMessageCalls[0].attachments, [{
type: MessageAttachmentKind.Resource,
uri: dirUri.toString(),
label: 'dir',
displayKind: 'directory',
}]);
});
test('does not re-snapshot attachments that already point under the session attachments folder', async () => {
const { svc, agent, session, attachmentsRoot } = await setup();
const existing = joinPath(attachmentsRoot, 'previous-id', 'note.txt');
await fileService.writeFile(existing, VSBuffer.fromString('already snapshotted'));
await dispatchTurnAndWait(svc, agent, session, [{
type: MessageAttachmentKind.Resource,
uri: existing.toString(),
label: 'note.txt',
displayKind: 'document',
}]);
const a = agent.sendMessageCalls[0].attachments?.[0];
assert.ok(a && a.type === MessageAttachmentKind.Resource);
assert.strictEqual(a.uri, existing.toString(), 'second-pass rewrite should be a no-op');
});
test('preserves the original attachment when the source cannot be read', async () => {
const { svc, agent, session } = await setup();
const missingUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/missing.txt' });
await dispatchTurnAndWait(svc, agent, session, [{
type: MessageAttachmentKind.Resource,
uri: missingUri.toString(),
label: 'missing.txt',
displayKind: 'document',
}]);
assert.deepStrictEqual(agent.sendMessageCalls[0].attachments, [{
type: MessageAttachmentKind.Resource,
uri: missingUri.toString(),
label: 'missing.txt',
displayKind: 'document',
}]);
});
});
suite('createSession', () => {
test('creates session via specified provider', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
assert.strictEqual(AgentSession.provider(session), 'copilot');
});
test('truncates working directories for a provider without multipleWorkingDirectories', async () => {
class CapturingAgent extends MockAgent {
lastConfig: IAgentCreateSessionConfig | undefined;
constructor(id: string, private readonly _caps: import('../../common/agentService.js').IAgentCapabilities | undefined) {
super(id);
}
override getDescriptor() {
return { ...super.getDescriptor(), capabilities: this._caps };
}
override async createSession(config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult> {
this.lastConfig = config;
return super.createSession(config);
}
}
const single = new CapturingAgent('single', undefined);
const multi = new CapturingAgent('multi', { multipleWorkingDirectories: { immutablePrimary: true } });
disposables.add(toDisposable(() => single.dispose()));
disposables.add(toDisposable(() => multi.dispose()));
service.registerProvider(single);
service.registerProvider(multi);
const dirs = [URI.file('/repoA'), URI.file('/repoB'), URI.file('/repoC')];
await service.createSession({ provider: 'single', workingDirectories: dirs });
await service.createSession({ provider: 'multi', workingDirectories: dirs });
// A provider that does not advertise the capability keeps only the
// primary (index 0); one that advertises it receives the full set.
assert.deepStrictEqual({
single: single.lastConfig?.workingDirectories?.map(d => d.toString()),
multi: multi.lastConfig?.workingDirectories?.map(d => d.toString()),
}, {
single: [dirs[0].toString()],
multi: dirs.map(d => d.toString()),
});
});
test('honors requested session URI', async () => {
service.registerProvider(copilotAgent);
const requestedSession = AgentSession.uri('copilot', 'requested-session');
const session = await service.createSession({ provider: 'copilot', session: requestedSession });
assert.strictEqual(session.toString(), requestedSession.toString());
});
test('scripted mock agent honors requested session URI', async () => {
const agent = new ScriptedMockAgent();
disposables.add(toDisposable(() => agent.dispose()));
const requestedSession = AgentSession.uri('mock', 'requested-session');
const result = await agent.createSession({ session: requestedSession });
const sessions = await agent.listSessions();
assert.deepStrictEqual({
created: result.session.toString(),
listed: sessions.some(s => s.session.toString() === requestedSession.toString()),
}, {
created: requestedSession.toString(),
listed: true,
});
});
test('uses default provider when none specified', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession();
// A create with no config at all is still workspace-less: the agent
// infers that from the absent working directory and assigns a
// scratch cwd, so the service must tag it to match — otherwise the
// session comes back looking workspace-bound, rooted at that scratch
// dir.
assert.deepStrictEqual({
provider: AgentSession.provider(session),
meta: service.stateManager.getSessionState(session.toString())?._meta,
}, {
provider: 'copilot',
meta: { workspaceless: true },
});
});
test('throws when no providers are registered at all', async () => {
await assert.rejects(() => service.createSession(), /No agent provider/);
});
});
// ---- disposeSession -------------------------------------------------
suite('disposeSession', () => {
test('dispatches to the correct provider and cleans up tracking', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
await service.disposeSession(session);
assert.strictEqual(copilotAgent.disposeSessionCalls.length, 1);
});
test('is a no-op for unknown sessions', async () => {
service.registerProvider(copilotAgent);
const unknownSession = URI.from({ scheme: 'unknown', path: '/nope' });
// Should not throw
await service.disposeSession(unknownSession);
});
test('deletes session data before removing the worktree', async () => {
// Subscribers of the will-delete event drop this session's git refs,
// which requires resolving the repository from the working directory.
// For a worktree-isolated session that directory *is* the worktree, so
// removing it first would strand the refs in the main repository.
const order: string[] = [];
const sessionDataService: ISessionDataService = {
...nullSessionDataService,
deleteSessionData: async () => { order.push('deleteSessionData'); },
};
const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
svc.registerProvider(copilotAgent);
const session = await svc.createSession({ provider: 'copilot' });
svc.setWorktreeIsolation({
removeCreatedWorktree: async () => { order.push('removeCreatedWorktree'); },
} as unknown as WorktreeIsolation);
await svc.disposeSession(session);
assert.deepStrictEqual(order, ['deleteSessionData', 'removeCreatedWorktree']);
});
});
// ---- listSessions / listModels --------------------------------------
suite('aggregation', () => {
test('listSessions aggregates sessions from all providers', async () => {
service.registerProvider(copilotAgent);
await service.createSession({ provider: 'copilot' });
const sessions = await service.listSessions();
assert.strictEqual(sessions.length, 1);
});
test('listSessions overlays custom title from session database', async () => {
// Pre-seed a custom title in an in-memory database
const db = disposables.add(await SessionDatabase.open(':memory:'));
await db.setMetadata('customTitle', 'My Custom Title');
const sessionId = 'test-session-abc';
const sessionUri = AgentSession.uri('copilot', sessionId);
const sessionDataService: ISessionDataService = {
_serviceBrand: undefined,
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
getSessionDataDirById: () => URI.parse('inmemory:/session-data'),
openDatabase: (): IReference<ISessionDatabase> => ({
object: db,
dispose: () => { },
}),
tryOpenDatabase: async (): Promise<IReference<ISessionDatabase> | undefined> => ({
object: db,
dispose: () => { },
}),
deleteSessionData: async () => { },
onWillDeleteSessionData: Event.None,
cleanupOrphanedData: async () => { },
whenIdle: async () => { },
};
// Create a mock that returns a session with that ID
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
agent.sessionMetadataOverrides = { summary: 'SDK Title' };
// Manually add the session to the mock
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
svc.registerProvider(agent);
const sessions = await svc.listSessions();
assert.strictEqual(sessions.length, 1);
assert.strictEqual(sessions[0].summary, 'My Custom Title');
});
test('listSessions overlays the AH-owned workspaceless marker for any agent', async () => {
// The AH service owns `agentHost.workspaceless` in the central session
// database and overlays it onto every agent's summary `_meta` — so an
// agent that persists/re-emits nothing itself still restores as a quick
// chat. Pre-seed the AH key with no agent-side re-emit.
const db = disposables.add(await SessionDatabase.open(':memory:'));
await db.setMetadata('agentHost.workspaceless', 'true');
const sessionId = 'test-session-workspaceless';
const sessionUri = AgentSession.uri('copilot', sessionId);
const sessionDataService: ISessionDataService = {
_serviceBrand: undefined,
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
getSessionDataDirById: () => URI.parse('inmemory:/session-data'),
openDatabase: (): IReference<ISessionDatabase> => ({
object: db,
dispose: () => { },
}),
tryOpenDatabase: async (): Promise<IReference<ISessionDatabase> | undefined> => ({
object: db,
dispose: () => { },
}),
deleteSessionData: async () => { },
onWillDeleteSessionData: Event.None,
cleanupOrphanedData: async () => { },
whenIdle: async () => { },
};
// The agent returns the session with NO `_meta.workspaceless` of its own.
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
agent.sessionMetadataOverrides = {
_meta: { multiRoot: { workspaceFile: 'file:///provider-spoof.code-workspace', name: 'Spoof' } },
};
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
svc.registerProvider(agent);
const sessions = await svc.listSessions();
assert.strictEqual(sessions.length, 1);
assert.deepStrictEqual(sessions[0]._meta, { workspaceless: true });
});
test('listSessions restores persisted multi-root metadata', async () => {
const db = new TestSessionDatabase();
const multiRoot = {
workspaceFile: 'vscode-remote://ssh-remote+host/work/demo.code-workspace',
name: 'Demo Workspace',
};
await db.setMetadata(SESSION_META_MULTI_ROOT_KEY, JSON.stringify(multiRoot));
const sessionId = 'test-session-multi-root';
const sessionUri = AgentSession.uri('copilot', sessionId);
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
agent.sessionMetadataOverrides = {
_meta: { multiRoot: { workspaceFile: 'file:///provider-spoof.code-workspace', name: 'Spoof' } },
};
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
svc.registerProvider(agent);
const sessions = await svc.listSessions();
assert.deepStrictEqual(readSessionMultiRootMetadata(sessions[0]._meta), multiRoot);
});
test('listSessions strips provider multi-root metadata when no session database exists', async () => {
const sessionId = 'test-session-provider-multi-root';
const sessionUri = AgentSession.uri('copilot', sessionId);
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
agent.sessionMetadataOverrides = {
_meta: { multiRoot: { workspaceFile: 'file:///provider-spoof.code-workspace', name: 'Spoof' } },
};
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
const svc = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
svc.registerProvider(agent);
const sessions = await svc.listSessions();
assert.strictEqual(readSessionMultiRootMetadata(sessions[0]._meta), undefined);
});
test('listSessions normalizes a persisted linked-worktree project without probing a missing session worktree', async () => {
const db = disposables.add(new TestSessionDatabase());
const primaryRoot = URI.file('/workspace/vscode');
const linkedCheckout = URI.file('/workspace/vscode.worktrees/parent');
const sessionWorktree = URI.file('/workspace/vscode.worktrees/parent.worktrees/child');
await db.setMetadata(WORKTREE_META_REPOSITORY_ROOT, linkedCheckout.toString());
const sessionId = 'test-session-linked-worktree';
const sessionUri = AgentSession.uri('copilot', sessionId);
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
agent.sessionMetadataOverrides = {
workingDirectories: [sessionWorktree],
project: { uri: linkedCheckout, displayName: 'parent' },
};
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
const gitService = createNoopGitService();
const resolvedFrom: URI[] = [];
gitService.getWorktreeRoots = async workingDirectory => {
resolvedFrom.push(workingDirectory);
return [primaryRoot, linkedCheckout, sessionWorktree];
};
const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, gitService));
svc.registerProvider(agent);
const sessions = await svc.listSessions();
await svc.listSessions();
assert.deepStrictEqual({
resolvedFrom: resolvedFrom.map(uri => uri.toString()),
project: sessions[0].project && { uri: sessions[0].project.uri.toString(), displayName: sessions[0].project.displayName },
persistedRepositoryRoot: await db.getMetadata(WORKTREE_META_REPOSITORY_ROOT),
}, {
resolvedFrom: [linkedCheckout.toString()],
project: { uri: primaryRoot.toString(), displayName: 'vscode' },
persistedRepositoryRoot: primaryRoot.toString(),
});
});
test('listSessions uses SDK title when no custom title exists', async () => {
service.registerProvider(copilotAgent);
copilotAgent.sessionMetadataOverrides = { summary: 'Auto-generated Title' };
await service.createSession({ provider: 'copilot' });
const sessions = await service.listSessions();
assert.strictEqual(sessions.length, 1);
assert.strictEqual(sessions[0].summary, 'Auto-generated Title');
});
test('listSessions never returns subagent sessions', async () => {
service.registerProvider(copilotAgent);
const parentSession = await service.createSession({ provider: 'copilot' });
// Simulate a live subagent being spawned: `_handleSubagentStarted`
// registers the child session via `restoreSession`, which records
// it in the announced-summary map that `listSessions` overlays
// onto provider results.
const childSessionUri = buildSubagentSessionUri(parentSession.toString(), 'tc-sub');
service.stateManager.restoreSession(
{
resource: childSessionUri,
provider: 'subagent',
title: 'Explore',
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
},
[],
);
// Sanity: the subagent child session is announced.
assert.ok(
service.stateManager.getOverlaySessionSummaries().some(s => s.resource === childSessionUri),
'subagent child session should be listed',
);
const listed = await service.listSessions();
assert.deepStrictEqual(
{
subagentSessions: listed.filter(s => isSubagentSession(s.session.toString())).map(s => s.session.toString()),
includesParent: listed.some(s => s.session.toString() === parentSession.toString()),
},
{
subagentSessions: [],
includesParent: true,
},
);
});
test('listSessions overlay excludes idle provisional sessions but keeps ones with an active turn (#321269)', async () => {
// A provisional agent whose `listSessions` never returns the
// provisional session (mirroring CLI/Claude, which don't persist a
// session until its first message). The agent service's overlay is
// then the only thing that could surface it.
class ProvisionalMockAgent extends MockAgent {
override async createSession(config?: import('../../common/agentService.js').IAgentCreateSessionConfig): Promise<import('../../common/agentService.js').IAgentCreateSessionResult> {
const result = await super.createSession(config);
return { ...result, provisional: true };
}
override async listSessions() {
return [];
}
}
const provisionalAgent = new ProvisionalMockAgent('copilot');
disposables.add(toDisposable(() => provisionalAgent.dispose()));
service.registerProvider(provisionalAgent);
const session = await service.createSession({ provider: 'copilot' });
// Idle provisional session (the new-session composer's eagerly
// created session, before its first message) must not leak in.
const idleListed = await service.listSessions();
assert.ok(
!idleListed.some(s => s.session.toString() === session.toString()),
'idle provisional session should not appear in listSessions',
);
// Once a turn is in flight (the first turn can start before
// materialization completes), the session must stay visible so
// renderer-side caches don't evict the in-flight session.
service.dispatchAction(
buildDefaultChatUri(session.toString()),
{ type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } } },
'test-client', 1,
);
const activeListed = await service.listSessions();
assert.ok(
activeListed.some(s => s.session.toString() === session.toString()),
'provisional session with an active turn should appear in listSessions',
);
// If the turn completes before the materialize event lands, the
// session is back to lifecycle=creating with no active turn — but it
// has a recorded turn now, so it must STAY visible (otherwise a
// listSessions refresh in this window would evict the just-finished
// session, reintroducing #321269's sibling eviction bug).
service.dispatchAction(
buildDefaultChatUri(session.toString()),
{ type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 },
'test-client', 2,
);
const stateAfterTurn = service.stateManager.getSessionState(session.toString());
assert.strictEqual(stateAfterTurn?.lifecycle, SessionLifecycle.Creating, 'session should still be provisional (materialize not yet fired)');
assert.strictEqual(stateAfterTurn?.activeTurn, undefined, 'completed turn should clear the active turn');
const completedListed = await service.listSessions();
assert.ok(
completedListed.some(s => s.session.toString() === session.toString()),
'provisional session with a completed turn should still appear in listSessions',
);
});
test('listSessions overlays live workspace metadata over a stale provider snapshot', async () => {
class DelayedListAgent extends MockAgent {
readonly listStarted = new DeferredPromise<void>();
readonly releaseList = new DeferredPromise<void>();
override async listSessions() {
const snapshot = await super.listSessions();
this.listStarted.complete();
await this.releaseList.p;
return snapshot;
}
}
const agent = new DelayedListAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
agent.resolvedWorkingDirectory = URI.file('/original');
service.registerProvider(agent);
const { session } = await agent.createSession();
const listing = service.listSessions();
await agent.listStarted.p;
service.stateManager.restoreSession({
resource: session.toString(),
provider: 'copilot',
title: 'Materialized',
status: SessionStatus.Idle,
createdAt: new Date(1000).toISOString(),
modifiedAt: new Date(2000).toISOString(),
project: { uri: URI.file('/project').toString(), displayName: 'project' },
workingDirectories: [URI.file('/worktree').toString()],
}, []);
agent.releaseList.complete();
const listed = (await listing).find(item => item.session.toString() === session.toString());
assert.deepStrictEqual({
modifiedTime: listed?.modifiedTime,
project: listed?.project && { uri: listed.project.uri.path, displayName: listed.project.displayName },
workingDirectory: listed?.workingDirectories?.[0]?.path,
}, {
modifiedTime: 2000,
project: { uri: '/project', displayName: 'project' },
workingDirectory: '/worktree',
});
});
test.skip('listSessions synthesizes the session changeset catalogue from persisted diffs for unopened sessions', async () => {
// Pre-seed a `'diffs'` blob in the in-memory DB. The agent's
// `listSessions()` returns the session metadata but the session
// is NOT live in the state manager (no createSession /
// restoreSession call), so the synthesised catalogue path runs.
const db = disposables.add(await SessionDatabase.open(':memory:'));
const persistedDiffs = [
{
after: { uri: 'file:///wd/a.ts', content: { uri: 'file:///wd/a.ts' } },
diff: { added: 5, removed: 2 },
},
{
after: { uri: 'file:///wd/b.ts', content: { uri: 'file:///wd/b.ts' } },
diff: { added: 3, removed: 0 },
},
];
await db.setMetadata('diffs', JSON.stringify(persistedDiffs));
const sessionId = 'persisted-session';
const sessionUri = AgentSession.uri('copilot', sessionId);
const sessionDataService: ISessionDataService = {
_serviceBrand: undefined,
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
getSessionDataDirById: () => URI.parse('inmemory:/session-data'),
openDatabase: (): IReference<ISessionDatabase> => ({ object: db, dispose: () => { } }),
tryOpenDatabase: async (): Promise<IReference<ISessionDatabase> | undefined> => ({ object: db, dispose: () => { } }),
deleteSessionData: async () => { },
onWillDeleteSessionData: Event.None,
cleanupOrphanedData: async () => { },
whenIdle: async () => { },
};
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
svc.registerProvider(agent);
const sessions = await svc.listSessions();
assert.strictEqual(sessions.length, 1);
assert.deepStrictEqual(sessions[0].changesets, [
{
label: 'Branch Changes',
uriTemplate: `${sessionUri.toString()}/changeset/session`,
additions: 8,
deletions: 2,
files: 2,
},
{
label: 'Uncommitted Changes',
uriTemplate: `${sessionUri.toString()}/changeset/uncommitted`,
description: 'Show uncommitted changes in this session',
},
]);
});
test.skip('listSessions silently ignores malformed persisted diffs', async () => {
const db = disposables.add(await SessionDatabase.open(':memory:'));
await db.setMetadata('diffs', '{ not valid json');
const sessionId = 'bad-diffs-session';
const sessionUri = AgentSession.uri('copilot', sessionId);
const sessionDataService: ISessionDataService = {
_serviceBrand: undefined,
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
getSessionDataDirById: () => URI.parse('inmemory:/session-data'),
openDatabase: (): IReference<ISessionDatabase> => ({ object: db, dispose: () => { } }),
tryOpenDatabase: async (): Promise<IReference<ISessionDatabase> | undefined> => ({ object: db, dispose: () => { } }),
deleteSessionData: async () => { },
onWillDeleteSessionData: Event.None,
cleanupOrphanedData: async () => { },
whenIdle: async () => { },
};
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
svc.registerProvider(agent);
const sessions = await svc.listSessions();
assert.strictEqual(sessions.length, 1);
assert.strictEqual(sessions[0].changesets, undefined);
});
test.skip('listSessions advertises persisted changeset counts without seeding state; changeset subscribe restores lazily', async () => {
const db = disposables.add(await SessionDatabase.open(':memory:'));
const persistedDiffs = [
{
after: { uri: 'file:///wd/a.ts', content: { uri: 'file:///wd/a.ts' } },
diff: { added: 5, removed: 2 },
},
];
await db.setMetadata('diffs', JSON.stringify(persistedDiffs));
const sessionId = 'unopened-with-diffs';
const sessionUri = AgentSession.uri('copilot', sessionId);
const sessionDataService: ISessionDataService = {
_serviceBrand: undefined,
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
getSessionDataDirById: () => URI.parse('inmemory:/session-data'),
openDatabase: (): IReference<ISessionDatabase> => ({ object: db, dispose: () => { } }),
tryOpenDatabase: async (): Promise<IReference<ISessionDatabase> | undefined> => ({ object: db, dispose: () => { } }),
deleteSessionData: async () => { },
onWillDeleteSessionData: Event.None,
cleanupOrphanedData: async () => { },
whenIdle: async () => { },
};
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
svc.registerProvider(agent);
const sessions = await svc.listSessions();
const changesetUri = buildSessionChangesetUri(sessionUri.toString());
assert.deepStrictEqual({
listCatalogueEntry: sessions[0].changesets?.find(c => c.uriTemplate === changesetUri),
listSeededSnapshot: svc.stateManager.getSnapshot(changesetUri),
}, {
listCatalogueEntry: {
label: 'Branch Changes',
uriTemplate: changesetUri,
additions: 5,
deletions: 2,
files: 1,
},
listSeededSnapshot: undefined,
});
const snapshot = await svc.subscribe(URI.parse(changesetUri), 'client-changeset');
const state = snapshot.state as { status: string; files: Array<{ id: string }> };
assert.strictEqual(state.status, 'ready');
assert.deepStrictEqual(state.files.map(f => f.id), ['file:///wd/a.ts']);
});
test.skip('listSessions prefers ready live changeset state over stale persisted diffs for unopened sessions', async () => {
const db = disposables.add(await SessionDatabase.open(':memory:'));
// Stale persisted diffs — obviously different totals so the
// source-of-truth choice is visible.
const persistedDiffs = [
{ after: { uri: 'file:///wd/x.ts', content: { uri: 'file:///wd/x.ts' } }, diff: { added: 99, removed: 0 } },
{ after: { uri: 'file:///wd/y.ts', content: { uri: 'file:///wd/y.ts' } }, diff: { added: 0, removed: 0 } },
{ after: { uri: 'file:///wd/z.ts', content: { uri: 'file:///wd/z.ts' } }, diff: { added: 0, removed: 0 } },
];
await db.setMetadata('diffs', JSON.stringify(persistedDiffs));
const sessionId = 'unopened-stale-diffs';
const sessionUri = AgentSession.uri('copilot', sessionId);
const sessionDataService: ISessionDataService = {
_serviceBrand: undefined,
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
getSessionDataDirById: () => URI.parse('inmemory:/session-data'),
openDatabase: (): IReference<ISessionDatabase> => ({ object: db, dispose: () => { } }),
tryOpenDatabase: async (): Promise<IReference<ISessionDatabase> | undefined> => ({ object: db, dispose: () => { } }),
deleteSessionData: async () => { },
onWillDeleteSessionData: Event.None,
cleanupOrphanedData: async () => { },
whenIdle: async () => { },
};
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
svc.registerProvider(agent);
// Seed live changeset state directly: a single file with
// different counts than the stale persisted blob.
const changesetUri = svc.stateManager.registerChangeset(buildSessionChangesetUri(sessionUri.toString()));
svc.stateManager.dispatchServerAction(changesetUri, {
type: ActionType.ChangesetFileSet,
file: {
id: 'file:///wd/live.ts',
edit: { after: { uri: 'file:///wd/live.ts', content: { uri: 'file:///wd/live.ts' } }, diff: { added: 1, removed: 0 } }
},
});
svc.stateManager.dispatchServerAction(changesetUri, {
type: ActionType.ChangesetStatusChanged,
status: ChangesetStatus.Ready,
});
const sessions = await svc.listSessions();
assert.deepStrictEqual(sessions[0].changesets, [
{
label: 'Branch Changes',
uriTemplate: changesetUri,
additions: 1,
deletions: 0,
files: 1,
},
{
label: 'Uncommitted Changes',
uriTemplate: `${sessionUri.toString()}/changeset/uncommitted`,
description: 'Show uncommitted changes in this session',
},
]);
});
test.skip('listSessions does not request the diffs metadata key when a live source can answer', async () => {
const requestedKeys: string[][] = [];
const db: ISessionDatabase = {
dispose: () => { },
getMetadata: async () => undefined,
getMetadataObject: async <T extends Record<string, unknown>>(obj: T): Promise<{ [K in keyof T]: string | undefined }> => {
requestedKeys.push(Object.keys(obj));
return Object.fromEntries(Object.keys(obj).map(k => [k, undefined])) as { [K in keyof T]: string | undefined };
},
setMetadata: async () => { },
deleteMetadata: async () => { },
appendEvent: async () => { },
readEvents: async () => [],
readEventCount: async () => 0,
} as unknown as ISessionDatabase;
const sessionId = 'unopened-live-source';
const sessionUri = AgentSession.uri('copilot', sessionId);
const sessionDataService: ISessionDataService = {
_serviceBrand: undefined,
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
getSessionDataDirById: () => URI.parse('inmemory:/session-data'),
openDatabase: (): IReference<ISessionDatabase> => ({ object: db, dispose: () => { } }),
tryOpenDatabase: async (): Promise<IReference<ISessionDatabase> | undefined> => ({ object: db, dispose: () => { } }),
deleteSessionData: async () => { },
onWillDeleteSessionData: Event.None,
cleanupOrphanedData: async () => { },
whenIdle: async () => { },
};
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
svc.registerProvider(agent);
// Seed a ready (zero-file) live changeset state — this alone
// must be authoritative enough to suppress the persisted-diffs
// read.
const changesetUri = svc.stateManager.registerChangeset(buildSessionChangesetUri(sessionUri.toString()));
svc.stateManager.dispatchServerAction(changesetUri, {
type: ActionType.ChangesetStatusChanged,
status: ChangesetStatus.Ready,
});
await svc.listSessions();
assert.strictEqual(requestedKeys.length, 1);
assert.strictEqual(requestedKeys[0].includes('diffs'), false, `expected listSessions to skip the 'diffs' key when ready live changeset state exists; requested=${requestedKeys[0].join(',')}`);
});
test.skip('listSessions still reads persisted diffs when only a computing (not ready) changeset state exists', async () => {
const db = disposables.add(await SessionDatabase.open(':memory:'));
const persistedDiffs = [
{ after: { uri: 'file:///wd/p.ts', content: { uri: 'file:///wd/p.ts' } }, diff: { added: 7, removed: 1 } },
];
await db.setMetadata('diffs', JSON.stringify(persistedDiffs));
const sessionId = 'unopened-computing-changeset';
const sessionUri = AgentSession.uri('copilot', sessionId);
const sessionDataService: ISessionDataService = {
_serviceBrand: undefined,
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
getSessionDataDirById: () => URI.parse('inmemory:/session-data'),
openDatabase: (): IReference<ISessionDatabase> => ({ object: db, dispose: () => { } }),
tryOpenDatabase: async (): Promise<IReference<ISessionDatabase> | undefined> => ({ object: db, dispose: () => { } }),
deleteSessionData: async () => { },
onWillDeleteSessionData: Event.None,
cleanupOrphanedData: async () => { },
whenIdle: async () => { },
};
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);
const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
svc.registerProvider(agent);
// Register a changeset but leave it in the default
// `Computing` status (no ChangesetStatusChanged dispatch).
svc.stateManager.registerChangeset(buildSessionChangesetUri(sessionUri.toString()));
const sessions = await svc.listSessions();
assert.deepStrictEqual(sessions[0].changesets, [
{
label: 'Branch Changes',
uriTemplate: `${sessionUri.toString()}/changeset/session`,
additions: 7,
deletions: 1,
files: 1,
},
{
label: 'Uncommitted Changes',
uriTemplate: `${sessionUri.toString()}/changeset/uncommitted`,
description: 'Show uncommitted changes in this session',
},
]);
});
test.skip('listSessions overlays live state manager title over SDK title', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
// Simulate immediate title change via state manager
service.stateManager.dispatchServerAction(session.toString(), {
type: ActionType.SessionTitleChanged,
title: 'User first message',
});
const sessions = await service.listSessions();
assert.strictEqual(sessions.length, 1);
assert.strictEqual(sessions[0].summary, 'User first message');
});
test('createSession attaches git state into state _meta when working directory is present', async () => {
const workingDirectory = URI.file('/workspace/repo');
const gitState = {
hasGitHubRemote: true,
branchName: 'feature/x',
baseBranchName: 'main',
upstreamBranchName: 'origin/feature/x',
incomingChanges: 1,
outgoingChanges: 2,
uncommittedChanges: 3,
};
const calls: string[] = [];
const gitService = {
_serviceBrand: undefined,
getCurrentBranch: async () => undefined,
getDefaultBranch: async () => undefined,
getBranch: async () => undefined,
getRefs: async () => [],
getBranches: async () => [],
getRepositoryRoot: async () => undefined,
getWorktreeRoots: async () => [],
addWorktree: async () => { },
copyWorktreeIncludeFiles: async () => { },
addExistingWorktree: async () => { },
removeWorktree: async () => { },
branchExists: async () => false,
hasUncommittedChanges: async () => false,
commitAll: async () => { },
restore: async () => { },
hasUpstream: async () => false,
pull: async () => { },
push: async () => { },
getSessionGitState: async (uri: URI) => { calls.push(uri.fsPath); return gitState; },
computeSessionFileDiffs: async () => undefined,
showBlob: async () => undefined,
captureWorkingTreeAsTree: async () => undefined,
commitTree: async () => undefined,
updateRef: async () => { },
deleteRefs: async () => { },
revParse: async () => undefined,
resolveBranchBaselineCommit: async () => undefined,
overlayPathIntoTree: async () => undefined,
diffTreePaths: async () => undefined,
computeFileDiffsBetweenRefs: async () => undefined,
getFetchRemoteUrls: async () => undefined,
getUntrackedPaths: async () => [],
getBranchDiffSafetyInfo: async () => undefined,
getDiffPatchBetweenRefs: async () => undefined,
};
const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
agent.resolvedWorkingDirectory = workingDirectory;
agent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined };
localService.registerProvider(agent);
// A normal session passes an input workingDirectory, so it is not
// inferred workspace-less; `_meta` carries only the git overlay.
const session = await localService.createSession({ provider: 'copilot', workingDirectories: workingDirectory ? [workingDirectory] : undefined });
// _attachGitState is fire-and-forget; drain microtasks until the
// git service's promise has resolved and setSessionMeta has run.
for (let i = 0; i < 5; i++) {
await Promise.resolve();
}
const sessions = await localService.listSessions();
assert.strictEqual(sessions.length, 1);
assert.deepStrictEqual(calls, [workingDirectory.fsPath]);
assert.deepStrictEqual(
localService.stateManager.getSessionState(session.toString())?._meta,
{ git: gitState },
);
});
test.skip('createSession refreshes branch and uncommitted changesets after git state attaches', async () => {
const workingDirectory = URI.file('/workspace/repo');
const gitState = {
hasGitHubRemote: false,
branchName: 'feature/x',
baseBranchName: 'main',
upstreamBranchName: undefined,
incomingChanges: 0,
outgoingChanges: 0,
uncommittedChanges: 0,
};
const computeCalls: Array<{ sessionUri: string; baseBranch: string | undefined }> = [];
const gitService = createNoopGitService();
gitService.getSessionGitState = async () => gitState;
gitService.computeSessionFileDiffs = async (_wd, opts) => {
computeCalls.push({ sessionUri: opts.sessionUri, baseBranch: opts.baseBranch });
return [];
};
const sessionDb = new SessionDatabase(':memory:');
disposables.add(toDisposable(() => sessionDb.close()));
const sessionDataService = createSessionDataService(sessionDb);
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
agent.resolvedWorkingDirectory = workingDirectory;
agent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined };
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
for (let i = 0; i < 100 && computeCalls.length < 2; i++) {
await new Promise(resolve => setTimeout(resolve, 2));
}
assert.deepStrictEqual(
computeCalls.sort((a, b) => (a.baseBranch ?? '').localeCompare(b.baseBranch ?? '')),
[
{ sessionUri: session.toString(), baseBranch: undefined },
{ sessionUri: session.toString(), baseBranch: 'main' },
],
);
});
test('createSession infers workspace-less (and skips git overlay) when no working directory', async () => {
const gitService = {
_serviceBrand: undefined,
getCurrentBranch: async () => undefined,
getDefaultBranch: async () => undefined,
getBranch: async () => undefined,
getRefs: async () => [],
getBranches: async () => [],
getRepositoryRoot: async () => undefined,
getWorktreeRoots: async () => [],
addWorktree: async () => { },
copyWorktreeIncludeFiles: async () => { },
addExistingWorktree: async () => { },
removeWorktree: async () => { },
branchExists: async () => false,
hasUncommittedChanges: async () => false,
commitAll: async () => { },
hasUpstream: async () => false,
pull: async () => { },
push: async () => { },
restore: async () => { },
getSessionGitState: async () => undefined,
computeSessionFileDiffs: async () => undefined,
showBlob: async () => undefined,
captureWorkingTreeAsTree: async () => undefined,
commitTree: async () => undefined,
updateRef: async () => { },
deleteRefs: async () => { },
revParse: async () => undefined,
resolveBranchBaselineCommit: async () => undefined,
overlayPathIntoTree: async () => undefined,
diffTreePaths: async () => undefined,
computeFileDiffsBetweenRefs: async () => undefined,
getFetchRemoteUrls: async () => undefined,
getUntrackedPaths: async () => [],
getBranchDiffSafetyInfo: async () => undefined,
getDiffPatchBetweenRefs: async () => undefined,
};
const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
// No resolvedWorkingDirectory set on the mock.
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
for (let i = 0; i < 5; i++) {
await Promise.resolve();
}
const sessions = await localService.listSessions();
assert.strictEqual(sessions.length, 1);
// No input workingDirectory → inferred workspace-less (tagged), and no
// git overlay because there is no working directory to probe.
assert.deepStrictEqual(localService.stateManager.getSessionState(session.toString())?._meta, { workspaceless: true });
});
test.skip('createSession strips git-only catalogue entries for non-git working directory', async () => {
const workingDirectory = URI.file('/workspace/not-a-repo');
const gitService = createNoopGitService();
// Probe runs but reports "not a git repo".
gitService.getSessionGitState = async () => undefined;
const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
agent.resolvedWorkingDirectory = workingDirectory;
agent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined };
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
for (let i = 0; i < 5; i++) {
await Promise.resolve();
}
const state = localService.stateManager.getSessionState(session.toString());
assert.ok(state);
assert.deepStrictEqual(state!.changesets?.length, 0);
});
test.skip('createSession keeps git-only catalogue entries for a git working directory', async () => {
const workingDirectory = URI.file('/workspace/repo');
const gitState = {
hasGitHubRemote: false,
branchName: 'main',
baseBranchName: 'main',
upstreamBranchName: undefined,
incomingChanges: 0,
outgoingChanges: 0,
uncommittedChanges: 0,
};
const gitService = createNoopGitService();
gitService.getSessionGitState = async () => gitState;
const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
agent.resolvedWorkingDirectory = workingDirectory;
agent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined };
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
for (let i = 0; i < 5; i++) {
await Promise.resolve();
}
const state = localService.stateManager.getSessionState(session.toString());
assert.ok(state);
assert.deepStrictEqual(state!.changesets, [
{ label: 'Branch Changes', uriTemplate: `${session.toString()}/changeset/session`, description: 'main', changeKind: 'session' },
{ label: 'Uncommitted Changes', uriTemplate: `${session.toString()}/changeset/uncommitted`, description: 'Show uncommitted changes in this session', changeKind: 'uncommitted' },
]);
});
test.skip('createSession sets Branch Changes description from worktree branch info', async () => {
const workingDirectory = URI.file('/workspace/repo');
const gitState = {
hasGitHubRemote: false,
branchName: 'feature/x',
baseBranchName: 'main',
upstreamBranchName: undefined,
incomingChanges: 0,
outgoingChanges: 0,
uncommittedChanges: 0,
};
const gitService = createNoopGitService();
gitService.getSessionGitState = async () => gitState;
const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
agent.resolvedWorkingDirectory = workingDirectory;
agent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined };
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
for (let i = 0; i < 5; i++) {
await Promise.resolve();
}
const state = localService.stateManager.getSessionState(session.toString());
assert.ok(state);
assert.deepStrictEqual(state!.changesets, [
{ label: 'Branch Changes', uriTemplate: `${session.toString()}/changeset/session`, description: 'feature/x → main', changeKind: 'session' },
{ label: 'Uncommitted Changes', uriTemplate: `${session.toString()}/changeset/uncommitted`, description: 'Show uncommitted changes in this session', changeKind: 'uncommitted' },
]);
});
test('subscribe lazily attaches git state when an existing session has no _meta.git', () => {
// Regression test: previously AgentService was constructed without
// a git service, so the git probe always bailed and `_meta.git`
// was never populated. This test ensures the lazy-fire path on
// subscribe() actually invokes the git service and writes git
// state into the session's `_meta`.
//
// subscribe() kicks off the git-state refresh as fire-and-forget
// (it does not await it), so the test must yield to let that async
// work run before asserting. Fake timers are used because the
// refresh is rate-limited (it only settles after a delay).
return runWithFakedTimers({ useFakeTimers: true }, async () => {
const workingDirectory = URI.file('/workspace/repo');
const gitState = {
hasGitHubRemote: false,
branchName: 'feature/lazy',
baseBranchName: 'main',
upstreamBranchName: undefined,
incomingChanges: 0,
outgoingChanges: 0,
uncommittedChanges: 0,
};
const calls: string[] = [];
const gitService = createNoopGitService();
gitService.getSessionGitState = async (uri: URI) => { calls.push(uri.fsPath); return gitState; };
const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
const agent = new MockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
agent.resolvedWorkingDirectory = workingDirectory;
agent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined };
localService.registerProvider(agent);
// Seed a session and clear its _meta so subscribe must lazily
// recompute git state. A microtask drain lets the
// createSession-triggered refresh record its call so we can
// reset the probes to a clean baseline.
const session = await localService.createSession({ provider: 'copilot' });
for (let i = 0; i < 5; i++) {
await Promise.resolve();
}
localService.stateManager.setSessionMeta(session.toString(), undefined);
calls.length = 0;
// subscribe fires the git-state refresh without awaiting it, so
// advance time to let that fire-and-forget refresh run and write
// _meta.git.
await localService.subscribe(session, 'client-1');
await timeout(5_000);
assert.deepStrictEqual(calls, [workingDirectory.fsPath]);
assert.deepStrictEqual(
localService.stateManager.getSessionState(session.toString())?._meta,
{ git: gitState },
);
});
});
test('subscribe to a registered session changeset URI returns a changeset snapshot', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
const changesetUri = buildSessionChangesetUri(session.toString());
const snapshot = await service.subscribe(URI.parse(changesetUri), 'client-cs-known');
assert.deepStrictEqual(
{
resource: snapshot.resource.toString(),
files: (snapshot.state as ChangesetState).files.length,
},
{
resource: changesetUri,
files: 0,
},
);
});
test('subscribe to an unknown changeset id fails without restoring the parent session', async () => {
service.registerProvider(copilotAgent);
// Build a changeset URI with a producer-defined id we don't
// recognise (`staged`). The unknown-changeset early throw must
// fire before the session-restore fallback so the parent session
// is not materialized as a side effect of subscribing to a child
// changeset URI.
const sessionUri = URI.from({ scheme: 'copilot', path: '/missing-session' }).toString();
const changesetUri = `${sessionUri}/changeset/staged`;
await assert.rejects(
() => service.subscribe(URI.parse(changesetUri), 'client-cs-unknown'),
/unknown changeset resource/,
);
assert.strictEqual(
service.stateManager.getSessionState(sessionUri),
undefined,
'parent session must not be materialized as a side effect of an unknown changeset subscription',
);
});
test('createSession stores live session config', async () => {
service.registerProvider(copilotAgent);
const config = { isolation: 'worktree', branch: 'feature/config' };
const session = await service.createSession({ provider: 'copilot', config });
assert.deepStrictEqual(service.stateManager.getSessionState(session.toString())?.config?.values, config);
});
test('seeds activeClient into the initial session state when provided', async () => {
service.registerProvider(copilotAgent);
const envelopes: ActionEnvelope[] = [];
disposables.add(service.onDidAction(env => envelopes.push(env)));
const activeClient: SessionActiveClient = {
clientId: 'client-eager',
tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }],
customizations: [{ type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'A', enabled: true }],
};
const session = await service.createSession({ provider: 'copilot', activeClient });
assert.deepStrictEqual({
activeClients: service.stateManager.getSessionState(session.toString())?.activeClients,
dispatchedActiveClientSet: envelopes.some(e => e.action.type === ActionType.SessionActiveClientSet),
}, {
activeClients: [activeClient],
dispatchedActiveClientSet: false,
});
});
test('omits activeClient from the initial session state when not provided', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
assert.deepStrictEqual(service.stateManager.getSessionState(session.toString())?.activeClients, []);
});
});
// ---- authenticate ---------------------------------------------------
suite('authenticate', () => {
test('routes token to provider matching the resource', async () => {
service.registerProvider(copilotAgent);
const result = await service.authenticate({ resource: 'https://api.github.com', token: 'ghp_test123' });
assert.deepStrictEqual(result, { authenticated: true });
assert.deepStrictEqual(copilotAgent.authenticateCalls, [{ resource: 'https://api.github.com', token: 'ghp_test123' }]);
});
test('returns not authenticated for unknown resource', async () => {
service.registerProvider(copilotAgent);
const result = await service.authenticate({ resource: 'https://unknown.example.com', token: 'tok' });
assert.deepStrictEqual({ result, token: service.getAuthToken({ resource: 'https://unknown.example.com' }), authenticateCalls: copilotAgent.authenticateCalls }, {
result: { authenticated: false },
token: undefined,
authenticateCalls: [],
});
});
test('stores GitHub Copilot token for operation handlers', async () => {
service.registerProvider(copilotAgent);
const result = await service.authenticate({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, token: 'copilot-token' });
assert.deepStrictEqual({ result, token: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported }), authenticateCalls: copilotAgent.authenticateCalls }, {
result: { authenticated: true },
token: 'copilot-token',
authenticateCalls: [{ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, token: 'copilot-token' }],
});
});
test('stores tokens for the same resource by scopes', async () => {
service.registerProvider(copilotAgent);
await service.authenticate({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['read:user'], token: 'read-token' });
await service.authenticate({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['read:user', 'user:email'], token: 'profile-token' });
assert.deepStrictEqual({
readToken: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['read:user'] }),
profileToken: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['user:email', 'read:user'] }),
supersetToken: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: ['user:email'] }),
}, {
readToken: 'read-token',
profileToken: 'profile-token',
supersetToken: 'profile-token',
});
});
test('accepts an already handled MCP token after retrying session handlers', async () => {
const mcpAgent = new MockAgent();
disposables.add(toDisposable(() => mcpAgent.dispose()));
const mcpAgentContract: IAgent = mcpAgent;
let handlerCalls = 0;
mcpAgentContract.handleAuthenticationToken = async () => ++handlerCalls === 1;
service.registerProvider(mcpAgentContract);
const first = await service.authenticate({ resource: 'https://mcp.example.com', scopes: ['write', 'read'], token: 'token-1' });
const duplicate = await service.authenticate({ resource: 'https://mcp.example.com', scopes: ['read', 'write'], token: 'token-1' });
const replacement = await service.authenticate({ resource: 'https://mcp.example.com', scopes: ['read', 'write'], token: 'token-2' });
assert.deepStrictEqual({ first, duplicate, replacement, handlerCalls }, {
first: { authenticated: true },
duplicate: { authenticated: true },
replacement: { authenticated: false },
handlerCalls: 3,
});
});
test('does not hide a session handler rejection with an accepted token', async () => {
const mcpAgent = new MockAgent();
disposables.add(toDisposable(() => mcpAgent.dispose()));
const mcpAgentContract: IAgent = mcpAgent;
let handlerCalls = 0;
mcpAgentContract.handleAuthenticationToken = async () => {
handlerCalls++;
if (handlerCalls === 1) {
return true;
}
throw new Error('failed');
};
service.registerProvider(mcpAgentContract);
const first = await service.authenticate({ resource: 'https://mcp.example.com', token: 'token-1' });
const duplicate = await service.authenticate({ resource: 'https://mcp.example.com', token: 'token-1' });
assert.deepStrictEqual({ first, duplicate, handlerCalls }, {
first: { authenticated: true },
duplicate: { authenticated: false },
handlerCalls: 2,
});
});
test('fans out to every provider that owns the resource', async () => {
// Two providers share the same protected resource (the real
// motivating example: both Copilot CLI and Claude consume the
// GitHub Copilot token). Both must see the token — the
// previous for-loop short-circuit only delivered to the first.
const claudeAgent = new MockAgent('claude');
claudeAgent.getProtectedResources = () => [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'], required: true }];
disposables.add(toDisposable(() => claudeAgent.dispose()));
service.registerProvider(copilotAgent);
service.registerProvider(claudeAgent);
const result = await service.authenticate({ resource: 'https://api.github.com', token: 'tok' });
assert.deepStrictEqual({
result,
copilotCalls: copilotAgent.authenticateCalls,
claudeCalls: claudeAgent.authenticateCalls,
}, {
result: { authenticated: true },
copilotCalls: [{ resource: 'https://api.github.com', token: 'tok' }],
claudeCalls: [{ resource: 'https://api.github.com', token: 'tok' }],
});
});
test('isolates a provider that throws — others still authenticate', async () => {
// Regression: if any provider's authenticate() rejects, the
// fan-out must NOT sink the others. Previously the call used
// Promise.all, which propagated the first rejection.
const flakyAgent = new MockAgent('claude');
flakyAgent.getProtectedResources = () => [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'], required: true }];
flakyAgent.authenticate = async () => { throw new Error('proxy bind failed'); };
disposables.add(toDisposable(() => flakyAgent.dispose()));
service.registerProvider(copilotAgent);
service.registerProvider(flakyAgent);
const result = await service.authenticate({ resource: 'https://api.github.com', token: 'tok' });
assert.deepStrictEqual({
result,
copilotCalls: copilotAgent.authenticateCalls,
}, {
result: { authenticated: true },
copilotCalls: [{ resource: 'https://api.github.com', token: 'tok' }],
});
});
test('reports not authenticated when every matching provider rejects', async () => {
// All matching providers fail — the result must be
// { authenticated: false } rather than a thrown error.
const flakyA = new MockAgent('claude');
const flakyB = new MockAgent('mock');
flakyA.getProtectedResources = () => [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'], required: true }];
flakyB.getProtectedResources = () => [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'], required: true }];
flakyA.authenticate = async () => { throw new Error('A'); };
flakyB.authenticate = async () => { throw new Error('B'); };
disposables.add(toDisposable(() => flakyA.dispose()));
disposables.add(toDisposable(() => flakyB.dispose()));
service.registerProvider(flakyA);
service.registerProvider(flakyB);
const result = await service.authenticate({ resource: 'https://api.github.com', token: 'tok' });
assert.deepStrictEqual(result, { authenticated: false });
});
});
// ---- shutdown -------------------------------------------------------
suite('shutdown', () => {
test('shuts down all providers', async () => {
let copilotShutdown = false;
copilotAgent.shutdown = async () => { copilotShutdown = true; };
service.registerProvider(copilotAgent);
await service.shutdown();
assert.ok(copilotShutdown);
});
});
// ---- restoreSession -------------------------------------------------
suite('restoreSession', () => {
async function waitForDraft(db: TestSessionDatabase, chat: URI, expected: unknown): Promise<void> {
for (let i = 0; i < 20; i++) {
if (JSON.stringify(await db.getChatDraft(chat)) === JSON.stringify(expected)) {
return;
}
await new Promise(resolve => setTimeout(resolve, 5));
}
assert.deepStrictEqual(await db.getChatDraft(chat), expected);
}
test('restores the AH-owned workspaceless marker onto the summary _meta for any agent', async () => {
// The workspace-less marker is owned by the AH service and overlaid on
// restore from the central session DB — the agent (MockAgent) re-emits
// nothing itself, yet the restored session still carries the tag.
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(copilotAgent);
await copilotAgent.createSession();
const sessionResource = (await copilotAgent.listSessions())[0].session;
copilotAgent.sessionMessages = [];
await db.setMetadata('agentHost.workspaceless', 'true');
await localService.restoreSession(sessionResource);
assert.deepStrictEqual(localService.stateManager.getSessionState(sessionResource.toString())?._meta, { workspaceless: true });
});
test('restores persisted multi-root metadata', async () => {
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(copilotAgent);
await copilotAgent.createSession();
const sessionResource = (await copilotAgent.listSessions())[0].session;
copilotAgent.sessionMessages = [];
copilotAgent.sessionMetadataOverrides = {
_meta: { multiRoot: { workspaceFile: 'file:///provider-spoof.code-workspace', name: 'Spoof' } },
};
const multiRoot = {
workspaceFile: 'vscode-remote://ssh-remote+host/work/demo.code-workspace',
name: 'Demo Workspace',
};
await db.setMetadata(SESSION_META_MULTI_ROOT_KEY, JSON.stringify(multiRoot));
await localService.restoreSession(sessionResource);
assert.deepStrictEqual(readSessionMultiRootMetadata(localService.stateManager.getSessionState(sessionResource.toString())?._meta), multiRoot);
});
test('restores a session with message history', async () => {
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi there!', toolRequests: [] },
];
await service.restoreSession(sessionResource);
const state = service.stateManager.getSessionState(sessionResource.toString());
assert.ok(state, 'session should be in state manager');
assert.strictEqual(state!.lifecycle, SessionLifecycle.Ready);
assert.strictEqual(state!.turns.length, 1);
assert.strictEqual(state!.turns[0].message.text, 'Hello');
const mdPart = state!.turns[0].responseParts.find((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown);
assert.ok(mdPart);
assert.strictEqual(mdPart.content, 'Hi there!');
assert.strictEqual(state!.turns[0].state, TurnState.Complete);
});
test('re-attaches persisted turn usage on restore', async () => {
// Providers don't durably record token/credit usage (the Copilot
// SDK's `assistant.usage` event is explicitly ephemeral), so without
// the host-side overlay a reloaded session comes back with no
// context-usage gauge and a session cost of 0.
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessionResource = (await copilotAgent.listSessions())[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi there!', toolRequests: [] },
];
await db.setTurnUsage('msg-1', JSON.stringify({ inputTokens: 100, outputTokens: 20, model: 'gpt-5' }));
await localService.restoreSession(sessionResource);
assert.deepStrictEqual(
localService.stateManager.getSessionState(sessionResource.toString())?.turns.map(t => t.usage),
[{ inputTokens: 100, outputTokens: 20, model: 'gpt-5' }],
);
});
test('re-attaches usage over an Auto-model stub, preserving the routing metadata', async () => {
// A turn that ran on Copilot Auto is restored with a token-less stub
// (`{ model, _meta: { autoModeResolved } }`, see mapSessionEvents)
// because the routing decision IS persisted while the usage event is
// not. Treating that stub as "already has usage" would skip exactly
// the turns needing re-attachment — and Auto is the default model.
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const autoModeResolved = { chosenModel: 'claude-opus-4.8', predictedLabel: 'needs_reasoning', confidence: 0.93 };
const agent = disposables.add(new MockAgent('copilot'));
agent.turnUsageOverride = { model: 'claude-opus-4.8', _meta: { autoModeResolved } };
localService.registerProvider(agent);
const { session } = await agent.createSession();
const sessionResource = (await agent.listSessions())[0].session;
agent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi there!', toolRequests: [] },
];
await db.setTurnUsage('msg-1', JSON.stringify({ inputTokens: 100, outputTokens: 20, model: 'claude-opus-4.8', _meta: { copilotUsage: { totalNanoAiu: 5_000_000_000 } } }));
await localService.restoreSession(sessionResource);
assert.deepStrictEqual(
localService.stateManager.getSessionState(sessionResource.toString())?.turns.map(t => t.usage),
[{
inputTokens: 100,
outputTokens: 20,
model: 'claude-opus-4.8',
// The stub's routing metadata survives alongside the persisted usage.
_meta: { autoModeResolved, copilotUsage: { totalNanoAiu: 5_000_000_000 } },
}],
);
});
test('interleaves persisted host-injected local turns after their anchor on restore', async () => {
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessionResource = (await copilotAgent.listSessions())[0].session;
const defaultChatUri = buildDefaultChatUri(sessionResource.toString());
// SDK transcript reconstructs a single real turn keyed by the user
// envelope id (`msg-real`, per mapSessionEvents).
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-real', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-real-a', content: 'Hi there!', toolRequests: [] },
];
// A host-injected local turn anchored after the real turn, plus one
// with no anchor (precedes any real turn), plus an orphan whose
// anchor is absent from the SDK transcript (should be dropped).
const localTurn = (id: string, text: string) => ({ id, message: { text, origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined, state: TurnState.Complete });
await db.insertLocalTurn({ turnId: 'local-head', chatUri: defaultChatUri, anchorTurnId: undefined, seq: 1, payload: JSON.stringify(localTurn('local-head', '!pwd')) });
await db.insertLocalTurn({ turnId: 'local-after', chatUri: defaultChatUri, anchorTurnId: 'msg-real', seq: 2, payload: JSON.stringify(localTurn('local-after', '!ls')) });
await db.insertLocalTurn({ turnId: 'local-orphan', chatUri: defaultChatUri, anchorTurnId: 'gone', seq: 3, payload: JSON.stringify(localTurn('local-orphan', '!echo')) });
await localService.restoreSession(sessionResource);
const state = localService.stateManager.getSessionState(sessionResource.toString());
// head (no anchor) first, then the real turn, then its anchored local; orphan dropped.
assert.deepStrictEqual(state!.turns.map(t => t.id), ['local-head', 'msg-real', 'local-after']);
});
test('restores the default chat\'s independently-renamed title', async () => {
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(copilotAgent);
await copilotAgent.createSession();
const sessionResource = (await copilotAgent.listSessions())[0].session;
copilotAgent.sessionMessages = [];
// The host persists an independent default-chat rename under this key;
// restore must seed it back or the main chat tab reverts to the session title.
const defaultChatUri = buildDefaultChatUri(sessionResource.toString());
await db.setMetadata(`customChatTitle:${defaultChatUri}`, 'Renamed Default Chat');
await localService.restoreSession(sessionResource);
const state = localService.stateManager.getSessionState(sessionResource.toString());
assert.strictEqual(state?.chats.find(c => c.resource === defaultChatUri)?.title, 'Renamed Default Chat');
});
test('persists chat drafts to session metadata', async () => {
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(copilotAgent);
const session = await localService.createSession({ provider: 'copilot' });
const draft = {
text: 'draft text',
origin: { kind: MessageKind.User },
model: { id: 'opus-4.7' },
agent: { uri: 'agent://reviewer' },
};
localService.dispatchAction(buildDefaultChatUri(session.toString()), {
type: ActionType.ChatDraftChanged,
draft,
}, 'test-client', 1);
await waitForDraft(db, URI.parse(buildDefaultChatUri(session.toString())), draft);
});
test('restores chat drafts from session metadata', async () => {
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessionResource = (await copilotAgent.listSessions())[0].session;
const draft = {
text: 'restored draft',
origin: { kind: MessageKind.User },
model: { id: 'opus-4.7' },
agent: { uri: 'agent://reviewer' },
};
await db.setChatDraft(URI.parse(buildDefaultChatUri(sessionResource.toString())), draft);
(copilotAgent as MockAgent & { getChatDraft(chat: URI): Promise<typeof draft | undefined> }).getChatDraft = chat => db.getChatDraft(chat) as Promise<typeof draft | undefined>;
copilotAgent.sessionMessages = [];
await localService.restoreSession(sessionResource);
assert.deepStrictEqual(localService.stateManager.getSessionState(session.toString())?.draft, draft);
});
test('restores a session with tool calls', async () => {
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Run a command', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'I will run a command.', toolRequests: [{ toolCallId: 'tc-1', name: 'shell' }] },
{ type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'shell', displayName: 'Shell', invocationMessage: 'Running command...' },
{ type: 'tool_complete', session, toolCallId: 'tc-1', result: { success: true, pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'output' }] } },
{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Done!', toolRequests: [] },
];
await service.restoreSession(sessionResource);
const state = service.stateManager.getSessionState(sessionResource.toString());
assert.ok(state);
const turn = state!.turns[0];
const toolCallParts = turn.responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);
assert.strictEqual(toolCallParts.length, 1);
const tc = toolCallParts[0].toolCall as ToolCallCompletedState;
assert.strictEqual(tc.status, ToolCallStatus.Completed);
assert.strictEqual(tc.toolCallId, 'tc-1');
assert.strictEqual(tc.confirmed, ToolCallConfirmationReason.NotNeeded);
});
test('interleaves reasoning, markdown, and tool calls in stream order on resume', async () => {
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'u-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'a-1', content: 'Reply A', reasoningText: 'Thinking A', toolRequests: [{ toolCallId: 'tc-1', name: 'shell' }] },
{ type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'shell', displayName: 'Shell', invocationMessage: 'Running...' },
{ type: 'tool_complete', session, toolCallId: 'tc-1', result: { success: true, pastTenseMessage: 'Ran', content: [{ type: ToolResultContentType.Text, text: 'ok' }] } },
{ type: 'message', session, role: 'assistant', messageId: 'a-2', content: 'Reply B', reasoningText: 'Thinking B', toolRequests: [] },
];
await service.restoreSession(sessionResource);
const state = service.stateManager.getSessionState(sessionResource.toString());
assert.ok(state);
const turn = state!.turns[0];
const summary = turn.responseParts.map(p => {
if (p.kind === ResponsePartKind.Reasoning) { return ['reasoning', p.content]; }
if (p.kind === ResponsePartKind.Markdown) { return ['markdown', p.content]; }
if (p.kind === ResponsePartKind.ToolCall) { return ['toolCall', p.toolCall.toolCallId]; }
return ['other'];
});
assert.deepStrictEqual(summary, [
['reasoning', 'Thinking A'],
['markdown', 'Reply A'],
['toolCall', 'tc-1'],
['reasoning', 'Thinking B'],
['markdown', 'Reply B'],
]);
});
test('flushes interrupted turns', async () => {
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Interrupted', toolRequests: [] },
{ type: 'message', session, role: 'user', messageId: 'msg-2', content: 'Retried', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Answer', toolRequests: [] },
];
await service.restoreSession(sessionResource);
const state = service.stateManager.getSessionState(sessionResource.toString());
assert.ok(state);
assert.strictEqual(state!.turns.length, 2);
assert.strictEqual(state!.turns[0].state, TurnState.Cancelled);
assert.strictEqual(state!.turns[1].state, TurnState.Complete);
});
test('throws when session is not found on backend', async () => {
service.registerProvider(copilotAgent);
await assert.rejects(
() => service.restoreSession(AgentSession.uri('copilot', 'nonexistent')),
/Session not found on backend/,
);
});
test('restores known session without listing all provider sessions', async () => {
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
service.stateManager.deleteSession(session.toString());
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
];
let listSessionsCalled = false;
copilotAgent.listSessions = async () => {
listSessionsCalled = true;
throw new Error('restoreSession should not enumerate sessions');
};
await service.restoreSession(session);
assert.strictEqual(listSessionsCalled, false);
assert.ok(service.stateManager.getSessionState(session.toString()));
});
test('falls back to listing sessions when direct metadata restore fails', async () => {
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
service.stateManager.deleteSession(session.toString());
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
];
copilotAgent.getSessionMetadata = async () => {
throw new Error('direct metadata unavailable');
};
const originalListSessions = copilotAgent.listSessions.bind(copilotAgent);
let listSessionsCalled = false;
copilotAgent.listSessions = async () => {
listSessionsCalled = true;
return originalListSessions();
};
await service.restoreSession(session);
assert.deepStrictEqual({
listSessionsCalled,
restored: !!service.stateManager.getSessionState(session.toString()),
}, {
listSessionsCalled: true,
restored: true,
});
});
test('coalesces concurrent restores for the same session', async () => {
class BlockingRestoreAgent extends MockAgent {
readonly metadataReached = new DeferredPromise<void>();
readonly metadataGate = new DeferredPromise<void>();
getSessionMetadataCalls = 0;
getSessionMessagesCalls = 0;
override async getSessionMetadata(session: URI) {
this.getSessionMetadataCalls++;
this.metadataReached.complete();
await this.metadataGate.p;
return super.getSessionMetadata(session);
}
override async getSessionMessages(session: URI): Promise<readonly Turn[]> {
this.getSessionMessagesCalls++;
return super.getSessionMessages(session);
}
}
const agent = disposables.add(new BlockingRestoreAgent('copilot'));
service.registerProvider(agent);
const { session } = await agent.createSession();
service.stateManager.deleteSession(session.toString());
agent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
];
const firstRestore = service.restoreSession(session);
await agent.metadataReached.p;
const secondRestore = service.restoreSession(session);
assert.strictEqual(agent.getSessionMetadataCalls, 1);
agent.metadataGate.complete();
await Promise.all([firstRestore, secondRestore]);
assert.deepStrictEqual({
metadataCalls: agent.getSessionMetadataCalls,
messageCalls: agent.getSessionMessagesCalls,
restored: !!service.stateManager.getSessionState(session.toString()),
}, {
metadataCalls: 1,
messageCalls: 1,
restored: true,
});
});
test('hydrates session customizations when restoring an existing session', async () => {
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
service.stateManager.deleteSession(session.toString());
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
];
let getSessionCustomizationsCalls = 0;
copilotAgent.getSessionCustomizations = async () => {
getSessionCustomizationsCalls++;
return [
{ type: CustomizationType.Plugin, id: customizationId('file:///restore-skill'), uri: 'file:///restore-skill', name: 'Restore Skill', enabled: true },
];
};
await service.restoreSession(session);
const customizations = service.stateManager.getSessionState(session.toString())?.customizations;
assert.strictEqual(getSessionCustomizationsCalls, 1);
assert.strictEqual(customizations?.length, 1);
assert.strictEqual(customizations?.[0]?.type, CustomizationType.Plugin);
assert.strictEqual(customizations?.[0]?.name, 'Restore Skill');
assert.strictEqual(customizations?.[0]?.id, customizationId('file:///restore-skill'));
assert.strictEqual(customizations?.[0]?.enabled, true);
});
test('clears failed restore attempts so sessions can be retried', async () => {
class FailingOnceRestoreAgent extends MockAgent {
shouldFailRestore = true;
getSessionMessagesCalls = 0;
override async getSessionMessages(session: URI): Promise<readonly Turn[]> {
this.getSessionMessagesCalls++;
if (this.shouldFailRestore) {
throw new Error('restore failed');
}
return super.getSessionMessages(session);
}
}
const agent = disposables.add(new FailingOnceRestoreAgent('copilot'));
service.registerProvider(agent);
const { session } = await agent.createSession();
service.stateManager.deleteSession(session.toString());
agent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
];
await assert.rejects(() => service.restoreSession(session), /restore failed/);
agent.shouldFailRestore = false;
await service.restoreSession(session);
assert.deepStrictEqual({
messageCalls: agent.getSessionMessagesCalls,
restored: !!service.stateManager.getSessionState(session.toString()),
}, {
messageCalls: 2,
restored: true,
});
});
test('restores a session with subagent tool calls', async () => {
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Review this code', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: '', toolRequests: [{ toolCallId: 'tc-sub', name: 'task' }] },
{ type: 'tool_start', session, toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...', toolKind: 'subagent' as const, subagentDescription: 'Find related files', subagentAgentName: 'explore' },
{ type: 'subagent_started', session, toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores the codebase' },
// Inner tool calls from the subagent (have parentToolCallId)
{ type: 'tool_start', session, toolCallId: 'tc-inner-1', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running ls...', parentToolCallId: 'tc-sub' },
{ type: 'tool_complete', session, toolCallId: 'tc-inner-1', result: { success: true, pastTenseMessage: 'Ran ls', content: [{ type: ToolResultContentType.Text, text: 'file1.ts' }] }, parentToolCallId: 'tc-sub' },
{ type: 'tool_start', session, toolCallId: 'tc-inner-2', toolName: 'view', displayName: 'View File', invocationMessage: 'Reading file1.ts', parentToolCallId: 'tc-sub' },
{ type: 'tool_complete', session, toolCallId: 'tc-inner-2', result: { success: true, pastTenseMessage: 'Read file1.ts' }, parentToolCallId: 'tc-sub' },
// Parent tool completes
{ type: 'tool_complete', session, toolCallId: 'tc-sub', result: { success: true, pastTenseMessage: 'Delegated task', content: [{ type: ToolResultContentType.Text, text: 'Found 3 issues' }] } },
{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'The review found 3 issues.', toolRequests: [] },
];
await service.restoreSession(sessionResource);
const state = service.stateManager.getSessionState(sessionResource.toString());
assert.ok(state);
// Should produce exactly one turn
assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}`);
const turn = state!.turns[0];
assert.strictEqual(turn.message.text, 'Review this code');
// The parent turn should only have the parent tool call — inner
// tool calls are excluded from the parent and belong to the
// child subagent session instead.
const toolCallParts = turn.responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);
assert.strictEqual(toolCallParts.length, 1, `Expected 1 tool call (parent only) but got ${toolCallParts.length}`);
// Parent subagent tool call
const parentTc = toolCallParts[0].toolCall as ToolCallCompletedState;
assert.strictEqual(parentTc.toolCallId, 'tc-sub');
assert.strictEqual(parentTc.status, ToolCallStatus.Completed);
assert.strictEqual(parentTc._meta?.toolKind, 'subagent');
assert.strictEqual(parentTc._meta?.subagentDescription, 'Find related files');
assert.strictEqual(parentTc._meta?.subagentAgentName, 'explore');
// Parent tool should have subagent content entry
const content = parentTc.content ?? [];
const subagentEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent);
assert.ok(subagentEntry, 'Completed tool call should have subagent content entry');
// Subscribing to the child session should restore it with inner tool calls
const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), 'tc-sub');
const snapshot = await service.subscribe(URI.parse(childSessionUri), 'client-test');
const childState = service.stateManager.getSessionState(childSessionUri);
assert.ok(snapshot?.state, 'Child session snapshot should exist');
assert.ok(childState, 'Child session state should exist');
assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn');
const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);
assert.strictEqual(childToolParts.length, 2, `Child session should have 2 inner tool calls but got ${childToolParts.length}`);
assert.ok(childToolParts.some(p => p.toolCall.toolCallId === 'tc-inner-1'), 'Should have tc-inner-1');
assert.ok(childToolParts.some(p => p.toolCall.toolCallId === 'tc-inner-2'), 'Should have tc-inner-2');
// The turn should also have the final markdown
const mdParts = turn.responseParts.filter((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown);
assert.ok(mdParts.some(p => p.content.includes('3 issues')), 'Should have the final markdown response');
});
test('inner assistant messages from subagent do not create extra turns (fixture)', async () => {
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
// Load real SDK events from fixture (sanitized from ~/.copilot/session-state/)
copilotAgent.sessionMessages = await loadFixtureMessages('subagent-session.jsonl', session);
await service.restoreSession(sessionResource);
const state = service.stateManager.getSessionState(sessionResource.toString());
assert.ok(state);
assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}: ${state!.turns.map(t => `"${t.message.text.substring(0, 40)}"`).join(', ')}`);
assert.strictEqual(state!.turns[0].message.text, 'Run a sync subagent to do some searches, just testing subagent rendering');
assert.strictEqual(state!.turns[0].state, TurnState.Complete);
// Should have the parent subagent tool call with subagent content
const toolCallParts = state!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);
const parentTc = toolCallParts.find(p => p.toolCall.toolName === 'task');
assert.ok(parentTc, 'Should have a task tool call');
assert.strictEqual(parentTc!.toolCall._meta?.toolKind, 'subagent');
// Inner tool calls should NOT be in the parent turn — they belong
// to the child subagent session.
const parentToolCallId = parentTc!.toolCall.toolCallId;
const nonParentTools = toolCallParts.filter(p => p.toolCall.toolCallId !== parentToolCallId);
assert.strictEqual(nonParentTools.length, 0, `Parent turn should only contain the task tool call, but found ${nonParentTools.length} extra tool calls`);
// Subscribe to the child subagent session and verify inner tools
const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), parentToolCallId);
const snapshot = await service.subscribe(URI.parse(childSessionUri), 'client-test');
assert.ok(snapshot?.state, 'Child session snapshot should exist');
const childState = service.stateManager.getSessionState(childSessionUri);
assert.ok(childState, 'Child session state should exist');
assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn');
const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);
assert.ok(childToolParts.length > 0, `Child session should have inner tool calls but got ${childToolParts.length}`);
// Should have the final markdown
const mdParts = state!.turns[0].responseParts.filter((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown);
assert.ok(mdParts.length > 0, 'Should have markdown content');
});
test('eagerly registers subagent child sessions during parent restore', async () => {
// An agent that surfaces its subagent children from the parent's
// reconstructed history, exercising the eager-registration path.
class EagerSubagentMockAgent extends MockAgent {
async getSubagentSessions(session: URI): Promise<readonly IRestoredSubagentSession[]> {
if (parseSubagentSessionUri(session)) {
return [];
}
const parent = session.toString();
const out: IRestoredSubagentSession[] = [];
const seen = new Set<string>();
for (const rec of this.sessionMessages) {
if (rec.type === 'subagent_started' && !seen.has(rec.toolCallId)) {
seen.add(rec.toolCallId);
const childUri = buildSubagentSessionUri(parent, rec.toolCallId);
const turns = await this.getSessionMessages(URI.parse(childUri));
if (turns.length > 0) {
out.push({ resource: URI.parse(childUri), toolCallId: rec.toolCallId, title: rec.agentDisplayName, turns });
}
}
}
return out;
}
}
const agent = new EagerSubagentMockAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
service.registerProvider(agent);
const { session } = await agent.createSession();
const sessions = await agent.listSessions();
const sessionResource = sessions[0].session;
agent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Review this code', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: '', toolRequests: [{ toolCallId: 'tc-sub', name: 'task' }] },
{ type: 'tool_start', session, toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...', toolKind: 'subagent' as const, subagentDescription: 'Find related files', subagentAgentName: 'explore' },
{ type: 'subagent_started', session, toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores the codebase' },
{ type: 'tool_start', session, toolCallId: 'tc-inner-1', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running ls...', parentToolCallId: 'tc-sub' },
{ type: 'tool_complete', session, toolCallId: 'tc-inner-1', result: { success: true, pastTenseMessage: 'Ran ls', content: [{ type: ToolResultContentType.Text, text: 'file1.ts' }] }, parentToolCallId: 'tc-sub' },
{ type: 'tool_complete', session, toolCallId: 'tc-sub', result: { success: true, pastTenseMessage: 'Delegated task', content: [{ type: ToolResultContentType.Text, text: 'Found 3 issues' }] } },
];
await service.restoreSession(sessionResource);
// The subagent child state must already exist WITHOUT any client
// subscribing to it: parent restore registered it eagerly.
const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), 'tc-sub');
const childState = service.stateManager.getSessionState(childSessionUri);
assert.ok(childState, 'subagent child should be eagerly registered during parent restore');
assert.strictEqual(childState!.turns.length, 1, 'child should have its reconstructed turn');
const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);
assert.ok(childToolParts.some(p => p.toolCall.toolCallId === 'tc-inner-1'), 'child should contain the inner tool call');
});
test('inner assistant messages from subagent route via envelope agentId (fixture)', async () => {
// Regression for the SDK migration away from the deprecated
// `data.parentToolCallId` to the envelope-level `agentId`. Newer
// session logs only tag subagent events with `agentId`, so the
// reopen/replay path must resolve those back to the parent tool
// call id — otherwise the subagent's assistant messages leak into
// the main session as extra turns.
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = await loadFixtureMessages('subagent-session-agentid.jsonl', session);
await service.restoreSession(sessionResource);
const state = service.stateManager.getSessionState(sessionResource.toString());
assert.ok(state);
assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}: ${state!.turns.map(t => `"${t.message.text.substring(0, 40)}"`).join(', ')}`);
assert.strictEqual(state!.turns[0].message.text, 'Run a sync subagent to do some searches, just testing subagent rendering');
assert.strictEqual(state!.turns[0].state, TurnState.Complete);
// Should have the parent subagent tool call with subagent content.
const toolCallParts = state!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);
const parentTc = toolCallParts.find(p => p.toolCall.toolName === 'task');
assert.ok(parentTc, 'Should have a task tool call');
assert.strictEqual(parentTc!.toolCall._meta?.toolKind, 'subagent');
// Inner tool calls should NOT be in the parent turn — they belong
// to the child subagent session.
const parentToolCallId = parentTc!.toolCall.toolCallId;
const nonParentTools = toolCallParts.filter(p => p.toolCall.toolCallId !== parentToolCallId);
assert.strictEqual(nonParentTools.length, 0, `Parent turn should only contain the task tool call, but found ${nonParentTools.length} extra tool calls`);
// The subagent's inner assistant message must not surface in the
// parent transcript.
const mdParts = state!.turns[0].responseParts.filter((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown);
assert.ok(
mdParts.every(p => !p.content.startsWith('Perfect! I now have enough information')),
'Subagent inner assistant message should not leak into the parent turn',
);
assert.ok(mdParts.length > 0, 'Should have markdown content');
// Subscribe to the child subagent session and verify inner tools
// and the subagent's assistant message landed there.
const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), parentToolCallId);
const snapshot = await service.subscribe(URI.parse(childSessionUri), 'client-test');
assert.ok(snapshot?.state, 'Child session snapshot should exist');
const childState = service.stateManager.getSessionState(childSessionUri);
assert.ok(childState, 'Child session state should exist');
assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn');
const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);
assert.ok(childToolParts.length > 0, `Child session should have inner tool calls but got ${childToolParts.length}`);
});
test('coalesces concurrent restores for the same subagent session', async () => {
class BlockingSubagentAgent extends MockAgent {
readonly subagentReached = new DeferredPromise<void>();
readonly subagentGate = new DeferredPromise<void>();
subagentGetSessionMessagesCalls = 0;
override async getSessionMessages(session: URI): Promise<readonly Turn[]> {
if (parseSubagentSessionUri(session)) {
this.subagentGetSessionMessagesCalls++;
this.subagentReached.complete();
await this.subagentGate.p;
}
return super.getSessionMessages(session);
}
}
const agent = disposables.add(new BlockingSubagentAgent('copilot'));
service.registerProvider(agent);
const { session } = await agent.createSession();
const sessions = await agent.listSessions();
const sessionResource = sessions[0].session;
agent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Review', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: '', toolRequests: [{ toolCallId: 'tc-sub', name: 'task' }] },
{ type: 'tool_start', session, toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...', toolKind: 'subagent' as const, subagentDescription: 'Find related files', subagentAgentName: 'explore' },
{ type: 'subagent_started', session, toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores the codebase' },
{ type: 'tool_start', session, toolCallId: 'tc-inner', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running ls...', parentToolCallId: 'tc-sub' },
{ type: 'tool_complete', session, toolCallId: 'tc-inner', result: { success: true, pastTenseMessage: 'Ran ls', content: [{ type: ToolResultContentType.Text, text: 'file1.ts' }] }, parentToolCallId: 'tc-sub' },
{ type: 'tool_complete', session, toolCallId: 'tc-sub', result: { success: true, pastTenseMessage: 'Delegated task', content: [{ type: ToolResultContentType.Text, text: 'Found files' }] } },
{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Done.', toolRequests: [] },
];
await service.restoreSession(sessionResource);
const childSessionUri = URI.parse(buildSubagentSessionUri(sessionResource.toString(), 'tc-sub'));
const firstSubscribe = service.subscribe(childSessionUri, 'client-1');
await agent.subagentReached.p;
const secondSubscribe = service.subscribe(childSessionUri, 'client-2');
assert.strictEqual(agent.subagentGetSessionMessagesCalls, 1);
agent.subagentGate.complete();
await Promise.all([firstSubscribe, secondSubscribe]);
assert.deepStrictEqual({
messageCalls: agent.subagentGetSessionMessagesCalls,
childTurns: service.stateManager.getSessionState(childSessionUri.toString())?.turns.length,
}, {
messageCalls: 1,
childTurns: 1,
});
});
});
// ---- createChat (multi-chat) ----------------------------------------
suite('createChat', () => {
test('routes to the provider for a restored session not tracked in the provider map', async () => {
// A session restored after a host restart lives in the state manager
// but is not recorded in the session→provider map (only createSession
// records that). createChat must still resolve the provider via the
// scheme fallback instead of throwing `no provider for session`.
const created: { session: string; chat: string }[] = [];
class MultiChatAgent extends MockAgent {
override async createChat(session: URI, chat: URI): Promise<void> {
created.push({ session: session.toString(), chat: chat.toString() });
}
}
const agent = disposables.add(new MultiChatAgent('copilot'));
service.registerProvider(agent);
const { session } = await agent.createSession();
// Drop any tracking so only the scheme fallback can resolve the agent.
service.stateManager.deleteSession(session.toString());
await service.restoreSession(session);
const chatUri = URI.parse(buildChatUri(session, 'peer-1'));
await service.createChat(session, chatUri);
const state = service.stateManager.getSessionState(session.toString());
assert.deepStrictEqual({
created,
inCatalog: !!state?.chats.some(c => c.resource.toString() === chatUri.toString()),
}, {
created: [{ session: session.toString(), chat: chatUri.toString() }],
inCatalog: true,
});
});
test('routes a tracked session and registers the chat with its title in the catalog', async () => {
class MultiChatAgent extends MockAgent {
override async createChat(_session: URI, _chat: URI): Promise<void> { }
}
const agent = disposables.add(new MultiChatAgent('copilot'));
service.registerProvider(agent);
const session = await service.createSession({ provider: 'copilot' });
const chatUri = URI.parse(buildChatUri(session, 'peer-1'));
await service.createChat(session, chatUri, { title: 'Peer Chat' });
const state = service.stateManager.getSessionState(session.toString());
assert.deepStrictEqual(
state?.chats.find(c => c.resource.toString() === chatUri.toString())?.title,
'Peer Chat',
);
});
test('creates the backing chat before registering the chat in the catalog', async () => {
let catalogHadChatDuringCreate: boolean | undefined;
class MultiChatAgent extends MockAgent {
override async createChat(session: URI, chat: URI): Promise<void> {
const state = service.stateManager.getSessionState(session.toString());
catalogHadChatDuringCreate = !!state?.chats.some(c => c.resource.toString() === chat.toString());
}
}
const agent = disposables.add(new MultiChatAgent('copilot'));
service.registerProvider(agent);
const session = await service.createSession({ provider: 'copilot' });
const chatUri = URI.parse(buildChatUri(session, 'peer-1'));
await service.createChat(session, chatUri);
assert.strictEqual(catalogHadChatDuringCreate, false);
});
test('throws when the provider does not support multiple chats', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
const chatUri = URI.parse(buildChatUri(session, 'peer-1'));
await assert.rejects(
() => service.createChat(session, chatUri),
/does not support multiple chats/,
);
});
test('disposeChat removes the chat from the catalog and tears down the chat', async () => {
const disposed: string[] = [];
class MultiChatAgent extends MockAgent {
override async createChat(_session: URI, _chat: URI): Promise<void> { }
override async disposeChat(_session: URI, chat: URI): Promise<void> {
disposed.push(chat.toString());
}
}
const agent = disposables.add(new MultiChatAgent('copilot'));
service.registerProvider(agent);
const session = await service.createSession({ provider: 'copilot' });
const chatUri = URI.parse(buildChatUri(session, 'peer-1'));
await service.createChat(session, chatUri);
await service.disposeChat(session, chatUri);
const state = service.stateManager.getSessionState(session.toString());
assert.deepStrictEqual({
disposed,
inCatalog: !!state?.chats.some(c => c.resource.toString() === chatUri.toString()),
}, {
disposed: [chatUri.toString()],
inCatalog: false,
});
});
test('restoreSession preserves peer chat catalog order regardless of load timing', async () => {
class MultiChatAgent extends MockAgent {
override async createChat(_session: URI, _chat: URI): Promise<void> { }
override async getSessionMessages(session: URI): Promise<readonly Turn[]> {
// Resolve in the reverse of catalog order so a resolution-order
// append would scramble the catalog; the restore must keep a,b,c.
const delays: Record<string, number> = { 'peer-a': 30, 'peer-b': 15, 'peer-c': 0 };
await timeout(delays[parseChatUri(session)?.chatId ?? ''] ?? 0);
return [];
}
}
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new MultiChatAgent('copilot'));
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
// Seed the orchestrator catalog in a,b,c order via createChat.
await localService.createChat(session, URI.parse(buildChatUri(session, 'peer-a')));
await localService.createChat(session, URI.parse(buildChatUri(session, 'peer-b')));
await localService.createChat(session, URI.parse(buildChatUri(session, 'peer-c')));
localService.stateManager.deleteSession(session.toString());
await localService.restoreSession(session);
const state = localService.stateManager.getSessionState(session.toString());
const peerChatIds = (state?.chats ?? [])
.map(c => parseChatUri(c.resource)?.chatId)
.filter((id): id is string => !!id && id.startsWith('peer-'));
assert.deepStrictEqual(peerChatIds, ['peer-a', 'peer-b', 'peer-c']);
});
test('fork seeds the new chat with remapped source turns and forwards fork to the provider', async () => {
let receivedFork: IAgentCreateChatForkSource | undefined;
class MultiChatAgent extends MockAgent {
override async createChat(_session: URI, _chat: URI, options?: IAgentCreateChatOptions): Promise<void> {
receivedFork = options?.fork;
}
}
const agent = disposables.add(new MultiChatAgent('copilot'));
service.registerProvider(agent);
const session = await service.createSession({ provider: 'copilot' });
// Seed the source (default) chat with two turns and a title.
const sourceTurns: Turn[] = [
{ id: 't1', state: TurnState.Complete, message: { text: 'first', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined },
{ id: 't2', state: TurnState.Complete, message: { text: 'second', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined },
];
service.stateManager.seedDefaultChatTurns(session.toString(), sourceTurns);
service.stateManager.updateChatTitle(session.toString(), buildDefaultChatUri(session.toString()), 'My Session');
const chatUri = URI.parse(buildChatUri(session, 'peer-1'));
await service.createChat(session, chatUri, { fork: { source: session, turnId: 't1' } });
const newChatState = service.stateManager.getChatState(chatUri.toString());
const newTurnIds = newChatState?.turns.map(t => t.id) ?? [];
assert.deepStrictEqual({
forkSource: receivedFork?.source.toString(),
forkTurnId: receivedFork?.turnId,
mappingSize: receivedFork?.turnIdMapping?.size,
mappedFromT1: receivedFork?.turnIdMapping?.get('t1'),
newTurnCount: newTurnIds.length,
newTurnIsRemapped: newTurnIds[0] !== undefined && newTurnIds[0] !== 't1',
title: newChatState?.title,
}, {
forkSource: session.toString(),
forkTurnId: 't1',
mappingSize: 1,
mappedFromT1: newTurnIds[0],
newTurnCount: 1,
newTurnIsRemapped: true,
title: 'Forked: My Session',
});
});
test('fork with an unknown turn id drops the fork and seeds no turns', async () => {
let receivedFork: IAgentCreateChatForkSource | undefined;
class MultiChatAgent extends MockAgent {
override async createChat(_session: URI, _chat: URI, options?: IAgentCreateChatOptions): Promise<void> {
receivedFork = options?.fork;
}
}
const agent = disposables.add(new MultiChatAgent('copilot'));
service.registerProvider(agent);
const session = await service.createSession({ provider: 'copilot' });
const sourceTurns: Turn[] = [
{ id: 't1', state: TurnState.Complete, message: { text: 'first', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined },
];
service.stateManager.seedDefaultChatTurns(session.toString(), sourceTurns);
const chatUri = URI.parse(buildChatUri(session, 'peer-1'));
await service.createChat(session, chatUri, { fork: { source: session, turnId: 'missing' } });
const newChatState = service.stateManager.getChatState(chatUri.toString());
assert.deepStrictEqual({
forkForwarded: receivedFork !== undefined,
newTurnCount: newChatState?.turns.length ?? 0,
}, {
forkForwarded: false,
newTurnCount: 0,
});
});
test('fork at a host-injected local turn redirects the SDK boundary to the concrete anchor and carries the local turn into the new chat', async () => {
let receivedFork: IAgentCreateChatForkSource | undefined;
class MultiChatAgent extends MockAgent {
override async createChat(_session: URI, _chat: URI, options?: IAgentCreateChatOptions): Promise<void> {
receivedFork = options?.fork;
}
}
const db = new TestSessionDatabase();
const agent = disposables.add(new MultiChatAgent('copilot'));
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(agent);
const { session } = await agent.createSession();
const sessionResource = (await agent.listSessions())[0].session;
const defaultChatUri = buildDefaultChatUri(sessionResource.toString());
// SDK transcript reconstructs a single real turn keyed by the user
// message id; a host-injected local turn is persisted after it.
agent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'real-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'real-1-a', content: 'Hi', toolRequests: [] },
];
const localTurn: Turn = { id: 'local-1', state: TurnState.Complete, message: { text: '!echo hi', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined };
await db.insertLocalTurn({ turnId: 'local-1', chatUri: defaultChatUri, anchorTurnId: 'real-1', seq: 1, payload: JSON.stringify(localTurn) });
// Restore so the source chat interleaves [real-1, local-1] and the
// in-memory local index knows local-1 is a local turn.
await localService.restoreSession(sessionResource);
assert.deepStrictEqual(localService.stateManager.getSessionState(sessionResource.toString())?.turns.map(t => t.id), ['real-1', 'local-1']);
// Fork the default chat AT the local turn into a new peer chat.
const peerUri = URI.parse(buildChatUri(sessionResource, 'peer-1'));
await localService.createChat(sessionResource, peerUri, { fork: { source: URI.parse(defaultChatUri), turnId: 'local-1' } });
const peerTurns = localService.stateManager.getChatState(peerUri.toString())?.turns ?? [];
const forkedLocals = (await db.getLocalTurns()).filter(r => r.chatUri === peerUri.toString());
assert.deepStrictEqual({
// SDK fork boundary redirected from the local turn to its concrete anchor.
sdkForkTurnId: receivedFork?.turnId,
// New chat seeded with remapped copies of both turns.
peerTurnCount: peerTurns.length,
// The forked local turn is persisted under the new chat, anchored to
// the forked copy of the real turn.
forkedLocalCount: forkedLocals.length,
forkedLocalAnchor: forkedLocals[0]?.anchorTurnId,
anchorIsPeerFirstTurn: forkedLocals[0]?.anchorTurnId === peerTurns[0]?.id,
}, {
sdkForkTurnId: 'real-1',
peerTurnCount: 2,
forkedLocalCount: 1,
forkedLocalAnchor: peerTurns[0]?.id,
anchorIsPeerFirstTurn: true,
});
});
test('a peer chat backing session is filtered out of listSessions and stays filtered across a restart', async () => {
// Per-session databases so the backing SDK session's marker is
// isolated from the parent session's own database.
const dbs = new Map<string, TestSessionDatabase>();
const dbFor = (session: URI): TestSessionDatabase => {
const key = session.toString();
let db = dbs.get(key);
if (!db) {
db = new TestSessionDatabase();
dbs.set(key, db);
}
return db;
};
const perSessionDataService: ISessionDataService = {
...createSessionDataService(),
openDatabase: (session: URI): IReference<ISessionDatabase> => ({ object: dbFor(session), dispose: () => { } }),
tryOpenDatabase: async (session: URI): Promise<IReference<ISessionDatabase> | undefined> => ({ object: dbFor(session), dispose: () => { } }),
};
const backingSdkId = 'backing-sdk-id';
const backingUri = AgentSession.uri('copilot', backingSdkId).toString();
// A Claude-like agent whose peer-chat backing is a fresh SDK session
// it also enumerates from listSessions — the leak this fix suppresses.
class LeakyMultiChatAgent extends MockAgent {
override async createChat(_session: URI, _chat: URI): Promise<IAgentCreateChatResult> {
return { providerData: 'blob', backingSession: AgentSession.uri(this.id, backingSdkId) };
}
override async listSessions(): Promise<IAgentSessionMetadata[]> {
const base = await super.listSessions();
return [...base, { session: AgentSession.uri(this.id, backingSdkId), startTime: Date.now(), modifiedTime: Date.now() }];
}
}
const agent = disposables.add(new LeakyMultiChatAgent('copilot'));
const svc = disposables.add(new AgentService(new NullLogService(), fileService, perSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
svc.registerProvider(agent);
const session = await svc.createSession({ provider: 'copilot' });
const chatUri = URI.parse(buildChatUri(session, 'peer-1'));
await svc.createChat(session, chatUri);
const beforeRestart = await svc.listSessions();
// Simulate a host restart: a fresh service over the same persisted
// databases, with a fresh agent still leaking the backing session.
const restartAgent = disposables.add(new LeakyMultiChatAgent('copilot'));
const restarted = disposables.add(new AgentService(new NullLogService(), fileService, perSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
restarted.registerProvider(restartAgent);
const afterRestart = await restarted.listSessions();
assert.deepStrictEqual({
leakedBeforeRestart: beforeRestart.map(s => s.session.toString()).includes(backingUri),
markerPersisted: await dbFor(AgentSession.uri('copilot', backingSdkId)).getMetadata('peerChatBacking'),
leakedAfterRestart: afterRestart.map(s => s.session.toString()).includes(backingUri),
}, {
leakedBeforeRestart: false,
markerPersisted: chatUri.toString(),
leakedAfterRestart: false,
});
});
});
suite('createChat side chats', () => {
class SideChatAgent extends MockAgent {
lastCreateOptions: IAgentCreateChatOptions | undefined;
readonly chatMessages = new Map<string, readonly Turn[]>();
override async createChat(_session: URI, _chat: URI, options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> {
this.lastCreateOptions = options;
}
override async getSessionMessages(chat: URI): Promise<readonly Turn[]> {
return this.chatMessages.get(chat.toString()) ?? super.getSessionMessages(chat);
}
}
function completedTurn(id: string, userText = 'user text', assistantText = 'assistant text'): Turn {
return {
id,
state: TurnState.Complete,
message: { text: userText, origin: { kind: MessageKind.User } },
responseParts: [{ kind: ResponsePartKind.Markdown, id: `${id}-md`, content: assistantText }],
usage: undefined,
};
}
test('rejects a side chat whose source turn does not exist', async () => {
const agent = disposables.add(new SideChatAgent('copilot'));
service.registerProvider(agent);
const session = await service.createSession({ provider: 'copilot' });
const chatUri = URI.parse(buildChatUri(session, 'side-1'));
await assert.rejects(
() => service.createChat(session, chatUri, { sideChat: { source: session, turnId: 'missing' } }),
/side chat source turn/,
);
});
test('rejects an empty side-chat selection snapshot', async () => {
const agent = disposables.add(new SideChatAgent('copilot'));
service.registerProvider(agent);
const session = await service.createSession({ provider: 'copilot' });
service.stateManager.seedDefaultChatTurns(session.toString(), [completedTurn('t1')]);
const chatUri = URI.parse(buildChatUri(session, 'side-1'));
await assert.rejects(
() => service.createChat(session, chatUri, { sideChat: { source: session, turnId: 't1', selection: { text: ' \n ' } } }),
/selection text must be non-empty/,
);
});
test('rejects a side chat whose source chat is in a different session', async () => {
const agent = disposables.add(new SideChatAgent('copilot'));
service.registerProvider(agent);
const sessionA = await service.createSession({ provider: 'copilot' });
const sessionB = await service.createSession({ provider: 'copilot' });
service.stateManager.seedDefaultChatTurns(sessionB.toString(), [completedTurn('t1')]);
const chatUri = URI.parse(buildChatUri(sessionA, 'side-1'));
await assert.rejects(
() => service.createChat(sessionA, chatUri, { sideChat: { source: sessionB, turnId: 't1' } }),
/does not belong to session/,
);
});
test('creates a fresh peer with a SideChat origin and no copied source turns', async () => {
const agent = disposables.add(new SideChatAgent('copilot'));
service.registerProvider(agent);
const session = await service.createSession({ provider: 'copilot' });
service.stateManager.seedDefaultChatTurns(session.toString(), [completedTurn('t1'), completedTurn('t2')]);
const chatUri = URI.parse(buildChatUri(session, 'side-1'));
const defaultChatUri = buildDefaultChatUri(session);
const selection = { text: ' selected text ', responsePartId: 'response-part-1' };
await service.createChat(session, chatUri, { sideChat: { source: session, turnId: 't1', selection } });
const state = service.stateManager.getChatState(chatUri.toString());
assert.deepStrictEqual({
origin: state?.origin,
copiedTurns: state?.turns.length,
forkForwarded: agent.lastCreateOptions?.fork,
sideChatForwarded: agent.lastCreateOptions?.sideChat,
}, {
origin: { kind: ChatOriginKind.SideChat, chat: defaultChatUri, turnId: 't1', selection },
copiedTurns: 0,
forkForwarded: undefined,
sideChatForwarded: { source: URI.parse(defaultChatUri), turnId: 't1', selection },
});
});
test('creates a side chat from a completed local turn without losing its stable source turn identity', async () => {
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new SideChatAgent('copilot'));
localService.registerProvider(agent);
const { session } = await agent.createSession();
const sessionResource = (await agent.listSessions())[0].session;
const defaultChatUri = buildDefaultChatUri(sessionResource.toString());
agent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'real-1', content: 'first question', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'real-1-a', content: 'first answer', toolRequests: [] },
];
const localTurn: Turn = {
id: 'local-1',
state: TurnState.Complete,
message: { text: '!command', origin: { kind: MessageKind.User } },
responseParts: [],
usage: undefined,
};
await db.insertLocalTurn({ turnId: 'local-1', chatUri: defaultChatUri, anchorTurnId: 'real-1', seq: 1, payload: JSON.stringify(localTurn) });
await localService.restoreSession(sessionResource);
const chatUri = URI.parse(buildChatUri(sessionResource, 'side-local'));
await localService.createChat(sessionResource, chatUri, { sideChat: { source: URI.parse(defaultChatUri), turnId: 'local-1' } });
assert.deepStrictEqual({
origin: localService.stateManager.getChatState(chatUri.toString())?.origin,
sideChatForwarded: agent.lastCreateOptions?.sideChat && {
source: agent.lastCreateOptions.sideChat.source.toString(),
turnId: agent.lastCreateOptions.sideChat.turnId,
providerAnchorTurnId: agent.lastCreateOptions.sideChat.providerAnchorTurnId,
sourceContext: agent.lastCreateOptions.sideChat.sourceContext,
},
}, {
origin: { kind: ChatOriginKind.SideChat, chat: defaultChatUri, turnId: 'local-1' },
sideChatForwarded: {
source: defaultChatUri,
turnId: 'local-1',
providerAnchorTurnId: 'real-1',
sourceContext: 'User request:\nfirst question\n\nAgent response:\nfirst answer\n\n---\n\nUser request:\n!command',
},
});
});
test('creates a side chat from the current active turn', async () => {
const agent = disposables.add(new SideChatAgent('copilot'));
service.registerProvider(agent);
const session = await service.createSession({ provider: 'copilot' });
const sourceChat = buildDefaultChatUri(session);
service.dispatchAction(sourceChat, {
type: ActionType.ChatTurnStarted,
turnId: 'active-turn',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'still running', origin: { kind: MessageKind.User } },
}, 'test-client', 1);
service.stateManager.dispatchServerAction(sourceChat, {
type: ActionType.ChatResponsePart,
turnId: 'active-turn',
part: { kind: ResponsePartKind.Markdown, id: 'partial', content: 'partial answer' },
});
const chatUri = URI.parse(buildChatUri(session, 'side-active'));
await service.createChat(session, chatUri, { sideChat: { source: URI.parse(sourceChat), turnId: 'active-turn' } });
assert.deepStrictEqual({
sourceActiveTurn: service.stateManager.getChatState(sourceChat)?.activeTurn?.id,
origin: service.stateManager.getChatState(chatUri.toString())?.origin,
sideChatForwarded: agent.lastCreateOptions?.sideChat
? {
source: agent.lastCreateOptions.sideChat.source.toString(),
turnId: agent.lastCreateOptions.sideChat.turnId,
sourceContext: agent.lastCreateOptions.sideChat.sourceContext,
partialResponse: agent.lastCreateOptions.sideChat.partialResponse,
}
: undefined,
}, {
sourceActiveTurn: 'active-turn',
origin: { kind: ChatOriginKind.SideChat, chat: sourceChat, turnId: 'active-turn' },
sideChatForwarded: { source: sourceChat, turnId: 'active-turn', sourceContext: 'User request:\nstill running', partialResponse: 'partial answer' },
});
});
test('creates a side chat from a later active turn without losing the current user question', async () => {
const agent = disposables.add(new SideChatAgent('copilot'));
service.registerProvider(agent);
const session = await service.createSession({ provider: 'copilot' });
const sourceChat = buildDefaultChatUri(session);
service.stateManager.seedDefaultChatTurns(session.toString(), [completedTurn('t1', 'first question', 'first answer')]);
service.dispatchAction(sourceChat, {
type: ActionType.ChatTurnStarted,
turnId: 'active-turn',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'second question', origin: { kind: MessageKind.User } },
}, 'test-client', 1);
service.stateManager.dispatchServerAction(sourceChat, {
type: ActionType.ChatResponsePart,
turnId: 'active-turn',
part: { kind: ResponsePartKind.Markdown, id: 'partial', content: 'partial answer' },
});
const chatUri = URI.parse(buildChatUri(session, 'side-active-later'));
await service.createChat(session, chatUri, { sideChat: { source: URI.parse(sourceChat), turnId: 'active-turn' } });
assert.deepStrictEqual(agent.lastCreateOptions?.sideChat && {
source: agent.lastCreateOptions.sideChat.source.toString(),
turnId: agent.lastCreateOptions.sideChat.turnId,
sourceContext: agent.lastCreateOptions.sideChat.sourceContext,
partialResponse: agent.lastCreateOptions.sideChat.partialResponse,
}, {
source: sourceChat,
turnId: 'active-turn',
sourceContext: 'User request:\nfirst question\n\nAgent response:\nfirst answer\n\n---\n\nUser request:\nsecond question',
partialResponse: 'partial answer',
});
});
test('persists and restores the SideChat origin', async () => {
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new SideChatAgent('copilot'));
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
localService.stateManager.seedDefaultChatTurns(session.toString(), [completedTurn('t1')]);
const chatUri = URI.parse(buildChatUri(session, 'side-1'));
const defaultChatUri = buildDefaultChatUri(session);
const selection = { text: ' selected text ', responsePartId: 'response-part-1' };
await localService.createChat(session, chatUri, { sideChat: { source: session, turnId: 't1', selection } });
let persistedOrigin: unknown;
for (let i = 0; i < 50; i++) {
const raw = await db.getMetadata('peerChats');
if (raw !== undefined) {
const parsed = JSON.parse(raw) as { uri: string; origin?: unknown }[];
persistedOrigin = parsed.find(entry => entry.uri === chatUri.toString())?.origin;
if (persistedOrigin) {
break;
}
}
await timeout(1);
}
localService.stateManager.deleteSession(session.toString());
await localService.restoreSession(session);
assert.deepStrictEqual({
persistedOrigin,
restoredOrigin: localService.stateManager.getChatState(chatUri.toString())?.origin,
}, {
persistedOrigin: { kind: ChatOriginKind.SideChat, chat: defaultChatUri, turnId: 't1', selection },
restoredOrigin: { kind: ChatOriginKind.SideChat, chat: defaultChatUri, turnId: 't1', selection },
});
});
test('hydrates a missing peer chat when resolving a generic Chat attachment', async () => {
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new SideChatAgent('copilot'));
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
const peerChat = URI.parse(buildChatUri(session, 'peer-1'));
await localService.createChat(session, peerChat);
for (let i = 0; i < 50 && await db.getMetadata('peerChats') === undefined; i++) {
await timeout(1);
}
agent.chatMessages.set(peerChat.toString(), [completedTurn('peer-turn', 'Remember X', 'Remembered')]);
localService.stateManager.removeChat(session.toString(), peerChat.toString());
const sent = Event.toPromise(agent.onDidSendMessage);
localService.dispatchAction(buildDefaultChatUri(session), {
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: peerChat.toString(),
endTurn: 'peer-turn',
label: 'Earlier chat',
}],
},
}, 'client-1', 1);
await sent;
const attachment = agent.sendMessageCalls[0].attachments?.[0];
assert.deepStrictEqual({
peerHydrated: !!localService.stateManager.getChatState(peerChat.toString()),
type: attachment?.type,
hasTranscript: attachment?.type === MessageAttachmentKind.Simple && attachment.modelRepresentation?.includes('User: Remember X'),
}, {
peerHydrated: true,
type: MessageAttachmentKind.Simple,
hasTranscript: true,
});
});
});
// ---- chat surface routing (G-C1) ----------------------------
suite('chat surface routing', () => {
/**
* An agent that exposes the chat surface AND the legacy
* `(session, chat?)` peer-chat methods, recording which path the
* orchestrator takes.
*/
class ChatSurfaceAgent extends MockAgent {
readonly sessionCreateCalls: URI[] = [];
readonly sessionDisposeCalls: URI[] = [];
readonly legacyCreateChatCalls: URI[] = [];
readonly chatCalls: { op: string; args: string[] }[] = [];
override async createSession(config?: import('../../common/agentService.js').IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult> {
const result = await super.createSession(config);
this.sessionCreateCalls.push(result.session);
return result;
}
override async disposeSession(session: URI): Promise<void> {
this.sessionDisposeCalls.push(session);
await super.disposeSession(session);
}
// The legacy peer-chat method is present too; it must NOT be used
// when the chats surface exists.
override async createChat(_session: URI, chat: URI): Promise<void> {
this.legacyCreateChatCalls.push(chat);
}
override readonly chats: IAgentChats = {
createChat: async (chat: URI, options?: IAgentCreateChatOptions) => {
const session = parseChatUri(chat)!.session;
this.chatCalls.push({ op: 'createChat', args: [session, chat.toString(), options?.title ?? ''] });
return { providerData: 'pd' };
},
fork: async (chat: URI, source: IAgentCreateChatForkSource) => {
const session = parseChatUri(chat)!.session;
this.chatCalls.push({ op: 'fork', args: [session, chat.toString(), source.source.toString(), source.turnId] });
return { providerData: 'pd-fork' };
},
disposeChat: async (chat: URI) => {
this.chatCalls.push({ op: 'disposeChat', args: [chat.toString()] });
},
sendMessage: async () => { },
abort: async () => { },
changeModel: async () => { },
changeAgent: async () => { },
getMessages: async (chat: URI) => {
this.chatCalls.push({ op: 'getMessages', args: [chat.toString()] });
return [];
},
};
}
test('createSession/createChat/disposeChat/disposeSession prefer the chat surface over legacy methods', async () => {
const agent = disposables.add(new ChatSurfaceAgent('copilot'));
service.registerProvider(agent);
const session = await service.createSession({ provider: 'copilot' });
const chatUri = URI.parse(buildChatUri(session, 'peer-1'));
await service.createChat(session, chatUri, { title: 'Peer' });
await service.disposeChat(session, chatUri);
await service.disposeSession(session);
assert.deepStrictEqual({
sessionCreate: agent.sessionCreateCalls.map(s => s.toString()),
sessionDispose: agent.sessionDisposeCalls.map(s => s.toString()),
legacyCreateChat: agent.legacyCreateChatCalls.length,
chatOps: agent.chatCalls.map(c => c.op),
createChatArgs: agent.chatCalls.find(c => c.op === 'createChat')?.args,
disposeChatArg: agent.chatCalls.find(c => c.op === 'disposeChat')?.args[0],
}, {
sessionCreate: [session.toString()],
sessionDispose: [session.toString()],
legacyCreateChat: 0,
chatOps: ['createChat', 'disposeChat'],
createChatArgs: [session.toString(), chatUri.toString(), 'Peer'],
disposeChatArg: chatUri.toString(),
});
});
test('fork routes to chats.fork with the resolved source chat', async () => {
const agent = disposables.add(new ChatSurfaceAgent('copilot'));
service.registerProvider(agent);
const session = await service.createSession({ provider: 'copilot' });
const sourceTurns: Turn[] = [
{ id: 't1', state: TurnState.Complete, message: { text: 'first', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined },
];
service.stateManager.seedDefaultChatTurns(session.toString(), sourceTurns);
const chatUri = URI.parse(buildChatUri(session, 'peer-1'));
await service.createChat(session, chatUri, { fork: { source: session, turnId: 't1' } });
const forkCall = agent.chatCalls.find(c => c.op === 'fork');
assert.deepStrictEqual(forkCall?.args, [session.toString(), chatUri.toString(), session.toString(), 't1']);
});
test('restore reads the default chat via chats.getMessages on the default chat URI', async () => {
const agent = disposables.add(new ChatSurfaceAgent('copilot'));
service.registerProvider(agent);
const { session } = await agent.createSession();
service.stateManager.deleteSession(session.toString());
await service.restoreSession(session);
const getMessages = agent.chatCalls.filter(c => c.op === 'getMessages').map(c => c.args[0]);
assert.deepStrictEqual(getMessages, [buildDefaultChatUri(session)]);
});
});
// ---- spawn channel routing (G-D1) -----------------------------------
suite('spawn channel routing', () => {
/**
* An agent that exposes the first-class spawn membership channel,
* with a test hook to fire {@link IAgent.onDidSpawnChat}.
*/
class SpawnChannelAgent extends MockAgent {
private readonly _onDidSpawnChat = new Emitter<IAgentSpawnChatEvent>();
readonly onDidSpawnChat = this._onDidSpawnChat.event;
fireSpawn(e: IAgentSpawnChatEvent): void {
this._onDidSpawnChat.fire(e);
}
override dispose(): void {
this._onDidSpawnChat.dispose();
super.dispose();
}
}
test('onDidSpawnChat adds the chat to the catalog with a Tool origin from its parent', async () => {
const agent = disposables.add(new SpawnChannelAgent('copilot'));
service.registerProvider(agent);
const session = await service.createSession({ provider: 'copilot' });
const parentChat = URI.parse(buildDefaultChatUri(session.toString()));
const spawned = URI.parse(buildChatUri(session, 'spawned-1'));
agent.fireSpawn({
session,
chat: spawned,
parent: { chat: parentChat, toolCallId: 'tc-task-1' },
title: 'Explore',
});
const chatState = service.stateManager.getChatState(spawned.toString());
const sessionChats = (service.stateManager.getSessionState(session.toString())?.chats ?? []).map(c => c.resource);
assert.deepStrictEqual({
title: chatState?.title,
origin: chatState?.origin,
inCatalog: sessionChats.includes(spawned.toString()),
}, {
title: 'Explore',
origin: { kind: ChatOriginKind.Tool, chat: parentChat.toString(), toolCallId: 'tc-task-1' },
inCatalog: true,
});
});
test('onDidSpawnChat without a parent adds the chat with no tool origin', async () => {
const agent = disposables.add(new SpawnChannelAgent('copilot'));
service.registerProvider(agent);
const session = await service.createSession({ provider: 'copilot' });
const spawned = URI.parse(buildChatUri(session, 'spawned-2'));
agent.fireSpawn({ session, chat: spawned });
const chatState = service.stateManager.getChatState(spawned.toString());
assert.deepStrictEqual({
origin: chatState?.origin,
inCatalog: chatState !== undefined,
}, {
origin: undefined,
inCatalog: true,
});
});
});
// ---- subagent membership sequencing (DR1: unified spawn channel) ----
suite('subagent membership sequencing', () => {
/** Fires a parent turn on the session's default chat. */
function startParentTurn(session: URI, turnId: string): void {
service.dispatchAction(
buildDefaultChatUri(session.toString()),
{ type: ActionType.ChatTurnStarted, turnId, startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'go', origin: { kind: MessageKind.User } } },
'client-test', 1,
);
}
test('a subagent_started signal yields exactly one catalog entry with the parent origin, title, and a started turn', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
const parentChat = buildDefaultChatUri(session.toString());
startParentTurn(session, 'turn-1');
copilotAgent.fireProgress({
kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub',
agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores',
taskDescription: 'Review package.json structure',
});
const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub');
const chatState = service.stateManager.getChatState(subagentUri);
const matching = (service.stateManager.getSessionState(session.toString())?.chats ?? []).filter(c => c.resource === subagentUri);
assert.deepStrictEqual({
catalogEntries: matching.length,
title: chatState?.title,
origin: chatState?.origin,
interactivity: chatState?.interactivity,
hasStartedTurn: service.stateManager.getActiveTurnId(subagentUri) !== undefined,
}, {
catalogEntries: 1,
// The concise per-task description names the tab (distinct even for
// two subagents of the same type), not the agent-type display name.
title: 'Review package.json structure',
origin: { kind: ChatOriginKind.Tool, chat: parentChat, toolCallId: 'tc-sub' },
interactivity: 'read-only',
hasStartedTurn: true,
});
});
test('the spawned catalog chat is resolvable from the inline pill resource via parseChatUri (the Open-Subagent contract)', async () => {
// The inline subagent pill (`ToolResultSubagentContent.resource`) and
// the catalog chat are both built from `buildSubagentChatUri`, and the
// Agents window resolves the pill to its tab by matching
// `parseChatUri(pillResource).chatId` against the catalog chat's
// parsed chatId (see `findSubagentChat`/`matchesResource` in
// `openSubagentChat.ts`). If the two ever desync, the pill shows the
// fallback "Open Subagent" label and clicking it no-ops. Guard the
// round-trip so the pill stays resolvable.
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
const parentChat = buildDefaultChatUri(session.toString());
startParentTurn(session, 'turn-1');
copilotAgent.fireProgress({
kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub',
agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores',
});
// The resource the inline pill carries for this subagent.
const pillResource = buildSubagentChatUri(session.toString(), 'tc-sub');
const pillChatId = parseChatUri(pillResource)?.chatId;
const catalog = service.stateManager.getSessionState(session.toString())?.chats ?? [];
const resolvedByPill = catalog.filter(c => parseChatUri(c.resource)?.chatId === pillChatId);
assert.deepStrictEqual({
pillChatId,
resolvedCatalogEntries: resolvedByPill.length,
}, {
pillChatId: 'subagent/tc-sub',
resolvedCatalogEntries: 1,
});
});
test('a subagent_started signal without a taskDescription falls back to the agent display name for the tab title', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
const parentChat = buildDefaultChatUri(session.toString());
startParentTurn(session, 'turn-1');
copilotAgent.fireProgress({
kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub',
agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores',
});
const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub');
assert.strictEqual(service.stateManager.getChatState(subagentUri)?.title, 'Explore');
});
test('membership stays a single entry when the agent also mirrors the subagent onto onDidSpawnChat, regardless of order', async () => {
// Mirror the real copilot/claude agents, which ALSO bridge their
// subagent signals onto onDidSpawnChat. The orchestrator's
// progress sequencer and the agent's spawn bridge both funnel to the
// idempotent _onChatSpawned, so the catalog must gain exactly
// one entry no matter which listener runs first.
class BridgingSubagentAgent extends MockAgent {
private readonly _onDidSpawnChat = new Emitter<IAgentSpawnChatEvent>();
readonly onDidSpawnChat = this._onDidSpawnChat.event;
private readonly _bridge = this.onDidSessionProgress(signal => {
const e = SubagentChatSignal.toSpawnEvent(signal);
if (e) {
this._onDidSpawnChat.fire(e);
}
});
override dispose(): void {
this._bridge.dispose();
this._onDidSpawnChat.dispose();
super.dispose();
}
}
const agent = new BridgingSubagentAgent('copilot');
disposables.add(toDisposable(() => agent.dispose()));
service.registerProvider(agent);
const session = await service.createSession({ provider: 'copilot' });
const parentChat = buildDefaultChatUri(session.toString());
startParentTurn(session, 'turn-1');
agent.fireProgress({
kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub',
agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores',
});
const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub');
const matching = (service.stateManager.getSessionState(session.toString())?.chats ?? []).filter(c => c.resource === subagentUri);
assert.deepStrictEqual({
catalogEntries: matching.length,
origin: service.stateManager.getChatState(subagentUri)?.origin,
hasStartedTurn: service.stateManager.getActiveTurnId(subagentUri) !== undefined,
}, {
catalogEntries: 1,
origin: { kind: ChatOriginKind.Tool, chat: parentChat, toolCallId: 'tc-sub' },
hasStartedTurn: true,
});
});
test('an inner tool call arriving before subagent_started is buffered and drained onto the subagent chat', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
const parentChat = buildDefaultChatUri(session.toString());
startParentTurn(session, 'turn-1');
// Parent task tool starts.
copilotAgent.fireProgress({ kind: 'action', resource: URI.parse(parentChat), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } });
copilotAgent.fireProgress({ kind: 'action', resource: URI.parse(parentChat), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-sub', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
// Inner tool arrives BEFORE subagent_started (buffered).
copilotAgent.fireProgress({ kind: 'action', resource: URI.parse(parentChat), parentToolCallId: 'tc-sub', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'inner-1', toolName: 'read', displayName: 'Read', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } });
copilotAgent.fireProgress({ kind: 'action', resource: URI.parse(parentChat), parentToolCallId: 'tc-sub', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'inner-1', invocationMessage: 'Reading...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } });
// subagent_started arrives and drains the buffer.
copilotAgent.fireProgress({ kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' });
const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub');
const subState = service.stateManager.getSessionState(subagentUri);
const innerOnSubagent = subState?.activeTurn?.responseParts.some(rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-1');
const innerOnParent = service.stateManager.getSessionState(session.toString())?.activeTurn?.responseParts.some(rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-1');
assert.deepStrictEqual({ innerOnSubagent, innerOnParent }, { innerOnSubagent: true, innerOnParent: false });
});
test('a subagent chat survives subagent_completed (stays live and subscribable, its turn completed)', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
const parentChat = buildDefaultChatUri(session.toString());
startParentTurn(session, 'turn-1');
copilotAgent.fireProgress({ kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' });
const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub');
assert.ok(service.stateManager.getChatState(subagentUri), 'precondition: subagent chat present after start');
copilotAgent.fireProgress({ kind: 'subagent_completed', chat: URI.parse(parentChat), toolCallId: 'tc-sub' });
const stillInCatalog = (service.stateManager.getSessionState(session.toString())?.chats ?? []).some(c => c.resource === subagentUri);
assert.deepStrictEqual({
hasChatState: service.stateManager.getChatState(subagentUri) !== undefined,
stillInCatalog,
hasActiveTurn: service.stateManager.getActiveTurnId(subagentUri) !== undefined,
}, {
hasChatState: true,
stillInCatalog: true,
hasActiveTurn: false,
});
});
test('a subagent tool call awaiting user confirmation does not time out before the user responds', () => {
return runWithFakedTimers({ useFakeTimers: true }, async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
const parentChat = buildDefaultChatUri(session.toString());
startParentTurn(session, 'turn-1');
copilotAgent.fireProgress({
kind: 'action', resource: URI.parse(parentChat),
action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: 'subagent', language: undefined } },
});
// No `confirmed` — the tool sits in PendingConfirmation, e.g. waiting on the user.
copilotAgent.fireProgress({
kind: 'action', resource: URI.parse(parentChat),
action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-sub', invocationMessage: 'Delegating...', toolInput: undefined },
});
// The user takes far longer than the pending-registration bound to respond.
await new Promise(resolve => setTimeout(resolve, 60_000));
// Only now does the user approve — this must still arm a fresh wait, not one already timed out.
service.dispatchAction(parentChat, { type: ActionType.ChatToolCallConfirmed, turnId: 'turn-1', toolCallId: 'tc-sub', approved: true, confirmed: ToolCallConfirmationReason.UserAction }, 'client-1', 1);
const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub');
const subscribePromise = service.subscribe(URI.parse(subagentUri), 'client-race');
let settled = false;
void subscribePromise.then(() => { settled = true; });
await timeout(0);
assert.strictEqual(settled, false, 'subscribe should still be pending right after approval');
copilotAgent.fireProgress({ kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' });
const snapshot = await subscribePromise;
assert.strictEqual(snapshot.resource, subagentUri);
});
});
test('denying a subagent tool call before confirmation does not leave a dangling wait', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
const parentChat = buildDefaultChatUri(session.toString());
startParentTurn(session, 'turn-1');
copilotAgent.fireProgress({
kind: 'action', resource: URI.parse(parentChat),
action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: 'subagent', language: undefined } },
});
copilotAgent.fireProgress({
kind: 'action', resource: URI.parse(parentChat),
action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-sub', invocationMessage: 'Delegating...', toolInput: undefined },
});
service.dispatchAction(parentChat, { type: ActionType.ChatToolCallConfirmed, turnId: 'turn-1', toolCallId: 'tc-sub', approved: false, reason: ToolCallCancellationReason.Denied }, 'client-1', 1);
const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub');
await assert.rejects(service.subscribe(URI.parse(subagentUri), 'client-race'), /Cannot subscribe to unknown resource/);
});
test('subscribe to a subagent chat announced via _meta.subagentChatUri waits for the resource instead of failing immediately', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
const parentChat = buildDefaultChatUri(session.toString());
startParentTurn(session, 'turn-1');
copilotAgent.fireProgress({
kind: 'action', resource: URI.parse(parentChat),
action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: 'subagent', language: undefined } },
});
copilotAgent.fireProgress({
kind: 'action', resource: URI.parse(parentChat),
action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-sub', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded },
});
const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub');
assert.strictEqual(service.stateManager.getSnapshot(subagentUri), undefined, 'precondition: resource not registered yet');
// Subscribe before the resource exists — this must not reject.
const subscribePromise = service.subscribe(URI.parse(subagentUri), 'client-race');
let settled = false;
void subscribePromise.then(() => { settled = true; });
await timeout(0);
assert.strictEqual(settled, false, 'subscribe should still be pending while the resource is unregistered');
copilotAgent.fireProgress({ kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' });
const snapshot = await subscribePromise;
assert.strictEqual(snapshot.resource, subagentUri);
});
test('subscribe to an announced subagent chat that never spawns eventually rejects instead of hanging', () => {
return runWithFakedTimers({ useFakeTimers: true }, async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
const parentChat = buildDefaultChatUri(session.toString());
startParentTurn(session, 'turn-1');
copilotAgent.fireProgress({
kind: 'action', resource: URI.parse(parentChat),
action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: 'subagent', language: undefined } },
});
copilotAgent.fireProgress({
kind: 'action', resource: URI.parse(parentChat),
action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-sub', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded },
});
const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub');
// The tool call is denied/cancelled before the SDK ever
// confirms subagent_started — the resource never registers.
const subscribePromise = service.subscribe(URI.parse(subagentUri), 'client-race');
await assert.rejects(subscribePromise, /Cannot subscribe to unknown resource/);
});
});
});
// ---- peer-chat catalog persistence (B2: orchestrator-owned) ---------
suite('peer chat catalog persistence', () => {
/** Polls the persisted peer-chat catalog blob until it appears or times out. */
async function readCatalog(db: TestSessionDatabase): Promise<{ uri: string; providerData?: string }[]> {
for (let i = 0; i < 50; i++) {
const raw = await db.getMetadata('peerChats');
if (raw !== undefined) {
return JSON.parse(raw);
}
await timeout(0);
}
return [];
}
test('createChat persists providerData; restore re-materializes from the orchestrator catalog before reading history', async () => {
const materializeOrder: { call: string; uri: string; providerData?: string }[] = [];
class MultiChatAgent extends MockAgent {
override async createChat(_session: URI, _chat: URI): Promise<{ providerData?: string }> {
return { providerData: 'blob-1' };
}
async materializeChat(chat: URI, providerData: string | undefined): Promise<void> {
materializeOrder.push({ call: 'materialize', uri: chat.toString(), providerData });
}
override async getSessionMessages(session: URI): Promise<readonly Turn[]> {
if (session.scheme === 'ahp-chat') {
materializeOrder.push({ call: 'getMessages', uri: session.toString() });
return [{
id: 'peer-turn-1',
state: TurnState.Complete,
message: { text: 'hi peer', origin: { kind: MessageKind.User } },
responseParts: [],
usage: undefined,
}];
}
return [];
}
}
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new MultiChatAgent('copilot'));
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
const peerUri = URI.parse(buildChatUri(session, 'peer-1'));
await localService.createChat(session, peerUri);
await readCatalog(db);
localService.stateManager.deleteSession(session.toString());
await localService.restoreSession(session);
const state = localService.stateManager.getSessionState(session.toString());
const peerChatState = localService.stateManager.getChatState(peerUri.toString());
assert.deepStrictEqual({
order: materializeOrder.map(o => o.call),
materializedWith: materializeOrder.find(o => o.call === 'materialize')?.providerData,
inCatalog: !!state?.chats.some(c => c.resource.toString() === peerUri.toString()),
restoredProviderData: localService.stateManager.getChatProviderData(peerUri.toString()),
peerTurnIds: peerChatState?.turns.map(t => t.id) ?? [],
}, {
// The default chat is read first; peer materialize must precede
// the peer history read on restore.
order: ['getMessages', 'materialize', 'getMessages'],
materializedWith: 'blob-1',
inCatalog: true,
restoredProviderData: 'blob-1',
peerTurnIds: ['peer-turn-1'],
});
});
test('onDidChangeChatData re-persists the updated providerData blob', async () => {
const onDidChangeChatData = disposables.add(new Emitter<IAgentChatDataChange>());
class MultiChatAgent extends MockAgent {
readonly onDidChangeChatData = onDidChangeChatData.event;
override async createChat(_session: URI, _chat: URI): Promise<{ providerData?: string }> {
return { providerData: 'v1' };
}
}
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new MultiChatAgent('copilot'));
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
const peerUri = URI.parse(buildChatUri(session, 'peer-1'));
await localService.createChat(session, peerUri);
const afterCreate = await readCatalog(db);
onDidChangeChatData.fire({ chat: peerUri, providerData: 'v2' });
// Wait for the re-persist write to flush.
let updated = afterCreate;
for (let i = 0; i < 50; i++) {
updated = await readCatalog(db);
if (updated.find(e => e.uri === peerUri.toString())?.providerData === 'v2') {
break;
}
await timeout(0);
}
assert.deepStrictEqual({
afterCreate: afterCreate.find(e => e.uri === peerUri.toString())?.providerData,
afterChange: updated.find(e => e.uri === peerUri.toString())?.providerData,
}, {
afterCreate: 'v1',
afterChange: 'v2',
});
});
test('disposeChat removes the chat from the persisted catalog', async () => {
class MultiChatAgent extends MockAgent {
override async createChat(_session: URI, _chat: URI): Promise<{ providerData?: string }> {
return { providerData: 'blob-1' };
}
override async disposeChat(_session: URI, _chat: URI): Promise<void> { }
}
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new MultiChatAgent('copilot'));
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
const peerUri = URI.parse(buildChatUri(session, 'peer-1'));
await localService.createChat(session, peerUri);
const afterCreate = await readCatalog(db);
await localService.disposeChat(session, peerUri);
let afterDispose = afterCreate;
for (let i = 0; i < 50; i++) {
afterDispose = await readCatalog(db);
if (!afterDispose.some(e => e.uri === peerUri.toString())) {
break;
}
await timeout(0);
}
assert.deepStrictEqual({
afterCreate: afterCreate.map(e => e.uri),
afterDispose: afterDispose.map(e => e.uri),
}, {
afterCreate: [peerUri.toString()],
afterDispose: [],
});
});
// ---- BC1: one-time legacy `*.chats` migration on restore ----------
test('legacy *.chats with no peerChats catalog migrates once into the orchestrator catalog', async () => {
class LegacyAgent extends MockAgent {
listLegacyCallCount = 0;
override async createChat(): Promise<IAgentCreateChatResult | void> { }
async materializeChat(): Promise<void> { }
async listLegacyChats(session: URI): Promise<readonly IAgentLegacyChat[]> {
this.listLegacyCallCount++;
return [
{ uri: URI.parse(buildChatUri(session, 'legacy-a')), providerData: 'lp-a' },
{ uri: URI.parse(buildChatUri(session, 'legacy-b')), providerData: 'lp-b' },
];
}
override async getSessionMessages(session: URI): Promise<readonly Turn[]> {
if (session.scheme === 'ahp-chat') {
return [{
id: `${parseChatUri(session)?.chatId}-turn`,
state: TurnState.Complete,
message: { text: 'legacy hi', origin: { kind: MessageKind.User } },
responseParts: [],
usage: undefined,
}];
}
return [];
}
}
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new LegacyAgent('copilot'));
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
// Seed a persisted title for one legacy chat so we can assert
// history + title are restored.
const legacyAUri = URI.parse(buildChatUri(session, 'legacy-a'));
const legacyBUri = URI.parse(buildChatUri(session, 'legacy-b'));
await db.setMetadata(`customChatTitle:${legacyAUri.toString()}`, 'Legacy A Title');
// No peerChats key exists (undefined catalog) -> migration runs.
localService.stateManager.deleteSession(session.toString());
await localService.restoreSession(session);
const catalogAfterFirst = await readCatalog(db);
// Second restore: catalog now present -> legacy read not consulted again.
localService.stateManager.deleteSession(session.toString());
await localService.restoreSession(session);
const stateA = localService.stateManager.getChatState(legacyAUri.toString());
const stateB = localService.stateManager.getChatState(legacyBUri.toString());
assert.deepStrictEqual({
legacyCalls: agent.listLegacyCallCount,
catalog: catalogAfterFirst.map(e => ({ uri: e.uri, providerData: e.providerData })),
aTitle: stateA?.title,
aTurns: stateA?.turns.map(t => t.id) ?? [],
aProviderData: localService.stateManager.getChatProviderData(legacyAUri.toString()),
bTurns: stateB?.turns.map(t => t.id) ?? [],
bProviderData: localService.stateManager.getChatProviderData(legacyBUri.toString()),
}, {
legacyCalls: 1,
catalog: [
{ uri: legacyAUri.toString(), providerData: 'lp-a' },
{ uri: legacyBUri.toString(), providerData: 'lp-b' },
],
aTitle: 'Legacy A Title',
aTurns: ['legacy-a-turn'],
aProviderData: 'lp-a',
bTurns: ['legacy-b-turn'],
bProviderData: 'lp-b',
});
});
test('an empty ([]) peerChats catalog does not resurrect legacy chats', async () => {
class LegacyAgent extends MockAgent {
listLegacyCallCount = 0;
async listLegacyChats(session: URI): Promise<readonly IAgentLegacyChat[]> {
this.listLegacyCallCount++;
return [{ uri: URI.parse(buildChatUri(session, 'legacy-a')), providerData: 'lp-a' }];
}
}
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new LegacyAgent('copilot'));
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
// Known-empty catalog must be treated as "no peer chats", never migrated.
await db.setMetadata('peerChats', '[]');
localService.stateManager.deleteSession(session.toString());
await localService.restoreSession(session);
const state = localService.stateManager.getSessionState(session.toString());
assert.deepStrictEqual({
legacyCalls: agent.listLegacyCallCount,
peerChats: (state?.chats ?? []).map(c => parseChatUri(c.resource)?.chatId).filter(id => id !== 'default'),
}, {
legacyCalls: 0,
peerChats: [],
});
});
test('a valid new-format peerChats catalog restores without consulting legacy chats', async () => {
class LegacyAgent extends MockAgent {
listLegacyCallCount = 0;
override async createChat(): Promise<IAgentCreateChatResult | void> {
return { providerData: 'new-blob' };
}
async materializeChat(): Promise<void> { }
async listLegacyChats(session: URI): Promise<readonly IAgentLegacyChat[]> {
this.listLegacyCallCount++;
return [{ uri: URI.parse(buildChatUri(session, 'legacy-a')), providerData: 'lp-a' }];
}
}
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new LegacyAgent('copilot'));
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
const peerUri = URI.parse(buildChatUri(session, 'peer-1'));
await localService.createChat(session, peerUri);
await readCatalog(db);
localService.stateManager.deleteSession(session.toString());
await localService.restoreSession(session);
const state = localService.stateManager.getSessionState(session.toString());
assert.deepStrictEqual({
legacyCalls: agent.listLegacyCallCount,
peerInCatalog: !!state?.chats.some(c => c.resource.toString() === peerUri.toString()),
legacyInCatalog: state?.chats.some(c => parseChatUri(c.resource)?.chatId === 'legacy-a') ?? false,
}, {
legacyCalls: 0,
peerInCatalog: true,
legacyInCatalog: false,
});
});
// ---- RV-1: legacy migration persists the catalog atomically ----------
test('legacy migration persists the whole set in one write (never a subset, even across a re-restore)', async () => {
class LegacyAgent extends MockAgent {
override async createChat(): Promise<IAgentCreateChatResult | void> { }
async materializeChat(): Promise<void> { }
async listLegacyChats(session: URI): Promise<readonly IAgentLegacyChat[]> {
return [
{ uri: URI.parse(buildChatUri(session, 'legacy-a')), providerData: 'lp-a' },
{ uri: URI.parse(buildChatUri(session, 'legacy-b')), providerData: 'lp-b' },
{ uri: URI.parse(buildChatUri(session, 'legacy-c')), providerData: 'lp-c' },
];
}
}
const db = new TestSessionDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new LegacyAgent('copilot'));
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
// Absent peerChats key => migration runs and must write the full set once.
localService.stateManager.deleteSession(session.toString());
await localService.restoreSession(session);
const catalog = await readCatalog(db);
const restoredIds = (localService.stateManager.getSessionState(session.toString())?.chats ?? [])
.map(c => parseChatUri(c.resource)?.chatId)
.filter(id => id !== 'default');
assert.deepStrictEqual({
catalogIds: catalog.map(e => parseChatUri(URI.parse(e.uri))?.chatId),
restoredIds,
}, {
catalogIds: ['legacy-a', 'legacy-b', 'legacy-c'],
restoredIds: ['legacy-a', 'legacy-b', 'legacy-c'],
});
});
test('a rejected migration write leaves the catalog absent (not a subset) so migration re-runs', async () => {
class FailingCatalogDatabase extends TestSessionDatabase {
failPeerChatsWrites = 1;
override async setMetadata(key: string, value: string): Promise<void> {
if (key === 'peerChats' && this.failPeerChatsWrites > 0) {
this.failPeerChatsWrites--;
throw new Error('simulated catalog write failure');
}
return super.setMetadata(key, value);
}
}
class LegacyAgent extends MockAgent {
override async createChat(): Promise<IAgentCreateChatResult | void> { }
async materializeChat(): Promise<void> { }
async listLegacyChats(session: URI): Promise<readonly IAgentLegacyChat[]> {
return [
{ uri: URI.parse(buildChatUri(session, 'legacy-a')), providerData: 'lp-a' },
{ uri: URI.parse(buildChatUri(session, 'legacy-b')), providerData: 'lp-b' },
];
}
}
const db = new FailingCatalogDatabase();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService()));
const agent = disposables.add(new LegacyAgent('copilot'));
localService.registerProvider(agent);
const session = await localService.createSession({ provider: 'copilot' });
// First restore: the single catalog write is rejected. Because the write
// is all-or-nothing, the key must stay absent (never a proper subset).
localService.stateManager.deleteSession(session.toString());
await localService.restoreSession(session);
const catalogAfterFailedWrite = await db.getMetadata('peerChats');
// Second restore: catalog still absent => migration re-runs and now
// persists the complete set.
localService.stateManager.deleteSession(session.toString());
await localService.restoreSession(session);
const catalog = await readCatalog(db);
assert.deepStrictEqual({
catalogAfterFailedWrite,
catalogIds: catalog.map(e => parseChatUri(URI.parse(e.uri))?.chatId),
}, {
catalogAfterFailedWrite: undefined,
catalogIds: ['legacy-a', 'legacy-b'],
});
});
});
suite('subscriber refcount eviction', () => {
test('an empty session created in this lifetime stays observable until GC fires', async () => {
service.registerProvider(copilotAgent);
const sessionResource = await service.createSession({ provider: 'copilot' });
service.addSubscriber(sessionResource, 'client-1');
service.unsubscribe(sessionResource, 'client-1');
// Empty sessions are routed to the GC pipeline rather than the
// eviction pipeline, so their state stays observable in the
// grace window for a re-subscribe to find.
assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'empty created session must remain observable for the GC grace window');
});
test('a session with an active turn is NOT evicted when its last subscriber drops', async () => {
service.registerProvider(copilotAgent);
const sessionResource = await service.createSession({ provider: 'copilot' });
service.addSubscriber(sessionResource, 'client-1');
// Simulate an in-flight turn — eviction must skip this session even
// when the refcount reaches zero, otherwise we'd drop live state
// mid-response.
service.dispatchAction(
buildDefaultChatUri(sessionResource.toString()),
{ type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } } },
'client-1', 1,
);
service.unsubscribe(sessionResource, 'client-1');
assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'active-turn session must not be evicted');
});
test('a restored idle session is evicted when its last subscriber drops', () => {
return runWithFakedTimers({ useFakeTimers: true }, async () => {
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
];
await service.restoreSession(sessionResource);
service.addSubscriber(sessionResource, 'client-1');
service.unsubscribe(sessionResource, 'client-1');
// Release is deferred behind the grace window — still cached until it elapses.
assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'session stays cached during the release grace');
await new Promise(resolve => setTimeout(resolve, 30_000));
assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'restored idle session should be evicted after the grace');
assert.deepStrictEqual(
copilotAgent.releaseSessionCalls.map(u => u.toString()),
[sessionResource.toString()],
'provider releaseSession should be invoked for the evicted root',
);
assert.strictEqual(copilotAgent.disposeSessionCalls.length, 0, 'eviction must not destructively dispose the session');
});
});
test('re-subscribing within the grace cancels the release', () => {
return runWithFakedTimers({ useFakeTimers: true }, async () => {
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
];
await service.restoreSession(sessionResource);
service.addSubscriber(sessionResource, 'client-1');
service.unsubscribe(sessionResource, 'client-1');
// Reconnect within the grace window.
service.addSubscriber(sessionResource, 'client-2');
await new Promise(resolve => setTimeout(resolve, 30_000));
assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'session must stay cached when re-subscribed within the grace');
assert.strictEqual(copilotAgent.releaseSessionCalls.length, 0, 'releaseSession must not fire when the grace was cancelled');
});
});
test('an evicted idle session restores losslessly on re-subscribe', () => {
return runWithFakedTimers({ useFakeTimers: true }, async () => {
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
];
await service.restoreSession(sessionResource);
service.addSubscriber(sessionResource, 'client-1');
const before = service.stateManager.getSessionState(sessionResource.toString());
assert.ok(before, 'session state present before eviction');
service.unsubscribe(sessionResource, 'client-1');
await new Promise(resolve => setTimeout(resolve, 30_000));
assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'session evicted after last subscriber drops');
// Re-subscribe rehydrates from the preserved durable data.
await service.subscribe(sessionResource, 'client-2');
const after = service.stateManager.getSessionState(sessionResource.toString());
assert.ok(after, 'session restored on re-subscribe');
// Response-part ids are freshly generated on each reconstruction, so
// normalize them out before comparing the durable turn content.
const normalizeTurns = (turns: ISessionWithDefaultChat['turns']) =>
turns.map(turn => ({ ...turn, responseParts: turn.responseParts.map(part => ({ ...part, id: undefined })) }));
assert.deepStrictEqual(normalizeTurns(after.turns), normalizeTurns(before.turns), 'restored turns match the pre-eviction state');
});
});
test('restored session is evicted after all subscribers drop', () => {
return runWithFakedTimers({ useFakeTimers: true }, async () => {
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
];
await service.restoreSession(sessionResource);
service.addSubscriber(sessionResource, 'client-1');
service.addSubscriber(sessionResource, 'client-2');
service.unsubscribe(sessionResource, 'client-1');
await new Promise(resolve => setTimeout(resolve, 30_000));
assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'still subscribed by client-2');
service.unsubscribe(sessionResource, 'client-2');
await new Promise(resolve => setTimeout(resolve, 30_000));
assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'evicted after last subscriber drops');
});
});
test('subagent subscriber pins the parent session against eviction', () => {
return runWithFakedTimers({ useFakeTimers: true }, async () => {
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Review', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: '', toolRequests: [{ toolCallId: 'tc-sub', name: 'task' }] },
{ type: 'tool_start', session, toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating', toolKind: 'subagent' as const, subagentDescription: 'Find files', subagentAgentName: 'explore' },
{ type: 'subagent_started', session, toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' },
{ type: 'tool_start', session, toolCallId: 'tc-inner', toolName: 'bash', displayName: 'Bash', invocationMessage: 'ls', parentToolCallId: 'tc-sub' },
{ type: 'tool_complete', session, toolCallId: 'tc-inner', result: { success: true, pastTenseMessage: 'ran', content: [{ type: ToolResultContentType.Text, text: 'a' }] }, parentToolCallId: 'tc-sub' },
{ type: 'tool_complete', session, toolCallId: 'tc-sub', result: { success: true, pastTenseMessage: 'done', content: [{ type: ToolResultContentType.Text, text: 'ok' }] } },
{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Done', toolRequests: [] },
];
await service.restoreSession(sessionResource);
const childUri = URI.parse(buildSubagentSessionUri(sessionResource.toString(), 'tc-sub'));
await service.subscribe(childUri, 'client-child');
service.addSubscriber(sessionResource, 'client-parent');
// Parent drops — child still subscribed, parent must not be evicted
service.unsubscribe(sessionResource, 'client-parent');
await new Promise(resolve => setTimeout(resolve, 30_000));
assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'parent must stay while child is subscribed');
assert.ok(service.stateManager.getSessionState(childUri.toString()), 'child still present');
// Child drops — parent and child can now be evicted.
service.unsubscribe(childUri, 'client-child');
await new Promise(resolve => setTimeout(resolve, 30_000));
assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'parent evicted after subagent drops');
assert.strictEqual(service.stateManager.getSessionState(childUri.toString()), undefined, 'child also evicted with parent');
});
});
test('nested subagent subscriber pins ancestor session against eviction', async () => {
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Review', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: '', toolRequests: [{ toolCallId: 'tc-sub', name: 'task' }] },
{ type: 'tool_start', session, toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating', toolKind: 'subagent' as const, subagentDescription: 'Find files', subagentAgentName: 'explore' },
{ type: 'subagent_started', session, toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' },
{ type: 'tool_start', session, toolCallId: 'tc-inner', toolName: 'bash', displayName: 'Bash', invocationMessage: 'ls', parentToolCallId: 'tc-sub' },
{ type: 'tool_complete', session, toolCallId: 'tc-inner', result: { success: true, pastTenseMessage: 'ran', content: [{ type: ToolResultContentType.Text, text: 'a' }] }, parentToolCallId: 'tc-sub' },
{ type: 'tool_complete', session, toolCallId: 'tc-sub', result: { success: true, pastTenseMessage: 'done', content: [{ type: ToolResultContentType.Text, text: 'ok' }] } },
{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Done', toolRequests: [] },
];
await service.restoreSession(sessionResource);
const childUri = URI.parse(buildSubagentSessionUri(sessionResource, 'tc-sub'));
await service.subscribe(childUri, 'client-child');
const nestedChildUri = URI.parse(buildSubagentSessionUri(childUri, 'tc-nested'));
service.addSubscriber(sessionResource, 'client-parent');
service.addSubscriber(nestedChildUri, 'client-nested-child');
service.unsubscribe(sessionResource, 'client-parent');
assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'ancestor parent must stay while nested child is subscribed');
assert.ok(service.stateManager.getSessionState(childUri.toString()), 'intermediate child still present');
});
test('depth-2 subagent unsubscribe evicts the root session state', () => {
return runWithFakedTimers({ useFakeTimers: true }, async () => {
// Regression: when a depth-2 subagent URI unsubscribes the eviction
// must reach all the way to the root, not stop at the intermediate
// parent and leave root state cached indefinitely.
service.registerProvider(copilotAgent);
const { session } = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'hi', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'done', toolRequests: [] },
];
await service.restoreSession(sessionResource);
// Simulate a client that only subscribed to the depth-2 URI.
const childUri = URI.parse(buildSubagentSessionUri(sessionResource, 'tc-sub'));
const nestedUri = URI.parse(buildSubagentSessionUri(childUri, 'tc-nested'));
service.addSubscriber(nestedUri, 'client-nested');
service.unsubscribe(nestedUri, 'client-nested');
await new Promise(resolve => setTimeout(resolve, 30_000));
assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'root state must be evicted when no subscribers remain');
});
});
});
// ---- handshake fast-path: uncommitted refresh on addSubscriber ----
suite('addSubscriber triggers uncommitted refresh', () => {
test('addSubscriber for <session>/changeset/uncommitted triggers the first git diff refresh', async () => {
const workingDirectory = URI.from({ scheme: Schemas.inMemory, path: '/wd-refresh' });
copilotAgent.resolvedWorkingDirectory = workingDirectory;
copilotAgent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined };
// Recording git service: a call to `computeSessionFileDiffs`
// with `baseBranch=undefined` is the signature of the uncommitted
// refresh fired by `_triggerUncommittedRefresh`.
const computeCalls: { wd: string; baseBranch: string | undefined }[] = [];
const gitService = createNoopGitService();
gitService.computeSessionFileDiffs = async (wd: URI, opts: { sessionUri: string; baseBranch?: string }) => {
computeCalls.push({ wd: wd.toString(), baseBranch: opts.baseBranch });
return undefined;
};
const sessionDataService = createSessionDataService();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
localService.registerProvider(copilotAgent);
const sessionResource = await localService.createSession({ provider: 'copilot' });
const uncommittedUri = URI.parse(buildUncommittedChangesetUri(sessionResource.toString()));
// The handshake fast-path used during connect/initialize when
// `getSnapshot(uri)` is already populated. This is the path
// that previously skipped the refresh for sessions that were
// already active when the Agents Window opened.
localService.addSubscriber(uncommittedUri, 'client-1');
// Refresh is scheduled through the per-session sequencer;
// allow it to drain.
await new Promise(r => setTimeout(r, 20));
assert.ok(
computeCalls.some(c => c.baseBranch === undefined && c.wd === workingDirectory.toString()),
`expected an uncommitted-kind git diff against the working dir, got: ${JSON.stringify(computeCalls)}`,
);
localService.unsubscribe(uncommittedUri, 'client-1');
});
test('addSubscriber for the session URI or session-changeset URI triggers a static refresh', async () => {
// The Agents Window subscribes to the session URI (list /
// detail) rather than to either of the static changeset URIs
// directly, so the chip would never refresh on session open
// without this trigger. Subscribing to the session-changeset
// URI from any other client must also fire its own refresh.
const workingDirectory = URI.from({ scheme: Schemas.inMemory, path: '/wd-refresh-2' });
copilotAgent.resolvedWorkingDirectory = workingDirectory;
copilotAgent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined };
const computeCalls: { wd: string; baseBranch: string | undefined }[] = [];
const gitService = createNoopGitService();
gitService.computeSessionFileDiffs = async (wd: URI, opts: { sessionUri: string; baseBranch?: string }) => {
computeCalls.push({ wd: wd.toString(), baseBranch: opts.baseBranch });
return undefined;
};
const sessionDataService = createSessionDataService();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
localService.registerProvider(copilotAgent);
const sessionResource = await localService.createSession({ provider: 'copilot' });
const sessionChangesetUri = URI.parse(buildSessionChangesetUri(sessionResource.toString()));
localService.addSubscriber(sessionChangesetUri, 'client-1');
localService.addSubscriber(sessionResource, 'client-2');
await new Promise(r => setTimeout(r, 20));
assert.ok(
computeCalls.some(c => c.wd === workingDirectory.toString()),
`session-URI / session-changeset subscriptions must trigger a git diff against the working dir, got: ${JSON.stringify(computeCalls)}`,
);
localService.unsubscribe(sessionChangesetUri, 'client-1');
localService.unsubscribe(sessionResource, 'client-2');
});
test('restoreSession drains a pending uncommitted refresh deferred by an earlier addSubscriber', async () => {
// Reproduces the cold-open race that broke §3:
// 1. Client subscribes to `<session>/changeset/uncommitted`
// before the session has been restored on the server.
// 2. addSubscriber's 0→1 trigger fires `_triggerUncommittedRefresh`,
// which reads `summary.workingDirectory` from live state
// — finds nothing (session not restored yet) — and defers
// via `_pendingUncommittedRefreshes`.
// 3. restoreSession then runs (driven by the chat-view path or
// a separate subscribe), populates `summary.workingDirectory`
// from disk, and MUST drain the pending refresh.
const workingDirectory = URI.from({ scheme: Schemas.inMemory, path: '/wd-restore-drain' });
copilotAgent.resolvedWorkingDirectory = workingDirectory;
copilotAgent.sessionMetadataOverrides = { workingDirectories: workingDirectory ? [workingDirectory] : undefined };
const computeCalls: { wd: string; baseBranch: string | undefined }[] = [];
const gitService = createNoopGitService();
gitService.computeSessionFileDiffs = async (wd: URI, opts: { sessionUri: string; baseBranch?: string }) => {
computeCalls.push({ wd: wd.toString(), baseBranch: opts.baseBranch });
return undefined;
};
const sessionDataService = createSessionDataService();
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
localService.registerProvider(copilotAgent);
// Seed a session on the agent without calling
// `localService.createSession` — mirrors a restored-from-disk
// session not yet in the service's state manager.
const { session } = await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
const uncommittedUri = URI.parse(buildUncommittedChangesetUri(sessionResource.toString()));
// Step 1+2: subscribe before restore. Trigger defers.
localService.addSubscriber(uncommittedUri, 'client-1');
await new Promise(r => setTimeout(r, 20));
assert.strictEqual(
computeCalls.length,
0,
`no compute should fire while the session is not restored (workingDirectory unknown), got: ${JSON.stringify(computeCalls)}`,
);
// Step 3: restoreSession runs (chat-view path / a parallel
// session-URI subscribe). After this, the pending refresh
// must drain and `_tryComputeGitDiffs` must run for the
// uncommitted slot.
copilotAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hi', toolRequests: [] },
];
await localService.restoreSession(sessionResource);
await new Promise(r => setTimeout(r, 20));
assert.ok(
computeCalls.some(c => c.baseBranch === undefined && c.wd === workingDirectory.toString()),
`restoreSession must drain the pending refresh; got compute calls: ${JSON.stringify(computeCalls)}`,
);
localService.unsubscribe(uncommittedUri, 'client-1');
});
});
// ---- empty-session GC ----------------------------------------------
suite('empty-session GC', () => {
test('an empty unsubscribed session is disposed after the grace period', () => {
return runWithFakedTimers({ useFakeTimers: true }, async () => {
service.registerProvider(copilotAgent);
const sessionResource = await service.createSession({ provider: 'copilot' });
service.addSubscriber(sessionResource, 'client-1');
service.unsubscribe(sessionResource, 'client-1');
// Before the grace period, dispose has not been called.
assert.strictEqual(copilotAgent.disposeSessionCalls.length, 0, 'no GC before grace expires');
// After the grace period, the session is disposed entirely.
await new Promise(resolve => setTimeout(resolve, 30_000));
assert.deepStrictEqual(
copilotAgent.disposeSessionCalls.map(u => u.toString()),
[sessionResource.toString()],
'GC fired after grace period',
);
});
});
test('a session with at least one turn is not GC-disposed', () => {
return runWithFakedTimers({ useFakeTimers: true }, async () => {
service.registerProvider(copilotAgent);
const sessionResource = await service.createSession({ provider: 'copilot' });
service.addSubscriber(sessionResource, 'client-1');
service.dispatchAction(
buildDefaultChatUri(sessionResource.toString()),
{ type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } } },
'client-1', 1,
);
service.dispatchAction(
buildDefaultChatUri(sessionResource.toString()),
{ type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 },
'client-1', 2,
);
service.unsubscribe(sessionResource, 'client-1');
await new Promise(resolve => setTimeout(resolve, 30_000));
assert.strictEqual(copilotAgent.disposeSessionCalls.length, 0, 'session with turns must not be GC-disposed');
});
});
test('resubscribe within the grace period cancels GC', () => {
return runWithFakedTimers({ useFakeTimers: true }, async () => {
service.registerProvider(copilotAgent);
const sessionResource = await service.createSession({ provider: 'copilot' });
service.addSubscriber(sessionResource, 'client-1');
service.unsubscribe(sessionResource, 'client-1');
// Resubscribe before the timer fires.
await new Promise(resolve => setTimeout(resolve, 5_000));
service.addSubscriber(sessionResource, 'client-1');
await new Promise(resolve => setTimeout(resolve, 30_000));
assert.strictEqual(copilotAgent.disposeSessionCalls.length, 0, 'GC must be cancelled after resubscribe');
});
});
test('GC is rearmed after a resubscribe-then-unsubscribe cycle', () => {
return runWithFakedTimers({ useFakeTimers: true }, async () => {
service.registerProvider(copilotAgent);
const sessionResource = await service.createSession({ provider: 'copilot' });
service.addSubscriber(sessionResource, 'client-1');
service.unsubscribe(sessionResource, 'client-1');
await new Promise(resolve => setTimeout(resolve, 5_000));
service.addSubscriber(sessionResource, 'client-1');
service.unsubscribe(sessionResource, 'client-1');
// Old timer was cancelled; a fresh 30s timer is now armed.
await new Promise(resolve => setTimeout(resolve, 29_000));
assert.strictEqual(copilotAgent.disposeSessionCalls.length, 0, 'rearmed timer not yet fired');
await new Promise(resolve => setTimeout(resolve, 2_000));
assert.strictEqual(copilotAgent.disposeSessionCalls.length, 1, 'rearmed timer fires after fresh 30s');
});
});
test('createSession on the same URI cancels a pending GC', () => {
// Models the reconnect path: client subscribes to a session,
// drops the subscription (GC armed), then re-issues
// `createSession` for the same URI before the grace expires.
// Without explicit cancellation, the timer would fire and
// dispose the just-revived session.
return runWithFakedTimers({ useFakeTimers: true }, async () => {
service.registerProvider(copilotAgent);
const sessionResource = await service.createSession({ provider: 'copilot', session: AgentSession.uri('copilot', 'recreate-test') });
service.addSubscriber(sessionResource, 'client-1');
service.unsubscribe(sessionResource, 'client-1');
// Re-issue createSession mid-grace.
await new Promise(resolve => setTimeout(resolve, 5_000));
await service.createSession({ provider: 'copilot', session: AgentSession.uri('copilot', 'recreate-test') });
// Wait past the original grace window. If GC wasn't
// cancelled by createSession, dispose would have fired.
await new Promise(resolve => setTimeout(resolve, 30_000));
assert.strictEqual(copilotAgent.disposeSessionCalls.length, 0, 'createSession on same URI must cancel pending GC');
});
});
test('a restored session that loaded zero turns is never GC-disposed', () => {
// Regression: GC used to key purely off "0 turns", but a restored
// session can present as empty because its history FAILED to load.
return runWithFakedTimers({ useFakeTimers: true }, async () => {
service.registerProvider(copilotAgent);
await copilotAgent.createSession();
const sessions = await copilotAgent.listSessions();
const sessionResource = sessions[0].session;
// No messages => restores with zero turns, exactly as a failed
// history load would look.
copilotAgent.sessionMessages = [];
await service.restoreSession(sessionResource);
service.addSubscriber(sessionResource, 'client-1');
service.unsubscribe(sessionResource, 'client-1');
await new Promise(resolve => setTimeout(resolve, 30_000));
assert.deepStrictEqual({
disposed: copilotAgent.disposeSessionCalls.map(u => u.toString()),
released: copilotAgent.releaseSessionCalls.map(u => u.toString()),
}, {
// Nothing destroyed, but the non-destructive idle release still happens.
disposed: [],
released: [sessionResource.toString()],
});
});
});
test('a session restored during the grace window is not GC-disposed', () => {
// The rehydrated session is deliberately still empty, so that the
// turns check cannot be what saves it — only draft status can.
return runWithFakedTimers({ useFakeTimers: true }, async () => {
service.registerProvider(copilotAgent);
const sessionResource = await service.createSession({ provider: 'copilot' });
service.addSubscriber(sessionResource, 'client-1');
service.unsubscribe(sessionResource, 'client-1');
await new Promise(resolve => setTimeout(resolve, 5_000));
service.stateManager.deleteSession(sessionResource.toString());
copilotAgent.sessionMessages = [];
await service.restoreSession(sessionResource);
assert.strictEqual(service.stateManager.isUnusedDraft(sessionResource.toString()), false, 'precondition: session is now durable state');
await new Promise(resolve => setTimeout(resolve, 30_000));
assert.strictEqual(copilotAgent.disposeSessionCalls.length, 0, 'a session restored mid-grace must not be GC-disposed');
});
});
test('a session truncated back to zero turns is not GC-disposed', () => {
// A session created in this process that has been used is durable
// data — its worktree holds real work — even though a truncate
// (checkpoint restore / first-message edit) empties its turns.
return runWithFakedTimers({ useFakeTimers: true }, async () => {
service.registerProvider(copilotAgent);
const sessionResource = await service.createSession({ provider: 'copilot' });
const chatUri = buildDefaultChatUri(sessionResource.toString());
service.addSubscriber(sessionResource, 'client-1');
service.dispatchAction(chatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'client-1', 1);
service.dispatchAction(chatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }, 'client-1', 2);
// Truncate every turn away, then drop the last subscriber.
service.dispatchAction(chatUri, { type: ActionType.ChatTruncated }, 'client-1', 3);
assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString())?.turns.length, 0, 'precondition: session now looks empty');
service.unsubscribe(sessionResource, 'client-1');
await new Promise(resolve => setTimeout(resolve, 30_000));
assert.strictEqual(copilotAgent.disposeSessionCalls.length, 0, 'a used session must not be GC-disposed after truncation');
});
});
});
suite('session config persistence', () => {
test('createSession persists initial config values to the session DB', async () => {
const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));
const sessionDataService = createSessionDataService(sessionDb);
const localAgent = new MockAgent('copilot');
disposables.add(toDisposable(() => localAgent.dispose()));
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(localAgent);
await localService.createSession({ provider: 'copilot', config: { autoApprove: 'autoApprove' } });
// Persistence is fire-and-forget; wait for it to flush
await new Promise(r => setTimeout(r, 50));
const persisted = await sessionDb.getMetadata('configValues');
assert.ok(persisted, 'configValues should be persisted');
assert.deepStrictEqual(JSON.parse(persisted!), { autoApprove: 'autoApprove' });
});
test('createSession does not write configValues when there are no values', async () => {
const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));
const sessionDataService = createSessionDataService(sessionDb);
const localAgent = new MockAgent('copilot');
disposables.add(toDisposable(() => localAgent.dispose()));
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(localAgent);
await localService.createSession({ provider: 'copilot' });
await new Promise(r => setTimeout(r, 50));
const persisted = await sessionDb.getMetadata('configValues');
assert.strictEqual(persisted, undefined);
});
test('restoreSession overlays persisted config values onto the resolved config', async () => {
const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));
const sessionDataService = createSessionDataService(sessionDb);
const localAgent = new MockAgent('copilot');
disposables.add(toDisposable(() => localAgent.dispose()));
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(localAgent);
// Create a session on the agent backend (no config) so listSessions can find it
const { session } = await localAgent.createSession();
const sessions = await localAgent.listSessions();
const sessionResource = sessions[0].session;
// Pre-seed persisted config values
await sessionDb.setMetadata('configValues', JSON.stringify({ autoApprove: 'autoApprove' }));
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);
// MockAgent.resolveSessionConfig echoes params.config back as values, so the
// persisted values are forwarded through and end up on state.config.values.
assert.deepStrictEqual(state!.config?.values, { autoApprove: 'autoApprove' });
});
test.skip('restoreSession seeds the session changeset from persisted diffs', async () => {
const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));
const sessionDataService = createSessionDataService(sessionDb);
const localAgent = new MockAgent('copilot');
disposables.add(toDisposable(() => localAgent.dispose()));
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(localAgent);
const { session } = await localAgent.createSession();
const sessions = await localAgent.listSessions();
const sessionResource = sessions[0].session;
const persistedDiffs = [
{
after: { uri: 'file:///wd/a.ts', content: { uri: 'file:///wd/a.ts' } },
diff: { added: 5, removed: 2 },
},
];
await sessionDb.setMetadata('diffs', JSON.stringify(persistedDiffs));
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);
// The session has no working directory, so `_attachGitState`
// treats it as transient and does NOT strip the two git-only
// catalogue entries. The Branch Changes entry receives the
// persisted diff counts seeded by the changeset coordinator.
assert.deepStrictEqual(state!.changesets, [
{
label: 'Branch Changes',
uriTemplate: `${sessionResource.toString()}/changeset/session`,
changeKind: 'session',
},
{
label: 'Uncommitted Changes',
description: 'Show uncommitted changes in this session',
uriTemplate: `${sessionResource.toString()}/changeset/uncommitted`,
changeKind: 'uncommitted',
},
]);
const changesetSnapshot = localService.stateManager.getSnapshot(`${sessionResource.toString()}/changeset/session`);
assert.ok(changesetSnapshot);
const changesetState = changesetSnapshot.state as { status: string; files: Array<{ id: string }> };
assert.strictEqual(changesetState.status, 'ready');
assert.deepStrictEqual(changesetState.files.map(f => f.id), ['file:///wd/a.ts']);
});
test.skip('restoreSession silently ignores malformed persisted diffs', async () => {
const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));
const sessionDataService = createSessionDataService(sessionDb);
const localAgent = new MockAgent('copilot');
disposables.add(toDisposable(() => localAgent.dispose()));
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(localAgent);
const { session } = await localAgent.createSession();
const sessions = await localAgent.listSessions();
const sessionResource = sessions[0].session;
await sessionDb.setMetadata('diffs', '{ not valid json');
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);
// Catalogue is seeded by `_buildInitialSummary` / `restoreSession`.
// The session has no working directory, so `_attachGitState` does
// NOT strip the git-only entries — they remain advertised but
// without counts until a real compute lands.
assert.deepStrictEqual(state!.changesets, [
{
label: 'Branch Changes',
uriTemplate: `${sessionResource.toString()}/changeset/session`,
changeKind: 'session',
},
{
description: 'Show uncommitted changes in this session',
label: 'Uncommitted Changes',
uriTemplate: `${sessionResource.toString()}/changeset/uncommitted`,
changeKind: 'uncommitted',
},
]);
const changesetSnapshot = localService.stateManager.getSnapshot(`${sessionResource.toString()}/changeset/session`);
assert.ok(changesetSnapshot);
const changesetState = changesetSnapshot.state as { status: string; files: Array<{ id: string }> };
assert.strictEqual(changesetState.status, 'computing');
assert.strictEqual(changesetState.files.length, 0);
});
test('createSession + restoreSession round-trip restores initial config without any mid-session changes', async () => {
// Regression test: when a session is created with initial config but no
// mid-session SessionConfigChanged actions are dispatched, restoring it
// must still rehydrate the initial values.
const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));
const sessionDataService = createSessionDataService(sessionDb);
const localAgent = new MockAgent('copilot');
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 localService.createSession({ provider: 'copilot', config: { autoApprove: 'autoApprove' } });
// Wait for the fire-and-forget persistence to flush
await new Promise(r => setTimeout(r, 50));
// Simulate a server restart: drop the in-memory state
localService.stateManager.removeSession(session.toString());
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(session);
const state = localService.stateManager.getSessionState(session.toString());
assert.ok(state);
assert.deepStrictEqual(state!.config?.values, { autoApprove: 'autoApprove' });
});
test('restoreSession ignores malformed persisted configValues', async () => {
const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));
const sessionDataService = createSessionDataService(sessionDb);
const localAgent = new MockAgent('copilot');
disposables.add(toDisposable(() => localAgent.dispose()));
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(localAgent);
const { session } = await localAgent.createSession();
const sessions = await localAgent.listSessions();
const sessionResource = sessions[0].session;
await sessionDb.setMetadata('configValues', '{not json');
localAgent.sessionMessages = [
{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },
{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },
];
// Should not throw despite the malformed JSON
await localService.restoreSession(sessionResource);
const state = localService.stateManager.getSessionState(sessionResource.toString());
assert.ok(state);
// MockAgent has a workingDirectory? No — but the metadata supplies it as undefined.
// _resolveCreatedSessionConfig bails when both .config and .workingDirectory are
// missing, so state.config is undefined here. The key point is: no throw.
assert.strictEqual(state!.config, undefined);
});
});
// ---- resourceList ------------------------------------------------
suite('resourceList', () => {
test('throws when the directory does not exist', async () => {
await assert.rejects(
() => service.resourceList(URI.from({ scheme: Schemas.inMemory, path: '/nonexistent' })),
/Directory not found/,
);
});
test('throws when the target is not a directory', async () => {
await assert.rejects(
() => service.resourceList(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' })),
/Not a directory/,
);
});
});
// ---- worktree working directory -------------------------------------
suite('worktree working directory', () => {
test('createSession uses agent-resolved working directory in state', async () => {
// Simulate an agent that resolves a worktree path different from the input
const worktreeDir = URI.file('/source/repo.worktrees/agents-xyz');
copilotAgent.resolvedWorkingDirectory = worktreeDir;
service.registerProvider(copilotAgent);
const sourceDir = URI.file('/source/repo');
const session = await service.createSession({ provider: 'copilot', workingDirectories: [sourceDir] });
// The state manager should have the worktree path, not the source path
const state = service.stateManager.getSessionState(session.toString());
assert.strictEqual(state?.workingDirectories?.[0], worktreeDir.toString());
});
test('createSession falls back to config working directory when agent does not resolve', async () => {
// Agent does not override the working directory (e.g. folder isolation)
copilotAgent.resolvedWorkingDirectory = undefined;
service.registerProvider(copilotAgent);
const sourceDir = URI.file('/source/repo');
const session = await service.createSession({ provider: 'copilot', workingDirectories: [sourceDir] });
const state = service.stateManager.getSessionState(session.toString());
assert.strictEqual(state?.workingDirectories?.[0], sourceDir.toString());
});
test('restoreSession uses agent working directory in state', async () => {
// Agent returns the worktree path through listSessions
const worktreeDir = URI.file('/source/repo.worktrees/agents-xyz');
copilotAgent.sessionMetadataOverrides = { workingDirectories: worktreeDir ? [worktreeDir] : undefined };
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
// Delete from state to simulate a server restart
service.stateManager.deleteSession(session.toString());
assert.strictEqual(service.stateManager.getSessionState(session.toString()), undefined);
// Restore the session (simulates a client subscribing after restart)
await service.restoreSession(session);
const state = service.stateManager.getSessionState(session.toString());
assert.strictEqual(state?.workingDirectories?.[0], worktreeDir.toString());
});
test('_resolveWorkingDirectoryBeforeSend returns the full set (index 0 + tail), or undefined when unset', async () => {
const resolver = service as unknown as {
_resolveWorkingDirectoryBeforeSend: (p: { session: string; chat: string; turnId: string; prompt: string }) => Promise<readonly URI[] | undefined>;
};
const resolve = (resource: string) => resolver._resolveWorkingDirectoryBeforeSend({ session: resource, chat: `${resource}/chat`, turnId: 't', prompt: 'hi' });
const inject = (resource: string, dirs?: readonly URI[]) => service.stateManager.restoreSession({
resource,
provider: 'copilot',
title: 't',
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
project: undefined,
workingDirectories: dirs?.map(d => d.toString()),
}, []);
const a = URI.file('/roots/a');
const b = URI.file('/roots/b');
const c = URI.file('/roots/c');
const multi = URI.from({ scheme: 'copilot', path: '/multi' }).toString();
const single = URI.from({ scheme: 'copilot', path: '/single' }).toString();
const none = URI.from({ scheme: 'copilot', path: '/none' }).toString();
inject(multi, [a, b, c]);
inject(single, [a]);
inject(none, undefined);
// No worktree isolation is configured, so index 0 resolves to itself and
// the additional roots are preserved as-is; a session with no roots
// resolves to `undefined` (the agent runs in its own scratch dir).
const toStrings = (r: readonly URI[] | undefined) => r?.map(d => d.toString());
assert.deepStrictEqual(
[toStrings(await resolve(multi)), toStrings(await resolve(single)), toStrings(await resolve(none))],
[[a, b, c].map(d => d.toString()), [a.toString()], undefined],
);
});
test('first-send worktree failure warns and falls back to the original folder', async () => {
const sourceDir = URI.file(mkdtempSync(`${tmpdir()}/agent-worktree-failure-`));
disposables.add(toDisposable(() => {
rmSync(sourceDir.fsPath, { recursive: true, force: true });
rmSync(getWorktreesRoot(sourceDir).fsPath, { recursive: true, force: true });
}));
const database = new TestSessionDatabase();
const sessionDataService = createSessionDataService(database);
const gitService = createNoopGitService();
gitService.getRepositoryRoot = async () => sourceDir;
gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'main' });
gitService.addWorktree = async () => {
throw new Error('git worktree exited with code 128: git-lfs filter-process: git-lfs: command not found');
};
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
const isolation = disposables.add(new WorktreeIsolation(
{ generateBranchName: async () => 'agents/failure' },
gitService,
new TestCopilotApiService(),
sessionDataService,
new NullLogService(),
));
localService.setWorktreeIsolation(isolation);
const session = AgentSession.uri('copilot', 'worktree-failure');
const sessionResource = session.toString();
const chat = buildDefaultChatUri(sessionResource);
localService.stateManager.restoreSession({
resource: sessionResource,
provider: 'copilot',
title: 'Worktree failure',
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
project: undefined,
workingDirectories: [sourceDir.toString()],
}, []);
localService.stateManager.setSessionConfig(sessionResource, {
schema: { type: 'object', properties: {} },
values: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' },
});
localService.stateManager.dispatchServerAction(chat, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'test', origin: { kind: MessageKind.User } },
});
isolation.notePending(AgentSession.id(session));
const resolver = localService as unknown as {
_resolveWorkingDirectoryBeforeSend: (params: { session: string; chat: string; turnId: string; prompt: string }) => Promise<readonly URI[] | undefined>;
};
const resolved = await resolver._resolveWorkingDirectoryBeforeSend({ session: sessionResource, chat, turnId: 'turn-1', prompt: 'test' });
const chatState = localService.stateManager.getChatState(chat);
assert.deepStrictEqual({
resolved: resolved?.map(uri => uri.toString()),
activity: chatState?.activity,
responseParts: chatState?.activeTurn?.responseParts,
persistedFailure: JSON.parse((await database.getMetadata('copilot.worktree.creationFailure'))!),
}, {
resolved: [sourceDir.toString()],
activity: undefined,
responseParts: [{
kind: ResponsePartKind.SystemNotification,
content: 'Couldn\'t create the isolated worktree. This session is continuing in the original folder.\n\n`git worktree exited with code 128: git-lfs filter-process: git-lfs: command not found`',
_meta: { kind: 'worktreeCreationFailure', severity: 'warning' },
}],
persistedFailure: {
sessionId: 'worktree-failure',
diagnostic: 'git worktree exited with code 128: git-lfs filter-process: git-lfs: command not found',
},
});
});
test('first-send worktree fallback warns when no repository root is resolved', async () => {
const sourceDir = URI.file('/source/repo');
const database = new TestSessionDatabase();
const sessionDataService = createSessionDataService(database);
const gitService = createNoopGitService();
gitService.getRepositoryRoot = async () => undefined;
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
const isolation = disposables.add(new WorktreeIsolation(
{ generateBranchName: async () => 'agents/fallback' },
gitService,
new TestCopilotApiService(),
sessionDataService,
new NullLogService(),
));
localService.setWorktreeIsolation(isolation);
const session = AgentSession.uri('copilot', 'worktree-fallback');
const sessionResource = session.toString();
const chat = buildDefaultChatUri(sessionResource);
localService.stateManager.restoreSession({
resource: sessionResource,
provider: 'copilot',
title: 'Worktree fallback',
status: SessionStatus.Idle,
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
project: undefined,
workingDirectories: [sourceDir.toString()],
}, []);
localService.stateManager.setSessionConfig(sessionResource, {
schema: { type: 'object', properties: {} },
values: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' },
});
localService.stateManager.dispatchServerAction(chat, {
type: ActionType.ChatTurnStarted,
turnId: 'turn-1',
startedAt: '2025-01-01T00:00:00.000Z',
message: { text: 'test', origin: { kind: MessageKind.User } },
});
isolation.notePending(AgentSession.id(session));
const resolver = localService as unknown as {
_resolveWorkingDirectoryBeforeSend: (params: { session: string; chat: string; turnId: string; prompt: string }) => Promise<readonly URI[] | undefined>;
};
const resolved = await resolver._resolveWorkingDirectoryBeforeSend({ session: sessionResource, chat, turnId: 'turn-1', prompt: 'test' });
assert.deepStrictEqual({
resolved: resolved?.map(uri => uri.toString()),
responseParts: localService.stateManager.getChatState(chat)?.activeTurn?.responseParts,
persistedFailure: JSON.parse((await database.getMetadata('copilot.worktree.creationFailure'))!),
}, {
resolved: [sourceDir.toString()],
responseParts: [{
kind: ResponsePartKind.SystemNotification,
content: 'Couldn\'t create the isolated worktree. This session is continuing in the original folder.',
_meta: { kind: 'worktreeCreationFailure', severity: 'warning' },
}],
persistedFailure: { sessionId: 'worktree-fallback' },
});
});
});
test('provisional workspace session advertises Uncommitted Changes before materialization', async () => {
class ProvisionalMockAgent extends MockAgent {
override async createSession(config?: import('../../common/agentService.js').IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult> {
const result = await super.createSession(config);
return { ...result, provisional: true };
}
}
const workingDirectory = URI.file('/workspace');
const gitCalls: string[] = [];
const gitService = createNoopGitService();
gitService.getSessionGitState = async resource => {
gitCalls.push(resource.toString());
return {
hasGitHubRemote: false,
branchName: 'main',
baseBranchName: 'main',
upstreamBranchName: undefined,
incomingChanges: 0,
outgoingChanges: 0,
uncommittedChanges: 1,
};
};
gitService.computeSessionFileDiffs = async () => [];
const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
const provisionalAgent = new ProvisionalMockAgent('provisional');
disposables.add(toDisposable(() => provisionalAgent.dispose()));
localService.registerProvider(provisionalAgent);
const workspaceSession = await localService.createSession({
provider: provisionalAgent.id,
workingDirectories: workingDirectory ? [workingDirectory] : undefined,
});
const uncommittedUri = buildUncommittedChangesetUri(workspaceSession.toString());
localService.addSubscriber(URI.parse(uncommittedUri), 'client-1');
for (let i = 0; i < 100; i++) {
if (localService.stateManager.getChangesetState(uncommittedUri)?.operations?.some(operation => operation.id === 'commit')) {
break;
}
await timeout(2);
}
const workspaceState = localService.stateManager.getSessionState(workspaceSession.toString());
assert.deepStrictEqual({
lifecycle: workspaceState?.lifecycle,
changesets: workspaceState?.changesets?.map(changeset => changeset.changeKind),
gitCalls,
hasCommit: localService.stateManager.getChangesetState(uncommittedUri)?.operations?.some(operation => operation.id === 'commit'),
}, {
lifecycle: SessionLifecycle.Creating,
changesets: ['uncommitted'],
gitCalls: [workingDirectory.toString()],
hasCommit: true,
});
localService.unsubscribe(URI.parse(uncommittedUri), 'client-1');
const workspaceLessSession = await localService.createSession({ provider: provisionalAgent.id });
assert.deepStrictEqual(
localService.stateManager.getSessionState(workspaceLessSession.toString())?.changesets ?? [],
[],
);
});
// ---- Item-2 regression: initial changeset seeding happens at create time --
/**
* These tests pin the create-time invariant that both halves of initial
* changeset seeding — the summary catalogue (`buildDefaultChangesetCatalogue`
* inside `_buildInitialSummary`) and the backing per-changeset states
* (`AgentHostChangesetService.registerStaticChangesets`) — run as part
* of session creation, never deferred to materialization. They assert
* both halves through the public snapshot surface only, never inspecting
* state-manager internals.
*/
suite.skip('item-2: initial changeset seeding at create time', () => {
/** Returns `true` when both static changeset URIs exist with `status: 'computing'`. */
function assertBackingChangesetsComputing(stateManager: AgentService['stateManager'], sessionStr: string): void {
const uncommitted = stateManager.getSnapshot(buildUncommittedChangesetUri(sessionStr));
const sessionWide = stateManager.getSnapshot(buildSessionChangesetUri(sessionStr));
assert.ok(uncommitted, `expected ${sessionStr}/changeset/uncommitted to be subscribable`);
assert.ok(sessionWide, `expected ${sessionStr}/changeset/session to be subscribable`);
assert.strictEqual((uncommitted.state as { status: string }).status, ChangesetStatus.Computing);
assert.strictEqual((sessionWide.state as { status: string }).status, ChangesetStatus.Computing);
}
function defaultCatalogue(sessionStr: string) {
// These tests have no working directory resolved, so
// `_attachGitState` treats it as transient and does NOT strip
// the two git-only entries. All three default entries are
// advertised (without counts) until a real compute lands.
return [
{
label: 'Branch Changes',
uriTemplate: `${sessionStr}/changeset/session`,
changeKind: 'session',
},
{
label: 'Uncommitted Changes',
description: 'Show uncommitted changes in this session',
uriTemplate: `${sessionStr}/changeset/uncommitted`,
changeKind: 'uncommitted',
},
];
}
test('createSession seeds both halves before SessionReady', async () => {
service.registerProvider(copilotAgent);
const session = await service.createSession({ provider: 'copilot' });
const sessionStr = session.toString();
const state = service.stateManager.getSessionState(sessionStr);
assert.ok(state);
assert.deepStrictEqual(state!.changesets, defaultCatalogue(sessionStr));
assertBackingChangesetsComputing(service.stateManager, sessionStr);
});
test('forked createSession seeds both halves on the forked session', async () => {
service.registerProvider(copilotAgent);
// Set up a source session with at least one completed turn. The
// fork path at agentService.ts:493-504 intentionally drops
// `config.fork` when the source has zero turns and falls through
// to the non-fork create path; without this prelude the test
// would silently exercise the non-fork branch and pass vacuously.
const sourceSession = await service.createSession({ provider: 'copilot' });
const sourceState = service.stateManager.getSessionState(sourceSession.toString())!;
const sourceTurnId = 'turn-src-1';
sourceState.turns = [{
id: sourceTurnId,
state: TurnState.Complete,
message: { text: 'hi', origin: { kind: MessageKind.User } },
responseParts: [],
usage: undefined,
}];
const forked = await service.createSession({
provider: 'copilot',
fork: { session: sourceSession, turnIndex: 0, turnId: sourceTurnId },
});
assert.notStrictEqual(forked.toString(), sourceSession.toString(), 'fork should produce a distinct session URI');
const forkedStr = forked.toString();
const forkedState = service.stateManager.getSessionState(forkedStr);
assert.ok(forkedState);
assert.deepStrictEqual(forkedState!.changesets, defaultCatalogue(forkedStr));
// Note: source-session turn was seeded directly on state, so the
// reducer never saw a ChatTurnStarted/Complete pair for it;
// the fork branch (agentService.ts:548 path) is still exercised
// because `config.fork` survives the L493-504 turn-count check.
assert.ok(forkedState!.turns.length > 0, 'forked session should carry copied turns');
assertBackingChangesetsComputing(service.stateManager, forkedStr);
});
test('provisional session materialization preserves both halves', async () => {
// Custom mock that returns `provisional: true` and exposes a hook
// to fire `onDidMaterializeSession` later, simulating the
// "session created in-memory now, persisted on first sendMessage"
// flow that Copilot CLI / Claude actually use in production.
class ProvisionalMockAgent extends MockAgent {
private readonly _onDidMaterialize = new Emitter<{ session: URI; workingDirectories: readonly URI[] | undefined; project: { uri: URI; displayName: string } | undefined }>();
readonly onDidMaterializeSession = this._onDidMaterialize.event;
override async createSession(config?: import('../../common/agentService.js').IAgentCreateSessionConfig): Promise<import('../../common/agentService.js').IAgentCreateSessionResult> {
const result = await super.createSession(config);
return { ...result, provisional: true };
}
materialize(session: URI, workingDirectory?: URI): void {
this._onDidMaterialize.fire({ session, workingDirectories: workingDirectory ? [workingDirectory] : undefined, project: undefined });
}
}
const provisionalAgent = new ProvisionalMockAgent('copilot');
disposables.add(toDisposable(() => provisionalAgent.dispose()));
service.registerProvider(provisionalAgent);
const session = await service.createSession({ provider: 'copilot' });
const sessionStr = session.toString();
// Snapshot the create-time state BEFORE materialization.
const stateBefore = service.stateManager.getSessionState(sessionStr);
assert.ok(stateBefore, 'provisional session should already have state');
assert.deepStrictEqual(stateBefore!.changesets, defaultCatalogue(sessionStr));
assertBackingChangesetsComputing(service.stateManager, sessionStr);
// `markSessionPersisted` (called from `_onDidMaterializeSession`)
// re-spreads flattened session metadata. A future change to that spread
// could drop the catalogue or invalidate the backing snapshots;
// the post-materialization re-assertion is what catches it.
provisionalAgent.materialize(session, URI.file('/wd'));
const stateAfter = service.stateManager.getSessionState(sessionStr);
assert.ok(stateAfter, 'materialized session should still have state');
assert.deepStrictEqual(stateAfter!.changesets, defaultCatalogue(sessionStr));
assertBackingChangesetsComputing(service.stateManager, sessionStr);
});
test('restoreSession with no persisted diffs seeds both halves in computing state', async () => {
const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));
const sessionDataService = createSessionDataService(sessionDb);
const localAgent = new MockAgent('copilot');
disposables.add(toDisposable(() => localAgent.dispose()));
const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));
localService.registerProvider(localAgent);
const { session } = await localAgent.createSession();
const sessions = await localAgent.listSessions();
const sessionResource = sessions[0].session;
const sessionStr = sessionResource.toString();
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(sessionStr);
assert.ok(state);
assert.deepStrictEqual(state!.changesets, defaultCatalogue(sessionStr));
assertBackingChangesetsComputing(localService.stateManager, sessionStr);
});
});
});