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