src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts
629 LOC · 490 covered · 139 uncovered · 127 ranges · 264 concepts · 45 introducers · 117 tests
File neighbourhood
The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.
Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file
In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.
Graph controls are ready.
Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.
/*---------------------------------------------------------------------------------------------
chatDebugServiceImpl.ts ×16
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { timeout } from '../../../../base/common/async.js';
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { onUnexpectedError } from '../../../../base/common/errors.js';
import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
import { ResourceMap } from '../../../../base/common/map.js';
import { extUri } from '../../../../base/common/resources.js';
import { URI } from '../../../../base/common/uri.js';
import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugLogProvider, IChatDebugResolvedEventContent, IChatDebugService } from './chatDebugService.js';
import { isAgentHostTarget, localChatSessionType } from './chatSessionsService.js';
import { getChatSessionType } from './model/chatUri.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { AgentHostAgentDebugLogMaxEventsSettingId } from './promptSyntax/promptTypes.js';
/**
* Per-session circular buffer for debug events.
* Stores up to `capacity` events using a ring buffer.
*/
class SessionEventBuffer {
private readonly _buffer: (IChatDebugEvent | undefined)[];
private _head = 0;
private _size = 0;
constructor(readonly capacity: number) {
}
get size(): number {
return this._size;
}
push(event: IChatDebugEvent): void {
this._buffer[idx] = event;
if (this._size < this.capacity) {
this._size++;
} else {
}
/** Return events in insertion order. */
toArray(): IChatDebugEvent[] {
for (let i = 0; i < this._size; i++) {
const event = this._buffer[(this._head + i) % this.capacity];
if (event) {
result.push(event);
}
}
return result;
}
/** Remove events matching the predicate and compact in-place. */
removeWhere(predicate: (event: IChatDebugEvent) => boolean): void {
let write = 0;
for (let i = 0; i < this._size; i++) {
const idx = (this._head + i) % this.capacity;
const event = this._buffer[idx];
if (event && predicate(event)) {
continue;
}
if (write !== i) {
const writeIdx = (this._head + write) % this.capacity;
this._buffer[writeIdx] = event;
}
write++;
}
for (let i = write; i < this._size; i++) {
this._buffer[(this._head + i) % this.capacity] = undefined;
}
this._size = write;
}
clear(): void {
this._head = 0;
this._size = 0;
}
export class ChatDebugServiceImpl extends Disposable implements IChatDebugService {
declare readonly _serviceBrand: undefined;
static readonly MAX_EVENTS_PER_SESSION = 10_000;
static readonly MAX_SESSIONS = 5;
/** Per-session event buffers. Ordered from oldest to newest session (LRU). */
private readonly _sessionBuffers = new ResourceMap<SessionEventBuffer>();
/** Ordered list of session URIs for LRU eviction. */
private readonly _sessionOrder: URI[] = [];
/** Per-session tracking of seen event IDs to deduplicate events
* that share the same ID (e.g. subagentInvocation + userMessage
* emitted from the same span). Stores id → event kind so we can
* keep the richer event kind on collision. */
private readonly _seenEventIds = new ResourceMap<Map<string, IChatDebugEvent['kind']>>();
private readonly _onDidAddEvent = this._register(new Emitter<IChatDebugEvent>());
readonly onDidAddEvent: Event<IChatDebugEvent> = this._onDidAddEvent.event;
private readonly _onDidClearProviderEvents = this._register(new Emitter<URI>());
readonly onDidClearProviderEvents: Event<URI> = this._onDidClearProviderEvents.event;
private readonly _onDidEndSession = this._register(new Emitter<URI>());
readonly onDidEndSession: Event<URI> = this._onDidEndSession.event;
private readonly _onDidChangeAvailableSessionResources = this._register(new Emitter<void>());
readonly onDidChangeAvailableSessionResources: Event<void> = this._onDidChangeAvailableSessionResources.event;
private readonly _providers = new Set<IChatDebugLogProvider>();
private readonly _invocationCts = new ResourceMap<CancellationTokenSource>();
/**
* Sessions whose provider events should be cleared before the next batch of
* provider events is applied. The clear is deferred until the first new
* provider event actually arrives so that a provider which transiently
* returns nothing (e.g. an Agent Host `events.jsonl` mid-rewrite) does not
* wipe the events currently shown.
*/
private readonly _pendingProviderClear = new ResourceMap<boolean>();
/** Events that were returned by providers (not internally logged). */
private readonly _providerEvents = new WeakSet<IChatDebugEvent>();
/** Session URIs created via import. */
private readonly _importedSessions = new ResourceMap<boolean>();
/** Session URIs reported by providers as available on disk (historical sessions). */
private readonly _availableSessionResources: URI[] = [];
private readonly _availableSessionResourceSet = new Set<string>();
/** Titles for historical sessions discovered from disk. */
private readonly _historicalSessionTitles = new ResourceMap<string>();
/** Human-readable titles for imported sessions. */
private readonly _importedSessionTitles = new ResourceMap<string>();
activeSessionResource: URI | undefined;
constructor(
@IConfigurationService private readonly _configurationService: IConfigurationService,
chatDebugServiceImpl.ts ×25
) {
super();
}
/** Priority for deduplicating events with the same ID: lower = richer. */
private static readonly _eventKindPriority: Record<string, number> = {
subagentInvocation: 0,
modelTurn: 1,
toolCall: 2,
agentResponse: 3,
userMessage: 4,
generic: 5,
};
/** Session types eligible for debug logging and provider invocation. */
private static readonly _debugEligibleSessionTypes = new Set([
localChatSessionType, // local sessions
'copilotcli', // Copilot CLI background sessions
'agent-host-copilotcli', // local Agent Host Copilot CLI sessions
'claude-code', // Claude Code CLI sessions
]);
private _isDebugEligibleSession(sessionResource: URI): boolean {
return ChatDebugServiceImpl._debugEligibleSessionTypes.has(sessionType)
// `remote-<authority>-copilotcli` scheme; see copilotCliEventsUri.ts.
|| (sessionType.startsWith('remote-') && sessionType.endsWith('-copilotcli'))
|| this._importedSessions.has(sessionResource);
/**
* The in-memory event capacity for a session. Agent host (Copilot CLI)
* sessions honor a dedicated, configurable cap so their (potentially large)
* on-disk logs can be surfaced without changing the local-session default;
* all other sessions use {@link ChatDebugServiceImpl.MAX_EVENTS_PER_SESSION}.
*/
private _capacityForSession(sessionResource: URI): number {
return ChatDebugServiceImpl.MAX_EVENTS_PER_SESSION;
}
const configured = this._configurationService.getValue<number>(AgentHostAgentDebugLogMaxEventsSettingId);
if (typeof configured === 'number' && Number.isFinite(configured) && configured >= 1) {
chatDebugServiceImpl.ts ×10
return Math.floor(configured);
}
return ChatDebugServiceImpl.MAX_EVENTS_PER_SESSION;
log(sessionResource: URI, name: string, details?: string, level: ChatDebugLogLevel = ChatDebugLogLevel.Info, options?: { id?: string; category?: string; parentEventId?: string }): void {
}
kind: 'generic',
id: options?.id,
created: new Date(),
name,
details,
level,
category: options?.category,
parentEventId: options?.parentEventId,
});
}
addEvent(event: IChatDebugEvent): void {
// Resolve the session's buffer (if any) once, and its capacity. New
chatDebugServiceImpl.ts ×10
// events during streaming target an existing buffer, so we reuse its
// capacity and avoid re-reading configuration on the hot path.
let buffer = this._sessionBuffers.get(event.sessionResource);
const capacity = buffer?.capacity ?? this._capacityForSession(event.sessionResource);
// Deduplicate events that share the same ID. The extension may emit
// both a subagentInvocation and a userMessage from the same span;
// keep the richer kind and discard the duplicate.
if (event.id) {
if (!seen) {
seen = new Map();
this._seenEventIds.set(event.sessionResource, seen);
}
const existingKind = seen.get(event.id);
if (existingKind !== undefined) {
if ((priority[event.kind] ?? 5) >= (priority[existingKind] ?? 5)) {
}
// the ring buffer, but the duplicate will be filtered out
// in getEvents(). Update the tracked kind.
}
// Cap the dedup map to prevent unbounded growth in long sessions.
if (seen.size > capacity) {
// Delete the oldest entry (first key in insertion order).
const firstKey = seen.keys().next().value;
if (firstKey !== undefined) {
seen.delete(firstKey);
}
}
if (!buffer) {
// Evict least-recently-used session if we are at the session cap.
if (this._sessionOrder.length >= ChatDebugServiceImpl.MAX_SESSIONS) {
this._evictSession(evicted);
}
this._sessionBuffers.set(event.sessionResource, buffer);
this._sessionOrder.push(event.sessionResource);
} else {
// Move to end of LRU order so actively-used sessions are not evicted.
chatDebugServiceImpl.ts ×2
// Fast-path: during streaming/backfill all events target the same
// session which is already at the tail — skip the linear scan.
const last = this._sessionOrder.length - 1;
if (last < 0 || !extUri.isEqual(this._sessionOrder[last], event.sessionResource)) {
const idx = this._sessionOrder.findIndex(u => extUri.isEqual(u, event.sessionResource));
chatDebugServiceImpl.ts ×1
if (idx !== -1 && idx !== last) {
this._sessionOrder.splice(idx, 1);
this._sessionOrder.push(event.sessionResource);
}
}
this._onDidAddEvent.fire(event);
}
addProviderEvent(event: IChatDebugEvent): void {
// If a re-invocation is pending for this session, clear the previously
chatDebugServiceImpl.ts ×6
// loaded provider events now that fresh data has actually arrived. This
// is deferred (rather than done up front in invokeProviders) so that a
// provider which returns nothing this cycle keeps the current events.
if (this._pendingProviderClear.has(event.sessionResource)) {
this._pendingProviderClear.delete(event.sessionResource);
this._clearProviderEvents(event.sessionResource);
}
this._providerEvents.add(event);
this.addEvent(event);
}
getEvents(sessionResource?: URI): readonly IChatDebugEvent[] {
if (!buffer) {
}
// Sort only when the buffer is not in chronological order,
// which can happen when events arrive out of order (e.g.
// tail-first backfill). When events arrive in
// order (the common case) the check is O(n) with no sort.
if (!this._isSorted(result)) {
result.sort((a, b) => a.created.getTime() - b.created.getTime());
}
// subagentInvocation + userMessage from the same span), keep
// the one with the richest kind.
result = this._deduplicateEvents(result);
return result;
}
// Cross-session query: merge all buffers and sort to interleave.
const result: IChatDebugEvent[] = [];
for (const buffer of this._sessionBuffers.values()) {
}
return result;
private _isSorted(events: IChatDebugEvent[]): boolean {
if (events[i].created.getTime() < events[i - 1].created.getTime()) {
chatDebugServiceImpl.ts ×2
return false;
}
}
private _deduplicateEvents(events: IChatDebugEvent[]): IChatDebugEvent[] {
const priority = ChatDebugServiceImpl._eventKindPriority;
const result: IChatDebugEvent[] = [];
for (const event of events) {
if (!event.id) {
continue;
}
if (existingIdx === undefined) {
seen.set(event.id, result.length);
result.push(event);
} else {
if ((priority[event.kind] ?? 5) < (priority[existing.kind] ?? 5)) {
result[existingIdx] = event;
}
}
return result;
}
getSessionResources(): readonly URI[] {
}
clear(): void {
this._sessionBuffers.clear();
this._sessionOrder.length = 0;
this._seenEventIds.clear();
this._importedSessions.clear();
this._importedSessionTitles.clear();
this._availableSessionResources.length = 0;
this._availableSessionResourceSet.clear();
this._historicalSessionTitles.clear();
}
/** Remove all ancillary state for an evicted session. */
private _evictSession(sessionResource: URI): void {
this._seenEventIds.delete(sessionResource);
this._importedSessions.delete(sessionResource);
this._importedSessionTitles.delete(sessionResource);
const cts = this._invocationCts.get(sessionResource);
if (cts) {
cts.cancel();
cts.dispose();
this._invocationCts.delete(sessionResource);
}
registerProvider(provider: IChatDebugLogProvider): IDisposable {
// Invoke the new provider for all sessions that already have active
// pipelines. This handles the case where invokeProviders() was called
// before this provider was registered (e.g. extension activated late).
for (const [sessionResource, cts] of this._invocationCts) {
this._invokeProvider(provider, sessionResource, cts.token).catch(onUnexpectedError);
}
}
return toDisposable(() => {
this._providers.delete(provider);
});
}
hasInvokedProviders(sessionResource: URI): boolean {
return this._invocationCts.has(sessionResource);
}
async invokeProviders(sessionResource: URI): Promise<void> {
if (!this._isDebugEligibleSession(sessionResource)) {
}
// Cancel only the previous invocation for THIS session, not others.
chatDebugServiceImpl.ts ×5
// Each session has its own pipeline so events from multiple sessions
// can be streamed concurrently.
const existingCts = this._invocationCts.get(sessionResource);
if (existingCts) {
existingCts.dispose();
}
// Mark provider events for this session to be cleared before the next
// batch is applied. The clear is deferred to addProviderEvent so that a
// provider returning nothing this cycle preserves the current events;
// see _pendingProviderClear.
this._pendingProviderClear.set(sessionResource, true);
const cts = new CancellationTokenSource();
this._invocationCts.set(sessionResource, cts);
try {
const promises = [...this._providers].map(provider =>
this._invokeProvider(provider, sessionResource, cts.token)
);
await Promise.allSettled(promises);
} catch (err) {
onUnexpectedError(err);
}
// extension-side progress pipeline which stays alive for streaming.
// It will be cancelled+disposed when re-invoking the same session
// or when the service is disposed.
}
private async _invokeProvider(provider: IChatDebugLogProvider, sessionResource: URI, token: CancellationToken): Promise<void> {
const events = await provider.provideChatDebugLog(sessionResource, token);
// responsive when a provider returns a large batch of events
// (e.g. importing a multi-MB log file).
const BATCH_SIZE = 500;
for (let i = 0; i < events.length; i++) {
break;
}
...events[i],
sessionResource: events[i].sessionResource ?? sessionResource,
});
if (i > 0 && i % BATCH_SIZE === 0) {
await timeout(0);
}
}
endSession(sessionResource: URI): void {
if (cts) {
cts.dispose();
this._invocationCts.delete(sessionResource);
}
}
private _clearProviderEvents(sessionResource: URI): void {
if (buffer) {
// Instead of iterating to remove them, extract the few core
// events, clear the buffer, and re-add them.
const coreEvents = buffer.toArray().filter(e => !this._providerEvents.has(e));
buffer.clear();
for (const e of coreEvents) {
buffer.push(e);
}
this._seenEventIds.delete(sessionResource);
this._onDidClearProviderEvents.fire(sessionResource);
}
async resolveEvent(eventId: string): Promise<IChatDebugResolvedEventContent | undefined> {
try {
const resolved = await provider.resolveChatDebugLogEvent(eventId, CancellationToken.None);
if (resolved !== undefined) {
}
onUnexpectedError(err);
}
}
isCoreEvent(event: IChatDebugEvent): boolean {
return !this._providerEvents.has(event);
}
setImportedSessionTitle(sessionResource: URI, title: string): void {
this._importedSessionTitles.set(sessionResource, title);
}
getImportedSessionTitle(sessionResource: URI): string | undefined {
return this._importedSessionTitles.get(sessionResource);
}
addAvailableSessionResources(resources: readonly { uri: URI; title?: string }[]): void {
let added = false;
for (const { uri, title } of resources) {
const key = uri.toString();
if (!this._availableSessionResourceSet.has(key)) {
this._availableSessionResourceSet.add(key);
this._availableSessionResources.push(uri);
added = true;
}
if (title) {
this._historicalSessionTitles.set(uri, title);
}
}
if (added) {
this._onDidChangeAvailableSessionResources.fire();
}
}
/** Lazy fetchers for available sessions from providers. Each is invoked at most once. */
private readonly _availableSessionsFetchers = new Set<{ readonly fetcher: (token: CancellationToken) => Promise<{ uri: URI; title?: string }[]>; started: boolean }>();
private _availableSessionsRequested = false;
getAvailableSessionResources(): readonly URI[] {
// Trigger lazy fetch when both a fetcher is registered and this getter is called.
this._availableSessionsRequested = true;
this._tryFetchAvailableSessions();
const known = new Set(this._sessionOrder.map(u => u.toString()));
const result = [...this._sessionOrder];
for (const uri of this._availableSessionResources) {
if (!known.has(uri.toString())) {
known.add(uri.toString());
result.push(uri);
}
}
return result;
}
registerAvailableSessionsFetcher(fetcher: (token: CancellationToken) => Promise<{ uri: URI; title?: string }[]>): IDisposable {
const entry = { fetcher, started: false };
this._availableSessionsFetchers.add(entry);
// If the UI already requested sessions before the fetcher was registered, fetch now.
this._tryFetchAvailableSessions();
return toDisposable(() => this._availableSessionsFetchers.delete(entry));
}
private _tryFetchAvailableSessions(): void {
if (!this._availableSessionsRequested) {
return;
}
for (const entry of this._availableSessionsFetchers) {
if (entry.started) {
continue;
}
entry.started = true;
// Fire-and-forget: don't block the caller.
entry.fetcher(CancellationToken.None).then(entries => {
if (entries.length > 0) {
this.addAvailableSessionResources(entries);
}
}).catch(onUnexpectedError);
}
}
getHistoricalSessionTitle(sessionResource: URI): string | undefined {
return this._historicalSessionTitles.get(sessionResource);
}
async exportLog(sessionResource: URI): Promise<Uint8Array | undefined> {
for (const provider of this._providers) {
if (provider.provideChatDebugLogExport) {
try {
const data = await provider.provideChatDebugLogExport(sessionResource, CancellationToken.None);
if (data !== undefined) {
return data;
}
} catch (err) {
onUnexpectedError(err);
}
}
}
return undefined;
}
async importLog(data: Uint8Array): Promise<URI | undefined> {
for (const provider of this._providers) {
if (provider.resolveChatDebugLogImport) {
try {
const sessionUri = await provider.resolveChatDebugLogImport(data, CancellationToken.None);
if (sessionUri !== undefined) {
this._importedSessions.set(sessionUri, true);
return sessionUri;
}
} catch (err) {
onUnexpectedError(err);
}
}
}
return undefined;
}
override dispose(): void {
cts.dispose();
}
this.clear();
this._providers.clear();
super.dispose();
}