Path: blob/main/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.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 { DisposableStore, Disposable } from '../../../../../base/common/lifecycle.js';7import { URI } from '../../../../../base/common/uri.js';8import { Emitter } from '../../../../../base/common/event.js';9import { constObservable, observableValue } from '../../../../../base/common/observable.js';10import { IAgentHostTerminalService } from '../../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js';11import { ITerminalProfileService } from '../../../../../workbench/contrib/terminal/common/terminal.js';12import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';13import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';14import { mock } from '../../../../../base/test/common/mock.js';15import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';16import { NullLogService, ILogService } from '../../../../../platform/log/common/log.js';17import { ITerminalInstance, ITerminalService } from '../../../../../workbench/contrib/terminal/browser/terminal.js';18import { ITerminalCapabilityStore, ICommandDetectionCapability, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js';19import { toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js';20import { AgentSessionProviders } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';21import { IChat, ISession } from '../../../../services/sessions/common/session.js';22import { Codicon } from '../../../../../base/common/codicons.js';23import { SessionsTerminalContribution } from '../../browser/sessionsTerminalContribution.js';24import { TestPathService } from '../../../../../workbench/test/browser/workbenchTestServices.js';25import { IPathService } from '../../../../../workbench/services/path/common/pathService.js';26import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';27import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js';28import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js';29import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';3031const HOME_DIR = URI.file('/home/user');3233class TestLogService extends NullLogService {34readonly traces: string[] = [];3536override trace(message: string, ...args: unknown[]): void {37this.traces.push([message, ...args].join(' '));38}39}4041type TestTerminalInstance = ITerminalInstance & {42_testCommandHistory: { timestamp: number }[];43_testSetDisposed(disposed: boolean): void;44_testSetShellLaunchConfig(shellLaunchConfig: ITerminalInstance['shellLaunchConfig']): void;45};4647function makeAgentSession(opts: {48repository?: URI;49worktree?: URI;50providerType?: string;51isArchived?: boolean;52sessionId?: string;53}): IActiveSession {54const repo = opts.repository || opts.worktree ? {55uri: opts.repository ?? opts.worktree!,56workingDirectory: opts.worktree,57detail: undefined,58baseBranchName: undefined,59} : undefined;60const chat: IChat = {61resource: URI.parse('file:///session'),62createdAt: new Date(),63title: observableValue('test.title', 'Test Session'),64updatedAt: observableValue('test.updatedAt', new Date()),65status: observableValue('test.status', 0),66changes: observableValue('test.changes', []),67modelId: observableValue('test.modelId', undefined),68mode: observableValue('test.mode', undefined),69isArchived: observableValue('test.isArchived', opts.isArchived ?? false),70isRead: observableValue('test.isRead', true),71lastTurnEnd: observableValue('test.lastTurnEnd', undefined),72description: observableValue('test.description', undefined),73};74const session: IActiveSession = {75sessionId: opts.sessionId ?? 'test:session',76resource: chat.resource,77providerId: 'test',78sessionType: opts.providerType ?? AgentSessionProviders.Local,79icon: Codicon.copilot,80createdAt: chat.createdAt,81workspace: observableValue('test.workspace', repo ? { label: 'test', icon: Codicon.repo, repositories: [repo], requiresWorkspaceTrust: false, } : undefined),82title: chat.title,83updatedAt: chat.updatedAt,84status: chat.status,85changes: chat.changes,86modelId: chat.modelId,87mode: chat.mode,88loading: observableValue('test.loading', false),89isArchived: chat.isArchived,90isRead: chat.isRead,91lastTurnEnd: chat.lastTurnEnd,92description: chat.description,93gitHubInfo: observableValue('test.gitHubInfo', undefined),94chats: observableValue('test.chats', [chat]),95activeChat: observableValue('test.activeChat', chat),96mainChat: chat,97capabilities: { supportsMultipleChats: false },98};99return session;100}101102function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerType?: string }): ISession {103const repo = opts.repository || opts.worktree ? {104uri: opts.repository ?? opts.worktree!,105workingDirectory: opts.worktree,106detail: undefined,107baseBranchName: undefined,108} : undefined;109const chat: IChat = {110resource: URI.parse('file:///session'),111createdAt: new Date(),112title: observableValue('test.title', 'Test Session'),113updatedAt: observableValue('test.updatedAt', new Date()),114status: observableValue('test.status', 0),115changes: observableValue('test.changes', []),116modelId: observableValue('test.modelId', undefined),117mode: observableValue('test.mode', undefined),118isArchived: observableValue('test.isArchived', false),119isRead: observableValue('test.isRead', true),120lastTurnEnd: observableValue('test.lastTurnEnd', undefined),121description: observableValue('test.description', undefined),122};123const session: ISession = {124sessionId: 'test:non-agent',125resource: chat.resource,126providerId: 'test',127sessionType: opts.providerType ?? AgentSessionProviders.Local,128icon: Codicon.copilot,129createdAt: chat.createdAt,130workspace: observableValue('test.workspace', repo ? { label: 'test', icon: Codicon.repo, repositories: [repo], requiresWorkspaceTrust: false, } : undefined),131title: chat.title,132updatedAt: chat.updatedAt,133status: chat.status,134changes: chat.changes,135modelId: chat.modelId,136mode: chat.mode,137loading: observableValue('test.loading', false),138isArchived: chat.isArchived,139isRead: chat.isRead,140lastTurnEnd: chat.lastTurnEnd,141description: chat.description,142gitHubInfo: observableValue('test.gitHubInfo', undefined),143chats: observableValue('test.chats', [chat]),144mainChat: chat,145capabilities: { supportsMultipleChats: false },146};147return session;148}149150function makeTerminalInstance(id: number, cwd: string): TestTerminalInstance {151const commandHistory: { timestamp: number }[] = [];152let isDisposed = false;153let shellLaunchConfig: ITerminalInstance['shellLaunchConfig'] = {} as ITerminalInstance['shellLaunchConfig'];154const capabilities = {155get(cap: TerminalCapability) {156if (cap === TerminalCapability.CommandDetection && commandHistory.length > 0) {157return { commands: commandHistory } as unknown as ICommandDetectionCapability;158}159return undefined;160}161} as ITerminalCapabilityStore;162163return {164instanceId: id,165get isDisposed() { return isDisposed; },166get shellLaunchConfig() { return shellLaunchConfig; },167getInitialCwd: () => Promise.resolve(cwd),168capabilities,169_testCommandHistory: commandHistory,170_testSetDisposed(disposed: boolean) {171isDisposed = disposed;172},173_testSetShellLaunchConfig(value: ITerminalInstance['shellLaunchConfig']) {174shellLaunchConfig = value;175},176} as unknown as TestTerminalInstance;177}178179function addCommandToInstance(instance: ITerminalInstance, timestamp: number): void {180(instance as TestTerminalInstance)._testCommandHistory.push({ timestamp });181}182183suite('SessionsTerminalContribution', () => {184const store = new DisposableStore();185let contribution: SessionsTerminalContribution;186let activeSessionObs: ReturnType<typeof observableValue<IActiveSession | undefined>>;187let onDidChangeSessions: Emitter<ISessionsChangeEvent>;188let onDidCreateInstance: Emitter<ITerminalInstance>;189190let createdTerminals: { cwd: URI }[];191let activeInstanceSet: number[];192let focusCalls: number;193let disposedInstances: ITerminalInstance[];194let nextInstanceId: number;195let terminalInstances: Map<number, ITerminalInstance>;196let backgroundedInstances: Set<number>;197let moveToBackgroundCalls: number[];198let showBackgroundCalls: number[];199let disposeOnCreatePaths: Set<string>;200let logService: TestLogService;201let allSessions: ISession[];202203setup(() => {204createdTerminals = [];205activeInstanceSet = [];206focusCalls = 0;207disposedInstances = [];208nextInstanceId = 1;209terminalInstances = new Map();210backgroundedInstances = new Set();211moveToBackgroundCalls = [];212showBackgroundCalls = [];213disposeOnCreatePaths = new Set();214logService = new TestLogService();215allSessions = [];216217const instantiationService = store.add(new TestInstantiationService());218219activeSessionObs = observableValue<IActiveSession | undefined>('activeSession', undefined);220onDidChangeSessions = store.add(new Emitter<ISessionsChangeEvent>());221onDidCreateInstance = store.add(new Emitter<ITerminalInstance>());222223instantiationService.stub(ILogService, logService);224225instantiationService.stub(ISessionsManagementService, new class extends mock<ISessionsManagementService>() {226override activeSession = activeSessionObs;227override readonly onDidChangeSessions = onDidChangeSessions.event;228override getSessions(): ISession[] { return [...allSessions]; }229});230231instantiationService.stub(ITerminalService, new class extends mock<ITerminalService>() {232override onDidCreateInstance = onDidCreateInstance.event;233override get instances(): readonly ITerminalInstance[] {234return [...terminalInstances.values()];235}236override get foregroundInstances(): readonly ITerminalInstance[] {237return [...terminalInstances.values()].filter(i => !backgroundedInstances.has(i.instanceId));238}239override async createTerminal(opts?: any): Promise<ITerminalInstance> {240const id = nextInstanceId++;241const cwdUri: URI | undefined = opts?.config?.cwd;242const cwdStr = cwdUri?.fsPath ?? '';243const instance = makeTerminalInstance(id, cwdStr);244createdTerminals.push({ cwd: opts?.config?.cwd });245terminalInstances.set(id, instance);246if (disposeOnCreatePaths.has(cwdStr)) {247instance._testSetDisposed(true);248terminalInstances.delete(id);249}250return instance;251}252override getInstanceFromId(id: number): ITerminalInstance | undefined {253return terminalInstances.get(id);254}255override setActiveInstance(instance: ITerminalInstance): void {256activeInstanceSet.push(instance.instanceId);257}258override async focusActiveInstance(): Promise<void> {259focusCalls++;260}261override async safeDisposeTerminal(instance: ITerminalInstance): Promise<void> {262disposedInstances.push(instance);263(instance as TestTerminalInstance)._testSetDisposed(true);264terminalInstances.delete(instance.instanceId);265backgroundedInstances.delete(instance.instanceId);266}267override moveToBackground(instance: ITerminalInstance): void {268backgroundedInstances.add(instance.instanceId);269moveToBackgroundCalls.push(instance.instanceId);270}271override async showBackgroundTerminal(instance: ITerminalInstance): Promise<void> {272backgroundedInstances.delete(instance.instanceId);273showBackgroundCalls.push(instance.instanceId);274}275});276277instantiationService.stub(IPathService, new TestPathService(HOME_DIR));278279instantiationService.stub(IAgentHostTerminalService, new class extends mock<IAgentHostTerminalService>() {280override readonly profiles = constObservable<never[]>([]);281override getProfileForConnection() { return undefined; }282override setDefaultCwd(): void { /* noop */ }283override async createTerminalForEntry() { return undefined; }284});285286instantiationService.stub(ITerminalProfileService, new class extends mock<ITerminalProfileService>() {287override overrideDefaultProfile() { return Disposable.None; }288});289290instantiationService.stub(ISessionsProvidersService, new class extends mock<ISessionsProvidersService>() {291override getProvider() { return undefined; }292});293294instantiationService.stub(IContextKeyService, store.add(new MockContextKeyService()));295296instantiationService.stub(IViewsService, new class extends mock<IViewsService>() {297override isViewVisible(): boolean { return false; }298override onDidChangeViewVisibility = store.add(new Emitter<{ id: string; visible: boolean }>()).event;299});300301contribution = store.add(instantiationService.createInstance(SessionsTerminalContribution));302});303304teardown(() => {305store.clear();306});307308ensureNoDisposablesAreLeakedInTestSuite();309310// --- Background provider: uses worktree/repository path ---311312test('creates a terminal at the worktree for a background session', async () => {313const worktreeUri = URI.file('/worktree');314const session = makeAgentSession({ worktree: worktreeUri, repository: URI.file('/repo'), providerType: AgentSessionProviders.Background });315activeSessionObs.set(session, undefined);316await tick();317318assert.strictEqual(createdTerminals.length, 1);319assert.strictEqual(createdTerminals[0].cwd.fsPath, worktreeUri.fsPath);320});321322test('falls back to repository when worktree is undefined for a background session', async () => {323const repoUri = URI.file('/repo');324const session = makeAgentSession({ repository: repoUri, providerType: AgentSessionProviders.Background });325activeSessionObs.set(session, undefined);326await tick();327328assert.strictEqual(createdTerminals.length, 1);329assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath);330});331332// --- Claude provider: also uses worktree/repository path ---333334test('creates a terminal at the worktree for a Claude session', async () => {335const worktreeUri = URI.file('/worktree');336const session = makeAgentSession({ worktree: worktreeUri, repository: URI.file('/repo'), providerType: AgentSessionProviders.Claude });337activeSessionObs.set(session, undefined);338await tick();339340assert.strictEqual(createdTerminals.length, 1);341assert.strictEqual(createdTerminals[0].cwd.fsPath, worktreeUri.fsPath);342});343344test('falls back to repository when worktree is undefined for a Claude session', async () => {345const repoUri = URI.file('/repo');346const session = makeAgentSession({ repository: repoUri, providerType: AgentSessionProviders.Claude });347activeSessionObs.set(session, undefined);348await tick();349350assert.strictEqual(createdTerminals.length, 1);351assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath);352});353354// --- Non-background providers: use home directory ---355356test('uses home directory for a cloud agent session', async () => {357const session = makeAgentSession({ worktree: URI.file('/worktree'), repository: URI.file('/repo'), providerType: AgentSessionProviders.Cloud });358activeSessionObs.set(session, undefined);359await tick();360361assert.strictEqual(createdTerminals.length, 1);362assert.strictEqual(createdTerminals[0].cwd.fsPath, HOME_DIR.fsPath);363});364365test('uses home directory for a local agent session', async () => {366const session = makeAgentSession({ worktree: URI.file('/worktree'), providerType: AgentSessionProviders.Local });367activeSessionObs.set(session, undefined);368await tick();369370assert.strictEqual(createdTerminals.length, 1);371assert.strictEqual(createdTerminals[0].cwd.fsPath, HOME_DIR.fsPath);372});373374test('uses home directory for a non-agent session', async () => {375const session = makeNonAgentSession({ repository: URI.file('/repo') });376activeSessionObs.set(session as IActiveSession, undefined);377await tick();378379assert.strictEqual(createdTerminals.length, 1);380assert.strictEqual(createdTerminals[0].cwd.fsPath, HOME_DIR.fsPath);381});382383test('does not recreate terminal when multiple non-background sessions share the home directory', async () => {384const session1 = makeAgentSession({ providerType: AgentSessionProviders.Cloud });385activeSessionObs.set(session1, undefined);386await tick();387assert.strictEqual(createdTerminals.length, 1);388389// Different non-background session — same home dir, no new terminal390const session2 = makeAgentSession({ providerType: AgentSessionProviders.Local });391activeSessionObs.set(session2, undefined);392await tick();393assert.strictEqual(createdTerminals.length, 1);394});395396test('does not create a terminal when there is no active session', async () => {397activeSessionObs.set(undefined, undefined);398await tick();399400assert.strictEqual(createdTerminals.length, 0);401});402403test('does not recreate terminal for the same path', async () => {404const worktreeUri = URI.file('/worktree');405const session1 = makeAgentSession({ worktree: worktreeUri, providerType: AgentSessionProviders.Background });406activeSessionObs.set(session1, undefined);407await tick();408409assert.strictEqual(createdTerminals.length, 1);410411// Setting a different session with the same worktree should not create a new terminal412const session2 = makeAgentSession({ worktree: worktreeUri, providerType: AgentSessionProviders.Background });413activeSessionObs.set(session2, undefined);414await tick();415416assert.strictEqual(createdTerminals.length, 1);417});418419test('creates new terminal when switching to a different background path', async () => {420const worktree1 = URI.file('/worktree1');421const worktree2 = URI.file('/worktree2');422423activeSessionObs.set(makeAgentSession({ worktree: worktree1, providerType: AgentSessionProviders.Background }), undefined);424await tick();425426activeSessionObs.set(makeAgentSession({ worktree: worktree2, providerType: AgentSessionProviders.Background }), undefined);427await tick();428429assert.strictEqual(createdTerminals.length, 2);430assert.strictEqual(createdTerminals[1].cwd.fsPath, worktree2.fsPath);431});432433// --- ensureTerminal ---434435test('ensureTerminal creates terminal and sets it active', async () => {436const cwd = URI.file('/test-cwd');437await contribution.ensureTerminal(cwd, false);438439assert.strictEqual(createdTerminals.length, 1);440assert.strictEqual(createdTerminals[0].cwd.fsPath, cwd.fsPath);441assert.strictEqual(activeInstanceSet.length, 1);442assert.strictEqual(focusCalls, 0);443});444445test('ensureTerminal focuses when requested', async () => {446const cwd = URI.file('/test-cwd');447await contribution.ensureTerminal(cwd, true);448449assert.strictEqual(focusCalls, 1);450});451452test('ensureTerminal reuses existing terminal for same path', async () => {453const cwd = URI.file('/test-cwd');454await contribution.ensureTerminal(cwd, false);455await contribution.ensureTerminal(cwd, false);456457assert.strictEqual(createdTerminals.length, 1, 'should reuse the existing terminal');458assert.strictEqual(activeInstanceSet.length, 1, 'should only set active instance on creation');459});460461test('ensureTerminal creates new terminal for different path', async () => {462await contribution.ensureTerminal(URI.file('/cwd1'), false);463await contribution.ensureTerminal(URI.file('/cwd2'), false);464465assert.strictEqual(createdTerminals.length, 2);466});467468test('ensureTerminal path comparison is case-insensitive', async () => {469await contribution.ensureTerminal(URI.file('/Test/CWD'), false);470await contribution.ensureTerminal(URI.file('/test/cwd'), false);471472assert.strictEqual(createdTerminals.length, 1, 'should match case-insensitively');473});474475test('ensureTerminal does not activate a terminal disposed during creation', async () => {476const cwd = URI.file('/test-cwd');477disposeOnCreatePaths.add(cwd.fsPath);478479const instances = await contribution.ensureTerminal(cwd, false);480481assert.strictEqual(instances.length, 0);482assert.strictEqual(activeInstanceSet.length, 0);483assert.ok(logService.traces.some(message => message.includes(`Cannot activate created terminal for ${cwd.fsPath}; terminal 1 is no longer available`)));484});485486// --- onDidChangeSessions (archived) ---487488test('closes terminals when session is archived', async () => {489const worktreeUri = URI.file('/worktree');490await contribution.ensureTerminal(worktreeUri, false);491492assert.strictEqual(createdTerminals.length, 1);493494const session = makeAgentSession({495isArchived: true,496worktree: worktreeUri,497providerType: AgentSessionProviders.Background,498});499onDidChangeSessions.fire({ added: [], removed: [], changed: [session] });500await tick();501502assert.strictEqual(disposedInstances.length, 1);503});504505test('does not close terminals when session is not archived', async () => {506const worktreeUri = URI.file('/worktree');507await contribution.ensureTerminal(worktreeUri, false);508509const session = makeAgentSession({510isArchived: false,511worktree: worktreeUri,512});513onDidChangeSessions.fire({ added: [], removed: [], changed: [session] });514await tick();515516assert.strictEqual(disposedInstances.length, 0);517});518519test('does not close terminals when archived session has no worktree', async () => {520const worktreeUri = URI.file('/worktree');521await contribution.ensureTerminal(worktreeUri, false);522523const session = makeAgentSession({ isArchived: true });524onDidChangeSessions.fire({ added: [], removed: [], changed: [session] });525await tick();526527assert.strictEqual(disposedInstances.length, 0);528});529530test('closes terminals when archived session has only a repository (no worktree)', async () => {531const repoUri = URI.file('/repo');532const session = makeAgentSession({ repository: repoUri, providerType: AgentSessionProviders.Background, isArchived: false });533activeSessionObs.set(session, undefined);534await tick();535536assert.strictEqual(createdTerminals.length, 1);537assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath);538539const archivedSession = makeAgentSession({ repository: repoUri, providerType: AgentSessionProviders.Background, isArchived: true });540onDidChangeSessions.fire({ added: [], removed: [], changed: [archivedSession] });541await tick();542543assert.strictEqual(disposedInstances.length, 1);544});545546test('closes terminals when session is removed', async () => {547const worktreeUri = URI.file('/worktree');548await contribution.ensureTerminal(worktreeUri, false);549550assert.strictEqual(createdTerminals.length, 1);551552const session = makeAgentSession({ worktree: worktreeUri, providerType: AgentSessionProviders.Background });553onDidChangeSessions.fire({ added: [], removed: [session], changed: [] });554await tick();555556assert.strictEqual(disposedInstances.length, 1);557});558559test('does not close terminal when another live session still owns the cwd (replace case)', async () => {560const worktreeUri = URI.file('/worktree');561await contribution.ensureTerminal(worktreeUri, false);562563// Simulate the onDidReplaceSession flow: `from` (untitled) is reported as564// removed while `to` (committed) is still live at the same cwd.565const fromSession = makeAgentSession({ sessionId: 'test:untitled', worktree: worktreeUri, providerType: AgentSessionProviders.Background });566const toSession = makeAgentSession({ sessionId: 'test:committed', worktree: worktreeUri, providerType: AgentSessionProviders.Background });567allSessions = [toSession];568569onDidChangeSessions.fire({ added: [], removed: [fromSession], changed: [toSession] });570await tick();571572assert.strictEqual(disposedInstances.length, 0, 'terminal should be kept alive for the surviving session');573});574575test('does not close terminal when archiving one of two sessions sharing a cwd', async () => {576const worktreeUri = URI.file('/worktree');577await contribution.ensureTerminal(worktreeUri, false);578579const liveSession = makeAgentSession({ sessionId: 'test:live', worktree: worktreeUri, providerType: AgentSessionProviders.Background });580const archivedSession = makeAgentSession({ sessionId: 'test:archived', worktree: worktreeUri, providerType: AgentSessionProviders.Background, isArchived: true });581allSessions = [liveSession, archivedSession];582583onDidChangeSessions.fire({ added: [], removed: [], changed: [archivedSession] });584await tick();585586assert.strictEqual(disposedInstances.length, 0, 'terminal should be kept for the still-live session');587});588589test('closes terminal when the only session at a cwd is removed even if other live sessions exist elsewhere', async () => {590const worktreeUri = URI.file('/worktree');591await contribution.ensureTerminal(worktreeUri, false);592593const otherLive = makeAgentSession({ sessionId: 'test:other', worktree: URI.file('/other'), providerType: AgentSessionProviders.Background });594const removedSession = makeAgentSession({ sessionId: 'test:gone', worktree: worktreeUri, providerType: AgentSessionProviders.Background });595allSessions = [otherLive];596597onDidChangeSessions.fire({ added: [], removed: [removedSession], changed: [] });598await tick();599600assert.strictEqual(disposedInstances.length, 1, 'no live session owns this cwd, terminal should be closed');601});602603// --- switching back to previously used path reuses terminal ---604605test('switching back to a previously used background path reuses the existing terminal', async () => {606const cwd1 = URI.file('/cwd1');607const cwd2 = URI.file('/cwd2');608609activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined);610await tick();611assert.strictEqual(createdTerminals.length, 1);612613activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined);614await tick();615assert.strictEqual(createdTerminals.length, 2);616617// Switch back to cwd1 - should reuse terminal, not create a new one618activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined);619await tick();620assert.strictEqual(createdTerminals.length, 2, 'should reuse the terminal for cwd1');621});622623// --- Terminal visibility management (cwd-based) ---624625test('hides terminals from previous session when switching to a new session', async () => {626const cwd1 = URI.file('/cwd1');627const cwd2 = URI.file('/cwd2');628629activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined);630await tick();631assert.strictEqual(createdTerminals.length, 1);632633activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined);634await tick();635636// The first terminal (id=1) should have been moved to background637assert.ok(moveToBackgroundCalls.includes(1), 'terminal for cwd1 should be backgrounded');638assert.ok(backgroundedInstances.has(1), 'terminal for cwd1 should remain backgrounded');639});640641test('shows previously hidden terminals when switching back to their session', async () => {642const cwd1 = URI.file('/cwd1');643const cwd2 = URI.file('/cwd2');644645activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined);646await tick();647648activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined);649await tick();650651// Switch back to cwd1652activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined);653await tick();654655// Terminal for cwd1 (id=1) should be shown again656assert.ok(showBackgroundCalls.includes(1), 'terminal for cwd1 should be shown');657assert.ok(!backgroundedInstances.has(1), 'terminal for cwd1 should be foreground');658// Terminal for cwd2 (id=2) should now be backgrounded659assert.ok(backgroundedInstances.has(2), 'terminal for cwd2 should be backgrounded');660});661662test('only terminals of the active session are visible after multiple switches', async () => {663const cwd1 = URI.file('/cwd1');664const cwd2 = URI.file('/cwd2');665const cwd3 = URI.file('/cwd3');666667activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined);668await tick();669670activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined);671await tick();672673activeSessionObs.set(makeAgentSession({ worktree: cwd3, providerType: AgentSessionProviders.Background }), undefined);674await tick();675676// Only terminal for cwd3 (id=3) should be foreground677assert.ok(backgroundedInstances.has(1), 'terminal for cwd1 should be backgrounded');678assert.ok(backgroundedInstances.has(2), 'terminal for cwd2 should be backgrounded');679assert.ok(!backgroundedInstances.has(3), 'terminal for cwd3 should be foreground');680});681682test('shows pre-existing terminal with matching cwd instead of creating a new one', async () => {683// Manually add a terminal that already exists with a matching cwd684const cwd = URI.file('/worktree');685const existingInstance = makeTerminalInstance(nextInstanceId++, cwd.fsPath);686terminalInstances.set(existingInstance.instanceId, existingInstance);687backgroundedInstances.add(existingInstance.instanceId);688689activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined);690await tick();691692assert.strictEqual(createdTerminals.length, 0, 'should reuse existing terminal, not create a new one');693assert.ok(showBackgroundCalls.includes(existingInstance.instanceId), 'should show the existing terminal');694});695696test('does not background a restored terminal that is disposed before cwd resolves', async () => {697let resolveInitialCwd: ((cwd: string) => void) | undefined;698const restoredInstance = makeTerminalInstance(nextInstanceId++, '/restored');699restoredInstance._testSetShellLaunchConfig({ attachPersistentProcess: {} as never } as ITerminalInstance['shellLaunchConfig']);700restoredInstance.getInitialCwd = () => new Promise<string>(resolve => {701resolveInitialCwd = resolve;702});703terminalInstances.set(restoredInstance.instanceId, restoredInstance);704705activeSessionObs.set(makeAgentSession({ worktree: URI.file('/active'), providerType: AgentSessionProviders.Background }), undefined);706await tick();707708onDidCreateInstance.fire(restoredInstance);709restoredInstance._testSetDisposed(true);710terminalInstances.delete(restoredInstance.instanceId);711resolveInitialCwd?.('/other');712await tick();713714assert.ok(!moveToBackgroundCalls.includes(restoredInstance.instanceId), 'disposed restored terminal should not be backgrounded');715assert.ok(logService.traces.some(message => message.includes('Cannot hide restored terminal for /other; terminal') && message.includes('is no longer available')));716});717718test('hides pre-existing terminal with non-matching cwd when session changes', async () => {719// Manually add a terminal that already exists with a different cwd720const otherInstance = makeTerminalInstance(nextInstanceId++, '/other/path');721terminalInstances.set(otherInstance.instanceId, otherInstance);722723const cwd = URI.file('/worktree');724activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined);725await tick();726727assert.ok(moveToBackgroundCalls.includes(otherInstance.instanceId), 'non-matching terminal should be backgrounded');728});729730test('ensureTerminal finds a backgrounded terminal instead of creating a new one', async () => {731const cwd = URI.file('/test-cwd');732await contribution.ensureTerminal(cwd, false);733const instanceId = activeInstanceSet[0];734735// Manually background it736backgroundedInstances.add(instanceId);737738// ensureTerminal should find it by cwd, not create a new one739const result = await contribution.ensureTerminal(cwd, false);740741assert.strictEqual(createdTerminals.length, 1, 'should not create a new terminal');742assert.strictEqual(result[0].instanceId, instanceId, 'should return the existing backgrounded terminal');743});744745test('visibility is determined by initial cwd, not by stored IDs', async () => {746// Create a terminal externally (not via ensureTerminal) with a known cwd747const cwd1 = URI.file('/cwd1');748const cwd2 = URI.file('/cwd2');749const ext1 = makeTerminalInstance(nextInstanceId++, cwd1.fsPath);750const ext2 = makeTerminalInstance(nextInstanceId++, cwd2.fsPath);751terminalInstances.set(ext1.instanceId, ext1);752terminalInstances.set(ext2.instanceId, ext2);753754// Switch to cwd1 — ext1 should stay visible, ext2 should be hidden755activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined);756await tick();757758assert.ok(!backgroundedInstances.has(ext1.instanceId), 'ext1 should be foreground (matching cwd)');759assert.ok(backgroundedInstances.has(ext2.instanceId), 'ext2 should be backgrounded (non-matching cwd)');760761// Switch to cwd2 — ext2 should be shown, ext1 should be hidden762activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined);763await tick();764765assert.ok(backgroundedInstances.has(ext1.instanceId), 'ext1 should now be backgrounded');766assert.ok(!backgroundedInstances.has(ext2.instanceId), 'ext2 should now be foreground');767});768769// --- Most-recent-command active terminal selection ---770771test('sets the terminal with the most recent command as active after visibility update', async () => {772const cwd = URI.file('/worktree');773const t1 = makeTerminalInstance(nextInstanceId++, cwd.fsPath);774const t2 = makeTerminalInstance(nextInstanceId++, cwd.fsPath);775terminalInstances.set(t1.instanceId, t1);776terminalInstances.set(t2.instanceId, t2);777778// t1 ran a command at timestamp 100, t2 at timestamp 200 (more recent)779addCommandToInstance(t1, 100);780addCommandToInstance(t2, 200);781782activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined);783await tick();784785// The most recent setActiveInstance call should be for t2786assert.strictEqual(activeInstanceSet.at(-1), t2.instanceId, 'should set the terminal with the most recent command as active');787});788789test('does not change active instance when no terminals have command history', async () => {790const cwd = URI.file('/worktree');791const t1 = makeTerminalInstance(nextInstanceId++, cwd.fsPath);792const t2 = makeTerminalInstance(nextInstanceId++, cwd.fsPath);793terminalInstances.set(t1.instanceId, t1);794terminalInstances.set(t2.instanceId, t2);795796const activeCountBefore = activeInstanceSet.length;797798activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined);799await tick();800801// No setActiveInstance calls from visibility update since no commands were run802assert.strictEqual(activeInstanceSet.length, activeCountBefore, 'should not call setActiveInstance when no command history exists');803});804805// --- Remote agent host sessions ---806807test('uses the unwrapped repository path for a background session with a remote agent host repository', async () => {808const remoteRepoUri = toAgentHostUri(URI.file('/Users/user/repo'), 'my-server');809const session = makeAgentSession({ repository: remoteRepoUri, providerType: AgentSessionProviders.Background });810activeSessionObs.set(session, undefined);811await tick();812813assert.strictEqual(createdTerminals.length, 1, 'should create a terminal at the unwrapped repository path');814assert.strictEqual(createdTerminals[0].cwd.fsPath, URI.file('/Users/user/repo').fsPath);815});816});817818function tick(): Promise<void> {819return new Promise(resolve => setTimeout(resolve, 0));820}821822823