Path: blob/main/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { timeout } from '../../../../base/common/async.js';6import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';7import { Emitter, Event } from '../../../../base/common/event.js';8import { onUnexpectedError } from '../../../../base/common/errors.js';9import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';10import { ResourceMap } from '../../../../base/common/map.js';11import { extUri } from '../../../../base/common/resources.js';12import { URI } from '../../../../base/common/uri.js';13import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugLogProvider, IChatDebugResolvedEventContent, IChatDebugService } from './chatDebugService.js';14import { LocalChatSessionUri } from './model/chatUri.js';1516/**17* Per-session circular buffer for debug events.18* Stores up to `capacity` events using a ring buffer.19*/20class SessionEventBuffer {21private readonly _buffer: (IChatDebugEvent | undefined)[];22private _head = 0;23private _size = 0;2425constructor(readonly capacity: number) {26this._buffer = new Array(capacity);27}2829get size(): number {30return this._size;31}3233push(event: IChatDebugEvent): void {34const idx = (this._head + this._size) % this.capacity;35this._buffer[idx] = event;36if (this._size < this.capacity) {37this._size++;38} else {39this._head = (this._head + 1) % this.capacity;40}41}4243/** Return events in insertion order. */44toArray(): IChatDebugEvent[] {45const result: IChatDebugEvent[] = [];46for (let i = 0; i < this._size; i++) {47const event = this._buffer[(this._head + i) % this.capacity];48if (event) {49result.push(event);50}51}52return result;53}5455/** Remove events matching the predicate and compact in-place. */56removeWhere(predicate: (event: IChatDebugEvent) => boolean): void {57let write = 0;58for (let i = 0; i < this._size; i++) {59const idx = (this._head + i) % this.capacity;60const event = this._buffer[idx];61if (event && predicate(event)) {62continue;63}64if (write !== i) {65const writeIdx = (this._head + write) % this.capacity;66this._buffer[writeIdx] = event;67}68write++;69}70for (let i = write; i < this._size; i++) {71this._buffer[(this._head + i) % this.capacity] = undefined;72}73this._size = write;74}7576clear(): void {77this._buffer.fill(undefined);78this._head = 0;79this._size = 0;80}81}8283export class ChatDebugServiceImpl extends Disposable implements IChatDebugService {84declare readonly _serviceBrand: undefined;8586static readonly MAX_EVENTS_PER_SESSION = 10_000;87static readonly MAX_SESSIONS = 5;8889/** Per-session event buffers. Ordered from oldest to newest session (LRU). */90private readonly _sessionBuffers = new ResourceMap<SessionEventBuffer>();91/** Ordered list of session URIs for LRU eviction. */92private readonly _sessionOrder: URI[] = [];93/** Per-session tracking of seen event IDs to deduplicate events94* that share the same ID (e.g. subagentInvocation + userMessage95* emitted from the same span). Stores id → event kind so we can96* keep the richer event kind on collision. */97private readonly _seenEventIds = new ResourceMap<Map<string, IChatDebugEvent['kind']>>();9899private readonly _onDidAddEvent = this._register(new Emitter<IChatDebugEvent>());100readonly onDidAddEvent: Event<IChatDebugEvent> = this._onDidAddEvent.event;101102private readonly _onDidClearProviderEvents = this._register(new Emitter<URI>());103readonly onDidClearProviderEvents: Event<URI> = this._onDidClearProviderEvents.event;104105private readonly _onDidChangeAvailableSessionResources = this._register(new Emitter<void>());106readonly onDidChangeAvailableSessionResources: Event<void> = this._onDidChangeAvailableSessionResources.event;107108private readonly _providers = new Set<IChatDebugLogProvider>();109private readonly _invocationCts = new ResourceMap<CancellationTokenSource>();110111/** Events that were returned by providers (not internally logged). */112private readonly _providerEvents = new WeakSet<IChatDebugEvent>();113114/** Session URIs created via import. */115private readonly _importedSessions = new ResourceMap<boolean>();116117/** Session URIs reported by providers as available on disk (historical sessions). */118private readonly _availableSessionResources: URI[] = [];119private readonly _availableSessionResourceSet = new Set<string>();120121/** Titles for historical sessions discovered from disk. */122private readonly _historicalSessionTitles = new ResourceMap<string>();123124/** Human-readable titles for imported sessions. */125private readonly _importedSessionTitles = new ResourceMap<string>();126127activeSessionResource: URI | undefined;128129/** Priority for deduplicating events with the same ID: lower = richer. */130private static readonly _eventKindPriority: Record<string, number> = {131subagentInvocation: 0,132modelTurn: 1,133toolCall: 2,134agentResponse: 3,135userMessage: 4,136generic: 5,137};138139/** Schemes eligible for debug logging and provider invocation. */140private static readonly _debugEligibleSchemes = new Set([141LocalChatSessionUri.scheme, // vscode-chat-session (local sessions)142'copilotcli', // Copilot CLI background sessions143'claude-code', // Claude Code CLI sessions144]);145146private _isDebugEligibleSession(sessionResource: URI): boolean {147return ChatDebugServiceImpl._debugEligibleSchemes.has(sessionResource.scheme)148|| this._importedSessions.has(sessionResource);149}150151log(sessionResource: URI, name: string, details?: string, level: ChatDebugLogLevel = ChatDebugLogLevel.Info, options?: { id?: string; category?: string; parentEventId?: string }): void {152if (!this._isDebugEligibleSession(sessionResource)) {153return;154}155this.addEvent({156kind: 'generic',157id: options?.id,158sessionResource,159created: new Date(),160name,161details,162level,163category: options?.category,164parentEventId: options?.parentEventId,165});166}167168addEvent(event: IChatDebugEvent): void {169// Deduplicate events that share the same ID. The extension may emit170// both a subagentInvocation and a userMessage from the same span;171// keep the richer kind and discard the duplicate.172if (event.id) {173let seen = this._seenEventIds.get(event.sessionResource);174if (!seen) {175seen = new Map();176this._seenEventIds.set(event.sessionResource, seen);177}178const existingKind = seen.get(event.id);179if (existingKind !== undefined) {180const priority = ChatDebugServiceImpl._eventKindPriority;181if ((priority[event.kind] ?? 5) >= (priority[existingKind] ?? 5)) {182return; // existing is richer or equal; skip this event183}184// New event is richer — we can't remove the old one from185// the ring buffer, but the duplicate will be filtered out186// in getEvents(). Update the tracked kind.187}188seen.set(event.id, event.kind);189// Cap the dedup map to prevent unbounded growth in long sessions.190if (seen.size > ChatDebugServiceImpl.MAX_EVENTS_PER_SESSION) {191// Delete the oldest entry (first key in insertion order).192const firstKey = seen.keys().next().value;193if (firstKey !== undefined) {194seen.delete(firstKey);195}196}197}198199let buffer = this._sessionBuffers.get(event.sessionResource);200if (!buffer) {201// Evict least-recently-used session if we are at the session cap.202if (this._sessionOrder.length >= ChatDebugServiceImpl.MAX_SESSIONS) {203const evicted = this._sessionOrder.shift()!;204this._evictSession(evicted);205}206buffer = new SessionEventBuffer(ChatDebugServiceImpl.MAX_EVENTS_PER_SESSION);207this._sessionBuffers.set(event.sessionResource, buffer);208this._sessionOrder.push(event.sessionResource);209} else {210// Move to end of LRU order so actively-used sessions are not evicted.211// Fast-path: during streaming/backfill all events target the same212// session which is already at the tail — skip the linear scan.213const last = this._sessionOrder.length - 1;214if (last < 0 || !extUri.isEqual(this._sessionOrder[last], event.sessionResource)) {215const idx = this._sessionOrder.findIndex(u => extUri.isEqual(u, event.sessionResource));216if (idx !== -1 && idx !== last) {217this._sessionOrder.splice(idx, 1);218this._sessionOrder.push(event.sessionResource);219}220}221}222buffer.push(event);223this._onDidAddEvent.fire(event);224}225226addProviderEvent(event: IChatDebugEvent): void {227this._providerEvents.add(event);228this.addEvent(event);229}230231getEvents(sessionResource?: URI): readonly IChatDebugEvent[] {232if (sessionResource) {233const buffer = this._sessionBuffers.get(sessionResource);234if (!buffer) {235return [];236}237let result = buffer.toArray();238// Sort only when the buffer is not in chronological order,239// which can happen when events arrive out of order (e.g.240// tail-first backfill). When events arrive in241// order (the common case) the check is O(n) with no sort.242if (!this._isSorted(result)) {243result.sort((a, b) => a.created.getTime() - b.created.getTime());244}245// Deduplicate: when multiple events share the same ID (e.g.246// subagentInvocation + userMessage from the same span), keep247// the one with the richest kind.248result = this._deduplicateEvents(result);249return result;250}251252// Cross-session query: merge all buffers and sort to interleave.253const result: IChatDebugEvent[] = [];254for (const buffer of this._sessionBuffers.values()) {255result.push(...buffer.toArray());256}257result.sort((a, b) => a.created.getTime() - b.created.getTime());258return result;259}260261private _isSorted(events: IChatDebugEvent[]): boolean {262for (let i = 1; i < events.length; i++) {263if (events[i].created.getTime() < events[i - 1].created.getTime()) {264return false;265}266}267return true;268}269270private _deduplicateEvents(events: IChatDebugEvent[]): IChatDebugEvent[] {271const seen = new Map<string, number>(); // id → index in result272const priority = ChatDebugServiceImpl._eventKindPriority;273const result: IChatDebugEvent[] = [];274for (const event of events) {275if (!event.id) {276result.push(event);277continue;278}279const existingIdx = seen.get(event.id);280if (existingIdx === undefined) {281seen.set(event.id, result.length);282result.push(event);283} else {284const existing = result[existingIdx];285if ((priority[event.kind] ?? 5) < (priority[existing.kind] ?? 5)) {286result[existingIdx] = event;287}288}289}290return result;291}292293getSessionResources(): readonly URI[] {294return [...this._sessionOrder];295}296297clear(): void {298this._sessionBuffers.clear();299this._sessionOrder.length = 0;300this._seenEventIds.clear();301this._importedSessions.clear();302this._importedSessionTitles.clear();303this._availableSessionResources.length = 0;304this._availableSessionResourceSet.clear();305this._historicalSessionTitles.clear();306}307308/** Remove all ancillary state for an evicted session. */309private _evictSession(sessionResource: URI): void {310this._sessionBuffers.delete(sessionResource);311this._seenEventIds.delete(sessionResource);312this._importedSessions.delete(sessionResource);313this._importedSessionTitles.delete(sessionResource);314const cts = this._invocationCts.get(sessionResource);315if (cts) {316cts.cancel();317cts.dispose();318this._invocationCts.delete(sessionResource);319}320}321322registerProvider(provider: IChatDebugLogProvider): IDisposable {323this._providers.add(provider);324325// Invoke the new provider for all sessions that already have active326// pipelines. This handles the case where invokeProviders() was called327// before this provider was registered (e.g. extension activated late).328for (const [sessionResource, cts] of this._invocationCts) {329if (!cts.token.isCancellationRequested) {330this._invokeProvider(provider, sessionResource, cts.token).catch(onUnexpectedError);331}332}333334return toDisposable(() => {335this._providers.delete(provider);336});337}338339hasInvokedProviders(sessionResource: URI): boolean {340return this._invocationCts.has(sessionResource);341}342343async invokeProviders(sessionResource: URI): Promise<void> {344345if (!this._isDebugEligibleSession(sessionResource)) {346return;347}348// Cancel only the previous invocation for THIS session, not others.349// Each session has its own pipeline so events from multiple sessions350// can be streamed concurrently.351const existingCts = this._invocationCts.get(sessionResource);352if (existingCts) {353existingCts.cancel();354existingCts.dispose();355}356357// Clear only provider-sourced events for this session to avoid358// duplicates when re-invoking (e.g. navigating back to a session).359// Internally-logged events (e.g. prompt discovery) are preserved.360this._clearProviderEvents(sessionResource);361362const cts = new CancellationTokenSource();363this._invocationCts.set(sessionResource, cts);364365try {366const promises = [...this._providers].map(provider =>367this._invokeProvider(provider, sessionResource, cts.token)368);369await Promise.allSettled(promises);370} catch (err) {371onUnexpectedError(err);372}373// Note: do NOT dispose the CTS here - the token is used by the374// extension-side progress pipeline which stays alive for streaming.375// It will be cancelled+disposed when re-invoking the same session376// or when the service is disposed.377}378379private async _invokeProvider(provider: IChatDebugLogProvider, sessionResource: URI, token: CancellationToken): Promise<void> {380try {381const events = await provider.provideChatDebugLog(sessionResource, token);382if (events) {383// Yield to the event loop periodically so the UI stays384// responsive when a provider returns a large batch of events385// (e.g. importing a multi-MB log file).386const BATCH_SIZE = 500;387for (let i = 0; i < events.length; i++) {388if (token.isCancellationRequested) {389break;390}391this.addProviderEvent({392...events[i],393sessionResource: events[i].sessionResource ?? sessionResource,394});395if (i > 0 && i % BATCH_SIZE === 0) {396await timeout(0);397}398}399}400} catch (err) {401onUnexpectedError(err);402}403}404405endSession(sessionResource: URI): void {406const cts = this._invocationCts.get(sessionResource);407if (cts) {408cts.cancel();409cts.dispose();410this._invocationCts.delete(sessionResource);411}412}413414private _clearProviderEvents(sessionResource: URI): void {415const buffer = this._sessionBuffers.get(sessionResource);416if (buffer) {417// Provider events are typically the vast majority (90%+).418// Instead of iterating to remove them, extract the few core419// events, clear the buffer, and re-add them.420const coreEvents = buffer.toArray().filter(e => !this._providerEvents.has(e));421buffer.clear();422for (const e of coreEvents) {423buffer.push(e);424}425}426// Reset dedup tracking so re-invoked provider events are accepted427this._seenEventIds.delete(sessionResource);428this._onDidClearProviderEvents.fire(sessionResource);429}430431async resolveEvent(eventId: string): Promise<IChatDebugResolvedEventContent | undefined> {432for (const provider of this._providers) {433if (provider.resolveChatDebugLogEvent) {434try {435const resolved = await provider.resolveChatDebugLogEvent(eventId, CancellationToken.None);436if (resolved !== undefined) {437return resolved;438}439} catch (err) {440onUnexpectedError(err);441}442}443}444return undefined;445}446447isCoreEvent(event: IChatDebugEvent): boolean {448return !this._providerEvents.has(event);449}450451setImportedSessionTitle(sessionResource: URI, title: string): void {452this._importedSessionTitles.set(sessionResource, title);453}454455getImportedSessionTitle(sessionResource: URI): string | undefined {456return this._importedSessionTitles.get(sessionResource);457}458459addAvailableSessionResources(resources: readonly { uri: URI; title?: string }[]): void {460let added = false;461for (const { uri, title } of resources) {462const key = uri.toString();463if (!this._availableSessionResourceSet.has(key)) {464this._availableSessionResourceSet.add(key);465this._availableSessionResources.push(uri);466added = true;467}468if (title) {469this._historicalSessionTitles.set(uri, title);470}471}472if (added) {473this._onDidChangeAvailableSessionResources.fire();474}475}476477/** Lazy fetcher for available sessions from the extension. */478private _availableSessionsFetcher: ((token: CancellationToken) => Promise<{ uri: URI; title?: string }[]>) | undefined;479private _availableSessionsFetchStarted = false;480private _availableSessionsRequested = false;481482getAvailableSessionResources(): readonly URI[] {483// Trigger lazy fetch when both a fetcher is registered and this getter is called.484this._availableSessionsRequested = true;485this._tryFetchAvailableSessions();486487const known = new Set(this._sessionOrder.map(u => u.toString()));488const result = [...this._sessionOrder];489for (const uri of this._availableSessionResources) {490if (!known.has(uri.toString())) {491known.add(uri.toString());492result.push(uri);493}494}495return result;496}497498registerAvailableSessionsFetcher(fetcher: (token: CancellationToken) => Promise<{ uri: URI; title?: string }[]>): void {499this._availableSessionsFetcher = fetcher;500this._availableSessionsFetchStarted = false;501// If the UI already requested sessions before the fetcher was registered, fetch now.502this._tryFetchAvailableSessions();503}504505private _tryFetchAvailableSessions(): void {506if (!this._availableSessionsFetcher || !this._availableSessionsRequested || this._availableSessionsFetchStarted) {507return;508}509this._availableSessionsFetchStarted = true;510// Fire-and-forget: don't block the caller.511const fetcher = this._availableSessionsFetcher;512fetcher(CancellationToken.None).then(entries => {513if (entries.length > 0) {514this.addAvailableSessionResources(entries);515}516}).catch(onUnexpectedError);517}518519getHistoricalSessionTitle(sessionResource: URI): string | undefined {520return this._historicalSessionTitles.get(sessionResource);521}522523async exportLog(sessionResource: URI): Promise<Uint8Array | undefined> {524for (const provider of this._providers) {525if (provider.provideChatDebugLogExport) {526try {527const data = await provider.provideChatDebugLogExport(sessionResource, CancellationToken.None);528if (data !== undefined) {529return data;530}531} catch (err) {532onUnexpectedError(err);533}534}535}536return undefined;537}538539async importLog(data: Uint8Array): Promise<URI | undefined> {540for (const provider of this._providers) {541if (provider.resolveChatDebugLogImport) {542try {543const sessionUri = await provider.resolveChatDebugLogImport(data, CancellationToken.None);544if (sessionUri !== undefined) {545this._importedSessions.set(sessionUri, true);546return sessionUri;547}548} catch (err) {549onUnexpectedError(err);550}551}552}553return undefined;554}555556override dispose(): void {557for (const cts of this._invocationCts.values()) {558cts.cancel();559cts.dispose();560}561this._invocationCts.clear();562this.clear();563this._providers.clear();564super.dispose();565}566}567568569