Revert "Revert "Debug Panel: oTel data source support and Import/export (#299256)"" (#300477)

Revert "Revert "Debug Panel: oTel data source support and Import/export (#299…"

This reverts commit 11246017b6.
This commit is contained in:
Vijay Upadya
2026-03-10 10:32:58 -07:00
committed by GitHub
parent 7daf926d27
commit 34bfd71aea
13 changed files with 502 additions and 100 deletions

View File

@@ -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',

View File

@@ -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,

View File

@@ -1502,6 +1502,8 @@ export type IChatDebugResolvedEventContentDto = IChatDebugEventTextContentDto |
export interface ExtHostChatDebugShape {
$provideChatDebugLog(handle: number, sessionResource: UriComponents, token: CancellationToken): Promise<IChatDebugEventDto[] | undefined>;
$resolveChatDebugLogEvent(handle: number, eventId: string, token: CancellationToken): Promise<IChatDebugResolvedEventContentDto | undefined>;
$exportChatDebugLog(handle: number, sessionResource: UriComponents, coreEvents: IChatDebugEventDto[], sessionTitle: string | undefined, token: CancellationToken): Promise<VSBuffer | undefined>;
$importChatDebugLog(handle: number, data: VSBuffer, token: CancellationToken): Promise<{ uri: UriComponents; sessionTitle?: string } | undefined>;
}
export interface MainThreadChatDebugShape extends IDisposable {

View File

@@ -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<VSBuffer | undefined> {
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();

View File

@@ -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<void> {
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<void> {
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);
}
});
}

View File

@@ -65,9 +65,6 @@ export class ChatDebugEditor extends EditorPane {
private readonly sessionModelListener = this._register(new MutableDisposable());
private readonly modelChangeListeners = this._register(new DisposableMap<string>());
/** 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);
}
}

View File

@@ -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: <description>" 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;
}

View File

@@ -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 {

View File

@@ -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, $<HTMLButtonElement>('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

View File

@@ -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);
}
});

View File

@@ -196,6 +196,34 @@ export interface IChatDebugService extends IDisposable {
*/
resolveEvent(eventId: string): Promise<IChatDebugResolvedEventContent | undefined>;
/**
/**
* Export the debug log for a session via the registered provider.
*/
exportLog(sessionResource: URI): Promise<Uint8Array | undefined>;
/**
* Import a previously exported debug log via the registered provider.
* Returns the session URI for the imported data.
*/
importLog(data: Uint8Array): Promise<URI | undefined>;
/**
* 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<IChatDebugEvent[] | undefined>;
resolveChatDebugLogEvent?(eventId: string, token: CancellationToken): Promise<IChatDebugResolvedEventContent | undefined>;
provideChatDebugLogExport?(sessionResource: URI, token: CancellationToken): Promise<Uint8Array | undefined>;
resolveChatDebugLogImport?(data: Uint8Array, token: CancellationToken): Promise<URI | undefined>;
}

View File

@@ -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<IChatDebugEvent>();
/** Session URIs created via import, allowed through the invokeProviders guard. */
private readonly _importedSessions = new ResourceMap<boolean>();
/** Human-readable titles for imported sessions. */
private readonly _importedSessionTitles = new ResourceMap<string>();
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<void> {
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<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 {
for (const cts of this._invocationCts.values()) {
cts.cancel();

View File

@@ -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<ChatDebugResolvedEventContent>;
/**
* 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<Uint8Array>;
/**
* 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<ChatDebugLogImportResult>;
}
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;
}
}