mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-02 08:15:56 +01:00
Revert "Revert "Debug Panel: oTel data source support and Import/export (#299…"
This reverts commit 11246017b6.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
65
src/vscode-dts/vscode.proposed.chatDebug.d.ts
vendored
65
src/vscode-dts/vscode.proposed.chatDebug.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user