Path: blob/main/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.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 { URI } from '../../../../../base/common/uri.js';10import { mock } from '../../../../../base/test/common/mock.js';11import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js';12import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';13import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js';14import type { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js';15import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js';16import { SessionLifecycle, type AgentInfo, type ModelSelection, type RootState, type SessionConfigState, type SessionState } from '../../../../../platform/agentHost/common/state/protocol/state.js';17import { ActionType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js';18import { SessionStatus as ProtocolSessionStatus, StateComponents } from '../../../../../platform/agentHost/common/state/sessionState.js';19import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js';20import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';21import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';22import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js';23import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';24import { INotificationService } from '../../../../../platform/notification/common/notification.js';25import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js';26import { IChatWidget, IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js';27import { IChatService, type ChatSendResult, type IChatSendRequestOptions } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js';28import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js';29import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js';30import { ISessionChangeEvent } from '../../../../services/sessions/common/sessionsProvider.js';31import { SessionStatus, COPILOT_CLI_SESSION_TYPE } from '../../../../services/sessions/common/session.js';32import { RemoteAgentHostSessionsProvider, type IRemoteAgentHostSessionsProviderConfig } from '../../browser/remoteAgentHostSessionsProvider.js';33import { ILabelService } from '../../../../../platform/label/common/label.js';3435// ---- Mock connection --------------------------------------------------------3637class MockAgentConnection extends mock<IAgentConnection>() {38declare readonly _serviceBrand: undefined;3940private readonly _onDidAction = new Emitter<ActionEnvelope>();41override readonly onDidAction = this._onDidAction.event;42private readonly _onDidNotification = new Emitter<INotification>();43override readonly onDidNotification = this._onDidNotification.event;4445private readonly _onDidRootStateChange = new Emitter<RootState>();46private _rootStateValue: RootState = { agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo] };47override readonly rootState: IAgentSubscription<RootState>;4849override readonly clientId = 'test-client-1';50private readonly _sessions = new Map<string, IAgentSessionMetadata>();51public disposedSessions: URI[] = [];52public dispatchedActions: { action: SessionAction | TerminalAction | IRootConfigChangedAction; clientId: string; clientSeq: number }[] = [];53public failResolveSessionConfig = false;54public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } };5556private _nextSeq = 0;5758constructor() {59super();60const self = this;61this.rootState = {62get value() { return self._rootStateValue; },63get verifiedValue() { return self._rootStateValue; },64onDidChange: self._onDidRootStateChange.event,65onWillApplyAction: Event.None,66onDidApplyAction: Event.None,67};68}6970nextClientSeq(): number {71return this._nextSeq++;72}7374override async listSessions(): Promise<IAgentSessionMetadata[]> {75return [...this._sessions.values()];76}7778override async disposeSession(session: URI): Promise<void> {79this.disposedSessions.push(session);80const rawId = AgentSession.id(session);81this._sessions.delete(rawId);82}8384override async resolveSessionConfig(): Promise<ResolveSessionConfigResult> {85await Promise.resolve();86if (this.failResolveSessionConfig) {87throw new Error('resolveSessionConfig unavailable');88}89return this.resolveSessionConfigResult;90}9192dispatchAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {93this.dispatchedActions.push({ action, clientId, clientSeq });94}9596override dispatch(action: SessionAction | TerminalAction | IRootConfigChangedAction): void {97this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ });98}99100// Test helpers101addSession(meta: IAgentSessionMetadata): void {102this._sessions.set(AgentSession.id(meta.session), meta);103}104105// ---- Session-state subscriptions ---------------------------------------106107private readonly _sessionStateEmitters = new Map<string, Emitter<SessionState>>();108private readonly _sessionStateValues = new Map<string, SessionState>();109public sessionSubscribeCounts = new Map<string, number>();110public sessionUnsubscribeCounts = new Map<string, number>();111112override getSubscription<T>(_kind: StateComponents, resource: URI): IReference<IAgentSubscription<T>> {113const key = resource.toString();114this.sessionSubscribeCounts.set(key, (this.sessionSubscribeCounts.get(key) ?? 0) + 1);115let emitter = this._sessionStateEmitters.get(key);116if (!emitter) {117emitter = new Emitter<SessionState>();118this._sessionStateEmitters.set(key, emitter);119}120const self = this;121const sub: IAgentSubscription<T> = {122get value() { return self._sessionStateValues.get(key) as unknown as T | undefined; },123get verifiedValue() { return self._sessionStateValues.get(key) as unknown as T | undefined; },124onDidChange: emitter.event as unknown as Event<T>,125onWillApplyAction: Event.None,126onDidApplyAction: Event.None,127};128return {129object: sub,130dispose: () => {131this.sessionUnsubscribeCounts.set(key, (this.sessionUnsubscribeCounts.get(key) ?? 0) + 1);132},133};134}135136setSessionState(rawId: string, provider: string, state: SessionState): void {137const key = AgentSession.uri(provider, rawId).toString();138this._sessionStateValues.set(key, state);139this._sessionStateEmitters.get(key)?.fire(state);140}141142setAgents(agents: AgentInfo[]): void {143this._rootStateValue = { agents };144this._onDidRootStateChange.fire(this._rootStateValue);145}146147fireNotification(n: INotification): void {148this._onDidNotification.fire(n);149}150151fireAction(envelope: ActionEnvelope): void {152this._onDidAction.fire(envelope);153}154155dispose(): void {156this._onDidAction.dispose();157this._onDidNotification.dispose();158this._onDidRootStateChange.dispose();159for (const emitter of this._sessionStateEmitters.values()) {160emitter.dispose();161}162this._sessionStateEmitters.clear();163}164}165166// ---- Test helpers -----------------------------------------------------------167168function createSession(id: string, opts?: { provider?: string; summary?: string; model?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number }): IAgentSessionMetadata {169return {170session: AgentSession.uri(opts?.provider ?? 'copilotcli', id),171startTime: opts?.startTime ?? 1000,172modifiedTime: opts?.modifiedTime ?? 2000,173summary: opts?.summary,174model: opts?.model ? { id: opts.model } : undefined,175project: opts?.project,176workingDirectory: opts?.workingDirectory,177};178}179180function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise<ChatSendResult>; openSession?: boolean; storageService?: IStorageService; noConnection?: boolean; isWebPlatform?: boolean }): RemoteAgentHostSessionsProvider {181const instantiationService = disposables.add(new TestInstantiationService());182183instantiationService.stub(IFileDialogService, {});184instantiationService.stub(IConfigurationService, new TestConfigurationService());185instantiationService.stub(INotificationService, { error: () => { } });186instantiationService.stub(IChatSessionsService, {187getChatSessionContribution: () => ({ type: 'remote-test-copilot', name: 'test', displayName: 'Test', description: 'test', icon: undefined }),188getOrCreateChatSession: async () => ({ onWillDispose: () => ({ dispose() { } }), sessionResource: URI.from({ scheme: 'test' }), history: [], dispose() { } }),189});190instantiationService.stub(IChatService, {191acquireOrLoadSession: async () => undefined,192sendRequest: overrides?.sendRequest ?? (async (): Promise<ChatSendResult> => ({ kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never })),193});194instantiationService.stub(IChatWidgetService, {195openSession: async () => overrides?.openSession ? new class extends mock<IChatWidget>() { }() : undefined,196});197instantiationService.stub(ILanguageModelsService, {198lookupLanguageModel: () => undefined,199});200instantiationService.stub(IStorageService, overrides?.storageService ?? disposables.add(new InMemoryStorageService()));201instantiationService.stub(ILabelService, {202getUriLabel: (uri: URI) => uri.path,203});204205const config: IRemoteAgentHostSessionsProviderConfig = {206address: overrides?.address ?? 'localhost:4321',207name: overrides !== undefined && Object.prototype.hasOwnProperty.call(overrides, 'connectionName') ? overrides.connectionName ?? '' : 'Test Host',208};209210const providerCtor = overrides?.isWebPlatform !== undefined211? class extends RemoteAgentHostSessionsProvider {212protected override get isWebPlatform(): boolean { return overrides.isWebPlatform!; }213}214: RemoteAgentHostSessionsProvider;215const provider = disposables.add(instantiationService.createInstance(providerCtor, config));216if (!overrides?.noConnection) {217provider.setConnection(connection);218}219return provider;220}221222async function waitForSessionConfig(provider: RemoteAgentHostSessionsProvider, 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(connection: MockAgentConnection, 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);240connection.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(connection: MockAgentConnection, rawId: string, provider = 'copilotcli'): void {257const sessionUri = AgentSession.uri(provider, rawId);258connection.fireNotification({259type: NotificationType.SessionRemoved,260session: sessionUri.toString(),261});262}263264suite('RemoteAgentHostSessionsProvider', () => {265const disposables = new DisposableStore();266let connection: MockAgentConnection;267268setup(() => {269connection = new MockAgentConnection();270disposables.add(toDisposable(() => connection.dispose()));271});272273teardown(() => {274disposables.clear();275});276277ensureNoDisposablesAreLeakedInTestSuite();278279// ---- Provider identity -------280281test('derives id and label from config, and session types from rootState agents', () => {282const provider = createProvider(disposables, connection, { address: '10.0.0.1:8080', connectionName: 'My Host' });283284assert.strictEqual(provider.id, 'agenthost-10.0.0.1__8080');285assert.strictEqual(provider.label, 'My Host');286assert.strictEqual(provider.sessionTypes.length, 1);287assert.strictEqual(provider.sessionTypes[0].id, COPILOT_CLI_SESSION_TYPE);288assert.strictEqual(provider.sessionTypes[0].label, 'Copilot [My Host]');289});290291test('session types update when the host advertises additional agents', () => {292const provider = createProvider(disposables, connection, { address: '10.0.0.1:8080', connectionName: 'My Host' });293assert.deepStrictEqual(provider.sessionTypes.map(t => t.id), [294COPILOT_CLI_SESSION_TYPE,295]);296297let changes = 0;298disposables.add(provider.onDidChangeSessionTypes!(() => changes++));299300connection.setAgents([301{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo,302{ provider: 'openai', displayName: 'OpenAI', description: '', models: [] } as AgentInfo,303]);304305assert.strictEqual(changes, 1);306assert.deepStrictEqual(provider.sessionTypes.map(t => ({ id: t.id, label: t.label })), [307{ id: COPILOT_CLI_SESSION_TYPE, label: 'Copilot [My Host]' },308{ id: 'openai', label: 'OpenAI [My Host]' },309]);310});311312test('falls back to address-based label when no name given', () => {313const provider = createProvider(disposables, connection, { connectionName: undefined, address: 'myhost:9999' });314315assert.strictEqual(provider.label, 'myhost:9999');316});317318// ---- Workspace resolution -------319320test('resolveWorkspace builds workspace from URI', () => {321const provider = createProvider(disposables, connection, { isWebPlatform: true });322const uri = URI.parse('vscode-agent-host://auth/home/user/project');323const ws = provider.resolveWorkspace(uri);324325assert.ok(ws, 'resolveWorkspace should resolve vscode-agent-host:// URIs');326assert.strictEqual(ws.label, 'project');327assert.strictEqual(ws.repositories.length, 1);328assert.strictEqual(ws.repositories[0].uri.toString(), uri.toString());329assert.strictEqual(ws.repositories[0].detail, undefined);330});331332// ---- Browse actions -------333334test('has one browse action for remote folders', () => {335const provider = createProvider(disposables, connection);336337assert.strictEqual(provider.browseActions.length, 1);338assert.ok(provider.browseActions[0].label.includes('Folders'));339assert.strictEqual(provider.browseActions[0].providerId, provider.id);340});341342// ---- Session listing via notifications -------343344test('onDidChangeSessions fires when session added notification arrives', () => {345const provider = createProvider(disposables, connection);346const changes: ISessionChangeEvent[] = [];347disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));348349fireSessionAdded(connection, 'notif-1', { title: 'Notif Session' });350351assert.strictEqual(changes.length, 1);352assert.strictEqual(changes[0].added.length, 1);353assert.strictEqual(changes[0].added[0].title.get(), 'Notif Session');354});355356test('session added notifications ingest any advertised agent provider', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {357connection.setAgents([358{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo,359{ provider: 'openai', displayName: 'OpenAI', description: '', models: [] } as AgentInfo,360]);361const provider = createProvider(disposables, connection);362363fireSessionAdded(connection, 'cop-1', { provider: 'copilotcli', title: 'Copilot Session' });364fireSessionAdded(connection, 'oai-1', { provider: 'openai', title: 'OpenAI Session' });365366const sessions = provider.getSessions();367assert.deepStrictEqual(368sessions.map(s => ({ title: s.title.get(), sessionType: s.sessionType })).sort((a, b) => a.title.localeCompare(b.title)),369[370{ title: 'Copilot Session', sessionType: COPILOT_CLI_SESSION_TYPE },371{ title: 'OpenAI Session', sessionType: 'openai' },372],373);374}));375376test('session removed notification removes from cache', () => {377const provider = createProvider(disposables, connection);378fireSessionAdded(connection, 'to-remove', { title: 'Removed' });379380const changes: ISessionChangeEvent[] = [];381disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));382383fireSessionRemoved(connection, '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, connection);391const changes: ISessionChangeEvent[] = [];392disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));393394fireSessionAdded(connection, 'dup-sess', { title: 'Dup' });395fireSessionAdded(connection, 'dup-sess', { title: 'Dup' });396397assert.strictEqual(changes.length, 1);398});399400test('uses project metadata as workspace group source', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {401const projectUri = URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/vscode');402const workingDirectory = URI.parse('vscode-agent-host://localhost__4321/file/-/tmp/copilot-worktrees/vscode-feature');403connection.addSession(createSession('project-1', {404summary: 'Project Session',405project: { uri: projectUri, displayName: 'vscode' },406workingDirectory,407}));408409const provider = createProvider(disposables, connection, { isWebPlatform: true });410provider.getSessions();411await timeout(0);412413const workspace = provider.getSessions()[0].workspace.get();414assert.deepStrictEqual({415label: workspace?.label,416repository: workspace?.repositories[0]?.uri.toString(),417workingDirectory: workspace?.repositories[0]?.workingDirectory?.toString(),418detail: workspace?.repositories[0]?.detail,419}, {420label: 'vscode',421repository: projectUri.toString(),422workingDirectory: workingDirectory.toString(),423detail: undefined,424});425}));426427test('session added converts file project URIs and preserves repository URLs', () => {428const provider = createProvider(disposables, connection);429430fireSessionAdded(connection, 'file-project', {431title: 'File Project',432project: { uri: 'file:///home/user/vscode', displayName: 'vscode' },433workingDirectory: 'file:///tmp/copilot-worktrees/vscode-feature',434});435fireSessionAdded(connection, 'url-project', {436title: 'URL Project',437project: { uri: 'https://github.com/microsoft/vscode', displayName: 'vscode' },438});439440const workspaces = provider.getSessions().map(session => session.workspace.get());441assert.deepStrictEqual(workspaces.map(workspace => workspace?.repositories[0]?.uri.toString()), [442'vscode-agent-host://localhost__4321/file/-/home/user/vscode',443'https://github.com/microsoft/vscode',444]);445});446447test('removing non-existent session is no-op', () => {448const provider = createProvider(disposables, connection);449const changes: ISessionChangeEvent[] = [];450disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));451452fireSessionRemoved(connection, 'does-not-exist');453454assert.strictEqual(changes.length, 0);455});456457// ---- Session listing via refresh -------458459test('getSessions populates from connection.listSessions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {460connection.addSession(createSession('list-1', { summary: 'First' }));461connection.addSession(createSession('list-2', { summary: 'Second' }));462463const provider = createProvider(disposables, connection);464const changes: ISessionChangeEvent[] = [];465disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));466467provider.getSessions();468await timeout(0);469470assert.ok(changes.length > 0);471const sessions = provider.getSessions();472assert.strictEqual(sessions.length, 2);473}));474475test('uses model metadata as selected model for listed sessions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {476connection.addSession(createSession('model-1', { summary: 'Model Session', model: 'claude-sonnet-4.5' }));477478const provider = createProvider(disposables, connection);479provider.getSessions();480await timeout(0);481482const session = provider.getSessions().find(s => s.title.get() === 'Model Session');483assert.strictEqual(session?.modelId.get(), 'remote-localhost__4321-copilotcli:claude-sonnet-4.5');484}));485486test('uses model metadata from session added notification', () => {487const provider = createProvider(disposables, connection);488fireSessionAdded(connection, 'notif-model', { title: 'Notif Model Session', model: 'gpt-5' });489490const session = provider.getSessions().find(s => s.title.get() === 'Notif Model Session');491assert.strictEqual(session?.modelId.get(), 'remote-localhost__4321-copilotcli:gpt-5');492});493494test('setModel updates existing session model and dispatches raw model', () => {495const provider = createProvider(disposables, connection);496fireSessionAdded(connection, 'set-model', { title: 'Set Model Session', model: 'old-model' });497498const session = provider.getSessions().find(s => s.title.get() === 'Set Model Session');499assert.ok(session);500501provider.setModel(session!.sessionId, 'remote-localhost__4321-copilotcli:new-model');502503assert.strictEqual(session!.modelId.get(), 'remote-localhost__4321-copilotcli:new-model');504assert.deepStrictEqual(connection.dispatchedActions.at(-1)?.action, {505type: ActionType.SessionModelChanged,506session: AgentSession.uri('copilotcli', 'set-model').toString(),507model: { id: 'new-model' },508});509});510511test('setModel preserves current model config when model id is unchanged', () => {512const provider = createProvider(disposables, connection);513fireSessionAdded(connection, 'set-model-config', { title: 'Set Model Config Session', model: 'configured-model', modelConfig: { thinkingLevel: 'high' } });514515const session = provider.getSessions().find(s => s.title.get() === 'Set Model Config Session');516assert.ok(session);517518provider.setModel(session!.sessionId, 'remote-localhost__4321-copilotcli:configured-model');519520assert.deepStrictEqual(connection.dispatchedActions.at(-1)?.action, {521type: ActionType.SessionModelChanged,522session: AgentSession.uri('copilotcli', 'set-model-config').toString(),523model: { id: 'configured-model', config: { thinkingLevel: 'high' } },524});525});526527// ---- Session lifecycle -------528529test('createNewSession returns session with correct fields', () => {530const provider = createProvider(disposables, connection, { isWebPlatform: true });531const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id);532533assert.strictEqual(session.providerId, provider.id);534assert.strictEqual(session.status.get(), SessionStatus.Untitled);535assert.ok(session.workspace.get());536assert.strictEqual(session.workspace.get()?.label, 'project');537// sessionType should be the logical type, not the resource scheme538assert.strictEqual(session.sessionType, provider.sessionTypes[0].id);539assert.deepStrictEqual(provider.getSessionConfig(session.sessionId), { schema: { type: 'object', properties: {} }, values: {} });540});541542test('createNewSession clears session config when resolving config is unavailable', async () => {543connection.failResolveSessionConfig = true;544const provider = createProvider(disposables, connection, { isWebPlatform: true });545const workspaceUri = URI.parse('vscode-agent-host://auth/home/user/project');546const session = provider.createNewSession(workspaceUri, provider.sessionTypes[0].id);547const resolved = provider.getSessionByResource(session.resource);548549assert.deepStrictEqual({550listedSessions: provider.getSessions().length,551resolvedResource: resolved?.resource.toString(),552resolvedWorkspaceLabel: resolved?.workspace.get()?.label,553}, {554listedSessions: 0,555resolvedResource: session.resource.toString(),556resolvedWorkspaceLabel: 'project',557});558});559560test('clearConnection clears pending new session config', () => {561const provider = createProvider(disposables, connection);562563const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id);564provider.clearConnection();565566assert.deepStrictEqual({567resolved: provider.getSessionByResource(session.resource),568config: provider.getSessionConfig(session.sessionId),569}, {570resolved: undefined,571config: undefined,572});573});574575// ---- Session actions -------576577test('deleteSession calls disposeSession with backend agent URI and removes from cache', async () => {578const provider = createProvider(disposables, connection);579fireSessionAdded(connection, 'del-sess', { title: 'To Delete' });580581const sessions = provider.getSessions();582const target = sessions.find((s) => s.title.get() === 'To Delete');583assert.ok(target, 'Session should exist');584585await provider.deleteSession(target!.sessionId);586587assert.strictEqual(connection.disposedSessions.length, 1);588// The disposed URI must be a backend agent session URI (copilot://del-sess),589// not the UI resource (remote-localhost_4321-copilot:///del-sess)590const disposedUri = connection.disposedSessions[0];591assert.strictEqual(AgentSession.provider(disposedUri), 'copilotcli');592assert.strictEqual(AgentSession.id(disposedUri), 'del-sess');593// Session should no longer appear in getSessions594const remaining = provider.getSessions();595assert.strictEqual(remaining.find((s) => s.title.get() === 'To Delete'), undefined);596});597598// ---- Rename -------599600test('renameSession dispatches SessionTitleChanged action with correct session URI', async () => {601const provider = createProvider(disposables, connection);602fireSessionAdded(connection, 'rename-sess', { title: 'Old Title' });603604const sessions = provider.getSessions();605const target = sessions.find((s) => s.title.get() === 'Old Title');606assert.ok(target, 'Session should exist');607608await provider.renameChat(target!.sessionId, target!.resource, 'New Title');609610assert.strictEqual(connection.dispatchedActions.length, 1);611const dispatched = connection.dispatchedActions[0];612assert.strictEqual(dispatched.action.type, ActionType.SessionTitleChanged);613assert.strictEqual((dispatched.action as { title: string }).title, 'New Title');614// The session URI in the action must be the backend agent session URI615const actionSession = (dispatched.action as { session: string }).session;616assert.strictEqual(AgentSession.provider(actionSession), 'copilotcli');617assert.strictEqual(AgentSession.id(actionSession), 'rename-sess');618assert.strictEqual(dispatched.clientId, 'test-client-1');619});620621test('renameSession updates local title optimistically', async () => {622const provider = createProvider(disposables, connection);623fireSessionAdded(connection, 'rename-opt', { title: 'Before' });624625const sessions = provider.getSessions();626const target = sessions.find((s) => s.title.get() === 'Before');627assert.ok(target);628629await provider.renameChat(target!.sessionId, target!.resource, 'After');630631assert.strictEqual(target!.title.get(), 'After');632});633634test('renameSession is no-op for unknown chatId', async () => {635const provider = createProvider(disposables, connection);636await provider.renameChat('nonexistent-id', URI.parse('test://nonexistent'), 'Ignored');637638assert.strictEqual(connection.dispatchedActions.length, 0);639});640641test('renameSession increments clientSeq on successive calls', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {642connection.addSession(createSession('seq-sess', { summary: 'Seq Test' }));643const provider = createProvider(disposables, connection);644provider.getSessions();645await timeout(0);646647const sessions = provider.getSessions();648const target = sessions.find((s) => s.title.get() === 'Seq Test');649assert.ok(target);650651await provider.renameChat(target!.sessionId, target!.resource, 'Title 1');652await provider.renameChat(target!.sessionId, target!.resource, 'Title 2');653654assert.strictEqual(connection.dispatchedActions.length, 2);655assert.strictEqual(connection.dispatchedActions[0].clientSeq, 0);656assert.strictEqual(connection.dispatchedActions[1].clientSeq, 1);657}));658659test('server-echoed SessionTitleChanged updates cached title', () => {660const provider = createProvider(disposables, connection);661fireSessionAdded(connection, 'echo-sess', { title: 'Original' });662663const sessions = provider.getSessions();664const target = sessions.find((s) => s.title.get() === 'Original');665assert.ok(target);666667const changes: ISessionChangeEvent[] = [];668disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));669670// Simulate the server echoing a title change (from auto-generation or another client)671connection.fireAction({672action: {673type: ActionType.SessionTitleChanged,674session: AgentSession.uri('copilotcli', 'echo-sess').toString(),675title: 'Server Title',676},677serverSeq: 1,678origin: undefined,679} as ActionEnvelope);680681assert.strictEqual(target!.title.get(), 'Server Title');682assert.strictEqual(changes.length, 1);683assert.strictEqual(changes[0].changed.length, 1);684});685686test('server-echoed SessionModelChanged updates cached model', () => {687const provider = createProvider(disposables, connection);688fireSessionAdded(connection, 'model-change', { title: 'Model Change', model: 'old-model' });689690const target = provider.getSessions().find(s => s.title.get() === 'Model Change');691assert.ok(target);692693const changes: ISessionChangeEvent[] = [];694disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));695696connection.fireAction({697action: {698type: ActionType.SessionModelChanged,699session: AgentSession.uri('copilotcli', 'model-change').toString(),700model: { id: 'new-model' } satisfies ModelSelection,701},702serverSeq: 1,703origin: undefined,704} as ActionEnvelope);705706assert.strictEqual(target!.modelId.get(), 'remote-localhost__4321-copilotcli:new-model');707assert.strictEqual(changes.length, 1);708assert.strictEqual(changes[0].changed.length, 1);709});710711test('renamed title survives session refresh from listSessions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {712// Simulate server persisting the renamed title: after rename, listSessions713// returns the updated summary714connection.addSession(createSession('persist-sess', { summary: 'Original Title' }));715const provider = createProvider(disposables, connection);716provider.getSessions();717await timeout(0);718719// Verify initial title720let sessions = provider.getSessions();721let target = sessions.find((s) => s.title.get() === 'Original Title');722assert.ok(target, 'Session should exist with original title');723724// Simulate server updating the summary (as would happen after persist + reload)725connection.addSession(createSession('persist-sess', { summary: 'Renamed Title', modifiedTime: 5000 }));726727// Trigger refresh via turnComplete action (simulates what happens on reload)728connection.fireAction({729action: {730type: 'session/turnComplete',731session: AgentSession.uri('copilotcli', 'persist-sess').toString(),732},733serverSeq: 1,734origin: undefined,735} as ActionEnvelope);736737await timeout(0);738739sessions = provider.getSessions();740target = sessions.find((s) => s.title.get() === 'Renamed Title');741assert.ok(target, 'Session should have renamed title after refresh');742}));743744// ---- Send -------745746test('new session stays loading when required config is missing', async () => {747connection.resolveSessionConfigResult = {748schema: { type: 'object', required: ['branch'], properties: { branch: { type: 'string', title: 'Branch', enum: ['main'] } } },749values: {},750};751const provider = createProvider(disposables, connection);752const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id);753await waitForSessionConfig(provider, session.sessionId, config => config?.schema.required?.includes('branch') === true);754755assert.strictEqual(session.loading.get(), true);756});757758test('cached session loading reflects authenticationPending', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {759connection.addSession(createSession('cached-auth', { summary: 'Cached' }));760const provider = createProvider(disposables, connection);761await timeout(0);762763const session = provider.getSessions().find(s => s.title.get() === 'Cached');764assert.ok(session);765// Default at construction is `true`; clear it and verify.766assert.strictEqual(session!.loading.get(), true);767768provider.setAuthenticationPending(false);769assert.strictEqual(session!.loading.get(), false);770771// Sticky: a subsequent re-auth pass must not flicker the UI back to loading.772provider.setAuthenticationPending(true);773assert.strictEqual(session!.loading.get(), false);774}));775776test('unpublishCachedSessions hides sessions but retains persisted cache', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {777const storageService = disposables.add(new InMemoryStorageService());778connection.addSession(createSession('keep-me', { summary: 'Keep Me' }));779const provider = createProvider(disposables, connection, { storageService });780await timeout(0);781assert.strictEqual(provider.getSessions().length, 1);782783const events: ISessionChangeEvent[] = [];784disposables.add(provider.onDidChangeSessions(e => events.push(e)));785786provider.unpublishCachedSessions();787788// Sessions are hidden from the listing immediately.789assert.deepStrictEqual(790{791sessionCount: provider.getSessions().length,792eventRemovedTitles: events.flatMap(e => e.removed.map(s => s.title.get())),793},794{ sessionCount: 0, eventRemovedTitles: ['Keep Me'] },795);796797// Flush triggers onWillSaveState; the metadata must survive so the798// session re-serializes instead of being dropped from storage.799await storageService.flush();800801const provider2 = createProvider(disposables, new MockAgentConnection(), { storageService, noConnection: true });802assert.deepStrictEqual(803provider2.getSessions().map(s => s.title.get()),804['Keep Me'],805);806}));807808test('setConnection after unpublishCachedSessions restores cached sessions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {809connection.addSession(createSession('restore-me', { summary: 'Restore Me' }));810const provider = createProvider(disposables, connection);811await timeout(0);812assert.strictEqual(provider.getSessions().length, 1);813814provider.unpublishCachedSessions();815assert.strictEqual(provider.getSessions().length, 0);816817// Simulate the host coming back online with a fresh connection that818// still reports the same session.819const reconnected = new MockAgentConnection();820disposables.add(toDisposable(() => reconnected.dispose()));821reconnected.addSession(createSession('restore-me', { summary: 'Restore Me' }));822provider.setConnection(reconnected);823await timeout(0);824825assert.deepStrictEqual(826provider.getSessions().map(s => s.title.get()),827['Restore Me'],828);829}));830831test('sendAndCreateChat throws for unknown session', async () => {832const provider = createProvider(disposables, connection);833await assert.rejects(834() => provider.sendAndCreateChat('nonexistent', { query: 'test' }),835/not found or not a new session/,836);837});838839test('sendAndCreateChat forwards resolved session config to chat service', async () => {840const sendOptions: IChatSendRequestOptions[] = [];841const provider = createProvider(disposables, connection, {842openSession: true,843sendRequest: async (_resource, _message, options): Promise<ChatSendResult> => {844if (options) {845sendOptions.push(options);846}847connection.addSession(createSession('created-from-send', { summary: 'Created From Send' }));848return { kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never };849},850});851const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id);852await timeout(0);853854await provider.sendAndCreateChat(session.sessionId, { query: 'hello' });855856assert.deepStrictEqual(sendOptions.map(options => options.agentHostSessionConfig), [{ isolation: 'worktree' }]);857});858859// ---- Session data adapter -------860861test('session adapter has correct workspace from working directory', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {862connection.addSession(createSession('ws-sess', { summary: 'WS Test', workingDirectory: URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/myrepo') }));863864const provider = createProvider(disposables, connection, { isWebPlatform: true });865provider.getSessions();866await timeout(0);867868const sessions = provider.getSessions();869const wsSession = sessions.find((s) => s.title.get() === 'WS Test');870assert.ok(wsSession, 'Session with working directory should exist');871872const workspace = wsSession!.workspace.get();873assert.ok(workspace, 'Workspace should be populated');874assert.strictEqual(workspace!.label, 'myrepo');875assert.strictEqual(workspace!.repositories[0].detail, undefined);876}));877878test('session adapter without working directory has no workspace', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {879connection.addSession(createSession('no-ws-sess', { summary: 'No WS' }));880881const provider = createProvider(disposables, connection);882provider.getSessions();883await timeout(0);884885const sessions = provider.getSessions();886const session = sessions.find((s) => s.title.get() === 'No WS');887assert.ok(session, 'Session should exist');888assert.strictEqual(session!.workspace.get(), undefined);889}));890891test('session adapter uses raw ID as fallback title', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {892connection.addSession(createSession('abcdef1234567890'));893894const provider = createProvider(disposables, connection);895provider.getSessions();896await timeout(0);897898const sessions = provider.getSessions();899const session = sessions[0];900assert.ok(session);901assert.strictEqual(session.title.get(), 'Session abcdef12');902}));903904// ---- Refresh on turnComplete -------905906test('turnComplete action triggers session refresh for matching provider', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {907connection.addSession(createSession('turn-sess', { summary: 'Before', modifiedTime: 1000 }));908909const provider = createProvider(disposables, connection);910provider.getSessions();911await timeout(0);912913// Update on connection side914connection.addSession(createSession('turn-sess', { summary: 'After', modifiedTime: 5000 }));915916const changes: ISessionChangeEvent[] = [];917disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));918919connection.fireAction({920action: {921type: 'session/turnComplete',922session: AgentSession.uri('copilotcli', 'turn-sess').toString(),923},924serverSeq: 1,925origin: undefined,926} as ActionEnvelope);927928await timeout(0);929930assert.ok(changes.length > 0);931const updatedSession = provider.getSessions().find((s) => s.title.get() === 'After');932assert.ok(updatedSession, 'Session should have updated title');933}));934935// ---- Running session config seeding (from SessionState.config) -------936937test('getSessionConfig seeds running config from session state subscription with full schema', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {938connection.addSession(createSession('seed-1', { summary: 'Seeded Session' }));939const provider = createProvider(disposables, connection);940provider.getSessions();941await timeout(0);942const session = provider.getSessions().find(s => s.title.get() === 'Seeded Session');943assert.ok(session);944945assert.strictEqual(provider.getSessionConfig(session!.sessionId), undefined);946947const config: SessionConfigState = {948schema: {949type: 'object',950properties: {951autoApprove: { type: 'string', title: 'Auto Approve', enum: ['default', 'autoApprove'], sessionMutable: true },952isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'], readOnly: true },953},954},955values: { autoApprove: 'default', isolation: 'worktree' },956};957const fakeState: SessionState = {958summary: { resource: AgentSession.uri('copilotcli', 'seed-1').toString(), provider: 'copilotcli', title: 'Seeded Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 },959lifecycle: SessionLifecycle.Ready,960turns: [],961config,962};963connection.setSessionState('seed-1', 'copilotcli', fakeState);964965await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default');966967// Full schema + values are retained; the JSONC settings editor relies968// on this to preserve non-mutable values through replace dispatches.969const seeded = provider.getSessionConfig(session!.sessionId);970assert.deepStrictEqual({971properties: Object.keys(seeded?.schema.properties ?? {}).sort(),972values: seeded?.values,973}, {974properties: ['autoApprove', 'isolation'],975values: { autoApprove: 'default', isolation: 'worktree' },976});977}));978979test('removing a session disposes its session-state subscription', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {980connection.addSession(createSession('seed-2', { summary: 'Sub Session' }));981const provider = createProvider(disposables, connection);982provider.getSessions();983await timeout(0);984const session = provider.getSessions().find(s => s.title.get() === 'Sub Session');985assert.ok(session);986987provider.getSessionConfig(session!.sessionId);988const sessionUriStr = AgentSession.uri('copilotcli', 'seed-2').toString();989assert.strictEqual(connection.sessionSubscribeCounts.get(sessionUriStr), 1);990assert.strictEqual(connection.sessionUnsubscribeCounts.get(sessionUriStr) ?? 0, 0);991992fireSessionRemoved(connection, 'seed-2');993994assert.strictEqual(connection.sessionUnsubscribeCounts.get(sessionUriStr), 1);995}));996997test('replacing the connection disposes all session-state subscriptions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {998connection.addSession(createSession('seed-3', { summary: 'Reconnect Session' }));999const provider = createProvider(disposables, connection);1000provider.getSessions();1001await timeout(0);1002const session = provider.getSessions().find(s => s.title.get() === 'Reconnect Session');1003assert.ok(session);10041005provider.getSessionConfig(session!.sessionId);1006const sessionUriStr = AgentSession.uri('copilotcli', 'seed-3').toString();1007assert.strictEqual(connection.sessionSubscribeCounts.get(sessionUriStr), 1);1008assert.strictEqual(connection.sessionUnsubscribeCounts.get(sessionUriStr) ?? 0, 0);10091010const newConnection = new MockAgentConnection();1011disposables.add(toDisposable(() => newConnection.dispose()));1012provider.setConnection(newConnection);10131014assert.strictEqual(connection.sessionUnsubscribeCounts.get(sessionUriStr), 1);1015}));10161017// ---- Non-web label formatting (native desktop) -------1018//1019// In the browser test runner `isWeb` is always `true`, so by default1020// every test above exercises the web branch (which drops the1021// `[<hostname>]` suffix because the titlebar host filter renders it1022// redundantly). These tests pin the non-web (desktop) behaviour where1023// the host suffix / host description must still appear.10241025test('non-web: resolveWorkspace includes [host] suffix in label', () => {1026const provider = createProvider(disposables, connection, { isWebPlatform: false });1027const uri = URI.parse('vscode-agent-host://auth/home/user/project');1028const ws = provider.resolveWorkspace(uri);10291030assert.ok(ws);1031assert.strictEqual(ws.label, 'project [Test Host]');1032});10331034test('non-web: session workspace from project metadata includes [host] suffix', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {1035const projectUri = URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/vscode');1036connection.addSession(createSession('project-1', {1037summary: 'Project Session',1038project: { uri: projectUri, displayName: 'vscode' },1039}));10401041const provider = createProvider(disposables, connection, { isWebPlatform: false });1042provider.getSessions();1043await timeout(0);10441045assert.strictEqual(provider.getSessions()[0].workspace.get()?.label, 'vscode [Test Host]');1046}));10471048test('non-web: session workspace from working directory includes [host] suffix', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {1049connection.addSession(createSession('ws-sess', {1050summary: 'WS Test',1051workingDirectory: URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/myrepo'),1052}));10531054const provider = createProvider(disposables, connection, { isWebPlatform: false });1055provider.getSessions();1056await timeout(0);10571058const wsSession = provider.getSessions().find(s => s.title.get() === 'WS Test');1059assert.strictEqual(wsSession?.workspace.get()?.label, 'myrepo [Test Host]');1060}));10611062test('non-web: createNewSession workspace label includes [host] suffix', () => {1063const provider = createProvider(disposables, connection, { isWebPlatform: false });1064const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id);10651066assert.strictEqual(session.workspace.get()?.label, 'project [Test Host]');1067});10681069test('non-web: session description is the host label', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {1070connection.addSession(createSession('desc-sess', { summary: 'Desc Test' }));10711072const provider = createProvider(disposables, connection, { isWebPlatform: false });1073provider.getSessions();1074await timeout(0);10751076const session = provider.getSessions().find(s => s.title.get() === 'Desc Test');1077const description = session?.description.get();1078assert.ok(description, 'description should be defined on non-web');1079// MarkdownString.appendText escapes spaces as — verify the1080// host label is present rather than the exact serialized form.1081assert.ok(description!.value.includes('Test') && description!.value.includes('Host'));1082}));10831084test('web: session description is undefined (host filter dropdown replaces it)', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {1085connection.addSession(createSession('desc-sess-web', { summary: 'Desc Web' }));10861087const provider = createProvider(disposables, connection, { isWebPlatform: true });1088provider.getSessions();1089await timeout(0);10901091const session = provider.getSessions().find(s => s.title.get() === 'Desc Web');1092assert.strictEqual(session?.description.get(), undefined);1093}));10941095});109610971098