Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/common/agentSubscription.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 { ActionType, type ActionEnvelope } from '../../common/state/sessionActions.js';
11
import { SessionLifecycle, SessionStatus, TerminalClaimKind, type RootState, type SessionState, type TerminalState } from '../../common/state/protocol/state.js';
12
import { StateComponents } from '../../common/state/sessionState.js';
13
import { AgentSubscriptionManager, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js';
14
15
// Helpers
16
17
function makeRootState(overrides?: Partial<RootState>): RootState {
18
return {
19
agents: [],
20
activeSessions: 0,
21
terminals: [],
22
...overrides,
23
};
24
}
25
26
function makeSessionState(sessionUri: string, overrides?: Partial<SessionState>): SessionState {
27
return {
28
summary: {
29
resource: sessionUri,
30
provider: 'copilot',
31
title: 'Test',
32
status: SessionStatus.Idle,
33
createdAt: 1,
34
modifiedAt: 1,
35
project: { uri: 'file:///test-project', displayName: 'Test Project' },
36
},
37
lifecycle: SessionLifecycle.Ready,
38
turns: [],
39
...overrides,
40
};
41
}
42
43
function makeTerminalState(overrides?: Partial<TerminalState>): TerminalState {
44
return {
45
title: 'bash',
46
content: [],
47
claim: { kind: TerminalClaimKind.Client, clientId: 'c1' },
48
...overrides,
49
};
50
}
51
52
function makeEnvelope(action: ActionEnvelope['action'], serverSeq: number, origin?: ActionEnvelope['origin'], rejectionReason?: string): ActionEnvelope {
53
return { action, serverSeq, origin, rejectionReason };
54
}
55
56
const noop = () => { };
57
const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString();
58
const terminalUri = URI.from({ scheme: 'agenthost-terminal', path: '/term1' }).toString();
59
60
// RootStateSubscription
61
62
suite('RootStateSubscription', () => {
63
64
let disposables: DisposableStore;
65
66
setup(() => {
67
disposables = new DisposableStore();
68
});
69
70
teardown(() => {
71
disposables.dispose();
72
});
73
74
ensureNoDisposablesAreLeakedInTestSuite();
75
76
test('value is undefined before snapshot', () => {
77
const sub = disposables.add(new RootStateSubscription('c1', noop));
78
assert.strictEqual(sub.value, undefined);
79
assert.strictEqual(sub.verifiedValue, undefined);
80
});
81
82
test('handleSnapshot sets value and verifiedValue', () => {
83
const sub = disposables.add(new RootStateSubscription('c1', noop));
84
const state = makeRootState({ activeSessions: 3 });
85
sub.handleSnapshot(state, 0);
86
assert.deepStrictEqual(sub.value, state);
87
assert.deepStrictEqual(sub.verifiedValue, state);
88
});
89
90
test('handleSnapshot fires onDidChange', () => {
91
const sub = disposables.add(new RootStateSubscription('c1', noop));
92
const fired: RootState[] = [];
93
disposables.add(sub.onDidChange(s => fired.push(s)));
94
sub.handleSnapshot(makeRootState(), 0);
95
assert.strictEqual(fired.length, 1);
96
});
97
98
test('receiveEnvelope updates state for root actions', () => {
99
const sub = disposables.add(new RootStateSubscription('c1', noop));
100
sub.handleSnapshot(makeRootState(), 0);
101
sub.receiveEnvelope(makeEnvelope(
102
{ type: ActionType.RootActiveSessionsChanged, activeSessions: 5 },
103
1,
104
));
105
assert.strictEqual((sub.value as RootState).activeSessions, 5);
106
});
107
108
test('ignores non-root actions', () => {
109
const sub = disposables.add(new RootStateSubscription('c1', noop));
110
const state = makeRootState();
111
sub.handleSnapshot(state, 0);
112
sub.receiveEnvelope(makeEnvelope(
113
{ type: ActionType.SessionReady, session: sessionUri },
114
1,
115
));
116
assert.deepStrictEqual(sub.value, state);
117
});
118
119
test('fires onWillApplyAction and onDidApplyAction around envelope', () => {
120
const sub = disposables.add(new RootStateSubscription('c1', noop));
121
sub.handleSnapshot(makeRootState(), 0);
122
const events: string[] = [];
123
disposables.add(sub.onWillApplyAction(() => events.push('will')));
124
disposables.add(sub.onDidApplyAction(() => events.push('did')));
125
sub.receiveEnvelope(makeEnvelope(
126
{ type: ActionType.RootActiveSessionsChanged, activeSessions: 1 },
127
1,
128
));
129
assert.deepStrictEqual(events, ['will', 'did']);
130
});
131
132
test('buffers envelopes before snapshot and replays after', () => {
133
const sub = disposables.add(new RootStateSubscription('c1', noop));
134
// Send envelope before snapshot
135
sub.receiveEnvelope(makeEnvelope(
136
{ type: ActionType.RootActiveSessionsChanged, activeSessions: 7 },
137
2,
138
));
139
assert.strictEqual(sub.value, undefined);
140
141
// Now apply snapshot with fromSeq=1; envelope at seq 2 should replay
142
sub.handleSnapshot(makeRootState(), 1);
143
assert.strictEqual((sub.value! as RootState).activeSessions, 7);
144
});
145
146
test('buffered envelopes with serverSeq <= fromSeq are discarded', () => {
147
const sub = disposables.add(new RootStateSubscription('c1', noop));
148
sub.receiveEnvelope(makeEnvelope(
149
{ type: ActionType.RootActiveSessionsChanged, activeSessions: 99 },
150
1,
151
));
152
sub.handleSnapshot(makeRootState({ activeSessions: 0 }), 1);
153
// Envelope at seq 1 should not replay since fromSeq === 1
154
assert.strictEqual((sub.value as RootState).activeSessions, 0);
155
});
156
157
test('setError makes value return the error', () => {
158
const sub = disposables.add(new RootStateSubscription('c1', noop));
159
sub.handleSnapshot(makeRootState(), 0);
160
const err = new Error('failed');
161
sub.setError(err);
162
assert.strictEqual(sub.value, err);
163
// verifiedValue should still be the state
164
assert.ok(sub.verifiedValue);
165
});
166
});
167
168
// SessionStateSubscription
169
170
suite('SessionStateSubscription', () => {
171
172
let disposables: DisposableStore;
173
let seq: number;
174
175
setup(() => {
176
disposables = new DisposableStore();
177
seq = 0;
178
});
179
180
teardown(() => {
181
disposables.dispose();
182
});
183
184
ensureNoDisposablesAreLeakedInTestSuite();
185
186
function createSub(uri: string = sessionUri, clientId: string = 'c1'): SessionStateSubscription {
187
return disposables.add(new SessionStateSubscription(uri, clientId, () => ++seq, noop));
188
}
189
190
test('value is undefined before snapshot', () => {
191
const sub = createSub();
192
assert.strictEqual(sub.value, undefined);
193
});
194
195
test('handleSnapshot sets value and verifiedValue', () => {
196
const sub = createSub();
197
const state = makeSessionState(sessionUri);
198
sub.handleSnapshot(state, 0);
199
assert.deepStrictEqual(sub.value, state);
200
assert.deepStrictEqual(sub.verifiedValue, state);
201
});
202
203
test('applyOptimistic returns clientSeq and updates value but not verifiedValue', () => {
204
const sub = createSub();
205
const state = makeSessionState(sessionUri);
206
sub.handleSnapshot(state, 0);
207
208
const clientSeq = sub.applyOptimistic({
209
type: ActionType.SessionTitleChanged,
210
session: sessionUri,
211
title: 'Optimistic',
212
});
213
214
assert.strictEqual(clientSeq, 1);
215
assert.strictEqual((sub.value as SessionState).summary.title, 'Optimistic');
216
// verifiedValue should remain unchanged
217
assert.strictEqual(sub.verifiedValue!.summary.title, 'Test');
218
});
219
220
test('confirmed own action removes pending and updates confirmed', () => {
221
const sub = createSub();
222
sub.handleSnapshot(makeSessionState(sessionUri), 0);
223
224
const clientSeq = sub.applyOptimistic({
225
type: ActionType.SessionTitleChanged,
226
session: sessionUri,
227
title: 'Optimistic',
228
});
229
230
// Server confirms the action
231
sub.receiveEnvelope(makeEnvelope(
232
{ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Optimistic' },
233
1,
234
{ clientId: 'c1', clientSeq },
235
));
236
237
// After confirmation, verifiedValue should match
238
assert.strictEqual(sub.verifiedValue!.summary.title, 'Optimistic');
239
// No pending, value falls through to confirmed
240
assert.strictEqual((sub.value as SessionState).summary.title, 'Optimistic');
241
});
242
243
test('rejected own action removes pending without updating confirmed', () => {
244
const sub = createSub();
245
sub.handleSnapshot(makeSessionState(sessionUri), 0);
246
247
const clientSeq = sub.applyOptimistic({
248
type: ActionType.SessionTitleChanged,
249
session: sessionUri,
250
title: 'Optimistic',
251
});
252
253
// Server rejects the action
254
sub.receiveEnvelope(makeEnvelope(
255
{ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Optimistic' },
256
1,
257
{ clientId: 'c1', clientSeq },
258
'denied',
259
));
260
261
// Confirmed state unchanged
262
assert.strictEqual(sub.verifiedValue!.summary.title, 'Test');
263
// No more pending, value = confirmed
264
assert.strictEqual((sub.value as SessionState).summary.title, 'Test');
265
});
266
267
test('foreign action updates confirmed and recomputes optimistic', () => {
268
const sub = createSub();
269
sub.handleSnapshot(makeSessionState(sessionUri), 0);
270
271
// Local optimistic action
272
sub.applyOptimistic({
273
type: ActionType.SessionTitleChanged,
274
session: sessionUri,
275
title: 'Local',
276
});
277
278
// Foreign action arrives
279
sub.receiveEnvelope(makeEnvelope(
280
{ type: ActionType.SessionReady, session: sessionUri },
281
1,
282
{ clientId: 'other-client', clientSeq: 1 },
283
));
284
285
// Confirmed state should have SessionReady applied
286
assert.strictEqual(sub.verifiedValue!.lifecycle, SessionLifecycle.Ready);
287
// Optimistic should still have 'Local' title on top
288
assert.strictEqual((sub.value as SessionState).summary.title, 'Local');
289
});
290
291
test('after all pending cleared, value falls through to verifiedValue', () => {
292
const sub = createSub();
293
sub.handleSnapshot(makeSessionState(sessionUri), 0);
294
295
const clientSeq = sub.applyOptimistic({
296
type: ActionType.SessionTitleChanged,
297
session: sessionUri,
298
title: 'Temp',
299
});
300
301
// Confirm the pending action
302
sub.receiveEnvelope(makeEnvelope(
303
{ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Temp' },
304
1,
305
{ clientId: 'c1', clientSeq },
306
));
307
308
// value and verifiedValue should be the same object reference
309
assert.strictEqual(sub.value, sub.verifiedValue);
310
});
311
312
test('clearPending resets optimistic state', () => {
313
const sub = createSub();
314
sub.handleSnapshot(makeSessionState(sessionUri), 0);
315
316
sub.applyOptimistic({
317
type: ActionType.SessionTitleChanged,
318
session: sessionUri,
319
title: 'Pending',
320
});
321
322
assert.strictEqual((sub.value as SessionState).summary.title, 'Pending');
323
324
sub.clearPending();
325
326
// Should fall back to confirmed
327
assert.strictEqual((sub.value as SessionState).summary.title, 'Test');
328
});
329
330
test('ignores actions for different session', () => {
331
const sub = createSub();
332
sub.handleSnapshot(makeSessionState(sessionUri), 0);
333
334
sub.receiveEnvelope(makeEnvelope(
335
{ type: ActionType.SessionTitleChanged, session: 'copilot:///other', title: 'Other' },
336
1,
337
));
338
339
assert.strictEqual((sub.value as SessionState).summary.title, 'Test');
340
});
341
342
test('buffers envelopes before snapshot and replays after', () => {
343
const sub = createSub();
344
345
sub.receiveEnvelope(makeEnvelope(
346
{ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Buffered' },
347
2,
348
));
349
350
assert.strictEqual(sub.value, undefined);
351
352
sub.handleSnapshot(makeSessionState(sessionUri), 1);
353
354
assert.strictEqual((sub.value! as SessionState).summary.title, 'Buffered');
355
});
356
357
test('fires onDidChange on optimistic apply', () => {
358
const sub = createSub();
359
sub.handleSnapshot(makeSessionState(sessionUri), 0);
360
361
const fired: SessionState[] = [];
362
disposables.add(sub.onDidChange(s => fired.push(s)));
363
364
sub.applyOptimistic({
365
type: ActionType.SessionTitleChanged,
366
session: sessionUri,
367
title: 'Changed',
368
});
369
370
assert.strictEqual(fired.length, 1);
371
assert.strictEqual(fired[0].summary.title, 'Changed');
372
});
373
});
374
375
// TerminalStateSubscription
376
377
suite('TerminalStateSubscription', () => {
378
379
let disposables: DisposableStore;
380
381
setup(() => {
382
disposables = new DisposableStore();
383
});
384
385
teardown(() => {
386
disposables.dispose();
387
});
388
389
ensureNoDisposablesAreLeakedInTestSuite();
390
391
test('accepts terminal actions matching its URI', () => {
392
const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop));
393
sub.handleSnapshot(makeTerminalState(), 0);
394
395
sub.receiveEnvelope(makeEnvelope(
396
{ type: ActionType.TerminalData, terminal: terminalUri, data: 'hello' },
397
1,
398
));
399
400
assert.deepStrictEqual((sub.value as TerminalState).content, [
401
{ type: 'unclassified', value: 'hello' },
402
]);
403
});
404
405
test('ignores terminal actions for other URIs', () => {
406
const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop));
407
sub.handleSnapshot(makeTerminalState(), 0);
408
409
sub.receiveEnvelope(makeEnvelope(
410
{ type: ActionType.TerminalData, terminal: 'agenthost-terminal:///other', data: 'nope' },
411
1,
412
));
413
414
assert.deepStrictEqual((sub.value as TerminalState).content, []);
415
});
416
417
test('ignores non-terminal actions', () => {
418
const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop));
419
sub.handleSnapshot(makeTerminalState(), 0);
420
421
sub.receiveEnvelope(makeEnvelope(
422
{ type: ActionType.RootActiveSessionsChanged, activeSessions: 5 },
423
1,
424
));
425
426
assert.deepStrictEqual((sub.value as TerminalState).content, []);
427
});
428
429
test('handleSnapshot sets value', () => {
430
const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop));
431
const state = makeTerminalState({ title: 'zsh' });
432
sub.handleSnapshot(state, 0);
433
assert.deepStrictEqual(sub.value, state);
434
});
435
});
436
437
// AgentSubscriptionManager
438
439
suite('AgentSubscriptionManager', () => {
440
441
let disposables: DisposableStore;
442
let seq: number;
443
let subscribedResources: string[];
444
let unsubscribedResources: string[];
445
446
setup(() => {
447
disposables = new DisposableStore();
448
seq = 0;
449
subscribedResources = [];
450
unsubscribedResources = [];
451
});
452
453
teardown(() => {
454
disposables.dispose();
455
});
456
457
ensureNoDisposablesAreLeakedInTestSuite();
458
459
function createManager(): AgentSubscriptionManager {
460
return disposables.add(new AgentSubscriptionManager(
461
'c1',
462
() => ++seq,
463
noop,
464
async (resource) => {
465
subscribedResources.push(resource.toString());
466
const key = resource.toString();
467
if (key.startsWith('copilot:')) {
468
return { resource: key, state: makeSessionState(key), fromSeq: 0 };
469
}
470
return { resource: key, state: makeTerminalState(), fromSeq: 0 };
471
},
472
(resource) => {
473
unsubscribedResources.push(resource.toString());
474
},
475
));
476
}
477
478
test('rootState is available immediately', () => {
479
const mgr = createManager();
480
assert.ok(mgr.rootState);
481
assert.strictEqual(mgr.rootState.value, undefined);
482
});
483
484
test('handleRootSnapshot initializes root state', () => {
485
const mgr = createManager();
486
const state = makeRootState({ activeSessions: 2 });
487
mgr.handleRootSnapshot(state, 0);
488
assert.deepStrictEqual(mgr.rootState.value, state);
489
});
490
491
test('getSubscription returns IReference with subscription', async () => {
492
const mgr = createManager();
493
const uri = URI.parse(sessionUri);
494
const ref = mgr.getSubscription<SessionState>(StateComponents.Session, uri);
495
496
assert.ok(ref.object);
497
assert.strictEqual(ref.object.value, undefined); // not yet initialized (async)
498
499
// Wait for async subscribe
500
await new Promise(r => setTimeout(r, 0));
501
502
assert.ok(ref.object.value);
503
ref.dispose();
504
});
505
506
test('second call for same resource increments refcount', async () => {
507
const mgr = createManager();
508
const uri = URI.parse(sessionUri);
509
const ref1 = mgr.getSubscription<SessionState>(StateComponents.Session, uri);
510
const ref2 = mgr.getSubscription<SessionState>(StateComponents.Session, uri);
511
512
await new Promise(r => setTimeout(r, 0));
513
514
// Should be the same subscription object
515
assert.strictEqual(ref1.object, ref2.object);
516
517
// Disposing one ref should not trigger unsubscribe
518
ref1.dispose();
519
assert.strictEqual(unsubscribedResources.length, 0);
520
521
// Disposing the last ref should trigger unsubscribe
522
ref2.dispose();
523
assert.strictEqual(unsubscribedResources.length, 1);
524
});
525
526
test('disposing last ref calls unsubscribe callback', async () => {
527
const mgr = createManager();
528
const uri = URI.parse(sessionUri);
529
const ref = mgr.getSubscription<SessionState>(StateComponents.Session, uri);
530
531
await new Promise(r => setTimeout(r, 0));
532
533
ref.dispose();
534
assert.ok(unsubscribedResources.includes(sessionUri));
535
});
536
537
test('receiveEnvelope routes to root and all active subscriptions', async () => {
538
const mgr = createManager();
539
mgr.handleRootSnapshot(makeRootState(), 0);
540
541
const uri = URI.parse(sessionUri);
542
const ref = mgr.getSubscription<SessionState>(StateComponents.Session, uri);
543
await new Promise(r => setTimeout(r, 0));
544
545
// Send a root action
546
mgr.receiveEnvelope(makeEnvelope(
547
{ type: ActionType.RootActiveSessionsChanged, activeSessions: 10 },
548
1,
549
));
550
assert.strictEqual((mgr.rootState.value as RootState).activeSessions, 10);
551
552
// Send a session action
553
mgr.receiveEnvelope(makeEnvelope(
554
{ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Routed' },
555
2,
556
));
557
assert.strictEqual((ref.object.value as SessionState).summary.title, 'Routed');
558
559
ref.dispose();
560
});
561
562
test('creating session subscription for copilot: URI', async () => {
563
const mgr = createManager();
564
const mySessionUri = URI.from({ scheme: 'copilot', path: '/my-session' });
565
const ref = mgr.getSubscription<SessionState>(StateComponents.Session, mySessionUri);
566
await new Promise(r => setTimeout(r, 0));
567
568
assert.ok(ref.object.value);
569
assert.ok(subscribedResources.includes(mySessionUri.toString()));
570
571
ref.dispose();
572
});
573
574
test('creating terminal subscription for terminal URI', async () => {
575
const mgr = createManager();
576
const uri = URI.parse(terminalUri);
577
const ref = mgr.getSubscription<TerminalState>(StateComponents.Terminal, uri);
578
await new Promise(r => setTimeout(r, 0));
579
580
assert.ok(ref.object.value);
581
assert.ok(subscribedResources.includes(terminalUri));
582
583
ref.dispose();
584
});
585
586
test('dispatchOptimistic applies to matching session subscription', async () => {
587
const mgr = createManager();
588
const uri = URI.parse(sessionUri);
589
const ref = mgr.getSubscription<SessionState>(StateComponents.Session, uri);
590
await new Promise(r => setTimeout(r, 0));
591
592
const clientSeq = mgr.dispatchOptimistic({
593
type: ActionType.SessionTitleChanged,
594
session: sessionUri,
595
title: 'Dispatched',
596
});
597
598
assert.ok(clientSeq > 0);
599
assert.strictEqual((ref.object.value as SessionState).summary.title, 'Dispatched');
600
// verifiedValue unchanged
601
assert.strictEqual(ref.object.verifiedValue!.summary.title, 'Test');
602
603
ref.dispose();
604
});
605
606
test('dispose clears all subscriptions and calls unsubscribe for each', async () => {
607
const mgr = createManager();
608
609
const ref1 = mgr.getSubscription<SessionState>(StateComponents.Session, URI.parse(sessionUri));
610
const ref2 = mgr.getSubscription<TerminalState>(StateComponents.Terminal, URI.parse(terminalUri));
611
await new Promise(r => setTimeout(r, 0));
612
613
// Remove the manager from disposables so we can dispose it manually
614
// without double-dispose
615
disposables.delete(mgr);
616
mgr.dispose();
617
618
assert.ok(unsubscribedResources.includes(sessionUri));
619
assert.ok(unsubscribedResources.includes(terminalUri));
620
621
// Clean up refs (already disposed with manager, but safe to call)
622
ref1.dispose();
623
ref2.dispose();
624
});
625
626
test('getSubscriptionUnmanaged returns undefined when no subscription exists', () => {
627
const mgr = createManager();
628
const result = mgr.getSubscriptionUnmanaged<SessionState>(URI.parse('copilot:/nonexistent'));
629
assert.strictEqual(result, undefined);
630
});
631
632
test('getSubscriptionUnmanaged returns existing subscription without affecting refcount', async () => {
633
const mgr = createManager();
634
const uri = URI.parse(sessionUri);
635
636
// Create a subscription via getSubscription
637
const ref = mgr.getSubscription<SessionState>(StateComponents.Session, uri);
638
await new Promise(r => setTimeout(r, 0));
639
640
// Get it unmanaged
641
const unmanaged = mgr.getSubscriptionUnmanaged<SessionState>(uri);
642
assert.ok(unmanaged);
643
assert.strictEqual(unmanaged, ref.object);
644
645
// Dispose the ref. Subscription should be released (refcount was 1)
646
ref.dispose();
647
648
// Now unmanaged should return undefined since it was released
649
const after = mgr.getSubscriptionUnmanaged<SessionState>(uri);
650
assert.strictEqual(after, undefined);
651
});
652
});
653
654