Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.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 { SubscribeResult } from '../../../common/state/protocol/commands.js';
9
import type { IModelChangedAction, IResponsePartAction, SessionAddedNotification, ITitleChangedAction } from '../../../common/state/sessionActions.js';
10
import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js';
11
import type { ListSessionsResult, INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js';
12
import { PendingMessageKind, ResponsePartKind, type SessionState } from '../../../common/state/sessionState.js';
13
import { MOCK_AUTO_TITLE } from '../mockAgent.js';
14
import {
15
createAndSubscribeSession,
16
dispatchTurnStarted,
17
getActionEnvelope,
18
isActionNotification,
19
IServerHandle,
20
nextSessionUri,
21
startServer,
22
TestProtocolClient,
23
} from './testHelpers.js';
24
25
suite('Protocol WebSocket — Session Features', function () {
26
27
let server: IServerHandle;
28
let client: TestProtocolClient;
29
30
suiteSetup(async function () {
31
this.timeout(15_000);
32
server = await startServer();
33
});
34
35
suiteTeardown(function () {
36
server.process.kill();
37
});
38
39
setup(async function () {
40
this.timeout(10_000);
41
client = new TestProtocolClient(server.port);
42
await client.connect();
43
});
44
45
teardown(function () {
46
client.close();
47
});
48
49
// ---- Session rename / title ------------------------------------------------
50
51
test('client titleChanged updates session state snapshot', async function () {
52
this.timeout(10_000);
53
54
const sessionUri = await createAndSubscribeSession(client, 'test-titleChanged');
55
56
client.notify('dispatchAction', {
57
clientSeq: 1,
58
action: {
59
type: 'session/titleChanged',
60
session: sessionUri,
61
title: 'My Custom Title',
62
},
63
});
64
65
const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged'));
66
const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction;
67
assert.strictEqual(titleAction.title, 'My Custom Title');
68
69
const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });
70
const state = snapshot.snapshot.state as SessionState;
71
assert.strictEqual(state.summary.title, 'My Custom Title');
72
});
73
74
test('agent-generated titleChanged is broadcast', async function () {
75
this.timeout(10_000);
76
77
const sessionUri = await createAndSubscribeSession(client, 'test-agent-title');
78
dispatchTurnStarted(client, sessionUri, 'turn-title', 'with-title', 1);
79
80
// The first titleChanged is the immediate fallback (user message text).
81
// Wait for the agent-generated title which arrives second.
82
const titleNotif = await client.waitForNotification(n => {
83
if (!isActionNotification(n, 'session/titleChanged')) {
84
return false;
85
}
86
const action = getActionEnvelope(n).action as ITitleChangedAction;
87
return action.title === MOCK_AUTO_TITLE;
88
});
89
const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction;
90
assert.strictEqual(titleAction.title, MOCK_AUTO_TITLE);
91
92
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
93
94
const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });
95
const state = snapshot.snapshot.state as SessionState;
96
assert.strictEqual(state.summary.title, MOCK_AUTO_TITLE);
97
});
98
99
test('first turn immediately sets title to user message', async function () {
100
this.timeout(10_000);
101
102
const sessionUri = await createAndSubscribeSession(client, 'test-immediate-title');
103
104
// Verify the session starts with the default placeholder title
105
const before = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });
106
assert.strictEqual((before.snapshot.state as SessionState).summary.title, '');
107
108
// Send first turn — side effects should dispatch an immediate titleChanged
109
// with the user's message text before the agent produces its own title.
110
dispatchTurnStarted(client, sessionUri, 'turn-immediate', 'Fix the login bug', 1);
111
112
// The first titleChanged should carry the user message text
113
const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged'));
114
const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction;
115
assert.strictEqual(titleAction.title, 'Fix the login bug');
116
117
// listSessions should also reflect the updated title
118
const result = await client.call<ListSessionsResult>('listSessions');
119
const session = result.items.find(s => s.resource === sessionUri);
120
assert.ok(session, 'session should appear in listSessions');
121
assert.strictEqual(session.title, 'Fix the login bug');
122
});
123
124
test('renamed session title persists across listSessions', async function () {
125
this.timeout(10_000);
126
127
const sessionUri = await createAndSubscribeSession(client, 'test-title-list');
128
129
client.notify('dispatchAction', {
130
clientSeq: 1,
131
action: {
132
type: 'session/titleChanged',
133
session: sessionUri,
134
title: 'Persisted Title',
135
},
136
});
137
138
await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged'));
139
140
// Poll listSessions until the persisted title appears (async DB write)
141
let session: { title: string } | undefined;
142
for (let i = 0; i < 20; i++) {
143
const result = await client.call<ListSessionsResult>('listSessions');
144
session = result.items.find(s => s.resource === sessionUri);
145
if (session?.title === 'Persisted Title') {
146
break;
147
}
148
await timeout(100);
149
}
150
assert.ok(session, 'session should appear in listSessions');
151
assert.strictEqual(session.title, 'Persisted Title');
152
});
153
154
// ---- Session model --------------------------------------------------------
155
156
test('session model flows through create, subscribe, listSessions, and modelChanged', async function () {
157
this.timeout(10_000);
158
159
await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-model-summary' });
160
161
const sessionUri = nextSessionUri();
162
await client.call('createSession', { session: sessionUri, provider: 'mock', model: { id: 'mock-model' } });
163
164
const addedNotif = await client.waitForNotification(n =>
165
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'
166
);
167
const addedSession = (addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification;
168
assert.deepStrictEqual(addedSession.summary.model, { id: 'mock-model' });
169
const createdSessionUri = addedSession.summary.resource;
170
171
const initialSnapshot = await client.call<SubscribeResult>('subscribe', { resource: createdSessionUri });
172
const initialState = initialSnapshot.snapshot.state as SessionState;
173
assert.deepStrictEqual(initialState.summary.model, { id: 'mock-model' });
174
175
const initialList = await client.call<ListSessionsResult>('listSessions');
176
assert.deepStrictEqual(initialList.items.find(s => s.resource === createdSessionUri)?.model, { id: 'mock-model' });
177
178
client.notify('dispatchAction', {
179
clientSeq: 1,
180
action: {
181
type: 'session/modelChanged',
182
session: createdSessionUri,
183
model: { id: 'mock-model-2' },
184
},
185
});
186
187
const modelNotif = await client.waitForNotification(n => isActionNotification(n, 'session/modelChanged'));
188
const modelAction = getActionEnvelope(modelNotif).action as IModelChangedAction;
189
assert.deepStrictEqual(modelAction.model, { id: 'mock-model-2' });
190
191
const updatedSnapshot = await client.call<SubscribeResult>('subscribe', { resource: createdSessionUri });
192
const updatedState = updatedSnapshot.snapshot.state as SessionState;
193
assert.deepStrictEqual(updatedState.summary.model, { id: 'mock-model-2' });
194
195
const updatedList = await client.call<ListSessionsResult>('listSessions');
196
assert.deepStrictEqual(updatedList.items.find(s => s.resource === createdSessionUri)?.model, { id: 'mock-model-2' });
197
});
198
199
// ---- Reasoning events ------------------------------------------------------
200
201
test('reasoning events produce reasoning response parts and append actions', async function () {
202
this.timeout(10_000);
203
204
const sessionUri = await createAndSubscribeSession(client, 'test-reasoning');
205
dispatchTurnStarted(client, sessionUri, 'turn-reasoning', 'with-reasoning', 1);
206
207
// The first reasoning event produces a responsePart with kind Reasoning
208
const reasoningPart = await client.waitForNotification(n => {
209
if (!isActionNotification(n, 'session/responsePart')) {
210
return false;
211
}
212
const action = getActionEnvelope(n).action as IResponsePartAction;
213
return action.part.kind === ResponsePartKind.Reasoning;
214
});
215
const reasoningAction = getActionEnvelope(reasoningPart).action as IResponsePartAction;
216
assert.strictEqual(reasoningAction.part.kind, ResponsePartKind.Reasoning);
217
218
// The second reasoning chunk produces a session/reasoning append action
219
const appendNotif = await client.waitForNotification(n => isActionNotification(n, 'session/reasoning'));
220
const appendAction = getActionEnvelope(appendNotif).action;
221
assert.strictEqual(appendAction.type, 'session/reasoning');
222
if (appendAction.type === 'session/reasoning') {
223
assert.strictEqual(appendAction.content, ' about this...');
224
}
225
226
// Then the markdown response part
227
const mdPart = await client.waitForNotification(n => {
228
if (!isActionNotification(n, 'session/responsePart')) {
229
return false;
230
}
231
const action = getActionEnvelope(n).action as IResponsePartAction;
232
return action.part.kind === ResponsePartKind.Markdown;
233
});
234
assert.ok(mdPart);
235
236
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
237
});
238
239
// ---- Queued messages -------------------------------------------------------
240
241
test('queued message is auto-consumed when session is idle', async function () {
242
this.timeout(10_000);
243
244
const sessionUri = await createAndSubscribeSession(client, 'test-queue-idle');
245
client.clearReceived();
246
247
// Queue a message when the session is idle — server should immediately consume it
248
client.notify('dispatchAction', {
249
clientSeq: 1,
250
action: {
251
type: 'session/pendingMessageSet',
252
session: sessionUri,
253
kind: PendingMessageKind.Queued,
254
id: 'q-1',
255
userMessage: { text: 'hello' },
256
},
257
});
258
259
// The server should auto-consume the queued message and start a turn
260
await client.waitForNotification(n => isActionNotification(n, 'session/turnStarted'));
261
await client.waitForNotification(n => isActionNotification(n, 'session/responsePart'));
262
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
263
264
// Verify the turn was created from the queued message
265
const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });
266
const state = snapshot.snapshot.state as SessionState;
267
assert.ok(state.turns.length >= 1);
268
assert.strictEqual(state.turns[state.turns.length - 1].userMessage.text, 'hello');
269
// Queue should be empty after consumption
270
assert.ok(!state.queuedMessages?.length, 'queued messages should be empty after consumption');
271
});
272
273
test('queued message waits for in-progress turn to complete', async function () {
274
this.timeout(15_000);
275
276
const sessionUri = await createAndSubscribeSession(client, 'test-queue-wait');
277
278
// Start a turn first
279
dispatchTurnStarted(client, sessionUri, 'turn-first', 'hello', 1);
280
281
// Wait for the first turn's response to confirm it is in progress
282
await client.waitForNotification(n => isActionNotification(n, 'session/responsePart'));
283
284
// Queue a message while the turn is in progress
285
client.notify('dispatchAction', {
286
clientSeq: 2,
287
action: {
288
type: 'session/pendingMessageSet',
289
session: sessionUri,
290
kind: PendingMessageKind.Queued,
291
id: 'q-wait-1',
292
userMessage: { text: 'hello' },
293
},
294
});
295
296
// First turn should complete
297
const firstComplete = await client.waitForNotification(n => {
298
if (!isActionNotification(n, 'session/turnComplete')) {
299
return false;
300
}
301
return (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-first';
302
});
303
const firstSeq = getActionEnvelope(firstComplete).serverSeq;
304
305
// The queued message's turn should complete AFTER the first turn
306
const secondComplete = await client.waitForNotification(n => {
307
if (!isActionNotification(n, 'session/turnComplete')) {
308
return false;
309
}
310
const envelope = getActionEnvelope(n);
311
return (envelope.action as { turnId: string }).turnId !== 'turn-first'
312
&& envelope.serverSeq > firstSeq;
313
});
314
assert.ok(secondComplete, 'should receive a second turnComplete from the queued message');
315
316
const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });
317
const state = snapshot.snapshot.state as SessionState;
318
assert.ok(state.turns.length >= 2, `expected >= 2 turns but got ${state.turns.length}`);
319
});
320
321
// ---- Steering messages ----------------------------------------------------
322
323
test('steering message is set and consumed by agent', async function () {
324
this.timeout(10_000);
325
326
const sessionUri = await createAndSubscribeSession(client, 'test-steering');
327
328
// Start a turn first
329
dispatchTurnStarted(client, sessionUri, 'turn-steer', 'hello', 1);
330
331
// Set a steering message while the turn is in progress
332
client.notify('dispatchAction', {
333
clientSeq: 2,
334
action: {
335
type: 'session/pendingMessageSet',
336
session: sessionUri,
337
kind: PendingMessageKind.Steering,
338
id: 'steer-1',
339
userMessage: { text: 'Please be concise' },
340
},
341
});
342
343
// The steering message should be set in state initially
344
const setNotif = await client.waitForNotification(n => isActionNotification(n, 'session/pendingMessageSet'));
345
assert.ok(setNotif, 'should see pendingMessageSet action');
346
347
// The mock agent consumes steering and fires steering_consumed,
348
// which causes the server to dispatch pendingMessageRemoved
349
const removedNotif = await client.waitForNotification(n => isActionNotification(n, 'session/pendingMessageRemoved'));
350
assert.ok(removedNotif, 'should see pendingMessageRemoved after agent consumes steering');
351
352
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
353
354
// Steering should be cleared from state
355
const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });
356
const state = snapshot.snapshot.state as SessionState;
357
assert.ok(!state.steeringMessage, 'steering message should be cleared after consumption');
358
});
359
360
// ---- Truncation -----------------------------------------------------------
361
362
test('truncate session removes turns after specified turn', async function () {
363
this.timeout(15_000);
364
365
const sessionUri = await createAndSubscribeSession(client, 'test-truncate');
366
367
// Create two turns
368
dispatchTurnStarted(client, sessionUri, 'turn-t1', 'hello', 1);
369
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-t1');
370
371
client.clearReceived();
372
dispatchTurnStarted(client, sessionUri, 'turn-t2', 'hello', 2);
373
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-t2');
374
375
// Verify 2 turns exist
376
let snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });
377
let state = snapshot.snapshot.state as SessionState;
378
assert.strictEqual(state.turns.length, 2);
379
380
client.clearReceived();
381
382
// Truncate: keep only turn-t1
383
client.notify('dispatchAction', {
384
clientSeq: 3,
385
action: { type: 'session/truncated', session: sessionUri, turnId: 'turn-t1' },
386
});
387
388
await client.waitForNotification(n => isActionNotification(n, 'session/truncated'));
389
390
snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });
391
state = snapshot.snapshot.state as SessionState;
392
assert.strictEqual(state.turns.length, 1);
393
assert.strictEqual(state.turns[0].id, 'turn-t1');
394
});
395
396
test('truncate all turns clears session history', async function () {
397
this.timeout(15_000);
398
399
const sessionUri = await createAndSubscribeSession(client, 'test-truncate-all');
400
401
dispatchTurnStarted(client, sessionUri, 'turn-ta1', 'hello', 1);
402
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
403
404
client.clearReceived();
405
406
// Truncate all (no turnId)
407
client.notify('dispatchAction', {
408
clientSeq: 2,
409
action: { type: 'session/truncated', session: sessionUri },
410
});
411
412
await client.waitForNotification(n => isActionNotification(n, 'session/truncated'));
413
414
const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });
415
const state = snapshot.snapshot.state as SessionState;
416
assert.strictEqual(state.turns.length, 0);
417
});
418
419
test('new turn after truncation works correctly', async function () {
420
this.timeout(15_000);
421
422
const sessionUri = await createAndSubscribeSession(client, 'test-truncate-resume');
423
424
dispatchTurnStarted(client, sessionUri, 'turn-tr1', 'hello', 1);
425
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-tr1');
426
427
client.clearReceived();
428
dispatchTurnStarted(client, sessionUri, 'turn-tr2', 'hello', 2);
429
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-tr2');
430
431
client.clearReceived();
432
433
// Truncate to turn-tr1
434
client.notify('dispatchAction', {
435
clientSeq: 3,
436
action: { type: 'session/truncated', session: sessionUri, turnId: 'turn-tr1' },
437
});
438
439
await client.waitForNotification(n => isActionNotification(n, 'session/truncated'));
440
441
// Send a new turn after truncation
442
dispatchTurnStarted(client, sessionUri, 'turn-tr3', 'hello', 4);
443
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
444
445
const snapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });
446
const state = snapshot.snapshot.state as SessionState;
447
assert.strictEqual(state.turns.length, 2);
448
assert.strictEqual(state.turns[0].id, 'turn-tr1');
449
assert.strictEqual(state.turns[1].id, 'turn-tr3');
450
});
451
452
// ---- Fork -----------------------------------------------------------------
453
454
test('fork creates a new session with source history', async function () {
455
this.timeout(15_000);
456
457
const sessionUri = await createAndSubscribeSession(client, 'test-fork');
458
459
// Create two turns
460
dispatchTurnStarted(client, sessionUri, 'turn-f1', 'hello', 1);
461
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f1');
462
463
client.clearReceived();
464
dispatchTurnStarted(client, sessionUri, 'turn-f2', 'hello', 2);
465
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f2');
466
467
client.clearReceived();
468
469
// Fork at turn-f1 (keep turns up to and including turn-f1)
470
const forkedSessionUri = nextSessionUri();
471
await client.call('createSession', {
472
session: forkedSessionUri,
473
provider: 'mock',
474
fork: { session: sessionUri, turnId: 'turn-f1' },
475
});
476
477
const addedNotif = await client.waitForNotification(n =>
478
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'
479
);
480
const addedSession = (addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification;
481
482
// Subscribe — forked session should have 1 turn
483
const snapshot = await client.call<SubscribeResult>('subscribe', { resource: addedSession.summary.resource });
484
const state = snapshot.snapshot.state as SessionState;
485
assert.strictEqual(state.lifecycle, 'ready');
486
assert.strictEqual(state.turns.length, 1, 'forked session should have 1 turn');
487
488
// Source session should be unaffected
489
const sourceSnapshot = await client.call<SubscribeResult>('subscribe', { resource: sessionUri });
490
const sourceState = sourceSnapshot.snapshot.state as SessionState;
491
assert.strictEqual(sourceState.turns.length, 2);
492
});
493
494
test('fork with invalid turn ID returns error', async function () {
495
this.timeout(10_000);
496
497
const sessionUri = await createAndSubscribeSession(client, 'test-fork-invalid');
498
499
let gotError = false;
500
try {
501
await client.call('createSession', {
502
session: nextSessionUri(),
503
provider: 'mock',
504
fork: { session: sessionUri, turnId: 'nonexistent-turn' },
505
});
506
} catch {
507
gotError = true;
508
}
509
assert.ok(gotError, 'should get error for invalid fork turn ID');
510
});
511
512
test('fork with invalid source session returns error', async function () {
513
this.timeout(10_000);
514
515
await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-fork-no-source' });
516
517
let gotError = false;
518
try {
519
await client.call('createSession', {
520
session: nextSessionUri(),
521
provider: 'mock',
522
fork: { session: 'mock://nonexistent-session', turnId: 'turn-1' },
523
});
524
} catch {
525
gotError = true;
526
}
527
assert.ok(gotError, 'should get error for invalid fork source session');
528
});
529
});
530
531