Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { DisposableStore } from '../../../../base/common/lifecycle.js';
8
import { URI } from '../../../../base/common/uri.js';
9
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
10
import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';
11
import { NullLogService } from '../../../log/common/log.js';
12
import { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js';
13
import { SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildSubagentSessionUri, isSubagentSession, parseSubagentSessionUri, type MarkdownResponsePart, type SessionState } from '../../common/state/sessionState.js';
14
import { type SessionSummaryChangedNotification } from '../../common/state/protocol/notifications.js';
15
import { AgentHostStateManager } from '../../node/agentHostStateManager.js';
16
17
suite('AgentHostStateManager', () => {
18
19
let disposables: DisposableStore;
20
let manager: AgentHostStateManager;
21
const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString();
22
23
function makeSessionSummary(resource?: string): SessionSummary {
24
return {
25
resource: resource ?? sessionUri,
26
provider: 'copilot',
27
title: 'Test',
28
status: SessionStatus.Idle,
29
createdAt: Date.now(),
30
modifiedAt: Date.now(),
31
project: { uri: 'file:///test-project', displayName: 'Test Project' },
32
};
33
}
34
35
setup(() => {
36
disposables = new DisposableStore();
37
manager = disposables.add(new AgentHostStateManager(new NullLogService()));
38
});
39
40
teardown(() => {
41
disposables.dispose();
42
});
43
44
ensureNoDisposablesAreLeakedInTestSuite();
45
46
test('createSession creates initial state with lifecycle Creating', () => {
47
const state = manager.createSession(makeSessionSummary());
48
assert.strictEqual(state.lifecycle, SessionLifecycle.Creating);
49
assert.strictEqual(state.turns.length, 0);
50
assert.strictEqual(state.activeTurn, undefined);
51
assert.strictEqual(state.summary.resource.toString(), sessionUri.toString());
52
});
53
54
test('getSnapshot returns undefined for unknown session', () => {
55
const unknown = URI.from({ scheme: 'copilot', path: '/unknown' }).toString();
56
const snapshot = manager.getSnapshot(unknown);
57
assert.strictEqual(snapshot, undefined);
58
});
59
60
test('getSnapshot returns root snapshot', () => {
61
const snapshot = manager.getSnapshot(ROOT_STATE_URI);
62
assert.ok(snapshot);
63
assert.strictEqual(snapshot.resource.toString(), ROOT_STATE_URI.toString());
64
const root = snapshot.state as { agents: unknown[]; activeSessions: number; config?: { values?: Record<string, unknown> } };
65
assert.deepStrictEqual(root.agents, []);
66
assert.strictEqual(root.activeSessions, 0);
67
// Host config is seeded with the platform root schema and defaults.
68
assert.ok(root.config, 'root state should include a seeded config');
69
});
70
71
test('getSnapshot returns session snapshot after creation', () => {
72
manager.createSession(makeSessionSummary());
73
const snapshot = manager.getSnapshot(sessionUri);
74
assert.ok(snapshot);
75
assert.strictEqual(snapshot.resource.toString(), sessionUri.toString());
76
assert.strictEqual((snapshot.state as SessionState).lifecycle, SessionLifecycle.Creating);
77
});
78
79
test('dispatchServerAction applies action and emits envelope', () => {
80
manager.createSession(makeSessionSummary());
81
82
const envelopes: ActionEnvelope[] = [];
83
disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e)));
84
85
manager.dispatchServerAction({
86
type: ActionType.SessionReady,
87
session: sessionUri,
88
});
89
90
const state = manager.getSessionState(sessionUri);
91
assert.ok(state);
92
assert.strictEqual(state.lifecycle, SessionLifecycle.Ready);
93
94
assert.strictEqual(envelopes.length, 1);
95
assert.strictEqual(envelopes[0].action.type, ActionType.SessionReady);
96
assert.strictEqual(envelopes[0].serverSeq, 1);
97
assert.strictEqual(envelopes[0].origin, undefined);
98
});
99
100
test('serverSeq increments monotonically', () => {
101
manager.createSession(makeSessionSummary());
102
103
const envelopes: ActionEnvelope[] = [];
104
disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e)));
105
106
manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
107
manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Updated' });
108
109
assert.strictEqual(envelopes.length, 2);
110
assert.strictEqual(envelopes[0].serverSeq, 1);
111
assert.strictEqual(envelopes[1].serverSeq, 2);
112
assert.ok(envelopes[1].serverSeq > envelopes[0].serverSeq);
113
});
114
115
test('dispatchClientAction includes origin in envelope', () => {
116
manager.createSession(makeSessionSummary());
117
118
const envelopes: ActionEnvelope[] = [];
119
disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e)));
120
121
const origin = { clientId: 'renderer-1', clientSeq: 42 };
122
manager.dispatchClientAction(
123
{ type: ActionType.SessionReady, session: sessionUri },
124
origin,
125
);
126
127
assert.strictEqual(envelopes.length, 1);
128
assert.deepStrictEqual(envelopes[0].origin, origin);
129
});
130
131
test('removeSession clears state without notification', () => {
132
manager.createSession(makeSessionSummary());
133
134
const notifications: INotification[] = [];
135
disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));
136
137
manager.removeSession(sessionUri);
138
139
assert.strictEqual(manager.getSessionState(sessionUri), undefined);
140
assert.strictEqual(manager.getSnapshot(sessionUri), undefined);
141
assert.strictEqual(notifications.length, 0);
142
});
143
144
test('deleteSession clears state and emits notification', () => {
145
manager.createSession(makeSessionSummary());
146
147
const notifications: INotification[] = [];
148
disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));
149
150
manager.deleteSession(sessionUri);
151
152
assert.strictEqual(manager.getSessionState(sessionUri), undefined);
153
assert.strictEqual(manager.getSnapshot(sessionUri), undefined);
154
assert.strictEqual(notifications.length, 1);
155
assert.strictEqual(notifications[0].type, NotificationType.SessionRemoved);
156
});
157
158
test('createSession emits sessionAdded notification', () => {
159
const notifications: INotification[] = [];
160
disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));
161
162
manager.createSession(makeSessionSummary());
163
164
assert.strictEqual(notifications.length, 1);
165
assert.strictEqual(notifications[0].type, NotificationType.SessionAdded);
166
});
167
168
test('getActiveTurnId returns active turn id after turnStarted', () => {
169
manager.createSession(makeSessionSummary());
170
manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
171
172
assert.strictEqual(manager.getActiveTurnId(sessionUri), undefined);
173
174
manager.dispatchServerAction({
175
type: ActionType.SessionTurnStarted,
176
session: sessionUri,
177
turnId: 'turn-1',
178
userMessage: { text: 'hello' },
179
});
180
181
assert.strictEqual(manager.getActiveTurnId(sessionUri), 'turn-1');
182
});
183
184
test('root state starts with activeSessions: 0', () => {
185
const snapshot = manager.getSnapshot(ROOT_STATE_URI);
186
assert.ok(snapshot);
187
const root = snapshot.state as { agents: unknown[]; activeSessions: number };
188
assert.deepStrictEqual(root.agents, []);
189
assert.strictEqual(root.activeSessions, 0);
190
});
191
192
test('turnStarted dispatches root/activeSessionsChanged with correct count', () => {
193
manager.createSession(makeSessionSummary());
194
manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
195
196
const envelopes: ActionEnvelope[] = [];
197
disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e)));
198
199
manager.dispatchServerAction({
200
type: ActionType.SessionTurnStarted,
201
session: sessionUri,
202
turnId: 'turn-1',
203
userMessage: { text: 'hello' },
204
});
205
206
const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged);
207
assert.strictEqual(activeChanged.length, 1);
208
assert.strictEqual((activeChanged[0].action as { activeSessions: number }).activeSessions, 1);
209
assert.strictEqual(manager.rootState.activeSessions, 1);
210
});
211
212
test('turnComplete dispatches root/activeSessionsChanged back to 0', () => {
213
manager.createSession(makeSessionSummary());
214
manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
215
manager.dispatchServerAction({
216
type: ActionType.SessionTurnStarted,
217
session: sessionUri,
218
turnId: 'turn-1',
219
userMessage: { text: 'hello' },
220
});
221
222
const envelopes: ActionEnvelope[] = [];
223
disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e)));
224
225
manager.dispatchServerAction({
226
type: ActionType.SessionTurnComplete,
227
session: sessionUri,
228
turnId: 'turn-1',
229
});
230
231
const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged);
232
assert.strictEqual(activeChanged.length, 1);
233
assert.strictEqual((activeChanged[0].action as { activeSessions: number }).activeSessions, 0);
234
assert.strictEqual(manager.rootState.activeSessions, 0);
235
});
236
237
test('activeSessions reflects concurrent turn count across sessions', () => {
238
const session2Uri = URI.from({ scheme: 'copilot', path: '/test-session-2' }).toString();
239
manager.createSession(makeSessionSummary(sessionUri));
240
manager.createSession(makeSessionSummary(session2Uri));
241
manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
242
manager.dispatchServerAction({ type: ActionType.SessionReady, session: session2Uri });
243
244
manager.dispatchServerAction({
245
type: ActionType.SessionTurnStarted,
246
session: sessionUri,
247
turnId: 'turn-1',
248
userMessage: { text: 'a' },
249
});
250
manager.dispatchServerAction({
251
type: ActionType.SessionTurnStarted,
252
session: session2Uri,
253
turnId: 'turn-2',
254
userMessage: { text: 'b' },
255
});
256
assert.strictEqual(manager.rootState.activeSessions, 2);
257
258
manager.dispatchServerAction({
259
type: ActionType.SessionTurnComplete,
260
session: sessionUri,
261
turnId: 'turn-1',
262
});
263
assert.strictEqual(manager.rootState.activeSessions, 1);
264
265
manager.dispatchServerAction({
266
type: ActionType.SessionTurnComplete,
267
session: session2Uri,
268
turnId: 'turn-2',
269
});
270
assert.strictEqual(manager.rootState.activeSessions, 0);
271
});
272
273
test('restoreSession creates session in Ready state with pre-populated turns', () => {
274
const turns = [
275
{
276
id: 'turn-1',
277
userMessage: { text: 'hello' },
278
responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'world' } satisfies MarkdownResponsePart],
279
usage: undefined,
280
state: TurnState.Complete,
281
},
282
];
283
284
const state = manager.restoreSession(makeSessionSummary(), turns);
285
assert.strictEqual(state.lifecycle, SessionLifecycle.Ready);
286
assert.strictEqual(state.turns.length, 1);
287
assert.strictEqual(state.turns[0].userMessage.text, 'hello');
288
assert.strictEqual((state.turns[0].responseParts[0] as MarkdownResponsePart).content, 'world');
289
});
290
291
test('restoreSession returns existing state for duplicate session', () => {
292
manager.createSession(makeSessionSummary());
293
const existing = manager.getSessionState(sessionUri);
294
295
const state = manager.restoreSession(makeSessionSummary(), []);
296
assert.strictEqual(state, existing);
297
});
298
299
test('restoreSession does not emit sessionAdded notification', () => {
300
const notifications: INotification[] = [];
301
disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));
302
303
manager.restoreSession(makeSessionSummary(), []);
304
305
assert.strictEqual(notifications.length, 0, 'should not emit notification for restored sessions');
306
});
307
308
test('emits sessionSummaryChanged when summary changes', () => {
309
return runWithFakedTimers({ useFakeTimers: true }, async () => {
310
manager.createSession(makeSessionSummary());
311
manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
312
313
const notifications: INotification[] = [];
314
disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));
315
316
manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'New Title' });
317
318
// Should not fire synchronously (debounced)
319
assert.strictEqual(notifications.filter(n => n.type === NotificationType.SessionSummaryChanged).length, 0);
320
321
// Advance past debounce
322
await new Promise(r => setTimeout(r, 150));
323
324
const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged);
325
assert.strictEqual(changed.length, 1);
326
const notification = changed[0] as SessionSummaryChangedNotification;
327
assert.strictEqual(notification.session, sessionUri);
328
assert.strictEqual(notification.changes.title, 'New Title');
329
assert.strictEqual(notification.changes.status, undefined, 'unchanged fields should be omitted');
330
});
331
});
332
333
test('coalesces multiple summary changes into one notification', () => {
334
return runWithFakedTimers({ useFakeTimers: true }, async () => {
335
manager.createSession(makeSessionSummary());
336
manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
337
338
const notifications: INotification[] = [];
339
disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));
340
341
manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'First' });
342
manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Second' });
343
344
await new Promise(r => setTimeout(r, 150));
345
346
const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged);
347
assert.strictEqual(changed.length, 1, 'should coalesce into one notification');
348
assert.strictEqual((changed[0] as SessionSummaryChangedNotification).changes.title, 'Second');
349
});
350
});
351
352
test('does not emit sessionSummaryChanged when summary is unchanged', () => {
353
return runWithFakedTimers({ useFakeTimers: true }, async () => {
354
manager.createSession(makeSessionSummary());
355
manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
356
357
const notifications: INotification[] = [];
358
disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));
359
360
// SessionReady changes lifecycle, not summary — so no summary notification
361
await new Promise(r => setTimeout(r, 150));
362
363
const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged);
364
assert.strictEqual(changed.length, 0);
365
});
366
});
367
368
test('does not emit sessionSummaryChanged for deleted session', () => {
369
return runWithFakedTimers({ useFakeTimers: true }, async () => {
370
manager.createSession(makeSessionSummary());
371
manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri });
372
373
const notifications: INotification[] = [];
374
disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));
375
376
manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'New Title' });
377
manager.deleteSession(sessionUri);
378
379
await new Promise(r => setTimeout(r, 150));
380
381
const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged);
382
assert.strictEqual(changed.length, 0, 'should not emit for deleted sessions');
383
});
384
});
385
});
386
387
suite('Subagent URI helpers', () => {
388
389
ensureNoDisposablesAreLeakedInTestSuite();
390
391
test('buildSubagentSessionUri creates correct URI', () => {
392
assert.strictEqual(
393
buildSubagentSessionUri('copilot:/session-1', 'tc-1'),
394
'copilot:/session-1/subagent/tc-1',
395
);
396
});
397
398
test('parseSubagentSessionUri extracts parent and toolCallId', () => {
399
const parsed = parseSubagentSessionUri('copilot:/session-1/subagent/tc-1');
400
assert.deepStrictEqual(parsed, {
401
parentSession: 'copilot:/session-1',
402
toolCallId: 'tc-1',
403
});
404
});
405
406
test('parseSubagentSessionUri returns undefined for non-subagent URIs', () => {
407
assert.strictEqual(parseSubagentSessionUri('copilot:/session-1'), undefined);
408
});
409
410
test('isSubagentSession identifies subagent URIs', () => {
411
assert.strictEqual(isSubagentSession('copilot:/session-1/subagent/tc-1'), true);
412
assert.strictEqual(isSubagentSession('copilot:/session-1'), false);
413
});
414
});
415
416