Path: blob/main/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts
13406 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 { Codicon } from '../../../../../base/common/codicons.js';7import { Emitter, Event } from '../../../../../base/common/event.js';8import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';9import { URI } from '../../../../../base/common/uri.js';10import { mock } from '../../../../../base/test/common/mock.js';11import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';12import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';13import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';14import { ICommandService } from '../../../../../platform/commands/common/commands.js';15import { IDialogService, IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js';16import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';17import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';18import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js';19import { IStorageService } from '../../../../../platform/storage/common/storage.js';20import { IAgentSession, IAgentSessionsModel } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js';21import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';22import { AgentSessionProviders } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';23import { IChatService, ChatSendResult, IChatSendRequestData } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js';24import { ChatSessionStatus, IChatSessionItem, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js';25import { IChatWidget, IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js';26import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js';27import { ILanguageModelToolsService } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js';28import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js';29import { IChatAgentData } from '../../../../../workbench/contrib/chat/common/participants/chatAgents.js';30import { IGitService } from '../../../../../workbench/contrib/git/common/gitService.js';31import { ISessionChangeEvent } from '../../../../services/sessions/common/sessionsProvider.js';32import { ClaudeCodeSessionType, CopilotCLISessionType, GITHUB_REMOTE_FILE_SCHEME, SessionStatus } from '../../../../services/sessions/common/session.js';33import { CLAUDE_CODE_ENABLED_SETTING, CopilotChatSessionsProvider, COPILOT_PROVIDER_ID } from '../../browser/copilotChatSessionsProvider.js';34import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js';35import { ILabelService } from '../../../../../platform/label/common/label.js';3637// ---- Helpers ----------------------------------------------------------------3839function createMockAgentSession(resource: URI, opts?: {40providerType?: string;41title?: string;42archived?: boolean;43read?: boolean;44createdAt?: number;45metadata?: Record<string, unknown>;46}): IAgentSession {47const providerType = opts?.providerType ?? AgentSessionProviders.Background;48let archived = opts?.archived ?? false;49let read = opts?.read ?? true;50return new class extends mock<IAgentSession>() {51override readonly resource = resource;52override readonly providerType = providerType;53override readonly providerLabel = 'Copilot';54override readonly label = opts?.title ?? 'Test Session';55override readonly status = ChatSessionStatus.Completed;56override readonly icon = Codicon.copilot;57override readonly timing = { created: opts?.createdAt ?? Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined };58override readonly metadata = opts?.metadata ?? { repositoryPath: '/test/repo' };59override isArchived(): boolean { return archived; }60override setArchived(value: boolean): void { archived = value; }61override isPinned(): boolean { return false; }62override setPinned(): void { }63override isRead(): boolean { return read; }64override isMarkedUnread(): boolean { return false; }65override setRead(value: boolean): void { read = value; }66}();67}6869// ---- Mock Agent Sessions Service --------------------------------------------7071class MockAgentSessionsModel {72private readonly _sessions: IAgentSession[] = [];73private readonly _onDidChangeSessions = new Emitter<void>();74readonly onDidChangeSessions = this._onDidChangeSessions.event;75readonly onWillResolve = Event.None;76readonly onDidResolve = Event.None;77readonly onDidChangeSessionArchivedState = Event.None;78readonly resolved = true;7980get sessions(): IAgentSession[] { return [...this._sessions]; }8182getSession(resource: URI): IAgentSession | undefined {83return this._sessions.find(s => s.resource.toString() === resource.toString());84}8586addSession(session: IAgentSession): void {87this._sessions.push(session);88this._onDidChangeSessions.fire();89}9091removeSession(resource: URI): void {92const idx = this._sessions.findIndex(s => s.resource.toString() === resource.toString());93if (idx !== -1) {94this._sessions.splice(idx, 1);95this._onDidChangeSessions.fire();96}97}9899async resolve(): Promise<void> { }100101dispose(): void {102this._onDidChangeSessions.dispose();103}104}105106// ---- Provider factory -------------------------------------------------------107108function createProvider(109disposables: DisposableStore,110model: MockAgentSessionsModel,111opts?: { multiChatEnabled?: boolean; claudeEnabled?: boolean },112): CopilotChatSessionsProvider {113return createProviderWithConfig(disposables, model, opts).provider;114}115116function createProviderWithConfig(117disposables: DisposableStore,118model: MockAgentSessionsModel,119opts?: { multiChatEnabled?: boolean; claudeEnabled?: boolean },120): { provider: CopilotChatSessionsProvider; configService: TestConfigurationService } {121const instantiationService = disposables.add(new TestInstantiationService());122123const configService = new TestConfigurationService();124configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', opts?.multiChatEnabled ?? true);125configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, opts?.claudeEnabled ?? true);126127instantiationService.stub(IConfigurationService, configService);128instantiationService.stub(IStorageService, disposables.add(new TestStorageService()));129instantiationService.stub(IFileDialogService, {});130instantiationService.stub(IDialogService, {131confirm: async () => ({ confirmed: true }),132});133instantiationService.stub(ICommandService, {134executeCommand: async (_id: string, ...args: any[]) => {135// Simulate 'agents.github.copilot.cli.deleteSessions' removing sessions136const items = args[0];137if (Array.isArray(items)) {138for (const item of items) {139if (item?.resource) {140model.removeSession(item.resource);141}142}143} else if (items?.resource) {144model.removeSession(items.resource);145}146return undefined;147},148});149instantiationService.stub(IAgentSessionsService, {150model: model as unknown as IAgentSessionsModel,151onDidChangeSessionArchivedState: Event.None,152getSession: (resource: URI) => model.getSession(resource),153});154instantiationService.stub(IChatSessionsService, {155getChatSessionContribution: () => ({ type: 'test-copilot', name: 'test', displayName: 'Test', description: 'test', icon: undefined }),156getOrCreateChatSession: async () => ({ onWillDispose: () => ({ dispose() { } }), sessionResource: URI.from({ scheme: 'test' }), history: [], dispose() { } }),157onDidCommitSession: Event.None,158updateSessionOptions: () => true,159setSessionOption: () => true,160getSessionOption: () => undefined,161onDidChangeOptionGroups: Event.None,162});163instantiationService.stub(IChatService, {164acquireOrLoadSession: async () => undefined,165sendRequest: async (): Promise<ChatSendResult> => ({ kind: 'sent' as const, data: {} as IChatSendRequestData }),166removeHistoryEntry: async (resource: URI) => { model.removeSession(resource); },167setChatSessionTitle: () => { },168});169instantiationService.stub(IChatWidgetService, {170openSession: async () => undefined,171lastFocusedWidget: undefined,172onDidChangeFocusedSession: Event.None,173});174instantiationService.stub(ILanguageModelsService, {175lookupLanguageModel: () => undefined,176});177instantiationService.stub(ILanguageModelToolsService, {178toToolReferences: () => [],179});180// Stub IInstantiationService so provider can use createInstance for CopilotCLISession181instantiationService.stub(IInstantiationService, instantiationService);182instantiationService.stub(ILabelService, {183getUriLabel: (uri: URI) => uri.path,184});185186const provider = disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider));187return { provider, configService };188}189190// ---- Provider factory for send/cancel tests ---------------------------------191192/**193* Creates a provider suitable for testing sendChat flows. Stubs all services194* needed by CopilotCLISession and _sendFirstChat, including IGitService and a195* non-null IChatWidget mock.196*197* The caller can pass a custom `sendRequest` implementation to control the198* lifecycle of the in-flight request.199*/200function createProviderForSendTests(201disposables: DisposableStore,202model: MockAgentSessionsModel,203sendRequest: () => Promise<ChatSendResult>,204opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; claudeEnabled?: boolean; createNewChatSessionItem?: IChatSessionsService['createNewChatSessionItem'] },205): CopilotChatSessionsProvider {206const instantiationService = disposables.add(new TestInstantiationService());207208const configService = new TestConfigurationService();209configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', true);210configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, opts?.claudeEnabled ?? true);211212instantiationService.stub(ILogService, NullLogService);213instantiationService.stub(IConfigurationService, configService);214instantiationService.stub(IStorageService, disposables.add(new TestStorageService()));215instantiationService.stub(IFileDialogService, {});216instantiationService.stub(IDialogService, {217confirm: async () => ({ confirmed: true }),218});219instantiationService.stub(ICommandService, { executeCommand: async () => undefined });220instantiationService.stub(IAgentSessionsService, {221model: model as unknown as IAgentSessionsModel,222onDidChangeSessionArchivedState: Event.None,223getSession: (resource: URI) => model.getSession(resource),224});225instantiationService.stub(IChatSessionsService, {226getChatSessionContribution: () => ({ type: 'test-copilot', name: 'test', displayName: 'Test', description: 'test', icon: undefined }),227getOrCreateChatSession: async () => ({ onWillDispose: () => ({ dispose() { } }), sessionResource: URI.from({ scheme: 'test' }), history: [], dispose() { } }),228onDidCommitSession: opts?.onDidCommitSession ?? Event.None,229updateSessionOptions: () => true,230setSessionOption: () => true,231getSessionOption: () => undefined,232onDidChangeOptionGroups: Event.None,233createNewChatSessionItem: opts?.createNewChatSessionItem ?? (async () => undefined),234});235instantiationService.stub(IChatService, {236acquireOrLoadSession: async () => undefined,237sendRequest: sendRequest,238removeHistoryEntry: async (resource: URI) => { model.removeSession(resource); },239setChatSessionTitle: () => { },240});241instantiationService.stub(IChatWidgetService, {242openSession: async () => new class extends mock<IChatWidget>() {243override input = new class extends mock<IChatWidget['input']>() {244override setPermissionLevel = () => { };245}();246}(),247lastFocusedWidget: undefined,248onDidChangeFocusedSession: Event.None,249});250instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: () => undefined });251instantiationService.stub(ILanguageModelToolsService, { toToolReferences: () => [] });252instantiationService.stub(IGitService, { openRepository: async () => undefined });253instantiationService.stub(IInstantiationService, instantiationService);254instantiationService.stub(ILabelService, {255getUriLabel: (uri: URI) => uri.path,256});257258return disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider));259}260261suite('CopilotChatSessionsProvider', () => {262const disposables = new DisposableStore();263let model: MockAgentSessionsModel;264265setup(() => {266model = new MockAgentSessionsModel();267disposables.add(toDisposable(() => model.dispose()));268});269270teardown(() => {271disposables.clear();272});273274ensureNoDisposablesAreLeakedInTestSuite();275276// ---- Provider identity -------277278test('has correct id and label', () => {279const provider = createProvider(disposables, model);280assert.strictEqual(provider.id, COPILOT_PROVIDER_ID);281assert.strictEqual(provider.sessionTypes.length, 3);282});283284test('sessionTypes excludes Claude when setting is disabled', () => {285const provider = createProvider(disposables, model, { claudeEnabled: false });286assert.strictEqual(provider.sessionTypes.length, 2);287assert.ok(!provider.sessionTypes.some(t => t.id === ClaudeCodeSessionType.id));288});289290test('onDidChangeSessionTypes fires when claude setting changes', () => {291const { provider, configService } = createProviderWithConfig(disposables, model);292assert.strictEqual(provider.sessionTypes.length, 3);293294let fired = false;295disposables.add(provider.onDidChangeSessionTypes(() => { fired = true; }));296297// Disable claude via config change298configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, false);299configService.onDidChangeConfigurationEmitter.fire({300source: ConfigurationTarget.USER,301affectedKeys: new Set([CLAUDE_CODE_ENABLED_SETTING]),302change: { keys: [CLAUDE_CODE_ENABLED_SETTING], overrides: [] },303affectsConfiguration: (key: string) => key === CLAUDE_CODE_ENABLED_SETTING,304});305306assert.ok(fired, 'onDidChangeSessionTypes should have fired');307assert.strictEqual(provider.sessionTypes.length, 2);308});309310test('toggling claude setting refreshes sessions list', () => {311const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' });312model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude }));313314const { provider, configService } = createProviderWithConfig(disposables, model);315assert.strictEqual(provider.getSessions().length, 1, 'Claude sessions should appear when enabled by default');316317// Disable Claude318configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, false);319configService.onDidChangeConfigurationEmitter.fire({320source: ConfigurationTarget.USER,321affectedKeys: new Set([CLAUDE_CODE_ENABLED_SETTING]),322change: { keys: [CLAUDE_CODE_ENABLED_SETTING], overrides: [] },323affectsConfiguration: (key: string) => key === CLAUDE_CODE_ENABLED_SETTING,324});325326assert.strictEqual(provider.getSessions().length, 0, 'Claude sessions should disappear after disabling');327328// Re-enable Claude329configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, true);330configService.onDidChangeConfigurationEmitter.fire({331source: ConfigurationTarget.USER,332affectedKeys: new Set([CLAUDE_CODE_ENABLED_SETTING]),333change: { keys: [CLAUDE_CODE_ENABLED_SETTING], overrides: [] },334affectsConfiguration: (key: string) => key === CLAUDE_CODE_ENABLED_SETTING,335});336337assert.strictEqual(provider.getSessions().length, 1, 'Claude sessions should reappear after re-enabling');338});339340// ---- getSessionTypes -------341342test('getSessionTypes returns Claude for local workspace when enabled', () => {343const provider = createProvider(disposables, model, { claudeEnabled: true });344const types = provider.getSessionTypes(URI.file('/test/project'));345assert.ok(types.some(t => t.id === ClaudeCodeSessionType.id));346});347348test('getSessionTypes does not return Claude for local workspace when disabled', () => {349const provider = createProvider(disposables, model, { claudeEnabled: false });350const types = provider.getSessionTypes(URI.file('/test/project'));351assert.ok(!types.some(t => t.id === ClaudeCodeSessionType.id));352});353354test('getSessionTypes returns only Cloud for remote workspace regardless of claude setting', () => {355const provider = createProvider(disposables, model, { claudeEnabled: true });356const types = provider.getSessionTypes(URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, path: '/owner/repo' }));357assert.strictEqual(types.length, 1);358assert.ok(!types.some(t => t.id === ClaudeCodeSessionType.id));359});360361// ---- Session listing -------362363test('getSessions returns empty array initially', () => {364const provider = createProvider(disposables, model);365assert.strictEqual(provider.getSessions().length, 0);366});367368test('getSessions returns adapted sessions from agent model', () => {369const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });370const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' });371model.addSession(createMockAgentSession(resource1, { title: 'Session 1' }));372model.addSession(createMockAgentSession(resource2, { title: 'Session 2' }));373374const provider = createProvider(disposables, model);375const sessions = provider.getSessions();376377assert.strictEqual(sessions.length, 2);378});379380test('getSessions ignores non-Background/Cloud/Claude sessions', () => {381const bgResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/bg-session' });382const localResource = URI.from({ scheme: AgentSessionProviders.Local, path: '/local-session' });383model.addSession(createMockAgentSession(bgResource));384model.addSession(createMockAgentSession(localResource, { providerType: AgentSessionProviders.Local }));385386const provider = createProvider(disposables, model);387const sessions = provider.getSessions();388389assert.strictEqual(sessions.length, 1);390});391392test('getSessions includes Claude agent sessions when enabled', () => {393const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' });394model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude }));395396const provider = createProvider(disposables, model, { claudeEnabled: true });397const sessions = provider.getSessions();398399assert.strictEqual(sessions.length, 1);400});401402test('getSessions excludes Claude agent sessions when disabled', () => {403const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' });404model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude }));405406const provider = createProvider(disposables, model, { claudeEnabled: false });407const sessions = provider.getSessions();408409assert.strictEqual(sessions.length, 0);410});411412test('onDidChangeSessions fires when agent model changes', () => {413const provider = createProvider(disposables, model);414provider.getSessions(); // Initialize cache415416const changes: ISessionChangeEvent[] = [];417disposables.add(provider.onDidChangeSessions(e => changes.push(e)));418419const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/new-session' });420model.addSession(createMockAgentSession(resource, { title: 'New Session' }));421422assert.ok(changes.length > 0);423assert.strictEqual(changes[0].added.length, 1);424});425426// ---- Session creation -------427// Note: createNewSession tests are limited because CopilotCLISession428// requires IGitService and creates disposables that are hard to clean429// up in isolation. Full integration tests should cover session creation.430431// ---- Session actions -------432433test('archiveSession sets archived state', () => {434const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });435const agentSession = createMockAgentSession(resource);436model.addSession(agentSession);437438const provider = createProvider(disposables, model);439provider.getSessions(); // Initialize cache440441const session = provider.getSessions()[0];442provider.archiveSession(session.sessionId);443444assert.strictEqual(agentSession.isArchived(), true);445});446447test('unarchiveSession clears archived state', () => {448const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });449const agentSession = createMockAgentSession(resource, { archived: true });450model.addSession(agentSession);451452const provider = createProvider(disposables, model);453provider.getSessions();454455const session = provider.getSessions()[0];456provider.unarchiveSession(session.sessionId);457458assert.strictEqual(agentSession.isArchived(), false);459});460461// ---- Session capabilities -------462463test('copilot CLI sessions have supportsMultipleChats capability', () => {464const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });465model.addSession(createMockAgentSession(resource));466467const provider = createProvider(disposables, model);468const sessions = provider.getSessions();469470assert.strictEqual(sessions.length, 1);471assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, true);472});473474test('copilot cloud sessions do not have supportsMultipleChats capability', () => {475const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: '/session-1' });476model.addSession(createMockAgentSession(resource, { providerType: AgentSessionProviders.Cloud }));477478const provider = createProvider(disposables, model);479const sessions = provider.getSessions();480481assert.strictEqual(sessions.length, 1);482assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, false);483});484485test('copilot CLI sessions do not have supportsMultipleChats when setting is disabled', () => {486const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });487model.addSession(createMockAgentSession(resource));488489const provider = createProvider(disposables, model, { multiChatEnabled: false });490const sessions = provider.getSessions();491492assert.strictEqual(sessions.length, 1);493assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, false);494});495496test('claude sessions do not have supportsMultipleChats capability', () => {497const resource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/session-1' });498model.addSession(createMockAgentSession(resource, { providerType: AgentSessionProviders.Claude }));499500const provider = createProvider(disposables, model, { claudeEnabled: true });501const sessions = provider.getSessions();502503assert.strictEqual(sessions.length, 1);504assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, false);505});506507// ---- Session listing & grouping -------508509test('each session has exactly one chat initially', () => {510const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });511model.addSession(createMockAgentSession(resource));512513const provider = createProvider(disposables, model);514const sessions = provider.getSessions();515516assert.strictEqual(sessions.length, 1);517assert.strictEqual(sessions[0].chats.get().length, 1);518assert.strictEqual(sessions[0].mainChat.resource.toString(), resource.toString());519});520521test('sendAndCreateChat throws for unknown session', async () => {522const provider = createProvider(disposables, model);523await assert.rejects(524() => provider.sendAndCreateChat('nonexistent', { query: 'test' }),525/not found/,526);527});528529test('getSessions groups chats by session group', () => {530const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });531const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' });532model.addSession(createMockAgentSession(resource1, { title: 'Chat 1' }));533model.addSession(createMockAgentSession(resource2, { title: 'Chat 2' }));534535const provider = createProvider(disposables, model);536const sessions = provider.getSessions();537538// Without explicit grouping, each chat is its own session539assert.strictEqual(sessions.length, 2);540});541542test('groups committed chats using metadata.sessionParentId', () => {543const rootResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/root-session' });544const child1Resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/child-session-1' });545const child2Resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/child-session-2' });546547model.addSession(createMockAgentSession(rootResource, { title: 'Root', createdAt: 1 }));548model.addSession(createMockAgentSession(child1Resource, {549title: 'Child 1',550createdAt: 2,551metadata: { repositoryPath: '/test/repo', sessionParentId: 'root-session' }552}));553model.addSession(createMockAgentSession(child2Resource, {554title: 'Child 2',555createdAt: 3,556metadata: { repositoryPath: '/test/repo', sessionParentId: 'root-session' }557}));558559const provider = createProvider(disposables, model);560const sessions = provider.getSessions();561562assert.strictEqual(sessions.length, 1);563assert.strictEqual(sessions[0].chats.get().length, 3);564assert.strictEqual(sessions[0].mainChat.resource.toString(), rootResource.toString());565});566567test('orders chats within a grouped session by createdAt', () => {568const rootResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/root-session' });569const olderChildResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/older-child' });570const newerChildResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/newer-child' });571572// Add out of order to ensure grouping order is driven by createdAt rather than insertion order.573model.addSession(createMockAgentSession(newerChildResource, {574title: 'Newer Child',575createdAt: 30,576metadata: { repositoryPath: '/test/repo', sessionParentId: 'root-session' }577}));578model.addSession(createMockAgentSession(rootResource, { title: 'Root', createdAt: 10 }));579model.addSession(createMockAgentSession(olderChildResource, {580title: 'Older Child',581createdAt: 20,582metadata: { repositoryPath: '/test/repo', sessionParentId: 'root-session' }583}));584585const provider = createProvider(disposables, model);586const sessions = provider.getSessions();587588assert.strictEqual(sessions.length, 1);589assert.deepStrictEqual(590sessions[0].chats.get().map(chat => chat.resource.toString()),591[rootResource.toString(), olderChildResource.toString(), newerChildResource.toString()]592);593});594595test('groups child sessions even when the parent/root session is missing', () => {596const orphan1Resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/orphan-child-1' });597const orphan2Resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/orphan-child-2' });598const provider = createProvider(disposables, model);599600provider.getSessions(); // initialize cache601602const changes: ISessionChangeEvent[] = [];603disposables.add(provider.onDidChangeSessions(e => changes.push(e)));604605model.addSession(createMockAgentSession(orphan1Resource, {606title: 'Orphan Child 1',607createdAt: 1,608metadata: { repositoryPath: '/test/repo', sessionParentId: 'missing-root' }609}));610model.addSession(createMockAgentSession(orphan2Resource, {611title: 'Orphan Child 2',612createdAt: 2,613metadata: { repositoryPath: '/test/repo', sessionParentId: 'missing-root' }614}));615616const sessions = provider.getSessions();617618assert.strictEqual(sessions.length, 1);619assert.deepStrictEqual(620sessions[0].chats.get().map(chat => chat.resource.toString()),621[orphan1Resource.toString(), orphan2Resource.toString()]622);623assert.deepStrictEqual(changes.map(e => ({ added: e.added.length, changed: e.changed.length })), [624{ added: 1, changed: 0 },625{ added: 0, changed: 1 },626]);627});628629test('groups nested parent chains under the ultimate root', () => {630const middleResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/middle-session' });631const leafResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/leaf-session' });632633model.addSession(createMockAgentSession(middleResource, {634title: 'Middle Session',635createdAt: 2,636metadata: { repositoryPath: '/test/repo', sessionParentId: 'missing-root' }637}));638model.addSession(createMockAgentSession(leafResource, {639title: 'Leaf Session',640createdAt: 3,641metadata: { repositoryPath: '/test/repo', sessionParentId: 'middle-session' }642}));643644const provider = createProvider(disposables, model);645const sessions = provider.getSessions();646647assert.strictEqual(sessions.length, 1);648assert.deepStrictEqual(649sessions[0].chats.get().map(chat => chat.resource.toString()),650[middleResource.toString(), leafResource.toString()]651);652});653654test('session title comes from primary (first) chat', () => {655const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });656model.addSession(createMockAgentSession(resource, { title: 'Primary Title' }));657658const provider = createProvider(disposables, model);659const sessions = provider.getSessions();660661assert.strictEqual(sessions[0].title.get(), 'Primary Title');662});663664test('session has mainChat set to the first chat', () => {665const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });666model.addSession(createMockAgentSession(resource));667668const provider = createProvider(disposables, model);669const sessions = provider.getSessions();670671assert.ok(sessions[0].mainChat);672assert.strictEqual(sessions[0].mainChat.resource.toString(), resource.toString());673});674675test('deleteSession removes session from model and list', async () => {676const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });677const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' });678model.addSession(createMockAgentSession(resource1, { title: 'Session 1' }));679model.addSession(createMockAgentSession(resource2, { title: 'Session 2' }));680681const provider = createProvider(disposables, model);682const sessions = provider.getSessions();683assert.strictEqual(sessions.length, 2);684685await provider.deleteSession(sessions[0].sessionId);686687const remainingSessions = provider.getSessions();688assert.strictEqual(remainingSessions.length, 1);689assert.strictEqual(remainingSessions[0].title.get(), 'Session 2');690});691692test('deleteChat with single chat delegates to deleteSession', async () => {693const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });694model.addSession(createMockAgentSession(resource));695696const provider = createProvider(disposables, model);697const sessions = provider.getSessions();698const session = sessions[0];699700await provider.deleteChat(session.sessionId, resource);701702// Model should no longer have the session703assert.strictEqual(model.sessions.length, 0);704});705706test('deleteChat throws when session does not support multi-chat', async () => {707const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: '/session-1' });708model.addSession(createMockAgentSession(resource, { providerType: AgentSessionProviders.Cloud }));709710const provider = createProvider(disposables, model);711const sessions = provider.getSessions();712const session = sessions[0];713714await assert.rejects(715() => provider.deleteChat(session.sessionId, resource),716/not supported when multi-chat is disabled/,717);718});719720test('session group cache is invalidated on session removal', () => {721const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });722const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' });723model.addSession(createMockAgentSession(resource1, { title: 'Session 1' }));724model.addSession(createMockAgentSession(resource2, { title: 'Session 2' }));725726const provider = createProvider(disposables, model);727728// Initialize sessions729let sessions = provider.getSessions();730assert.strictEqual(sessions.length, 2);731732// Remove one from the model733model.removeSession(resource1);734735// Re-fetch736sessions = provider.getSessions();737assert.strictEqual(sessions.length, 1);738assert.strictEqual(sessions[0].title.get(), 'Session 2');739});740741test('chats observable updates when group model changes', () => {742const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });743const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' });744model.addSession(createMockAgentSession(resource1, { title: 'Chat 1' }));745model.addSession(createMockAgentSession(resource2, { title: 'Chat 2' }));746747const provider = createProvider(disposables, model);748const sessions = provider.getSessions();749assert.strictEqual(sessions.length, 2);750751// Both are separate sessions initially752const session1 = sessions[0];753assert.strictEqual(session1.chats.get().length, 1);754});755756test('session status aggregates across chats', () => {757const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });758model.addSession(createMockAgentSession(resource));759760const provider = createProvider(disposables, model);761const sessions = provider.getSessions();762763// With a single chat, session status should match the chat status764assert.ok(sessions[0].status.get() !== undefined);765});766767test('session isRead aggregates across all chats', () => {768const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });769model.addSession(createMockAgentSession(resource, { read: true }));770771const provider = createProvider(disposables, model);772const sessions = provider.getSessions();773774assert.strictEqual(sessions[0].isRead.get(), true);775});776777test('session isRead is false when any chat is unread', () => {778const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });779model.addSession(createMockAgentSession(resource, { read: false }));780781const provider = createProvider(disposables, model);782const sessions = provider.getSessions();783784assert.strictEqual(sessions[0].isRead.get(), false);785});786787test('removing a chat from a group fires changed (not removed) with correct sessionId', async () => {788const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });789const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' });790model.addSession(createMockAgentSession(resource1, { title: 'Chat 1' }));791model.addSession(createMockAgentSession(resource2, { title: 'Chat 2' }));792793const provider = createProvider(disposables, model);794const sessions = provider.getSessions();795assert.strictEqual(sessions.length, 2);796797// Manually group both chats under the first session798const chat2Id = sessions[1].sessionId;799// Access the group model indirectly by deleting the second session's group800// and re-adding its chat to the first group via deleteChat flow801// Instead, simulate by removing the second chat from the model802const changes: ISessionChangeEvent[] = [];803disposables.add(provider.onDidChangeSessions(e => changes.push(e)));804805model.removeSession(resource2);806807// The removed chat was standalone, so it should fire a removed event808assert.ok(changes.length > 0);809const lastChange = changes[changes.length - 1];810assert.strictEqual(lastChange.removed.length, 1);811assert.strictEqual(lastChange.removed[0].sessionId, chat2Id);812});813814test('getSessions does not create duplicate groups on repeated calls', () => {815const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });816model.addSession(createMockAgentSession(resource));817818const provider = createProvider(disposables, model);819820// Call getSessions multiple times821const sessions1 = provider.getSessions();822const sessions2 = provider.getSessions();823824assert.strictEqual(sessions1.length, 1);825assert.strictEqual(sessions2.length, 1);826// Should return the same cached session object827assert.strictEqual(sessions1[0], sessions2[0]);828});829830test('changed events are not duplicated when multiple chats update', () => {831const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });832const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' });833model.addSession(createMockAgentSession(resource1, { title: 'Session 1' }));834model.addSession(createMockAgentSession(resource2, { title: 'Session 2' }));835836const provider = createProvider(disposables, model);837provider.getSessions(); // Initialize838839const changes: ISessionChangeEvent[] = [];840disposables.add(provider.onDidChangeSessions(e => changes.push(e)));841842// Trigger a refresh that updates both sessions843model.addSession(createMockAgentSession(844URI.from({ scheme: AgentSessionProviders.Background, path: '/session-3' }),845{ title: 'Session 3' }846));847848// Each event should not have duplicates in the changed array849for (const change of changes) {850const changedIds = change.changed.map(s => s.sessionId);851const uniqueIds = new Set(changedIds);852assert.strictEqual(changedIds.length, uniqueIds.size, 'Changed events should not have duplicates');853}854});855856// ---- Browse actions -------857858test('resolveWorkspace creates proper workspace structure', () => {859const provider = createProvider(disposables, model);860const uri = URI.file('/test/project');861862const workspace = provider.resolveWorkspace(uri);863864assert.ok(workspace, 'resolveWorkspace should resolve file:// URIs');865assert.strictEqual(workspace.label, 'project');866assert.strictEqual(workspace.repositories.length, 1);867assert.strictEqual(workspace.repositories[0].uri.toString(), uri.toString());868assert.strictEqual(workspace.requiresWorkspaceTrust, true);869});870871test('builds an unknown workspace fallback when repository metadata is missing', () => {872const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/unknown-workspace-session' });873model.addSession(createMockAgentSession(resource, { metadata: {} }));874875const provider = createProvider(disposables, model);876const sessions = provider.getSessions();877const workspace = sessions[0].workspace.get();878879assert.ok(workspace);880assert.strictEqual(workspace.repositories.length, 1);881assert.strictEqual(workspace.repositories[0].uri.toString(), URI.parse('unknown:///').toString());882assert.strictEqual(workspace.requiresWorkspaceTrust, true);883884// The core symptom of #310777: any of these calls must not throw.885assert.doesNotThrow(() => URI.joinPath(workspace.repositories[0].uri, '.vscode', 'settings.json'));886assert.doesNotThrow(() => URI.joinPath(workspace.repositories[0].uri, '.vscode/extensions.json'));887});888889// ---- Claude session creation -------890891function makeClaudeInFlightProvider(): { provider: CopilotChatSessionsProvider; cancelRequest: () => void; realResource: URI; commitSession: () => void } {892let resolveComplete!: () => void;893let resolveCreated!: (r: IChatResponseModel) => void;894const responseCompletePromise = new Promise<void>(r => { resolveComplete = r; });895const responseCreatedPromise = new Promise<IChatResponseModel>(r => { resolveCreated = r; });896897// The real resource that createNewChatSessionItem returns898const realResource = URI.from({ scheme: AgentSessionProviders.Claude, path: `/claude-session-${Date.now()}` });899900const provider = createProviderForSendTests(disposables, model, async () => ({901kind: 'sent' as const,902data: {903responseCompletePromise,904responseCreatedPromise,905agent: new class extends mock<IChatAgentData>() { }(),906} as IChatSendRequestData,907}), {908claudeEnabled: true,909createNewChatSessionItem: async (_type, request): Promise<IChatSessionItem> => ({910resource: realResource,911label: request.prompt,912timing: { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined },913}),914});915916return {917provider,918realResource,919cancelRequest: () => {920resolveCreated({ isCanceled: true } as unknown as IChatResponseModel);921resolveComplete();922},923commitSession: () => {924// Add the agent session to the model so _waitForSessionInCache resolves925model.addSession(createMockAgentSession(realResource, { providerType: AgentSessionProviders.Claude }));926},927};928}929930function waitForSessionAdded(provider: CopilotChatSessionsProvider): Promise<void> {931return new Promise<void>(resolve => {932const d = provider.onDidChangeSessions(e => {933if (e.added.length > 0) {934d.dispose();935resolve();936}937});938});939}940941test('createNewSession with Claude type creates a session', async () => {942const { provider, commitSession } = makeClaudeInFlightProvider();943const workspace = URI.file('/test/project');944945const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id);946947assert.ok(session);948assert.strictEqual(session.sessionType, ClaudeCodeSessionType.id);949assert.strictEqual(session.status.get(), SessionStatus.Untitled);950951// Send and commit so the session enters the cache and can be disposed952const added = waitForSessionAdded(provider);953const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' });954await added;955commitSession();956await assert.doesNotReject(sendPromise);957});958959test('archiveSession archives a Claude temp session', async () => {960const { provider, cancelRequest } = makeClaudeInFlightProvider();961const workspace = URI.file('/test/project');962const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id);963964const added = waitForSessionAdded(provider);965const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' });966await added;967968await provider.archiveSession(session.sessionId);969assert.strictEqual(provider.getSessions()[0].isArchived.get(), true);970971cancelRequest();972await assert.doesNotReject(sendPromise);973974// Clean up975await provider.deleteSession(session.sessionId);976});977978test('unarchiveSession unarchives a Claude temp session', async () => {979const { provider, cancelRequest } = makeClaudeInFlightProvider();980const workspace = URI.file('/test/project');981const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id);982983const added = waitForSessionAdded(provider);984const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' });985await added;986987await provider.archiveSession(session.sessionId);988assert.strictEqual(provider.getSessions()[0].isArchived.get(), true);989990await provider.unarchiveSession(session.sessionId);991assert.strictEqual(provider.getSessions()[0].isArchived.get(), false);992993cancelRequest();994await assert.doesNotReject(sendPromise);995996// Clean up997await provider.deleteSession(session.sessionId);998});9991000// ---- Claude controller-based send flow -------10011002test('sendAndCreateChat replaces temp session with committed session on success', async () => {1003const { provider, commitSession } = makeClaudeInFlightProvider();1004const workspace = URI.file('/test/project');1005const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id);10061007const replacements: { from: unknown; to: unknown }[] = [];1008disposables.add(provider.onDidReplaceSession(e => replacements.push(e)));10091010const added = waitForSessionAdded(provider);1011const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'hello world' });1012await added;10131014assert.strictEqual(provider.getSessions().length, 1, 'temp session should appear while in-flight');10151016// Simulate the agent session appearing in the model1017commitSession();1018await sendPromise;10191020// The temp session should have been replaced by the committed one1021assert.ok(replacements.length > 0, 'onDidReplaceSessions should have fired');1022});10231024test('sendAndCreateChat uses the query as the temp session title', async () => {1025const { provider, cancelRequest } = makeClaudeInFlightProvider();1026const workspace = URI.file('/test/project');1027const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id);10281029const added = waitForSessionAdded(provider);1030const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'fix the login bug' });1031await added;10321033const sessions = provider.getSessions();1034assert.strictEqual(sessions[0].title.get(), 'fix the login bug');10351036cancelRequest();1037await assert.doesNotReject(sendPromise);1038await provider.deleteSession(session.sessionId);1039});10401041test('sendAndCreateChat keeps temp session on cancellation', async () => {1042const { provider, cancelRequest } = makeClaudeInFlightProvider();1043const workspace = URI.file('/test/project');1044const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id);10451046const added = waitForSessionAdded(provider);1047const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' });1048await added;10491050// Cancel before the agent session appears1051cancelRequest();1052await sendPromise;10531054assert.strictEqual(provider.getSessions().length, 1, 'session should remain after cancellation');1055assert.strictEqual(provider.getSessions()[0].status.get(), SessionStatus.Completed, 'should be marked completed');10561057await provider.deleteSession(session.sessionId);1058});10591060// ---- Rename -------10611062test('renameChat delegates to claude rename command', async () => {1063const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' });1064model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude }));10651066const provider = createProvider(disposables, model, { claudeEnabled: true });1067const sessions = provider.getSessions();1068assert.strictEqual(sessions.length, 1);10691070// Should not throw — delegates to ICommandService.executeCommand1071await provider.renameChat(sessions[0].sessionId, claudeResource, 'New Title');1072});10731074test('renameChat throws for unsupported session type', async () => {1075const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: '/cloud-session' });1076model.addSession(createMockAgentSession(resource, { providerType: AgentSessionProviders.Cloud }));10771078const provider = createProvider(disposables, model);1079const sessions = provider.getSessions();10801081await assert.rejects(1082() => provider.renameChat(sessions[0].sessionId, resource, 'New Title'),1083/not supported/,1084);1085});10861087// ---- Uncommitted temp session cleanup ------------------------------------10881089suite('uncommitted temp session cleanup', () => {1090const workspace = URI.file('/test/repo');10911092/**1093* Returns a provider wired up so that sendRequest keeps the request1094* in-flight indefinitely. Also returns helpers to resolve the request1095* as a cancellation (so the provider cleans up promptly in tests).1096*/1097function makeInFlightProvider(): {1098provider: CopilotChatSessionsProvider;1099cancelRequest: () => void;1100} {1101let resolveComplete!: () => void;1102let resolveCreated!: (r: IChatResponseModel) => void;1103const responseCompletePromise = new Promise<void>(r => { resolveComplete = r; });1104const responseCreatedPromise = new Promise<IChatResponseModel>(r => { resolveCreated = r; });11051106const provider = createProviderForSendTests(disposables, model, async () => ({1107kind: 'sent' as const,1108data: {1109responseCompletePromise,1110responseCreatedPromise,1111agent: new class extends mock<IChatAgentData>() { }(),1112} as IChatSendRequestData,1113}));11141115return {1116provider,1117cancelRequest: () => {1118resolveCreated({ isCanceled: true } as unknown as IChatResponseModel);1119resolveComplete();1120},1121};1122}11231124/** Wait for the provider to fire an "added" session change event. */1125function waitForSessionAdded(provider: CopilotChatSessionsProvider): Promise<void> {1126return new Promise<void>(resolve => {1127const d = provider.onDidChangeSessions(e => {1128if (e.added.length > 0) {1129d.dispose();1130resolve();1131}1132});1133});1134}11351136test('deleteSession removes a temp session that is awaiting commit', async () => {1137const { provider, cancelRequest } = makeInFlightProvider();11381139const newSession = provider.createNewSession(workspace, CopilotCLISessionType.id);1140const sessionId = newSession.sessionId;11411142const added = waitForSessionAdded(provider);1143const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' });1144await added;11451146assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight');11471148await provider.deleteSession(sessionId);1149assert.strictEqual(provider.getSessions().length, 0, 'session should be removed after deleteSession');11501151// Cancellation after delete should resolve cleanly1152cancelRequest();1153await assert.doesNotReject(sendPromise);1154});11551156test('archiveSession archives a temp session that is awaiting commit', async () => {1157const { provider, cancelRequest } = makeInFlightProvider();11581159const newSession = provider.createNewSession(workspace, CopilotCLISessionType.id);1160const sessionId = newSession.sessionId;11611162const added = waitForSessionAdded(provider);1163const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' });1164await added;11651166assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight');11671168await provider.archiveSession(sessionId);1169assert.strictEqual(provider.getSessions().length, 1, 'session should still be in the list after archiveSession');1170assert.strictEqual(provider.getSessions()[0].isArchived.get(), true, 'session should be archived');11711172// Cancellation after archive should resolve cleanly1173cancelRequest();1174await assert.doesNotReject(sendPromise);11751176// Clean up to avoid leaked disposable1177await provider.deleteSession(sessionId);1178});11791180test('archiveSession archives a stopped session that was never committed', async () => {1181const { provider, cancelRequest } = makeInFlightProvider();11821183const newSession = provider.createNewSession(workspace, CopilotCLISessionType.id);1184const sessionId = newSession.sessionId;11851186const added = waitForSessionAdded(provider);1187const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' });1188await added;11891190// Stop before commit arrives — session should stay as completed1191cancelRequest();1192await sendPromise;11931194assert.strictEqual(provider.getSessions().length, 1, 'stopped session should remain in the list');1195assert.strictEqual(provider.getSessions()[0].status.get(), SessionStatus.Completed, 'session should be completed');11961197await provider.archiveSession(sessionId);1198assert.strictEqual(provider.getSessions().length, 1, 'session should still be in the list after archiving');1199assert.strictEqual(provider.getSessions()[0].isArchived.get(), true, 'session should be archived');12001201// Unarchive should also work1202await provider.unarchiveSession(sessionId);1203assert.strictEqual(provider.getSessions()[0].isArchived.get(), false, 'session should be unarchived');12041205// Clean up to avoid leaked disposable1206await provider.deleteSession(sessionId);1207});12081209/**1210* Returns a provider where the commit event is controllable. The1211* caller can fire the commit event at the right moment to simulate1212* the session being committed mid-request, then cancel the request1213* afterwards. The session should persist after cancellation.1214*/1215function makeCommittableProvider(): {1216provider: CopilotChatSessionsProvider;1217commitSession: (original: URI, committed: URI) => void;1218cancelRequest: () => void;1219} {1220let resolveComplete!: () => void;1221let resolveCreated!: (r: IChatResponseModel) => void;1222const responseCompletePromise = new Promise<void>(r => { resolveComplete = r; });1223const responseCreatedPromise = new Promise<IChatResponseModel>(r => { resolveCreated = r; });12241225const commitEmitter = disposables.add(new Emitter<{ original: URI; committed: URI }>());12261227const provider = createProviderForSendTests(disposables, model, async () => ({1228kind: 'sent' as const,1229data: {1230responseCompletePromise,1231responseCreatedPromise,1232agent: new class extends mock<IChatAgentData>() { }(),1233} as IChatSendRequestData,1234}), { onDidCommitSession: commitEmitter.event });12351236return {1237provider,1238commitSession: (original, committed) => commitEmitter.fire({ original, committed }),1239cancelRequest: () => {1240resolveCreated({ isCanceled: true } as unknown as IChatResponseModel);1241resolveComplete();1242},1243};1244}12451246test('stopping a committed session keeps it in the list', async () => {1247const { provider, commitSession, cancelRequest } = makeCommittableProvider();12481249const newSession = provider.createNewSession(workspace, CopilotCLISessionType.id);1250const sessionId = newSession.sessionId;12511252const added = waitForSessionAdded(provider);1253const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' });1254await added;12551256assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight');12571258// Get the temp session's resource so we can fire the commit event1259const tempSession = provider.getSessions()[0];1260const tempResource = tempSession.resource;12611262// Simulate commit: the agent created the worktree, so the URI1263// swaps from untitled to a real committed resource.1264const committedResource = URI.from({ scheme: AgentSessionProviders.Background, path: `/committed-${Date.now()}` });1265const committedAgentSession = createMockAgentSession(committedResource);1266model.addSession(committedAgentSession);1267commitSession(tempResource, committedResource);12681269// _sendFirstChat should complete successfully now1270await sendPromise;12711272assert.strictEqual(provider.getSessions().length, 1, 'committed session should remain in list');12731274// Now cancel the request — session must stay1275cancelRequest();12761277assert.strictEqual(provider.getSessions().length, 1, 'committed session should persist after stopping');1278});12791280test('cancelling the request before commit keeps the session with completed status', async () => {1281const { provider, cancelRequest } = makeInFlightProvider();12821283const changes: ISessionChangeEvent[] = [];1284disposables.add(provider.onDidChangeSessions(e => changes.push(e)));12851286const newSession = provider.createNewSession(workspace, CopilotCLISessionType.id);1287const sessionId = newSession.sessionId;12881289const added = waitForSessionAdded(provider);1290const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' });1291await added;12921293assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight');1294assert.ok(changes.some(e => e.added.some(s => s.sessionId === sessionId)), 'added event should have fired');12951296// Simulate user stopping the request1297cancelRequest();1298await sendPromise;12991300assert.strictEqual(provider.getSessions().length, 1, 'session should stay in list after cancellation');1301assert.ok(1302changes.some(e => e.changed.some(s => s.sessionId === sessionId)),1303'changed event should have fired',1304);13051306// Clean up the kept session so it doesn't leak1307await provider.deleteSession(sessionId);1308});1309});1310});131113121313