Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts
13406 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 { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';
8
import { errorHandler } from '../../../../../base/common/errors.js';
9
import { URI } from '../../../../../base/common/uri.js';
10
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
11
import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugGenericEvent, IChatDebugLogProvider, IChatDebugModelTurnEvent, IChatDebugResolvedEventContent, IChatDebugToolCallEvent } from '../../common/chatDebugService.js';
12
import { ChatDebugServiceImpl } from '../../common/chatDebugServiceImpl.js';
13
import { LocalChatSessionUri } from '../../common/model/chatUri.js';
14
15
suite('ChatDebugServiceImpl', () => {
16
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
17
18
let service: ChatDebugServiceImpl;
19
20
const session1 = URI.parse('vscode-chat-session://local/session-1');
21
const session2 = URI.parse('vscode-chat-session://local/session-2');
22
const sessionA = LocalChatSessionUri.forSession('a');
23
const sessionB = LocalChatSessionUri.forSession('b');
24
const sessionGeneric = URI.parse('vscode-chat-session://local/session');
25
const nonLocalSession = URI.parse('some-other-scheme://authority/session-1');
26
const copilotCliSession = URI.parse('copilotcli:/test-session-id');
27
const claudeCodeSession = URI.parse('claude-code:/test-session-id');
28
29
setup(() => {
30
service = disposables.add(new ChatDebugServiceImpl());
31
});
32
33
suite('addEvent and getEvents', () => {
34
test('should add and retrieve events', () => {
35
const event: IChatDebugGenericEvent = {
36
kind: 'generic',
37
sessionResource: session1,
38
created: new Date(),
39
name: 'test-event',
40
level: ChatDebugLogLevel.Info,
41
};
42
43
service.addEvent(event);
44
45
assert.deepStrictEqual(service.getEvents(), [event]);
46
});
47
48
test('should filter events by sessionResource', () => {
49
const event1: IChatDebugGenericEvent = {
50
kind: 'generic',
51
sessionResource: session1,
52
created: new Date(),
53
name: 'event-1',
54
level: ChatDebugLogLevel.Info,
55
};
56
const event2: IChatDebugGenericEvent = {
57
kind: 'generic',
58
sessionResource: session2,
59
created: new Date(),
60
name: 'event-2',
61
level: ChatDebugLogLevel.Warning,
62
};
63
64
service.addEvent(event1);
65
service.addEvent(event2);
66
67
assert.deepStrictEqual(service.getEvents(session1), [event1]);
68
assert.deepStrictEqual(service.getEvents(session2), [event2]);
69
assert.strictEqual(service.getEvents().length, 2);
70
});
71
72
test('should fire onDidAddEvent when event is added', () => {
73
const firedEvents: IChatDebugEvent[] = [];
74
disposables.add(service.onDidAddEvent(e => firedEvents.push(e)));
75
76
const event: IChatDebugGenericEvent = {
77
kind: 'generic',
78
sessionResource: session1,
79
created: new Date(),
80
name: 'test',
81
level: ChatDebugLogLevel.Info,
82
};
83
service.addEvent(event);
84
85
assert.deepStrictEqual(firedEvents, [event]);
86
});
87
88
test('should handle different event kinds', () => {
89
const toolCall: IChatDebugToolCallEvent = {
90
kind: 'toolCall',
91
sessionResource: session1,
92
created: new Date(),
93
toolName: 'readFile',
94
toolCallId: 'call-1',
95
input: '{"path": "/foo.ts"}',
96
output: 'file contents',
97
result: 'success',
98
durationInMillis: 42,
99
};
100
const modelTurn: IChatDebugModelTurnEvent = {
101
kind: 'modelTurn',
102
sessionResource: session1,
103
created: new Date(),
104
model: 'gpt-4',
105
inputTokens: 100,
106
outputTokens: 50,
107
totalTokens: 150,
108
durationInMillis: 1200,
109
};
110
111
service.addEvent(toolCall);
112
service.addEvent(modelTurn);
113
114
const events = service.getEvents(session1);
115
assert.strictEqual(events.length, 2);
116
assert.strictEqual(events[0].kind, 'toolCall');
117
assert.strictEqual(events[1].kind, 'modelTurn');
118
});
119
});
120
121
suite('log', () => {
122
test('should create a generic event with defaults', () => {
123
const firedEvents: IChatDebugEvent[] = [];
124
disposables.add(service.onDidAddEvent(e => firedEvents.push(e)));
125
126
service.log(session1, 'Some name', 'Some details');
127
128
assert.strictEqual(firedEvents.length, 1);
129
const event = firedEvents[0];
130
assert.strictEqual(event.kind, 'generic');
131
assert.strictEqual(event.sessionResource.toString(), session1.toString());
132
assert.strictEqual((event as IChatDebugGenericEvent).name, 'Some name');
133
assert.strictEqual((event as IChatDebugGenericEvent).details, 'Some details');
134
assert.strictEqual((event as IChatDebugGenericEvent).level, ChatDebugLogLevel.Info);
135
});
136
137
test('should accept custom level and options', () => {
138
const firedEvents: IChatDebugEvent[] = [];
139
disposables.add(service.onDidAddEvent(e => firedEvents.push(e)));
140
141
service.log(session1, 'warning-event', 'oh no', ChatDebugLogLevel.Warning, {
142
id: 'my-id',
143
category: 'testing',
144
parentEventId: 'parent-1',
145
});
146
147
const event = firedEvents[0] as IChatDebugGenericEvent;
148
assert.strictEqual(event.level, ChatDebugLogLevel.Warning);
149
assert.strictEqual(event.id, 'my-id');
150
assert.strictEqual(event.category, 'testing');
151
assert.strictEqual(event.parentEventId, 'parent-1');
152
});
153
154
test('should not log events for ineligible session schemes', () => {
155
const firedEvents: IChatDebugEvent[] = [];
156
disposables.add(service.onDidAddEvent(e => firedEvents.push(e)));
157
158
service.log(nonLocalSession, 'should-be-skipped', 'details');
159
160
assert.strictEqual(firedEvents.length, 0);
161
assert.strictEqual(service.getEvents(nonLocalSession).length, 0);
162
});
163
164
test('should log events for copilotcli sessions', () => {
165
const firedEvents: IChatDebugEvent[] = [];
166
disposables.add(service.onDidAddEvent(e => firedEvents.push(e)));
167
168
service.log(copilotCliSession, 'cli-event', 'details');
169
170
assert.strictEqual(firedEvents.length, 1);
171
assert.strictEqual(service.getEvents(copilotCliSession).length, 1);
172
});
173
174
test('should log events for claude-code sessions', () => {
175
const firedEvents: IChatDebugEvent[] = [];
176
disposables.add(service.onDidAddEvent(e => firedEvents.push(e)));
177
178
service.log(claudeCodeSession, 'claude-event', 'details');
179
180
assert.strictEqual(firedEvents.length, 1);
181
assert.strictEqual(service.getEvents(claudeCodeSession).length, 1);
182
});
183
});
184
185
suite('getSessionResources', () => {
186
test('should return unique session resources', () => {
187
service.addEvent({ kind: 'generic', sessionResource: sessionA, created: new Date(), name: 'e1', level: ChatDebugLogLevel.Info });
188
service.addEvent({ kind: 'generic', sessionResource: sessionB, created: new Date(), name: 'e2', level: ChatDebugLogLevel.Info });
189
service.addEvent({ kind: 'generic', sessionResource: sessionA, created: new Date(), name: 'e3', level: ChatDebugLogLevel.Info });
190
191
const resources = service.getSessionResources();
192
assert.strictEqual(resources.length, 2);
193
});
194
195
test('should return empty array when no events', () => {
196
assert.deepStrictEqual(service.getSessionResources(), []);
197
});
198
});
199
200
suite('clear', () => {
201
test('should clear all events', () => {
202
service.addEvent({ kind: 'generic', sessionResource: sessionA, created: new Date(), name: 'e', level: ChatDebugLogLevel.Info });
203
service.addEvent({ kind: 'generic', sessionResource: sessionB, created: new Date(), name: 'e', level: ChatDebugLogLevel.Info });
204
205
service.clear();
206
207
assert.strictEqual(service.getEvents().length, 0);
208
});
209
});
210
211
suite('MAX_EVENTS_PER_SESSION cap', () => {
212
test('should evict oldest events when exceeding per-session cap', () => {
213
// The max per session is 10_000. Add more than that to a single session.
214
for (let i = 0; i < 10_001; i++) {
215
service.addEvent({ kind: 'generic', sessionResource: sessionGeneric, created: new Date(), name: `event-${i}`, level: ChatDebugLogLevel.Info });
216
}
217
218
const events = service.getEvents();
219
assert.ok(events.length <= 10_000, 'Should not exceed MAX_EVENTS_PER_SESSION');
220
// The first event should have been evicted
221
assert.ok(!(events as IChatDebugGenericEvent[]).find(e => e.name === 'event-0'), 'Event-0 should have been evicted');
222
// The last event should be present
223
assert.ok((events as IChatDebugGenericEvent[]).find(e => e.name === 'event-10000'), 'Last event should be present');
224
});
225
226
test('should evict oldest session when exceeding MAX_SESSIONS', () => {
227
// MAX_SESSIONS is 5 — add events to 6 different sessions
228
const sessions: URI[] = [];
229
for (let i = 0; i < 6; i++) {
230
const uri = URI.parse(`vscode-chat-session://local/session-lru-${i}`);
231
sessions.push(uri);
232
service.addEvent({ kind: 'generic', sessionResource: uri, created: new Date(), name: `event-${i}`, level: ChatDebugLogLevel.Info });
233
}
234
235
const resources = service.getSessionResources();
236
assert.strictEqual(resources.length, 5, 'Should not exceed MAX_SESSIONS');
237
// The first session should have been evicted
238
assert.ok(!resources.some(r => r.toString() === sessions[0].toString()), 'Session-0 should have been evicted');
239
assert.strictEqual(service.getEvents(sessions[0]).length, 0, 'Events from evicted session should be gone');
240
// The last session should be present
241
assert.ok(resources.some(r => r.toString() === sessions[5].toString()), 'Session-5 should be present');
242
});
243
244
test('should use LRU eviction — recently-used sessions are kept', () => {
245
// Fill to MAX_SESSIONS (5)
246
const sessions: URI[] = [];
247
for (let i = 0; i < 5; i++) {
248
const uri = URI.parse(`vscode-chat-session://local/session-lru2-${i}`);
249
sessions.push(uri);
250
service.addEvent({ kind: 'generic', sessionResource: uri, created: new Date(), name: `init-${i}`, level: ChatDebugLogLevel.Info });
251
}
252
253
// Touch session-0 so it moves to the back of the LRU order
254
service.addEvent({ kind: 'generic', sessionResource: sessions[0], created: new Date(), name: 'touch', level: ChatDebugLogLevel.Info });
255
256
// Add a 6th session — session-1 (the true LRU) should be evicted, not session-0
257
const session6 = URI.parse('vscode-chat-session://local/session-lru2-5');
258
service.addEvent({ kind: 'generic', sessionResource: session6, created: new Date(), name: 'new', level: ChatDebugLogLevel.Info });
259
260
const resources = service.getSessionResources();
261
assert.strictEqual(resources.length, 5);
262
assert.ok(resources.some(r => r.toString() === sessions[0].toString()), 'Session-0 should be kept (recently used)');
263
assert.ok(!resources.some(r => r.toString() === sessions[1].toString()), 'Session-1 should be evicted (LRU)');
264
assert.ok(resources.some(r => r.toString() === session6.toString()), 'Session-5 should be present');
265
});
266
});
267
268
suite('activeSessionResource', () => {
269
test('should default to undefined', () => {
270
assert.strictEqual(service.activeSessionResource, undefined);
271
});
272
273
test('should be settable', () => {
274
service.activeSessionResource = session1;
275
276
assert.strictEqual(service.activeSessionResource, session1);
277
});
278
});
279
280
suite('registerProvider', () => {
281
test('should register and unregister a provider', async () => {
282
const extSession = URI.parse('vscode-chat-session://local/ext-session');
283
const provider: IChatDebugLogProvider = {
284
provideChatDebugLog: async () => [{
285
kind: 'generic',
286
sessionResource: extSession,
287
created: new Date(),
288
name: 'from-provider',
289
level: ChatDebugLogLevel.Info,
290
}],
291
};
292
293
const reg = service.registerProvider(provider);
294
await service.invokeProviders(extSession);
295
296
const events = service.getEvents(extSession);
297
assert.ok(events.some(e => e.kind === 'generic' && (e as IChatDebugGenericEvent).name === 'from-provider'));
298
299
reg.dispose();
300
});
301
302
test('provider returning undefined should not add events', async () => {
303
const emptySession = URI.parse('vscode-chat-session://local/empty-session');
304
const provider: IChatDebugLogProvider = {
305
provideChatDebugLog: async () => undefined,
306
};
307
308
disposables.add(service.registerProvider(provider));
309
await service.invokeProviders(emptySession);
310
311
assert.strictEqual(service.getEvents(emptySession).length, 0);
312
});
313
314
test('provider errors should be handled gracefully', async () => {
315
const errorSession = URI.parse('vscode-chat-session://local/error-session');
316
const provider: IChatDebugLogProvider = {
317
provideChatDebugLog: async () => { throw new Error('boom'); },
318
};
319
320
disposables.add(service.registerProvider(provider));
321
// Suppress the expected onUnexpectedError from _invokeProvider
322
const origHandler = errorHandler.getUnexpectedErrorHandler();
323
errorHandler.setUnexpectedErrorHandler(() => { });
324
try {
325
await service.invokeProviders(errorSession);
326
} finally {
327
errorHandler.setUnexpectedErrorHandler(origHandler);
328
}
329
assert.strictEqual(service.getEvents(errorSession).length, 0);
330
});
331
});
332
333
suite('invokeProviders', () => {
334
test('should invoke multiple providers and merge events', async () => {
335
const providerA: IChatDebugLogProvider = {
336
provideChatDebugLog: async () => [{
337
kind: 'generic',
338
sessionResource: sessionGeneric,
339
created: new Date(),
340
name: 'from-A',
341
level: ChatDebugLogLevel.Info,
342
}],
343
};
344
const providerB: IChatDebugLogProvider = {
345
provideChatDebugLog: async () => [{
346
kind: 'generic',
347
sessionResource: sessionGeneric,
348
created: new Date(),
349
name: 'from-B',
350
level: ChatDebugLogLevel.Info,
351
}],
352
};
353
354
disposables.add(service.registerProvider(providerA));
355
disposables.add(service.registerProvider(providerB));
356
await service.invokeProviders(sessionGeneric);
357
358
const names = (service.getEvents(sessionGeneric) as IChatDebugGenericEvent[]).map(e => e.name);
359
assert.ok(names.includes('from-A'));
360
assert.ok(names.includes('from-B'));
361
});
362
363
test('should cancel previous invocation for same session', async () => {
364
let cancelledToken: CancellationToken | undefined;
365
366
const provider: IChatDebugLogProvider = {
367
provideChatDebugLog: async (_sessionResource, token) => {
368
cancelledToken = token;
369
return [];
370
},
371
};
372
373
disposables.add(service.registerProvider(provider));
374
375
// First invocation
376
await service.invokeProviders(sessionGeneric);
377
const firstToken = cancelledToken!;
378
379
// Second invocation for same session should cancel the first
380
await service.invokeProviders(sessionGeneric);
381
assert.strictEqual(firstToken.isCancellationRequested, true);
382
});
383
384
test('should fire onDidClearProviderEvents when clearing provider events', async () => {
385
const clearedSessions: URI[] = [];
386
disposables.add(service.onDidClearProviderEvents(sessionResource => clearedSessions.push(sessionResource)));
387
388
const provider: IChatDebugLogProvider = {
389
provideChatDebugLog: async (sessionResource) => [{
390
kind: 'generic',
391
sessionResource,
392
created: new Date(),
393
name: 'provider-event',
394
level: ChatDebugLogLevel.Info,
395
}],
396
};
397
398
disposables.add(service.registerProvider(provider));
399
400
// First invocation clears empty set and fires clear event
401
await service.invokeProviders(sessionGeneric);
402
assert.strictEqual(clearedSessions.length, 1, 'Clear event should fire on first invocation');
403
404
// Second invocation clears provider events from first invocation
405
await service.invokeProviders(sessionGeneric);
406
assert.strictEqual(clearedSessions.length, 2, 'Clear event should fire on second invocation');
407
assert.strictEqual(clearedSessions[1].toString(), sessionGeneric.toString());
408
});
409
410
test('should not cancel invocations for different sessions', async () => {
411
const tokens: Map<string, CancellationToken> = new Map();
412
413
const provider: IChatDebugLogProvider = {
414
provideChatDebugLog: async (sessionResource, token) => {
415
tokens.set(sessionResource.toString(), token);
416
return [];
417
},
418
};
419
420
disposables.add(service.registerProvider(provider));
421
422
await service.invokeProviders(sessionA);
423
await service.invokeProviders(sessionB);
424
425
const tokenA = tokens.get(sessionA.toString())!;
426
assert.strictEqual(tokenA.isCancellationRequested, false, 'session-a token should not be cancelled');
427
});
428
429
test('should not invoke providers for ineligible session schemes', async () => {
430
let providerCalled = false;
431
432
const provider: IChatDebugLogProvider = {
433
provideChatDebugLog: async () => {
434
providerCalled = true;
435
return [{
436
kind: 'generic',
437
sessionResource: nonLocalSession,
438
created: new Date(),
439
name: 'should-not-appear',
440
level: ChatDebugLogLevel.Info,
441
}];
442
},
443
};
444
445
disposables.add(service.registerProvider(provider));
446
await service.invokeProviders(nonLocalSession);
447
448
assert.strictEqual(providerCalled, false);
449
assert.strictEqual(service.getEvents(nonLocalSession).length, 0);
450
});
451
452
test('should invoke providers for copilotcli sessions', async () => {
453
let providerCalled = false;
454
455
const provider: IChatDebugLogProvider = {
456
provideChatDebugLog: async () => {
457
providerCalled = true;
458
return [{
459
kind: 'generic',
460
sessionResource: copilotCliSession,
461
created: new Date(),
462
name: 'cli-provider-event',
463
level: ChatDebugLogLevel.Info,
464
}];
465
},
466
};
467
468
disposables.add(service.registerProvider(provider));
469
await service.invokeProviders(copilotCliSession);
470
471
assert.strictEqual(providerCalled, true);
472
assert.ok(service.getEvents(copilotCliSession).length > 0);
473
});
474
475
test('should invoke providers for claude-code sessions', async () => {
476
let providerCalled = false;
477
478
const provider: IChatDebugLogProvider = {
479
provideChatDebugLog: async () => {
480
providerCalled = true;
481
return [{
482
kind: 'generic',
483
sessionResource: claudeCodeSession,
484
created: new Date(),
485
name: 'claude-provider-event',
486
level: ChatDebugLogLevel.Info,
487
}];
488
},
489
};
490
491
disposables.add(service.registerProvider(provider));
492
await service.invokeProviders(claudeCodeSession);
493
494
assert.strictEqual(providerCalled, true);
495
assert.ok(service.getEvents(claudeCodeSession).length > 0);
496
});
497
498
test('newly registered provider should be invoked for active sessions', async () => {
499
// Start an invocation before the provider is registered
500
const firstProvider: IChatDebugLogProvider = {
501
provideChatDebugLog: async () => [],
502
};
503
disposables.add(service.registerProvider(firstProvider));
504
await service.invokeProviders(sessionGeneric);
505
506
// Now register a new provider — it should be invoked for the active session
507
const lateEvents: IChatDebugEvent[] = [];
508
const lateProvider: IChatDebugLogProvider = {
509
provideChatDebugLog: async () => {
510
const event: IChatDebugGenericEvent = {
511
kind: 'generic',
512
sessionResource: sessionGeneric,
513
created: new Date(),
514
name: 'late-provider-event',
515
level: ChatDebugLogLevel.Info,
516
};
517
lateEvents.push(event);
518
return [event];
519
},
520
};
521
522
disposables.add(service.registerProvider(lateProvider));
523
524
// Give it a tick to let the async invocation complete
525
await new Promise(resolve => setTimeout(resolve, 10));
526
527
assert.ok(lateEvents.length > 0, 'Late provider should have been invoked');
528
});
529
});
530
531
suite('resolveEvent', () => {
532
test('should delegate to provider with resolveChatDebugLogEvent', async () => {
533
const resolved: IChatDebugResolvedEventContent = {
534
kind: 'text',
535
value: 'resolved detail text',
536
};
537
538
const provider: IChatDebugLogProvider = {
539
provideChatDebugLog: async () => undefined,
540
resolveChatDebugLogEvent: async (eventId) => {
541
if (eventId === 'my-event') {
542
return resolved;
543
}
544
return undefined;
545
},
546
};
547
548
disposables.add(service.registerProvider(provider));
549
550
const result = await service.resolveEvent('my-event');
551
assert.deepStrictEqual(result, resolved);
552
});
553
554
test('should return undefined if no provider resolves the event', async () => {
555
const provider: IChatDebugLogProvider = {
556
provideChatDebugLog: async () => undefined,
557
resolveChatDebugLogEvent: async () => undefined,
558
};
559
560
disposables.add(service.registerProvider(provider));
561
562
const result = await service.resolveEvent('nonexistent');
563
assert.strictEqual(result, undefined);
564
});
565
566
test('should return undefined when no providers registered', async () => {
567
const result = await service.resolveEvent('any-id');
568
assert.strictEqual(result, undefined);
569
});
570
571
test('should return first non-undefined resolution from multiple providers', async () => {
572
const provider1: IChatDebugLogProvider = {
573
provideChatDebugLog: async () => undefined,
574
resolveChatDebugLogEvent: async () => undefined,
575
};
576
const provider2: IChatDebugLogProvider = {
577
provideChatDebugLog: async () => undefined,
578
resolveChatDebugLogEvent: async () => ({ kind: 'text', value: 'from provider 2' }),
579
};
580
581
disposables.add(service.registerProvider(provider1));
582
disposables.add(service.registerProvider(provider2));
583
584
const result = await service.resolveEvent('any');
585
assert.deepStrictEqual(result, { kind: 'text', value: 'from provider 2' });
586
});
587
});
588
589
suite('endSession', () => {
590
test('should cancel and remove the CTS for a session', async () => {
591
let capturedToken: CancellationToken | undefined;
592
593
const provider: IChatDebugLogProvider = {
594
provideChatDebugLog: async (_sessionResource, token) => {
595
capturedToken = token;
596
return [];
597
},
598
};
599
600
disposables.add(service.registerProvider(provider));
601
await service.invokeProviders(sessionGeneric);
602
603
assert.ok(capturedToken);
604
assert.strictEqual(capturedToken.isCancellationRequested, false);
605
606
service.endSession(sessionGeneric);
607
608
assert.strictEqual(capturedToken.isCancellationRequested, true);
609
});
610
611
test('should be safe to call for unknown session', () => {
612
// Should not throw
613
service.endSession(URI.parse('vscode-chat-session://local/nonexistent'));
614
});
615
616
test('late provider should not be invoked for ended session', async () => {
617
const firstProvider: IChatDebugLogProvider = {
618
provideChatDebugLog: async () => [],
619
};
620
disposables.add(service.registerProvider(firstProvider));
621
await service.invokeProviders(sessionGeneric);
622
623
service.endSession(sessionGeneric);
624
625
let lateCalled = false;
626
const lateProvider: IChatDebugLogProvider = {
627
provideChatDebugLog: async () => {
628
lateCalled = true;
629
return [];
630
},
631
};
632
disposables.add(service.registerProvider(lateProvider));
633
634
await new Promise(resolve => setTimeout(resolve, 10));
635
assert.strictEqual(lateCalled, false, 'Late provider should not be invoked for ended session');
636
});
637
});
638
639
suite('dispose', () => {
640
test('should cancel active invocations on dispose', async () => {
641
let capturedToken: CancellationToken | undefined;
642
643
const provider: IChatDebugLogProvider = {
644
provideChatDebugLog: async (_sessionResource, token) => {
645
capturedToken = token;
646
return [];
647
},
648
};
649
650
disposables.add(service.registerProvider(provider));
651
await service.invokeProviders(sessionGeneric);
652
653
const cts = new CancellationTokenSource();
654
disposables.add(cts);
655
656
service.dispose();
657
658
assert.ok(capturedToken);
659
assert.strictEqual(capturedToken.isCancellationRequested, true);
660
});
661
});
662
663
suite('event deduplication', () => {
664
test('should deduplicate events with the same ID, keeping the richer kind', () => {
665
const userMsg: IChatDebugEvent = {
666
kind: 'userMessage',
667
id: 'shared-id-1',
668
sessionResource: session1,
669
created: new Date('2026-01-01T00:00:00Z'),
670
message: 'hello',
671
sections: [],
672
};
673
const subagent: IChatDebugEvent = {
674
kind: 'subagentInvocation',
675
id: 'shared-id-1',
676
sessionResource: session1,
677
created: new Date('2026-01-01T00:00:01Z'),
678
agentName: 'Explore',
679
};
680
service.addEvent(userMsg);
681
service.addEvent(subagent);
682
683
const events = service.getEvents(session1);
684
assert.strictEqual(events.length, 1);
685
assert.strictEqual(events[0].kind, 'subagentInvocation');
686
});
687
688
test('should keep richer event when it arrives first', () => {
689
const subagent: IChatDebugEvent = {
690
kind: 'subagentInvocation',
691
id: 'shared-id-2',
692
sessionResource: session1,
693
created: new Date('2026-01-01T00:00:00Z'),
694
agentName: 'Explore',
695
};
696
const userMsg: IChatDebugEvent = {
697
kind: 'userMessage',
698
id: 'shared-id-2',
699
sessionResource: session1,
700
created: new Date('2026-01-01T00:00:01Z'),
701
message: 'hello',
702
sections: [],
703
};
704
service.addEvent(subagent);
705
service.addEvent(userMsg);
706
707
const events = service.getEvents(session1);
708
assert.strictEqual(events.length, 1);
709
assert.strictEqual(events[0].kind, 'subagentInvocation');
710
});
711
712
test('should not fire onDidAddEvent for skipped duplicates', () => {
713
const firedKinds: string[] = [];
714
disposables.add(service.onDidAddEvent(e => firedKinds.push(e.kind)));
715
716
const subagent: IChatDebugEvent = {
717
kind: 'subagentInvocation',
718
id: 'shared-id-3',
719
sessionResource: session1,
720
created: new Date('2026-01-01T00:00:00Z'),
721
agentName: 'Explore',
722
};
723
const userMsg: IChatDebugEvent = {
724
kind: 'userMessage',
725
id: 'shared-id-3',
726
sessionResource: session1,
727
created: new Date('2026-01-01T00:00:01Z'),
728
message: 'hello',
729
sections: [],
730
};
731
service.addEvent(subagent);
732
service.addEvent(userMsg); // should be skipped
733
734
assert.deepStrictEqual(firedKinds, ['subagentInvocation']);
735
});
736
737
test('should allow events without IDs to coexist', () => {
738
const event1: IChatDebugGenericEvent = {
739
kind: 'generic',
740
sessionResource: session1,
741
created: new Date('2026-01-01T00:00:00Z'),
742
name: 'a',
743
level: ChatDebugLogLevel.Info,
744
};
745
const event2: IChatDebugGenericEvent = {
746
kind: 'generic',
747
sessionResource: session1,
748
created: new Date('2026-01-01T00:00:01Z'),
749
name: 'b',
750
level: ChatDebugLogLevel.Info,
751
};
752
service.addEvent(event1);
753
service.addEvent(event2);
754
755
const events = service.getEvents(session1);
756
assert.strictEqual(events.length, 2);
757
});
758
});
759
});
760
761