Path: blob/main/src/vs/platform/agentHost/test/node/agentService.test.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { mkdtempSync, readFileSync, rmSync } from 'fs';7import { tmpdir } from 'os';8import { fileURLToPath } from 'url';9import { VSBuffer } from '../../../../base/common/buffer.js';10import { DisposableStore, IReference, toDisposable } from '../../../../base/common/lifecycle.js';11import { Schemas } from '../../../../base/common/network.js';12import { joinPath } from '../../../../base/common/resources.js';13import { URI } from '../../../../base/common/uri.js';14import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';15import { hasKey } from '../../../../base/common/types.js';16import { NullLogService } from '../../../log/common/log.js';17import { FileService } from '../../../files/common/fileService.js';18import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';19import { AgentSession } from '../../common/agentService.js';20import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';21import { SessionDatabase } from '../../node/sessionDatabase.js';22import { ActionType, ActionEnvelope } from '../../common/state/sessionActions.js';23import { SessionActiveClient, ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart } from '../../common/state/sessionState.js';24import { IProductService } from '../../../product/common/productService.js';25import { AgentService } from '../../node/agentService.js';26import { MockAgent, ScriptedMockAgent } from './mockAgent.js';27import { mapSessionEventsToHistoryRecords } from './historyRecordFixtures.js';28import { type ISessionEvent } from '../../node/copilot/mapSessionEvents.js';29import { createNoopGitService, createSessionDataService } from '../common/sessionTestHelpers.js';3031/**32* Loads a JSONL fixture of raw Copilot SDK events, runs them through33* {@link mapSessionEventsToHistoryRecords}, and returns the result34* suitable for setting on {@link MockAgent.sessionMessages}. Tests the35* full pipeline: SDK events → IHistoryRecord → buildTurnsFromHistory →36* Turn[].37*38* Fixture files live in `test-cases/` and are sanitized copies of real39* `events.jsonl` files from `~/.copilot/session-state/`.40*/41async function loadFixtureMessages(fixtureName: string, session: URI) {42// Resolve the fixture from the source tree (test-cases/ is not compiled to out/)43const thisFile = fileURLToPath(import.meta.url);44// Navigate from out/vs/... to src/vs/... by replacing the out/ prefix.45// Use a regex that handles both / and \ separators for Windows compat.46const srcFile = thisFile.replace(/[/\\]out[/\\]/, (m) => m.replace('out', 'src'));47const lastSep = Math.max(srcFile.lastIndexOf('/'), srcFile.lastIndexOf('\\'));48const fixtureDir = srcFile.substring(0, lastSep);49const sep = srcFile.includes('\\') ? '\\' : '/';50const raw = readFileSync(`${fixtureDir}${sep}test-cases${sep}${fixtureName}`, 'utf-8');51const events: ISessionEvent[] = raw.trim().split('\n').map(line => JSON.parse(line));52return mapSessionEventsToHistoryRecords(session, undefined, events);53}5455suite('AgentService (node dispatcher)', () => {5657const disposables = new DisposableStore();58let service: AgentService;59let copilotAgent: MockAgent;60let fileService: FileService;61let nullSessionDataService: ISessionDataService;6263setup(async () => {64nullSessionDataService = {65_serviceBrand: undefined,66getSessionDataDir: () => URI.parse('inmemory:/session-data'),67getSessionDataDirById: () => URI.parse('inmemory:/session-data'),68openDatabase: () => { throw new Error('not implemented'); },69tryOpenDatabase: async () => undefined,70deleteSessionData: async () => { },71cleanupOrphanedData: async () => { },72whenIdle: async () => { },73};7475fileService = disposables.add(new FileService(new NullLogService()));76disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider())));7778// Seed a directory for browseDirectory tests79await fileService.createFolder(URI.from({ scheme: Schemas.inMemory, path: '/testDir' }));80await fileService.writeFile(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }), VSBuffer.fromString('hello'));8182service = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));83copilotAgent = new MockAgent('copilot');84disposables.add(toDisposable(() => copilotAgent.dispose()));85});8687teardown(() => disposables.clear());88ensureNoDisposablesAreLeakedInTestSuite();8990// ---- Provider registration ------------------------------------------9192suite('registerProvider', () => {9394test('registers a provider successfully', () => {95service.registerProvider(copilotAgent);96// No throw - success97});9899test('throws on duplicate provider registration', () => {100service.registerProvider(copilotAgent);101const duplicate = new MockAgent('copilot');102disposables.add(toDisposable(() => duplicate.dispose()));103assert.throws(() => service.registerProvider(duplicate), /already registered/);104});105106test('maps progress events to protocol actions via onDidAction', async () => {107service.registerProvider(copilotAgent);108const session = await service.createSession({ provider: 'copilot' });109110// Start a turn so there's an active turn to map events to111service.dispatchAction(112{ type: ActionType.SessionTurnStarted, session: session.toString(), turnId: 'turn-1', userMessage: { text: 'hello' } },113'test-client', 1,114);115116const envelopes: ActionEnvelope[] = [];117disposables.add(service.onDidAction(e => envelopes.push(e)));118119copilotAgent.fireProgress({120kind: 'action', session,121action: { type: ActionType.SessionResponsePart, session: session.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'hello' } },122});123assert.ok(envelopes.some(e => e.action.type === ActionType.SessionResponsePart));124});125});126127// ---- createSession --------------------------------------------------128129suite('dispatchAction', () => {130131test('applies and persists root config changes from clients', async () => {132const tempDir = URI.file(mkdtempSync(`${tmpdir()}/agent-host-config-`));133try {134const rootConfigResource = joinPath(tempDir, 'agent-host-config.json');135const svc = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), rootConfigResource));136const agent = new MockAgent('copilot');137disposables.add(toDisposable(() => agent.dispose()));138svc.registerProvider(agent);139140const customization = { uri: 'file:///plugin-a', displayName: 'Plugin A' };141svc.dispatchAction({142type: ActionType.RootConfigChanged,143config: { customizations: [customization] },144}, 'test-client', 1);145146let persisted = false;147for (let attempt = 0; attempt < 20; attempt++) {148try {149const parsed = JSON.parse(readFileSync(rootConfigResource.fsPath, 'utf8'));150assert.deepStrictEqual(151parsed.customizations,152[customization],153);154persisted = true;155break;156} catch {157// Wait for the serialized root-config write to complete.158}159if (attempt === 19) {160break;161}162await new Promise(resolve => setTimeout(resolve, 5));163}164165assert.ok(persisted, 'should persist the root config change');166} finally {167rmSync(tempDir.fsPath, { recursive: true, force: true });168}169});170});171172suite('createSession', () => {173174test('creates session via specified provider', async () => {175service.registerProvider(copilotAgent);176177const session = await service.createSession({ provider: 'copilot' });178assert.strictEqual(AgentSession.provider(session), 'copilot');179});180181test('honors requested session URI', async () => {182service.registerProvider(copilotAgent);183184const requestedSession = AgentSession.uri('copilot', 'requested-session');185const session = await service.createSession({ provider: 'copilot', session: requestedSession });186assert.strictEqual(session.toString(), requestedSession.toString());187});188189test('scripted mock agent honors requested session URI', async () => {190const agent = new ScriptedMockAgent();191disposables.add(toDisposable(() => agent.dispose()));192193const requestedSession = AgentSession.uri('mock', 'requested-session');194const result = await agent.createSession({ session: requestedSession });195const sessions = await agent.listSessions();196197assert.deepStrictEqual({198created: result.session.toString(),199listed: sessions.some(s => s.session.toString() === requestedSession.toString()),200}, {201created: requestedSession.toString(),202listed: true,203});204});205206test('uses default provider when none specified', async () => {207service.registerProvider(copilotAgent);208209const session = await service.createSession();210assert.strictEqual(AgentSession.provider(session), 'copilot');211});212213test('throws when no providers are registered at all', async () => {214await assert.rejects(() => service.createSession(), /No agent provider/);215});216});217218// ---- disposeSession -------------------------------------------------219220suite('disposeSession', () => {221222test('dispatches to the correct provider and cleans up tracking', async () => {223service.registerProvider(copilotAgent);224225const session = await service.createSession({ provider: 'copilot' });226await service.disposeSession(session);227228assert.strictEqual(copilotAgent.disposeSessionCalls.length, 1);229});230231test('is a no-op for unknown sessions', async () => {232service.registerProvider(copilotAgent);233const unknownSession = URI.from({ scheme: 'unknown', path: '/nope' });234235// Should not throw236await service.disposeSession(unknownSession);237});238});239240// ---- listSessions / listModels --------------------------------------241242suite('aggregation', () => {243244test('listSessions aggregates sessions from all providers', async () => {245service.registerProvider(copilotAgent);246247await service.createSession({ provider: 'copilot' });248249const sessions = await service.listSessions();250assert.strictEqual(sessions.length, 1);251});252253test('listSessions overlays custom title from session database', async () => {254// Pre-seed a custom title in an in-memory database255const db = disposables.add(await SessionDatabase.open(':memory:'));256await db.setMetadata('customTitle', 'My Custom Title');257258const sessionId = 'test-session-abc';259const sessionUri = AgentSession.uri('copilot', sessionId);260261const sessionDataService: ISessionDataService = {262_serviceBrand: undefined,263getSessionDataDir: () => URI.parse('inmemory:/session-data'),264getSessionDataDirById: () => URI.parse('inmemory:/session-data'),265openDatabase: (): IReference<ISessionDatabase> => ({266object: db,267dispose: () => { },268}),269tryOpenDatabase: async (): Promise<IReference<ISessionDatabase> | undefined> => ({270object: db,271dispose: () => { },272}),273deleteSessionData: async () => { },274cleanupOrphanedData: async () => { },275whenIdle: async () => { },276};277278// Create a mock that returns a session with that ID279const agent = new MockAgent('copilot');280disposables.add(toDisposable(() => agent.dispose()));281agent.sessionMetadataOverrides = { summary: 'SDK Title' };282// Manually add the session to the mock283(agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri);284285const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));286svc.registerProvider(agent);287288const sessions = await svc.listSessions();289assert.strictEqual(sessions.length, 1);290assert.strictEqual(sessions[0].summary, 'My Custom Title');291});292293test('listSessions uses SDK title when no custom title exists', async () => {294service.registerProvider(copilotAgent);295copilotAgent.sessionMetadataOverrides = { summary: 'Auto-generated Title' };296297await service.createSession({ provider: 'copilot' });298299const sessions = await service.listSessions();300assert.strictEqual(sessions.length, 1);301assert.strictEqual(sessions[0].summary, 'Auto-generated Title');302});303304test('listSessions overlays live state manager title over SDK title', async () => {305service.registerProvider(copilotAgent);306307const session = await service.createSession({ provider: 'copilot' });308309// Simulate immediate title change via state manager310service.stateManager.dispatchServerAction({311type: ActionType.SessionTitleChanged,312session: session.toString(),313title: 'User first message',314});315316const sessions = await service.listSessions();317assert.strictEqual(sessions.length, 1);318assert.strictEqual(sessions[0].summary, 'User first message');319});320321test('createSession attaches git state into state _meta when working directory is present', async () => {322const workingDirectory = URI.file('/workspace/repo');323const gitState = {324hasGitHubRemote: true,325branchName: 'feature/x',326baseBranchName: 'main',327upstreamBranchName: 'origin/feature/x',328incomingChanges: 1,329outgoingChanges: 2,330uncommittedChanges: 3,331};332const calls: string[] = [];333const gitService = {334_serviceBrand: undefined,335isInsideWorkTree: async () => true,336getCurrentBranch: async () => undefined,337getDefaultBranch: async () => undefined,338getBranches: async () => [],339getRepositoryRoot: async () => undefined,340getWorktreeRoots: async () => [],341addWorktree: async () => { },342addExistingWorktree: async () => { },343removeWorktree: async () => { },344branchExists: async () => false,345hasUncommittedChanges: async () => false,346getSessionGitState: async (uri: URI) => { calls.push(uri.fsPath); return gitState; },347computeSessionFileDiffs: async () => undefined,348showBlob: async () => undefined,349};350const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));351const agent = new MockAgent('copilot');352disposables.add(toDisposable(() => agent.dispose()));353agent.resolvedWorkingDirectory = workingDirectory;354agent.sessionMetadataOverrides = { workingDirectory };355localService.registerProvider(agent);356357const session = await localService.createSession({ provider: 'copilot' });358359// _attachGitState is fire-and-forget; drain microtasks until the360// git service's promise has resolved and setSessionMeta has run.361for (let i = 0; i < 5; i++) {362await Promise.resolve();363}364365const sessions = await localService.listSessions();366assert.strictEqual(sessions.length, 1);367assert.deepStrictEqual(calls, [workingDirectory.fsPath]);368assert.deepStrictEqual(369localService.stateManager.getSessionState(session.toString())?._meta,370{ git: gitState },371);372});373374test('createSession skips git overlay when no working directory or no git state', async () => {375const gitService = {376_serviceBrand: undefined,377isInsideWorkTree: async () => false,378getCurrentBranch: async () => undefined,379getDefaultBranch: async () => undefined,380getBranches: async () => [],381getRepositoryRoot: async () => undefined,382getWorktreeRoots: async () => [],383addWorktree: async () => { },384addExistingWorktree: async () => { },385removeWorktree: async () => { },386branchExists: async () => false,387hasUncommittedChanges: async () => false,388getSessionGitState: async () => undefined,389computeSessionFileDiffs: async () => undefined,390showBlob: async () => undefined,391};392const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));393const agent = new MockAgent('copilot');394disposables.add(toDisposable(() => agent.dispose()));395// No resolvedWorkingDirectory set on the mock.396localService.registerProvider(agent);397398const session = await localService.createSession({ provider: 'copilot' });399for (let i = 0; i < 5; i++) {400await Promise.resolve();401}402const sessions = await localService.listSessions();403404assert.strictEqual(sessions.length, 1);405assert.strictEqual(localService.stateManager.getSessionState(session.toString())?._meta, undefined);406});407408test('subscribe lazily attaches git state when an existing session has no _meta.git', async () => {409// Regression test: previously AgentService was constructed without410// a git service, so _attachGitState always bailed and `_meta.git`411// was never populated. This test ensures the lazy-fire path on412// subscribe() actually invokes the git service and writes git413// state into the session's `_meta`.414const workingDirectory = URI.file('/workspace/repo');415const gitState = {416hasGitHubRemote: false,417branchName: 'feature/lazy',418baseBranchName: 'main',419upstreamBranchName: undefined,420incomingChanges: 0,421outgoingChanges: 0,422uncommittedChanges: 0,423};424const calls: string[] = [];425const gitService = createNoopGitService();426gitService.getSessionGitState = async (uri: URI) => { calls.push(uri.fsPath); return gitState; };427const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));428const agent = new MockAgent('copilot');429disposables.add(toDisposable(() => agent.dispose()));430agent.resolvedWorkingDirectory = workingDirectory;431agent.sessionMetadataOverrides = { workingDirectory };432localService.registerProvider(agent);433434// Seed a session and clear its _meta so subscribe must lazily435// recompute git state.436const session = await localService.createSession({ provider: 'copilot' });437for (let i = 0; i < 5; i++) {438await Promise.resolve();439}440localService.stateManager.setSessionMeta(session.toString(), undefined);441calls.length = 0;442443await localService.subscribe(session);444for (let i = 0; i < 5; i++) {445await Promise.resolve();446}447448assert.deepStrictEqual(calls, [workingDirectory.fsPath]);449assert.deepStrictEqual(450localService.stateManager.getSessionState(session.toString())?._meta,451{ git: gitState },452);453});454455test('createSession stores live session config', async () => {456service.registerProvider(copilotAgent);457458const config = { isolation: 'worktree', branch: 'feature/config' };459const session = await service.createSession({ provider: 'copilot', config });460461assert.deepStrictEqual(service.stateManager.getSessionState(session.toString())?.config?.values, config);462});463464test('seeds activeClient into the initial session state when provided', async () => {465service.registerProvider(copilotAgent);466467const envelopes: ActionEnvelope[] = [];468disposables.add(service.onDidAction(env => envelopes.push(env)));469470const activeClient: SessionActiveClient = {471clientId: 'client-eager',472tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }],473customizations: [{ uri: 'file:///plugin-a', displayName: 'A' }],474};475const session = await service.createSession({ provider: 'copilot', activeClient });476477assert.deepStrictEqual({478activeClient: service.stateManager.getSessionState(session.toString())?.activeClient,479dispatchedActiveClientChanged: envelopes.some(e => e.action.type === ActionType.SessionActiveClientChanged),480}, {481activeClient,482dispatchedActiveClientChanged: false,483});484});485486test('omits activeClient from the initial session state when not provided', async () => {487service.registerProvider(copilotAgent);488489const session = await service.createSession({ provider: 'copilot' });490491assert.strictEqual(service.stateManager.getSessionState(session.toString())?.activeClient, undefined);492});493});494495// ---- authenticate ---------------------------------------------------496497suite('authenticate', () => {498499test('routes token to provider matching the resource', async () => {500service.registerProvider(copilotAgent);501502const result = await service.authenticate({ resource: 'https://api.github.com', token: 'ghp_test123' });503504assert.deepStrictEqual(result, { authenticated: true });505assert.deepStrictEqual(copilotAgent.authenticateCalls, [{ resource: 'https://api.github.com', token: 'ghp_test123' }]);506});507508test('returns not authenticated for unknown resource', async () => {509service.registerProvider(copilotAgent);510511const result = await service.authenticate({ resource: 'https://unknown.example.com', token: 'tok' });512513assert.deepStrictEqual(result, { authenticated: false });514assert.strictEqual(copilotAgent.authenticateCalls.length, 0);515});516});517518// ---- shutdown -------------------------------------------------------519520suite('shutdown', () => {521522test('shuts down all providers', async () => {523let copilotShutdown = false;524copilotAgent.shutdown = async () => { copilotShutdown = true; };525526service.registerProvider(copilotAgent);527528await service.shutdown();529assert.ok(copilotShutdown);530});531});532533// ---- restoreSession -------------------------------------------------534535suite('restoreSession', () => {536537test('restores a session with message history', async () => {538service.registerProvider(copilotAgent);539const { session } = await copilotAgent.createSession();540const sessions = await copilotAgent.listSessions();541const sessionResource = sessions[0].session;542543copilotAgent.sessionMessages = [544{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },545{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi there!', toolRequests: [] },546];547548await service.restoreSession(sessionResource);549550const state = service.stateManager.getSessionState(sessionResource.toString());551assert.ok(state, 'session should be in state manager');552assert.strictEqual(state!.lifecycle, SessionLifecycle.Ready);553assert.strictEqual(state!.turns.length, 1);554assert.strictEqual(state!.turns[0].userMessage.text, 'Hello');555const mdPart = state!.turns[0].responseParts.find((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown);556assert.ok(mdPart);557assert.strictEqual(mdPart.content, 'Hi there!');558assert.strictEqual(state!.turns[0].state, TurnState.Complete);559});560561test('restores a session with tool calls', async () => {562service.registerProvider(copilotAgent);563const { session } = await copilotAgent.createSession();564const sessions = await copilotAgent.listSessions();565const sessionResource = sessions[0].session;566567copilotAgent.sessionMessages = [568{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Run a command', toolRequests: [] },569{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'I will run a command.', toolRequests: [{ toolCallId: 'tc-1', name: 'shell' }] },570{ type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'shell', displayName: 'Shell', invocationMessage: 'Running command...' },571{ type: 'tool_complete', session, toolCallId: 'tc-1', result: { success: true, pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'output' }] } },572{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Done!', toolRequests: [] },573];574575await service.restoreSession(sessionResource);576577const state = service.stateManager.getSessionState(sessionResource.toString());578assert.ok(state);579const turn = state!.turns[0];580const toolCallParts = turn.responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);581assert.strictEqual(toolCallParts.length, 1);582const tc = toolCallParts[0].toolCall as ToolCallCompletedState;583assert.strictEqual(tc.status, ToolCallStatus.Completed);584assert.strictEqual(tc.toolCallId, 'tc-1');585assert.strictEqual(tc.confirmed, ToolCallConfirmationReason.NotNeeded);586});587588test('interleaves reasoning, markdown, and tool calls in stream order on resume', async () => {589service.registerProvider(copilotAgent);590const { session } = await copilotAgent.createSession();591const sessions = await copilotAgent.listSessions();592const sessionResource = sessions[0].session;593594copilotAgent.sessionMessages = [595{ type: 'message', session, role: 'user', messageId: 'u-1', content: 'Hello', toolRequests: [] },596{ type: 'message', session, role: 'assistant', messageId: 'a-1', content: 'Reply A', reasoningText: 'Thinking A', toolRequests: [{ toolCallId: 'tc-1', name: 'shell' }] },597{ type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'shell', displayName: 'Shell', invocationMessage: 'Running...' },598{ type: 'tool_complete', session, toolCallId: 'tc-1', result: { success: true, pastTenseMessage: 'Ran', content: [{ type: ToolResultContentType.Text, text: 'ok' }] } },599{ type: 'message', session, role: 'assistant', messageId: 'a-2', content: 'Reply B', reasoningText: 'Thinking B', toolRequests: [] },600];601602await service.restoreSession(sessionResource);603604const state = service.stateManager.getSessionState(sessionResource.toString());605assert.ok(state);606const turn = state!.turns[0];607const summary = turn.responseParts.map(p => {608if (p.kind === ResponsePartKind.Reasoning) { return ['reasoning', p.content]; }609if (p.kind === ResponsePartKind.Markdown) { return ['markdown', p.content]; }610if (p.kind === ResponsePartKind.ToolCall) { return ['toolCall', p.toolCall.toolCallId]; }611return ['other'];612});613assert.deepStrictEqual(summary, [614['reasoning', 'Thinking A'],615['markdown', 'Reply A'],616['toolCall', 'tc-1'],617['reasoning', 'Thinking B'],618['markdown', 'Reply B'],619]);620});621622test('flushes interrupted turns', async () => {623service.registerProvider(copilotAgent);624const { session } = await copilotAgent.createSession();625const sessions = await copilotAgent.listSessions();626const sessionResource = sessions[0].session;627628copilotAgent.sessionMessages = [629{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Interrupted', toolRequests: [] },630{ type: 'message', session, role: 'user', messageId: 'msg-2', content: 'Retried', toolRequests: [] },631{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Answer', toolRequests: [] },632];633634await service.restoreSession(sessionResource);635636const state = service.stateManager.getSessionState(sessionResource.toString());637assert.ok(state);638assert.strictEqual(state!.turns.length, 2);639assert.strictEqual(state!.turns[0].state, TurnState.Cancelled);640assert.strictEqual(state!.turns[1].state, TurnState.Complete);641});642643test('throws when session is not found on backend', async () => {644service.registerProvider(copilotAgent);645await assert.rejects(646() => service.restoreSession(AgentSession.uri('copilot', 'nonexistent')),647/Session not found on backend/,648);649});650651test('restores a session with subagent tool calls', async () => {652service.registerProvider(copilotAgent);653const { session } = await copilotAgent.createSession();654const sessions = await copilotAgent.listSessions();655const sessionResource = sessions[0].session;656657copilotAgent.sessionMessages = [658{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Review this code', toolRequests: [] },659{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: '', toolRequests: [{ toolCallId: 'tc-sub', name: 'task' }] },660{ type: 'tool_start', session, toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...', toolKind: 'subagent' as const, subagentDescription: 'Find related files', subagentAgentName: 'explore' },661{ type: 'subagent_started', session, toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores the codebase' },662// Inner tool calls from the subagent (have parentToolCallId)663{ type: 'tool_start', session, toolCallId: 'tc-inner-1', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running ls...', parentToolCallId: 'tc-sub' },664{ type: 'tool_complete', session, toolCallId: 'tc-inner-1', result: { success: true, pastTenseMessage: 'Ran ls', content: [{ type: ToolResultContentType.Text, text: 'file1.ts' }] }, parentToolCallId: 'tc-sub' },665{ type: 'tool_start', session, toolCallId: 'tc-inner-2', toolName: 'view', displayName: 'View File', invocationMessage: 'Reading file1.ts', parentToolCallId: 'tc-sub' },666{ type: 'tool_complete', session, toolCallId: 'tc-inner-2', result: { success: true, pastTenseMessage: 'Read file1.ts' }, parentToolCallId: 'tc-sub' },667// Parent tool completes668{ type: 'tool_complete', session, toolCallId: 'tc-sub', result: { success: true, pastTenseMessage: 'Delegated task', content: [{ type: ToolResultContentType.Text, text: 'Found 3 issues' }] } },669{ type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'The review found 3 issues.', toolRequests: [] },670];671672await service.restoreSession(sessionResource);673674const state = service.stateManager.getSessionState(sessionResource.toString());675assert.ok(state);676677// Should produce exactly one turn678assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}`);679680const turn = state!.turns[0];681assert.strictEqual(turn.userMessage.text, 'Review this code');682683// The parent turn should only have the parent tool call — inner684// tool calls are excluded from the parent and belong to the685// child subagent session instead.686const toolCallParts = turn.responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);687assert.strictEqual(toolCallParts.length, 1, `Expected 1 tool call (parent only) but got ${toolCallParts.length}`);688689// Parent subagent tool call690const parentTc = toolCallParts[0].toolCall as ToolCallCompletedState;691assert.strictEqual(parentTc.toolCallId, 'tc-sub');692assert.strictEqual(parentTc.status, ToolCallStatus.Completed);693assert.strictEqual(parentTc._meta?.toolKind, 'subagent');694assert.strictEqual(parentTc._meta?.subagentDescription, 'Find related files');695assert.strictEqual(parentTc._meta?.subagentAgentName, 'explore');696697// Parent tool should have subagent content entry698const content = parentTc.content ?? [];699const subagentEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent);700assert.ok(subagentEntry, 'Completed tool call should have subagent content entry');701702// Subscribing to the child session should restore it with inner tool calls703const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), 'tc-sub');704const snapshot = await service.subscribe(URI.parse(childSessionUri));705const childState = service.stateManager.getSessionState(childSessionUri);706assert.ok(snapshot?.state, 'Child session snapshot should exist');707assert.ok(childState, 'Child session state should exist');708assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn');709const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);710assert.strictEqual(childToolParts.length, 2, `Child session should have 2 inner tool calls but got ${childToolParts.length}`);711assert.ok(childToolParts.some(p => p.toolCall.toolCallId === 'tc-inner-1'), 'Should have tc-inner-1');712assert.ok(childToolParts.some(p => p.toolCall.toolCallId === 'tc-inner-2'), 'Should have tc-inner-2');713714// The turn should also have the final markdown715const mdParts = turn.responseParts.filter((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown);716assert.ok(mdParts.some(p => p.content.includes('3 issues')), 'Should have the final markdown response');717});718719test('inner assistant messages from subagent do not create extra turns (fixture)', async () => {720service.registerProvider(copilotAgent);721const { session } = await copilotAgent.createSession();722const sessions = await copilotAgent.listSessions();723const sessionResource = sessions[0].session;724725// Load real SDK events from fixture (sanitized from ~/.copilot/session-state/)726copilotAgent.sessionMessages = await loadFixtureMessages('subagent-session.jsonl', session);727728await service.restoreSession(sessionResource);729730const state = service.stateManager.getSessionState(sessionResource.toString());731assert.ok(state);732assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}: ${state!.turns.map(t => `"${t.userMessage.text.substring(0, 40)}"`).join(', ')}`);733assert.strictEqual(state!.turns[0].userMessage.text, 'Run a sync subagent to do some searches, just testing subagent rendering');734assert.strictEqual(state!.turns[0].state, TurnState.Complete);735736// Should have the parent subagent tool call with subagent content737const toolCallParts = state!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);738const parentTc = toolCallParts.find(p => p.toolCall.toolName === 'task');739assert.ok(parentTc, 'Should have a task tool call');740assert.strictEqual(parentTc!.toolCall._meta?.toolKind, 'subagent');741742// Inner tool calls should NOT be in the parent turn — they belong743// to the child subagent session.744const parentToolCallId = parentTc!.toolCall.toolCallId;745const nonParentTools = toolCallParts.filter(p => p.toolCall.toolCallId !== parentToolCallId);746assert.strictEqual(nonParentTools.length, 0, `Parent turn should only contain the task tool call, but found ${nonParentTools.length} extra tool calls`);747748// Subscribe to the child subagent session and verify inner tools749const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), parentToolCallId);750const snapshot = await service.subscribe(URI.parse(childSessionUri));751assert.ok(snapshot?.state, 'Child session snapshot should exist');752const childState = service.stateManager.getSessionState(childSessionUri);753assert.ok(childState, 'Child session state should exist');754assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn');755const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall);756assert.ok(childToolParts.length > 0, `Child session should have inner tool calls but got ${childToolParts.length}`);757758// Should have the final markdown759const mdParts = state!.turns[0].responseParts.filter((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown);760assert.ok(mdParts.length > 0, 'Should have markdown content');761});762});763764// ---- session config persistence -------------------------------------765766suite('session config persistence', () => {767768test('createSession persists initial config values to the session DB', async () => {769const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));770const sessionDataService = createSessionDataService(sessionDb);771const localAgent = new MockAgent('copilot');772disposables.add(toDisposable(() => localAgent.dispose()));773const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));774localService.registerProvider(localAgent);775776await localService.createSession({ provider: 'copilot', config: { autoApprove: 'autoApprove' } });777778// Persistence is fire-and-forget; wait for it to flush779await new Promise(r => setTimeout(r, 50));780781const persisted = await sessionDb.getMetadata('configValues');782assert.ok(persisted, 'configValues should be persisted');783assert.deepStrictEqual(JSON.parse(persisted!), { autoApprove: 'autoApprove' });784});785786test('createSession does not write configValues when there are no values', async () => {787const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));788const sessionDataService = createSessionDataService(sessionDb);789const localAgent = new MockAgent('copilot');790disposables.add(toDisposable(() => localAgent.dispose()));791const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));792localService.registerProvider(localAgent);793794await localService.createSession({ provider: 'copilot' });795796await new Promise(r => setTimeout(r, 50));797798const persisted = await sessionDb.getMetadata('configValues');799assert.strictEqual(persisted, undefined);800});801802test('restoreSession overlays persisted config values onto the resolved config', async () => {803const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));804const sessionDataService = createSessionDataService(sessionDb);805const localAgent = new MockAgent('copilot');806disposables.add(toDisposable(() => localAgent.dispose()));807const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));808localService.registerProvider(localAgent);809810// Create a session on the agent backend (no config) so listSessions can find it811const { session } = await localAgent.createSession();812const sessions = await localAgent.listSessions();813const sessionResource = sessions[0].session;814815// Pre-seed persisted config values816await sessionDb.setMetadata('configValues', JSON.stringify({ autoApprove: 'autoApprove' }));817818localAgent.sessionMessages = [819{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },820{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },821];822823await localService.restoreSession(sessionResource);824825const state = localService.stateManager.getSessionState(sessionResource.toString());826assert.ok(state);827// MockAgent.resolveSessionConfig echoes params.config back as values, so the828// persisted values are forwarded through and end up on state.config.values.829assert.deepStrictEqual(state!.config?.values, { autoApprove: 'autoApprove' });830});831832test('createSession + restoreSession round-trip restores initial config without any mid-session changes', async () => {833// Regression test: when a session is created with initial config but no834// mid-session SessionConfigChanged actions are dispatched, restoring it835// must still rehydrate the initial values.836const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));837const sessionDataService = createSessionDataService(sessionDb);838const localAgent = new MockAgent('copilot');839disposables.add(toDisposable(() => localAgent.dispose()));840const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));841localService.registerProvider(localAgent);842843const session = await localService.createSession({ provider: 'copilot', config: { autoApprove: 'autoApprove' } });844845// Wait for the fire-and-forget persistence to flush846await new Promise(r => setTimeout(r, 50));847848// Simulate a server restart: drop the in-memory state849localService.stateManager.removeSession(session.toString());850851localAgent.sessionMessages = [852{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },853{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },854];855await localService.restoreSession(session);856857const state = localService.stateManager.getSessionState(session.toString());858assert.ok(state);859assert.deepStrictEqual(state!.config?.values, { autoApprove: 'autoApprove' });860});861862test('restoreSession ignores malformed persisted configValues', async () => {863const sessionDb = disposables.add(await SessionDatabase.open(':memory:'));864const sessionDataService = createSessionDataService(sessionDb);865const localAgent = new MockAgent('copilot');866disposables.add(toDisposable(() => localAgent.dispose()));867const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService()));868localService.registerProvider(localAgent);869870const { session } = await localAgent.createSession();871const sessions = await localAgent.listSessions();872const sessionResource = sessions[0].session;873874await sessionDb.setMetadata('configValues', '{not json');875876localAgent.sessionMessages = [877{ type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] },878{ type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] },879];880881// Should not throw despite the malformed JSON882await localService.restoreSession(sessionResource);883884const state = localService.stateManager.getSessionState(sessionResource.toString());885assert.ok(state);886// MockAgent has a workingDirectory? No — but the metadata supplies it as undefined.887// _resolveCreatedSessionConfig bails when both .config and .workingDirectory are888// missing, so state.config is undefined here. The key point is: no throw.889assert.strictEqual(state!.config, undefined);890});891});892893// ---- resourceList ------------------------------------------------894895suite('resourceList', () => {896897test('throws when the directory does not exist', async () => {898await assert.rejects(899() => service.resourceList(URI.from({ scheme: Schemas.inMemory, path: '/nonexistent' })),900/Directory not found/,901);902});903904test('throws when the target is not a directory', async () => {905await assert.rejects(906() => service.resourceList(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' })),907/Not a directory/,908);909});910});911912// ---- worktree working directory -------------------------------------913914suite('worktree working directory', () => {915916test('createSession uses agent-resolved working directory in state', async () => {917// Simulate an agent that resolves a worktree path different from the input918const worktreeDir = URI.file('/source/repo.worktrees/agents-xyz');919copilotAgent.resolvedWorkingDirectory = worktreeDir;920service.registerProvider(copilotAgent);921922const sourceDir = URI.file('/source/repo');923const session = await service.createSession({ provider: 'copilot', workingDirectory: sourceDir });924925// The state manager should have the worktree path, not the source path926const state = service.stateManager.getSessionState(session.toString());927assert.strictEqual(state?.summary.workingDirectory, worktreeDir.toString());928});929930test('createSession falls back to config working directory when agent does not resolve', async () => {931// Agent does not override the working directory (e.g. folder isolation)932copilotAgent.resolvedWorkingDirectory = undefined;933service.registerProvider(copilotAgent);934935const sourceDir = URI.file('/source/repo');936const session = await service.createSession({ provider: 'copilot', workingDirectory: sourceDir });937938const state = service.stateManager.getSessionState(session.toString());939assert.strictEqual(state?.summary.workingDirectory, sourceDir.toString());940});941942test('restoreSession uses agent working directory in state', async () => {943// Agent returns the worktree path through listSessions944const worktreeDir = URI.file('/source/repo.worktrees/agents-xyz');945copilotAgent.sessionMetadataOverrides = { workingDirectory: worktreeDir };946service.registerProvider(copilotAgent);947948const session = await service.createSession({ provider: 'copilot' });949950// Delete from state to simulate a server restart951service.stateManager.deleteSession(session.toString());952assert.strictEqual(service.stateManager.getSessionState(session.toString()), undefined);953954// Restore the session (simulates a client subscribing after restart)955await service.restoreSession(session);956957const state = service.stateManager.getSessionState(session.toString());958assert.strictEqual(state?.summary.workingDirectory, worktreeDir.toString());959});960});961});962963964