Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts
13401 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 { timeout } from '../../../../base/common/async.js';
7
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
8
import { Emitter, Event } from '../../../../base/common/event.js';
9
import { onUnexpectedError } from '../../../../base/common/errors.js';
10
import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
11
import { ResourceMap } from '../../../../base/common/map.js';
12
import { extUri } from '../../../../base/common/resources.js';
13
import { URI } from '../../../../base/common/uri.js';
14
import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugLogProvider, IChatDebugResolvedEventContent, IChatDebugService } from './chatDebugService.js';
15
import { LocalChatSessionUri } from './model/chatUri.js';
16
17
/**
18
* Per-session circular buffer for debug events.
19
* Stores up to `capacity` events using a ring buffer.
20
*/
21
class SessionEventBuffer {
22
private readonly _buffer: (IChatDebugEvent | undefined)[];
23
private _head = 0;
24
private _size = 0;
25
26
constructor(readonly capacity: number) {
27
this._buffer = new Array(capacity);
28
}
29
30
get size(): number {
31
return this._size;
32
}
33
34
push(event: IChatDebugEvent): void {
35
const idx = (this._head + this._size) % this.capacity;
36
this._buffer[idx] = event;
37
if (this._size < this.capacity) {
38
this._size++;
39
} else {
40
this._head = (this._head + 1) % this.capacity;
41
}
42
}
43
44
/** Return events in insertion order. */
45
toArray(): IChatDebugEvent[] {
46
const result: IChatDebugEvent[] = [];
47
for (let i = 0; i < this._size; i++) {
48
const event = this._buffer[(this._head + i) % this.capacity];
49
if (event) {
50
result.push(event);
51
}
52
}
53
return result;
54
}
55
56
/** Remove events matching the predicate and compact in-place. */
57
removeWhere(predicate: (event: IChatDebugEvent) => boolean): void {
58
let write = 0;
59
for (let i = 0; i < this._size; i++) {
60
const idx = (this._head + i) % this.capacity;
61
const event = this._buffer[idx];
62
if (event && predicate(event)) {
63
continue;
64
}
65
if (write !== i) {
66
const writeIdx = (this._head + write) % this.capacity;
67
this._buffer[writeIdx] = event;
68
}
69
write++;
70
}
71
for (let i = write; i < this._size; i++) {
72
this._buffer[(this._head + i) % this.capacity] = undefined;
73
}
74
this._size = write;
75
}
76
77
clear(): void {
78
this._buffer.fill(undefined);
79
this._head = 0;
80
this._size = 0;
81
}
82
}
83
84
export class ChatDebugServiceImpl extends Disposable implements IChatDebugService {
85
declare readonly _serviceBrand: undefined;
86
87
static readonly MAX_EVENTS_PER_SESSION = 10_000;
88
static readonly MAX_SESSIONS = 5;
89
90
/** Per-session event buffers. Ordered from oldest to newest session (LRU). */
91
private readonly _sessionBuffers = new ResourceMap<SessionEventBuffer>();
92
/** Ordered list of session URIs for LRU eviction. */
93
private readonly _sessionOrder: URI[] = [];
94
/** Per-session tracking of seen event IDs to deduplicate events
95
* that share the same ID (e.g. subagentInvocation + userMessage
96
* emitted from the same span). Stores id → event kind so we can
97
* keep the richer event kind on collision. */
98
private readonly _seenEventIds = new ResourceMap<Map<string, IChatDebugEvent['kind']>>();
99
100
private readonly _onDidAddEvent = this._register(new Emitter<IChatDebugEvent>());
101
readonly onDidAddEvent: Event<IChatDebugEvent> = this._onDidAddEvent.event;
102
103
private readonly _onDidClearProviderEvents = this._register(new Emitter<URI>());
104
readonly onDidClearProviderEvents: Event<URI> = this._onDidClearProviderEvents.event;
105
106
private readonly _onDidChangeAvailableSessionResources = this._register(new Emitter<void>());
107
readonly onDidChangeAvailableSessionResources: Event<void> = this._onDidChangeAvailableSessionResources.event;
108
109
private readonly _providers = new Set<IChatDebugLogProvider>();
110
private readonly _invocationCts = new ResourceMap<CancellationTokenSource>();
111
112
/** Events that were returned by providers (not internally logged). */
113
private readonly _providerEvents = new WeakSet<IChatDebugEvent>();
114
115
/** Session URIs created via import. */
116
private readonly _importedSessions = new ResourceMap<boolean>();
117
118
/** Session URIs reported by providers as available on disk (historical sessions). */
119
private readonly _availableSessionResources: URI[] = [];
120
private readonly _availableSessionResourceSet = new Set<string>();
121
122
/** Titles for historical sessions discovered from disk. */
123
private readonly _historicalSessionTitles = new ResourceMap<string>();
124
125
/** Human-readable titles for imported sessions. */
126
private readonly _importedSessionTitles = new ResourceMap<string>();
127
128
activeSessionResource: URI | undefined;
129
130
/** Priority for deduplicating events with the same ID: lower = richer. */
131
private static readonly _eventKindPriority: Record<string, number> = {
132
subagentInvocation: 0,
133
modelTurn: 1,
134
toolCall: 2,
135
agentResponse: 3,
136
userMessage: 4,
137
generic: 5,
138
};
139
140
/** Schemes eligible for debug logging and provider invocation. */
141
private static readonly _debugEligibleSchemes = new Set([
142
LocalChatSessionUri.scheme, // vscode-chat-session (local sessions)
143
'copilotcli', // Copilot CLI background sessions
144
'claude-code', // Claude Code CLI sessions
145
]);
146
147
private _isDebugEligibleSession(sessionResource: URI): boolean {
148
return ChatDebugServiceImpl._debugEligibleSchemes.has(sessionResource.scheme)
149
|| this._importedSessions.has(sessionResource);
150
}
151
152
log(sessionResource: URI, name: string, details?: string, level: ChatDebugLogLevel = ChatDebugLogLevel.Info, options?: { id?: string; category?: string; parentEventId?: string }): void {
153
if (!this._isDebugEligibleSession(sessionResource)) {
154
return;
155
}
156
this.addEvent({
157
kind: 'generic',
158
id: options?.id,
159
sessionResource,
160
created: new Date(),
161
name,
162
details,
163
level,
164
category: options?.category,
165
parentEventId: options?.parentEventId,
166
});
167
}
168
169
addEvent(event: IChatDebugEvent): void {
170
// Deduplicate events that share the same ID. The extension may emit
171
// both a subagentInvocation and a userMessage from the same span;
172
// keep the richer kind and discard the duplicate.
173
if (event.id) {
174
let seen = this._seenEventIds.get(event.sessionResource);
175
if (!seen) {
176
seen = new Map();
177
this._seenEventIds.set(event.sessionResource, seen);
178
}
179
const existingKind = seen.get(event.id);
180
if (existingKind !== undefined) {
181
const priority = ChatDebugServiceImpl._eventKindPriority;
182
if ((priority[event.kind] ?? 5) >= (priority[existingKind] ?? 5)) {
183
return; // existing is richer or equal; skip this event
184
}
185
// New event is richer — we can't remove the old one from
186
// the ring buffer, but the duplicate will be filtered out
187
// in getEvents(). Update the tracked kind.
188
}
189
seen.set(event.id, event.kind);
190
// Cap the dedup map to prevent unbounded growth in long sessions.
191
if (seen.size > ChatDebugServiceImpl.MAX_EVENTS_PER_SESSION) {
192
// Delete the oldest entry (first key in insertion order).
193
const firstKey = seen.keys().next().value;
194
if (firstKey !== undefined) {
195
seen.delete(firstKey);
196
}
197
}
198
}
199
200
let buffer = this._sessionBuffers.get(event.sessionResource);
201
if (!buffer) {
202
// Evict least-recently-used session if we are at the session cap.
203
if (this._sessionOrder.length >= ChatDebugServiceImpl.MAX_SESSIONS) {
204
const evicted = this._sessionOrder.shift()!;
205
this._evictSession(evicted);
206
}
207
buffer = new SessionEventBuffer(ChatDebugServiceImpl.MAX_EVENTS_PER_SESSION);
208
this._sessionBuffers.set(event.sessionResource, buffer);
209
this._sessionOrder.push(event.sessionResource);
210
} else {
211
// Move to end of LRU order so actively-used sessions are not evicted.
212
// Fast-path: during streaming/backfill all events target the same
213
// session which is already at the tail — skip the linear scan.
214
const last = this._sessionOrder.length - 1;
215
if (last < 0 || !extUri.isEqual(this._sessionOrder[last], event.sessionResource)) {
216
const idx = this._sessionOrder.findIndex(u => extUri.isEqual(u, event.sessionResource));
217
if (idx !== -1 && idx !== last) {
218
this._sessionOrder.splice(idx, 1);
219
this._sessionOrder.push(event.sessionResource);
220
}
221
}
222
}
223
buffer.push(event);
224
this._onDidAddEvent.fire(event);
225
}
226
227
addProviderEvent(event: IChatDebugEvent): void {
228
this._providerEvents.add(event);
229
this.addEvent(event);
230
}
231
232
getEvents(sessionResource?: URI): readonly IChatDebugEvent[] {
233
if (sessionResource) {
234
const buffer = this._sessionBuffers.get(sessionResource);
235
if (!buffer) {
236
return [];
237
}
238
let result = buffer.toArray();
239
// Sort only when the buffer is not in chronological order,
240
// which can happen when events arrive out of order (e.g.
241
// tail-first backfill). When events arrive in
242
// order (the common case) the check is O(n) with no sort.
243
if (!this._isSorted(result)) {
244
result.sort((a, b) => a.created.getTime() - b.created.getTime());
245
}
246
// Deduplicate: when multiple events share the same ID (e.g.
247
// subagentInvocation + userMessage from the same span), keep
248
// the one with the richest kind.
249
result = this._deduplicateEvents(result);
250
return result;
251
}
252
253
// Cross-session query: merge all buffers and sort to interleave.
254
const result: IChatDebugEvent[] = [];
255
for (const buffer of this._sessionBuffers.values()) {
256
result.push(...buffer.toArray());
257
}
258
result.sort((a, b) => a.created.getTime() - b.created.getTime());
259
return result;
260
}
261
262
private _isSorted(events: IChatDebugEvent[]): boolean {
263
for (let i = 1; i < events.length; i++) {
264
if (events[i].created.getTime() < events[i - 1].created.getTime()) {
265
return false;
266
}
267
}
268
return true;
269
}
270
271
private _deduplicateEvents(events: IChatDebugEvent[]): IChatDebugEvent[] {
272
const seen = new Map<string, number>(); // id → index in result
273
const priority = ChatDebugServiceImpl._eventKindPriority;
274
const result: IChatDebugEvent[] = [];
275
for (const event of events) {
276
if (!event.id) {
277
result.push(event);
278
continue;
279
}
280
const existingIdx = seen.get(event.id);
281
if (existingIdx === undefined) {
282
seen.set(event.id, result.length);
283
result.push(event);
284
} else {
285
const existing = result[existingIdx];
286
if ((priority[event.kind] ?? 5) < (priority[existing.kind] ?? 5)) {
287
result[existingIdx] = event;
288
}
289
}
290
}
291
return result;
292
}
293
294
getSessionResources(): readonly URI[] {
295
return [...this._sessionOrder];
296
}
297
298
clear(): void {
299
this._sessionBuffers.clear();
300
this._sessionOrder.length = 0;
301
this._seenEventIds.clear();
302
this._importedSessions.clear();
303
this._importedSessionTitles.clear();
304
this._availableSessionResources.length = 0;
305
this._availableSessionResourceSet.clear();
306
this._historicalSessionTitles.clear();
307
}
308
309
/** Remove all ancillary state for an evicted session. */
310
private _evictSession(sessionResource: URI): void {
311
this._sessionBuffers.delete(sessionResource);
312
this._seenEventIds.delete(sessionResource);
313
this._importedSessions.delete(sessionResource);
314
this._importedSessionTitles.delete(sessionResource);
315
const cts = this._invocationCts.get(sessionResource);
316
if (cts) {
317
cts.cancel();
318
cts.dispose();
319
this._invocationCts.delete(sessionResource);
320
}
321
}
322
323
registerProvider(provider: IChatDebugLogProvider): IDisposable {
324
this._providers.add(provider);
325
326
// Invoke the new provider for all sessions that already have active
327
// pipelines. This handles the case where invokeProviders() was called
328
// before this provider was registered (e.g. extension activated late).
329
for (const [sessionResource, cts] of this._invocationCts) {
330
if (!cts.token.isCancellationRequested) {
331
this._invokeProvider(provider, sessionResource, cts.token).catch(onUnexpectedError);
332
}
333
}
334
335
return toDisposable(() => {
336
this._providers.delete(provider);
337
});
338
}
339
340
hasInvokedProviders(sessionResource: URI): boolean {
341
return this._invocationCts.has(sessionResource);
342
}
343
344
async invokeProviders(sessionResource: URI): Promise<void> {
345
346
if (!this._isDebugEligibleSession(sessionResource)) {
347
return;
348
}
349
// Cancel only the previous invocation for THIS session, not others.
350
// Each session has its own pipeline so events from multiple sessions
351
// can be streamed concurrently.
352
const existingCts = this._invocationCts.get(sessionResource);
353
if (existingCts) {
354
existingCts.cancel();
355
existingCts.dispose();
356
}
357
358
// Clear only provider-sourced events for this session to avoid
359
// duplicates when re-invoking (e.g. navigating back to a session).
360
// Internally-logged events (e.g. prompt discovery) are preserved.
361
this._clearProviderEvents(sessionResource);
362
363
const cts = new CancellationTokenSource();
364
this._invocationCts.set(sessionResource, cts);
365
366
try {
367
const promises = [...this._providers].map(provider =>
368
this._invokeProvider(provider, sessionResource, cts.token)
369
);
370
await Promise.allSettled(promises);
371
} catch (err) {
372
onUnexpectedError(err);
373
}
374
// Note: do NOT dispose the CTS here - the token is used by the
375
// extension-side progress pipeline which stays alive for streaming.
376
// It will be cancelled+disposed when re-invoking the same session
377
// or when the service is disposed.
378
}
379
380
private async _invokeProvider(provider: IChatDebugLogProvider, sessionResource: URI, token: CancellationToken): Promise<void> {
381
try {
382
const events = await provider.provideChatDebugLog(sessionResource, token);
383
if (events) {
384
// Yield to the event loop periodically so the UI stays
385
// responsive when a provider returns a large batch of events
386
// (e.g. importing a multi-MB log file).
387
const BATCH_SIZE = 500;
388
for (let i = 0; i < events.length; i++) {
389
if (token.isCancellationRequested) {
390
break;
391
}
392
this.addProviderEvent({
393
...events[i],
394
sessionResource: events[i].sessionResource ?? sessionResource,
395
});
396
if (i > 0 && i % BATCH_SIZE === 0) {
397
await timeout(0);
398
}
399
}
400
}
401
} catch (err) {
402
onUnexpectedError(err);
403
}
404
}
405
406
endSession(sessionResource: URI): void {
407
const cts = this._invocationCts.get(sessionResource);
408
if (cts) {
409
cts.cancel();
410
cts.dispose();
411
this._invocationCts.delete(sessionResource);
412
}
413
}
414
415
private _clearProviderEvents(sessionResource: URI): void {
416
const buffer = this._sessionBuffers.get(sessionResource);
417
if (buffer) {
418
// Provider events are typically the vast majority (90%+).
419
// Instead of iterating to remove them, extract the few core
420
// events, clear the buffer, and re-add them.
421
const coreEvents = buffer.toArray().filter(e => !this._providerEvents.has(e));
422
buffer.clear();
423
for (const e of coreEvents) {
424
buffer.push(e);
425
}
426
}
427
// Reset dedup tracking so re-invoked provider events are accepted
428
this._seenEventIds.delete(sessionResource);
429
this._onDidClearProviderEvents.fire(sessionResource);
430
}
431
432
async resolveEvent(eventId: string): Promise<IChatDebugResolvedEventContent | undefined> {
433
for (const provider of this._providers) {
434
if (provider.resolveChatDebugLogEvent) {
435
try {
436
const resolved = await provider.resolveChatDebugLogEvent(eventId, CancellationToken.None);
437
if (resolved !== undefined) {
438
return resolved;
439
}
440
} catch (err) {
441
onUnexpectedError(err);
442
}
443
}
444
}
445
return undefined;
446
}
447
448
isCoreEvent(event: IChatDebugEvent): boolean {
449
return !this._providerEvents.has(event);
450
}
451
452
setImportedSessionTitle(sessionResource: URI, title: string): void {
453
this._importedSessionTitles.set(sessionResource, title);
454
}
455
456
getImportedSessionTitle(sessionResource: URI): string | undefined {
457
return this._importedSessionTitles.get(sessionResource);
458
}
459
460
addAvailableSessionResources(resources: readonly { uri: URI; title?: string }[]): void {
461
let added = false;
462
for (const { uri, title } of resources) {
463
const key = uri.toString();
464
if (!this._availableSessionResourceSet.has(key)) {
465
this._availableSessionResourceSet.add(key);
466
this._availableSessionResources.push(uri);
467
added = true;
468
}
469
if (title) {
470
this._historicalSessionTitles.set(uri, title);
471
}
472
}
473
if (added) {
474
this._onDidChangeAvailableSessionResources.fire();
475
}
476
}
477
478
/** Lazy fetcher for available sessions from the extension. */
479
private _availableSessionsFetcher: ((token: CancellationToken) => Promise<{ uri: URI; title?: string }[]>) | undefined;
480
private _availableSessionsFetchStarted = false;
481
private _availableSessionsRequested = false;
482
483
getAvailableSessionResources(): readonly URI[] {
484
// Trigger lazy fetch when both a fetcher is registered and this getter is called.
485
this._availableSessionsRequested = true;
486
this._tryFetchAvailableSessions();
487
488
const known = new Set(this._sessionOrder.map(u => u.toString()));
489
const result = [...this._sessionOrder];
490
for (const uri of this._availableSessionResources) {
491
if (!known.has(uri.toString())) {
492
known.add(uri.toString());
493
result.push(uri);
494
}
495
}
496
return result;
497
}
498
499
registerAvailableSessionsFetcher(fetcher: (token: CancellationToken) => Promise<{ uri: URI; title?: string }[]>): void {
500
this._availableSessionsFetcher = fetcher;
501
this._availableSessionsFetchStarted = false;
502
// If the UI already requested sessions before the fetcher was registered, fetch now.
503
this._tryFetchAvailableSessions();
504
}
505
506
private _tryFetchAvailableSessions(): void {
507
if (!this._availableSessionsFetcher || !this._availableSessionsRequested || this._availableSessionsFetchStarted) {
508
return;
509
}
510
this._availableSessionsFetchStarted = true;
511
// Fire-and-forget: don't block the caller.
512
const fetcher = this._availableSessionsFetcher;
513
fetcher(CancellationToken.None).then(entries => {
514
if (entries.length > 0) {
515
this.addAvailableSessionResources(entries);
516
}
517
}).catch(onUnexpectedError);
518
}
519
520
getHistoricalSessionTitle(sessionResource: URI): string | undefined {
521
return this._historicalSessionTitles.get(sessionResource);
522
}
523
524
async exportLog(sessionResource: URI): Promise<Uint8Array | undefined> {
525
for (const provider of this._providers) {
526
if (provider.provideChatDebugLogExport) {
527
try {
528
const data = await provider.provideChatDebugLogExport(sessionResource, CancellationToken.None);
529
if (data !== undefined) {
530
return data;
531
}
532
} catch (err) {
533
onUnexpectedError(err);
534
}
535
}
536
}
537
return undefined;
538
}
539
540
async importLog(data: Uint8Array): Promise<URI | undefined> {
541
for (const provider of this._providers) {
542
if (provider.resolveChatDebugLogImport) {
543
try {
544
const sessionUri = await provider.resolveChatDebugLogImport(data, CancellationToken.None);
545
if (sessionUri !== undefined) {
546
this._importedSessions.set(sessionUri, true);
547
return sessionUri;
548
}
549
} catch (err) {
550
onUnexpectedError(err);
551
}
552
}
553
}
554
return undefined;
555
}
556
557
override dispose(): void {
558
for (const cts of this._invocationCts.values()) {
559
cts.cancel();
560
cts.dispose();
561
}
562
this._invocationCts.clear();
563
this.clear();
564
this._providers.clear();
565
super.dispose();
566
}
567
}
568
569