Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { DisposableStore, Disposable } from '../../../../../base/common/lifecycle.js';
8
import { URI } from '../../../../../base/common/uri.js';
9
import { Emitter } from '../../../../../base/common/event.js';
10
import { constObservable, observableValue } from '../../../../../base/common/observable.js';
11
import { IAgentHostTerminalService } from '../../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js';
12
import { ITerminalProfileService } from '../../../../../workbench/contrib/terminal/common/terminal.js';
13
import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';
14
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
15
import { mock } from '../../../../../base/test/common/mock.js';
16
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
17
import { NullLogService, ILogService } from '../../../../../platform/log/common/log.js';
18
import { ITerminalInstance, ITerminalService } from '../../../../../workbench/contrib/terminal/browser/terminal.js';
19
import { ITerminalCapabilityStore, ICommandDetectionCapability, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js';
20
import { toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js';
21
import { AgentSessionProviders } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';
22
import { IChat, ISession } from '../../../../services/sessions/common/session.js';
23
import { Codicon } from '../../../../../base/common/codicons.js';
24
import { SessionsTerminalContribution } from '../../browser/sessionsTerminalContribution.js';
25
import { TestPathService } from '../../../../../workbench/test/browser/workbenchTestServices.js';
26
import { IPathService } from '../../../../../workbench/services/path/common/pathService.js';
27
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
28
import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js';
29
import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js';
30
import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
31
32
const HOME_DIR = URI.file('/home/user');
33
34
class TestLogService extends NullLogService {
35
readonly traces: string[] = [];
36
37
override trace(message: string, ...args: unknown[]): void {
38
this.traces.push([message, ...args].join(' '));
39
}
40
}
41
42
type TestTerminalInstance = ITerminalInstance & {
43
_testCommandHistory: { timestamp: number }[];
44
_testSetDisposed(disposed: boolean): void;
45
_testSetShellLaunchConfig(shellLaunchConfig: ITerminalInstance['shellLaunchConfig']): void;
46
};
47
48
function makeAgentSession(opts: {
49
repository?: URI;
50
worktree?: URI;
51
providerType?: string;
52
isArchived?: boolean;
53
sessionId?: string;
54
}): IActiveSession {
55
const repo = opts.repository || opts.worktree ? {
56
uri: opts.repository ?? opts.worktree!,
57
workingDirectory: opts.worktree,
58
detail: undefined,
59
baseBranchName: undefined,
60
} : undefined;
61
const chat: IChat = {
62
resource: URI.parse('file:///session'),
63
createdAt: new Date(),
64
title: observableValue('test.title', 'Test Session'),
65
updatedAt: observableValue('test.updatedAt', new Date()),
66
status: observableValue('test.status', 0),
67
changes: observableValue('test.changes', []),
68
modelId: observableValue('test.modelId', undefined),
69
mode: observableValue('test.mode', undefined),
70
isArchived: observableValue('test.isArchived', opts.isArchived ?? false),
71
isRead: observableValue('test.isRead', true),
72
lastTurnEnd: observableValue('test.lastTurnEnd', undefined),
73
description: observableValue('test.description', undefined),
74
};
75
const session: IActiveSession = {
76
sessionId: opts.sessionId ?? 'test:session',
77
resource: chat.resource,
78
providerId: 'test',
79
sessionType: opts.providerType ?? AgentSessionProviders.Local,
80
icon: Codicon.copilot,
81
createdAt: chat.createdAt,
82
workspace: observableValue('test.workspace', repo ? { label: 'test', icon: Codicon.repo, repositories: [repo], requiresWorkspaceTrust: false, } : undefined),
83
title: chat.title,
84
updatedAt: chat.updatedAt,
85
status: chat.status,
86
changes: chat.changes,
87
modelId: chat.modelId,
88
mode: chat.mode,
89
loading: observableValue('test.loading', false),
90
isArchived: chat.isArchived,
91
isRead: chat.isRead,
92
lastTurnEnd: chat.lastTurnEnd,
93
description: chat.description,
94
gitHubInfo: observableValue('test.gitHubInfo', undefined),
95
chats: observableValue('test.chats', [chat]),
96
activeChat: observableValue('test.activeChat', chat),
97
mainChat: chat,
98
capabilities: { supportsMultipleChats: false },
99
};
100
return session;
101
}
102
103
function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerType?: string }): ISession {
104
const repo = opts.repository || opts.worktree ? {
105
uri: opts.repository ?? opts.worktree!,
106
workingDirectory: opts.worktree,
107
detail: undefined,
108
baseBranchName: undefined,
109
} : undefined;
110
const chat: IChat = {
111
resource: URI.parse('file:///session'),
112
createdAt: new Date(),
113
title: observableValue('test.title', 'Test Session'),
114
updatedAt: observableValue('test.updatedAt', new Date()),
115
status: observableValue('test.status', 0),
116
changes: observableValue('test.changes', []),
117
modelId: observableValue('test.modelId', undefined),
118
mode: observableValue('test.mode', undefined),
119
isArchived: observableValue('test.isArchived', false),
120
isRead: observableValue('test.isRead', true),
121
lastTurnEnd: observableValue('test.lastTurnEnd', undefined),
122
description: observableValue('test.description', undefined),
123
};
124
const session: ISession = {
125
sessionId: 'test:non-agent',
126
resource: chat.resource,
127
providerId: 'test',
128
sessionType: opts.providerType ?? AgentSessionProviders.Local,
129
icon: Codicon.copilot,
130
createdAt: chat.createdAt,
131
workspace: observableValue('test.workspace', repo ? { label: 'test', icon: Codicon.repo, repositories: [repo], requiresWorkspaceTrust: false, } : undefined),
132
title: chat.title,
133
updatedAt: chat.updatedAt,
134
status: chat.status,
135
changes: chat.changes,
136
modelId: chat.modelId,
137
mode: chat.mode,
138
loading: observableValue('test.loading', false),
139
isArchived: chat.isArchived,
140
isRead: chat.isRead,
141
lastTurnEnd: chat.lastTurnEnd,
142
description: chat.description,
143
gitHubInfo: observableValue('test.gitHubInfo', undefined),
144
chats: observableValue('test.chats', [chat]),
145
mainChat: chat,
146
capabilities: { supportsMultipleChats: false },
147
};
148
return session;
149
}
150
151
function makeTerminalInstance(id: number, cwd: string): TestTerminalInstance {
152
const commandHistory: { timestamp: number }[] = [];
153
let isDisposed = false;
154
let shellLaunchConfig: ITerminalInstance['shellLaunchConfig'] = {} as ITerminalInstance['shellLaunchConfig'];
155
const capabilities = {
156
get(cap: TerminalCapability) {
157
if (cap === TerminalCapability.CommandDetection && commandHistory.length > 0) {
158
return { commands: commandHistory } as unknown as ICommandDetectionCapability;
159
}
160
return undefined;
161
}
162
} as ITerminalCapabilityStore;
163
164
return {
165
instanceId: id,
166
get isDisposed() { return isDisposed; },
167
get shellLaunchConfig() { return shellLaunchConfig; },
168
getInitialCwd: () => Promise.resolve(cwd),
169
capabilities,
170
_testCommandHistory: commandHistory,
171
_testSetDisposed(disposed: boolean) {
172
isDisposed = disposed;
173
},
174
_testSetShellLaunchConfig(value: ITerminalInstance['shellLaunchConfig']) {
175
shellLaunchConfig = value;
176
},
177
} as unknown as TestTerminalInstance;
178
}
179
180
function addCommandToInstance(instance: ITerminalInstance, timestamp: number): void {
181
(instance as TestTerminalInstance)._testCommandHistory.push({ timestamp });
182
}
183
184
suite('SessionsTerminalContribution', () => {
185
const store = new DisposableStore();
186
let contribution: SessionsTerminalContribution;
187
let activeSessionObs: ReturnType<typeof observableValue<IActiveSession | undefined>>;
188
let onDidChangeSessions: Emitter<ISessionsChangeEvent>;
189
let onDidCreateInstance: Emitter<ITerminalInstance>;
190
191
let createdTerminals: { cwd: URI }[];
192
let activeInstanceSet: number[];
193
let focusCalls: number;
194
let disposedInstances: ITerminalInstance[];
195
let nextInstanceId: number;
196
let terminalInstances: Map<number, ITerminalInstance>;
197
let backgroundedInstances: Set<number>;
198
let moveToBackgroundCalls: number[];
199
let showBackgroundCalls: number[];
200
let disposeOnCreatePaths: Set<string>;
201
let logService: TestLogService;
202
let allSessions: ISession[];
203
204
setup(() => {
205
createdTerminals = [];
206
activeInstanceSet = [];
207
focusCalls = 0;
208
disposedInstances = [];
209
nextInstanceId = 1;
210
terminalInstances = new Map();
211
backgroundedInstances = new Set();
212
moveToBackgroundCalls = [];
213
showBackgroundCalls = [];
214
disposeOnCreatePaths = new Set();
215
logService = new TestLogService();
216
allSessions = [];
217
218
const instantiationService = store.add(new TestInstantiationService());
219
220
activeSessionObs = observableValue<IActiveSession | undefined>('activeSession', undefined);
221
onDidChangeSessions = store.add(new Emitter<ISessionsChangeEvent>());
222
onDidCreateInstance = store.add(new Emitter<ITerminalInstance>());
223
224
instantiationService.stub(ILogService, logService);
225
226
instantiationService.stub(ISessionsManagementService, new class extends mock<ISessionsManagementService>() {
227
override activeSession = activeSessionObs;
228
override readonly onDidChangeSessions = onDidChangeSessions.event;
229
override getSessions(): ISession[] { return [...allSessions]; }
230
});
231
232
instantiationService.stub(ITerminalService, new class extends mock<ITerminalService>() {
233
override onDidCreateInstance = onDidCreateInstance.event;
234
override get instances(): readonly ITerminalInstance[] {
235
return [...terminalInstances.values()];
236
}
237
override get foregroundInstances(): readonly ITerminalInstance[] {
238
return [...terminalInstances.values()].filter(i => !backgroundedInstances.has(i.instanceId));
239
}
240
override async createTerminal(opts?: any): Promise<ITerminalInstance> {
241
const id = nextInstanceId++;
242
const cwdUri: URI | undefined = opts?.config?.cwd;
243
const cwdStr = cwdUri?.fsPath ?? '';
244
const instance = makeTerminalInstance(id, cwdStr);
245
createdTerminals.push({ cwd: opts?.config?.cwd });
246
terminalInstances.set(id, instance);
247
if (disposeOnCreatePaths.has(cwdStr)) {
248
instance._testSetDisposed(true);
249
terminalInstances.delete(id);
250
}
251
return instance;
252
}
253
override getInstanceFromId(id: number): ITerminalInstance | undefined {
254
return terminalInstances.get(id);
255
}
256
override setActiveInstance(instance: ITerminalInstance): void {
257
activeInstanceSet.push(instance.instanceId);
258
}
259
override async focusActiveInstance(): Promise<void> {
260
focusCalls++;
261
}
262
override async safeDisposeTerminal(instance: ITerminalInstance): Promise<void> {
263
disposedInstances.push(instance);
264
(instance as TestTerminalInstance)._testSetDisposed(true);
265
terminalInstances.delete(instance.instanceId);
266
backgroundedInstances.delete(instance.instanceId);
267
}
268
override moveToBackground(instance: ITerminalInstance): void {
269
backgroundedInstances.add(instance.instanceId);
270
moveToBackgroundCalls.push(instance.instanceId);
271
}
272
override async showBackgroundTerminal(instance: ITerminalInstance): Promise<void> {
273
backgroundedInstances.delete(instance.instanceId);
274
showBackgroundCalls.push(instance.instanceId);
275
}
276
});
277
278
instantiationService.stub(IPathService, new TestPathService(HOME_DIR));
279
280
instantiationService.stub(IAgentHostTerminalService, new class extends mock<IAgentHostTerminalService>() {
281
override readonly profiles = constObservable<never[]>([]);
282
override getProfileForConnection() { return undefined; }
283
override setDefaultCwd(): void { /* noop */ }
284
override async createTerminalForEntry() { return undefined; }
285
});
286
287
instantiationService.stub(ITerminalProfileService, new class extends mock<ITerminalProfileService>() {
288
override overrideDefaultProfile() { return Disposable.None; }
289
});
290
291
instantiationService.stub(ISessionsProvidersService, new class extends mock<ISessionsProvidersService>() {
292
override getProvider() { return undefined; }
293
});
294
295
instantiationService.stub(IContextKeyService, store.add(new MockContextKeyService()));
296
297
instantiationService.stub(IViewsService, new class extends mock<IViewsService>() {
298
override isViewVisible(): boolean { return false; }
299
override onDidChangeViewVisibility = store.add(new Emitter<{ id: string; visible: boolean }>()).event;
300
});
301
302
contribution = store.add(instantiationService.createInstance(SessionsTerminalContribution));
303
});
304
305
teardown(() => {
306
store.clear();
307
});
308
309
ensureNoDisposablesAreLeakedInTestSuite();
310
311
// --- Background provider: uses worktree/repository path ---
312
313
test('creates a terminal at the worktree for a background session', async () => {
314
const worktreeUri = URI.file('/worktree');
315
const session = makeAgentSession({ worktree: worktreeUri, repository: URI.file('/repo'), providerType: AgentSessionProviders.Background });
316
activeSessionObs.set(session, undefined);
317
await tick();
318
319
assert.strictEqual(createdTerminals.length, 1);
320
assert.strictEqual(createdTerminals[0].cwd.fsPath, worktreeUri.fsPath);
321
});
322
323
test('falls back to repository when worktree is undefined for a background session', async () => {
324
const repoUri = URI.file('/repo');
325
const session = makeAgentSession({ repository: repoUri, providerType: AgentSessionProviders.Background });
326
activeSessionObs.set(session, undefined);
327
await tick();
328
329
assert.strictEqual(createdTerminals.length, 1);
330
assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath);
331
});
332
333
// --- Claude provider: also uses worktree/repository path ---
334
335
test('creates a terminal at the worktree for a Claude session', async () => {
336
const worktreeUri = URI.file('/worktree');
337
const session = makeAgentSession({ worktree: worktreeUri, repository: URI.file('/repo'), providerType: AgentSessionProviders.Claude });
338
activeSessionObs.set(session, undefined);
339
await tick();
340
341
assert.strictEqual(createdTerminals.length, 1);
342
assert.strictEqual(createdTerminals[0].cwd.fsPath, worktreeUri.fsPath);
343
});
344
345
test('falls back to repository when worktree is undefined for a Claude session', async () => {
346
const repoUri = URI.file('/repo');
347
const session = makeAgentSession({ repository: repoUri, providerType: AgentSessionProviders.Claude });
348
activeSessionObs.set(session, undefined);
349
await tick();
350
351
assert.strictEqual(createdTerminals.length, 1);
352
assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath);
353
});
354
355
// --- Non-background providers: use home directory ---
356
357
test('uses home directory for a cloud agent session', async () => {
358
const session = makeAgentSession({ worktree: URI.file('/worktree'), repository: URI.file('/repo'), providerType: AgentSessionProviders.Cloud });
359
activeSessionObs.set(session, undefined);
360
await tick();
361
362
assert.strictEqual(createdTerminals.length, 1);
363
assert.strictEqual(createdTerminals[0].cwd.fsPath, HOME_DIR.fsPath);
364
});
365
366
test('uses home directory for a local agent session', async () => {
367
const session = makeAgentSession({ worktree: URI.file('/worktree'), providerType: AgentSessionProviders.Local });
368
activeSessionObs.set(session, undefined);
369
await tick();
370
371
assert.strictEqual(createdTerminals.length, 1);
372
assert.strictEqual(createdTerminals[0].cwd.fsPath, HOME_DIR.fsPath);
373
});
374
375
test('uses home directory for a non-agent session', async () => {
376
const session = makeNonAgentSession({ repository: URI.file('/repo') });
377
activeSessionObs.set(session as IActiveSession, undefined);
378
await tick();
379
380
assert.strictEqual(createdTerminals.length, 1);
381
assert.strictEqual(createdTerminals[0].cwd.fsPath, HOME_DIR.fsPath);
382
});
383
384
test('does not recreate terminal when multiple non-background sessions share the home directory', async () => {
385
const session1 = makeAgentSession({ providerType: AgentSessionProviders.Cloud });
386
activeSessionObs.set(session1, undefined);
387
await tick();
388
assert.strictEqual(createdTerminals.length, 1);
389
390
// Different non-background session — same home dir, no new terminal
391
const session2 = makeAgentSession({ providerType: AgentSessionProviders.Local });
392
activeSessionObs.set(session2, undefined);
393
await tick();
394
assert.strictEqual(createdTerminals.length, 1);
395
});
396
397
test('does not create a terminal when there is no active session', async () => {
398
activeSessionObs.set(undefined, undefined);
399
await tick();
400
401
assert.strictEqual(createdTerminals.length, 0);
402
});
403
404
test('does not recreate terminal for the same path', async () => {
405
const worktreeUri = URI.file('/worktree');
406
const session1 = makeAgentSession({ worktree: worktreeUri, providerType: AgentSessionProviders.Background });
407
activeSessionObs.set(session1, undefined);
408
await tick();
409
410
assert.strictEqual(createdTerminals.length, 1);
411
412
// Setting a different session with the same worktree should not create a new terminal
413
const session2 = makeAgentSession({ worktree: worktreeUri, providerType: AgentSessionProviders.Background });
414
activeSessionObs.set(session2, undefined);
415
await tick();
416
417
assert.strictEqual(createdTerminals.length, 1);
418
});
419
420
test('creates new terminal when switching to a different background path', async () => {
421
const worktree1 = URI.file('/worktree1');
422
const worktree2 = URI.file('/worktree2');
423
424
activeSessionObs.set(makeAgentSession({ worktree: worktree1, providerType: AgentSessionProviders.Background }), undefined);
425
await tick();
426
427
activeSessionObs.set(makeAgentSession({ worktree: worktree2, providerType: AgentSessionProviders.Background }), undefined);
428
await tick();
429
430
assert.strictEqual(createdTerminals.length, 2);
431
assert.strictEqual(createdTerminals[1].cwd.fsPath, worktree2.fsPath);
432
});
433
434
// --- ensureTerminal ---
435
436
test('ensureTerminal creates terminal and sets it active', async () => {
437
const cwd = URI.file('/test-cwd');
438
await contribution.ensureTerminal(cwd, false);
439
440
assert.strictEqual(createdTerminals.length, 1);
441
assert.strictEqual(createdTerminals[0].cwd.fsPath, cwd.fsPath);
442
assert.strictEqual(activeInstanceSet.length, 1);
443
assert.strictEqual(focusCalls, 0);
444
});
445
446
test('ensureTerminal focuses when requested', async () => {
447
const cwd = URI.file('/test-cwd');
448
await contribution.ensureTerminal(cwd, true);
449
450
assert.strictEqual(focusCalls, 1);
451
});
452
453
test('ensureTerminal reuses existing terminal for same path', async () => {
454
const cwd = URI.file('/test-cwd');
455
await contribution.ensureTerminal(cwd, false);
456
await contribution.ensureTerminal(cwd, false);
457
458
assert.strictEqual(createdTerminals.length, 1, 'should reuse the existing terminal');
459
assert.strictEqual(activeInstanceSet.length, 1, 'should only set active instance on creation');
460
});
461
462
test('ensureTerminal creates new terminal for different path', async () => {
463
await contribution.ensureTerminal(URI.file('/cwd1'), false);
464
await contribution.ensureTerminal(URI.file('/cwd2'), false);
465
466
assert.strictEqual(createdTerminals.length, 2);
467
});
468
469
test('ensureTerminal path comparison is case-insensitive', async () => {
470
await contribution.ensureTerminal(URI.file('/Test/CWD'), false);
471
await contribution.ensureTerminal(URI.file('/test/cwd'), false);
472
473
assert.strictEqual(createdTerminals.length, 1, 'should match case-insensitively');
474
});
475
476
test('ensureTerminal does not activate a terminal disposed during creation', async () => {
477
const cwd = URI.file('/test-cwd');
478
disposeOnCreatePaths.add(cwd.fsPath);
479
480
const instances = await contribution.ensureTerminal(cwd, false);
481
482
assert.strictEqual(instances.length, 0);
483
assert.strictEqual(activeInstanceSet.length, 0);
484
assert.ok(logService.traces.some(message => message.includes(`Cannot activate created terminal for ${cwd.fsPath}; terminal 1 is no longer available`)));
485
});
486
487
// --- onDidChangeSessions (archived) ---
488
489
test('closes terminals when session is archived', async () => {
490
const worktreeUri = URI.file('/worktree');
491
await contribution.ensureTerminal(worktreeUri, false);
492
493
assert.strictEqual(createdTerminals.length, 1);
494
495
const session = makeAgentSession({
496
isArchived: true,
497
worktree: worktreeUri,
498
providerType: AgentSessionProviders.Background,
499
});
500
onDidChangeSessions.fire({ added: [], removed: [], changed: [session] });
501
await tick();
502
503
assert.strictEqual(disposedInstances.length, 1);
504
});
505
506
test('does not close terminals when session is not archived', async () => {
507
const worktreeUri = URI.file('/worktree');
508
await contribution.ensureTerminal(worktreeUri, false);
509
510
const session = makeAgentSession({
511
isArchived: false,
512
worktree: worktreeUri,
513
});
514
onDidChangeSessions.fire({ added: [], removed: [], changed: [session] });
515
await tick();
516
517
assert.strictEqual(disposedInstances.length, 0);
518
});
519
520
test('does not close terminals when archived session has no worktree', async () => {
521
const worktreeUri = URI.file('/worktree');
522
await contribution.ensureTerminal(worktreeUri, false);
523
524
const session = makeAgentSession({ isArchived: true });
525
onDidChangeSessions.fire({ added: [], removed: [], changed: [session] });
526
await tick();
527
528
assert.strictEqual(disposedInstances.length, 0);
529
});
530
531
test('closes terminals when archived session has only a repository (no worktree)', async () => {
532
const repoUri = URI.file('/repo');
533
const session = makeAgentSession({ repository: repoUri, providerType: AgentSessionProviders.Background, isArchived: false });
534
activeSessionObs.set(session, undefined);
535
await tick();
536
537
assert.strictEqual(createdTerminals.length, 1);
538
assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath);
539
540
const archivedSession = makeAgentSession({ repository: repoUri, providerType: AgentSessionProviders.Background, isArchived: true });
541
onDidChangeSessions.fire({ added: [], removed: [], changed: [archivedSession] });
542
await tick();
543
544
assert.strictEqual(disposedInstances.length, 1);
545
});
546
547
test('closes terminals when session is removed', async () => {
548
const worktreeUri = URI.file('/worktree');
549
await contribution.ensureTerminal(worktreeUri, false);
550
551
assert.strictEqual(createdTerminals.length, 1);
552
553
const session = makeAgentSession({ worktree: worktreeUri, providerType: AgentSessionProviders.Background });
554
onDidChangeSessions.fire({ added: [], removed: [session], changed: [] });
555
await tick();
556
557
assert.strictEqual(disposedInstances.length, 1);
558
});
559
560
test('does not close terminal when another live session still owns the cwd (replace case)', async () => {
561
const worktreeUri = URI.file('/worktree');
562
await contribution.ensureTerminal(worktreeUri, false);
563
564
// Simulate the onDidReplaceSession flow: `from` (untitled) is reported as
565
// removed while `to` (committed) is still live at the same cwd.
566
const fromSession = makeAgentSession({ sessionId: 'test:untitled', worktree: worktreeUri, providerType: AgentSessionProviders.Background });
567
const toSession = makeAgentSession({ sessionId: 'test:committed', worktree: worktreeUri, providerType: AgentSessionProviders.Background });
568
allSessions = [toSession];
569
570
onDidChangeSessions.fire({ added: [], removed: [fromSession], changed: [toSession] });
571
await tick();
572
573
assert.strictEqual(disposedInstances.length, 0, 'terminal should be kept alive for the surviving session');
574
});
575
576
test('does not close terminal when archiving one of two sessions sharing a cwd', async () => {
577
const worktreeUri = URI.file('/worktree');
578
await contribution.ensureTerminal(worktreeUri, false);
579
580
const liveSession = makeAgentSession({ sessionId: 'test:live', worktree: worktreeUri, providerType: AgentSessionProviders.Background });
581
const archivedSession = makeAgentSession({ sessionId: 'test:archived', worktree: worktreeUri, providerType: AgentSessionProviders.Background, isArchived: true });
582
allSessions = [liveSession, archivedSession];
583
584
onDidChangeSessions.fire({ added: [], removed: [], changed: [archivedSession] });
585
await tick();
586
587
assert.strictEqual(disposedInstances.length, 0, 'terminal should be kept for the still-live session');
588
});
589
590
test('closes terminal when the only session at a cwd is removed even if other live sessions exist elsewhere', async () => {
591
const worktreeUri = URI.file('/worktree');
592
await contribution.ensureTerminal(worktreeUri, false);
593
594
const otherLive = makeAgentSession({ sessionId: 'test:other', worktree: URI.file('/other'), providerType: AgentSessionProviders.Background });
595
const removedSession = makeAgentSession({ sessionId: 'test:gone', worktree: worktreeUri, providerType: AgentSessionProviders.Background });
596
allSessions = [otherLive];
597
598
onDidChangeSessions.fire({ added: [], removed: [removedSession], changed: [] });
599
await tick();
600
601
assert.strictEqual(disposedInstances.length, 1, 'no live session owns this cwd, terminal should be closed');
602
});
603
604
// --- switching back to previously used path reuses terminal ---
605
606
test('switching back to a previously used background path reuses the existing terminal', async () => {
607
const cwd1 = URI.file('/cwd1');
608
const cwd2 = URI.file('/cwd2');
609
610
activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined);
611
await tick();
612
assert.strictEqual(createdTerminals.length, 1);
613
614
activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined);
615
await tick();
616
assert.strictEqual(createdTerminals.length, 2);
617
618
// Switch back to cwd1 - should reuse terminal, not create a new one
619
activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined);
620
await tick();
621
assert.strictEqual(createdTerminals.length, 2, 'should reuse the terminal for cwd1');
622
});
623
624
// --- Terminal visibility management (cwd-based) ---
625
626
test('hides terminals from previous session when switching to a new session', async () => {
627
const cwd1 = URI.file('/cwd1');
628
const cwd2 = URI.file('/cwd2');
629
630
activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined);
631
await tick();
632
assert.strictEqual(createdTerminals.length, 1);
633
634
activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined);
635
await tick();
636
637
// The first terminal (id=1) should have been moved to background
638
assert.ok(moveToBackgroundCalls.includes(1), 'terminal for cwd1 should be backgrounded');
639
assert.ok(backgroundedInstances.has(1), 'terminal for cwd1 should remain backgrounded');
640
});
641
642
test('shows previously hidden terminals when switching back to their session', async () => {
643
const cwd1 = URI.file('/cwd1');
644
const cwd2 = URI.file('/cwd2');
645
646
activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined);
647
await tick();
648
649
activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined);
650
await tick();
651
652
// Switch back to cwd1
653
activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined);
654
await tick();
655
656
// Terminal for cwd1 (id=1) should be shown again
657
assert.ok(showBackgroundCalls.includes(1), 'terminal for cwd1 should be shown');
658
assert.ok(!backgroundedInstances.has(1), 'terminal for cwd1 should be foreground');
659
// Terminal for cwd2 (id=2) should now be backgrounded
660
assert.ok(backgroundedInstances.has(2), 'terminal for cwd2 should be backgrounded');
661
});
662
663
test('only terminals of the active session are visible after multiple switches', async () => {
664
const cwd1 = URI.file('/cwd1');
665
const cwd2 = URI.file('/cwd2');
666
const cwd3 = URI.file('/cwd3');
667
668
activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined);
669
await tick();
670
671
activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined);
672
await tick();
673
674
activeSessionObs.set(makeAgentSession({ worktree: cwd3, providerType: AgentSessionProviders.Background }), undefined);
675
await tick();
676
677
// Only terminal for cwd3 (id=3) should be foreground
678
assert.ok(backgroundedInstances.has(1), 'terminal for cwd1 should be backgrounded');
679
assert.ok(backgroundedInstances.has(2), 'terminal for cwd2 should be backgrounded');
680
assert.ok(!backgroundedInstances.has(3), 'terminal for cwd3 should be foreground');
681
});
682
683
test('shows pre-existing terminal with matching cwd instead of creating a new one', async () => {
684
// Manually add a terminal that already exists with a matching cwd
685
const cwd = URI.file('/worktree');
686
const existingInstance = makeTerminalInstance(nextInstanceId++, cwd.fsPath);
687
terminalInstances.set(existingInstance.instanceId, existingInstance);
688
backgroundedInstances.add(existingInstance.instanceId);
689
690
activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined);
691
await tick();
692
693
assert.strictEqual(createdTerminals.length, 0, 'should reuse existing terminal, not create a new one');
694
assert.ok(showBackgroundCalls.includes(existingInstance.instanceId), 'should show the existing terminal');
695
});
696
697
test('does not background a restored terminal that is disposed before cwd resolves', async () => {
698
let resolveInitialCwd: ((cwd: string) => void) | undefined;
699
const restoredInstance = makeTerminalInstance(nextInstanceId++, '/restored');
700
restoredInstance._testSetShellLaunchConfig({ attachPersistentProcess: {} as never } as ITerminalInstance['shellLaunchConfig']);
701
restoredInstance.getInitialCwd = () => new Promise<string>(resolve => {
702
resolveInitialCwd = resolve;
703
});
704
terminalInstances.set(restoredInstance.instanceId, restoredInstance);
705
706
activeSessionObs.set(makeAgentSession({ worktree: URI.file('/active'), providerType: AgentSessionProviders.Background }), undefined);
707
await tick();
708
709
onDidCreateInstance.fire(restoredInstance);
710
restoredInstance._testSetDisposed(true);
711
terminalInstances.delete(restoredInstance.instanceId);
712
resolveInitialCwd?.('/other');
713
await tick();
714
715
assert.ok(!moveToBackgroundCalls.includes(restoredInstance.instanceId), 'disposed restored terminal should not be backgrounded');
716
assert.ok(logService.traces.some(message => message.includes('Cannot hide restored terminal for /other; terminal') && message.includes('is no longer available')));
717
});
718
719
test('hides pre-existing terminal with non-matching cwd when session changes', async () => {
720
// Manually add a terminal that already exists with a different cwd
721
const otherInstance = makeTerminalInstance(nextInstanceId++, '/other/path');
722
terminalInstances.set(otherInstance.instanceId, otherInstance);
723
724
const cwd = URI.file('/worktree');
725
activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined);
726
await tick();
727
728
assert.ok(moveToBackgroundCalls.includes(otherInstance.instanceId), 'non-matching terminal should be backgrounded');
729
});
730
731
test('ensureTerminal finds a backgrounded terminal instead of creating a new one', async () => {
732
const cwd = URI.file('/test-cwd');
733
await contribution.ensureTerminal(cwd, false);
734
const instanceId = activeInstanceSet[0];
735
736
// Manually background it
737
backgroundedInstances.add(instanceId);
738
739
// ensureTerminal should find it by cwd, not create a new one
740
const result = await contribution.ensureTerminal(cwd, false);
741
742
assert.strictEqual(createdTerminals.length, 1, 'should not create a new terminal');
743
assert.strictEqual(result[0].instanceId, instanceId, 'should return the existing backgrounded terminal');
744
});
745
746
test('visibility is determined by initial cwd, not by stored IDs', async () => {
747
// Create a terminal externally (not via ensureTerminal) with a known cwd
748
const cwd1 = URI.file('/cwd1');
749
const cwd2 = URI.file('/cwd2');
750
const ext1 = makeTerminalInstance(nextInstanceId++, cwd1.fsPath);
751
const ext2 = makeTerminalInstance(nextInstanceId++, cwd2.fsPath);
752
terminalInstances.set(ext1.instanceId, ext1);
753
terminalInstances.set(ext2.instanceId, ext2);
754
755
// Switch to cwd1 — ext1 should stay visible, ext2 should be hidden
756
activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined);
757
await tick();
758
759
assert.ok(!backgroundedInstances.has(ext1.instanceId), 'ext1 should be foreground (matching cwd)');
760
assert.ok(backgroundedInstances.has(ext2.instanceId), 'ext2 should be backgrounded (non-matching cwd)');
761
762
// Switch to cwd2 — ext2 should be shown, ext1 should be hidden
763
activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined);
764
await tick();
765
766
assert.ok(backgroundedInstances.has(ext1.instanceId), 'ext1 should now be backgrounded');
767
assert.ok(!backgroundedInstances.has(ext2.instanceId), 'ext2 should now be foreground');
768
});
769
770
// --- Most-recent-command active terminal selection ---
771
772
test('sets the terminal with the most recent command as active after visibility update', async () => {
773
const cwd = URI.file('/worktree');
774
const t1 = makeTerminalInstance(nextInstanceId++, cwd.fsPath);
775
const t2 = makeTerminalInstance(nextInstanceId++, cwd.fsPath);
776
terminalInstances.set(t1.instanceId, t1);
777
terminalInstances.set(t2.instanceId, t2);
778
779
// t1 ran a command at timestamp 100, t2 at timestamp 200 (more recent)
780
addCommandToInstance(t1, 100);
781
addCommandToInstance(t2, 200);
782
783
activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined);
784
await tick();
785
786
// The most recent setActiveInstance call should be for t2
787
assert.strictEqual(activeInstanceSet.at(-1), t2.instanceId, 'should set the terminal with the most recent command as active');
788
});
789
790
test('does not change active instance when no terminals have command history', async () => {
791
const cwd = URI.file('/worktree');
792
const t1 = makeTerminalInstance(nextInstanceId++, cwd.fsPath);
793
const t2 = makeTerminalInstance(nextInstanceId++, cwd.fsPath);
794
terminalInstances.set(t1.instanceId, t1);
795
terminalInstances.set(t2.instanceId, t2);
796
797
const activeCountBefore = activeInstanceSet.length;
798
799
activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined);
800
await tick();
801
802
// No setActiveInstance calls from visibility update since no commands were run
803
assert.strictEqual(activeInstanceSet.length, activeCountBefore, 'should not call setActiveInstance when no command history exists');
804
});
805
806
// --- Remote agent host sessions ---
807
808
test('uses the unwrapped repository path for a background session with a remote agent host repository', async () => {
809
const remoteRepoUri = toAgentHostUri(URI.file('/Users/user/repo'), 'my-server');
810
const session = makeAgentSession({ repository: remoteRepoUri, providerType: AgentSessionProviders.Background });
811
activeSessionObs.set(session, undefined);
812
await tick();
813
814
assert.strictEqual(createdTerminals.length, 1, 'should create a terminal at the unwrapped repository path');
815
assert.strictEqual(createdTerminals[0].cwd.fsPath, URI.file('/Users/user/repo').fsPath);
816
});
817
});
818
819
function tick(): Promise<void> {
820
return new Promise(resolve => setTimeout(resolve, 0));
821
}
822
823