Path: blob/main/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts
13405 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 { timeout } from '../../../../../base/common/async.js';7import { Emitter, Event } from '../../../../../base/common/event.js';8import { DisposableStore, toDisposable, type IReference } from '../../../../../base/common/lifecycle.js';9import { ISettableObservable, observableValue, type IObservable } from '../../../../../base/common/observable.js';10import { URI } from '../../../../../base/common/uri.js';11import { mock } from '../../../../../base/test/common/mock.js';12import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js';13import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';14import { AgentSession, IAgentHostService, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js';15import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js';16import type { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js';17import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js';18import { SessionLifecycle, type AgentInfo, type ModelSelection, type RootState, type SessionConfigState, type SessionState } from '../../../../../platform/agentHost/common/state/protocol/state.js';19import { SessionStatus as ProtocolSessionStatus, StateComponents } from '../../../../../platform/agentHost/common/state/sessionState.js';20import { ActionType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js';21import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';22import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';23import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js';24import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';25import { IChatWidget, IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js';26import { IChatService, type ChatSendResult, type IChatSendRequestOptions } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js';27import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js';28import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js';29import { ISessionChangeEvent } from '../../../../services/sessions/common/sessionsProvider.js';30import { SessionStatus } from '../../../../services/sessions/common/session.js';31import { LocalAgentHostSessionsProvider } from '../../browser/localAgentHostSessionsProvider.js';32import { ILabelService } from '../../../../../platform/label/common/label.js';3334// ---- Mock IAgentHostService -------------------------------------------------3536class MockAgentHostService extends mock<IAgentHostService>() {37declare readonly _serviceBrand: undefined;3839private readonly _onDidAction = new Emitter<ActionEnvelope>();40override readonly onDidAction = this._onDidAction.event;41private readonly _onDidNotification = new Emitter<INotification>();42override readonly onDidNotification = this._onDidNotification.event;43private readonly _onDidRootStateChange = new Emitter<RootState>();44private _rootStateValue: RootState | Error | undefined = { agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo] };45override readonly rootState: IAgentSubscription<RootState>;4647override readonly clientId = 'test-local-client';48private readonly _sessions = new Map<string, IAgentSessionMetadata>();49public disposedSessions: URI[] = [];50public dispatchedActions: { action: SessionAction | TerminalAction | IRootConfigChangedAction; clientId: string; clientSeq: number }[] = [];51public failResolveSessionConfig = false;52public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } };5354private readonly _authenticationPending: ISettableObservable<boolean> = observableValue('authenticationPending', false);55override readonly authenticationPending: IObservable<boolean> = this._authenticationPending;56override setAuthenticationPending(pending: boolean): void {57this._authenticationPending.set(pending, undefined);58}5960private _nextSeq = 0;6162constructor() {63super();64const self = this;65this.rootState = {66get value() { return self._rootStateValue; },67get verifiedValue() { return self._rootStateValue instanceof Error ? undefined : self._rootStateValue; },68onDidChange: self._onDidRootStateChange.event,69onWillApplyAction: Event.None,70onDidApplyAction: Event.None,71};72}7374nextClientSeq(): number {75return this._nextSeq++;76}7778override async listSessions(): Promise<IAgentSessionMetadata[]> {79return [...this._sessions.values()];80}8182override async disposeSession(session: URI): Promise<void> {83this.disposedSessions.push(session);84const rawId = AgentSession.id(session);85this._sessions.delete(rawId);86}8788override async resolveSessionConfig(): Promise<ResolveSessionConfigResult> {89await Promise.resolve();90if (this.failResolveSessionConfig) {91throw new Error('resolveSessionConfig unavailable');92}93return this.resolveSessionConfigResult;94}9596dispatchAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {97this.dispatchedActions.push({ action, clientId, clientSeq });98}99100override dispatch(action: SessionAction | TerminalAction | IRootConfigChangedAction): void {101this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ });102}103104// Test helpers105addSession(meta: IAgentSessionMetadata): void {106this._sessions.set(AgentSession.id(meta.session), meta);107}108109// ---- Session-state subscriptions ---------------------------------------110111private readonly _sessionStateEmitters = new Map<string, Emitter<SessionState>>();112private readonly _sessionStateValues = new Map<string, SessionState>();113public sessionSubscribeCounts = new Map<string, number>();114public sessionUnsubscribeCounts = new Map<string, number>();115116override getSubscription<T>(_kind: StateComponents, resource: URI): IReference<IAgentSubscription<T>> {117const key = resource.toString();118this.sessionSubscribeCounts.set(key, (this.sessionSubscribeCounts.get(key) ?? 0) + 1);119let emitter = this._sessionStateEmitters.get(key);120if (!emitter) {121emitter = new Emitter<SessionState>();122this._sessionStateEmitters.set(key, emitter);123}124const self = this;125const sub: IAgentSubscription<T> = {126get value() { return self._sessionStateValues.get(key) as unknown as T | undefined; },127get verifiedValue() { return self._sessionStateValues.get(key) as unknown as T | undefined; },128onDidChange: emitter.event as unknown as Event<T>,129onWillApplyAction: Event.None,130onDidApplyAction: Event.None,131};132return {133object: sub,134dispose: () => {135this.sessionUnsubscribeCounts.set(key, (this.sessionUnsubscribeCounts.get(key) ?? 0) + 1);136},137};138}139140setSessionState(rawId: string, provider: string, state: SessionState): void {141const key = AgentSession.uri(provider, rawId).toString();142this._sessionStateValues.set(key, state);143this._sessionStateEmitters.get(key)?.fire(state);144}145146setAgents(agents: AgentInfo[]): void {147this._rootStateValue = { agents };148this._onDidRootStateChange.fire(this._rootStateValue);149}150151clearRootState(): void {152this._rootStateValue = undefined;153}154155setRootStateError(): void {156this._rootStateValue = new Error('root state failed');157}158159fireNotification(n: INotification): void {160this._onDidNotification.fire(n);161}162163fireAction(envelope: ActionEnvelope): void {164this._onDidAction.fire(envelope);165}166167dispose(): void {168this._onDidAction.dispose();169this._onDidNotification.dispose();170this._onDidRootStateChange.dispose();171for (const emitter of this._sessionStateEmitters.values()) {172emitter.dispose();173}174this._sessionStateEmitters.clear();175}176}177178// ---- Test helpers -----------------------------------------------------------179180function createSession(id: string, opts?: { provider?: string; summary?: string; model?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number }): IAgentSessionMetadata {181return {182session: AgentSession.uri(opts?.provider ?? 'copilotcli', id),183startTime: opts?.startTime ?? 1000,184modifiedTime: opts?.modifiedTime ?? 2000,185summary: opts?.summary,186model: opts?.model ? { id: opts.model } : undefined,187project: opts?.project,188workingDirectory: opts?.workingDirectory,189};190}191192function createProvider(disposables: DisposableStore, agentHostService: MockAgentHostService, contributions = [193{ type: 'agent-host-copilotcli', name: 'copilot', displayName: 'Copilot', description: 'test', icon: undefined },194], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise<ChatSendResult>; openSession?: boolean }): LocalAgentHostSessionsProvider {195const instantiationService = disposables.add(new TestInstantiationService());196197instantiationService.stub(IAgentHostService, agentHostService);198instantiationService.stub(IConfigurationService, new TestConfigurationService());199instantiationService.stub(IFileDialogService, {});200instantiationService.stub(IChatSessionsService, {201getChatSessionContribution: (chatSessionType: string) => contributions.find(c => c.type === chatSessionType),202getAllChatSessionContributions: () => contributions,203getOrCreateChatSession: async () => ({ onWillDispose: () => ({ dispose() { } }), sessionResource: URI.from({ scheme: 'test' }), history: [], dispose() { } }),204});205instantiationService.stub(IChatService, {206acquireOrLoadSession: async () => undefined,207sendRequest: options?.sendRequest ?? (async (): Promise<ChatSendResult> => ({ kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never })),208});209instantiationService.stub(IChatWidgetService, {210openSession: async () => options?.openSession ? new class extends mock<IChatWidget>() { }() : undefined,211});212instantiationService.stub(ILanguageModelsService, {213lookupLanguageModel: () => undefined,214});215instantiationService.stub(ILabelService, {216getUriLabel: (uri: URI) => uri.path,217});218219return disposables.add(instantiationService.createInstance(LocalAgentHostSessionsProvider));220}221222async function waitForSessionConfig(provider: LocalAgentHostSessionsProvider, sessionId: string, predicate: (config: ResolveSessionConfigResult | undefined) => boolean): Promise<void> {223if (predicate(provider.getSessionConfig(sessionId))) {224return;225}226227await new Promise<void>(resolve => {228const disposable = provider.onDidChangeSessionConfig(changedSessionId => {229if (changedSessionId === sessionId && predicate(provider.getSessionConfig(sessionId))) {230disposable.dispose();231resolve();232}233});234});235}236237function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { provider?: string; title?: string; model?: string; modelConfig?: Record<string, string>; project?: { uri: string; displayName: string }; workingDirectory?: string }): void {238const provider = opts?.provider ?? 'copilotcli';239const sessionUri = AgentSession.uri(provider, rawId);240agentHost.fireNotification({241type: NotificationType.SessionAdded,242summary: {243resource: sessionUri.toString(),244provider,245title: opts?.title ?? `Session ${rawId}`,246status: ProtocolSessionStatus.Idle,247createdAt: Date.now(),248modifiedAt: Date.now(),249model: opts?.model ? { id: opts.model, ...(opts.modelConfig ? { config: opts.modelConfig } : {}) } : undefined,250project: opts?.project,251workingDirectory: opts?.workingDirectory,252},253});254}255256function fireSessionRemoved(agentHost: MockAgentHostService, rawId: string, provider = 'copilotcli'): void {257const sessionUri = AgentSession.uri(provider, rawId);258agentHost.fireNotification({259type: NotificationType.SessionRemoved,260session: sessionUri.toString(),261});262}263264suite('LocalAgentHostSessionsProvider', () => {265const disposables = new DisposableStore();266let agentHost: MockAgentHostService;267268setup(() => {269agentHost = new MockAgentHostService();270disposables.add(toDisposable(() => agentHost.dispose()));271});272273teardown(() => {274disposables.clear();275});276277ensureNoDisposablesAreLeakedInTestSuite();278279// ---- Provider identity -------280281test('has correct id, label, and sessionType from rootState agents', () => {282const provider = createProvider(disposables, agentHost);283284assert.strictEqual(provider.id, 'local-agent-host');285assert.ok(provider.label.length > 0);286assert.strictEqual(provider.sessionTypes.length, 1);287// The logical sessionType id is the agent provider name itself, so288// the same agent (e.g. `copilotcli`) shares one session type across289// local and remote hosts and the standalone Copilot CLI provider.290assert.strictEqual(provider.sessionTypes[0].id, 'copilotcli');291assert.strictEqual(provider.sessionTypes[0].label, 'Copilot');292});293294test('session types update when the local host advertises additional agents', () => {295const provider = createProvider(disposables, agentHost);296assert.deepStrictEqual(provider.sessionTypes.map(t => ({ id: t.id, label: t.label })), [297{ id: 'copilotcli', label: 'Copilot' },298]);299300let changes = 0;301disposables.add(provider.onDidChangeSessionTypes!(() => changes++));302303agentHost.setAgents([304{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo,305{ provider: 'openai', displayName: 'OpenAI', description: '', models: [] } as AgentInfo,306]);307308assert.strictEqual(changes, 1);309// The logical sessionType id is the agent provider name itself.310assert.deepStrictEqual(provider.sessionTypes.map(t => ({ id: t.id, label: t.label })), [311{ id: 'copilotcli', label: 'Copilot' },312{ id: 'openai', label: 'OpenAI' },313]);314});315316test('reports no session types before rootState hydrates', () => {317agentHost.clearRootState();318const provider = createProvider(disposables, agentHost);319320assert.deepStrictEqual(provider.sessionTypes, []);321});322323test('reports no session types when rootState advertises no agents', () => {324agentHost.setAgents([]);325const provider = createProvider(disposables, agentHost);326327assert.deepStrictEqual(provider.sessionTypes, []);328});329330test('reports no session types after rootState resolves to an error', () => {331agentHost.clearRootState();332const provider = createProvider(disposables, agentHost);333assert.deepStrictEqual(provider.sessionTypes, []);334335agentHost.setRootStateError();336337assert.deepStrictEqual(provider.sessionTypes, []);338});339340// ---- Workspace resolution -------341342test('resolveWorkspace builds workspace from URI with [Local] tag', () => {343const provider = createProvider(disposables, agentHost);344const uri = URI.parse('file:///home/user/project');345const ws = provider.resolveWorkspace(uri);346347assert.ok(ws, 'resolveWorkspace should resolve file:// URIs');348assert.strictEqual(ws.label, 'project [Local]');349assert.strictEqual(ws.repositories.length, 1);350assert.strictEqual(ws.repositories[0].uri.toString(), uri.toString());351assert.strictEqual(ws.requiresWorkspaceTrust, true);352});353354// ---- Browse actions -------355356test('has no browse actions', () => {357const provider = createProvider(disposables, agentHost);358359assert.strictEqual(provider.browseActions.length, 0);360});361362// ---- Session listing via notifications -------363364test('onDidChangeSessions fires when session added notification arrives', () => {365const provider = createProvider(disposables, agentHost);366const changes: ISessionChangeEvent[] = [];367disposables.add(provider.onDidChangeSessions(e => changes.push(e)));368369fireSessionAdded(agentHost, 'notif-1', { title: 'Notif Session' });370371assert.strictEqual(changes.length, 1);372assert.strictEqual(changes[0].added.length, 1);373assert.strictEqual(changes[0].added[0].title.get(), 'Notif Session');374});375376test('session removed notification removes from cache', () => {377const provider = createProvider(disposables, agentHost);378fireSessionAdded(agentHost, 'to-remove', { title: 'Removed' });379380const changes: ISessionChangeEvent[] = [];381disposables.add(provider.onDidChangeSessions(e => changes.push(e)));382383fireSessionRemoved(agentHost, 'to-remove');384385assert.strictEqual(changes.length, 1);386assert.strictEqual(changes[0].removed.length, 1);387});388389test('duplicate session added notification is ignored', () => {390const provider = createProvider(disposables, agentHost);391const changes: ISessionChangeEvent[] = [];392disposables.add(provider.onDidChangeSessions(e => changes.push(e)));393394fireSessionAdded(agentHost, 'dup-sess', { title: 'Dup' });395fireSessionAdded(agentHost, 'dup-sess', { title: 'Dup' });396397assert.strictEqual(changes.length, 1);398});399400test('removing non-existent session is no-op', () => {401const provider = createProvider(disposables, agentHost);402const changes: ISessionChangeEvent[] = [];403disposables.add(provider.onDidChangeSessions(e => changes.push(e)));404405fireSessionRemoved(agentHost, 'does-not-exist');406407assert.strictEqual(changes.length, 0);408});409410// ---- Session listing via refresh -------411412test('getSessions populates from listSessions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {413agentHost.addSession(createSession('list-1', { summary: 'First' }));414agentHost.addSession(createSession('list-2', { summary: 'Second' }));415416const provider = createProvider(disposables, agentHost);417const changes: ISessionChangeEvent[] = [];418disposables.add(provider.onDidChangeSessions(e => changes.push(e)));419420provider.getSessions();421await timeout(0);422423assert.ok(changes.length > 0);424const sessions = provider.getSessions();425assert.strictEqual(sessions.length, 2);426}));427428test('eagerly populates and fires onDidChangeSessions after construction without a getSessions() call', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {429agentHost.addSession(createSession('eager-1', { summary: 'First' }));430agentHost.addSession(createSession('eager-2', { summary: 'Second' }));431432const provider = createProvider(disposables, agentHost);433const changes: ISessionChangeEvent[] = [];434disposables.add(provider.onDidChangeSessions(e => changes.push(e)));435436// Wait for the eager listSessions() triggered by the constructor.437await timeout(0);438439assert.deepStrictEqual({440eventCount: changes.length,441added: changes[0]?.added.map(s => s.title.get()).sort(),442removed: changes[0]?.removed.length,443changed: changes[0]?.changed.length,444cachedTitles: provider.getSessions().map(s => s.title.get()).sort(),445}, {446eventCount: 1,447added: ['First', 'Second'],448removed: 0,449changed: 0,450cachedTitles: ['First', 'Second'],451});452}));453454test('defers eager session list fetch until authentication settles', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {455// Simulate fresh launch: auth is pending and the agent host has no456// sessions yet (returns []), then auth completes and the real session457// list becomes available.458agentHost.setAuthenticationPending(true);459460const provider = createProvider(disposables, agentHost);461const changes: ISessionChangeEvent[] = [];462disposables.add(provider.onDidChangeSessions(e => changes.push(e)));463464await timeout(0);465466assert.strictEqual(changes.length, 0, 'no event should fire while authentication is pending');467assert.strictEqual(provider.getSessions().length, 0, 'no sessions should be cached while authentication is pending');468469// Auth completes; sessions become available on the agent host.470agentHost.addSession(createSession('after-auth-1', { summary: 'First' }));471agentHost.addSession(createSession('after-auth-2', { summary: 'Second' }));472agentHost.setAuthenticationPending(false);473474await timeout(0);475476assert.deepStrictEqual({477eventCount: changes.length,478added: changes[0]?.added.map(s => s.title.get()).sort(),479cachedTitles: provider.getSessions().map(s => s.title.get()).sort(),480}, {481eventCount: 1,482added: ['First', 'Second'],483cachedTitles: ['First', 'Second'],484});485}));486487test('uses project metadata as workspace group source', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {488const projectUri = URI.file('/home/user/vscode');489const workingDirectory = URI.file('/tmp/copilot-worktrees/vscode-feature');490agentHost.addSession(createSession('project-1', {491summary: 'Project Session',492project: { uri: projectUri, displayName: 'vscode' },493workingDirectory,494}));495496const provider = createProvider(disposables, agentHost);497provider.getSessions();498await timeout(0);499500const workspace = provider.getSessions()[0].workspace.get();501assert.deepStrictEqual({502label: workspace?.label,503repository: workspace?.repositories[0]?.uri.toString(),504workingDirectory: workspace?.repositories[0]?.workingDirectory?.toString(),505}, {506label: 'vscode [Local]',507repository: projectUri.toString(),508workingDirectory: workingDirectory.toString(),509});510}));511512test('listed session with only workingDirectory (no project) shows folder name with [Local] tag', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {513const workingDirectory = URI.file('/home/user/standalone-folder');514agentHost.addSession(createSession('wd-only-1', {515summary: 'WD-only Session',516workingDirectory,517}));518519const provider = createProvider(disposables, agentHost);520provider.getSessions();521await timeout(0);522523const workspace = provider.getSessions()[0].workspace.get();524assert.strictEqual(workspace?.label, 'standalone-folder [Local]');525}));526527test('uses model metadata as selected model for listed sessions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {528agentHost.addSession(createSession('model-1', { summary: 'Model Session', model: 'claude-sonnet-4.5' }));529530const provider = createProvider(disposables, agentHost);531provider.getSessions();532await timeout(0);533534const session = provider.getSessions().find(s => s.title.get() === 'Model Session');535assert.strictEqual(session?.modelId.get(), 'agent-host-copilotcli:claude-sonnet-4.5');536}));537538test('uses model metadata from session added notification', () => {539const provider = createProvider(disposables, agentHost);540fireSessionAdded(agentHost, 'notif-model', { title: 'Notif Model Session', model: 'gpt-5' });541542const session = provider.getSessions().find(s => s.title.get() === 'Notif Model Session');543assert.strictEqual(session?.modelId.get(), 'agent-host-copilotcli:gpt-5');544});545546test('setModel updates existing session model and dispatches raw model', () => {547const provider = createProvider(disposables, agentHost);548fireSessionAdded(agentHost, 'set-model', { title: 'Set Model Session', model: 'old-model' });549550const session = provider.getSessions().find(s => s.title.get() === 'Set Model Session');551assert.ok(session);552553provider.setModel(session!.sessionId, 'agent-host-copilotcli:new-model');554555assert.strictEqual(session!.modelId.get(), 'agent-host-copilotcli:new-model');556assert.deepStrictEqual(agentHost.dispatchedActions.at(-1)?.action, {557type: ActionType.SessionModelChanged,558session: AgentSession.uri('copilotcli', 'set-model').toString(),559model: { id: 'new-model' },560});561});562563test('setModel preserves current model config when model id is unchanged', () => {564const provider = createProvider(disposables, agentHost);565fireSessionAdded(agentHost, 'set-model-config', { title: 'Set Model Config Session', model: 'configured-model', modelConfig: { thinkingLevel: 'high' } });566567const session = provider.getSessions().find(s => s.title.get() === 'Set Model Config Session');568assert.ok(session);569570provider.setModel(session!.sessionId, 'agent-host-copilotcli:configured-model');571572assert.deepStrictEqual(agentHost.dispatchedActions.at(-1)?.action, {573type: ActionType.SessionModelChanged,574session: AgentSession.uri('copilotcli', 'set-model-config').toString(),575model: { id: 'configured-model', config: { thinkingLevel: 'high' } },576});577});578579// ---- Session lifecycle -------580581test('createNewSession returns session with correct fields', () => {582const provider = createProvider(disposables, agentHost);583const workspaceUri = URI.parse('file:///home/user/my-project');584const session = provider.createNewSession(workspaceUri, provider.sessionTypes[0].id);585586assert.strictEqual(session.providerId, provider.id);587assert.strictEqual(session.status.get(), SessionStatus.Untitled);588assert.ok(session.workspace.get());589assert.strictEqual(session.workspace.get()?.label, 'my-project [Local]');590assert.strictEqual(session.sessionType, provider.sessionTypes[0].id);591assert.deepStrictEqual(provider.getSessionConfig(session.sessionId), { schema: { type: 'object', properties: {} }, values: {} });592});593594test('createNewSession clears session config when resolving config is unavailable', async () => {595agentHost.failResolveSessionConfig = true;596const provider = createProvider(disposables, agentHost);597const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id);598await waitForSessionConfig(provider, session.sessionId, config => config === undefined);599600assert.strictEqual(provider.getSessionConfig(session.sessionId), undefined);601});602603test('getSessionByResource resolves current new session without listing it', () => {604const provider = createProvider(disposables, agentHost);605const workspaceUri = URI.parse('file:///home/user/my-project');606const session = provider.createNewSession(workspaceUri, provider.sessionTypes[0].id);607const resolved = provider.getSessionByResource(session.resource);608609assert.deepStrictEqual({610listedSessions: provider.getSessions().length,611resolvedResource: resolved?.resource.toString(),612resolvedWorkspaceLabel: resolved?.workspace.get()?.label,613}, {614listedSessions: 0,615resolvedResource: session.resource.toString(),616resolvedWorkspaceLabel: 'my-project [Local]',617});618});619620// ---- Session actions -------621622test('deleteSession calls disposeSession and removes from cache', async () => {623const provider = createProvider(disposables, agentHost);624fireSessionAdded(agentHost, 'del-sess', { title: 'To Delete' });625626const sessions = provider.getSessions();627const target = sessions.find(s => s.title.get() === 'To Delete');628assert.ok(target);629630await provider.deleteSession(target!.sessionId);631632assert.strictEqual(agentHost.disposedSessions.length, 1);633const disposedUri = agentHost.disposedSessions[0];634assert.strictEqual(AgentSession.provider(disposedUri), 'copilotcli');635assert.strictEqual(AgentSession.id(disposedUri), 'del-sess');636assert.strictEqual(provider.getSessions().find(s => s.title.get() === 'To Delete'), undefined);637});638639// ---- Rename -------640641test('renameChat dispatches SessionTitleChanged action', async () => {642const provider = createProvider(disposables, agentHost);643fireSessionAdded(agentHost, 'rename-sess', { title: 'Old Title' });644645const sessions = provider.getSessions();646const target = sessions.find(s => s.title.get() === 'Old Title');647assert.ok(target);648649await provider.renameChat(target!.sessionId, target!.resource, 'New Title');650651assert.strictEqual(agentHost.dispatchedActions.length, 1);652const dispatched = agentHost.dispatchedActions[0];653assert.strictEqual(dispatched.action.type, ActionType.SessionTitleChanged);654assert.strictEqual((dispatched.action as { title: string }).title, 'New Title');655const actionSession = (dispatched.action as { session: string }).session;656assert.strictEqual(AgentSession.provider(actionSession), 'copilotcli');657assert.strictEqual(AgentSession.id(actionSession), 'rename-sess');658assert.strictEqual(dispatched.clientId, 'test-local-client');659});660661test('renameChat updates local title optimistically', async () => {662const provider = createProvider(disposables, agentHost);663fireSessionAdded(agentHost, 'rename-opt', { title: 'Before' });664665const sessions = provider.getSessions();666const target = sessions.find(s => s.title.get() === 'Before');667assert.ok(target);668669await provider.renameChat(target!.sessionId, target!.resource, 'After');670assert.strictEqual(target!.title.get(), 'After');671});672673test('renameChat is no-op for unknown session', async () => {674const provider = createProvider(disposables, agentHost);675await provider.renameChat('nonexistent-id', URI.parse('test://nonexistent'), 'Ignored');676677assert.strictEqual(agentHost.dispatchedActions.length, 0);678});679680// ---- Title change from server -------681682test('server-echoed SessionTitleChanged updates cached title', () => {683const provider = createProvider(disposables, agentHost);684fireSessionAdded(agentHost, 'echo-sess', { title: 'Original' });685686const sessions = provider.getSessions();687const target = sessions.find(s => s.title.get() === 'Original');688assert.ok(target);689690const changes: ISessionChangeEvent[] = [];691disposables.add(provider.onDidChangeSessions(e => changes.push(e)));692693agentHost.fireAction({694action: {695type: ActionType.SessionTitleChanged,696session: AgentSession.uri('copilotcli', 'echo-sess').toString(),697title: 'Server Title',698},699serverSeq: 1,700origin: undefined,701} as ActionEnvelope);702703assert.strictEqual(target!.title.get(), 'Server Title');704assert.strictEqual(changes.length, 1);705assert.strictEqual(changes[0].changed.length, 1);706});707708test('server-echoed SessionModelChanged updates cached model', () => {709const provider = createProvider(disposables, agentHost);710fireSessionAdded(agentHost, 'model-change', { title: 'Model Change', model: 'old-model' });711712const target = provider.getSessions().find(s => s.title.get() === 'Model Change');713assert.ok(target);714715const changes: ISessionChangeEvent[] = [];716disposables.add(provider.onDidChangeSessions(e => changes.push(e)));717718agentHost.fireAction({719action: {720type: ActionType.SessionModelChanged,721session: AgentSession.uri('copilotcli', 'model-change').toString(),722model: { id: 'new-model' } satisfies ModelSelection,723},724serverSeq: 1,725origin: undefined,726} as ActionEnvelope);727728assert.strictEqual(target!.modelId.get(), 'agent-host-copilotcli:new-model');729assert.strictEqual(changes.length, 1);730assert.strictEqual(changes[0].changed.length, 1);731});732733// ---- Refresh on turnComplete -------734735test('turnComplete action triggers session refresh', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {736agentHost.addSession(createSession('turn-sess', { summary: 'Before', modifiedTime: 1000 }));737738const provider = createProvider(disposables, agentHost);739provider.getSessions();740await timeout(0);741742// Update on connection side743agentHost.addSession(createSession('turn-sess', { summary: 'After', modifiedTime: 5000 }));744745const changes: ISessionChangeEvent[] = [];746disposables.add(provider.onDidChangeSessions(e => changes.push(e)));747748agentHost.fireAction({749action: {750type: 'session/turnComplete',751session: AgentSession.uri('copilotcli', 'turn-sess').toString(),752},753serverSeq: 1,754origin: undefined,755} as ActionEnvelope);756757await timeout(0);758759assert.ok(changes.length > 0);760const updatedSession = provider.getSessions().find(s => s.title.get() === 'After');761assert.ok(updatedSession);762}));763764// ---- Session data adapter -------765766test('session adapter has correct workspace from working directory', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {767agentHost.addSession(createSession('ws-sess', { summary: 'WS Test', workingDirectory: URI.parse('file:///home/user/myrepo') }));768769const provider = createProvider(disposables, agentHost);770provider.getSessions();771await timeout(0);772773const sessions = provider.getSessions();774const wsSession = sessions.find(s => s.title.get() === 'WS Test');775assert.ok(wsSession);776777const workspace = wsSession!.workspace.get();778assert.ok(workspace);779assert.strictEqual(workspace!.label, 'myrepo [Local]');780}));781782test('session adapter without working directory has no workspace', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {783agentHost.addSession(createSession('no-ws-sess', { summary: 'No WS' }));784785const provider = createProvider(disposables, agentHost);786provider.getSessions();787await timeout(0);788789const sessions = provider.getSessions();790const session = sessions.find(s => s.title.get() === 'No WS');791assert.ok(session);792assert.strictEqual(session!.workspace.get(), undefined);793}));794795test('session adapter uses raw ID as fallback title', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {796agentHost.addSession(createSession('abcdef1234567890'));797798const provider = createProvider(disposables, agentHost);799provider.getSessions();800await timeout(0);801802const sessions = provider.getSessions();803const session = sessions[0];804assert.ok(session);805assert.strictEqual(session.title.get(), 'Session abcdef12');806}));807808test('new session stays loading when required config is missing', async () => {809agentHost.resolveSessionConfigResult = {810schema: { type: 'object', required: ['branch'], properties: { branch: { type: 'string', title: 'Branch', enum: ['main'] } } },811values: {},812};813const provider = createProvider(disposables, agentHost);814const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id);815await waitForSessionConfig(provider, session.sessionId, config => config?.schema.required?.includes('branch') === true);816817assert.strictEqual(session.loading.get(), true);818});819820test('cached session loading reflects authenticationPending', async () => {821agentHost.setAuthenticationPending(true);822agentHost.addSession(createSession('cached-auth-loading', { summary: 'Cached' }));823824const provider = createProvider(disposables, agentHost);825provider.getSessions();826await timeout(0);827828const session = provider.getSessions().find(s => s.title.get() === 'Cached');829assert.ok(session);830assert.strictEqual(session!.loading.get(), true);831832agentHost.setAuthenticationPending(false);833assert.strictEqual(session!.loading.get(), false);834});835836test('new session loading reflects authenticationPending until config resolves', async () => {837agentHost.setAuthenticationPending(true);838const provider = createProvider(disposables, agentHost);839const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id);840// Wait for the resolved config (the mock returns `values.isolation: 'worktree'`)841// so that the per-session loading flag has been turned off.842await waitForSessionConfig(provider, session.sessionId, config => config?.values.isolation === 'worktree');843844// Even though config has resolved (per-session loading is false), the845// auth-pending flag keeps the session in the loading state.846assert.strictEqual(session.loading.get(), true);847848agentHost.setAuthenticationPending(false);849assert.strictEqual(session.loading.get(), false);850});851852// ---- sendAndCreateChat -------853854test('sendAndCreateChat throws for unknown session', async () => {855const provider = createProvider(disposables, agentHost);856await assert.rejects(857() => provider.sendAndCreateChat('nonexistent', { query: 'test' }),858/not found or not a new session/,859);860});861862test('sendAndCreateChat forwards resolved session config to chat service', async () => {863const sendOptions: IChatSendRequestOptions[] = [];864const provider = createProvider(disposables, agentHost, undefined, {865openSession: true,866sendRequest: async (_resource, _message, options): Promise<ChatSendResult> => {867if (options) {868sendOptions.push(options);869}870agentHost.addSession(createSession('created-from-send', { summary: 'Created From Send' }));871return { kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never };872},873});874const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id);875await waitForSessionConfig(provider, session.sessionId, config => config?.values.isolation === 'worktree');876877await provider.sendAndCreateChat(session.sessionId, { query: 'hello' });878879assert.deepStrictEqual(sendOptions.map(options => options.agentHostSessionConfig), [{ isolation: 'worktree' }]);880});881882// ---- Running session config seeding (from SessionState.config) -------883884test('getSessionConfig seeds running config from session state subscription with full schema', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {885agentHost.addSession(createSession('seed-1', { summary: 'Seeded Session' }));886const provider = createProvider(disposables, agentHost);887provider.getSessions();888await timeout(0);889const session = provider.getSessions().find(s => s.title.get() === 'Seeded Session');890assert.ok(session);891892// Initially the cache has nothing for this session — the picker reads893// `undefined` while the subscription kicks off (and starts subscribing).894assert.strictEqual(provider.getSessionConfig(session!.sessionId), undefined);895896// Now have the fake host hydrate the session-state snapshot with a897// config containing one mutable and one read-only property.898const config: SessionConfigState = {899schema: {900type: 'object',901properties: {902autoApprove: { type: 'string', title: 'Auto Approve', enum: ['default', 'autoApprove'], sessionMutable: true },903isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'], readOnly: true },904},905},906values: { autoApprove: 'default', isolation: 'worktree' },907};908const fakeState: SessionState = {909summary: { resource: AgentSession.uri('copilotcli', 'seed-1').toString(), provider: 'copilotcli', title: 'Seeded Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 },910lifecycle: SessionLifecycle.Ready,911turns: [],912config,913};914agentHost.setSessionState('seed-1', 'copilotcli', fakeState);915916await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default');917918// The full schema + values are retained (non-mutable values are919// required by the JSONC settings editor to round-trip via replace920// semantics without dropping server-side config).921const seeded = provider.getSessionConfig(session!.sessionId);922assert.deepStrictEqual({923properties: Object.keys(seeded?.schema.properties ?? {}).sort(),924values: seeded?.values,925}, {926properties: ['autoApprove', 'isolation'],927values: { autoApprove: 'default', isolation: 'worktree' },928});929}));930931test('removing a session disposes its session-state subscription', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {932agentHost.addSession(createSession('seed-2', { summary: 'Sub Session' }));933const provider = createProvider(disposables, agentHost);934provider.getSessions();935await timeout(0);936const session = provider.getSessions().find(s => s.title.get() === 'Sub Session');937assert.ok(session);938939// Trigger lazy subscription940provider.getSessionConfig(session!.sessionId);941const sessionUriStr = AgentSession.uri('copilotcli', 'seed-2').toString();942assert.strictEqual(agentHost.sessionSubscribeCounts.get(sessionUriStr), 1);943assert.strictEqual(agentHost.sessionUnsubscribeCounts.get(sessionUriStr) ?? 0, 0);944945fireSessionRemoved(agentHost, 'seed-2');946947assert.strictEqual(agentHost.sessionUnsubscribeCounts.get(sessionUriStr), 1);948}));949950// ---- replaceSessionConfig -------951952test('replaceSessionConfig only replaces sessionMutable, non-readOnly values and preserves everything else', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {953agentHost.addSession(createSession('rep-1', { summary: 'Replace Session' }));954const provider = createProvider(disposables, agentHost);955provider.getSessions();956await timeout(0);957const session = provider.getSessions().find(s => s.title.get() === 'Replace Session');958assert.ok(session);959960const config: SessionConfigState = {961schema: {962type: 'object',963properties: {964autoApprove: { type: 'string', title: 'Auto Approve', enum: ['default', 'autoApprove'], sessionMutable: true },965isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'] }, // non-mutable966branch: { type: 'string', title: 'Branch', enum: ['main'], sessionMutable: true, readOnly: true }, // readOnly967},968},969values: { autoApprove: 'default', isolation: 'worktree', branch: 'main' },970};971const fakeState: SessionState = {972summary: { resource: AgentSession.uri('copilotcli', 'rep-1').toString(), provider: 'copilotcli', title: 'Replace Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 },973lifecycle: SessionLifecycle.Ready,974turns: [],975config,976};977agentHost.setSessionState('rep-1', 'copilotcli', fakeState);978await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default');979980// Caller attempts to change everything — including non-mutable981// `isolation`, readOnly `branch`, and an unknown `rogue` key. Only982// `autoApprove` should actually change; all other values must be983// carried through unchanged and `rogue` must be dropped.984await provider.replaceSessionConfig(session!.sessionId, {985autoApprove: 'autoApprove',986isolation: 'folder',987branch: 'other',988rogue: 'ignored',989});990991const sessionUri = AgentSession.uri('copilotcli', 'rep-1').toString();992const configChanged = agentHost.dispatchedActions.find(d => d.action.type === ActionType.SessionConfigChanged && (d.action as { session: string }).session === sessionUri);993assert.ok(configChanged, 'a SessionConfigChanged action should be dispatched');994assert.deepStrictEqual(configChanged.action, {995type: ActionType.SessionConfigChanged,996session: sessionUri,997config: { autoApprove: 'autoApprove', isolation: 'worktree', branch: 'main' },998replace: true,999});10001001const latest = provider.getSessionConfig(session!.sessionId);1002assert.deepStrictEqual(latest?.values, { autoApprove: 'autoApprove', isolation: 'worktree', branch: 'main' });1003}));10041005test('replaceSessionConfig is a no-op when nothing editable actually changes', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {1006agentHost.addSession(createSession('rep-2', { summary: 'No-op Session' }));1007const provider = createProvider(disposables, agentHost);1008provider.getSessions();1009await timeout(0);1010const session = provider.getSessions().find(s => s.title.get() === 'No-op Session');1011assert.ok(session);10121013const config: SessionConfigState = {1014schema: {1015type: 'object',1016properties: {1017autoApprove: { type: 'string', title: 'Auto Approve', enum: ['default', 'autoApprove'], sessionMutable: true },1018isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'] },1019},1020},1021values: { autoApprove: 'default', isolation: 'worktree' },1022};1023const fakeState: SessionState = {1024summary: { resource: AgentSession.uri('copilotcli', 'rep-2').toString(), provider: 'copilotcli', title: 'No-op Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 },1025lifecycle: SessionLifecycle.Ready,1026turns: [],1027config,1028};1029agentHost.setSessionState('rep-2', 'copilotcli', fakeState);1030await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default');10311032const before = agentHost.dispatchedActions.length;1033// Caller re-asserts the same editable value; everything else either1034// matches or is non-editable.1035await provider.replaceSessionConfig(session!.sessionId, { autoApprove: 'default' });1036assert.strictEqual(agentHost.dispatchedActions.length, before, 'no action should be dispatched');1037}));10381039// ---- Server-echoed SessionConfigChanged -------10401041test('server-echoed SessionConfigChanged merges config values into the running cache by default', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {1042agentHost.addSession(createSession('cfg-merge', { summary: 'Merge Session' }));1043const provider = createProvider(disposables, agentHost);1044provider.getSessions();1045await timeout(0);1046const session = provider.getSessions().find(s => s.title.get() === 'Merge Session');1047assert.ok(session);10481049const fakeState: SessionState = {1050summary: { resource: AgentSession.uri('copilotcli', 'cfg-merge').toString(), provider: 'copilotcli', title: 'Merge Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 },1051lifecycle: SessionLifecycle.Ready,1052turns: [],1053config: {1054schema: {1055type: 'object',1056properties: {1057autoApprove: { type: 'string', title: 'Auto Approve', enum: ['default', 'autoApprove'], sessionMutable: true },1058isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'] },1059},1060},1061values: { autoApprove: 'default', isolation: 'worktree' },1062},1063};1064agentHost.setSessionState('cfg-merge', 'copilotcli', fakeState);1065await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default');10661067agentHost.fireAction({1068action: {1069type: ActionType.SessionConfigChanged,1070session: AgentSession.uri('copilotcli', 'cfg-merge').toString(),1071config: { autoApprove: 'autoApprove' },1072},1073serverSeq: 1,1074origin: undefined,1075} as ActionEnvelope);10761077const updated = provider.getSessionConfig(session!.sessionId);1078assert.deepStrictEqual(updated?.values, { autoApprove: 'autoApprove', isolation: 'worktree' });1079}));10801081test('server-echoed SessionConfigChanged with replace:true overwrites the running cache', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {1082agentHost.addSession(createSession('cfg-replace', { summary: 'Replace Session' }));1083const provider = createProvider(disposables, agentHost);1084provider.getSessions();1085await timeout(0);1086const session = provider.getSessions().find(s => s.title.get() === 'Replace Session');1087assert.ok(session);10881089const fakeState: SessionState = {1090summary: { resource: AgentSession.uri('copilotcli', 'cfg-replace').toString(), provider: 'copilotcli', title: 'Replace Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 },1091lifecycle: SessionLifecycle.Ready,1092turns: [],1093config: {1094schema: {1095type: 'object',1096properties: {1097autoApprove: { type: 'string', title: 'Auto Approve', enum: ['default', 'autoApprove'], sessionMutable: true },1098mode: { type: 'string', title: 'Mode', enum: ['a', 'b'], sessionMutable: true },1099isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'] },1100},1101},1102values: { autoApprove: 'default', mode: 'a', isolation: 'worktree' },1103},1104};1105agentHost.setSessionState('cfg-replace', 'copilotcli', fakeState);1106await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default');11071108agentHost.fireAction({1109action: {1110type: ActionType.SessionConfigChanged,1111session: AgentSession.uri('copilotcli', 'cfg-replace').toString(),1112config: { autoApprove: 'autoApprove', isolation: 'worktree' },1113replace: true,1114},1115serverSeq: 1,1116origin: undefined,1117} as ActionEnvelope);11181119// `mode` is dropped because it wasn't re-asserted in the replace payload.1120const updated = provider.getSessionConfig(session!.sessionId);1121assert.deepStrictEqual(updated?.values, { autoApprove: 'autoApprove', isolation: 'worktree' });1122}));1123});112411251126