Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.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 { timeout } from '../../../../../base/common/async.js';
8
import { Emitter, Event } from '../../../../../base/common/event.js';
9
import { DisposableStore, toDisposable, type IReference } from '../../../../../base/common/lifecycle.js';
10
import { URI } from '../../../../../base/common/uri.js';
11
import { mock } from '../../../../../base/test/common/mock.js';
12
import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js';
13
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
14
import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js';
15
import type { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js';
16
import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js';
17
import { SessionLifecycle, type AgentInfo, type ModelSelection, type RootState, type SessionConfigState, type SessionState } from '../../../../../platform/agentHost/common/state/protocol/state.js';
18
import { ActionType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js';
19
import { SessionStatus as ProtocolSessionStatus, StateComponents } from '../../../../../platform/agentHost/common/state/sessionState.js';
20
import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js';
21
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
22
import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';
23
import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
24
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
25
import { INotificationService } from '../../../../../platform/notification/common/notification.js';
26
import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js';
27
import { IChatWidget, IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js';
28
import { IChatService, type ChatSendResult, type IChatSendRequestOptions } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js';
29
import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js';
30
import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js';
31
import { ISessionChangeEvent } from '../../../../services/sessions/common/sessionsProvider.js';
32
import { SessionStatus, COPILOT_CLI_SESSION_TYPE } from '../../../../services/sessions/common/session.js';
33
import { RemoteAgentHostSessionsProvider, type IRemoteAgentHostSessionsProviderConfig } from '../../browser/remoteAgentHostSessionsProvider.js';
34
import { ILabelService } from '../../../../../platform/label/common/label.js';
35
36
// ---- Mock connection --------------------------------------------------------
37
38
class MockAgentConnection extends mock<IAgentConnection>() {
39
declare readonly _serviceBrand: undefined;
40
41
private readonly _onDidAction = new Emitter<ActionEnvelope>();
42
override readonly onDidAction = this._onDidAction.event;
43
private readonly _onDidNotification = new Emitter<INotification>();
44
override readonly onDidNotification = this._onDidNotification.event;
45
46
private readonly _onDidRootStateChange = new Emitter<RootState>();
47
private _rootStateValue: RootState = { agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo] };
48
override readonly rootState: IAgentSubscription<RootState>;
49
50
override readonly clientId = 'test-client-1';
51
private readonly _sessions = new Map<string, IAgentSessionMetadata>();
52
public disposedSessions: URI[] = [];
53
public dispatchedActions: { action: SessionAction | TerminalAction | IRootConfigChangedAction; clientId: string; clientSeq: number }[] = [];
54
public failResolveSessionConfig = false;
55
public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } };
56
57
private _nextSeq = 0;
58
59
constructor() {
60
super();
61
const self = this;
62
this.rootState = {
63
get value() { return self._rootStateValue; },
64
get verifiedValue() { return self._rootStateValue; },
65
onDidChange: self._onDidRootStateChange.event,
66
onWillApplyAction: Event.None,
67
onDidApplyAction: Event.None,
68
};
69
}
70
71
nextClientSeq(): number {
72
return this._nextSeq++;
73
}
74
75
override async listSessions(): Promise<IAgentSessionMetadata[]> {
76
return [...this._sessions.values()];
77
}
78
79
override async disposeSession(session: URI): Promise<void> {
80
this.disposedSessions.push(session);
81
const rawId = AgentSession.id(session);
82
this._sessions.delete(rawId);
83
}
84
85
override async resolveSessionConfig(): Promise<ResolveSessionConfigResult> {
86
await Promise.resolve();
87
if (this.failResolveSessionConfig) {
88
throw new Error('resolveSessionConfig unavailable');
89
}
90
return this.resolveSessionConfigResult;
91
}
92
93
dispatchAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {
94
this.dispatchedActions.push({ action, clientId, clientSeq });
95
}
96
97
override dispatch(action: SessionAction | TerminalAction | IRootConfigChangedAction): void {
98
this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ });
99
}
100
101
// Test helpers
102
addSession(meta: IAgentSessionMetadata): void {
103
this._sessions.set(AgentSession.id(meta.session), meta);
104
}
105
106
// ---- Session-state subscriptions ---------------------------------------
107
108
private readonly _sessionStateEmitters = new Map<string, Emitter<SessionState>>();
109
private readonly _sessionStateValues = new Map<string, SessionState>();
110
public sessionSubscribeCounts = new Map<string, number>();
111
public sessionUnsubscribeCounts = new Map<string, number>();
112
113
override getSubscription<T>(_kind: StateComponents, resource: URI): IReference<IAgentSubscription<T>> {
114
const key = resource.toString();
115
this.sessionSubscribeCounts.set(key, (this.sessionSubscribeCounts.get(key) ?? 0) + 1);
116
let emitter = this._sessionStateEmitters.get(key);
117
if (!emitter) {
118
emitter = new Emitter<SessionState>();
119
this._sessionStateEmitters.set(key, emitter);
120
}
121
const self = this;
122
const sub: IAgentSubscription<T> = {
123
get value() { return self._sessionStateValues.get(key) as unknown as T | undefined; },
124
get verifiedValue() { return self._sessionStateValues.get(key) as unknown as T | undefined; },
125
onDidChange: emitter.event as unknown as Event<T>,
126
onWillApplyAction: Event.None,
127
onDidApplyAction: Event.None,
128
};
129
return {
130
object: sub,
131
dispose: () => {
132
this.sessionUnsubscribeCounts.set(key, (this.sessionUnsubscribeCounts.get(key) ?? 0) + 1);
133
},
134
};
135
}
136
137
setSessionState(rawId: string, provider: string, state: SessionState): void {
138
const key = AgentSession.uri(provider, rawId).toString();
139
this._sessionStateValues.set(key, state);
140
this._sessionStateEmitters.get(key)?.fire(state);
141
}
142
143
setAgents(agents: AgentInfo[]): void {
144
this._rootStateValue = { agents };
145
this._onDidRootStateChange.fire(this._rootStateValue);
146
}
147
148
fireNotification(n: INotification): void {
149
this._onDidNotification.fire(n);
150
}
151
152
fireAction(envelope: ActionEnvelope): void {
153
this._onDidAction.fire(envelope);
154
}
155
156
dispose(): void {
157
this._onDidAction.dispose();
158
this._onDidNotification.dispose();
159
this._onDidRootStateChange.dispose();
160
for (const emitter of this._sessionStateEmitters.values()) {
161
emitter.dispose();
162
}
163
this._sessionStateEmitters.clear();
164
}
165
}
166
167
// ---- Test helpers -----------------------------------------------------------
168
169
function createSession(id: string, opts?: { provider?: string; summary?: string; model?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number }): IAgentSessionMetadata {
170
return {
171
session: AgentSession.uri(opts?.provider ?? 'copilotcli', id),
172
startTime: opts?.startTime ?? 1000,
173
modifiedTime: opts?.modifiedTime ?? 2000,
174
summary: opts?.summary,
175
model: opts?.model ? { id: opts.model } : undefined,
176
project: opts?.project,
177
workingDirectory: opts?.workingDirectory,
178
};
179
}
180
181
function 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 {
182
const instantiationService = disposables.add(new TestInstantiationService());
183
184
instantiationService.stub(IFileDialogService, {});
185
instantiationService.stub(IConfigurationService, new TestConfigurationService());
186
instantiationService.stub(INotificationService, { error: () => { } });
187
instantiationService.stub(IChatSessionsService, {
188
getChatSessionContribution: () => ({ type: 'remote-test-copilot', name: 'test', displayName: 'Test', description: 'test', icon: undefined }),
189
getOrCreateChatSession: async () => ({ onWillDispose: () => ({ dispose() { } }), sessionResource: URI.from({ scheme: 'test' }), history: [], dispose() { } }),
190
});
191
instantiationService.stub(IChatService, {
192
acquireOrLoadSession: async () => undefined,
193
sendRequest: overrides?.sendRequest ?? (async (): Promise<ChatSendResult> => ({ kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never })),
194
});
195
instantiationService.stub(IChatWidgetService, {
196
openSession: async () => overrides?.openSession ? new class extends mock<IChatWidget>() { }() : undefined,
197
});
198
instantiationService.stub(ILanguageModelsService, {
199
lookupLanguageModel: () => undefined,
200
});
201
instantiationService.stub(IStorageService, overrides?.storageService ?? disposables.add(new InMemoryStorageService()));
202
instantiationService.stub(ILabelService, {
203
getUriLabel: (uri: URI) => uri.path,
204
});
205
206
const config: IRemoteAgentHostSessionsProviderConfig = {
207
address: overrides?.address ?? 'localhost:4321',
208
name: overrides !== undefined && Object.prototype.hasOwnProperty.call(overrides, 'connectionName') ? overrides.connectionName ?? '' : 'Test Host',
209
};
210
211
const providerCtor = overrides?.isWebPlatform !== undefined
212
? class extends RemoteAgentHostSessionsProvider {
213
protected override get isWebPlatform(): boolean { return overrides.isWebPlatform!; }
214
}
215
: RemoteAgentHostSessionsProvider;
216
const provider = disposables.add(instantiationService.createInstance(providerCtor, config));
217
if (!overrides?.noConnection) {
218
provider.setConnection(connection);
219
}
220
return provider;
221
}
222
223
async function waitForSessionConfig(provider: RemoteAgentHostSessionsProvider, sessionId: string, predicate: (config: ResolveSessionConfigResult | undefined) => boolean): Promise<void> {
224
if (predicate(provider.getSessionConfig(sessionId))) {
225
return;
226
}
227
228
await new Promise<void>(resolve => {
229
const disposable = provider.onDidChangeSessionConfig(changedSessionId => {
230
if (changedSessionId === sessionId && predicate(provider.getSessionConfig(sessionId))) {
231
disposable.dispose();
232
resolve();
233
}
234
});
235
});
236
}
237
238
function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: { provider?: string; title?: string; model?: string; modelConfig?: Record<string, string>; project?: { uri: string; displayName: string }; workingDirectory?: string }): void {
239
const provider = opts?.provider ?? 'copilotcli';
240
const sessionUri = AgentSession.uri(provider, rawId);
241
connection.fireNotification({
242
type: NotificationType.SessionAdded,
243
summary: {
244
resource: sessionUri.toString(),
245
provider,
246
title: opts?.title ?? `Session ${rawId}`,
247
status: ProtocolSessionStatus.Idle,
248
createdAt: Date.now(),
249
modifiedAt: Date.now(),
250
model: opts?.model ? { id: opts.model, ...(opts.modelConfig ? { config: opts.modelConfig } : {}) } : undefined,
251
project: opts?.project,
252
workingDirectory: opts?.workingDirectory,
253
},
254
});
255
}
256
257
function fireSessionRemoved(connection: MockAgentConnection, rawId: string, provider = 'copilotcli'): void {
258
const sessionUri = AgentSession.uri(provider, rawId);
259
connection.fireNotification({
260
type: NotificationType.SessionRemoved,
261
session: sessionUri.toString(),
262
});
263
}
264
265
suite('RemoteAgentHostSessionsProvider', () => {
266
const disposables = new DisposableStore();
267
let connection: MockAgentConnection;
268
269
setup(() => {
270
connection = new MockAgentConnection();
271
disposables.add(toDisposable(() => connection.dispose()));
272
});
273
274
teardown(() => {
275
disposables.clear();
276
});
277
278
ensureNoDisposablesAreLeakedInTestSuite();
279
280
// ---- Provider identity -------
281
282
test('derives id and label from config, and session types from rootState agents', () => {
283
const provider = createProvider(disposables, connection, { address: '10.0.0.1:8080', connectionName: 'My Host' });
284
285
assert.strictEqual(provider.id, 'agenthost-10.0.0.1__8080');
286
assert.strictEqual(provider.label, 'My Host');
287
assert.strictEqual(provider.sessionTypes.length, 1);
288
assert.strictEqual(provider.sessionTypes[0].id, COPILOT_CLI_SESSION_TYPE);
289
assert.strictEqual(provider.sessionTypes[0].label, 'Copilot [My Host]');
290
});
291
292
test('session types update when the host advertises additional agents', () => {
293
const provider = createProvider(disposables, connection, { address: '10.0.0.1:8080', connectionName: 'My Host' });
294
assert.deepStrictEqual(provider.sessionTypes.map(t => t.id), [
295
COPILOT_CLI_SESSION_TYPE,
296
]);
297
298
let changes = 0;
299
disposables.add(provider.onDidChangeSessionTypes!(() => changes++));
300
301
connection.setAgents([
302
{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo,
303
{ provider: 'openai', displayName: 'OpenAI', description: '', models: [] } as AgentInfo,
304
]);
305
306
assert.strictEqual(changes, 1);
307
assert.deepStrictEqual(provider.sessionTypes.map(t => ({ id: t.id, label: t.label })), [
308
{ id: COPILOT_CLI_SESSION_TYPE, label: 'Copilot [My Host]' },
309
{ id: 'openai', label: 'OpenAI [My Host]' },
310
]);
311
});
312
313
test('falls back to address-based label when no name given', () => {
314
const provider = createProvider(disposables, connection, { connectionName: undefined, address: 'myhost:9999' });
315
316
assert.strictEqual(provider.label, 'myhost:9999');
317
});
318
319
// ---- Workspace resolution -------
320
321
test('resolveWorkspace builds workspace from URI', () => {
322
const provider = createProvider(disposables, connection, { isWebPlatform: true });
323
const uri = URI.parse('vscode-agent-host://auth/home/user/project');
324
const ws = provider.resolveWorkspace(uri);
325
326
assert.ok(ws, 'resolveWorkspace should resolve vscode-agent-host:// URIs');
327
assert.strictEqual(ws.label, 'project');
328
assert.strictEqual(ws.repositories.length, 1);
329
assert.strictEqual(ws.repositories[0].uri.toString(), uri.toString());
330
assert.strictEqual(ws.repositories[0].detail, undefined);
331
});
332
333
// ---- Browse actions -------
334
335
test('has one browse action for remote folders', () => {
336
const provider = createProvider(disposables, connection);
337
338
assert.strictEqual(provider.browseActions.length, 1);
339
assert.ok(provider.browseActions[0].label.includes('Folders'));
340
assert.strictEqual(provider.browseActions[0].providerId, provider.id);
341
});
342
343
// ---- Session listing via notifications -------
344
345
test('onDidChangeSessions fires when session added notification arrives', () => {
346
const provider = createProvider(disposables, connection);
347
const changes: ISessionChangeEvent[] = [];
348
disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));
349
350
fireSessionAdded(connection, 'notif-1', { title: 'Notif Session' });
351
352
assert.strictEqual(changes.length, 1);
353
assert.strictEqual(changes[0].added.length, 1);
354
assert.strictEqual(changes[0].added[0].title.get(), 'Notif Session');
355
});
356
357
test('session added notifications ingest any advertised agent provider', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
358
connection.setAgents([
359
{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo,
360
{ provider: 'openai', displayName: 'OpenAI', description: '', models: [] } as AgentInfo,
361
]);
362
const provider = createProvider(disposables, connection);
363
364
fireSessionAdded(connection, 'cop-1', { provider: 'copilotcli', title: 'Copilot Session' });
365
fireSessionAdded(connection, 'oai-1', { provider: 'openai', title: 'OpenAI Session' });
366
367
const sessions = provider.getSessions();
368
assert.deepStrictEqual(
369
sessions.map(s => ({ title: s.title.get(), sessionType: s.sessionType })).sort((a, b) => a.title.localeCompare(b.title)),
370
[
371
{ title: 'Copilot Session', sessionType: COPILOT_CLI_SESSION_TYPE },
372
{ title: 'OpenAI Session', sessionType: 'openai' },
373
],
374
);
375
}));
376
377
test('session removed notification removes from cache', () => {
378
const provider = createProvider(disposables, connection);
379
fireSessionAdded(connection, 'to-remove', { title: 'Removed' });
380
381
const changes: ISessionChangeEvent[] = [];
382
disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));
383
384
fireSessionRemoved(connection, 'to-remove');
385
386
assert.strictEqual(changes.length, 1);
387
assert.strictEqual(changes[0].removed.length, 1);
388
});
389
390
test('duplicate session added notification is ignored', () => {
391
const provider = createProvider(disposables, connection);
392
const changes: ISessionChangeEvent[] = [];
393
disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));
394
395
fireSessionAdded(connection, 'dup-sess', { title: 'Dup' });
396
fireSessionAdded(connection, 'dup-sess', { title: 'Dup' });
397
398
assert.strictEqual(changes.length, 1);
399
});
400
401
test('uses project metadata as workspace group source', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
402
const projectUri = URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/vscode');
403
const workingDirectory = URI.parse('vscode-agent-host://localhost__4321/file/-/tmp/copilot-worktrees/vscode-feature');
404
connection.addSession(createSession('project-1', {
405
summary: 'Project Session',
406
project: { uri: projectUri, displayName: 'vscode' },
407
workingDirectory,
408
}));
409
410
const provider = createProvider(disposables, connection, { isWebPlatform: true });
411
provider.getSessions();
412
await timeout(0);
413
414
const workspace = provider.getSessions()[0].workspace.get();
415
assert.deepStrictEqual({
416
label: workspace?.label,
417
repository: workspace?.repositories[0]?.uri.toString(),
418
workingDirectory: workspace?.repositories[0]?.workingDirectory?.toString(),
419
detail: workspace?.repositories[0]?.detail,
420
}, {
421
label: 'vscode',
422
repository: projectUri.toString(),
423
workingDirectory: workingDirectory.toString(),
424
detail: undefined,
425
});
426
}));
427
428
test('session added converts file project URIs and preserves repository URLs', () => {
429
const provider = createProvider(disposables, connection);
430
431
fireSessionAdded(connection, 'file-project', {
432
title: 'File Project',
433
project: { uri: 'file:///home/user/vscode', displayName: 'vscode' },
434
workingDirectory: 'file:///tmp/copilot-worktrees/vscode-feature',
435
});
436
fireSessionAdded(connection, 'url-project', {
437
title: 'URL Project',
438
project: { uri: 'https://github.com/microsoft/vscode', displayName: 'vscode' },
439
});
440
441
const workspaces = provider.getSessions().map(session => session.workspace.get());
442
assert.deepStrictEqual(workspaces.map(workspace => workspace?.repositories[0]?.uri.toString()), [
443
'vscode-agent-host://localhost__4321/file/-/home/user/vscode',
444
'https://github.com/microsoft/vscode',
445
]);
446
});
447
448
test('removing non-existent session is no-op', () => {
449
const provider = createProvider(disposables, connection);
450
const changes: ISessionChangeEvent[] = [];
451
disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));
452
453
fireSessionRemoved(connection, 'does-not-exist');
454
455
assert.strictEqual(changes.length, 0);
456
});
457
458
// ---- Session listing via refresh -------
459
460
test('getSessions populates from connection.listSessions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
461
connection.addSession(createSession('list-1', { summary: 'First' }));
462
connection.addSession(createSession('list-2', { summary: 'Second' }));
463
464
const provider = createProvider(disposables, connection);
465
const changes: ISessionChangeEvent[] = [];
466
disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));
467
468
provider.getSessions();
469
await timeout(0);
470
471
assert.ok(changes.length > 0);
472
const sessions = provider.getSessions();
473
assert.strictEqual(sessions.length, 2);
474
}));
475
476
test('uses model metadata as selected model for listed sessions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
477
connection.addSession(createSession('model-1', { summary: 'Model Session', model: 'claude-sonnet-4.5' }));
478
479
const provider = createProvider(disposables, connection);
480
provider.getSessions();
481
await timeout(0);
482
483
const session = provider.getSessions().find(s => s.title.get() === 'Model Session');
484
assert.strictEqual(session?.modelId.get(), 'remote-localhost__4321-copilotcli:claude-sonnet-4.5');
485
}));
486
487
test('uses model metadata from session added notification', () => {
488
const provider = createProvider(disposables, connection);
489
fireSessionAdded(connection, 'notif-model', { title: 'Notif Model Session', model: 'gpt-5' });
490
491
const session = provider.getSessions().find(s => s.title.get() === 'Notif Model Session');
492
assert.strictEqual(session?.modelId.get(), 'remote-localhost__4321-copilotcli:gpt-5');
493
});
494
495
test('setModel updates existing session model and dispatches raw model', () => {
496
const provider = createProvider(disposables, connection);
497
fireSessionAdded(connection, 'set-model', { title: 'Set Model Session', model: 'old-model' });
498
499
const session = provider.getSessions().find(s => s.title.get() === 'Set Model Session');
500
assert.ok(session);
501
502
provider.setModel(session!.sessionId, 'remote-localhost__4321-copilotcli:new-model');
503
504
assert.strictEqual(session!.modelId.get(), 'remote-localhost__4321-copilotcli:new-model');
505
assert.deepStrictEqual(connection.dispatchedActions.at(-1)?.action, {
506
type: ActionType.SessionModelChanged,
507
session: AgentSession.uri('copilotcli', 'set-model').toString(),
508
model: { id: 'new-model' },
509
});
510
});
511
512
test('setModel preserves current model config when model id is unchanged', () => {
513
const provider = createProvider(disposables, connection);
514
fireSessionAdded(connection, 'set-model-config', { title: 'Set Model Config Session', model: 'configured-model', modelConfig: { thinkingLevel: 'high' } });
515
516
const session = provider.getSessions().find(s => s.title.get() === 'Set Model Config Session');
517
assert.ok(session);
518
519
provider.setModel(session!.sessionId, 'remote-localhost__4321-copilotcli:configured-model');
520
521
assert.deepStrictEqual(connection.dispatchedActions.at(-1)?.action, {
522
type: ActionType.SessionModelChanged,
523
session: AgentSession.uri('copilotcli', 'set-model-config').toString(),
524
model: { id: 'configured-model', config: { thinkingLevel: 'high' } },
525
});
526
});
527
528
// ---- Session lifecycle -------
529
530
test('createNewSession returns session with correct fields', () => {
531
const provider = createProvider(disposables, connection, { isWebPlatform: true });
532
const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id);
533
534
assert.strictEqual(session.providerId, provider.id);
535
assert.strictEqual(session.status.get(), SessionStatus.Untitled);
536
assert.ok(session.workspace.get());
537
assert.strictEqual(session.workspace.get()?.label, 'project');
538
// sessionType should be the logical type, not the resource scheme
539
assert.strictEqual(session.sessionType, provider.sessionTypes[0].id);
540
assert.deepStrictEqual(provider.getSessionConfig(session.sessionId), { schema: { type: 'object', properties: {} }, values: {} });
541
});
542
543
test('createNewSession clears session config when resolving config is unavailable', async () => {
544
connection.failResolveSessionConfig = true;
545
const provider = createProvider(disposables, connection, { isWebPlatform: true });
546
const workspaceUri = URI.parse('vscode-agent-host://auth/home/user/project');
547
const session = provider.createNewSession(workspaceUri, provider.sessionTypes[0].id);
548
const resolved = provider.getSessionByResource(session.resource);
549
550
assert.deepStrictEqual({
551
listedSessions: provider.getSessions().length,
552
resolvedResource: resolved?.resource.toString(),
553
resolvedWorkspaceLabel: resolved?.workspace.get()?.label,
554
}, {
555
listedSessions: 0,
556
resolvedResource: session.resource.toString(),
557
resolvedWorkspaceLabel: 'project',
558
});
559
});
560
561
test('clearConnection clears pending new session config', () => {
562
const provider = createProvider(disposables, connection);
563
564
const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id);
565
provider.clearConnection();
566
567
assert.deepStrictEqual({
568
resolved: provider.getSessionByResource(session.resource),
569
config: provider.getSessionConfig(session.sessionId),
570
}, {
571
resolved: undefined,
572
config: undefined,
573
});
574
});
575
576
// ---- Session actions -------
577
578
test('deleteSession calls disposeSession with backend agent URI and removes from cache', async () => {
579
const provider = createProvider(disposables, connection);
580
fireSessionAdded(connection, 'del-sess', { title: 'To Delete' });
581
582
const sessions = provider.getSessions();
583
const target = sessions.find((s) => s.title.get() === 'To Delete');
584
assert.ok(target, 'Session should exist');
585
586
await provider.deleteSession(target!.sessionId);
587
588
assert.strictEqual(connection.disposedSessions.length, 1);
589
// The disposed URI must be a backend agent session URI (copilot://del-sess),
590
// not the UI resource (remote-localhost_4321-copilot:///del-sess)
591
const disposedUri = connection.disposedSessions[0];
592
assert.strictEqual(AgentSession.provider(disposedUri), 'copilotcli');
593
assert.strictEqual(AgentSession.id(disposedUri), 'del-sess');
594
// Session should no longer appear in getSessions
595
const remaining = provider.getSessions();
596
assert.strictEqual(remaining.find((s) => s.title.get() === 'To Delete'), undefined);
597
});
598
599
// ---- Rename -------
600
601
test('renameSession dispatches SessionTitleChanged action with correct session URI', async () => {
602
const provider = createProvider(disposables, connection);
603
fireSessionAdded(connection, 'rename-sess', { title: 'Old Title' });
604
605
const sessions = provider.getSessions();
606
const target = sessions.find((s) => s.title.get() === 'Old Title');
607
assert.ok(target, 'Session should exist');
608
609
await provider.renameChat(target!.sessionId, target!.resource, 'New Title');
610
611
assert.strictEqual(connection.dispatchedActions.length, 1);
612
const dispatched = connection.dispatchedActions[0];
613
assert.strictEqual(dispatched.action.type, ActionType.SessionTitleChanged);
614
assert.strictEqual((dispatched.action as { title: string }).title, 'New Title');
615
// The session URI in the action must be the backend agent session URI
616
const actionSession = (dispatched.action as { session: string }).session;
617
assert.strictEqual(AgentSession.provider(actionSession), 'copilotcli');
618
assert.strictEqual(AgentSession.id(actionSession), 'rename-sess');
619
assert.strictEqual(dispatched.clientId, 'test-client-1');
620
});
621
622
test('renameSession updates local title optimistically', async () => {
623
const provider = createProvider(disposables, connection);
624
fireSessionAdded(connection, 'rename-opt', { title: 'Before' });
625
626
const sessions = provider.getSessions();
627
const target = sessions.find((s) => s.title.get() === 'Before');
628
assert.ok(target);
629
630
await provider.renameChat(target!.sessionId, target!.resource, 'After');
631
632
assert.strictEqual(target!.title.get(), 'After');
633
});
634
635
test('renameSession is no-op for unknown chatId', async () => {
636
const provider = createProvider(disposables, connection);
637
await provider.renameChat('nonexistent-id', URI.parse('test://nonexistent'), 'Ignored');
638
639
assert.strictEqual(connection.dispatchedActions.length, 0);
640
});
641
642
test('renameSession increments clientSeq on successive calls', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
643
connection.addSession(createSession('seq-sess', { summary: 'Seq Test' }));
644
const provider = createProvider(disposables, connection);
645
provider.getSessions();
646
await timeout(0);
647
648
const sessions = provider.getSessions();
649
const target = sessions.find((s) => s.title.get() === 'Seq Test');
650
assert.ok(target);
651
652
await provider.renameChat(target!.sessionId, target!.resource, 'Title 1');
653
await provider.renameChat(target!.sessionId, target!.resource, 'Title 2');
654
655
assert.strictEqual(connection.dispatchedActions.length, 2);
656
assert.strictEqual(connection.dispatchedActions[0].clientSeq, 0);
657
assert.strictEqual(connection.dispatchedActions[1].clientSeq, 1);
658
}));
659
660
test('server-echoed SessionTitleChanged updates cached title', () => {
661
const provider = createProvider(disposables, connection);
662
fireSessionAdded(connection, 'echo-sess', { title: 'Original' });
663
664
const sessions = provider.getSessions();
665
const target = sessions.find((s) => s.title.get() === 'Original');
666
assert.ok(target);
667
668
const changes: ISessionChangeEvent[] = [];
669
disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));
670
671
// Simulate the server echoing a title change (from auto-generation or another client)
672
connection.fireAction({
673
action: {
674
type: ActionType.SessionTitleChanged,
675
session: AgentSession.uri('copilotcli', 'echo-sess').toString(),
676
title: 'Server Title',
677
},
678
serverSeq: 1,
679
origin: undefined,
680
} as ActionEnvelope);
681
682
assert.strictEqual(target!.title.get(), 'Server Title');
683
assert.strictEqual(changes.length, 1);
684
assert.strictEqual(changes[0].changed.length, 1);
685
});
686
687
test('server-echoed SessionModelChanged updates cached model', () => {
688
const provider = createProvider(disposables, connection);
689
fireSessionAdded(connection, 'model-change', { title: 'Model Change', model: 'old-model' });
690
691
const target = provider.getSessions().find(s => s.title.get() === 'Model Change');
692
assert.ok(target);
693
694
const changes: ISessionChangeEvent[] = [];
695
disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));
696
697
connection.fireAction({
698
action: {
699
type: ActionType.SessionModelChanged,
700
session: AgentSession.uri('copilotcli', 'model-change').toString(),
701
model: { id: 'new-model' } satisfies ModelSelection,
702
},
703
serverSeq: 1,
704
origin: undefined,
705
} as ActionEnvelope);
706
707
assert.strictEqual(target!.modelId.get(), 'remote-localhost__4321-copilotcli:new-model');
708
assert.strictEqual(changes.length, 1);
709
assert.strictEqual(changes[0].changed.length, 1);
710
});
711
712
test('renamed title survives session refresh from listSessions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
713
// Simulate server persisting the renamed title: after rename, listSessions
714
// returns the updated summary
715
connection.addSession(createSession('persist-sess', { summary: 'Original Title' }));
716
const provider = createProvider(disposables, connection);
717
provider.getSessions();
718
await timeout(0);
719
720
// Verify initial title
721
let sessions = provider.getSessions();
722
let target = sessions.find((s) => s.title.get() === 'Original Title');
723
assert.ok(target, 'Session should exist with original title');
724
725
// Simulate server updating the summary (as would happen after persist + reload)
726
connection.addSession(createSession('persist-sess', { summary: 'Renamed Title', modifiedTime: 5000 }));
727
728
// Trigger refresh via turnComplete action (simulates what happens on reload)
729
connection.fireAction({
730
action: {
731
type: 'session/turnComplete',
732
session: AgentSession.uri('copilotcli', 'persist-sess').toString(),
733
},
734
serverSeq: 1,
735
origin: undefined,
736
} as ActionEnvelope);
737
738
await timeout(0);
739
740
sessions = provider.getSessions();
741
target = sessions.find((s) => s.title.get() === 'Renamed Title');
742
assert.ok(target, 'Session should have renamed title after refresh');
743
}));
744
745
// ---- Send -------
746
747
test('new session stays loading when required config is missing', async () => {
748
connection.resolveSessionConfigResult = {
749
schema: { type: 'object', required: ['branch'], properties: { branch: { type: 'string', title: 'Branch', enum: ['main'] } } },
750
values: {},
751
};
752
const provider = createProvider(disposables, connection);
753
const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id);
754
await waitForSessionConfig(provider, session.sessionId, config => config?.schema.required?.includes('branch') === true);
755
756
assert.strictEqual(session.loading.get(), true);
757
});
758
759
test('cached session loading reflects authenticationPending', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
760
connection.addSession(createSession('cached-auth', { summary: 'Cached' }));
761
const provider = createProvider(disposables, connection);
762
await timeout(0);
763
764
const session = provider.getSessions().find(s => s.title.get() === 'Cached');
765
assert.ok(session);
766
// Default at construction is `true`; clear it and verify.
767
assert.strictEqual(session!.loading.get(), true);
768
769
provider.setAuthenticationPending(false);
770
assert.strictEqual(session!.loading.get(), false);
771
772
// Sticky: a subsequent re-auth pass must not flicker the UI back to loading.
773
provider.setAuthenticationPending(true);
774
assert.strictEqual(session!.loading.get(), false);
775
}));
776
777
test('unpublishCachedSessions hides sessions but retains persisted cache', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
778
const storageService = disposables.add(new InMemoryStorageService());
779
connection.addSession(createSession('keep-me', { summary: 'Keep Me' }));
780
const provider = createProvider(disposables, connection, { storageService });
781
await timeout(0);
782
assert.strictEqual(provider.getSessions().length, 1);
783
784
const events: ISessionChangeEvent[] = [];
785
disposables.add(provider.onDidChangeSessions(e => events.push(e)));
786
787
provider.unpublishCachedSessions();
788
789
// Sessions are hidden from the listing immediately.
790
assert.deepStrictEqual(
791
{
792
sessionCount: provider.getSessions().length,
793
eventRemovedTitles: events.flatMap(e => e.removed.map(s => s.title.get())),
794
},
795
{ sessionCount: 0, eventRemovedTitles: ['Keep Me'] },
796
);
797
798
// Flush triggers onWillSaveState; the metadata must survive so the
799
// session re-serializes instead of being dropped from storage.
800
await storageService.flush();
801
802
const provider2 = createProvider(disposables, new MockAgentConnection(), { storageService, noConnection: true });
803
assert.deepStrictEqual(
804
provider2.getSessions().map(s => s.title.get()),
805
['Keep Me'],
806
);
807
}));
808
809
test('setConnection after unpublishCachedSessions restores cached sessions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
810
connection.addSession(createSession('restore-me', { summary: 'Restore Me' }));
811
const provider = createProvider(disposables, connection);
812
await timeout(0);
813
assert.strictEqual(provider.getSessions().length, 1);
814
815
provider.unpublishCachedSessions();
816
assert.strictEqual(provider.getSessions().length, 0);
817
818
// Simulate the host coming back online with a fresh connection that
819
// still reports the same session.
820
const reconnected = new MockAgentConnection();
821
disposables.add(toDisposable(() => reconnected.dispose()));
822
reconnected.addSession(createSession('restore-me', { summary: 'Restore Me' }));
823
provider.setConnection(reconnected);
824
await timeout(0);
825
826
assert.deepStrictEqual(
827
provider.getSessions().map(s => s.title.get()),
828
['Restore Me'],
829
);
830
}));
831
832
test('sendAndCreateChat throws for unknown session', async () => {
833
const provider = createProvider(disposables, connection);
834
await assert.rejects(
835
() => provider.sendAndCreateChat('nonexistent', { query: 'test' }),
836
/not found or not a new session/,
837
);
838
});
839
840
test('sendAndCreateChat forwards resolved session config to chat service', async () => {
841
const sendOptions: IChatSendRequestOptions[] = [];
842
const provider = createProvider(disposables, connection, {
843
openSession: true,
844
sendRequest: async (_resource, _message, options): Promise<ChatSendResult> => {
845
if (options) {
846
sendOptions.push(options);
847
}
848
connection.addSession(createSession('created-from-send', { summary: 'Created From Send' }));
849
return { kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never };
850
},
851
});
852
const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id);
853
await timeout(0);
854
855
await provider.sendAndCreateChat(session.sessionId, { query: 'hello' });
856
857
assert.deepStrictEqual(sendOptions.map(options => options.agentHostSessionConfig), [{ isolation: 'worktree' }]);
858
});
859
860
// ---- Session data adapter -------
861
862
test('session adapter has correct workspace from working directory', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
863
connection.addSession(createSession('ws-sess', { summary: 'WS Test', workingDirectory: URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/myrepo') }));
864
865
const provider = createProvider(disposables, connection, { isWebPlatform: true });
866
provider.getSessions();
867
await timeout(0);
868
869
const sessions = provider.getSessions();
870
const wsSession = sessions.find((s) => s.title.get() === 'WS Test');
871
assert.ok(wsSession, 'Session with working directory should exist');
872
873
const workspace = wsSession!.workspace.get();
874
assert.ok(workspace, 'Workspace should be populated');
875
assert.strictEqual(workspace!.label, 'myrepo');
876
assert.strictEqual(workspace!.repositories[0].detail, undefined);
877
}));
878
879
test('session adapter without working directory has no workspace', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
880
connection.addSession(createSession('no-ws-sess', { summary: 'No WS' }));
881
882
const provider = createProvider(disposables, connection);
883
provider.getSessions();
884
await timeout(0);
885
886
const sessions = provider.getSessions();
887
const session = sessions.find((s) => s.title.get() === 'No WS');
888
assert.ok(session, 'Session should exist');
889
assert.strictEqual(session!.workspace.get(), undefined);
890
}));
891
892
test('session adapter uses raw ID as fallback title', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
893
connection.addSession(createSession('abcdef1234567890'));
894
895
const provider = createProvider(disposables, connection);
896
provider.getSessions();
897
await timeout(0);
898
899
const sessions = provider.getSessions();
900
const session = sessions[0];
901
assert.ok(session);
902
assert.strictEqual(session.title.get(), 'Session abcdef12');
903
}));
904
905
// ---- Refresh on turnComplete -------
906
907
test('turnComplete action triggers session refresh for matching provider', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
908
connection.addSession(createSession('turn-sess', { summary: 'Before', modifiedTime: 1000 }));
909
910
const provider = createProvider(disposables, connection);
911
provider.getSessions();
912
await timeout(0);
913
914
// Update on connection side
915
connection.addSession(createSession('turn-sess', { summary: 'After', modifiedTime: 5000 }));
916
917
const changes: ISessionChangeEvent[] = [];
918
disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e)));
919
920
connection.fireAction({
921
action: {
922
type: 'session/turnComplete',
923
session: AgentSession.uri('copilotcli', 'turn-sess').toString(),
924
},
925
serverSeq: 1,
926
origin: undefined,
927
} as ActionEnvelope);
928
929
await timeout(0);
930
931
assert.ok(changes.length > 0);
932
const updatedSession = provider.getSessions().find((s) => s.title.get() === 'After');
933
assert.ok(updatedSession, 'Session should have updated title');
934
}));
935
936
// ---- Running session config seeding (from SessionState.config) -------
937
938
test('getSessionConfig seeds running config from session state subscription with full schema', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
939
connection.addSession(createSession('seed-1', { summary: 'Seeded Session' }));
940
const provider = createProvider(disposables, connection);
941
provider.getSessions();
942
await timeout(0);
943
const session = provider.getSessions().find(s => s.title.get() === 'Seeded Session');
944
assert.ok(session);
945
946
assert.strictEqual(provider.getSessionConfig(session!.sessionId), undefined);
947
948
const config: SessionConfigState = {
949
schema: {
950
type: 'object',
951
properties: {
952
autoApprove: { type: 'string', title: 'Auto Approve', enum: ['default', 'autoApprove'], sessionMutable: true },
953
isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'], readOnly: true },
954
},
955
},
956
values: { autoApprove: 'default', isolation: 'worktree' },
957
};
958
const fakeState: SessionState = {
959
summary: { resource: AgentSession.uri('copilotcli', 'seed-1').toString(), provider: 'copilotcli', title: 'Seeded Session', status: ProtocolSessionStatus.Idle, createdAt: 0, modifiedAt: 0 },
960
lifecycle: SessionLifecycle.Ready,
961
turns: [],
962
config,
963
};
964
connection.setSessionState('seed-1', 'copilotcli', fakeState);
965
966
await waitForSessionConfig(provider, session!.sessionId, c => c?.values.autoApprove === 'default');
967
968
// Full schema + values are retained; the JSONC settings editor relies
969
// on this to preserve non-mutable values through replace dispatches.
970
const seeded = provider.getSessionConfig(session!.sessionId);
971
assert.deepStrictEqual({
972
properties: Object.keys(seeded?.schema.properties ?? {}).sort(),
973
values: seeded?.values,
974
}, {
975
properties: ['autoApprove', 'isolation'],
976
values: { autoApprove: 'default', isolation: 'worktree' },
977
});
978
}));
979
980
test('removing a session disposes its session-state subscription', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
981
connection.addSession(createSession('seed-2', { summary: 'Sub Session' }));
982
const provider = createProvider(disposables, connection);
983
provider.getSessions();
984
await timeout(0);
985
const session = provider.getSessions().find(s => s.title.get() === 'Sub Session');
986
assert.ok(session);
987
988
provider.getSessionConfig(session!.sessionId);
989
const sessionUriStr = AgentSession.uri('copilotcli', 'seed-2').toString();
990
assert.strictEqual(connection.sessionSubscribeCounts.get(sessionUriStr), 1);
991
assert.strictEqual(connection.sessionUnsubscribeCounts.get(sessionUriStr) ?? 0, 0);
992
993
fireSessionRemoved(connection, 'seed-2');
994
995
assert.strictEqual(connection.sessionUnsubscribeCounts.get(sessionUriStr), 1);
996
}));
997
998
test('replacing the connection disposes all session-state subscriptions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
999
connection.addSession(createSession('seed-3', { summary: 'Reconnect Session' }));
1000
const provider = createProvider(disposables, connection);
1001
provider.getSessions();
1002
await timeout(0);
1003
const session = provider.getSessions().find(s => s.title.get() === 'Reconnect Session');
1004
assert.ok(session);
1005
1006
provider.getSessionConfig(session!.sessionId);
1007
const sessionUriStr = AgentSession.uri('copilotcli', 'seed-3').toString();
1008
assert.strictEqual(connection.sessionSubscribeCounts.get(sessionUriStr), 1);
1009
assert.strictEqual(connection.sessionUnsubscribeCounts.get(sessionUriStr) ?? 0, 0);
1010
1011
const newConnection = new MockAgentConnection();
1012
disposables.add(toDisposable(() => newConnection.dispose()));
1013
provider.setConnection(newConnection);
1014
1015
assert.strictEqual(connection.sessionUnsubscribeCounts.get(sessionUriStr), 1);
1016
}));
1017
1018
// ---- Non-web label formatting (native desktop) -------
1019
//
1020
// In the browser test runner `isWeb` is always `true`, so by default
1021
// every test above exercises the web branch (which drops the
1022
// `[<hostname>]` suffix because the titlebar host filter renders it
1023
// redundantly). These tests pin the non-web (desktop) behaviour where
1024
// the host suffix / host description must still appear.
1025
1026
test('non-web: resolveWorkspace includes [host] suffix in label', () => {
1027
const provider = createProvider(disposables, connection, { isWebPlatform: false });
1028
const uri = URI.parse('vscode-agent-host://auth/home/user/project');
1029
const ws = provider.resolveWorkspace(uri);
1030
1031
assert.ok(ws);
1032
assert.strictEqual(ws.label, 'project [Test Host]');
1033
});
1034
1035
test('non-web: session workspace from project metadata includes [host] suffix', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
1036
const projectUri = URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/vscode');
1037
connection.addSession(createSession('project-1', {
1038
summary: 'Project Session',
1039
project: { uri: projectUri, displayName: 'vscode' },
1040
}));
1041
1042
const provider = createProvider(disposables, connection, { isWebPlatform: false });
1043
provider.getSessions();
1044
await timeout(0);
1045
1046
assert.strictEqual(provider.getSessions()[0].workspace.get()?.label, 'vscode [Test Host]');
1047
}));
1048
1049
test('non-web: session workspace from working directory includes [host] suffix', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
1050
connection.addSession(createSession('ws-sess', {
1051
summary: 'WS Test',
1052
workingDirectory: URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/myrepo'),
1053
}));
1054
1055
const provider = createProvider(disposables, connection, { isWebPlatform: false });
1056
provider.getSessions();
1057
await timeout(0);
1058
1059
const wsSession = provider.getSessions().find(s => s.title.get() === 'WS Test');
1060
assert.strictEqual(wsSession?.workspace.get()?.label, 'myrepo [Test Host]');
1061
}));
1062
1063
test('non-web: createNewSession workspace label includes [host] suffix', () => {
1064
const provider = createProvider(disposables, connection, { isWebPlatform: false });
1065
const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id);
1066
1067
assert.strictEqual(session.workspace.get()?.label, 'project [Test Host]');
1068
});
1069
1070
test('non-web: session description is the host label', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
1071
connection.addSession(createSession('desc-sess', { summary: 'Desc Test' }));
1072
1073
const provider = createProvider(disposables, connection, { isWebPlatform: false });
1074
provider.getSessions();
1075
await timeout(0);
1076
1077
const session = provider.getSessions().find(s => s.title.get() === 'Desc Test');
1078
const description = session?.description.get();
1079
assert.ok(description, 'description should be defined on non-web');
1080
// MarkdownString.appendText escapes spaces as &nbsp; — verify the
1081
// host label is present rather than the exact serialized form.
1082
assert.ok(description!.value.includes('Test') && description!.value.includes('Host'));
1083
}));
1084
1085
test('web: session description is undefined (host filter dropdown replaces it)', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
1086
connection.addSession(createSession('desc-sess-web', { summary: 'Desc Web' }));
1087
1088
const provider = createProvider(disposables, connection, { isWebPlatform: true });
1089
provider.getSessions();
1090
await timeout(0);
1091
1092
const session = provider.getSessions().find(s => s.title.get() === 'Desc Web');
1093
assert.strictEqual(session?.description.get(), undefined);
1094
}));
1095
1096
});
1097
1098