From 34bfd71aeac1598fe87bbd37163f345ca49dffaa Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:32:58 -0700 Subject: [PATCH] Revert "Revert "Debug Panel: oTel data source support and Import/export (#299256)"" (#300477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "Revert "Debug Panel: oTel data source support and Import/export (#299…" This reverts commit 11246017b66d444e6aa5e3a2535e161498182b51. --- .../common/extensionsApiProposals.ts | 2 +- .../api/browser/mainThreadChatDebug.ts | 47 ++++++++ .../workbench/api/common/extHost.protocol.ts | 2 + .../workbench/api/common/extHostChatDebug.ts | 103 ++++++++++++++++- .../actions/chatOpenAgentDebugPanelAction.ts | 105 +++++++++++++++++- .../chat/browser/chatDebug/chatDebugEditor.ts | 47 ++------ .../browser/chatDebug/chatDebugFlowGraph.ts | 53 ++++----- .../browser/chatDebug/chatDebugFlowLayout.ts | 10 +- .../browser/chatDebug/chatDebugHomeView.ts | 49 ++++---- .../browser/chatDebug/chatDebugLogsView.ts | 34 +++++- .../contrib/chat/common/chatDebugService.ts | 30 +++++ .../chat/common/chatDebugServiceImpl.ts | 55 ++++++++- src/vscode-dts/vscode.proposed.chatDebug.d.ts | 65 ++++++++++- 13 files changed, 502 insertions(+), 100 deletions(-) diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index a9bc2d2fa10..00e09a016ac 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -45,7 +45,7 @@ const _allApiProposals = { }, chatDebug: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatDebug.d.ts', - version: 2 + version: 3 }, chatHooks: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', diff --git a/src/vs/workbench/api/browser/mainThreadChatDebug.ts b/src/vs/workbench/api/browser/mainThreadChatDebug.ts index 82594dcb038..169324d37e7 100644 --- a/src/vs/workbench/api/browser/mainThreadChatDebug.ts +++ b/src/vs/workbench/api/browser/mainThreadChatDebug.ts @@ -5,7 +5,9 @@ import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugService } from '../../contrib/chat/common/chatDebugService.js'; +import { IChatService } from '../../contrib/chat/common/chatService/chatService.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { ExtHostChatDebugShape, ExtHostContext, IChatDebugEventDto, MainContext, MainThreadChatDebugShape } from '../common/extHost.protocol.js'; import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; @@ -19,6 +21,7 @@ export class MainThreadChatDebug extends Disposable implements MainThreadChatDeb constructor( extHostContext: IExtHostContext, @IChatDebugService private readonly _chatDebugService: IChatDebugService, + @IChatService private readonly _chatService: IChatService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatDebug); @@ -36,6 +39,26 @@ export class MainThreadChatDebug extends Disposable implements MainThreadChatDeb }, resolveChatDebugLogEvent: async (eventId, token) => { return this._proxy.$resolveChatDebugLogEvent(handle, eventId, token); + }, + provideChatDebugLogExport: async (sessionResource, token) => { + // Gather core events and session title to pass to the extension. + const coreEventDtos = this._chatDebugService.getEvents(sessionResource) + .filter(e => this._chatDebugService.isCoreEvent(e)) + .map(e => this._serializeEvent(e)); + const sessionTitle = this._chatService.getSessionTitle(sessionResource); + const result = await this._proxy.$exportChatDebugLog(handle, sessionResource, coreEventDtos, sessionTitle, token); + return result?.buffer; + }, + resolveChatDebugLogImport: async (data, token) => { + const result = await this._proxy.$importChatDebugLog(handle, VSBuffer.wrap(data), token); + if (!result) { + return undefined; + } + const uri = URI.revive(result.uri); + if (result.sessionTitle) { + this._chatDebugService.setImportedSessionTitle(uri, result.sessionTitle); + } + return uri; } })); } @@ -58,6 +81,30 @@ export class MainThreadChatDebug extends Disposable implements MainThreadChatDeb this._chatDebugService.addProviderEvent(revived); } + private _serializeEvent(event: IChatDebugEvent): IChatDebugEventDto { + const base = { + id: event.id, + sessionResource: event.sessionResource, + created: event.created.getTime(), + parentEventId: event.parentEventId, + }; + + switch (event.kind) { + case 'toolCall': + return { ...base, kind: 'toolCall', toolName: event.toolName, toolCallId: event.toolCallId, input: event.input, output: event.output, result: event.result, durationInMillis: event.durationInMillis }; + case 'modelTurn': + return { ...base, kind: 'modelTurn', model: event.model, requestName: event.requestName, inputTokens: event.inputTokens, outputTokens: event.outputTokens, totalTokens: event.totalTokens, durationInMillis: event.durationInMillis }; + case 'generic': + return { ...base, kind: 'generic', name: event.name, details: event.details, level: event.level, category: event.category }; + case 'subagentInvocation': + return { ...base, kind: 'subagentInvocation', agentName: event.agentName, description: event.description, status: event.status, durationInMillis: event.durationInMillis, toolCallCount: event.toolCallCount, modelTurnCount: event.modelTurnCount }; + case 'userMessage': + return { ...base, kind: 'userMessage', message: event.message, sections: event.sections.map(s => ({ name: s.name, content: s.content })) }; + case 'agentResponse': + return { ...base, kind: 'agentResponse', message: event.message, sections: event.sections.map(s => ({ name: s.name, content: s.content })) }; + } + } + private _reviveEvent(dto: IChatDebugEventDto, sessionResource: URI): IChatDebugEvent { const base = { id: dto.id, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index ecd7167f23c..ef5be258b18 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1502,6 +1502,8 @@ export type IChatDebugResolvedEventContentDto = IChatDebugEventTextContentDto | export interface ExtHostChatDebugShape { $provideChatDebugLog(handle: number, sessionResource: UriComponents, token: CancellationToken): Promise; $resolveChatDebugLogEvent(handle: number, eventId: string, token: CancellationToken): Promise; + $exportChatDebugLog(handle: number, sessionResource: UriComponents, coreEvents: IChatDebugEventDto[], sessionTitle: string | undefined, token: CancellationToken): Promise; + $importChatDebugLog(handle: number, data: VSBuffer, token: CancellationToken): Promise<{ uri: UriComponents; sessionTitle?: string } | undefined>; } export interface MainThreadChatDebugShape extends IDisposable { diff --git a/src/vs/workbench/api/common/extHostChatDebug.ts b/src/vs/workbench/api/common/extHostChatDebug.ts index 04125f3e551..b83bb2f3cf5 100644 --- a/src/vs/workbench/api/common/extHostChatDebug.ts +++ b/src/vs/workbench/api/common/extHostChatDebug.ts @@ -4,12 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import type * as vscode from 'vscode'; +import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { ExtHostChatDebugShape, IChatDebugEventDto, IChatDebugResolvedEventContentDto, MainContext, MainThreadChatDebugShape } from './extHost.protocol.js'; -import { ChatDebugMessageContentType, ChatDebugSubagentStatus, ChatDebugToolCallResult } from './extHostTypes.js'; +import { ChatDebugGenericEvent, ChatDebugLogLevel, ChatDebugMessageContentType, ChatDebugMessageSection, ChatDebugModelTurnEvent, ChatDebugSubagentInvocationEvent, ChatDebugSubagentStatus, ChatDebugToolCallEvent, ChatDebugToolCallResult, ChatDebugUserMessageEvent, ChatDebugAgentResponseEvent } from './extHostTypes.js'; import { IExtHostRpcService } from './extHostRpcService.js'; export class ExtHostChatDebug extends Disposable implements ExtHostChatDebugShape { @@ -291,6 +292,106 @@ export class ExtHostChatDebug extends Disposable implements ExtHostChatDebugShap } } + private _deserializeEvent(dto: IChatDebugEventDto): vscode.ChatDebugEvent | undefined { + const created = new Date(dto.created); + const sessionResource = dto.sessionResource ? URI.revive(dto.sessionResource) : undefined; + switch (dto.kind) { + case 'toolCall': { + const evt = new ChatDebugToolCallEvent(dto.toolName, created); + evt.id = dto.id; + evt.sessionResource = sessionResource; + evt.parentEventId = dto.parentEventId; + evt.toolCallId = dto.toolCallId; + evt.input = dto.input; + evt.output = dto.output; + evt.result = dto.result === 'success' ? ChatDebugToolCallResult.Success + : dto.result === 'error' ? ChatDebugToolCallResult.Error + : undefined; + evt.durationInMillis = dto.durationInMillis; + return evt; + } + case 'modelTurn': { + const evt = new ChatDebugModelTurnEvent(created); + evt.id = dto.id; + evt.sessionResource = sessionResource; + evt.parentEventId = dto.parentEventId; + evt.model = dto.model; + evt.inputTokens = dto.inputTokens; + evt.outputTokens = dto.outputTokens; + evt.totalTokens = dto.totalTokens; + evt.durationInMillis = dto.durationInMillis; + return evt; + } + case 'generic': { + const evt = new ChatDebugGenericEvent(dto.name, dto.level as ChatDebugLogLevel, created); + evt.id = dto.id; + evt.sessionResource = sessionResource; + evt.parentEventId = dto.parentEventId; + evt.details = dto.details; + evt.category = dto.category; + return evt; + } + case 'subagentInvocation': { + const evt = new ChatDebugSubagentInvocationEvent(dto.agentName, created); + evt.id = dto.id; + evt.sessionResource = sessionResource; + evt.parentEventId = dto.parentEventId; + evt.description = dto.description; + evt.status = dto.status === 'running' ? ChatDebugSubagentStatus.Running + : dto.status === 'completed' ? ChatDebugSubagentStatus.Completed + : dto.status === 'failed' ? ChatDebugSubagentStatus.Failed + : undefined; + evt.durationInMillis = dto.durationInMillis; + evt.toolCallCount = dto.toolCallCount; + evt.modelTurnCount = dto.modelTurnCount; + return evt; + } + case 'userMessage': { + const evt = new ChatDebugUserMessageEvent(dto.message, created); + evt.id = dto.id; + evt.sessionResource = sessionResource; + evt.parentEventId = dto.parentEventId; + evt.sections = dto.sections.map(s => new ChatDebugMessageSection(s.name, s.content)); + return evt; + } + case 'agentResponse': { + const evt = new ChatDebugAgentResponseEvent(dto.message, created); + evt.id = dto.id; + evt.sessionResource = sessionResource; + evt.parentEventId = dto.parentEventId; + evt.sections = dto.sections.map(s => new ChatDebugMessageSection(s.name, s.content)); + return evt; + } + default: + return undefined; + } + } + + async $exportChatDebugLog(_handle: number, sessionResource: UriComponents, coreEventDtos: IChatDebugEventDto[], sessionTitle: string | undefined, token: CancellationToken): Promise { + if (!this._provider?.provideChatDebugLogExport) { + return undefined; + } + const sessionUri = URI.revive(sessionResource); + const coreEvents = coreEventDtos.map(dto => this._deserializeEvent(dto)).filter((e): e is vscode.ChatDebugEvent => e !== undefined); + const options: vscode.ChatDebugLogExportOptions = { coreEvents, sessionTitle }; + const result = await this._provider.provideChatDebugLogExport(sessionUri, options, token); + if (!result) { + return undefined; + } + return VSBuffer.wrap(result); + } + + async $importChatDebugLog(_handle: number, data: VSBuffer, token: CancellationToken): Promise<{ uri: UriComponents; sessionTitle?: string } | undefined> { + if (!this._provider?.resolveChatDebugLogImport) { + return undefined; + } + const result = await this._provider.resolveChatDebugLogImport(data.buffer, token); + if (!result) { + return undefined; + } + return { uri: result.uri, sessionTitle: result.sessionTitle }; + } + override dispose(): void { for (const store of this._activeProgress.values()) { store.dispose(); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts index 860559d64e3..685ebc58948 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts @@ -3,12 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { joinPath } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; -import { localize2 } from '../../../../../nls.js'; +import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; +import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { isChatViewTitleActionContext } from '../../common/actions/chatActions.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; @@ -16,6 +22,7 @@ import { IChatDebugService } from '../../common/chatDebugService.js'; import { ChatViewId, IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js'; import { ChatDebugEditorInput } from '../chatDebug/chatDebugEditorInput.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { IChatDebugEditorOptions } from '../chatDebug/chatDebugTypes.js'; /** @@ -92,4 +99,100 @@ export function registerChatOpenAgentDebugPanelAction() { await editorService.openEditor(ChatDebugEditorInput.instance, options); } }); + + const defaultDebugLogFileName = 'agent-debug-log.json'; + const debugLogFilters = [{ name: localize('chatDebugLog.file.label', "Agent Debug Log"), extensions: ['json'] }]; + + registerAction2(class ExportAgentDebugLogAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.exportAgentDebugLog', + title: localize2('chat.exportAgentDebugLog.label', "Export Agent Debug Log..."), + icon: Codicon.desktopDownload, + f1: true, + category: Categories.Developer, + precondition: ChatContextKeys.enabled, + menu: [{ + id: MenuId.EditorTitle, + group: 'navigation', + when: ActiveEditorContext.isEqualTo(ChatDebugEditorInput.ID), + order: 10 + }], + }); + } + + async run(accessor: ServicesAccessor): Promise { + const chatDebugService = accessor.get(IChatDebugService); + const fileDialogService = accessor.get(IFileDialogService); + const fileService = accessor.get(IFileService); + const notificationService = accessor.get(INotificationService); + + const sessionResource = chatDebugService.activeSessionResource; + if (!sessionResource) { + notificationService.notify({ severity: Severity.Info, message: localize('chatDebugLog.noSession', "No active debug session to export. Navigate to a session first.") }); + return; + } + + const defaultUri = joinPath(await fileDialogService.defaultFilePath(), defaultDebugLogFileName); + const outputPath = await fileDialogService.showSaveDialog({ defaultUri, filters: debugLogFilters }); + if (!outputPath) { + return; + } + + const data = await chatDebugService.exportLog(sessionResource); + if (!data) { + notificationService.notify({ severity: Severity.Warning, message: localize('chatDebugLog.exportFailed', "Export is not supported by the current provider.") }); + return; + } + + await fileService.writeFile(outputPath, VSBuffer.wrap(data)); + } + }); + + registerAction2(class ImportAgentDebugLogAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.importAgentDebugLog', + title: localize2('chat.importAgentDebugLog.label', "Import Agent Debug Log..."), + icon: Codicon.cloudUpload, + f1: true, + category: Categories.Developer, + precondition: ChatContextKeys.enabled, + menu: [{ + id: MenuId.EditorTitle, + group: 'navigation', + when: ActiveEditorContext.isEqualTo(ChatDebugEditorInput.ID), + order: 11 + }], + }); + } + + async run(accessor: ServicesAccessor): Promise { + const chatDebugService = accessor.get(IChatDebugService); + const editorService = accessor.get(IEditorService); + const fileDialogService = accessor.get(IFileDialogService); + const fileService = accessor.get(IFileService); + const notificationService = accessor.get(INotificationService); + + const defaultUri = joinPath(await fileDialogService.defaultFilePath(), defaultDebugLogFileName); + const result = await fileDialogService.showOpenDialog({ + defaultUri, + canSelectFiles: true, + filters: debugLogFilters + }); + if (!result) { + return; + } + + const content = await fileService.readFile(result[0]); + const sessionUri = await chatDebugService.importLog(content.value.buffer); + if (!sessionUri) { + notificationService.notify({ severity: Severity.Warning, message: localize('chatDebugLog.importFailed', "Import is not supported by the current provider.") }); + return; + } + + const options: IChatDebugEditorOptions = { pinned: true, sessionResource: sessionUri, viewHint: 'overview' }; + await editorService.openEditor(ChatDebugEditorInput.instance, options); + } + }); } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts index 8276d701b2a..867053d97ac 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts @@ -65,9 +65,6 @@ export class ChatDebugEditor extends EditorPane { private readonly sessionModelListener = this._register(new MutableDisposable()); private readonly modelChangeListeners = this._register(new DisposableMap()); - /** Saved session resource so we can restore it after the editor is re-shown. */ - private savedSessionResource: URI | undefined; - /** * Stops the streaming pipeline and clears cached events for the * active session. Called when navigating away from a session or @@ -175,7 +172,10 @@ export class ChatDebugEditor extends EditorPane { this._register(this.chatService.onDidCreateModel(model => { if (this.viewState === ViewState.Home) { - this.homeView?.render(); + // Auto-navigate to the new session when the debug panel is + // already open on the home view. This avoids the user having to + // wait for the title to resolve and manually clicking the session. + this.navigateToSession(model.sessionResource); } // Track title changes per model, disposing the previous listener @@ -307,40 +307,11 @@ export class ChatDebugEditor extends EditorPane { super.setEditorVisible(visible); if (visible) { this.telemetryService.publicLog2<{}, ChatDebugPanelOpenedClassification>('chatDebugPanelOpened'); - // Note: do NOT read this.options here. When the editor becomes - // visible via openEditor(), setEditorVisible fires before - // setOptions, so this.options still contains stale values from - // the previous openEditor() call. Navigation from new options - // is handled entirely by setOptions → _applyNavigationOptions. - // Here we only restore the previous state when the editor is - // re-shown without a new openEditor() call (e.g., tab switch). - if (this.viewState === ViewState.Home) { - const sessionResource = this.chatDebugService.activeSessionResource ?? this.savedSessionResource; - this.savedSessionResource = undefined; - if (sessionResource) { - this.navigateToSession(sessionResource, 'overview'); - } else { - this.showView(ViewState.Home); - } - } else { - // Re-activate the streaming pipeline for the current session, - // restoring the saved session resource if the editor was temporarily hidden. - const sessionResource = this.chatDebugService.activeSessionResource ?? this.savedSessionResource; - this.savedSessionResource = undefined; - if (sessionResource) { - this.chatDebugService.activeSessionResource = sessionResource; - if (!this.chatDebugService.hasInvokedProviders(sessionResource)) { - this.chatDebugService.invokeProviders(sessionResource); - } - } else { - this.showView(ViewState.Home); - } - } - } else { - // Remember the active session so we can restore when re-shown - this.savedSessionResource = this.chatDebugService.activeSessionResource; - // Stop the streaming pipeline when the editor is hidden - this.endActiveSession(); + // Re-show the current view so it reloads events from scratch, + // ensuring correct ordering and no stale duplicates. + // Navigation from new openEditor() options is handled by + // setOptions → _applyNavigationOptions (fires after this). + this.showView(this.viewState); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts index 442c56360b1..48e5ce0dced 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts @@ -179,13 +179,18 @@ export function buildFlowGraph(events: readonly IChatDebugEvent[]): FlowNode[] { // For subagent invocations, enrich with description from the // filtered-out completion sibling, or fall back to the event's own field. - let sublabel = getEventSublabel(event, effectiveKind); + let label = getEventLabel(event, effectiveKind); + const sublabel = getEventSublabel(event, effectiveKind); let tooltip = getEventTooltip(event); let description: string | undefined; if (effectiveKind === 'subagentInvocation') { description = getSubagentDescription(event); + // Show "Subagent: " as the label so users can identify + // these nodes and see what task they perform. + label = description + ? localize('subagentWithDesc', "Subagent: {0}", truncateLabel(description, 30)) + : localize('subagentLabel', "Subagent"); if (description) { - sublabel = truncateLabel(description, 30) + (sublabel ? ` \u00b7 ${sublabel}` : ''); // Ensure description appears in tooltip if not already present if (tooltip && !tooltip.includes(description)) { const lines = tooltip.split('\n'); @@ -199,7 +204,7 @@ export function buildFlowGraph(events: readonly IChatDebugEvent[]): FlowNode[] { id: event.id ?? `event-${events.indexOf(event)}`, kind: effectiveKind, category: event.kind === 'generic' ? event.category : undefined, - label: getEventLabel(event, effectiveKind), + label, sublabel, description, tooltip, @@ -524,29 +529,17 @@ function getEventLabel(event: IChatDebugEvent, effectiveKind?: IChatDebugEvent[' const kind = effectiveKind ?? event.kind; switch (kind) { case 'userMessage': - return localize('userLabel', "User"); + return localize('userLabel', "User Message"); case 'modelTurn': return event.kind === 'modelTurn' ? (event.model ?? localize('modelTurnLabel', "Model Turn")) : localize('modelTurnLabel', "Model Turn"); case 'toolCall': - return event.kind === 'toolCall' ? event.toolName : event.kind === 'generic' ? event.name : ''; + return event.kind === 'toolCall' ? event.toolName : event.kind === 'generic' ? event.name : localize('toolCallLabel', "Tool Call"); case 'subagentInvocation': - return event.kind === 'subagentInvocation' ? event.agentName : ''; - case 'agentResponse': { - if (event.kind === 'agentResponse') { - return event.message || localize('responseLabel', "Response"); - } - // Remapped generic event — extract model name from parenthesized suffix - // e.g. "Agent response (claude-opus-4.5)" → "claude-opus-4.5" - if (event.kind === 'generic') { - const match = /\(([^)]+)\)\s*$/.exec(event.name); - if (match) { - return match[1]; - } - } - return localize('responseLabel', "Response"); - } + return event.kind === 'subagentInvocation' ? event.agentName : localize('subagentFallback', "Subagent"); + case 'agentResponse': + return localize('agentResponseLabel', "Agent Response"); case 'generic': - return event.kind === 'generic' ? event.name : ''; + return event.kind === 'generic' ? event.name : localize('genericLabel', "Event"); } } @@ -588,30 +581,32 @@ function getEventSublabel(event: IChatDebugEvent, effectiveKind?: IChatDebugEven } case 'userMessage': case 'agentResponse': { - // For proper typed events, prefer the first section's content - // (which has the actual message text) over the `message` field - // (which is a short summary/name). Fall back to `message` when - // no sections are available. For remapped generic events, use - // the details property. + // Use the message summary as the sublabel. For remapped generic + // events, use the details property. let text: string | undefined; if (event.kind === 'userMessage' || event.kind === 'agentResponse') { - text = event.sections[0]?.content || event.message; + text = event.message; } else if (event.kind === 'generic') { text = event.details; } if (!text) { return undefined; } - // Find the first non-empty line (content may start with newlines) + // Find the first meaningful line, skipping trivial lines like + // lone brackets/braces that appear when the message is JSON. const lines = text.split('\n'); let firstLine = ''; for (const line of lines) { const trimmed = line.trim(); - if (trimmed) { + if (trimmed && trimmed.length > 2) { firstLine = trimmed; break; } } + if (!firstLine) { + // Fall back to the full text collapsed to a single line + firstLine = text.replace(/\s+/g, ' ').trim(); + } if (!firstLine) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts index cf9dd80103e..403003e62bd 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts @@ -159,7 +159,15 @@ function measureNodeWidth(label: string, sublabel?: string): number { } function subgraphHeaderLabel(node: FlowNode): string { - return node.description ? `${node.label}: ${node.description}` : node.label; + // For subagent nodes, the label already includes the description + // (e.g. "Subagent: Count markdown files"), so don't append it again. + if (node.kind === 'subagentInvocation') { + return node.label; + } + if (node.description && node.description !== node.label) { + return `${node.label}: ${node.description}`; + } + return node.label; } function measureSubgraphHeaderWidth(headerLabel: string): number { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts index f167776e078..0492768b660 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts @@ -85,7 +85,24 @@ export class ChatDebugHomeView extends Disposable { const items: HTMLButtonElement[] = []; for (const sessionResource of sessionResources) { - const sessionTitle = this.chatService.getSessionTitle(sessionResource) || LocalChatSessionUri.parseLocalSessionId(sessionResource) || sessionResource.toString(); + const rawTitle = this.chatService.getSessionTitle(sessionResource); + let sessionTitle: string; + if (rawTitle && !isUUID(rawTitle)) { + sessionTitle = rawTitle; + } else if (LocalChatSessionUri.isLocalSession(sessionResource)) { + sessionTitle = localize('chatDebug.newSession', "New Chat"); + } else { + // For imported/external sessions, use the stored title if available + const importedTitle = this.chatDebugService.getImportedSessionTitle(sessionResource); + if (importedTitle) { + sessionTitle = localize('chatDebug.importedSession', "Imported: {0}", importedTitle); + } else { + // Fall back to URI segment + const uriLabel = sessionResource.path || sessionResource.fragment || sessionResource.toString(); + const segment = uriLabel.replace(/^\/+/, '').split('/').pop() || uriLabel; + sessionTitle = localize('chatDebug.importedSession', "Imported: {0}", segment); + } + } const isActive = activeSessionResource !== undefined && sessionResource.toString() === activeSessionResource.toString(); const item = DOM.append(sessionList, $('button.chat-debug-home-session-item')); @@ -98,32 +115,20 @@ export class ChatDebugHomeView extends Disposable { DOM.append(item, $(`span${ThemeIcon.asCSSSelector(Codicon.comment)}`)); const titleSpan = DOM.append(item, $('span.chat-debug-home-session-item-title')); - // Show shimmer when the title is still a UUID — the session is - // either not yet loaded or hasn't produced a real title yet. - const isShimmering = isUUID(sessionTitle); - if (isShimmering) { - titleSpan.classList.add('chat-debug-home-session-item-shimmer'); - item.disabled = true; - item.setAttribute('aria-busy', 'true'); - item.setAttribute('aria-label', localize('chatDebug.loadingSession', "Loading session…")); - } else { - titleSpan.textContent = sessionTitle; - const ariaLabel = isActive - ? localize('chatDebug.sessionItemActive', "{0} (active)", sessionTitle) - : sessionTitle; - item.setAttribute('aria-label', ariaLabel); - } + titleSpan.textContent = sessionTitle; + const ariaLabel = isActive + ? localize('chatDebug.sessionItemActive', "{0} (active)", sessionTitle) + : sessionTitle; + item.setAttribute('aria-label', ariaLabel); if (isActive) { DOM.append(item, $('span.chat-debug-home-session-badge', undefined, localize('chatDebug.active', "Active"))); } - if (!isShimmering) { - this.renderDisposables.add(DOM.addDisposableListener(item, DOM.EventType.CLICK, () => { - this._onNavigateToSession.fire(sessionResource); - })); - items.push(item); - } + this.renderDisposables.add(DOM.addDisposableListener(item, DOM.EventType.CLICK, () => { + this._onNavigateToSession.fire(sessionResource); + })); + items.push(item); } // Arrow key navigation between session items diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts index 8cbf8c1a90d..550fa005b81 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts @@ -12,6 +12,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; import { combinedDisposable, Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../base/common/observable.js'; +import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; @@ -63,6 +64,7 @@ export class ChatDebugLogsView extends Disposable { private currentDimension: Dimension | undefined; private readonly eventListener = this._register(new MutableDisposable()); private readonly sessionStateDisposable = this._register(new MutableDisposable()); + private readonly refreshScheduler: RunOnceScheduler; private shimmerRow!: HTMLElement; constructor( @@ -75,6 +77,7 @@ export class ChatDebugLogsView extends Disposable { @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, ) { super(); + this.refreshScheduler = this._register(new RunOnceScheduler(() => this.refreshList(), 50)); this.container = DOM.append(parent, $('.chat-debug-logs')); DOM.hide(this.container); @@ -383,8 +386,32 @@ export class ChatDebugLogsView extends Disposable { } addEvent(event: IChatDebugEvent): void { - this.events.push(event); - this.refreshList(); + // Binary-insert to maintain chronological order without a full sort. + // Events almost always arrive in order, so the insertion point is + // typically at the end (O(log n) comparison, O(1) splice). + const time = event.created.getTime(); + let lo = 0; + let hi = this.events.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (this.events[mid].created.getTime() <= time) { + lo = mid + 1; + } else { + hi = mid; + } + } + if (lo === this.events.length) { + this.events.push(event); + } else { + this.events.splice(lo, 0, event); + } + this.scheduleRefresh(); + } + + private scheduleRefresh(): void { + if (!this.refreshScheduler.isScheduled()) { + this.refreshScheduler.schedule(); + } } private loadEvents(): void { @@ -392,8 +419,7 @@ export class ChatDebugLogsView extends Disposable { const addEventDisposable = this.chatDebugService.onDidAddEvent(e => { if (!this.currentSessionResource || e.sessionResource.toString() === this.currentSessionResource.toString()) { - this.events.push(e); - this.refreshList(); + this.addEvent(e); } }); diff --git a/src/vs/workbench/contrib/chat/common/chatDebugService.ts b/src/vs/workbench/contrib/chat/common/chatDebugService.ts index 7a6e5721ba1..d97213d3f3e 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugService.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugService.ts @@ -196,6 +196,34 @@ export interface IChatDebugService extends IDisposable { */ resolveEvent(eventId: string): Promise; + /** + /** + * Export the debug log for a session via the registered provider. + */ + exportLog(sessionResource: URI): Promise; + + /** + * Import a previously exported debug log via the registered provider. + * Returns the session URI for the imported data. + */ + importLog(data: Uint8Array): Promise; + + /** + * Returns true if the event was logged by VS Code core + * (not sourced from an external provider). + */ + isCoreEvent(event: IChatDebugEvent): boolean; + + /** + * Store a human-readable title for an imported session. + */ + setImportedSessionTitle(sessionResource: URI, title: string): void; + + /** + * Get the stored title for an imported session, if available. + */ + getImportedSessionTitle(sessionResource: URI): string | undefined; + /** * Fired when debug data is attached to a session. */ @@ -314,4 +342,6 @@ export type IChatDebugResolvedEventContent = IChatDebugEventTextContent | IChatD export interface IChatDebugLogProvider { provideChatDebugLog(sessionResource: URI, token: CancellationToken): Promise; resolveChatDebugLogEvent?(eventId: string, token: CancellationToken): Promise; + provideChatDebugLogExport?(sessionResource: URI, token: CancellationToken): Promise; + resolveChatDebugLogImport?(data: Uint8Array, token: CancellationToken): Promise; } diff --git a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts index 9cdd711a311..c80186d968d 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts @@ -39,6 +39,12 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic /** Events that were returned by providers (not internally logged). */ private readonly _providerEvents = new WeakSet(); + /** Session URIs created via import, allowed through the invokeProviders guard. */ + private readonly _importedSessions = new ResourceMap(); + + /** Human-readable titles for imported sessions. */ + private readonly _importedSessionTitles = new ResourceMap(); + activeSessionResource: URI | undefined; log(sessionResource: URI, name: string, details?: string, level: ChatDebugLogLevel = ChatDebugLogLevel.Info, options?: { id?: string; category?: string; parentEventId?: string }): void { @@ -135,10 +141,10 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic } async invokeProviders(sessionResource: URI): Promise { - if (!LocalChatSessionUri.isLocalSession(sessionResource)) { + + if (!LocalChatSessionUri.isLocalSession(sessionResource) && !this._importedSessions.has(sessionResource)) { return; } - // Cancel only the previous invocation for THIS session, not others. // Each session has its own pipeline so events from multiple sessions // can be streamed concurrently. @@ -247,6 +253,51 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic return undefined; } + 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); + } + + async exportLog(sessionResource: URI): Promise { + 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 { + 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 { for (const cts of this._invocationCts.values()) { cts.cancel(); diff --git a/src/vscode-dts/vscode.proposed.chatDebug.d.ts b/src/vscode-dts/vscode.proposed.chatDebug.d.ts index f74f4e7ba11..3fe781d29fc 100644 --- a/src/vscode-dts/vscode.proposed.chatDebug.d.ts +++ b/src/vscode-dts/vscode.proposed.chatDebug.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 2 +// version: 3 declare module 'vscode' { /** @@ -642,6 +642,37 @@ declare module 'vscode' { eventId: string, token: CancellationToken ): ProviderResult; + + /** + * Export the debug log for a chat session as a serialized byte array. + * The extension controls the format (e.g., OTLP JSON with Copilot extensions). + * Core provides the save dialog and writes the returned bytes to disk. + * + * @param sessionResource The resource URI of the chat session to export. + * @param options Export options including core events and session metadata. + * @param token A cancellation token. + * @returns The serialized debug log data, or undefined if export is not available. + */ + provideChatDebugLogExport?( + sessionResource: Uri, + options: ChatDebugLogExportOptions, + token: CancellationToken + ): ProviderResult; + + /** + * Import a previously exported debug log from a serialized byte array. + * Core provides the open dialog and reads the file bytes. + * The extension deserializes the data and returns a session URI that can be + * opened in the debug panel via {@link provideChatDebugLog}. + * + * @param data The serialized debug log data (as returned by {@link provideChatDebugLogExport}). + * @param token A cancellation token. + * @returns The imported session info, or undefined if import failed. + */ + resolveChatDebugLogImport?( + data: Uint8Array, + token: CancellationToken + ): ProviderResult; } export namespace chat { @@ -654,4 +685,36 @@ declare module 'vscode' { */ export function registerChatDebugLogProvider(provider: ChatDebugLogProvider): Disposable; } + + /** + * Options passed to {@link ChatDebugLogProvider.provideChatDebugLogExport}. + */ + export interface ChatDebugLogExportOptions { + /** + * Core-originated debug events (prompt discovery, skill loading, etc.) + * for the session. The extension may include these in the export alongside its own data. + */ + readonly coreEvents: readonly ChatDebugEvent[]; + + /** + * Session title, if available. + * Used to provide a human-readable label in the exported file. + */ + readonly sessionTitle?: string; + } + + /** + * Result of importing a debug log via {@link ChatDebugLogProvider.resolveChatDebugLogImport}. + */ + export interface ChatDebugLogImportResult { + /** + * The session resource URI for the imported session. + */ + readonly uri: Uri; + + /** + * The session title from the imported file, if available. + */ + readonly sessionTitle?: string; + } }