diff --git a/package.json b/package.json index 4978fb523e4..f8a9d07778b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "1f2dfeb39feaf6ddf8e15132c1e49fcf8be5b796", + "distro": "1b8e5b3448f8be12228bc9bee56b42a5f95158e7", "author": { "name": "Microsoft Corporation" }, diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 1cbbd458e6d..d58340091df 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -43,6 +43,10 @@ const _allApiProposals = { chatContextProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatContextProvider.d.ts', }, + chatDebug: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatDebug.d.ts', + version: 1 + }, chatHooks: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', version: 6 diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 7e8e6220540..051a9f6f85d 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -93,6 +93,7 @@ import './mainThreadAiEmbeddingVector.js'; import './mainThreadAiSettingsSearch.js'; import './mainThreadMcp.js'; import './mainThreadChatContext.js'; +import './mainThreadChatDebug.js'; import './mainThreadChatStatus.js'; import './mainThreadChatOutputRenderer.js'; import './mainThreadChatSessions.js'; diff --git a/src/vs/workbench/api/browser/mainThreadChatDebug.ts b/src/vs/workbench/api/browser/mainThreadChatDebug.ts new file mode 100644 index 00000000000..d36be258647 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadChatDebug.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugService } from '../../contrib/chat/common/chatDebugService.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'; + +@extHostNamedCustomer(MainContext.MainThreadChatDebug) +export class MainThreadChatDebug extends Disposable implements MainThreadChatDebugShape { + private readonly _proxy: Proxied; + private readonly _providerDisposables = new Map(); + private readonly _activeSessionResources = new Map(); + + constructor( + extHostContext: IExtHostContext, + @IChatDebugService private readonly _chatDebugService: IChatDebugService, + ) { + super(); + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatDebug); + } + + $registerChatDebugLogProvider(handle: number): void { + const disposables = new DisposableStore(); + this._providerDisposables.set(handle, disposables); + + disposables.add(this._chatDebugService.registerProvider({ + provideChatDebugLog: async (sessionResource, token) => { + this._activeSessionResources.set(handle, sessionResource); + const dtos = await this._proxy.$provideChatDebugLog(handle, sessionResource, token); + return dtos?.map(dto => this._reviveEvent(dto, sessionResource)); + }, + resolveChatDebugLogEvent: async (eventId, token) => { + return this._proxy.$resolveChatDebugLogEvent(handle, eventId, token); + } + })); + } + + $unregisterChatDebugLogProvider(handle: number): void { + const disposables = this._providerDisposables.get(handle); + disposables?.dispose(); + this._providerDisposables.delete(handle); + this._activeSessionResources.delete(handle); + } + + $acceptChatDebugEvent(handle: number, dto: IChatDebugEventDto): void { + const sessionResource = (dto.sessionResource ? URI.revive(dto.sessionResource) : undefined) + ?? this._activeSessionResources.get(handle) + ?? this._chatDebugService.activeSessionResource; + if (!sessionResource) { + return; + } + const revived = this._reviveEvent(dto, sessionResource); + this._chatDebugService.addProviderEvent(revived); + } + + private _reviveEvent(dto: IChatDebugEventDto, sessionResource: URI): IChatDebugEvent { + const base = { + id: dto.id, + sessionResource, + created: new Date(dto.created), + parentEventId: dto.parentEventId, + }; + + switch (dto.kind) { + case 'toolCall': + return { + ...base, + kind: 'toolCall', + toolName: dto.toolName, + toolCallId: dto.toolCallId, + input: dto.input, + output: dto.output, + result: dto.result, + durationInMillis: dto.durationInMillis, + }; + case 'modelTurn': + return { + ...base, + kind: 'modelTurn', + model: dto.model, + inputTokens: dto.inputTokens, + outputTokens: dto.outputTokens, + totalTokens: dto.totalTokens, + durationInMillis: dto.durationInMillis, + }; + case 'generic': + return { + ...base, + kind: 'generic', + name: dto.name, + details: dto.details, + level: dto.level as ChatDebugLogLevel, + category: dto.category, + }; + case 'subagentInvocation': + return { + ...base, + kind: 'subagentInvocation', + agentName: dto.agentName, + description: dto.description, + status: dto.status, + durationInMillis: dto.durationInMillis, + toolCallCount: dto.toolCallCount, + modelTurnCount: dto.modelTurnCount, + }; + case 'userMessage': + return { + ...base, + kind: 'userMessage', + message: dto.message, + sections: dto.sections, + }; + case 'agentResponse': + return { + ...base, + kind: 'agentResponse', + message: dto.message, + sections: dto.sections, + }; + } + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index a8a6733b681..4d3c602d6a2 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -117,6 +117,7 @@ import { IExtHostWindow } from './extHostWindow.js'; import { IExtHostPower } from './extHostPower.js'; import { IExtHostWorkspace } from './extHostWorkspace.js'; import { ExtHostChatContext } from './extHostChatContext.js'; +import { ExtHostChatDebug } from './extHostChatDebug.js'; import { IExtHostMeteredConnection } from './extHostMeteredConnection.js'; import { IExtHostGitExtensionService } from './extHostGitExtensionService.js'; @@ -239,6 +240,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostChatSessions = rpcProtocol.set(ExtHostContext.ExtHostChatSessions, new ExtHostChatSessions(extHostCommands, extHostLanguageModels, rpcProtocol, extHostLogService)); const extHostChatAgents2 = rpcProtocol.set(ExtHostContext.ExtHostChatAgents2, new ExtHostChatAgents2(rpcProtocol, extHostLogService, extHostCommands, extHostDocuments, extHostDocumentsAndEditors, extHostLanguageModels, extHostDiagnostics, extHostLanguageModelTools)); const extHostChatContext = rpcProtocol.set(ExtHostContext.ExtHostChatContext, new ExtHostChatContext(rpcProtocol, extHostCommands)); + const extHostChatDebug = rpcProtocol.set(ExtHostContext.ExtHostChatDebug, new ExtHostChatDebug(rpcProtocol)); const extHostAiRelatedInformation = rpcProtocol.set(ExtHostContext.ExtHostAiRelatedInformation, new ExtHostRelatedInformation(rpcProtocol)); const extHostAiEmbeddingVector = rpcProtocol.set(ExtHostContext.ExtHostAiEmbeddingVector, new ExtHostAiEmbeddingVector(rpcProtocol)); const extHostAiSettingsSearch = rpcProtocol.set(ExtHostContext.ExtHostAiSettingsSearch, new ExtHostAiSettingsSearch(rpcProtocol)); @@ -1674,6 +1676,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.registerPromptFileProvider(extension, PromptsType.skill, provider); }, + registerChatDebugLogProvider(provider: vscode.ChatDebugLogProvider): vscode.Disposable { + checkProposedApiEnabled(extension, 'chatDebug'); + return extHostChatDebug.registerChatDebugLogProvider(provider); + }, }; // namespace: lm @@ -2061,6 +2067,18 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatToolInvocationPart: extHostTypes.ChatToolInvocationPart, ChatLocation: extHostTypes.ChatLocation, ChatSessionStatus: extHostTypes.ChatSessionStatus, + ChatDebugLogLevel: extHostTypes.ChatDebugLogLevel, + ChatDebugToolCallResult: extHostTypes.ChatDebugToolCallResult, + ChatDebugToolCallEvent: extHostTypes.ChatDebugToolCallEvent, + ChatDebugModelTurnEvent: extHostTypes.ChatDebugModelTurnEvent, + ChatDebugGenericEvent: extHostTypes.ChatDebugGenericEvent, + ChatDebugSubagentInvocationEvent: extHostTypes.ChatDebugSubagentInvocationEvent, + ChatDebugUserMessageEvent: extHostTypes.ChatDebugUserMessageEvent, + ChatDebugAgentResponseEvent: extHostTypes.ChatDebugAgentResponseEvent, + ChatDebugMessageSection: extHostTypes.ChatDebugMessageSection, + ChatDebugEventTextContent: extHostTypes.ChatDebugEventTextContent, + ChatDebugMessageContentType: extHostTypes.ChatDebugMessageContentType, + ChatDebugEventMessageContent: extHostTypes.ChatDebugEventMessageContent, ChatRequestEditorData: extHostTypes.ChatRequestEditorData, ChatRequestNotebookData: extHostTypes.ChatRequestNotebookData, ChatReferenceBinaryData: extHostTypes.ChatReferenceBinaryData, @@ -2103,6 +2121,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I McpToolInvocationContentData: extHostTypes.McpToolInvocationContentData, SettingsSearchResultKind: extHostTypes.SettingsSearchResultKind, ChatTodoStatus: extHostTypes.ChatTodoStatus, + ChatDebugSubagentStatus: extHostTypes.ChatDebugSubagentStatus, }; }; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 420815602e5..05e77f2e300 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1395,6 +1395,94 @@ export interface MainThreadChatContextShape extends IDisposable { $executeChatContextItemCommand(itemHandle: number): Promise; } +export interface IChatDebugEventCommonDto { + readonly id?: string; + readonly sessionResource?: UriComponents; + readonly created: number; + readonly parentEventId?: string; +} + +export interface IChatDebugToolCallEventDto extends IChatDebugEventCommonDto { + readonly kind: 'toolCall'; + readonly toolName: string; + readonly toolCallId?: string; + readonly input?: string; + readonly output?: string; + readonly result?: 'success' | 'error'; + readonly durationInMillis?: number; +} + +export interface IChatDebugModelTurnEventDto extends IChatDebugEventCommonDto { + readonly kind: 'modelTurn'; + readonly model?: string; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly totalTokens?: number; + readonly durationInMillis?: number; +} + +export interface IChatDebugGenericEventDto extends IChatDebugEventCommonDto { + readonly kind: 'generic'; + readonly name: string; + readonly details?: string; + readonly level: number; + readonly category?: string; +} + +export interface IChatDebugSubagentInvocationEventDto extends IChatDebugEventCommonDto { + readonly kind: 'subagentInvocation'; + readonly agentName: string; + readonly description?: string; + readonly status?: 'running' | 'completed' | 'failed'; + readonly durationInMillis?: number; + readonly toolCallCount?: number; + readonly modelTurnCount?: number; +} + +export interface IChatDebugMessageSectionDto { + readonly name: string; + readonly content: string; +} + +export interface IChatDebugUserMessageEventDto extends IChatDebugEventCommonDto { + readonly kind: 'userMessage'; + readonly message: string; + readonly sections: readonly IChatDebugMessageSectionDto[]; +} + +export interface IChatDebugAgentResponseEventDto extends IChatDebugEventCommonDto { + readonly kind: 'agentResponse'; + readonly message: string; + readonly sections: readonly IChatDebugMessageSectionDto[]; +} + +export type IChatDebugEventDto = IChatDebugToolCallEventDto | IChatDebugModelTurnEventDto | IChatDebugGenericEventDto | IChatDebugSubagentInvocationEventDto | IChatDebugUserMessageEventDto | IChatDebugAgentResponseEventDto; + +export interface IChatDebugEventTextContentDto { + readonly kind: 'text'; + readonly value: string; +} + +export interface IChatDebugEventMessageContentDto { + readonly kind: 'message'; + readonly type: 'user' | 'agent'; + readonly message: string; + readonly sections: readonly IChatDebugMessageSectionDto[]; +} + +export type IChatDebugResolvedEventContentDto = IChatDebugEventTextContentDto | IChatDebugEventMessageContentDto; + +export interface ExtHostChatDebugShape { + $provideChatDebugLog(handle: number, sessionResource: UriComponents, token: CancellationToken): Promise; + $resolveChatDebugLogEvent(handle: number, eventId: string, token: CancellationToken): Promise; +} + +export interface MainThreadChatDebugShape extends IDisposable { + $registerChatDebugLogProvider(handle: number): void; + $unregisterChatDebugLogProvider(handle: number): void; + $acceptChatDebugEvent(handle: number, event: IChatDebugEventDto): void; +} + export interface MainThreadEmbeddingsShape extends IDisposable { $registerEmbeddingProvider(handle: number, identifier: string): void; $unregisterEmbeddingProvider(handle: number): void; @@ -3606,6 +3694,7 @@ export const MainContext = { MainThreadChatSessions: createProxyIdentifier('MainThreadChatSessions'), MainThreadChatOutputRenderer: createProxyIdentifier('MainThreadChatOutputRenderer'), MainThreadChatContext: createProxyIdentifier('MainThreadChatContext'), + MainThreadChatDebug: createProxyIdentifier('MainThreadChatDebug'), }; export const ExtHostContext = { @@ -3667,6 +3756,7 @@ export const ExtHostContext = { ExtHostLanguageModelTools: createProxyIdentifier('ExtHostChatSkills'), ExtHostChatProvider: createProxyIdentifier('ExtHostChatProvider'), ExtHostChatContext: createProxyIdentifier('ExtHostChatContext'), + ExtHostChatDebug: createProxyIdentifier('ExtHostChatDebug'), ExtHostSpeech: createProxyIdentifier('ExtHostSpeech'), ExtHostEmbeddings: createProxyIdentifier('ExtHostEmbeddings'), ExtHostAiRelatedInformation: createProxyIdentifier('ExtHostAiRelatedInformation'), diff --git a/src/vs/workbench/api/common/extHostChatDebug.ts b/src/vs/workbench/api/common/extHostChatDebug.ts new file mode 100644 index 00000000000..bf9b6495b84 --- /dev/null +++ b/src/vs/workbench/api/common/extHostChatDebug.ts @@ -0,0 +1,267 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +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 { IExtHostRpcService } from './extHostRpcService.js'; + +export class ExtHostChatDebug extends Disposable implements ExtHostChatDebugShape { + declare _serviceBrand: undefined; + + private readonly _proxy: MainThreadChatDebugShape; + private _provider: vscode.ChatDebugLogProvider | undefined; + private _nextHandle: number = 0; + /** Progress pipelines keyed by `${handle}:${sessionResource}` so multiple sessions can stream concurrently. */ + private readonly _activeProgress = new Map(); + + constructor( + @IExtHostRpcService extHostRpc: IExtHostRpcService, + ) { + super(); + this._proxy = extHostRpc.getProxy(MainContext.MainThreadChatDebug); + } + + private _progressKey(handle: number, sessionResource: UriComponents): string { + return `${handle}:${URI.revive(sessionResource).toString()}`; + } + + private _cleanupProgress(key: string): void { + const store = this._activeProgress.get(key); + if (store) { + store.dispose(); + this._activeProgress.delete(key); + } + } + + registerChatDebugLogProvider(provider: vscode.ChatDebugLogProvider): vscode.Disposable { + if (this._provider) { + throw new Error('A ChatDebugLogProvider is already registered.'); + } + this._provider = provider; + const handle = this._nextHandle++; + this._proxy.$registerChatDebugLogProvider(handle); + + return toDisposable(() => { + this._provider = undefined; + // Clean up all progress pipelines for this handle + for (const [key, store] of this._activeProgress) { + if (key.startsWith(`${handle}:`)) { + store.dispose(); + this._activeProgress.delete(key); + } + } + this._proxy.$unregisterChatDebugLogProvider(handle); + }); + } + + async $provideChatDebugLog(handle: number, sessionResource: UriComponents, token: CancellationToken): Promise { + if (!this._provider) { + return undefined; + } + + // Clean up any previous progress pipeline for this handle+session pair + const key = this._progressKey(handle, sessionResource); + this._cleanupProgress(key); + + const store = new DisposableStore(); + this._activeProgress.set(key, store); + + const emitter = store.add(new Emitter()); + + // Forward progress events to the main thread + store.add(emitter.event(event => { + const dto = this._serializeEvent(event); + if (!dto.sessionResource) { + (dto as { sessionResource?: UriComponents }).sessionResource = sessionResource; + } + this._proxy.$acceptChatDebugEvent(handle, dto); + })); + + // Clean up when the token is cancelled + store.add(token.onCancellationRequested(() => { + this._cleanupProgress(key); + })); + + try { + const progress: vscode.Progress = { + report: (value) => emitter.fire(value) + }; + + const sessionUri = URI.revive(sessionResource); + const result = await this._provider.provideChatDebugLog(sessionUri, progress, token); + if (!result) { + return undefined; + } + + return result.map(event => this._serializeEvent(event)); + } catch (err) { + this._cleanupProgress(key); + throw err; + } + // Note: do NOT dispose progress pipeline here - keep it alive for + // streaming events via progress.report() after the initial return. + // It will be cleaned up when a new session is requested, the token + // is cancelled, or the provider is unregistered. + } + + private _serializeEvent(event: vscode.ChatDebugEvent): IChatDebugEventDto { + const base = { + id: event.id, + sessionResource: (event as { sessionResource?: vscode.Uri }).sessionResource, + created: event.created.getTime(), + parentEventId: event.parentEventId, + }; + + // Use the _kind discriminant set by all event class constructors. + // This works both for direct instances and when extensions bundle + // their own copy of the API types (where instanceof would fail). + const kind = (event as { _kind?: string })._kind; + switch (kind) { + case 'toolCall': { + const e = event as vscode.ChatDebugToolCallEvent; + return { + ...base, + kind: 'toolCall', + toolName: e.toolName, + toolCallId: e.toolCallId, + input: e.input, + output: e.output, + result: e.result === ChatDebugToolCallResult.Success ? 'success' + : e.result === ChatDebugToolCallResult.Error ? 'error' + : undefined, + durationInMillis: e.durationInMillis, + }; + } + case 'modelTurn': { + const e = event as vscode.ChatDebugModelTurnEvent; + return { + ...base, + kind: 'modelTurn', + model: e.model, + inputTokens: e.inputTokens, + outputTokens: e.outputTokens, + totalTokens: e.totalTokens, + durationInMillis: e.durationInMillis, + }; + } + case 'generic': { + const e = event as vscode.ChatDebugGenericEvent; + return { + ...base, + kind: 'generic', + name: e.name, + details: e.details, + level: e.level, + category: e.category, + }; + } + case 'subagentInvocation': { + const e = event as vscode.ChatDebugSubagentInvocationEvent; + return { + ...base, + kind: 'subagentInvocation', + agentName: e.agentName, + description: e.description, + status: e.status === ChatDebugSubagentStatus.Running ? 'running' + : e.status === ChatDebugSubagentStatus.Completed ? 'completed' + : e.status === ChatDebugSubagentStatus.Failed ? 'failed' + : undefined, + durationInMillis: e.durationInMillis, + toolCallCount: e.toolCallCount, + modelTurnCount: e.modelTurnCount, + }; + } + case 'userMessage': { + const e = event as vscode.ChatDebugUserMessageEvent; + return { + ...base, + kind: 'userMessage', + message: e.message, + sections: e.sections.map(s => ({ name: s.name, content: s.content })), + }; + } + case 'agentResponse': { + const e = event as vscode.ChatDebugAgentResponseEvent; + return { + ...base, + kind: 'agentResponse', + message: e.message, + sections: e.sections.map(s => ({ name: s.name, content: s.content })), + }; + } + default: { + // Final fallback: treat as generic + const generic = event as vscode.ChatDebugGenericEvent; + return { + ...base, + kind: 'generic', + name: generic.name ?? '', + details: generic.details, + level: generic.level ?? 1, + category: generic.category, + }; + } + } + } + + async $resolveChatDebugLogEvent(_handle: number, eventId: string, token: CancellationToken): Promise { + if (!this._provider?.resolveChatDebugLogEvent) { + return undefined; + } + const result = await this._provider.resolveChatDebugLogEvent(eventId, token); + if (!result) { + return undefined; + } + + // Use the _kind discriminant set by all content class constructors. + const kind = (result as { _kind?: string })._kind; + switch (kind) { + case 'text': + return { kind: 'text', value: (result as vscode.ChatDebugEventTextContent).value }; + case 'messageContent': { + const msg = result as vscode.ChatDebugEventMessageContent; + return { + kind: 'message', + type: msg.type === ChatDebugMessageContentType.User ? 'user' : 'agent', + message: msg.message, + sections: msg.sections.map(s => ({ name: s.name, content: s.content })), + }; + } + case 'userMessage': { + const msg = result as vscode.ChatDebugUserMessageEvent; + return { + kind: 'message', + type: 'user', + message: msg.message, + sections: msg.sections.map(s => ({ name: s.name, content: s.content })), + }; + } + case 'agentResponse': { + const msg = result as vscode.ChatDebugAgentResponseEvent; + return { + kind: 'message', + type: 'agent', + message: msg.message, + sections: msg.sections.map(s => ({ name: s.name, content: s.content })), + }; + } + default: + return undefined; + } + } + + override dispose(): void { + for (const store of this._activeProgress.values()) { + store.dispose(); + } + this._activeProgress.clear(); + super.dispose(); + } +} diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 0b762be0bdc..890d4cfc677 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3485,6 +3485,12 @@ export enum ChatTodoStatus { Completed = 3 } +export enum ChatDebugSubagentStatus { + Running = 0, + Completed = 1, + Failed = 2 +} + export class ChatToolInvocationPart { toolName: string; toolCallId: string; @@ -3555,6 +3561,161 @@ export enum ChatSessionStatus { NeedsInput = 3 } +export enum ChatDebugLogLevel { + Trace = 0, + Info = 1, + Warning = 2, + Error = 3 +} + +export enum ChatDebugToolCallResult { + Success = 0, + Error = 1 +} + +export class ChatDebugToolCallEvent { + readonly _kind = 'toolCall'; + id?: string; + sessionResource?: vscode.Uri; + created: Date; + parentEventId?: string; + toolName: string; + toolCallId?: string; + input?: string; + output?: string; + result?: ChatDebugToolCallResult; + durationInMillis?: number; + + constructor(toolName: string, created: Date) { + this.toolName = toolName; + this.created = created; + } +} + +export class ChatDebugModelTurnEvent { + readonly _kind = 'modelTurn'; + id?: string; + sessionResource?: vscode.Uri; + created: Date; + parentEventId?: string; + model?: string; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + cost?: number; + durationInMillis?: number; + + constructor(created: Date) { + this.created = created; + } +} + +export class ChatDebugGenericEvent { + readonly _kind = 'generic'; + id?: string; + sessionResource?: vscode.Uri; + created: Date; + parentEventId?: string; + name: string; + details?: string; + level: ChatDebugLogLevel; + category?: string; + + constructor(name: string, level: ChatDebugLogLevel, created: Date) { + this.name = name; + this.level = level; + this.created = created; + } +} + +export class ChatDebugSubagentInvocationEvent { + readonly _kind = 'subagentInvocation'; + id?: string; + sessionResource?: vscode.Uri; + created: Date; + parentEventId?: string; + agentName: string; + description?: string; + status?: ChatDebugSubagentStatus; + durationInMillis?: number; + toolCallCount?: number; + modelTurnCount?: number; + + constructor(agentName: string, created: Date) { + this.agentName = agentName; + this.created = created; + } +} + +export class ChatDebugMessageSection { + name: string; + content: string; + + constructor(name: string, content: string) { + this.name = name; + this.content = content; + } +} + +export class ChatDebugUserMessageEvent { + readonly _kind = 'userMessage'; + id?: string; + sessionResource?: vscode.Uri; + created: Date; + parentEventId?: string; + message: string; + sections: ChatDebugMessageSection[]; + + constructor(message: string, created: Date) { + this.message = message; + this.created = created; + this.sections = []; + } +} + +export class ChatDebugAgentResponseEvent { + readonly _kind = 'agentResponse'; + id?: string; + sessionResource?: vscode.Uri; + created: Date; + parentEventId?: string; + message: string; + sections: ChatDebugMessageSection[]; + + constructor(message: string, created: Date) { + this.message = message; + this.created = created; + this.sections = []; + } +} + +export class ChatDebugEventTextContent { + readonly _kind = 'text'; + value: string; + + constructor(value: string) { + this.value = value; + } +} + +export enum ChatDebugMessageContentType { + User = 0, + Agent = 1 +} + +export class ChatDebugEventMessageContent { + readonly _kind = 'messageContent'; + type: ChatDebugMessageContentType; + message: string; + sections: ChatDebugMessageSection[]; + + constructor(type: ChatDebugMessageContentType, message: string, sections: ChatDebugMessageSection[]) { + this.type = type; + this.message = message; + this.sections = sections; + } +} + export class ChatSessionChangedFile { constructor(public readonly modifiedUri: vscode.Uri, public readonly insertions: number, public readonly deletions: number, public readonly originalUri?: vscode.Uri) { } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts deleted file mode 100644 index 225803f5ae0..00000000000 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts +++ /dev/null @@ -1,833 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { Schemas } from '../../../../../base/common/network.js'; -import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; -import { localize2 } from '../../../../../nls.js'; -import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IPromptsService, PromptsStorage, IPromptFileDiscoveryResult, PromptFileSkipReason, AgentFileType } from '../../common/promptSyntax/service/promptsService.js'; -import { PromptsConfig } from '../../common/promptSyntax/config/config.js'; -import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { basename, dirname, relativePath } from '../../../../../base/common/resources.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; -import { URI } from '../../../../../base/common/uri.js'; -import * as nls from '../../../../../nls.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, IResolvedPromptSourceFolder } from '../../common/promptSyntax/config/promptFileLocations.js'; -import { IUntitledTextEditorService } from '../../../../services/untitled/common/untitledTextEditorService.js'; -import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js'; -import { ChatViewId } from '../chat.js'; -import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; -import { IPathService } from '../../../../services/path/common/pathService.js'; -import { parseAllHookFiles, IParsedHook } from '../promptSyntax/hookUtils.js'; -import { ILabelService } from '../../../../../platform/label/common/label.js'; -import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; -import { OS } from '../../../../../base/common/platform.js'; - -/** - * URL encodes path segments for use in markdown links. - * Encodes each segment individually to preserve path separators. - */ -function encodePathForMarkdown(path: string): string { - return path.split('/').map(segment => encodeURIComponent(segment)).join('/'); -} - -/** - * Converts a URI to a relative path string for markdown links. - * Tries to make the path relative to a workspace folder if possible. - * The returned path is URL encoded for use in markdown link targets. - */ -function getRelativePath(uri: URI, workspaceFolders: readonly IWorkspaceFolder[]): string { - // On desktop, vscode-userdata scheme maps 1:1 to file scheme paths via FileUserDataProvider. - // Convert to file scheme so relativePath() can compute paths correctly. - // On web, vscode-userdata uses IndexedDB so this conversion has no effect (different schemes won't match workspace folders). - const normalizedUri = uri.scheme === Schemas.vscodeUserData ? uri.with({ scheme: Schemas.file }) : uri; - - for (const folder of workspaceFolders) { - const relative = relativePath(folder.uri, normalizedUri); - if (relative) { - return encodePathForMarkdown(relative); - } - } - // Fall back to fsPath if not under any workspace folder - // Use forward slashes for consistency in markdown links - return encodePathForMarkdown(normalizedUri.fsPath.replace(/\\/g, '/')); -} - -// Tree prefixes -// allow-any-unicode-next-line -const TREE_BRANCH = '├─'; -// allow-any-unicode-next-line -const TREE_END = '└─'; -// allow-any-unicode-next-line -const ICON_ERROR = '❌'; -// allow-any-unicode-next-line -const ICON_WARN = '⚠️'; -// allow-any-unicode-next-line -const ICON_MANUAL = '🔧'; -// allow-any-unicode-next-line -const ICON_HIDDEN = '👁️‍🗨️'; - -/** - * Information about a file that was loaded or skipped. - */ -export interface IFileStatusInfo { - uri: URI; - status: 'loaded' | 'skipped' | 'overwritten'; - reason?: string; - name?: string; - storage: PromptsStorage; - /** For overwritten files, the name of the file that took precedence */ - overwrittenBy?: string; - /** Extension ID if this file comes from an extension */ - extensionId?: string; - /** If false, hidden from / menu (user-invocable: false) */ - userInvocable?: boolean; - /** If true, won't be auto-loaded by agent (disable-model-invocation: true) */ - disableModelInvocation?: boolean; -} - -/** - * Path information with scan order. - */ -export interface IPathInfo { - uri: URI; - exists: boolean; - storage: PromptsStorage; - /** 1-based scan order (lower = higher priority) */ - scanOrder: number; - /** Original path string for display (e.g., '~/.copilot/agents' or '.github/agents') */ - displayPath: string; - /** Whether this is a default folder (vs custom configured) */ - isDefault: boolean; -} - -/** - * Status information for a specific type of prompt files. - */ -export interface ITypeStatusInfo { - type: PromptsType; - paths: IPathInfo[]; - files: IFileStatusInfo[]; - enabled: boolean; - /** For hooks only: parsed hooks grouped by lifecycle */ - parsedHooks?: IParsedHook[]; -} - -/** - * Registers the Diagnostics action for the chat context menu. - */ -export function registerChatCustomizationDiagnosticsAction() { - registerAction2(class DiagnosticsAction extends Action2 { - constructor() { - super({ - id: 'workbench.action.chat.diagnostics', - title: localize2('chat.diagnostics.label', "Diagnostics"), - f1: false, - category: CHAT_CATEGORY, - menu: [{ - id: MenuId.ChatContext, - group: 'z_clear', - order: -1 - }, { - id: CHAT_CONFIG_MENU_ID, - when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)), - order: 14, - group: '3_configure' - }, { - id: MenuId.ChatWelcomeContext, - group: '2_settings', - order: 0, - when: ChatContextKeys.inChatEditor.negate() - }] - }); - } - - async run(accessor: ServicesAccessor): Promise { - const promptsService = accessor.get(IPromptsService); - const configurationService = accessor.get(IConfigurationService); - const fileService = accessor.get(IFileService); - const untitledTextEditorService = accessor.get(IUntitledTextEditorService); - const commandService = accessor.get(ICommandService); - const workspaceContextService = accessor.get(IWorkspaceContextService); - const labelService = accessor.get(ILabelService); - const remoteAgentService = accessor.get(IRemoteAgentService); - - const token = CancellationToken.None; - const workspaceFolders = workspaceContextService.getWorkspace().folders; - const pathService = accessor.get(IPathService); - - // Collect status for each type - const statusInfos: ITypeStatusInfo[] = []; - - // 1. Custom Agents - const agentsStatus = await collectAgentsStatus(promptsService, fileService, token); - statusInfos.push(agentsStatus); - - // 2. Instructions - const instructionsStatus = await collectInstructionsStatus(promptsService, fileService, token); - statusInfos.push(instructionsStatus); - - // 3. Prompt Files - const promptsStatus = await collectPromptsStatus(promptsService, fileService, token); - statusInfos.push(promptsStatus); - - // 4. Skills - const skillsStatus = await collectSkillsStatus(promptsService, configurationService, fileService, token); - statusInfos.push(skillsStatus); - - // 5. Hooks - const hooksStatus = await collectHooksStatus(promptsService, fileService, labelService, pathService, workspaceContextService, remoteAgentService, token); - statusInfos.push(hooksStatus); - - // 6. Special files (AGENTS.md, copilot-instructions.md) - const specialFilesStatus = await collectSpecialFilesStatus(promptsService, configurationService, token); - - // Generate the markdown output - const output = formatStatusOutput(statusInfos, specialFilesStatus, workspaceFolders); - - // Create an untitled markdown document with the content - const untitledModel = untitledTextEditorService.create({ - initialValue: output, - languageId: 'markdown' - }); - - // Open the markdown file in edit mode - await commandService.executeCommand('vscode.open', untitledModel.resource); - } - }); -} - -/** - * Collects status for custom agents. - */ -async function collectAgentsStatus( - promptsService: IPromptsService, - fileService: IFileService, - token: CancellationToken -): Promise { - const type = PromptsType.agent; - const enabled = true; // Agents are always enabled - - // Get resolved source folders using the shared path resolution logic - const resolvedFolders = await promptsService.getResolvedSourceFolders(type); - const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService); - - // Get discovery info from the service (handles all duplicate detection and error tracking) - const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token); - const files = discoveryInfo.files.map(convertDiscoveryResultToFileStatus); - - return { type, paths, files, enabled }; -} - -/** - * Collects status for instructions files. - */ -async function collectInstructionsStatus( - promptsService: IPromptsService, - fileService: IFileService, - token: CancellationToken -): Promise { - const type = PromptsType.instructions; - const enabled = true; - - // Get resolved source folders using the shared path resolution logic - const resolvedFolders = await promptsService.getResolvedSourceFolders(type); - const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService); - - // Get discovery info from the service - // Filter out copilot-instructions.md files as they are handled separately in the special files section - const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token); - const files = discoveryInfo.files - .filter(f => basename(f.uri) !== COPILOT_CUSTOM_INSTRUCTIONS_FILENAME) - .map(convertDiscoveryResultToFileStatus); - - return { type, paths, files, enabled }; -} - -/** - * Collects status for prompt files. - */ -async function collectPromptsStatus( - promptsService: IPromptsService, - fileService: IFileService, - token: CancellationToken -): Promise { - const type = PromptsType.prompt; - const enabled = true; - - // Get resolved source folders using the shared path resolution logic - const resolvedFolders = await promptsService.getResolvedSourceFolders(type); - const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService); - - // Get discovery info from the service - const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token); - const files = discoveryInfo.files.map(convertDiscoveryResultToFileStatus); - - return { type, paths, files, enabled }; -} - -/** - * Collects status for skill files. - */ -async function collectSkillsStatus( - promptsService: IPromptsService, - configurationService: IConfigurationService, - fileService: IFileService, - token: CancellationToken -): Promise { - const type = PromptsType.skill; - const enabled = configurationService.getValue(PromptsConfig.USE_AGENT_SKILLS) ?? false; - - // Get resolved source folders using the shared path resolution logic - const resolvedFolders = await promptsService.getResolvedSourceFolders(type); - const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService); - - // Get discovery info from the service (handles all duplicate detection and error tracking) - const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token); - const files = discoveryInfo.files.map(convertDiscoveryResultToFileStatus); - - return { type, paths, files, enabled }; -} - -export interface ISpecialFilesStatus { - agentsMd: { enabled: boolean; files: URI[] }; - copilotInstructions: { enabled: boolean; files: URI[] }; - claudeMd: { enabled: boolean; files: URI[] }; -} - -/** - * Collects status for hook files. - */ -async function collectHooksStatus( - promptsService: IPromptsService, - fileService: IFileService, - labelService: ILabelService, - pathService: IPathService, - workspaceContextService: IWorkspaceContextService, - remoteAgentService: IRemoteAgentService, - token: CancellationToken -): Promise { - const type = PromptsType.hook; - const enabled = true; // Hooks are always enabled - - // Get resolved source folders using the shared path resolution logic - const resolvedFolders = await promptsService.getResolvedSourceFolders(type); - const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService); - - // Get discovery info from the service (handles all duplicate detection and error tracking) - const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token); - const files = discoveryInfo.files.map(convertDiscoveryResultToFileStatus); - - // Collect URIs of files skipped due to disableAllHooks so we can show their hidden hooks - const disabledFileUris = discoveryInfo.files - .filter(f => f.status === 'skipped' && f.skipReason === 'all-hooks-disabled') - .map(f => f.uri); - - // Parse hook files to extract individual hooks grouped by lifecycle - const parsedHooks = await parseHookFiles(promptsService, fileService, labelService, pathService, workspaceContextService, remoteAgentService, token, disabledFileUris); - - return { type, paths, files, enabled, parsedHooks }; -} - -/** - * Parses all hook files and extracts individual hooks. - */ -async function parseHookFiles( - promptsService: IPromptsService, - fileService: IFileService, - labelService: ILabelService, - pathService: IPathService, - workspaceContextService: IWorkspaceContextService, - remoteAgentService: IRemoteAgentService, - token: CancellationToken, - additionalDisabledFileUris?: URI[] -): Promise { - // Get workspace root and user home for path resolution - const workspaceFolder = workspaceContextService.getWorkspace().folders[0]; - const workspaceRootUri = workspaceFolder?.uri; - const userHomeUri = await pathService.userHome(); - const userHome = userHomeUri.fsPath ?? userHomeUri.path; - - // Get the remote OS (or fall back to local OS) - const remoteEnv = await remoteAgentService.getEnvironment(); - const targetOS = remoteEnv?.os ?? OS; - - // Use the shared helper - return parseAllHookFiles(promptsService, fileService, labelService, workspaceRootUri, userHome, targetOS, token, { additionalDisabledFileUris }); -} - -/** - * Collects status for special files like AGENTS.md and copilot-instructions.md. - */ -async function collectSpecialFilesStatus( - promptsService: IPromptsService, - configurationService: IConfigurationService, - token: CancellationToken -): Promise { - const useAgentMd = configurationService.getValue(PromptsConfig.USE_AGENT_MD) ?? false; - const useClaudeMd = configurationService.getValue(PromptsConfig.USE_CLAUDE_MD) ?? false; - const useCopilotInstructions = configurationService.getValue(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES) ?? false; - - const allFiles = await promptsService.listAgentInstructions(token); - - return { - agentsMd: { - enabled: useAgentMd, - files: allFiles.filter(f => f.type === AgentFileType.agentsMd).map(f => f.uri) - }, - claudeMd: { - enabled: useClaudeMd, - files: allFiles.filter(f => f.type === AgentFileType.claudeMd).map(f => f.uri) - }, - copilotInstructions: { - enabled: useCopilotInstructions, - files: allFiles.filter(f => f.type === AgentFileType.copilotInstructionsMd).map(f => f.uri) - } - }; -} - -/** - * Checks if a directory exists. - */ -async function checkDirectoryExists(fileService: IFileService, uri: URI): Promise { - try { - const stat = await fileService.stat(uri); - return stat.isDirectory; - } catch { - return false; - } -} - -/** - * Converts resolved source folders to path info with existence checks. - * This uses the shared path resolution logic from the prompts service. - */ -async function convertResolvedFoldersToPathInfo( - resolvedFolders: readonly IResolvedPromptSourceFolder[], - fileService: IFileService -): Promise { - const paths: IPathInfo[] = []; - let scanOrder = 1; - - for (const folder of resolvedFolders) { - const exists = await checkDirectoryExists(fileService, folder.uri); - paths.push({ - uri: folder.uri, - exists, - storage: folder.storage, - scanOrder: scanOrder++, - displayPath: folder.displayPath ?? folder.uri.path, - isDefault: folder.isDefault ?? false - }); - } - - return paths; -} - -/** - * Converts skip reason enum to user-friendly message. - */ -function getSkipReasonMessage(skipReason: PromptFileSkipReason | undefined, errorMessage: string | undefined): string { - switch (skipReason) { - case 'missing-name': - return nls.localize('status.missingName', 'Missing name attribute'); - case 'missing-description': - return nls.localize('status.skillMissingDescription', 'Missing description attribute'); - case 'name-mismatch': - return errorMessage ?? nls.localize('status.skillNameMismatch2', 'Name does not match folder'); - case 'duplicate-name': - return nls.localize('status.overwrittenByHigherPriority', 'Overwritten by higher priority file'); - case 'parse-error': - return errorMessage ?? nls.localize('status.parseError', 'Parse error'); - case 'disabled': - return nls.localize('status.typeDisabled', 'Disabled'); - case 'all-hooks-disabled': - return nls.localize('status.allHooksDisabled', 'All hooks disabled via disableAllHooks'); - case 'claude-hooks-disabled': - return nls.localize('status.claudeHooksDisabled', 'Claude hooks disabled via chat.useClaudeHooks setting'); - default: - return errorMessage ?? nls.localize('status.unknownError', 'Unknown error'); - } -} - -/** - * Converts IPromptFileDiscoveryResult to IFileStatusInfo for display. - */ -function convertDiscoveryResultToFileStatus(result: IPromptFileDiscoveryResult): IFileStatusInfo { - if (result.status === 'loaded') { - return { - uri: result.uri, - status: 'loaded', - name: result.name, - storage: result.storage, - extensionId: result.extensionId, - userInvocable: result.userInvocable, - disableModelInvocation: result.disableModelInvocation - }; - } - - // Handle skipped files - if (result.skipReason === 'duplicate-name' && result.duplicateOf) { - // This is an overwritten file - return { - uri: result.uri, - status: 'overwritten', - name: result.name, - storage: result.storage, - overwrittenBy: result.name, - extensionId: result.extensionId - }; - } - - // Regular skip - return { - uri: result.uri, - status: 'skipped', - name: result.name, - reason: getSkipReasonMessage(result.skipReason, result.errorMessage), - storage: result.storage, - extensionId: result.extensionId - }; -} - -/** - * Formats the status output as a compact markdown string with tree structure. - * Files are grouped under their parent paths. - * Special files (AGENTS.md, copilot-instructions.md) are merged into their respective sections. - */ -export function formatStatusOutput( - statusInfos: ITypeStatusInfo[], - specialFiles: ISpecialFilesStatus, - workspaceFolders: readonly IWorkspaceFolder[] -): string { - const lines: string[] = []; - - lines.push(`## ${nls.localize('status.title', 'Chat Customization Diagnostics')}`); - lines.push(`*${nls.localize('status.sensitiveWarning', 'WARNING: This file may contain sensitive information.')}*`); - lines.push(''); - - for (const info of statusInfos) { - const typeName = getTypeName(info.type); - - // Special handling for disabled skills - if (info.type === PromptsType.skill && !info.enabled) { - lines.push(`**${typeName}**`); - lines.push(`*${nls.localize('status.skillsDisabled', 'Skills are disabled. Enable them by setting `chat.useAgentSkills` to `true` in your settings.')}*`); - lines.push(''); - continue; - } - - const enabledStatus = info.enabled - ? '' - : ` *(${nls.localize('status.disabled', 'disabled')})*`; - - // Count loaded and skipped files (overwritten counts as skipped) - let loadedCount = info.files.filter(f => f.status === 'loaded').length; - const skippedCount = info.files.filter(f => f.status === 'skipped' || f.status === 'overwritten').length; - // Include special files in the loaded count for instructions - if (info.type === PromptsType.instructions) { - if (specialFiles.agentsMd.enabled) { - loadedCount += specialFiles.agentsMd.files.length; - } - if (specialFiles.copilotInstructions.enabled) { - loadedCount += specialFiles.copilotInstructions.files.length; - } - if (specialFiles.claudeMd.enabled) { - loadedCount += specialFiles.claudeMd.files.length; - } - } - - lines.push(`**${typeName}**${enabledStatus}
`); - - // Show stats line - use "skills" for skills type, "hooks" for hooks type, "files" for others - const statsParts: string[] = []; - if (info.type === PromptsType.hook) { - // For hooks, show both file count and individual hook count - if (loadedCount > 0) { - statsParts.push(loadedCount === 1 - ? nls.localize('status.fileLoaded', '1 file loaded') - : nls.localize('status.filesLoaded', '{0} files loaded', loadedCount)); - } - if (info.parsedHooks && info.parsedHooks.length > 0) { - const hookCount = info.parsedHooks.length; - statsParts.push(hookCount === 1 - ? nls.localize('status.hookLoaded', '1 hook loaded') - : nls.localize('status.hooksLoaded', '{0} hooks loaded', hookCount)); - } - } else if (loadedCount > 0) { - if (info.type === PromptsType.skill) { - statsParts.push(loadedCount === 1 - ? nls.localize('status.skillLoaded', '1 skill loaded') - : nls.localize('status.skillsLoaded', '{0} skills loaded', loadedCount)); - } else { - statsParts.push(loadedCount === 1 - ? nls.localize('status.fileLoaded', '1 file loaded') - : nls.localize('status.filesLoaded', '{0} files loaded', loadedCount)); - } - } - if (skippedCount > 0) { - statsParts.push(nls.localize('status.skippedCount', '{0} skipped', skippedCount)); - } - if (statsParts.length > 0) { - lines.push(`*${statsParts.join(', ')}*`); - } - lines.push(''); - - const allPaths = info.paths; - const allFiles = info.files; - - // Group files by their parent path - const filesByPath = new Map(); - const unmatchedFiles: IFileStatusInfo[] = []; - - for (const file of allFiles) { - let matched = false; - for (const path of allPaths) { - if (isFileUnderPath(file.uri, path.uri)) { - const key = path.uri.toString(); - if (!filesByPath.has(key)) { - filesByPath.set(key, []); - } - filesByPath.get(key)!.push(file); - matched = true; - break; - } - } - if (!matched) { - unmatchedFiles.push(file); - } - } - - // Render each path with its files as a tree - // Skip for hooks since we show files with their hooks below - let hasContent = false; - if (info.type !== PromptsType.hook) { - for (const path of allPaths) { - const pathFiles = filesByPath.get(path.uri.toString()) || []; - - if (path.exists) { - lines.push(`${path.displayPath}
`); - } else if (path.isDefault) { - // Default folders that don't exist - no error icon - lines.push(`${path.displayPath}
`); - } else { - // Custom folders that don't exist - show error - lines.push(`${ICON_ERROR} ${path.displayPath} - *${nls.localize('status.folderNotFound', 'Folder does not exist')}*
`); - } - - if (path.exists && pathFiles.length > 0) { - for (let i = 0; i < pathFiles.length; i++) { - const file = pathFiles[i]; - // Show the file ID: skill name for skills, basename for others - let fileName: string; - if (info.type === PromptsType.skill) { - fileName = file.name || `${basename(dirname(file.uri))}`; - } else { - fileName = basename(file.uri); - } - const isLast = i === pathFiles.length - 1; - const prefix = isLast ? TREE_END : TREE_BRANCH; - const filePath = getRelativePath(file.uri, workspaceFolders); - if (file.status === 'loaded') { - const flags = getSkillFlags(file, info.type); - lines.push(`${prefix} [\`${fileName}\`](${filePath})${flags}
`); - } else if (file.status === 'overwritten') { - lines.push(`${prefix} ${ICON_WARN} [\`${fileName}\`](${filePath}) - *${nls.localize('status.overwrittenByHigherPriority', 'Overwritten by higher priority file')}*
`); - } else { - lines.push(`${prefix} ${ICON_ERROR} [\`${fileName}\`](${filePath}) - *${file.reason}*
`); - } - } - } - hasContent = true; - } - } - - // Render unmatched files (e.g., from extensions) - group by extension ID - // Skip for hooks since we show files with their hooks below - if (info.type !== PromptsType.hook && unmatchedFiles.length > 0) { - // Group files by extension ID - const filesByExtension = new Map(); - for (const file of unmatchedFiles) { - const extId = file.extensionId || 'unknown'; - if (!filesByExtension.has(extId)) { - filesByExtension.set(extId, []); - } - filesByExtension.get(extId)!.push(file); - } - - // Render each extension group - for (const [extId, extFiles] of filesByExtension) { - lines.push(`${nls.localize('status.extension', 'Extension')}: ${extId}
`); - for (let i = 0; i < extFiles.length; i++) { - const file = extFiles[i]; - // Show the file ID: skill name for skills, basename for others - let fileName: string; - if (info.type === PromptsType.skill) { - fileName = file.name || `${basename(dirname(file.uri))}`; - } else { - fileName = basename(file.uri); - } - const isLast = i === extFiles.length - 1; - const prefix = isLast ? TREE_END : TREE_BRANCH; - const filePath = getRelativePath(file.uri, workspaceFolders); - if (file.status === 'loaded') { - const flags = getSkillFlags(file, info.type); - lines.push(`${prefix} [\`${fileName}\`](${filePath})${flags}
`); - } else if (file.status === 'overwritten') { - lines.push(`${prefix} ${ICON_WARN} [\`${fileName}\`](${filePath}) - *${nls.localize('status.overwrittenByHigherPriority', 'Overwritten by higher priority file')}*
`); - } else { - lines.push(`${prefix} ${ICON_ERROR} [\`${fileName}\`](${filePath}) - *${file.reason}*
`); - } - } - } - hasContent = true; - } - - // Add special files for instructions (AGENTS.md and copilot-instructions.md) - if (info.type === PromptsType.instructions) { - // AGENTS.md - if (specialFiles.agentsMd.enabled && specialFiles.agentsMd.files.length > 0) { - lines.push(`AGENTS.md
`); - for (let i = 0; i < specialFiles.agentsMd.files.length; i++) { - const file = specialFiles.agentsMd.files[i]; - const fileName = basename(file); - const isLast = i === specialFiles.agentsMd.files.length - 1; - const prefix = isLast ? TREE_END : TREE_BRANCH; - const filePath = getRelativePath(file, workspaceFolders); - lines.push(`${prefix} [\`${fileName}\`](${filePath})
`); - } - hasContent = true; - } else if (!specialFiles.agentsMd.enabled) { - lines.push(`AGENTS.md -
`); - hasContent = true; - } - - // copilot-instructions.md - if (specialFiles.copilotInstructions.enabled && specialFiles.copilotInstructions.files.length > 0) { - lines.push(`${COPILOT_CUSTOM_INSTRUCTIONS_FILENAME}
`); - for (let i = 0; i < specialFiles.copilotInstructions.files.length; i++) { - const file = specialFiles.copilotInstructions.files[i]; - const fileName = basename(file); - const isLast = i === specialFiles.copilotInstructions.files.length - 1; - const prefix = isLast ? TREE_END : TREE_BRANCH; - const filePath = getRelativePath(file, workspaceFolders); - lines.push(`${prefix} [\`${fileName}\`](${filePath})
`); - } - hasContent = true; - } else if (!specialFiles.copilotInstructions.enabled) { - lines.push(`${COPILOT_CUSTOM_INSTRUCTIONS_FILENAME} -
`); - hasContent = true; - } - } - - // Special handling for hooks - display grouped by file, then by lifecycle - if (info.type === PromptsType.hook && info.parsedHooks && info.parsedHooks.length > 0) { - // Group hooks first by file, then by lifecycle within each file - const hooksByFile = new Map(); - for (const hook of info.parsedHooks) { - const fileKey = hook.fileUri.toString(); - const existing = hooksByFile.get(fileKey) ?? []; - existing.push(hook); - hooksByFile.set(fileKey, existing); - } - - // Display hooks grouped by file - const fileUris = Array.from(hooksByFile.keys()); - for (let fileIdx = 0; fileIdx < fileUris.length; fileIdx++) { - const fileKey = fileUris[fileIdx]; - const fileHooks = hooksByFile.get(fileKey)!; - const firstHook = fileHooks[0]; - const filePath = getRelativePath(firstHook.fileUri, workspaceFolders); - const fileDisabled = fileHooks[0].disabled; - - // File as clickable link, with note if hooks are disabled via flag - if (fileDisabled) { - lines.push(`[${firstHook.filePath}](${filePath}) - *${nls.localize('status.allHooksDisabledLabel', 'all hooks disabled via disableAllHooks')}*
`); - } else { - lines.push(`[${firstHook.filePath}](${filePath})
`); - } - - // Flatten hooks with their lifecycle label - for (let i = 0; i < fileHooks.length; i++) { - const hook = fileHooks[i]; - const isLast = i === fileHooks.length - 1; - const prefix = isLast ? TREE_END : TREE_BRANCH; - const disabledPrefix = hook.disabled ? `${ICON_ERROR} ` : ''; - lines.push(`${prefix} ${disabledPrefix}${hook.hookTypeLabel}: \`${hook.commandLabel}\`
`); - } - } - hasContent = true; - } - - if (!hasContent && info.enabled) { - lines.push(`*${nls.localize('status.noFilesLoaded', 'No files loaded')}*`); - } - lines.push(''); - } - - return lines.join('\n'); -} - -/** - * Gets flag annotations for skills based on their visibility settings. - * Returns an empty string for non-skill types or skills with default settings. - */ -function getSkillFlags(file: IFileStatusInfo, type: PromptsType): string { - if (type !== PromptsType.skill) { - return ''; - } - - const flags: string[] = []; - - // disableModelInvocation: true means agent won't auto-load, only manual /name trigger - if (file.disableModelInvocation) { - flags.push(`${ICON_MANUAL} *${nls.localize('status.skill.manualOnly', 'manual only')}*`); - } - - // userInvocable: false means hidden from / menu - if (file.userInvocable === false) { - flags.push(`${ICON_HIDDEN} *${nls.localize('status.skill.hiddenFromMenu', 'hidden from menu')}*`); - } - - if (flags.length === 0) { - return ''; - } - - return ` - ${flags.join(', ')}`; -} - -/** - * Checks if a file URI is under a given path URI. - */ -function isFileUnderPath(fileUri: URI, pathUri: URI): boolean { - const filePath = fileUri.toString(); - const folderPath = pathUri.toString(); - return filePath.startsWith(folderPath + '/') || filePath.startsWith(folderPath + '\\'); -} - -/** - * Gets a human-readable name for a prompt type. - */ -function getTypeName(type: PromptsType): string { - switch (type) { - case PromptsType.agent: - return nls.localize('status.type.agents', 'Custom Agents'); - case PromptsType.instructions: - return nls.localize('status.type.instructions', 'Instructions'); - case PromptsType.prompt: - return nls.localize('status.type.prompts', 'Prompt Files'); - case PromptsType.skill: - return nls.localize('status.type.skills', 'Skills'); - case PromptsType.hook: - return nls.localize('status.type.hooks', 'Hooks'); - default: - return type; - } -} diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatOpenDebugPanelAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatOpenDebugPanelAction.ts new file mode 100644 index 00000000000..17860ac6b1f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/actions/chatOpenDebugPanelAction.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +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 { IChatDebugEditorOptions } from '../chatDebug/chatDebugTypes.js'; + +/** + * Registers the Open Debug Panel and View Logs actions. + */ +export function registerChatOpenDebugPanelAction() { + registerAction2(class OpenDebugViewAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.openDebugView', + title: localize2('chat.openDebugView.label', "Open Debug Panel"), + f1: true, + category: CHAT_CATEGORY, + precondition: ChatContextKeys.enabled, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + const chatDebugService = accessor.get(IChatDebugService); + + // Clear active session so the editor shows the home view + chatDebugService.activeSessionResource = undefined; + + const options: IChatDebugEditorOptions = { pinned: true, viewHint: 'home' }; + await editorService.openEditor(ChatDebugEditorInput.instance, options); + } + }); + + registerAction2(class TroubleshootAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.troubleshoot', + title: localize2('chat.troubleshoot.label', "View Logs"), + f1: false, + category: CHAT_CATEGORY, + menu: [{ + id: MenuId.ChatContext, + group: 'z_clear', + order: -1, + when: ChatContextKeys.chatSessionHasDebugData + }, { + id: CHAT_CONFIG_MENU_ID, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId), ChatContextKeys.chatSessionHasDebugData), + order: 14, + group: '3_configure' + }, { + id: MenuId.ChatWelcomeContext, + group: '2_settings', + order: 0, + when: ContextKeyExpr.and(ChatContextKeys.inChatEditor.negate(), ChatContextKeys.chatSessionHasDebugData) + }] + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + const chatWidgetService = accessor.get(IChatWidgetService); + const chatDebugService = accessor.get(IChatDebugService); + + // Get the active chat session resource from the last focused widget + const widget = chatWidgetService.lastFocusedWidget; + const sessionResource = widget?.viewModel?.sessionResource; + chatDebugService.activeSessionResource = sessionResource; + + const options: IChatDebugEditorOptions = { pinned: true, sessionResource, viewHint: 'logs' }; + await editorService.openEditor(ChatDebugEditorInput.instance, options); + } + }); +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 9a9a3d9f5dd..218dad8cb8f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -427,10 +427,18 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode private registerListeners(): void { // Sessions changes - this._register(this.chatSessionsService.onDidChangeItemsProviders(({ chatSessionType }) => this.resolve(chatSessionType))); - this._register(this.chatSessionsService.onDidChangeAvailability(() => this.resolve(undefined))); - this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => this.updateItems([chatSessionType], CancellationToken.None))); - this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.resolve(undefined))); + this._register(this.chatSessionsService.onDidChangeItemsProviders(({ chatSessionType }) => { + this.resolve(chatSessionType); + })); + this._register(this.chatSessionsService.onDidChangeAvailability(() => { + this.resolve(undefined); + })); + this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => { + this.updateItems([chatSessionType], CancellationToken.None); + })); + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => { + this.resolve(undefined); + })); // State this._register(this.storageService.onWillSaveState(() => { diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index f152dc71848..1f0c66b25c4 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -89,7 +89,12 @@ import { registerChatTitleActions } from './actions/chatTitleActions.js'; import { registerChatElicitationActions } from './actions/chatElicitationActions.js'; import { registerChatToolActions } from './actions/chatToolActions.js'; import { ChatTransferContribution } from './actions/chatTransfer.js'; -import { registerChatCustomizationDiagnosticsAction } from './actions/chatCustomizationDiagnosticsAction.js'; +import { registerChatOpenDebugPanelAction } from './actions/chatOpenDebugPanelAction.js'; +import { IChatDebugService } from '../common/chatDebugService.js'; +import { ChatDebugServiceImpl } from '../common/chatDebugServiceImpl.js'; +import { ChatDebugEditor } from './chatDebug/chatDebugEditor.js'; +import { PromptsDebugContribution } from './promptsDebugContribution.js'; +import { ChatDebugEditorInput, ChatDebugEditorInputSerializer } from './chatDebug/chatDebugEditorInput.js'; import './agentSessions/agentSessions.contribution.js'; import { backgroundAgentDisplayName } from './agentSessions/agentSessions.js'; @@ -1245,6 +1250,16 @@ Registry.as(EditorExtensions.EditorPane).registerEditorPane new SyncDescriptor(ChatEditorInput) ] ); +Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + ChatDebugEditor, + ChatDebugEditorInput.ID, + nls.localize('chatDebug', "Debug View") + ), + [ + new SyncDescriptor(ChatDebugEditorInput) + ] +); Registry.as(Extensions.ConfigurationMigration).registerConfigurationMigrations([ { key: 'chat.experimental.detectParticipant.enabled', @@ -1347,6 +1362,36 @@ class ChatResolverContribution extends Disposable { } } +class ChatDebugResolverContribution implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatDebugResolver'; + + constructor( + @IEditorResolverService editorResolverService: IEditorResolverService, + ) { + editorResolverService.registerEditor( + `${ChatDebugEditorInput.RESOURCE.scheme}:**/**`, + { + id: ChatDebugEditorInput.ID, + label: nls.localize('chatDebug', "Debug View"), + priority: RegisteredEditorPriority.exclusive + }, + { + singlePerResource: true, + canSupportResource: resource => resource.scheme === ChatDebugEditorInput.RESOURCE.scheme + }, + { + createEditorInput: () => { + return { + editor: ChatDebugEditorInput.instance, + options: { pinned: true } + }; + } + } + ); + } +} + class ChatAgentSettingContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.chatAgentSetting'; @@ -1531,8 +1576,11 @@ AccessibleViewRegistry.register(new AgentChatAccessibilityHelp()); registerEditorFeature(ChatInputBoxContentProvider); Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatEditorInput.TypeID, ChatEditorInputSerializer); +Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatDebugEditorInput.ID, ChatDebugEditorInputSerializer); registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(ChatDebugResolverContribution.ID, ChatDebugResolverContribution, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(PromptsDebugContribution.ID, PromptsDebugContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatLanguageModelsDataContribution.ID, ChatLanguageModelsDataContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatSlashCommandsContribution.ID, ChatSlashCommandsContribution, WorkbenchPhase.Eventually); @@ -1573,7 +1621,7 @@ registerWorkbenchContribution2(AgentPluginsViewsContribution.ID, AgentPluginsVie registerChatActions(); registerChatAccessibilityActions(); registerChatCopyActions(); -registerChatCustomizationDiagnosticsAction(); +registerChatOpenDebugPanelAction(); registerChatCodeBlockActions(); registerChatCodeCompareBlockActions(); registerChatFileTreeActions(); @@ -1631,5 +1679,6 @@ registerSingleton(IChatTodoListService, ChatTodoListService, InstantiationType.D registerSingleton(IChatOutputRendererService, ChatOutputRendererService, InstantiationType.Delayed); registerSingleton(IChatLayoutService, ChatLayoutService, InstantiationType.Delayed); registerSingleton(IChatTipService, ChatTipService, InstantiationType.Delayed); +registerSingleton(IChatDebugService, ChatDebugServiceImpl, InstantiationType.Delayed); ChatWidget.CONTRIBS.push(ChatDynamicVariableModel); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts new file mode 100644 index 00000000000..70ab35f071e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IUntitledTextResourceEditorInput } from '../../../../common/editor.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js'; +import { formatEventDetail } from './chatDebugEventDetailRenderer.js'; +import { renderFileListContent, fileListToPlainText } from './chatDebugFileListRenderer.js'; +import { renderUserMessageContent, renderAgentResponseContent, messageEventToPlainText, renderResolvedMessageContent, resolvedMessageToPlainText } from './chatDebugMessageContentRenderer.js'; + +const $ = DOM.$; + +/** + * Reusable detail panel that resolves and displays the content of a + * single {@link IChatDebugEvent}. Used by both the logs view and the + * flow chart view. + */ +export class ChatDebugDetailPanel extends Disposable { + + private readonly _onDidHide = this._register(new Emitter()); + readonly onDidHide = this._onDidHide.event; + + readonly element: HTMLElement; + private readonly detailDisposables = this._register(new DisposableStore()); + private currentDetailText: string = ''; + private currentDetailEventId: string | undefined; + + constructor( + parent: HTMLElement, + @IChatDebugService private readonly chatDebugService: IChatDebugService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IEditorService private readonly editorService: IEditorService, + @IClipboardService private readonly clipboardService: IClipboardService, + @IHoverService private readonly hoverService: IHoverService, + @IOpenerService private readonly openerService: IOpenerService, + ) { + super(); + this.element = DOM.append(parent, $('.chat-debug-detail-panel')); + DOM.hide(this.element); + + // Handle Ctrl+A / Cmd+A to select all within the detail panel + this._register(DOM.addDisposableListener(this.element, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + const target = e.target as HTMLElement | null; + if (target && this.element.contains(target)) { + e.preventDefault(); + const targetWindow = DOM.getWindow(target); + const selection = targetWindow.getSelection(); + if (selection) { + const range = targetWindow.document.createRange(); + range.selectNodeContents(target); + selection.removeAllRanges(); + selection.addRange(range); + } + } + } + })); + } + + async show(event: IChatDebugEvent): Promise { + // Skip re-rendering if we're already showing this event's detail + if (event.id && event.id === this.currentDetailEventId) { + return; + } + this.currentDetailEventId = event.id; + + const resolved = event.id ? await this.chatDebugService.resolveEvent(event.id) : undefined; + + DOM.show(this.element); + DOM.clearNode(this.element); + this.detailDisposables.clear(); + + // Header with action buttons + const header = DOM.append(this.element, $('.chat-debug-detail-header')); + + const fullScreenButton = this.detailDisposables.add(new Button(header, { ariaLabel: localize('chatDebug.openInEditor', "Open in Editor"), title: localize('chatDebug.openInEditor', "Open in Editor") })); + fullScreenButton.element.classList.add('chat-debug-detail-button'); + fullScreenButton.icon = Codicon.goToFile; + this.detailDisposables.add(fullScreenButton.onDidClick(() => { + this.editorService.openEditor({ contents: this.currentDetailText, resource: undefined } satisfies IUntitledTextResourceEditorInput); + })); + + const copyButton = this.detailDisposables.add(new Button(header, { ariaLabel: localize('chatDebug.copyToClipboard', "Copy"), title: localize('chatDebug.copyToClipboard', "Copy") })); + copyButton.element.classList.add('chat-debug-detail-button'); + copyButton.icon = Codicon.copy; + this.detailDisposables.add(copyButton.onDidClick(() => { + this.clipboardService.writeText(this.currentDetailText); + })); + + const closeButton = this.detailDisposables.add(new Button(header, { ariaLabel: localize('chatDebug.closeDetail', "Close"), title: localize('chatDebug.closeDetail', "Close") })); + closeButton.element.classList.add('chat-debug-detail-button'); + closeButton.icon = Codicon.close; + this.detailDisposables.add(closeButton.onDidClick(() => { + this.hide(); + })); + + if (resolved && resolved.kind === 'fileList') { + this.currentDetailText = fileListToPlainText(resolved); + const { element: contentEl, disposables: contentDisposables } = this.instantiationService.invokeFunction(accessor => + renderFileListContent(resolved, this.openerService, accessor.get(IModelService), accessor.get(ILanguageService), this.hoverService, accessor.get(ILabelService)) + ); + this.detailDisposables.add(contentDisposables); + this.element.appendChild(contentEl); + } else if (resolved && resolved.kind === 'message') { + this.currentDetailText = resolvedMessageToPlainText(resolved); + const { element: contentEl, disposables: contentDisposables } = renderResolvedMessageContent(resolved); + this.detailDisposables.add(contentDisposables); + this.element.appendChild(contentEl); + } else if (event.kind === 'userMessage') { + this.currentDetailText = messageEventToPlainText(event); + const { element: contentEl, disposables: contentDisposables } = renderUserMessageContent(event); + this.detailDisposables.add(contentDisposables); + this.element.appendChild(contentEl); + } else if (event.kind === 'agentResponse') { + this.currentDetailText = messageEventToPlainText(event); + const { element: contentEl, disposables: contentDisposables } = renderAgentResponseContent(event); + this.detailDisposables.add(contentDisposables); + this.element.appendChild(contentEl); + } else { + const pre = DOM.append(this.element, $('pre')); + pre.tabIndex = 0; + if (resolved) { + this.currentDetailText = resolved.value; + } else { + this.currentDetailText = formatEventDetail(event); + } + pre.textContent = this.currentDetailText; + } + } + + hide(): void { + this.currentDetailEventId = undefined; + DOM.hide(this.element); + DOM.clearNode(this.element); + this.detailDisposables.clear(); + this._onDidHide.fire(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts new file mode 100644 index 00000000000..772e96585a2 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts @@ -0,0 +1,362 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatDebug.css'; + +import * as DOM from '../../../../../base/browser/dom.js'; +import { Dimension } from '../../../../../base/browser/dom.js'; +import { DisposableMap, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { EditorPane } from '../../../../browser/parts/editor/editorPane.js'; +import { IEditorGroup } from '../../../../services/editor/common/editorGroupsService.js'; +import { IChatDebugService } from '../../common/chatDebugService.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { IChatWidgetService } from '../chat.js'; +import { ViewState, IChatDebugEditorOptions } from './chatDebugTypes.js'; +import { ChatDebugFilterState, registerFilterMenuItems } from './chatDebugFilters.js'; +import { ChatDebugHomeView } from './chatDebugHomeView.js'; +import { ChatDebugOverviewView, OverviewNavigation } from './chatDebugOverviewView.js'; +import { ChatDebugLogsView, LogsNavigation } from './chatDebugLogsView.js'; +import { ChatDebugFlowChartView, FlowChartNavigation } from './chatDebugFlowChartView.js'; + +const $ = DOM.$; + +type ChatDebugPanelOpenedClassification = { + owner: 'vijayu'; + comment: 'Event fired when the chat debug panel is opened'; +}; + +type ChatDebugViewSwitchedEvent = { + viewState: string; +}; + +type ChatDebugViewSwitchedClassification = { + viewState: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The view the user navigated to (home, overview, logs, flowchart).' }; + owner: 'vijayu'; + comment: 'Tracks which views users navigate to in the debug panel.'; +}; + +export class ChatDebugEditor extends EditorPane { + + static readonly ID: string = 'workbench.editor.chatDebug'; + + private container: HTMLElement | undefined; + private currentDimension: Dimension | undefined; + + private viewState: ViewState = ViewState.Home; + + private homeView: ChatDebugHomeView | undefined; + private overviewView: ChatDebugOverviewView | undefined; + private logsView: ChatDebugLogsView | undefined; + private flowChartView: ChatDebugFlowChartView | undefined; + private filterState: ChatDebugFilterState | undefined; + + 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 + * when the editor becomes hidden. + */ + private endActiveSession(): void { + const sessionResource = this.chatDebugService.activeSessionResource; + if (sessionResource) { + this.chatDebugService.endSession(sessionResource); + } + this.chatDebugService.activeSessionResource = undefined; + } + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IChatDebugService private readonly chatDebugService: IChatDebugService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IChatService private readonly chatService: IChatService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + ) { + super(ChatDebugEditor.ID, group, telemetryService, themeService, storageService); + } + + protected override createEditor(parent: HTMLElement): void { + this.container = DOM.append(parent, $('.chat-debug-editor')); + + // Shared filter state used by both Logs and FlowChart views + this.filterState = this._register(new ChatDebugFilterState()); + const scopedContextKeyService = this._register(this.contextKeyService.createScoped(this.container)); + this._register(registerFilterMenuItems(this.filterState, scopedContextKeyService)); + + // Create sub-views via DI + this.homeView = this._register(this.instantiationService.createInstance(ChatDebugHomeView, this.container)); + this._register(this.homeView.onNavigateToSession(sessionResource => { + this.navigateToSession(sessionResource); + })); + + this.overviewView = this._register(this.instantiationService.createInstance(ChatDebugOverviewView, this.container)); + this._register(this.overviewView.onNavigate(nav => { + switch (nav) { + case OverviewNavigation.Home: + this.endActiveSession(); + this.showView(ViewState.Home); + break; + case OverviewNavigation.Logs: + this.showView(ViewState.Logs); + break; + case OverviewNavigation.FlowChart: + this.showView(ViewState.FlowChart); + break; + } + })); + + this.logsView = this._register(this.instantiationService.createInstance(ChatDebugLogsView, this.container, this.filterState)); + this._register(this.logsView.onNavigate(nav => { + switch (nav) { + case LogsNavigation.Home: + this.endActiveSession(); + this.showView(ViewState.Home); + break; + case LogsNavigation.Overview: + this.showView(ViewState.Overview); + break; + } + })); + + this.flowChartView = this._register(this.instantiationService.createInstance(ChatDebugFlowChartView, this.container, this.filterState)); + this._register(this.flowChartView.onNavigate(nav => { + switch (nav) { + case FlowChartNavigation.Home: + this.endActiveSession(); + this.showView(ViewState.Home); + break; + case FlowChartNavigation.Overview: + this.showView(ViewState.Overview); + break; + } + })); + + // When new debug events arrive, refresh the active session view + this._register(this.chatDebugService.onDidAddEvent(event => { + if (this.viewState === ViewState.Home) { + this.homeView?.render(); + } else if (this.chatDebugService.activeSessionResource && event.sessionResource.toString() === this.chatDebugService.activeSessionResource.toString()) { + if (this.viewState === ViewState.Overview) { + this.overviewView?.refresh(); + } else if (this.viewState === ViewState.Logs) { + this.logsView?.refreshList(); + } else if (this.viewState === ViewState.FlowChart) { + this.flowChartView?.refresh(); + } + } + })); + + // When the focused chat widget changes, refresh home view session list + this._register(this.chatWidgetService.onDidChangeFocusedSession(() => { + if (this.viewState === ViewState.Home) { + this.homeView?.render(); + } + })); + + this._register(this.chatService.onDidCreateModel(model => { + if (this.viewState === ViewState.Home) { + this.homeView?.render(); + } + + // Track title changes per model, disposing the previous listener + // for the same model URI to avoid leaks. + const key = model.sessionResource.toString(); + this.modelChangeListeners.set(key, model.onDidChange(e => { + if (e.kind === 'setCustomTitle') { + if (this.viewState === ViewState.Home) { + this.homeView?.render(); + } else if (this.viewState === ViewState.Overview || this.viewState === ViewState.Logs || this.viewState === ViewState.FlowChart) { + this.overviewView?.updateBreadcrumb(); + this.logsView?.updateBreadcrumb(); + this.flowChartView?.updateBreadcrumb(); + } + } + })); + })); + + this._register(this.chatService.onDidDisposeSession(() => { + if (this.viewState === ViewState.Home) { + this.homeView?.render(); + } + })); + + this.showView(ViewState.Home); + } + + // ===================================================================== + // View switching + // ===================================================================== + + private showView(state: ViewState): void { + this.viewState = state; + + this.telemetryService.publicLog2('chatDebugViewSwitched', { + viewState: state, + }); + + if (state === ViewState.Home) { + this.homeView?.show(); + } else { + this.homeView?.hide(); + } + + if (state === ViewState.Overview) { + this.overviewView?.show(); + } else { + this.overviewView?.hide(); + } + + if (state === ViewState.Logs) { + this.logsView?.show(); + this.doLayout(); + this.logsView?.focus(); + } else { + this.logsView?.hide(); + } + + if (state === ViewState.FlowChart) { + this.flowChartView?.show(); + } else { + this.flowChartView?.hide(); + } + + } + + navigateToSession(sessionResource: URI, view?: 'logs' | 'overview' | 'flowchart'): void { + // End the previous session's streaming pipeline before switching + const previousSessionResource = this.chatDebugService.activeSessionResource; + if (previousSessionResource && previousSessionResource.toString() !== sessionResource.toString()) { + this.chatDebugService.endSession(previousSessionResource); + } + + this.chatDebugService.activeSessionResource = sessionResource; + this.chatDebugService.invokeProviders(sessionResource); + this.trackSessionModelChanges(sessionResource); + + this.overviewView?.setSession(sessionResource); + this.logsView?.setSession(sessionResource); + this.flowChartView?.setSession(sessionResource); + + this.showView(view === 'logs' ? ViewState.Logs : view === 'flowchart' ? ViewState.FlowChart : ViewState.Overview); + } + + private trackSessionModelChanges(sessionResource: URI): void { + const model = this.chatService.getSession(sessionResource); + if (!model) { + this.sessionModelListener.clear(); + return; + } + this.sessionModelListener.value = model.onDidChange(e => { + if (e.kind === 'addRequest' || e.kind === 'completedRequest') { + if (this.viewState === ViewState.Overview) { + this.overviewView?.refresh(); + } + } + }); + } + + // ===================================================================== + // EditorPane overrides + // ===================================================================== + + override focus(): void { + if (this.viewState === ViewState.Logs) { + this.logsView?.focus(); + } else { + this.container?.focus(); + } + } + + override setOptions(options: IChatDebugEditorOptions | undefined): void { + super.setOptions(options); + if (options) { + this._applyNavigationOptions(options); + } + } + + override setEditorVisible(visible: boolean): void { + super.setEditorVisible(visible); + if (visible) { + this.telemetryService.publicLog2<{}, ChatDebugPanelOpenedClassification>('chatDebugPanelOpened'); + const options = this.options as IChatDebugEditorOptions | undefined; + if (options) { + this._applyNavigationOptions(options); + } else if (this.viewState === ViewState.Home) { + // Restore the saved session resource if the editor was temporarily hidden + 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; + 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(); + } + } + + private _applyNavigationOptions(options: IChatDebugEditorOptions): void { + const { sessionResource, viewHint } = options; + if (viewHint === 'logs' && sessionResource) { + this.navigateToSession(sessionResource, 'logs'); + } else if (viewHint === 'flowchart' && sessionResource) { + this.navigateToSession(sessionResource, 'flowchart'); + } else if (viewHint === 'overview' && sessionResource) { + this.navigateToSession(sessionResource, 'overview'); + } else if (viewHint === 'home') { + this.endActiveSession(); + this.showView(ViewState.Home); + } else if (sessionResource) { + this.navigateToSession(sessionResource, 'overview'); + } else if (this.viewState === ViewState.Home) { + this.showView(ViewState.Home); + } + } + + override layout(dimension: Dimension): void { + this.currentDimension = dimension; + if (this.container) { + this.container.style.width = `${dimension.width}px`; + this.container.style.height = `${dimension.height}px`; + } + this.doLayout(); + } + + private doLayout(): void { + if (!this.currentDimension || this.viewState !== ViewState.Logs) { + return; + } + this.logsView?.layout(this.currentDimension); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditorInput.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditorInput.ts new file mode 100644 index 00000000000..fb7ab5d0020 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditorInput.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { registerIcon } from '../../../../../platform/theme/common/iconRegistry.js'; +import { EditorInputCapabilities, IEditorSerializer, IUntypedEditorInput } from '../../../../common/editor.js'; +import { EditorInput } from '../../../../common/editor/editorInput.js'; + +const chatDebugEditorIcon = registerIcon('chat-debug-editor-label-icon', Codicon.bug, localize('chatDebugEditorLabelIcon', 'Icon of the chat debug editor label.')); + +export class ChatDebugEditorInput extends EditorInput { + + static readonly ID = 'workbench.editor.chatDebug'; + + static readonly RESOURCE = URI.from({ + scheme: 'chat-debug', + path: 'default' + }); + + private static _instance: ChatDebugEditorInput; + static get instance() { + if (!ChatDebugEditorInput._instance || ChatDebugEditorInput._instance.isDisposed()) { + ChatDebugEditorInput._instance = new ChatDebugEditorInput(); + } + + return ChatDebugEditorInput._instance; + } + + override get typeId(): string { return ChatDebugEditorInput.ID; } + + override get editorId(): string | undefined { return ChatDebugEditorInput.ID; } + + override get capabilities(): EditorInputCapabilities { return EditorInputCapabilities.Readonly | EditorInputCapabilities.Singleton; } + + readonly resource = ChatDebugEditorInput.RESOURCE; + + override getName(): string { + return localize('chatDebugInputName', "Chat Debug Panel"); + } + + override getIcon(): ThemeIcon { + return chatDebugEditorIcon; + } + + override matches(other: EditorInput | IUntypedEditorInput): boolean { + if (super.matches(other)) { + return true; + } + + return other instanceof ChatDebugEditorInput; + } +} + +export class ChatDebugEditorInputSerializer implements IEditorSerializer { + + canSerialize(editorInput: EditorInput): boolean { + return true; + } + + serialize(editorInput: EditorInput): string { + return ''; + } + + deserialize(instantiationService: IInstantiationService): EditorInput { + return ChatDebugEditorInput.instance; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEventDetailRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEventDetailRenderer.ts new file mode 100644 index 00000000000..be68f3d757e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEventDetailRenderer.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../nls.js'; +import { IChatDebugEvent } from '../../common/chatDebugService.js'; + +/** + * Format the detail text for a debug event (used when no resolved content is available). + */ +export function formatEventDetail(event: IChatDebugEvent): string { + switch (event.kind) { + case 'toolCall': { + const parts = [localize('chatDebug.detail.tool', "Tool: {0}", event.toolName)]; + if (event.toolCallId) { parts.push(localize('chatDebug.detail.callId', "Call ID: {0}", event.toolCallId)); } + if (event.result) { parts.push(localize('chatDebug.detail.result', "Result: {0}", event.result)); } + if (event.durationInMillis !== undefined) { parts.push(localize('chatDebug.detail.durationMs', "Duration: {0}ms", event.durationInMillis)); } + if (event.input) { parts.push(`\n${localize('chatDebug.detail.input', "Input:")}\n${event.input}`); } + if (event.output) { parts.push(`\n${localize('chatDebug.detail.output', "Output:")}\n${event.output}`); } + return parts.join('\n'); + } + case 'modelTurn': { + const parts = [event.model ?? localize('chatDebug.detail.modelTurn', "Model Turn")]; + if (event.inputTokens !== undefined) { parts.push(localize('chatDebug.detail.inputTokens', "Input tokens: {0}", event.inputTokens)); } + if (event.outputTokens !== undefined) { parts.push(localize('chatDebug.detail.outputTokens', "Output tokens: {0}", event.outputTokens)); } + if (event.totalTokens !== undefined) { parts.push(localize('chatDebug.detail.totalTokens', "Total tokens: {0}", event.totalTokens)); } + if (event.durationInMillis !== undefined) { parts.push(localize('chatDebug.detail.durationMs', "Duration: {0}ms", event.durationInMillis)); } + return parts.join('\n'); + } + case 'generic': + return `${event.name}\n${event.details ?? ''}`; + case 'subagentInvocation': { + const parts = [localize('chatDebug.detail.agent', "Agent: {0}", event.agentName)]; + if (event.description) { parts.push(localize('chatDebug.detail.description', "Description: {0}", event.description)); } + if (event.status) { parts.push(localize('chatDebug.detail.status', "Status: {0}", event.status)); } + if (event.durationInMillis !== undefined) { parts.push(localize('chatDebug.detail.durationMs', "Duration: {0}ms", event.durationInMillis)); } + if (event.toolCallCount !== undefined) { parts.push(localize('chatDebug.detail.toolCallCount', "Tool calls: {0}", event.toolCallCount)); } + if (event.modelTurnCount !== undefined) { parts.push(localize('chatDebug.detail.modelTurnCount', "Model turns: {0}", event.modelTurnCount)); } + return parts.join('\n'); + } + case 'userMessage': { + const parts = [localize('chatDebug.detail.userMessage', "User Message: {0}", event.message)]; + for (const section of event.sections) { + parts.push(`\n--- ${section.name} ---\n${section.content}`); + } + return parts.join('\n'); + } + case 'agentResponse': { + const parts = [localize('chatDebug.detail.agentResponse', "Agent Response: {0}", event.message)]; + for (const section of event.sections) { + parts.push(`\n--- ${section.name} ---\n${section.content}`); + } + return parts.join('\n'); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEventList.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEventList.ts new file mode 100644 index 00000000000..7ae85ffa0f8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEventList.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; +import { ITreeNode, ITreeRenderer } from '../../../../../base/browser/ui/tree/tree.js'; +import { localize } from '../../../../../nls.js'; +import { ChatDebugLogLevel, IChatDebugEvent } from '../../common/chatDebugService.js'; +import { safeIntl } from '../../../../../base/common/date.js'; + +const $ = DOM.$; + +const dateFormatter = safeIntl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit', +}); + +export interface IChatDebugEventTemplate { + readonly container: HTMLElement; + readonly created: HTMLElement; + readonly name: HTMLElement; + readonly details: HTMLElement; +} + +function renderEventToTemplate(element: IChatDebugEvent, templateData: IChatDebugEventTemplate): void { + templateData.created.textContent = dateFormatter.value.format(element.created); + + switch (element.kind) { + case 'toolCall': + templateData.name.textContent = element.toolName; + templateData.details.textContent = element.result ?? ''; + break; + case 'modelTurn': + templateData.name.textContent = element.model ?? localize('chatDebug.modelTurn', "Model Turn"); + templateData.details.textContent = element.totalTokens !== undefined + ? localize('chatDebug.tokens', "{0} tokens", element.totalTokens) + : ''; + break; + case 'generic': + templateData.name.textContent = element.name; + templateData.details.textContent = element.details ?? ''; + break; + case 'subagentInvocation': + templateData.name.textContent = element.agentName; + templateData.details.textContent = element.description ?? (element.status ?? ''); + break; + case 'userMessage': + templateData.name.textContent = localize('chatDebug.userMessage', "User Message"); + templateData.details.textContent = element.message; + break; + case 'agentResponse': + templateData.name.textContent = localize('chatDebug.agentResponse', "Agent Response"); + templateData.details.textContent = element.message; + break; + } + + const isError = element.kind === 'generic' && element.level === ChatDebugLogLevel.Error + || element.kind === 'toolCall' && element.result === 'error'; + const isWarning = element.kind === 'generic' && element.level === ChatDebugLogLevel.Warning; + const isTrace = element.kind === 'generic' && element.level === ChatDebugLogLevel.Trace; + + templateData.container.classList.toggle('chat-debug-log-error', isError); + templateData.container.classList.toggle('chat-debug-log-warning', isWarning); + templateData.container.classList.toggle('chat-debug-log-trace', isTrace); +} + +function createEventTemplate(container: HTMLElement): IChatDebugEventTemplate { + container.classList.add('chat-debug-log-row'); + const created = DOM.append(container, $('span.chat-debug-log-created')); + const name = DOM.append(container, $('span.chat-debug-log-name')); + const details = DOM.append(container, $('span.chat-debug-log-details')); + return { container, created, name, details }; +} + +export class ChatDebugEventRenderer implements IListRenderer { + static readonly TEMPLATE_ID = 'chatDebugEvent'; + + get templateId(): string { + return ChatDebugEventRenderer.TEMPLATE_ID; + } + + renderTemplate(container: HTMLElement): IChatDebugEventTemplate { + return createEventTemplate(container); + } + + renderElement(element: IChatDebugEvent, index: number, templateData: IChatDebugEventTemplate): void { + renderEventToTemplate(element, templateData); + } + + disposeTemplate(_templateData: IChatDebugEventTemplate): void { + // noop + } +} + +export class ChatDebugEventDelegate implements IListVirtualDelegate { + getHeight(_element: IChatDebugEvent): number { + return 28; + } + + getTemplateId(_element: IChatDebugEvent): string { + return ChatDebugEventRenderer.TEMPLATE_ID; + } +} + +export class ChatDebugEventTreeRenderer implements ITreeRenderer { + static readonly TEMPLATE_ID = 'chatDebugEvent'; + + get templateId(): string { + return ChatDebugEventTreeRenderer.TEMPLATE_ID; + } + + renderTemplate(container: HTMLElement): IChatDebugEventTemplate { + return createEventTemplate(container); + } + + renderElement(node: ITreeNode, index: number, templateData: IChatDebugEventTemplate): void { + renderEventToTemplate(node.element, templateData); + } + + disposeTemplate(_templateData: IChatDebugEventTemplate): void { + // noop + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFileListRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFileListRenderer.ts new file mode 100644 index 00000000000..d1d1756523b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFileListRenderer.ts @@ -0,0 +1,334 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../widget/chatContentParts/media/chatInlineAnchorWidget.css'; +import * as DOM from '../../../../../base/browser/dom.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { dirname } from '../../../../../base/common/resources.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { getIconClasses } from '../../../../../editor/common/services/getIconClasses.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; +import { localize } from '../../../../../nls.js'; +import { FileKind } from '../../../../../platform/files/common/files.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IChatDebugEventFileListContent } from '../../common/chatDebugService.js'; +import { InlineAnchorWidget } from '../widget/chatContentParts/chatInlineAnchorWidget.js'; +import { setupCollapsibleToggle } from './chatDebugMessageContentRenderer.js'; + +const $ = DOM.$; + +/** + * Map a discovery type string to its corresponding settings key. + */ +function getSettingsKeyForDiscoveryType(discoveryType: string): string | undefined { + switch (discoveryType) { + case 'prompt': return 'chat.promptFilesLocations'; + case 'instructions': return 'chat.instructionsFilesLocations'; + case 'agent': return 'chat.agentFilesLocations'; + case 'skill': return 'chat.agentSkillsLocations'; + case 'hook': return 'chat.hookFilesLocations'; + default: return undefined; + } +} + +/** + * Get a display label for a file's location. + * Extension files show the extension ID, + * all other files show the relative (or tildified) parent folder path. + */ +function getFileLocationLabel(file: { uri: URI; storage?: string; extensionId?: string }, labelService: ILabelService): string { + if (file.extensionId) { + return file.extensionId; + } + return labelService.getUriLabel(dirname(file.uri), { relative: true }); +} + +/** + * Create a file link element styled like the chat panel's InlineAnchorWidget. + */ +function createInlineFileLink(uri: URI, displayText: string, fileKind: FileKind, openerService: IOpenerService, modelService: IModelService, languageService: ILanguageService, hoverService: IHoverService, labelService: ILabelService, disposables: DisposableStore, hoverSuffix?: string): HTMLElement { + const link = $(`a.${InlineAnchorWidget.className}.show-file-icons`); + link.tabIndex = -1; + + const iconEl = DOM.append(link, $('span.icon')); + const iconClasses = getIconClasses(modelService, languageService, uri, fileKind); + iconEl.classList.add(...iconClasses); + + DOM.append(link, $('span.icon-label', undefined, displayText)); + + const relativeLabel = labelService.getUriLabel(uri, { relative: true }); + const hoverText = hoverSuffix ? `${relativeLabel} ${hoverSuffix}` : relativeLabel; + disposables.add(hoverService.setupManagedHover(getDefaultHoverDelegate('element'), link, hoverText)); + disposables.add(DOM.addDisposableListener(link, DOM.EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + openerService.open(uri); + })); + + return link; +} + +/** + * Set up roving tabindex with arrow-key navigation on a list of rows. + * The first row starts with tabIndex 0; the rest get -1. + * Up/Down arrow keys move focus, Home/End jump to first/last. + * Enter on a focused row activates the associated action. + */ +function setupFileListNavigation(listEl: HTMLElement, rows: { element: HTMLElement; activate: () => void }[], disposables: DisposableStore): void { + if (rows.length === 0) { + return; + } + + for (let i = 0; i < rows.length; i++) { + rows[i].element.tabIndex = i === 0 ? 0 : -1; + rows[i].element.setAttribute('role', 'listitem'); + } + + disposables.add(DOM.addDisposableListener(listEl, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + const index = rows.findIndex(r => r.element === target); + if (index === -1) { + return; + } + + let nextIndex: number | undefined; + switch (e.key) { + case 'ArrowDown': + nextIndex = Math.min(index + 1, rows.length - 1); + break; + case 'ArrowUp': + nextIndex = Math.max(index - 1, 0); + break; + case 'Home': + nextIndex = 0; + break; + case 'End': + nextIndex = rows.length - 1; + break; + case 'Enter': { + rows[index].activate(); + e.preventDefault(); + return; + } + } + + if (nextIndex !== undefined && nextIndex !== index) { + e.preventDefault(); + rows[index].element.tabIndex = -1; + rows[nextIndex].element.tabIndex = 0; + rows[nextIndex].element.focus(); + } + })); +} + +/** + * Append a location badge to a row. If the file comes from an extension, + * the badge is a clickable link that opens the extension in the marketplace. + */ +function appendLocationBadge(row: HTMLElement, file: { extensionId?: string }, badgeText: string, cssClass: string, openerService: IOpenerService, hoverService: IHoverService, disposables: DisposableStore): void { + if (file.extensionId) { + const link = DOM.append(row, $(`a.${cssClass}.chat-debug-file-list-badge-link`)); + link.textContent = badgeText; + link.tabIndex = -1; + disposables.add(hoverService.setupManagedHover(getDefaultHoverDelegate('element'), link, localize('chatDebug.openExtension', "Open {0} in Extensions", file.extensionId))); + disposables.add(DOM.addDisposableListener(link, DOM.EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + openerService.open(URI.parse(`command:extension.open?${encodeURIComponent(JSON.stringify([file.extensionId]))}`), { allowCommands: true }); + })); + } else { + DOM.append(row, $(`span.${cssClass}`, undefined, badgeText)); + } +} + +/** + * Render a file list resolved content as a rich HTML element. + */ +export function renderFileListContent(content: IChatDebugEventFileListContent, openerService: IOpenerService, modelService: IModelService, languageService: ILanguageService, hoverService: IHoverService, labelService: ILabelService): { element: HTMLElement; disposables: DisposableStore } { + const disposables = new DisposableStore(); + const container = $('div.chat-debug-file-list'); + container.tabIndex = 0; + + const capitalizedType = content.discoveryType.charAt(0).toUpperCase() + content.discoveryType.slice(1); + DOM.append(container, $('div.chat-debug-file-list-title', undefined, localize('chatDebug.discoveryResults', "{0} Discovery Results", capitalizedType))); + DOM.append(container, $('div.chat-debug-file-list-summary', undefined, localize('chatDebug.totalFiles', "Total files: {0}", content.files.length))); + + // Loaded files + const loaded = content.files.filter(f => f.status === 'loaded'); + if (loaded.length > 0) { + const section = DOM.append(container, $('div.chat-debug-file-list-section')); + DOM.append(section, $('div.chat-debug-file-list-section-title', undefined, + localize('chatDebug.loadedFiles', "Loaded ({0})", loaded.length))); + + const listEl = DOM.append(section, $('div.chat-debug-file-list-rows')); + listEl.setAttribute('role', 'list'); + listEl.setAttribute('aria-label', localize('chatDebug.loadedFilesList', "Loaded files")); + + const rows: { element: HTMLElement; activate: () => void }[] = []; + for (const file of loaded) { + const row = DOM.append(listEl, $('div.chat-debug-file-list-row')); + DOM.append(row, $(`span.chat-debug-file-list-icon${ThemeIcon.asCSSSelector(Codicon.check)}`)); + const locationBadgeText = localize('chatDebug.locationBadge', " ({0})", getFileLocationLabel(file, labelService)); + // Only include location in tooltip when it's an extension ID (path would be redundant) + const hoverSuffix = file.extensionId ? locationBadgeText.trim() : undefined; + row.appendChild(createInlineFileLink(file.uri, file.name ?? file.uri.path, FileKind.FILE, openerService, modelService, languageService, hoverService, labelService, disposables, hoverSuffix)); + appendLocationBadge(row, file, locationBadgeText, 'chat-debug-file-list-badge', openerService, hoverService, disposables); + const relativeLabel = labelService.getUriLabel(file.uri, { relative: true }); + row.setAttribute('aria-label', relativeLabel); + const uri = file.uri; + rows.push({ element: row, activate: () => openerService.open(uri) }); + } + setupFileListNavigation(listEl, rows, disposables); + } + + // Skipped files + const skipped = content.files.filter(f => f.status === 'skipped'); + if (skipped.length > 0) { + const section = DOM.append(container, $('div.chat-debug-file-list-section')); + DOM.append(section, $('div.chat-debug-file-list-section-title', undefined, + localize('chatDebug.skippedFiles', "Skipped ({0})", skipped.length))); + + const listEl = DOM.append(section, $('div.chat-debug-file-list-rows')); + listEl.setAttribute('role', 'list'); + listEl.setAttribute('aria-label', localize('chatDebug.skippedFilesList', "Skipped files")); + + const rows: { element: HTMLElement; activate: () => void }[] = []; + for (const file of skipped) { + const row = DOM.append(listEl, $('div.chat-debug-file-list-row')); + DOM.append(row, $(`span.chat-debug-file-list-icon${ThemeIcon.asCSSSelector(Codicon.close)}`)); + + let reasonText = ` (${file.skipReason ?? localize('chatDebug.unknown', "unknown")}`; + if (file.errorMessage) { + reasonText += `: ${file.errorMessage}`; + } + if (file.duplicateOf) { + reasonText += localize('chatDebug.duplicateOf', ", duplicate of {0}", file.duplicateOf.path); + } + reasonText += ')'; + // Only include reason in tooltip when it's an extension file (path-based location is redundant) + const skippedHoverSuffix = file.extensionId ? reasonText.trim() : undefined; + row.appendChild(createInlineFileLink(file.uri, file.name ?? file.uri.path, FileKind.FILE, openerService, modelService, languageService, hoverService, labelService, disposables, skippedHoverSuffix)); + appendLocationBadge(row, file, reasonText, 'chat-debug-file-list-detail', openerService, hoverService, disposables); + const relativeLabel = labelService.getUriLabel(file.uri, { relative: true }); + row.setAttribute('aria-label', relativeLabel); + const uri = file.uri; + rows.push({ element: row, activate: () => openerService.open(uri) }); + } + setupFileListNavigation(listEl, rows, disposables); + } + + // Source folders (paths attempted) - collapsible, initially collapsed + if (content.sourceFolders && content.sourceFolders.length > 0) { + const sectionEl = DOM.append(container, $('div.chat-debug-message-section')); + + const header = DOM.append(sectionEl, $('div.chat-debug-message-section-header')); + + const chevron = DOM.append(header, $('span.chat-debug-message-section-chevron')); + DOM.append(header, $('span.chat-debug-message-section-title', undefined, + localize('chatDebug.sourceFolders', "Sources ({0})", content.sourceFolders.length))); + + // Settings gear button on the right side of the header + const settingsKey = getSettingsKeyForDiscoveryType(content.discoveryType); + if (settingsKey) { + const gearBtn = disposables.add(new Button(header, { + title: localize('chatDebug.openSettingsTooltip', "Configure locations"), + ariaLabel: localize('chatDebug.configureLocations', "Configure locations"), + hoverDelegate: getDefaultHoverDelegate('mouse'), + })); + gearBtn.icon = Codicon.settingsGear; + gearBtn.element.classList.add('chat-debug-settings-gear'); + disposables.add(DOM.addDisposableListener(gearBtn.element, DOM.EventType.MOUSE_ENTER, () => { + header.classList.add('chat-debug-settings-gear-header-passthrough'); + })); + disposables.add(DOM.addDisposableListener(gearBtn.element, DOM.EventType.MOUSE_LEAVE, () => { + header.classList.remove('chat-debug-settings-gear-header-passthrough'); + })); + disposables.add(gearBtn.onDidClick((e) => { + if (e) { + DOM.EventHelper.stop(e, true); + } + openerService.open(URI.parse(`command:workbench.action.openSettings?${encodeURIComponent(JSON.stringify([`@id:${settingsKey}`]))}`), { allowCommands: true }); + })); + } + + const contentEl = DOM.append(sectionEl, $('div.chat-debug-source-folder-content')); + contentEl.tabIndex = 0; + contentEl.setAttribute('role', 'region'); + contentEl.setAttribute('aria-label', localize('chatDebug.sourceFoldersContent', "Source folders")); + + const capitalizedType = content.discoveryType.charAt(0).toUpperCase() + content.discoveryType.slice(1); + const sourcesCaption = capitalizedType.endsWith('s') ? capitalizedType : capitalizedType + 's'; + DOM.append(contentEl, $('div.chat-debug-source-folder-note', undefined, + localize('chatDebug.sourcesNote', "{0} were discovered by checking the following sources in order:", sourcesCaption))); + for (let i = 0; i < content.sourceFolders.length; i++) { + const folder = content.sourceFolders[i]; + const row = DOM.append(contentEl, $('div.chat-debug-source-folder-row')); + DOM.append(row, $('span.chat-debug-source-folder-index', undefined, `${i + 1}.`)); + DOM.append(row, $('span.chat-debug-source-folder-label', undefined, folder.uri.path)); + } + + setupCollapsibleToggle(chevron, header, contentEl, disposables, /* initiallyCollapsed */ true); + } + + return { element: container, disposables }; +} + +/** + * Convert a file list content to plain text for clipboard / editor output. + */ +export function fileListToPlainText(content: IChatDebugEventFileListContent): string { + const lines: string[] = []; + const capitalizedType = content.discoveryType.charAt(0).toUpperCase() + content.discoveryType.slice(1); + lines.push(localize('chatDebug.plainText.discoveryResults', "{0} Discovery Results", capitalizedType)); + lines.push(localize('chatDebug.plainText.totalFiles', "Total files: {0}", content.files.length)); + lines.push(''); + + const loaded = content.files.filter(f => f.status === 'loaded'); + const skipped = content.files.filter(f => f.status === 'skipped'); + + if (loaded.length > 0) { + lines.push(localize('chatDebug.plainText.loaded', "Loaded ({0})", loaded.length)); + for (const f of loaded) { + const label = f.name ?? f.uri.path; + const locationLabel = f.extensionId ?? dirname(f.uri).path; + lines.push(` \u2713 ${label} - ${f.uri.path} (${locationLabel})`); + } + lines.push(''); + } + + if (skipped.length > 0) { + lines.push(localize('chatDebug.plainText.skipped', "Skipped ({0})", skipped.length)); + for (const f of skipped) { + const label = f.name ?? f.uri.path; + const reason = f.skipReason ?? localize('chatDebug.plainText.unknown', "unknown"); + let detail = ` \u2717 ${label} (${reason}`; + if (f.errorMessage) { + detail += `: ${f.errorMessage}`; + } + if (f.duplicateOf) { + detail += localize('chatDebug.plainText.duplicateOf', ", duplicate of {0}", f.duplicateOf.path); + } + detail += ')'; + lines.push(detail); + } + } + + if (content.sourceFolders && content.sourceFolders.length > 0) { + lines.push(''); + lines.push(localize('chatDebug.plainText.sourceFolders', "Sources ({0})", content.sourceFolders.length)); + for (const folder of content.sourceFolders) { + lines.push(` ${folder.uri.path}`); + } + } + + return lines.join('\n'); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts new file mode 100644 index 00000000000..50ce745c94f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { viewFilterSubmenu } from '../../../../browser/parts/views/viewFilter.js'; +import { + CHAT_DEBUG_FILTER_ACTIVE, + CHAT_DEBUG_KIND_TOOL_CALL, CHAT_DEBUG_KIND_MODEL_TURN, CHAT_DEBUG_KIND_GENERIC, CHAT_DEBUG_KIND_SUBAGENT, + CHAT_DEBUG_KIND_USER_MESSAGE, CHAT_DEBUG_KIND_AGENT_RESPONSE, + CHAT_DEBUG_LEVEL_TRACE, CHAT_DEBUG_LEVEL_INFO, CHAT_DEBUG_LEVEL_WARNING, CHAT_DEBUG_LEVEL_ERROR, + CHAT_DEBUG_CMD_TOGGLE_TOOL_CALL, CHAT_DEBUG_CMD_TOGGLE_MODEL_TURN, CHAT_DEBUG_CMD_TOGGLE_GENERIC, + CHAT_DEBUG_CMD_TOGGLE_SUBAGENT, CHAT_DEBUG_CMD_TOGGLE_USER_MESSAGE, CHAT_DEBUG_CMD_TOGGLE_AGENT_RESPONSE, + CHAT_DEBUG_CMD_TOGGLE_TRACE, CHAT_DEBUG_CMD_TOGGLE_INFO, CHAT_DEBUG_CMD_TOGGLE_WARNING, CHAT_DEBUG_CMD_TOGGLE_ERROR, +} from './chatDebugTypes.js'; + +/** + * Shared filter state for the Chat Debug Panel. + * + * Both the Logs view and the Flow Chart view read from this single source of + * truth. Toggle commands modify the state and fire `onDidChange` so every + * consumer can re-render. + */ +export class ChatDebugFilterState extends Disposable { + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + // Kind visibility + filterKindToolCall: boolean = true; + filterKindModelTurn: boolean = true; + filterKindGeneric: boolean = true; + filterKindSubagent: boolean = true; + filterKindUserMessage: boolean = true; + filterKindAgentResponse: boolean = true; + + // Level visibility + filterLevelTrace: boolean = true; + filterLevelInfo: boolean = true; + filterLevelWarning: boolean = true; + filterLevelError: boolean = true; + + // Text filter + textFilter: string = ''; + + isKindVisible(kind: string): boolean { + switch (kind) { + case 'toolCall': return this.filterKindToolCall; + case 'modelTurn': return this.filterKindModelTurn; + case 'generic': return this.filterKindGeneric; + case 'subagentInvocation': return this.filterKindSubagent; + case 'userMessage': return this.filterKindUserMessage; + case 'agentResponse': return this.filterKindAgentResponse; + default: return true; + } + } + + isAllKindsVisible(): boolean { + return this.filterKindToolCall && this.filterKindModelTurn && + this.filterKindGeneric && this.filterKindSubagent && + this.filterKindUserMessage && this.filterKindAgentResponse; + } + + isAllLevelsVisible(): boolean { + return this.filterLevelTrace && this.filterLevelInfo && + this.filterLevelWarning && this.filterLevelError; + } + + isAllFiltersDefault(): boolean { + return this.isAllKindsVisible() && this.isAllLevelsVisible(); + } + + setTextFilter(text: string): void { + const normalized = text.toLowerCase(); + if (this.textFilter !== normalized) { + this.textFilter = normalized; + this._onDidChange.fire(); + } + } + + fire(): void { + this._onDidChange.fire(); + } +} + +/** + * Registers the toggle-filter commands and menu items once, wired to a shared + * {@link ChatDebugFilterState}. Returns a disposable that unregisters them. + */ +export function registerFilterMenuItems( + state: ChatDebugFilterState, + scopedContextKeyService: IContextKeyService, +): DisposableStore { + const store = new DisposableStore(); + + // Bind context keys so the "More Filters" submenu shows toggle checkboxes + CHAT_DEBUG_FILTER_ACTIVE.bindTo(scopedContextKeyService).set(true); + + const kindToolCallKey = CHAT_DEBUG_KIND_TOOL_CALL.bindTo(scopedContextKeyService); + kindToolCallKey.set(true); + const kindModelTurnKey = CHAT_DEBUG_KIND_MODEL_TURN.bindTo(scopedContextKeyService); + kindModelTurnKey.set(true); + const kindGenericKey = CHAT_DEBUG_KIND_GENERIC.bindTo(scopedContextKeyService); + kindGenericKey.set(true); + const kindSubagentKey = CHAT_DEBUG_KIND_SUBAGENT.bindTo(scopedContextKeyService); + kindSubagentKey.set(true); + const kindUserMessageKey = CHAT_DEBUG_KIND_USER_MESSAGE.bindTo(scopedContextKeyService); + kindUserMessageKey.set(true); + const kindAgentResponseKey = CHAT_DEBUG_KIND_AGENT_RESPONSE.bindTo(scopedContextKeyService); + kindAgentResponseKey.set(true); + const levelTraceKey = CHAT_DEBUG_LEVEL_TRACE.bindTo(scopedContextKeyService); + levelTraceKey.set(true); + const levelInfoKey = CHAT_DEBUG_LEVEL_INFO.bindTo(scopedContextKeyService); + levelInfoKey.set(true); + const levelWarningKey = CHAT_DEBUG_LEVEL_WARNING.bindTo(scopedContextKeyService); + levelWarningKey.set(true); + const levelErrorKey = CHAT_DEBUG_LEVEL_ERROR.bindTo(scopedContextKeyService); + levelErrorKey.set(true); + + const registerToggle = ( + id: string, title: string, key: RawContextKey, group: string, + getter: () => boolean, setter: (v: boolean) => void, ctxKey: IContextKey, + ) => { + store.add(CommandsRegistry.registerCommand(id, () => { + const newVal = !getter(); + setter(newVal); + ctxKey.set(newVal); + state.fire(); + })); + store.add(MenuRegistry.appendMenuItem(viewFilterSubmenu, { + command: { id, title, toggled: key }, + group, + when: CHAT_DEBUG_FILTER_ACTIVE, + })); + }; + + registerToggle(CHAT_DEBUG_CMD_TOGGLE_TOOL_CALL, localize('chatDebug.filter.toolCall', "Tool Calls"), CHAT_DEBUG_KIND_TOOL_CALL, '1_kind', () => state.filterKindToolCall, v => { state.filterKindToolCall = v; }, kindToolCallKey); + registerToggle(CHAT_DEBUG_CMD_TOGGLE_MODEL_TURN, localize('chatDebug.filter.modelTurn', "Model Turns"), CHAT_DEBUG_KIND_MODEL_TURN, '1_kind', () => state.filterKindModelTurn, v => { state.filterKindModelTurn = v; }, kindModelTurnKey); + registerToggle(CHAT_DEBUG_CMD_TOGGLE_GENERIC, localize('chatDebug.filter.generic', "Generic"), CHAT_DEBUG_KIND_GENERIC, '1_kind', () => state.filterKindGeneric, v => { state.filterKindGeneric = v; }, kindGenericKey); + registerToggle(CHAT_DEBUG_CMD_TOGGLE_SUBAGENT, localize('chatDebug.filter.subagent', "Subagent Invocations"), CHAT_DEBUG_KIND_SUBAGENT, '1_kind', () => state.filterKindSubagent, v => { state.filterKindSubagent = v; }, kindSubagentKey); + registerToggle(CHAT_DEBUG_CMD_TOGGLE_USER_MESSAGE, localize('chatDebug.filter.userMessage', "User Messages"), CHAT_DEBUG_KIND_USER_MESSAGE, '1_kind', () => state.filterKindUserMessage, v => { state.filterKindUserMessage = v; }, kindUserMessageKey); + registerToggle(CHAT_DEBUG_CMD_TOGGLE_AGENT_RESPONSE, localize('chatDebug.filter.agentResponse', "Agent Responses"), CHAT_DEBUG_KIND_AGENT_RESPONSE, '1_kind', () => state.filterKindAgentResponse, v => { state.filterKindAgentResponse = v; }, kindAgentResponseKey); + + registerToggle(CHAT_DEBUG_CMD_TOGGLE_TRACE, localize('chatDebug.filter.trace', "Trace"), CHAT_DEBUG_LEVEL_TRACE, '2_level', () => state.filterLevelTrace, v => { state.filterLevelTrace = v; }, levelTraceKey); + registerToggle(CHAT_DEBUG_CMD_TOGGLE_INFO, localize('chatDebug.filter.info', "Info"), CHAT_DEBUG_LEVEL_INFO, '2_level', () => state.filterLevelInfo, v => { state.filterLevelInfo = v; }, levelInfoKey); + registerToggle(CHAT_DEBUG_CMD_TOGGLE_WARNING, localize('chatDebug.filter.warning', "Warning"), CHAT_DEBUG_LEVEL_WARNING, '2_level', () => state.filterLevelWarning, v => { state.filterLevelWarning = v; }, levelWarningKey); + registerToggle(CHAT_DEBUG_CMD_TOGGLE_ERROR, localize('chatDebug.filter.error', "Error"), CHAT_DEBUG_LEVEL_ERROR, '2_level', () => state.filterLevelError, v => { state.filterLevelError = v; }, levelErrorKey); + + return store; +} + +/** + * Binds context keys for filter state into a scoped context key service. + * Returns a function to sync all keys from the current state. + */ +export function bindFilterContextKeys( + state: ChatDebugFilterState, + scopedContextKeyService: IContextKeyService, +): () => void { + CHAT_DEBUG_FILTER_ACTIVE.bindTo(scopedContextKeyService).set(true); + const kindToolCallKey = CHAT_DEBUG_KIND_TOOL_CALL.bindTo(scopedContextKeyService); + const kindModelTurnKey = CHAT_DEBUG_KIND_MODEL_TURN.bindTo(scopedContextKeyService); + const kindGenericKey = CHAT_DEBUG_KIND_GENERIC.bindTo(scopedContextKeyService); + const kindSubagentKey = CHAT_DEBUG_KIND_SUBAGENT.bindTo(scopedContextKeyService); + const kindUserMessageKey = CHAT_DEBUG_KIND_USER_MESSAGE.bindTo(scopedContextKeyService); + const kindAgentResponseKey = CHAT_DEBUG_KIND_AGENT_RESPONSE.bindTo(scopedContextKeyService); + const levelTraceKey = CHAT_DEBUG_LEVEL_TRACE.bindTo(scopedContextKeyService); + const levelInfoKey = CHAT_DEBUG_LEVEL_INFO.bindTo(scopedContextKeyService); + const levelWarningKey = CHAT_DEBUG_LEVEL_WARNING.bindTo(scopedContextKeyService); + const levelErrorKey = CHAT_DEBUG_LEVEL_ERROR.bindTo(scopedContextKeyService); + + return () => { + kindToolCallKey.set(state.filterKindToolCall); + kindModelTurnKey.set(state.filterKindModelTurn); + kindGenericKey.set(state.filterKindGeneric); + kindSubagentKey.set(state.filterKindSubagent); + kindUserMessageKey.set(state.filterKindUserMessage); + kindAgentResponseKey.set(state.filterKindAgentResponse); + levelTraceKey.set(state.filterLevelTrace); + levelInfoKey.set(state.filterLevelInfo); + levelWarningKey.set(state.filterLevelWarning); + levelErrorKey.set(state.filterLevelError); + }; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChart.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChart.ts new file mode 100644 index 00000000000..b7dfe35052e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChart.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Barrel re-export — keeps existing imports stable. +// Data model + graph building (stable): +export { buildFlowGraph, filterFlowNodes, sliceFlowNodes } from './chatDebugFlowGraph.js'; +export type { FlowNode, FlowFilterOptions, FlowSliceResult, FlowLayout, FlowChartRenderResult, LayoutNode, LayoutEdge, SubgraphRect } from './chatDebugFlowGraph.js'; +// Layout + rendering +export { layoutFlowGraph, renderFlowChartSVG } from './chatDebugFlowLayout.js'; + diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts new file mode 100644 index 00000000000..a61f134a9a8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts @@ -0,0 +1,549 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { BreadcrumbsWidget } from '../../../../../base/browser/ui/breadcrumbs/breadcrumbsWidget.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { defaultBreadcrumbsWidgetStyles, defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { FilterWidget } from '../../../../browser/parts/views/viewFilter.js'; +import { IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { setupBreadcrumbKeyboardNavigation, TextBreadcrumbItem } from './chatDebugTypes.js'; +import { ChatDebugFilterState, bindFilterContextKeys } from './chatDebugFilters.js'; +import { buildFlowGraph, filterFlowNodes, sliceFlowNodes, layoutFlowGraph, renderFlowChartSVG, FlowChartRenderResult } from './chatDebugFlowChart.js'; +import { ChatDebugDetailPanel } from './chatDebugDetailPanel.js'; + +const $ = DOM.$; + +const MIN_SCALE = 0.1; +const MAX_SCALE = 5; +const ZOOM_STEP = 0.15; +const WHEEL_ZOOM_FACTOR = 0.002; +const CLICK_THRESHOLD_SQ = 25; +const PAGE_SIZE = 100; + +export const enum FlowChartNavigation { + Home = 'home', + Overview = 'overview', +} + +export class ChatDebugFlowChartView extends Disposable { + + private readonly _onNavigate = this._register(new Emitter()); + readonly onNavigate = this._onNavigate.event; + + readonly container: HTMLElement; + private readonly content: HTMLElement; + private readonly breadcrumbWidget: BreadcrumbsWidget; + private readonly filterWidget: FilterWidget; + private readonly loadDisposables = this._register(new DisposableStore()); + + // Pan/zoom state + private scale = 1; + private translateX = 0; + private translateY = 0; + private isPanning = false; + private startX = 0; + private startY = 0; + + // Click detection (distinguish click from drag) + private mouseDownX = 0; + private mouseDownY = 0; + + // Direct element references (avoid querySelector) + private svgWrapper: HTMLElement | undefined; + private svgElement: SVGElement | undefined; + private renderResult: FlowChartRenderResult | undefined; + + private currentSessionResource: URI | undefined; + private lastEventCount: number = 0; + private hasUserPanned: boolean = false; + + // Focus state — preserved across re-renders + private focusedElementId: string | undefined; + + // Collapse state — persists across refreshes, resets on session change + private readonly collapsedNodeIds = new Set(); + + // Pagination state + private visibleLimit: number = PAGE_SIZE; + + // Detail panel + private readonly detailPanel: ChatDebugDetailPanel; + private eventById = new Map(); + + constructor( + parent: HTMLElement, + private readonly filterState: ChatDebugFilterState, + @IChatService private readonly chatService: IChatService, + @IChatDebugService private readonly chatDebugService: IChatDebugService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + this.container = DOM.append(parent, $('.chat-debug-flowchart')); + DOM.hide(this.container); + + // Breadcrumb + const breadcrumbContainer = DOM.append(this.container, $('.chat-debug-breadcrumb')); + this.breadcrumbWidget = this._register(new BreadcrumbsWidget(breadcrumbContainer, 3, undefined, Codicon.chevronRight, defaultBreadcrumbsWidgetStyles)); + this._register(setupBreadcrumbKeyboardNavigation(breadcrumbContainer, this.breadcrumbWidget)); + this._register(this.breadcrumbWidget.onDidSelectItem(e => { + if (e.type === 'select' && e.item instanceof TextBreadcrumbItem) { + this.breadcrumbWidget.setSelection(undefined); + const items = this.breadcrumbWidget.getItems(); + const idx = items.indexOf(e.item); + if (idx === 0) { + this._onNavigate.fire(FlowChartNavigation.Home); + } else if (idx === 1) { + this._onNavigate.fire(FlowChartNavigation.Overview); + } + } + })); + + // Header with FilterWidget + const headerContainer = DOM.append(this.container, $('.chat-debug-editor-header')); + const scopedContextKeyService = this._register(this.contextKeyService.createScoped(headerContainer)); + const syncContextKeys = bindFilterContextKeys(this.filterState, scopedContextKeyService); + syncContextKeys(); + + const childInstantiationService = this._register(this.instantiationService.createChild( + new ServiceCollection([IContextKeyService, scopedContextKeyService]) + )); + this.filterWidget = this._register(childInstantiationService.createInstance(FilterWidget, { + placeholder: localize('chatDebug.flowchart.search', "Filter nodes..."), + ariaLabel: localize('chatDebug.flowchart.filterAriaLabel', "Filter flow chart nodes"), + })); + const filterContainer = DOM.append(headerContainer, $('.viewpane-filter-container')); + filterContainer.appendChild(this.filterWidget.element); + + this._register(this.filterWidget.onDidChangeFilterText(text => { + this.filterState.setTextFilter(text); + })); + + // React to shared filter state changes + this._register(this.filterState.onDidChange(() => { + syncContextKeys(); + this.filterWidget.checkMoreFilters(!this.filterState.isAllFiltersDefault()); + this.visibleLimit = PAGE_SIZE; + // Reset pan/zoom so filtered content is visible + this.hasUserPanned = false; + this.lastEventCount = 0; + this.load(); + })); + + // Content wrapper (flex row: chart canvas + detail panel) + const contentWrapper = DOM.append(this.container, $('.chat-debug-flowchart-content-wrapper')); + this.content = DOM.append(contentWrapper, $('.chat-debug-flowchart-content')); + + // Detail panel (sibling of chart canvas) + this.detailPanel = this._register(this.instantiationService.createInstance(ChatDebugDetailPanel, contentWrapper)); + + // Set up pan/zoom event listeners and keyboard handling + this.setupPanZoom(); + this.setupKeyboard(); + } + + setSession(sessionResource: URI): void { + if (!this.currentSessionResource || this.currentSessionResource.toString() !== sessionResource.toString()) { + // Reset pan/zoom, focus, collapse, and pagination state on session change + this.scale = 1; + this.translateX = 0; + this.translateY = 0; + this.lastEventCount = 0; + this.hasUserPanned = false; + this.focusedElementId = undefined; + this.collapsedNodeIds.clear(); + this.visibleLimit = PAGE_SIZE; + this.detailPanel.hide(); + } + this.currentSessionResource = sessionResource; + } + + show(): void { + DOM.show(this.container); + this.load(); + } + + hide(): void { + DOM.hide(this.container); + } + + refresh(): void { + if (this.container.style.display !== 'none') { + this.load(); + } + } + + updateBreadcrumb(): void { + if (!this.currentSessionResource) { + return; + } + const sessionTitle = this.chatService.getSessionTitle(this.currentSessionResource) || LocalChatSessionUri.parseLocalSessionId(this.currentSessionResource) || this.currentSessionResource.toString(); + this.breadcrumbWidget.setItems([ + new TextBreadcrumbItem(localize('chatDebug.title', "Chat Debug Panel"), true), + new TextBreadcrumbItem(sessionTitle, true), + new TextBreadcrumbItem(localize('chatDebug.flowChart', "Agent Flow Chart")), + ]); + } + + private load(): void { + DOM.clearNode(this.content); + this.loadDisposables.clear(); + this.updateBreadcrumb(); + + const events = this.chatDebugService.getEvents(this.currentSessionResource); + const isFirstLoad = this.lastEventCount === 0; + this.lastEventCount = events.length; + + // Build event ID → event map for detail panel lookups + this.eventById.clear(); + for (const e of events) { + if (e.id) { + this.eventById.set(e.id, e); + } + } + + if (events.length === 0) { + const emptyMsg = DOM.append(this.content, $('.chat-debug-flowchart-empty')); + emptyMsg.textContent = localize('chatDebug.flowChart.noEvents', "No events recorded for this session."); + return; + } + + // Build, filter, slice, and render the flow chart + const flowNodes = buildFlowGraph(events); + const filtered = filterFlowNodes(flowNodes, { + isKindVisible: kind => this.filterState.isKindVisible(kind), + textFilter: this.filterState.textFilter, + }); + + if (filtered.length === 0) { + const emptyMsg = DOM.append(this.content, $('.chat-debug-flowchart-empty')); + emptyMsg.textContent = localize('chatDebug.flowChart.noMatches', "No nodes match the current filter."); + return; + } + + const slice = sliceFlowNodes(filtered, this.visibleLimit); + const layout = layoutFlowGraph(slice.nodes, { collapsedIds: this.collapsedNodeIds }); + this.renderResult = renderFlowChartSVG(layout); + + this.svgWrapper = DOM.append(this.content, $('.chat-debug-flowchart-svg-wrapper')); + this.svgWrapper.appendChild(this.renderResult.svg); + this.svgElement = this.renderResult.svg; + + // Show "Show More" button below the chart when there are more nodes + if (slice.shownCount < slice.totalCount) { + const remaining = slice.totalCount - slice.shownCount; + const showMoreContainer = DOM.append(this.svgWrapper, $('.chat-debug-flowchart-show-more')); + const showMoreBtn = this.loadDisposables.add(new Button(showMoreContainer, { ...defaultButtonStyles, secondary: true, title: localize('chatDebug.flowChart.showMoreTitle', "Load more nodes") })); + showMoreBtn.label = localize('chatDebug.flowChart.showMore', "Show More ({0})", remaining); + this.loadDisposables.add(showMoreBtn.onDidClick(() => { + this.visibleLimit += PAGE_SIZE; + this.load(); + })); + } + + // Only center on first load when user hasn't panned yet + if (isFirstLoad && !this.hasUserPanned) { + DOM.getWindow(this.content).requestAnimationFrame(() => { + this.centerContent(); + }); + } else { + // Apply existing transform to preserve position + this.applyTransform(); + } + + // Restore focus after re-render (e.g. after collapse toggle) + if (this.focusedElementId) { + this.restoreFocus(this.focusedElementId); + } + } + + private setupPanZoom(): void { + this._register(DOM.addDisposableListener(this.content, DOM.EventType.MOUSE_DOWN, e => this.handleMouseDown(e))); + const targetDocument = DOM.getWindow(this.content).document; + this._register(DOM.addDisposableListener(targetDocument, DOM.EventType.MOUSE_MOVE, e => this.handleMouseMove(e))); + this._register(DOM.addDisposableListener(targetDocument, DOM.EventType.MOUSE_UP, e => this.handleMouseUp(e))); + this._register(DOM.addDisposableListener(this.content, 'wheel', e => this.handleWheel(e), { passive: false })); + } + + private setupKeyboard(): void { + // Track which node/header gets focus + this._register(DOM.addDisposableListener(this.content, DOM.EventType.FOCUS_IN, (e: FocusEvent) => { + const el = e.target as Element | null; + if (!el) { + return; + } + // Check for subgraph header or node + const subgraphId = el.getAttribute?.('data-subgraph-id'); + if (subgraphId) { + this.focusedElementId = `sg:${subgraphId}`; + return; + } + const nodeId = el.getAttribute?.('data-node-id'); + if (nodeId) { + this.focusedElementId = nodeId; + } + })); + + // Handle keyboard actions + this._register(DOM.addDisposableListener(this.content, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + const target = e.target as Element | null; + if (!target) { + return; + } + const subgraphId = target.getAttribute?.('data-subgraph-id'); + + switch (e.key) { + case 'Tab': { + e.preventDefault(); + if (this.focusedElementId) { + this.focusAdjacentElement(this.focusedElementId, e.shiftKey ? -1 : 1); + } else { + this.focusFirstElement(); + } + break; + } + case 'Enter': + case ' ': + if (subgraphId) { + e.preventDefault(); + e.stopPropagation(); + this.toggleSubgraph(subgraphId); + } else { + const nodeId = target.getAttribute?.('data-node-id'); + if (nodeId) { + e.preventDefault(); + const event = this.eventById.get(nodeId); + if (event) { + this.detailPanel.show(event); + } + } + } + break; + case 'ArrowDown': + case 'ArrowRight': + e.preventDefault(); + if (this.focusedElementId) { + this.focusAdjacentElement(this.focusedElementId, 1); + } else { + this.focusFirstElement(); + } + break; + case 'ArrowUp': + case 'ArrowLeft': + e.preventDefault(); + if (this.focusedElementId) { + this.focusAdjacentElement(this.focusedElementId, -1); + } else { + this.focusFirstElement(); + } + break; + case 'Home': + e.preventDefault(); + this.focusFirstElement(); + break; + case 'End': + e.preventDefault(); + this.focusLastElement(); + break; + case '=': + case '+': + if (!e.ctrlKey && !e.metaKey) { + e.preventDefault(); + this.zoomBy(ZOOM_STEP); + } + break; + case '-': + if (!e.ctrlKey && !e.metaKey) { + e.preventDefault(); + this.zoomBy(-ZOOM_STEP); + } + break; + } + })); + } + + private toggleSubgraph(subgraphId: string): void { + if (this.collapsedNodeIds.has(subgraphId)) { + this.collapsedNodeIds.delete(subgraphId); + } else { + this.collapsedNodeIds.add(subgraphId); + } + this.focusedElementId = `sg:${subgraphId}`; + this.load(); + } + + private focusFirstElement(): void { + if (!this.renderResult) { + return; + } + const first = this.renderResult.focusableElements.values().next(); + if (!first.done) { + (first.value as SVGElement).focus(); + } + } + + private focusLastElement(): void { + if (!this.renderResult) { + return; + } + const entries = [...this.renderResult.focusableElements.values()]; + if (entries.length > 0) { + (entries[entries.length - 1] as SVGElement).focus(); + } + } + + private focusAdjacentElement(currentMapKey: string, direction: 1 | -1): void { + if (!this.renderResult) { + return; + } + const keys = [...this.renderResult.focusableElements.keys()]; + const idx = keys.indexOf(currentMapKey); + if (idx === -1) { + return; + } + const nextIdx = idx + direction; + if (nextIdx < 0 || nextIdx >= keys.length) { + return; + } + const el = this.renderResult.focusableElements.get(keys[nextIdx]); + if (el) { + (el as SVGElement).focus(); + } + } + + private restoreFocus(elementId: string): void { + const el = this.renderResult?.focusableElements.get(elementId); + if (el) { + el.focus(); + } + } + + private zoomBy(delta: number): void { + const rect = this.content.getBoundingClientRect(); + const centerX = rect.width / 2; + const centerY = rect.height / 2; + const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, this.scale * (1 + delta))); + const scaleFactor = newScale / this.scale; + this.translateX = centerX - (centerX - this.translateX) * scaleFactor; + this.translateY = centerY - (centerY - this.translateY) * scaleFactor; + this.scale = newScale; + this.hasUserPanned = true; + this.applyTransform(); + } + + private handleMouseDown(e: MouseEvent): void { + if (e.button !== 0) { + return; + } + e.preventDefault(); + this.isPanning = true; + this.hasUserPanned = true; + this.startX = e.clientX - this.translateX; + this.startY = e.clientY - this.translateY; + this.mouseDownX = e.clientX; + this.mouseDownY = e.clientY; + this.content.style.cursor = 'grabbing'; + } + + private handleMouseMove(e: MouseEvent): void { + if (!this.isPanning) { + return; + } + if (e.buttons === 0) { + this.handleMouseUp(e); + return; + } + this.translateX = e.clientX - this.startX; + this.translateY = e.clientY - this.startY; + this.applyTransform(); + } + + private handleMouseUp(e: MouseEvent): void { + if (this.isPanning) { + this.isPanning = false; + this.content.style.cursor = 'grab'; + + // Detect click (not a drag) — distance < 5px + const dx = e.clientX - this.mouseDownX; + const dy = e.clientY - this.mouseDownY; + if (dx * dx + dy * dy < CLICK_THRESHOLD_SQ) { + this.handleClick(e); + } + } + } + + private handleClick(e: MouseEvent): void { + // Walk up from the click target to find a focusable element + let target = e.target as Element | null; + while (target && target !== this.content) { + const subgraphId = target.getAttribute?.('data-subgraph-id'); + if (subgraphId) { + this.toggleSubgraph(subgraphId); + return; + } + const nodeId = target.getAttribute?.('data-node-id'); + if (nodeId) { + (target as HTMLElement).focus(); + const event = this.eventById.get(nodeId); + if (event) { + this.detailPanel.show(event); + } + return; + } + target = target.parentElement; + } + } + + private handleWheel(e: WheelEvent): void { + e.preventDefault(); + e.stopPropagation(); + + this.hasUserPanned = true; + + const rect = this.content.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + const delta = -e.deltaY * WHEEL_ZOOM_FACTOR; + const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, this.scale * (1 + delta))); + + const scaleFactor = newScale / this.scale; + this.translateX = mouseX - (mouseX - this.translateX) * scaleFactor; + this.translateY = mouseY - (mouseY - this.translateY) * scaleFactor; + this.scale = newScale; + + this.applyTransform(); + } + + private applyTransform(): void { + if (this.svgWrapper) { + this.svgWrapper.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`; + } + } + + private centerContent(): void { + const containerRect = this.content.getBoundingClientRect(); + if (!this.svgElement) { + return; + } + const svgWidth = parseFloat(this.svgElement.getAttribute('width') || '0'); + const svgHeight = parseFloat(this.svgElement.getAttribute('height') || '0'); + + this.translateX = (containerRect.width - svgWidth) / 2; + this.translateY = Math.max(20, (containerRect.height - svgHeight) / 2); + this.applyTransform(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts new file mode 100644 index 00000000000..41125ffd836 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts @@ -0,0 +1,527 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IChatDebugEvent } from '../../common/chatDebugService.js'; + +// ---- Data model ---- + +export interface FlowNode { + readonly id: string; + readonly kind: IChatDebugEvent['kind']; + readonly label: string; + readonly sublabel?: string; + readonly description?: string; + readonly tooltip?: string; + readonly isError?: boolean; + readonly created: number; + readonly children: FlowNode[]; +} + +export interface FlowFilterOptions { + readonly isKindVisible: (kind: string) => boolean; + readonly textFilter: string; +} + +export interface LayoutNode { + readonly id: string; + readonly kind: IChatDebugEvent['kind']; + readonly label: string; + readonly sublabel?: string; + readonly tooltip?: string; + readonly isError?: boolean; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface LayoutEdge { + readonly fromX: number; + readonly fromY: number; + readonly toX: number; + readonly toY: number; +} + +export interface SubgraphRect { + readonly label: string; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly depth: number; + readonly nodeId: string; + readonly collapsedChildCount?: number; +} + +export interface FlowLayout { + readonly nodes: LayoutNode[]; + readonly edges: LayoutEdge[]; + readonly subgraphs: SubgraphRect[]; + readonly width: number; + readonly height: number; +} + +export interface FlowChartRenderResult { + readonly svg: SVGElement; + /** Map from node/subgraph ID to its focusable SVG element. */ + readonly focusableElements: Map; +} + +// ---- Build flow graph from debug events ---- + +/** + * Truncates a string to a max length, appending an ellipsis if trimmed. + */ +function truncateLabel(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + return text.substring(0, maxLength - 1) + '\u2026'; +} + +export function buildFlowGraph(events: readonly IChatDebugEvent[]): FlowNode[] { + // Before filtering, extract description metadata from subagent events + // that will be filtered out, so we can enrich the surviving sibling events. + const subagentToolNames = new Set(['runSubagent', 'search_subagent']); + + // The extension emits two subagentInvocation events per subagent: + // 1. "started" marker (agentName = descriptive name, status = running) — survives filtering + // 2. completion event (agentName = "runSubagent", status = completed) — filtered out + // The completion event carries the real description. When multiple subagents + // run under the same parent, they share a parentEventId, so we match them + // by order: the N-th started marker gets the N-th completion's description. + const completionDescsByParent = new Map(); + const startedCountByParent = new Map(); + for (const e of events) { + if (e.kind === 'subagentInvocation' && subagentToolNames.has(e.agentName) && e.description && e.parentEventId) { + let descs = completionDescsByParent.get(e.parentEventId); + if (!descs) { + descs = []; + completionDescsByParent.set(e.parentEventId, descs); + } + descs.push(e.description); + } + } + + function getSubagentDescription(event: IChatDebugEvent): string | undefined { + if (event.kind !== 'subagentInvocation' || !event.parentEventId) { + return undefined; + } + const descs = completionDescsByParent.get(event.parentEventId); + if (!descs || descs.length === 0) { + return event.description && event.description !== event.agentName ? event.description : undefined; + } + const idx = startedCountByParent.get(event.parentEventId) ?? 0; + startedCountByParent.set(event.parentEventId, idx + 1); + return descs[idx] ?? descs[0]; + } + + // Filter out redundant events: + // - toolCall with subagent tool names: the subagentInvocation event has richer metadata + // - subagentInvocation with agentName matching a tool name: these are completion + // duplicates of the "SubAgent started" marker which has the proper descriptive name + const filtered = events.filter(e => { + if (e.kind === 'toolCall' && subagentToolNames.has(e.toolName.replace(/^\u{1F6E0}\uFE0F?\s*/u, ''))) { + return false; + } + if (e.kind === 'subagentInvocation' && subagentToolNames.has(e.agentName)) { + return false; + } + return true; + }); + + const idToEvent = new Map(); + const idToChildren = new Map(); + const roots: IChatDebugEvent[] = []; + + for (const event of filtered) { + if (event.id) { + idToEvent.set(event.id, event); + } + } + + for (const event of filtered) { + if (event.parentEventId && idToEvent.has(event.parentEventId)) { + let children = idToChildren.get(event.parentEventId); + if (!children) { + children = []; + idToChildren.set(event.parentEventId, children); + } + children.push(event); + } else { + roots.push(event); + } + } + + function toFlowNode(event: IChatDebugEvent): FlowNode { + const children = event.id ? idToChildren.get(event.id) : undefined; + + // 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); + let tooltip = getEventTooltip(event); + let description: string | undefined; + if (event.kind === 'subagentInvocation') { + description = getSubagentDescription(event); + 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'); + lines.splice(1, 0, description); + tooltip = lines.join('\n'); + } + } + } + + return { + id: event.id ?? `event-${events.indexOf(event)}`, + kind: event.kind, + label: getEventLabel(event), + sublabel, + description, + tooltip, + isError: isErrorEvent(event), + created: event.created.getTime(), + children: children?.map(toFlowNode) ?? [], + }; + } + + return mergeModelTurns(roots.map(toFlowNode)); +} + +/** + * Absorbs model turn nodes into the subsequent sibling node. + * + * Each model turn represents an LLM call that decides what to do next + * (call tools, respond, etc.). Rather than showing model turns as separate + * boxes, we merge their metadata (token count, LLM latency) into the next + * node's sublabel and tooltip so the diagram stays compact while + * preserving the correlation. + */ +function mergeModelTurns(nodes: FlowNode[]): FlowNode[] { + const result: FlowNode[] = []; + let pendingModelTurn: FlowNode | undefined; + + for (const node of nodes) { + if (node.kind === 'modelTurn') { + pendingModelTurn = node; + continue; + } + + const merged = applyModelTurnInfo(node, pendingModelTurn); + pendingModelTurn = undefined; + result.push(merged); + } + + // If the last node was a model turn with no successor, keep it + if (pendingModelTurn) { + result.push(pendingModelTurn); + } + + return result; +} + +/** + * Enriches a node with model turn metadata and recursively + * merges model turns within its children. + */ +function applyModelTurnInfo(node: FlowNode, modelTurn: FlowNode | undefined): FlowNode { + const mergedChildren = node.children.length > 0 ? mergeModelTurns(node.children) : node.children; + + if (!modelTurn) { + return mergedChildren !== node.children ? { ...node, children: mergedChildren } : node; + } + + // Build compact annotation from model turn info (e.g. "500 tok · LLM 2.3s") + const annotation = modelTurn.sublabel; + const newSublabel = annotation + ? (node.sublabel ? `${node.sublabel} \u00b7 ${annotation}` : annotation) + : node.sublabel; + + // Enrich tooltip with model turn details + const modelTooltip = modelTurn.tooltip ?? (modelTurn.label !== 'Model Turn' ? modelTurn.label : undefined); + const newTooltip = modelTooltip + ? (node.tooltip ? `${node.tooltip}\n\nModel: ${modelTooltip}` : `Model: ${modelTooltip}`) + : node.tooltip; + + return { + ...node, + sublabel: newSublabel, + tooltip: newTooltip, + children: mergedChildren, + }; +} + +// ---- Flow node filtering ---- + +/** + * Filters a flow node tree by kind visibility and text search. + * Returns a new tree — the input is not mutated. + * + * Kind filtering: nodes whose kind is not visible are removed. + * For `subagentInvocation` nodes, the entire subgraph is removed. + * For other kinds, the node is removed and its children are re-parented. + * + * Text filtering: only nodes whose label, sublabel, or tooltip match the + * search term are kept, along with all their ancestors (path to root). + * If a subagent label matches, its entire subgraph is kept. + */ +export function filterFlowNodes(nodes: FlowNode[], options: FlowFilterOptions): FlowNode[] { + let result = filterByKind(nodes, options.isKindVisible); + if (options.textFilter) { + result = filterByText(result, options.textFilter); + } + return result; +} + +function filterByKind(nodes: FlowNode[], isKindVisible: (kind: string) => boolean): FlowNode[] { + const result: FlowNode[] = []; + let changed = false; + for (const node of nodes) { + if (!isKindVisible(node.kind)) { + changed = true; + // For subagents, drop the entire subgraph + if (node.kind === 'subagentInvocation') { + continue; + } + // For other kinds, re-parent children up + result.push(...filterByKind(node.children, isKindVisible)); + continue; + } + const filteredChildren = filterByKind(node.children, isKindVisible); + if (filteredChildren !== node.children) { + changed = true; + result.push({ ...node, children: filteredChildren }); + } else { + result.push(node); + } + } + return changed ? result : nodes; +} + +function nodeMatchesText(node: FlowNode, text: string): boolean { + return node.label.toLowerCase().includes(text) || + (node.sublabel?.toLowerCase().includes(text) ?? false) || + (node.tooltip?.toLowerCase().includes(text) ?? false); +} + +function filterByText(nodes: FlowNode[], text: string): FlowNode[] { + const result: FlowNode[] = []; + for (const node of nodes) { + if (nodeMatchesText(node, text)) { + // Node matches — keep it with all descendants + result.push(node); + continue; + } + // Check if any descendant matches + const filteredChildren = filterByText(node.children, text); + if (filteredChildren.length > 0) { + // Keep this node as an ancestor of matching descendants + result.push({ ...node, children: filteredChildren }); + } + } + return result; +} + +// ---- Node slicing (pagination) ---- + +export interface FlowSliceResult { + readonly nodes: FlowNode[]; + readonly totalCount: number; + readonly shownCount: number; +} + +/** + * Counts the total number of nodes in a tree (each node + all descendants). + */ +function countNodes(nodes: readonly FlowNode[]): number { + let count = 0; + for (const node of nodes) { + count += 1 + countNodes(node.children); + } + return count; +} + +/** + * Slices a flow node tree to at most `maxCount` nodes (pre-order DFS). + * + * When a subagent's children would exceed the remaining budget, the + * children list is truncated. Returns the sliced tree along with total + * and shown node counts for the "Show More" UI. + */ +export function sliceFlowNodes(nodes: readonly FlowNode[], maxCount: number): FlowSliceResult { + const totalCount = countNodes(nodes); + if (totalCount <= maxCount) { + return { nodes: nodes as FlowNode[], totalCount, shownCount: totalCount }; + } + + let remaining = maxCount; + + function sliceTree(nodeList: readonly FlowNode[]): FlowNode[] { + const result: FlowNode[] = []; + for (const node of nodeList) { + if (remaining <= 0) { + break; + } + remaining--; // count this node + if (node.children.length === 0 || remaining <= 0) { + result.push(node.children.length === 0 ? node : { ...node, children: [] }); + } else { + const slicedChildren = sliceTree(node.children); + result.push(slicedChildren !== node.children ? { ...node, children: slicedChildren } : node); + } + } + return result; + } + + const sliced = sliceTree(nodes); + const shownCount = maxCount - remaining; + return { nodes: sliced, totalCount, shownCount }; +} + +// ---- Event helpers ---- + +function getEventLabel(event: IChatDebugEvent): string { + switch (event.kind) { + case 'userMessage': { + const firstLine = event.message.split('\n')[0]; + return firstLine.length > 40 ? firstLine.substring(0, 37) + '...' : firstLine; + } + case 'modelTurn': + return event.model ?? 'Model Turn'; + case 'toolCall': + return event.toolName; + case 'subagentInvocation': + return event.agentName; + case 'agentResponse': + return 'Response'; + case 'generic': + return event.name; + } +} + +function getEventSublabel(event: IChatDebugEvent): string | undefined { + switch (event.kind) { + case 'modelTurn': { + const parts: string[] = []; + if (event.totalTokens) { + parts.push(`${event.totalTokens} tokens`); + } + if (event.durationInMillis) { + parts.push(formatDuration(event.durationInMillis)); + } + return parts.length > 0 ? parts.join(' \u00b7 ') : undefined; + } + case 'toolCall': { + const parts: string[] = []; + if (event.result) { + parts.push(event.result); + } + if (event.durationInMillis) { + parts.push(formatDuration(event.durationInMillis)); + } + return parts.length > 0 ? parts.join(' \u00b7 ') : undefined; + } + case 'subagentInvocation': { + const parts: string[] = []; + if (event.status) { + parts.push(event.status); + } + if (event.durationInMillis) { + parts.push(formatDuration(event.durationInMillis)); + } + return parts.length > 0 ? parts.join(' \u00b7 ') : undefined; + } + default: + return undefined; + } +} + +function formatDuration(ms: number): string { + if (ms < 1000) { + return `${ms}ms`; + } + return `${(ms / 1000).toFixed(1)}s`; +} + +function isErrorEvent(event: IChatDebugEvent): boolean { + return (event.kind === 'toolCall' && event.result === 'error') || + (event.kind === 'generic' && event.level === 3 /* ChatDebugLogLevel.Error */) || + (event.kind === 'subagentInvocation' && event.status === 'failed'); +} + +const TOOLTIP_MAX_LENGTH = 500; + +function getEventTooltip(event: IChatDebugEvent): string | undefined { + switch (event.kind) { + case 'userMessage': { + const msg = event.message.trim(); + if (msg.length > TOOLTIP_MAX_LENGTH) { + return msg.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026'; + } + return msg || undefined; + } + case 'toolCall': { + const parts: string[] = [event.toolName]; + if (event.input) { + const input = event.input.trim(); + parts.push(`Input: ${input.length > TOOLTIP_MAX_LENGTH ? input.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : input}`); + } + if (event.output) { + const output = event.output.trim(); + parts.push(`Output: ${output.length > TOOLTIP_MAX_LENGTH ? output.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : output}`); + } + if (event.result) { + parts.push(`Result: ${event.result}`); + } + return parts.join('\n'); + } + case 'subagentInvocation': { + const parts: string[] = [event.agentName]; + if (event.description) { + parts.push(event.description); + } + if (event.status) { + parts.push(`Status: ${event.status}`); + } + if (event.toolCallCount !== undefined) { + parts.push(`Tool calls: ${event.toolCallCount}`); + } + if (event.modelTurnCount !== undefined) { + parts.push(`Model turns: ${event.modelTurnCount}`); + } + return parts.join('\n'); + } + case 'generic': { + if (event.details) { + const details = event.details.trim(); + return details.length > TOOLTIP_MAX_LENGTH ? details.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : details; + } + return undefined; + } + case 'modelTurn': { + const parts: string[] = []; + if (event.model) { + parts.push(event.model); + } + if (event.totalTokens) { + parts.push(`Tokens: ${event.totalTokens}`); + } + if (event.inputTokens) { + parts.push(`Input tokens: ${event.inputTokens}`); + } + if (event.outputTokens) { + parts.push(`Output tokens: ${event.outputTokens}`); + } + if (event.durationInMillis) { + parts.push(`Duration: ${formatDuration(event.durationInMillis)}`); + } + return parts.length > 0 ? parts.join('\n') : undefined; + } + default: + return undefined; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts new file mode 100644 index 00000000000..5e730d0309c --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts @@ -0,0 +1,652 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IChatDebugEvent } from '../../common/chatDebugService.js'; +import { FlowLayout, FlowNode, LayoutEdge, LayoutNode, SubgraphRect, FlowChartRenderResult } from './chatDebugFlowGraph.js'; + +// ---- Layout constants ---- + +const NODE_HEIGHT = 36; +const NODE_MIN_WIDTH = 140; +const NODE_MAX_WIDTH = 320; +const NODE_PADDING_H = 16; +const NODE_PADDING_V = 6; +const NODE_GAP_Y = 24; +const NODE_BORDER_RADIUS = 6; +const EDGE_STROKE_WIDTH = 1.5; +const FONT_SIZE = 12; +const SUBLABEL_FONT_SIZE = 10; +const SUBGRAPH_PADDING = 12; +const CANVAS_PADDING = 24; +const PARALLEL_GAP_X = 40; +const SUBGRAPH_HEADER_HEIGHT = 22; +const GUTTER_WIDTH = 3; + +// ---- Layout internals ---- + +interface SubtreeLayout { + nodes: LayoutNode[]; + edges: LayoutEdge[]; + subgraphs: SubgraphRect[]; + width: number; + height: number; + entryNode: LayoutNode; + exitNodes: LayoutNode[]; +} + +interface ChildGroup { + readonly type: 'sequential' | 'parallel'; + readonly children: FlowNode[]; +} + +// ---- Parallel detection ---- + +/** Max time gap (ms) between subagent `created` timestamps to consider them parallel. */ +const PARALLEL_TIME_THRESHOLD_MS = 5_000; + +/** + * Groups a list of sibling nodes into sequential and parallel segments. + * + * Subagent invocations whose `created` timestamps fall within + * {@link PARALLEL_TIME_THRESHOLD_MS} of each other are clustered as parallel. + * Non-subagent nodes interleaved within a cluster are emitted as a sequential + * group before the parallel fork. When fewer than 2 subagents exist, + * everything is sequential. + */ +function groupChildren(children: FlowNode[]): ChildGroup[] { + const subagentIndices: number[] = []; + for (let i = 0; i < children.length; i++) { + if (children[i].kind === 'subagentInvocation') { + subagentIndices.push(i); + } + } + + if (subagentIndices.length < 2) { + return [{ type: 'sequential', children }]; + } + + // Cluster subagents whose created timestamps are within the threshold. + const parallelClusters: number[][] = []; + let cluster: number[] = [subagentIndices[0]]; + for (let k = 1; k < subagentIndices.length; k++) { + const prevCreated = children[subagentIndices[k - 1]].created; + const currCreated = children[subagentIndices[k]].created; + if (Math.abs(currCreated - prevCreated) <= PARALLEL_TIME_THRESHOLD_MS) { + cluster.push(subagentIndices[k]); + } else { + if (cluster.length >= 2) { + parallelClusters.push(cluster); + } + cluster = [subagentIndices[k]]; + } + } + if (cluster.length >= 2) { + parallelClusters.push(cluster); + } + + if (parallelClusters.length === 0) { + return [{ type: 'sequential', children }]; + } + + // Build groups from the timestamp-derived clusters. + const parallelIndices = new Set(); + for (const c of parallelClusters) { + for (const idx of c) { + parallelIndices.add(idx); + } + } + + const groups: ChildGroup[] = []; + let clusterIdx = 0; + let i = 0; + while (i < children.length) { + if (clusterIdx < parallelClusters.length && i === parallelClusters[clusterIdx][0]) { + const cl = parallelClusters[clusterIdx]; + const lastIdx = cl[cl.length - 1]; + + const setup: FlowNode[] = []; + const subagents: FlowNode[] = []; + for (let j = cl[0]; j <= lastIdx; j++) { + if (parallelIndices.has(j)) { + subagents.push(children[j]); + } else { + setup.push(children[j]); + } + } + if (setup.length > 0) { + groups.push({ type: 'sequential', children: setup }); + } + groups.push({ type: 'parallel', children: subagents }); + i = lastIdx + 1; + clusterIdx++; + } else { + const start = i; + const nextStart = clusterIdx < parallelClusters.length ? parallelClusters[clusterIdx][0] : children.length; + while (i < nextStart && !parallelIndices.has(i)) { + i++; + } + if (i > start) { + groups.push({ type: 'sequential', children: children.slice(start, i) }); + } + } + } + return groups; +} + +// ---- Layout engine ---- + +function measureNodeWidth(label: string, sublabel?: string): number { + const charWidth = 7; + const labelWidth = label.length * charWidth + NODE_PADDING_H * 2; + const sublabelWidth = sublabel ? sublabel.length * (charWidth - 1) + NODE_PADDING_H * 2 : 0; + return Math.min(NODE_MAX_WIDTH, Math.max(NODE_MIN_WIDTH, labelWidth, sublabelWidth)); +} + +function subgraphHeaderLabel(node: FlowNode): string { + return node.description ? `${node.label}: ${node.description}` : node.label; +} + +function measureSubgraphHeaderWidth(headerLabel: string): number { + return headerLabel.length * 6 + SUBGRAPH_PADDING * 2 + 20; // 20 for chevron +} + +function countDescendants(node: FlowNode): number { + let count = node.children.length; + for (const child of node.children) { + count += countDescendants(child); + } + return count; +} + +/** + * Lays out grouped children (sequential or parallel) and connects edges. + * Shared by both root-level layout and subtree-level layout. + * + * @returns The final exit nodes, max width, and the y position after the last node. + */ +function layoutGroups( + groups: ChildGroup[], + startX: number, + startY: number, + depth: number, + prevExitNodes: LayoutNode[], + result: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] }, + collapsedIds?: ReadonlySet, +): { exitNodes: LayoutNode[]; maxWidth: number; endY: number } { + let currentY = startY; + let maxWidth = 0; + let exitNodes = prevExitNodes; + + for (const group of groups) { + if (group.type === 'parallel') { + const pg = layoutParallelGroup(group.children, startX, currentY, depth, collapsedIds); + result.nodes.push(...pg.nodes); + result.edges.push(...pg.edges); + result.subgraphs.push(...pg.subgraphs); + + for (const prev of exitNodes) { + for (const entry of pg.entryNodes) { + result.edges.push(makeEdge(prev, entry)); + } + } + exitNodes = pg.exitNodes; + maxWidth = Math.max(maxWidth, pg.width); + currentY += pg.height + NODE_GAP_Y; + } else { + for (const child of group.children) { + const sub = layoutSubtree(child, startX, currentY, depth, collapsedIds); + result.nodes.push(...sub.nodes); + result.edges.push(...sub.edges); + result.subgraphs.push(...sub.subgraphs); + + for (const prev of exitNodes) { + result.edges.push(makeEdge(prev, sub.entryNode)); + } + exitNodes = sub.exitNodes; + maxWidth = Math.max(maxWidth, sub.width); + currentY += sub.height + NODE_GAP_Y; + } + } + } + return { exitNodes, maxWidth, endY: currentY }; +} + +function makeEdge(from: LayoutNode, to: LayoutNode): LayoutEdge { + return { + fromX: from.x + from.width / 2, + fromY: from.y + from.height, + toX: to.x + to.width / 2, + toY: to.y, + }; +} + +/** + * Lays out a list of flow nodes in a top-down vertical flow. + * Parallel subagent invocations are arranged side by side. + */ +export function layoutFlowGraph(roots: FlowNode[], options?: { collapsedIds?: ReadonlySet }): FlowLayout { + if (roots.length === 0) { + return { nodes: [], edges: [], subgraphs: [], width: 0, height: 0 }; + } + + const collapsedIds = options?.collapsedIds; + const groups = groupChildren(roots); + const result: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] } = { + nodes: [], + edges: [], + subgraphs: [], + }; + + const { maxWidth, endY } = layoutGroups(groups, CANVAS_PADDING, CANVAS_PADDING, 0, [], result, collapsedIds); + const width = maxWidth + CANVAS_PADDING * 2; + const height = endY - NODE_GAP_Y + CANVAS_PADDING; + + centerLayout(result as FlowLayout & { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] }, width / 2); + + return { nodes: result.nodes, edges: result.edges, subgraphs: result.subgraphs, width, height }; +} + +function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, collapsedIds?: ReadonlySet): SubtreeLayout { + const nodeWidth = measureNodeWidth(node.label, node.sublabel); + const isSubagent = node.kind === 'subagentInvocation'; + const isCollapsed = isSubagent && collapsedIds?.has(node.id); + + const layoutNode: LayoutNode = { + id: node.id, + kind: node.kind, + label: node.label, + sublabel: node.sublabel, + tooltip: node.tooltip, + isError: node.isError, + x: startX, + y: y, + width: nodeWidth, + height: NODE_HEIGHT, + }; + + const result: SubtreeLayout = { + nodes: [layoutNode], + edges: [], + subgraphs: [], + width: nodeWidth, + height: NODE_HEIGHT, + entryNode: layoutNode, + exitNodes: [layoutNode], + }; + + if (node.children.length === 0 && !isCollapsed) { + return result; + } + + // Collapsed subagent: show just the header + a compact badge area + if (isCollapsed) { + const collapsedHeight = SUBGRAPH_HEADER_HEIGHT + SUBGRAPH_PADDING * 2; + const totalChildCount = countDescendants(node); + const sgY = (y + NODE_HEIGHT + NODE_GAP_Y) - NODE_GAP_Y / 2; + const headerLabel = subgraphHeaderLabel(node); + const sgWidth = Math.max(NODE_MIN_WIDTH, measureSubgraphHeaderWidth(headerLabel)) + SUBGRAPH_PADDING * 2; + result.subgraphs.push({ + label: headerLabel, + x: startX - SUBGRAPH_PADDING, + y: sgY, + width: sgWidth, + height: collapsedHeight, + depth, + nodeId: node.id, + collapsedChildCount: totalChildCount, + }); + // Draw a connecting edge from the node to the collapsed subgraph + result.edges.push({ + fromX: startX + nodeWidth / 2, + fromY: y + NODE_HEIGHT, + toX: startX - SUBGRAPH_PADDING + sgWidth / 2, + toY: sgY, + }); + result.width = Math.max(nodeWidth, sgWidth); + result.height = NODE_HEIGHT + NODE_GAP_Y + collapsedHeight; + return result; + } + + if (node.children.length === 0) { + return result; + } + + const childDepth = isSubagent ? depth + 1 : depth; + const indentX = isSubagent ? SUBGRAPH_PADDING : 0; + const groups = groupChildren(node.children); + + let childStartY = y + NODE_HEIGHT + NODE_GAP_Y; + if (isSubagent) { + childStartY += SUBGRAPH_HEADER_HEIGHT; + } + + const { exitNodes, maxWidth, endY } = layoutGroups( + groups, startX + indentX, childStartY, childDepth, [layoutNode], result, collapsedIds, + ); + + const totalChildrenHeight = endY - childStartY - NODE_GAP_Y; + + let sgContentWidth = maxWidth; + if (isSubagent) { + const headerLabel = subgraphHeaderLabel(node); + sgContentWidth = Math.max(maxWidth, measureSubgraphHeaderWidth(headerLabel)); + result.subgraphs.push({ + label: headerLabel, + x: startX - SUBGRAPH_PADDING, + y: (y + NODE_HEIGHT + NODE_GAP_Y) - NODE_GAP_Y / 2, + width: sgContentWidth + SUBGRAPH_PADDING * 2, + height: totalChildrenHeight + SUBGRAPH_HEADER_HEIGHT + NODE_GAP_Y, + depth, + nodeId: node.id, + }); + } + + result.width = Math.max(nodeWidth, maxWidth + indentX * 2, isSubagent ? sgContentWidth + indentX * 2 : 0); + result.height = NODE_HEIGHT + NODE_GAP_Y + totalChildrenHeight + (isSubagent ? SUBGRAPH_HEADER_HEIGHT : 0); + result.exitNodes = exitNodes; + + return result; +} + +function layoutParallelGroup(children: FlowNode[], startX: number, y: number, depth: number, collapsedIds?: ReadonlySet): { + nodes: LayoutNode[]; + edges: LayoutEdge[]; + subgraphs: SubgraphRect[]; + entryNodes: LayoutNode[]; + exitNodes: LayoutNode[]; + width: number; + height: number; +} { + const subtreeLayouts: SubtreeLayout[] = []; + let totalWidth = 0; + let maxHeight = 0; + + for (const child of children) { + const subtree = layoutSubtree(child, 0, y, depth, collapsedIds); + subtreeLayouts.push(subtree); + totalWidth += subtree.width; + maxHeight = Math.max(maxHeight, subtree.height); + } + totalWidth += (children.length - 1) * PARALLEL_GAP_X; + + const nodes: LayoutNode[] = []; + const edges: LayoutEdge[] = []; + const subgraphs: SubgraphRect[] = []; + const entryNodes: LayoutNode[] = []; + const exitNodes: LayoutNode[] = []; + + let currentX = startX; + for (const subtree of subtreeLayouts) { + const dx = currentX; + const offsetNodes = subtree.nodes.map(n => ({ ...n, x: n.x + dx })); + const offsetEdges = subtree.edges.map(e => ({ + fromX: e.fromX + dx, fromY: e.fromY, + toX: e.toX + dx, toY: e.toY, + })); + const offsetSubgraphs = subtree.subgraphs.map(s => ({ ...s, x: s.x + dx })); + + nodes.push(...offsetNodes); + edges.push(...offsetEdges); + subgraphs.push(...offsetSubgraphs); + entryNodes.push(offsetNodes.find(n => n.id === subtree.entryNode.id)!); + + const exitIds = new Set(subtree.exitNodes.map(n => n.id)); + exitNodes.push(...offsetNodes.filter(n => exitIds.has(n.id))); + currentX += subtree.width + PARALLEL_GAP_X; + } + + return { nodes, edges, subgraphs, entryNodes, exitNodes, width: totalWidth, height: maxHeight }; +} + +function centerLayout(layout: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] }, centerX: number): void { + if (layout.nodes.length === 0) { + return; + } + + let minX = Infinity; + let maxX = -Infinity; + for (const node of layout.nodes) { + minX = Math.min(minX, node.x); + maxX = Math.max(maxX, node.x + node.width); + } + const dx = centerX - (minX + maxX) / 2; + + for (let i = 0; i < layout.nodes.length; i++) { + const n = layout.nodes[i]; + (layout.nodes as LayoutNode[])[i] = { ...n, x: n.x + dx }; + } + for (let i = 0; i < layout.edges.length; i++) { + const e = layout.edges[i]; + (layout.edges as LayoutEdge[])[i] = { fromX: e.fromX + dx, fromY: e.fromY, toX: e.toX + dx, toY: e.toY }; + } + for (let i = 0; i < layout.subgraphs.length; i++) { + const s = layout.subgraphs[i]; + (layout.subgraphs as SubgraphRect[])[i] = { ...s, x: s.x + dx }; + } +} + +// ---- SVG Rendering ---- + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +function svgEl(tag: K, attrs: Record): SVGElementTagNameMap[K] { + const el = document.createElementNS(SVG_NS, tag); + for (const [k, v] of Object.entries(attrs)) { + el.setAttribute(k, String(v)); + } + return el; +} + +function getNodeColor(kind: IChatDebugEvent['kind'], isError?: boolean): string { + if (isError) { + return 'var(--vscode-errorForeground)'; + } + switch (kind) { + case 'userMessage': + return 'var(--vscode-textLink-foreground)'; + case 'modelTurn': + return 'var(--vscode-charts-blue, var(--vscode-textLink-foreground))'; + case 'toolCall': + return 'var(--vscode-testing-iconPassed, #73c991)'; + case 'subagentInvocation': + return 'var(--vscode-charts-purple, #b267e6)'; + case 'agentResponse': + return 'var(--vscode-foreground)'; + case 'generic': + return 'var(--vscode-descriptionForeground)'; + } +} + +const SUBGRAPH_COLORS = [ + 'var(--vscode-charts-purple, #b267e6)', + 'var(--vscode-charts-blue, #3dc9b0)', + 'var(--vscode-charts-yellow, #e5c07b)', + 'var(--vscode-charts-orange, #d19a66)', +]; + +export function renderFlowChartSVG(layout: FlowLayout): FlowChartRenderResult { + const focusableElements = new Map(); + const svg = svgEl('svg', { + width: layout.width, + height: layout.height, + viewBox: `0 0 ${layout.width} ${layout.height}`, + role: 'img', + 'aria-label': `Agent flow chart with ${layout.nodes.length} nodes`, + }); + svg.classList.add('chat-debug-flowchart-svg'); + + renderSubgraphs(svg, layout.subgraphs, focusableElements); + renderEdges(svg, layout.edges); + renderNodes(svg, layout.nodes, focusableElements); + + // Sort focusable elements by visual position (top-to-bottom, left-to-right) + // so keyboard navigation follows the flow chart order. + const positionByKey = new Map(); + for (const sg of layout.subgraphs) { + positionByKey.set(`sg:${sg.nodeId}`, { y: sg.y, x: sg.x }); + } + for (const node of layout.nodes) { + positionByKey.set(node.id, { y: node.y, x: node.x }); + } + const sortedFocusable = new Map( + [...focusableElements.entries()].sort((a, b) => { + const posA = positionByKey.get(a[0]); + const posB = positionByKey.get(b[0]); + if (!posA || !posB) { + return 0; + } + return posA.y !== posB.y ? posA.y - posB.y : posA.x - posB.x; + }) + ); + + return { svg, focusableElements: sortedFocusable }; +} + +function renderSubgraphs(svg: SVGElement, subgraphs: readonly SubgraphRect[], focusableElements: Map): void { + for (let sgIdx = 0; sgIdx < subgraphs.length; sgIdx++) { + const sg = subgraphs[sgIdx]; + const color = SUBGRAPH_COLORS[sg.depth % SUBGRAPH_COLORS.length]; + const isCollapsed = sg.collapsedChildCount !== undefined; + const g = document.createElementNS(SVG_NS, 'g'); + g.classList.add('chat-debug-flowchart-subgraph'); + + const rectAttrs = { x: sg.x, y: sg.y, width: sg.width, height: sg.height, rx: NODE_BORDER_RADIUS, ry: NODE_BORDER_RADIUS }; + const clipId = `sg-clip-${sgIdx}`; + + // ClipPath for rounded corners + const clipPath = svgEl('clipPath', { id: clipId }); + clipPath.appendChild(svgEl('rect', rectAttrs)); + svg.appendChild(clipPath); + + // Filled background + g.appendChild(svgEl('rect', { ...rectAttrs, fill: color, opacity: 0.06 + sg.depth * 0.02 })); + + // Dashed border + g.appendChild(svgEl('rect', { ...rectAttrs, fill: 'none', stroke: color, 'stroke-width': 1, 'stroke-dasharray': '6,3', opacity: 0.5 })); + + // Gutter line + g.appendChild(svgEl('rect', { x: sg.x, y: sg.y, width: GUTTER_WIDTH, height: sg.height, fill: color, opacity: 0.7, 'clip-path': `url(#${clipId})` })); + + // Header group (clickable, keyboard accessible) + const headerGroup = document.createElementNS(SVG_NS, 'g'); + headerGroup.setAttribute('data-subgraph-id', sg.nodeId); + headerGroup.classList.add('chat-debug-flowchart-subgraph-header'); + headerGroup.setAttribute('tabindex', '0'); + headerGroup.setAttribute('role', 'button'); + headerGroup.setAttribute('aria-expanded', String(!isCollapsed)); + headerGroup.setAttribute('aria-label', `${sg.label}: ${isCollapsed ? 'collapsed' : 'expanded'}${isCollapsed && sg.collapsedChildCount !== undefined ? `, ${sg.collapsedChildCount} items hidden` : ''}`); + + const headerBar = svgEl('rect', { x: sg.x, y: sg.y, width: sg.width, height: SUBGRAPH_HEADER_HEIGHT, fill: color, opacity: 0.15, 'clip-path': `url(#${clipId})` }); + headerGroup.appendChild(headerBar); + + // Chevron + header label + const chevron = isCollapsed ? '\u25B6' : '\u25BC'; + const headerText = svgEl('text', { + x: sg.x + GUTTER_WIDTH + 8, + y: sg.y + SUBGRAPH_HEADER_HEIGHT / 2 + 4, + 'font-size': SUBLABEL_FONT_SIZE, + fill: color, + 'font-family': 'var(--vscode-font-family, sans-serif)', + 'font-weight': '600', + }); + headerText.textContent = `${chevron} ${sg.label}`; + headerGroup.appendChild(headerText); + g.appendChild(headerGroup); + focusableElements.set(`sg:${sg.nodeId}`, headerGroup as unknown as SVGElement); + + // Collapsed badge + if (isCollapsed && sg.collapsedChildCount !== undefined) { + const badgeText = svgEl('text', { + x: sg.x + sg.width / 2, + y: sg.y + SUBGRAPH_HEADER_HEIGHT + SUBGRAPH_PADDING + 4, + 'font-size': SUBLABEL_FONT_SIZE, + fill: 'var(--vscode-descriptionForeground)', + 'font-family': 'var(--vscode-font-family, sans-serif)', + 'font-style': 'italic', + 'text-anchor': 'middle', + }); + badgeText.textContent = `+${sg.collapsedChildCount} items`; + g.appendChild(badgeText); + } + + svg.appendChild(g); + } +} + +function renderEdges(svg: SVGElement, edges: readonly LayoutEdge[]): void { + const strokeAttrs = { fill: 'none', stroke: 'var(--vscode-descriptionForeground)', 'stroke-width': EDGE_STROKE_WIDTH, 'stroke-linecap': 'round' }; + + for (const edge of edges) { + const midY = (edge.fromY + edge.toY) / 2; + svg.appendChild(svgEl('path', { + ...strokeAttrs, + d: `M ${edge.fromX} ${edge.fromY} C ${edge.fromX} ${midY}, ${edge.toX} ${midY}, ${edge.toX} ${edge.toY}`, + })); + + const a = 5; // arrowhead size + svg.appendChild(svgEl('path', { + ...strokeAttrs, + 'stroke-linejoin': 'round', + d: `M ${edge.toX - a} ${edge.toY - a * 1.5} L ${edge.toX} ${edge.toY} L ${edge.toX + a} ${edge.toY - a * 1.5}`, + })); + } +} + +function renderNodes(svg: SVGElement, nodes: readonly LayoutNode[], focusableElements: Map): void { + const fontFamily = 'var(--vscode-font-family, sans-serif)'; + const nodeFill = 'var(--vscode-editor-background, var(--vscode-editorWidget-background))'; + + for (const node of nodes) { + const g = document.createElementNS(SVG_NS, 'g'); + g.classList.add('chat-debug-flowchart-node'); + g.setAttribute('data-node-id', node.id); + g.setAttribute('tabindex', '0'); + g.setAttribute('role', 'img'); + + const ariaLabel = node.sublabel ? `${node.label}, ${node.sublabel}` : node.label; + g.setAttribute('aria-label', ariaLabel); + focusableElements.set(node.id, g as unknown as SVGElement); + + if (node.tooltip) { + const title = document.createElementNS(SVG_NS, 'title'); + title.textContent = node.tooltip; + g.appendChild(title); + } + + const color = getNodeColor(node.kind, node.isError); + const safeId = node.id.replace(/[^a-zA-Z0-9]/g, '_'); + const rectAttrs = { x: node.x, y: node.y, width: node.width, height: node.height, rx: NODE_BORDER_RADIUS, ry: NODE_BORDER_RADIUS }; + + // Clip path shared by gutter bar and text + const clipId = `clip-${safeId}`; + const clipPath = svgEl('clipPath', { id: clipId }); + clipPath.appendChild(svgEl('rect', rectAttrs)); + svg.appendChild(clipPath); + + // Node rectangle + g.appendChild(svgEl('rect', { ...rectAttrs, fill: nodeFill, stroke: color, 'stroke-width': node.isError ? 2 : 1.5 })); + + // Kind indicator (colored gutter bar) + g.appendChild(svgEl('rect', { x: node.x, y: node.y, width: 4, height: node.height, fill: color, 'clip-path': `url(#${clipId})` })); + + // Label text + const textX = node.x + NODE_PADDING_H; + if (node.sublabel) { + const label = svgEl('text', { x: textX, y: node.y + NODE_PADDING_V + FONT_SIZE, 'font-size': FONT_SIZE, fill: 'var(--vscode-foreground)', 'font-family': fontFamily, 'clip-path': `url(#${clipId})` }); + label.textContent = node.label; + g.appendChild(label); + + const sub = svgEl('text', { x: textX, y: node.y + NODE_HEIGHT - NODE_PADDING_V, 'font-size': SUBLABEL_FONT_SIZE, fill: 'var(--vscode-descriptionForeground)', 'font-family': fontFamily, 'clip-path': `url(#${clipId})` }); + sub.textContent = node.sublabel; + g.appendChild(sub); + } else { + const label = svgEl('text', { x: textX, y: node.y + NODE_HEIGHT / 2 + FONT_SIZE / 2 - 1, 'font-size': FONT_SIZE, fill: 'var(--vscode-foreground)', 'font-family': fontFamily, 'clip-path': `url(#${clipId})` }); + label.textContent = node.label; + g.appendChild(label); + } + + svg.appendChild(g); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts new file mode 100644 index 00000000000..802ca0183af --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts @@ -0,0 +1,165 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { isUUID } from '../../../../../base/common/uuid.js'; +import { localize } from '../../../../../nls.js'; +import { IChatDebugService } from '../../common/chatDebugService.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { IChatWidgetService } from '../chat.js'; + +const $ = DOM.$; + +export class ChatDebugHomeView extends Disposable { + + private readonly _onNavigateToSession = this._register(new Emitter()); + readonly onNavigateToSession = this._onNavigateToSession.event; + + readonly container: HTMLElement; + private readonly scrollContent: HTMLElement; + private readonly scrollable: DomScrollableElement; + private readonly renderDisposables = this._register(new DisposableStore()); + + constructor( + parent: HTMLElement, + @IChatService private readonly chatService: IChatService, + @IChatDebugService private readonly chatDebugService: IChatDebugService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + ) { + super(); + this.container = DOM.append(parent, $('.chat-debug-home')); + this.scrollContent = $('div.chat-debug-home-content'); + this.scrollable = this._register(new DomScrollableElement(this.scrollContent, {})); + DOM.append(this.container, this.scrollable.getDomNode()); + } + + show(): void { + this.container.style.display = ''; + this.render(); + } + + hide(): void { + this.container.style.display = 'none'; + } + + render(): void { + DOM.clearNode(this.scrollContent); + this.renderDisposables.clear(); + + DOM.append(this.scrollContent, $('h2.chat-debug-home-title', undefined, localize('chatDebug.title', "Chat Debug Panel"))); + + // Determine the active session resource + const activeWidget = this.chatWidgetService.lastFocusedWidget; + const activeSessionResource = activeWidget?.viewModel?.sessionResource; + + // List sessions that have debug event data. + // Use the debug service as the source of truth — it includes sessions + // whose chat models may have been archived (e.g. when a new chat was started). + const sessionResources = [...this.chatDebugService.getSessionResources()].reverse(); + + // Sort: active session first + if (activeSessionResource) { + const activeIndex = sessionResources.findIndex(r => r.toString() === activeSessionResource.toString()); + if (activeIndex > 0) { + sessionResources.splice(activeIndex, 1); + sessionResources.unshift(activeSessionResource); + } + } + + DOM.append(this.scrollContent, $('p.chat-debug-home-subtitle', undefined, + sessionResources.length > 0 + ? localize('chatDebug.homeSubtitle', "Select a chat session to debug") + : localize('chatDebug.noSessions', "Send a chat message to get started") + )); + + if (sessionResources.length > 0) { + const sessionList = DOM.append(this.scrollContent, $('.chat-debug-home-session-list')); + sessionList.setAttribute('role', 'list'); + sessionList.setAttribute('aria-label', localize('chatDebug.sessionList', "Chat sessions")); + + const items: HTMLButtonElement[] = []; + + for (const sessionResource of sessionResources) { + const sessionTitle = this.chatService.getSessionTitle(sessionResource) || LocalChatSessionUri.parseLocalSessionId(sessionResource) || sessionResource.toString(); + const isActive = activeSessionResource !== undefined && sessionResource.toString() === activeSessionResource.toString(); + + const item = DOM.append(sessionList, $('button.chat-debug-home-session-item')); + item.setAttribute('role', 'listitem'); + if (isActive) { + item.classList.add('chat-debug-home-session-item-active'); + item.setAttribute('aria-current', 'true'); + } + + DOM.append(item, $(`span${ThemeIcon.asCSSSelector(Codicon.comment)}`)); + + const titleSpan = DOM.append(item, $('span.chat-debug-home-session-item-title')); + 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); + } + + 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); + } + } + + // Arrow key navigation between session items + this.renderDisposables.add(DOM.addDisposableListener(sessionList, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (items.length === 0) { + return; + } + const focused = DOM.getActiveElement() as HTMLElement; + const idx = items.indexOf(focused as HTMLButtonElement); + if (idx === -1) { + return; + } + let nextIdx: number | undefined; + switch (e.key) { + case 'ArrowDown': + nextIdx = idx + 1 < items.length ? idx + 1 : idx; + break; + case 'ArrowUp': + nextIdx = idx - 1 >= 0 ? idx - 1 : idx; + break; + case 'Home': + nextIdx = 0; + break; + case 'End': + nextIdx = items.length - 1; + break; + } + if (nextIdx !== undefined) { + e.preventDefault(); + items[nextIdx].focus(); + } + })); + } + + this.scrollable.scanDomNode(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts new file mode 100644 index 00000000000..5a260445fae --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts @@ -0,0 +1,501 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { Dimension } from '../../../../../base/browser/dom.js'; +import { BreadcrumbsWidget } from '../../../../../base/browser/ui/breadcrumbs/breadcrumbsWidget.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { IObjectTreeElement } from '../../../../../base/browser/ui/tree/tree.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { WorkbenchList, WorkbenchObjectTree } from '../../../../../platform/list/browser/listService.js'; +import { defaultBreadcrumbsWidgetStyles, defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { FilterWidget } from '../../../../browser/parts/views/viewFilter.js'; +import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { ChatDebugEventRenderer, ChatDebugEventDelegate, ChatDebugEventTreeRenderer } from './chatDebugEventList.js'; +import { setupBreadcrumbKeyboardNavigation, TextBreadcrumbItem, LogsViewMode } from './chatDebugTypes.js'; +import { ChatDebugFilterState, bindFilterContextKeys } from './chatDebugFilters.js'; +import { ChatDebugDetailPanel } from './chatDebugDetailPanel.js'; + +const $ = DOM.$; + +export const enum LogsNavigation { + Home = 'home', + Overview = 'overview', +} + +export class ChatDebugLogsView extends Disposable { + + private readonly _onNavigate = this._register(new Emitter()); + readonly onNavigate = this._onNavigate.event; + + readonly container: HTMLElement; + private readonly breadcrumbWidget: BreadcrumbsWidget; + private readonly headerContainer: HTMLElement; + private readonly tableHeader: HTMLElement; + private readonly bodyContainer: HTMLElement; + private readonly listContainer: HTMLElement; + private readonly treeContainer: HTMLElement; + private readonly detailPanel: ChatDebugDetailPanel; + private readonly filterWidget: FilterWidget; + private readonly viewModeToggle: Button; + + private list: WorkbenchList; + private tree: WorkbenchObjectTree; + + private currentSessionResource: URI | undefined; + private logsViewMode: LogsViewMode = LogsViewMode.List; + private events: IChatDebugEvent[] = []; + private currentDimension: Dimension | undefined; + private readonly eventListener = this._register(new MutableDisposable()); + private readonly sessionStateDisposable = this._register(new MutableDisposable()); + private shimmerRow!: HTMLElement; + + constructor( + parent: HTMLElement, + private readonly filterState: ChatDebugFilterState, + @IChatService private readonly chatService: IChatService, + @IChatDebugService private readonly chatDebugService: IChatDebugService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + ) { + super(); + this.container = DOM.append(parent, $('.chat-debug-logs')); + DOM.hide(this.container); + + // Breadcrumb + const breadcrumbContainer = DOM.append(this.container, $('.chat-debug-breadcrumb')); + this.breadcrumbWidget = this._register(new BreadcrumbsWidget(breadcrumbContainer, 3, undefined, Codicon.chevronRight, defaultBreadcrumbsWidgetStyles)); + this._register(setupBreadcrumbKeyboardNavigation(breadcrumbContainer, this.breadcrumbWidget)); + this._register(this.breadcrumbWidget.onDidSelectItem(e => { + if (e.type === 'select' && e.item instanceof TextBreadcrumbItem) { + this.breadcrumbWidget.setSelection(undefined); + const items = this.breadcrumbWidget.getItems(); + const idx = items.indexOf(e.item); + if (idx === 0) { + this._onNavigate.fire(LogsNavigation.Home); + } else if (idx === 1) { + this._onNavigate.fire(LogsNavigation.Overview); + } + } + })); + + // Header (filter) + this.headerContainer = DOM.append(this.container, $('.chat-debug-editor-header')); + + // Scoped context key service for filter menu items + const scopedContextKeyService = this._register(this.contextKeyService.createScoped(this.headerContainer)); + const syncContextKeys = bindFilterContextKeys(this.filterState, scopedContextKeyService); + syncContextKeys(); + + const childInstantiationService = this._register(this.instantiationService.createChild( + new ServiceCollection([IContextKeyService, scopedContextKeyService]) + )); + this.filterWidget = this._register(childInstantiationService.createInstance(FilterWidget, { + placeholder: localize('chatDebug.search', "Filter (e.g. text, !exclude)"), + ariaLabel: localize('chatDebug.filterAriaLabel', "Filter debug events"), + })); + + // View mode toggle + this.viewModeToggle = this._register(new Button(this.headerContainer, { ...defaultButtonStyles, secondary: true, title: localize('chatDebug.toggleViewMode', "Toggle between list and tree view") })); + this.viewModeToggle.element.classList.add('chat-debug-view-mode-toggle'); + this.updateViewModeToggle(); + this._register(this.viewModeToggle.onDidClick(() => { + this.toggleViewMode(); + })); + + const filterContainer = DOM.append(this.headerContainer, $('.viewpane-filter-container')); + filterContainer.appendChild(this.filterWidget.element); + + this._register(this.filterWidget.onDidChangeFilterText(text => { + this.filterState.setTextFilter(text); + })); + + // React to shared filter state changes + this._register(this.filterState.onDidChange(() => { + syncContextKeys(); + this.updateMoreFiltersChecked(); + this.refreshList(); + })); + + // Content wrapper (flex row: main column + detail panel) + const contentContainer = DOM.append(this.container, $('.chat-debug-logs-content')); + + // Main column (table header + list/tree body) + const mainColumn = DOM.append(contentContainer, $('.chat-debug-logs-main')); + + // Table header + this.tableHeader = DOM.append(mainColumn, $('.chat-debug-table-header')); + DOM.append(this.tableHeader, $('span.chat-debug-col-created', undefined, localize('chatDebug.col.created', "Created"))); + DOM.append(this.tableHeader, $('span.chat-debug-col-name', undefined, localize('chatDebug.col.name', "Name"))); + DOM.append(this.tableHeader, $('span.chat-debug-col-details', undefined, localize('chatDebug.col.details', "Details"))); + + // Body container + this.bodyContainer = DOM.append(mainColumn, $('.chat-debug-logs-body')); + + // List container + this.listContainer = DOM.append(this.bodyContainer, $('.chat-debug-list-container')); + + const accessibilityProvider = { + getAriaLabel: (e: IChatDebugEvent) => { + switch (e.kind) { + case 'toolCall': return localize('chatDebug.aria.toolCall', "Tool call: {0}{1}", e.toolName, e.result ? ` (${e.result})` : ''); + case 'modelTurn': return localize('chatDebug.aria.modelTurn', "Model turn: {0}{1}", e.model ?? localize('chatDebug.aria.model', "model"), e.totalTokens ? localize('chatDebug.aria.tokenCount', " {0} tokens", e.totalTokens) : ''); + case 'generic': return `${e.category ? e.category + ': ' : ''}${e.name}: ${e.details ?? ''}`; + case 'subagentInvocation': return localize('chatDebug.aria.subagent', "Subagent: {0}{1}", e.agentName, e.description ? ` - ${e.description}` : ''); + case 'userMessage': return localize('chatDebug.aria.userMessage', "User message: {0}", e.message); + case 'agentResponse': return localize('chatDebug.aria.agentResponse', "Agent response: {0}", e.message); + } + }, + getWidgetAriaLabel: () => localize('chatDebug.ariaLabel', "Chat Debug Events"), + }; + let nextFallbackId = 0; + const fallbackIds = new WeakMap(); + const identityProvider = { + getId: (e: IChatDebugEvent) => { + if (e.id) { + return e.id; + } + let fallback = fallbackIds.get(e); + if (!fallback) { + fallback = `_fallback_${nextFallbackId++}`; + fallbackIds.set(e, fallback); + } + return fallback; + } + }; + + this.list = this._register(this.instantiationService.createInstance( + WorkbenchList, + 'ChatDebugEvents', + this.listContainer, + new ChatDebugEventDelegate(), + [new ChatDebugEventRenderer()], + { identityProvider, accessibilityProvider } + )); + + // Tree container (initially hidden) + this.treeContainer = DOM.append(this.bodyContainer, $('.chat-debug-list-container')); + DOM.hide(this.treeContainer); + + this.tree = this._register(this.instantiationService.createInstance( + WorkbenchObjectTree, + 'ChatDebugEventsTree', + this.treeContainer, + new ChatDebugEventDelegate(), + [new ChatDebugEventTreeRenderer()], + { identityProvider, accessibilityProvider } + )); + + // Shimmer row (positioned right below last row to indicate session is running) + this.shimmerRow = DOM.append(this.bodyContainer, $('.chat-debug-logs-shimmer-row')); + this.shimmerRow.setAttribute('aria-label', localize('chatDebug.loadingMore', "Loading more events…")); + this.shimmerRow.setAttribute('aria-busy', 'true'); + DOM.append(this.shimmerRow, $('span.chat-debug-logs-shimmer-bar')); + DOM.hide(this.shimmerRow); + + // Detail panel (sibling of main column so it aligns with table header) + this.detailPanel = this._register(this.instantiationService.createInstance(ChatDebugDetailPanel, contentContainer)); + this._register(this.detailPanel.onDidHide(() => { + if (this.list.getSelection().length > 0) { + this.list.setSelection([]); + } + if (this.tree.getSelection().length > 0) { + this.tree.setSelection([]); + } + })); + + // Resolve event details on selection + this._register(this.list.onDidChangeSelection(e => { + const selected = e.elements[0]; + if (selected) { + this.detailPanel.show(selected); + } else { + this.detailPanel.hide(); + } + })); + + this._register(this.tree.onDidChangeSelection(e => { + const selected = e.elements[0]; + if (selected) { + this.detailPanel.show(selected); + } else { + this.detailPanel.hide(); + } + })); + } + + setSession(sessionResource: URI): void { + this.currentSessionResource = sessionResource; + } + + show(): void { + DOM.show(this.container); + this.loadEvents(); + this.refreshList(); + } + + hide(): void { + DOM.hide(this.container); + } + + focus(): void { + if (this.logsViewMode === LogsViewMode.Tree) { + this.tree.domFocus(); + } else { + this.list.domFocus(); + } + } + + updateBreadcrumb(): void { + if (!this.currentSessionResource) { + return; + } + const sessionTitle = this.chatService.getSessionTitle(this.currentSessionResource) || LocalChatSessionUri.parseLocalSessionId(this.currentSessionResource) || this.currentSessionResource.toString(); + this.breadcrumbWidget.setItems([ + new TextBreadcrumbItem(localize('chatDebug.title', "Chat Debug Panel"), true), + new TextBreadcrumbItem(sessionTitle, true), + new TextBreadcrumbItem(localize('chatDebug.logs', "Logs")), + ]); + } + + layout(dimension: Dimension): void { + this.currentDimension = dimension; + const breadcrumbHeight = 22; + const headerHeight = this.headerContainer.offsetHeight; + const tableHeaderHeight = this.tableHeader.offsetHeight; + const detailVisible = this.detailPanel.element.style.display !== 'none'; + const detailWidth = detailVisible ? this.detailPanel.element.offsetWidth : 0; + const listHeight = dimension.height - breadcrumbHeight - headerHeight - tableHeaderHeight; + const listWidth = dimension.width - detailWidth; + if (this.logsViewMode === LogsViewMode.Tree) { + this.tree.layout(listHeight, listWidth); + } else { + this.list.layout(listHeight, listWidth); + } + } + + refreshList(): void { + let filtered = this.events; + + // Filter by kind toggles + filtered = filtered.filter(e => this.filterState.isKindVisible(e.kind)); + + // Filter by level toggles + filtered = filtered.filter(e => { + if (e.kind === 'generic') { + switch (e.level) { + case ChatDebugLogLevel.Trace: return this.filterState.filterLevelTrace; + case ChatDebugLogLevel.Info: return this.filterState.filterLevelInfo; + case ChatDebugLogLevel.Warning: return this.filterState.filterLevelWarning; + case ChatDebugLogLevel.Error: return this.filterState.filterLevelError; + } + } + if (e.kind === 'toolCall' && e.result === 'error') { + return this.filterState.filterLevelError; + } + return true; + }); + + // Filter by text search + const filterText = this.filterState.textFilter; + if (filterText) { + const terms = filterText.split(/\s*,\s*/).filter(t => t.length > 0); + const includeTerms = terms.filter(t => !t.startsWith('!')).map(t => t.trim()); + const excludeTerms = terms.filter(t => t.startsWith('!')).map(t => t.slice(1).trim()).filter(t => t.length > 0); + + filtered = filtered.filter(e => { + const matchesText = (term: string): boolean => { + if (e.kind.toLowerCase().includes(term)) { + return true; + } + switch (e.kind) { + case 'toolCall': + return e.toolName.toLowerCase().includes(term) || + (e.input?.toLowerCase().includes(term) ?? false) || + (e.output?.toLowerCase().includes(term) ?? false); + case 'modelTurn': + return (e.model?.toLowerCase().includes(term) ?? false); + case 'generic': + return e.name.toLowerCase().includes(term) || + (e.details?.toLowerCase().includes(term) ?? false) || + (e.category?.toLowerCase().includes(term) ?? false); + case 'subagentInvocation': + return e.agentName.toLowerCase().includes(term) || + (e.description?.toLowerCase().includes(term) ?? false); + case 'userMessage': + return e.message.toLowerCase().includes(term) || + e.sections.some(s => s.name.toLowerCase().includes(term) || s.content.toLowerCase().includes(term)); + case 'agentResponse': + return e.message.toLowerCase().includes(term) || + e.sections.some(s => s.name.toLowerCase().includes(term) || s.content.toLowerCase().includes(term)); + } + }; + + // Exclude terms: if any exclude term matches, filter out the event + if (excludeTerms.some(term => matchesText(term))) { + return false; + } + // Include terms: if present, at least one must match + if (includeTerms.length > 0) { + return includeTerms.some(term => matchesText(term)); + } + return true; + }); + } + + if (this.logsViewMode === LogsViewMode.List) { + this.list.splice(0, this.list.length, filtered); + } else { + this.refreshTree(filtered); + } + this.updateShimmerPosition(filtered.length); + } + + private updateShimmerPosition(itemCount: number): void { + this.shimmerRow.style.top = `${itemCount * 28}px`; + } + + addEvent(event: IChatDebugEvent): void { + this.events.push(event); + this.refreshList(); + } + + private loadEvents(): void { + this.events = [...this.chatDebugService.getEvents(this.currentSessionResource || undefined)]; + this.eventListener.value = this.chatDebugService.onDidAddEvent(e => { + if (!this.currentSessionResource || e.sessionResource.toString() === this.currentSessionResource.toString()) { + this.events.push(e); + this.refreshList(); + } + }); + this.updateBreadcrumb(); + this.trackSessionState(); + } + + private trackSessionState(): void { + if (!this.currentSessionResource) { + DOM.hide(this.shimmerRow); + this.sessionStateDisposable.clear(); + return; + } + + const model = this.chatService.getSession(this.currentSessionResource); + if (!model) { + DOM.hide(this.shimmerRow); + this.sessionStateDisposable.clear(); + return; + } + + this.sessionStateDisposable.value = autorun(reader => { + const inProgress = model.requestInProgress.read(reader); + if (inProgress) { + DOM.show(this.shimmerRow); + } else { + DOM.hide(this.shimmerRow); + } + }); + } + + private refreshTree(filtered: IChatDebugEvent[]): void { + const treeElements = this.buildTreeHierarchy(filtered); + this.tree.setChildren(null, treeElements); + } + + private buildTreeHierarchy(events: IChatDebugEvent[]): IObjectTreeElement[] { + const idToEvent = new Map(); + const idToChildren = new Map(); + const roots: IChatDebugEvent[] = []; + + for (const event of events) { + if (event.id) { + idToEvent.set(event.id, event); + } + } + + for (const event of events) { + if (event.parentEventId && idToEvent.has(event.parentEventId)) { + let children = idToChildren.get(event.parentEventId); + if (!children) { + children = []; + idToChildren.set(event.parentEventId, children); + } + children.push(event); + } else { + roots.push(event); + } + } + + const toTreeElement = (event: IChatDebugEvent): IObjectTreeElement => { + const children = event.id ? idToChildren.get(event.id) : undefined; + return { + element: event, + children: children?.map(toTreeElement), + collapsible: (children?.length ?? 0) > 0, + collapsed: false, + }; + }; + + return roots.map(toTreeElement); + } + + private toggleViewMode(): void { + if (this.logsViewMode === LogsViewMode.List) { + this.logsViewMode = LogsViewMode.Tree; + DOM.hide(this.listContainer); + DOM.show(this.treeContainer); + } else { + this.logsViewMode = LogsViewMode.List; + DOM.show(this.listContainer); + DOM.hide(this.treeContainer); + } + this.updateViewModeToggle(); + this.refreshList(); + if (this.currentDimension) { + this.layout(this.currentDimension); + } + } + + private updateViewModeToggle(): void { + const el = this.viewModeToggle.element; + DOM.clearNode(el); + const isTree = this.logsViewMode === LogsViewMode.Tree; + DOM.append(el, $(`span${ThemeIcon.asCSSSelector(isTree ? Codicon.listTree : Codicon.listFlat)}`)); + + const labelContainer = DOM.append(el, $('span.chat-debug-view-mode-labels')); + const treeLabel = DOM.append(labelContainer, $('span.chat-debug-view-mode-label')); + treeLabel.textContent = localize('chatDebug.treeView', "Tree View"); + const listLabel = DOM.append(labelContainer, $('span.chat-debug-view-mode-label')); + listLabel.textContent = localize('chatDebug.listView', "List View"); + + if (isTree) { + listLabel.classList.add('hidden'); + } else { + treeLabel.classList.add('hidden'); + } + + const activeLabel = isTree + ? localize('chatDebug.switchToListView', "Switch to List View") + : localize('chatDebug.switchToTreeView', "Switch to Tree View"); + el.setAttribute('aria-label', activeLabel); + this.viewModeToggle.setTitle(activeLabel); + } + + private updateMoreFiltersChecked(): void { + this.filterWidget.checkMoreFilters(!this.filterState.isAllFiltersDefault()); + } + + +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugMessageContentRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugMessageContentRenderer.ts new file mode 100644 index 00000000000..9d2b717a8e0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugMessageContentRenderer.ts @@ -0,0 +1,196 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { localize } from '../../../../../nls.js'; +import { IChatDebugMessageSection, IChatDebugUserMessageEvent, IChatDebugAgentResponseEvent, IChatDebugEventMessageContent } from '../../common/chatDebugService.js'; + +const $ = DOM.$; + +/** + * Wire up a collapsible toggle on a chevron+header+content triple. + * Handles icon switching and display toggling. + */ +export function setupCollapsibleToggle(chevron: HTMLElement, header: HTMLElement, contentEl: HTMLElement, disposables: DisposableStore, initiallyCollapsed: boolean = false): void { + let collapsed = initiallyCollapsed; + + // Accessibility: make header keyboard-focusable and expose toggle semantics + header.tabIndex = 0; + header.role = 'button'; + chevron.setAttribute('aria-hidden', 'true'); + + const updateState = () => { + DOM.clearNode(chevron); + const icon = collapsed ? Codicon.chevronRight : Codicon.chevronDown; + chevron.classList.add(...ThemeIcon.asClassName(icon).split(' ')); + contentEl.style.display = collapsed ? 'none' : 'block'; + header.style.borderRadius = collapsed ? '' : '3px 3px 0 0'; + header.setAttribute('aria-expanded', String(!collapsed)); + }; + + updateState(); + + disposables.add(DOM.addDisposableListener(header, DOM.EventType.CLICK, () => { + collapsed = !collapsed; + chevron.className = 'chat-debug-message-section-chevron'; + updateState(); + })); + + disposables.add(DOM.addDisposableListener(header, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + header.click(); + } + })); +} + +/** + * Render a collapsible section with a clickable header and pre-formatted content. + */ +function renderCollapsibleSection(parent: HTMLElement, section: IChatDebugMessageSection, disposables: DisposableStore, initiallyCollapsed: boolean = false): void { + const sectionEl = DOM.append(parent, $('div.chat-debug-message-section')); + + const header = DOM.append(sectionEl, $('div.chat-debug-message-section-header')); + + const chevron = DOM.append(header, $(`span.chat-debug-message-section-chevron`)); + DOM.append(header, $('span.chat-debug-message-section-title', undefined, section.name)); + + const contentEl = $('pre.chat-debug-message-section-content'); + contentEl.textContent = section.content; + contentEl.tabIndex = 0; + + const scrollable = new DomScrollableElement(contentEl, {}); + disposables.add(scrollable); + + const wrapper = scrollable.getDomNode(); + wrapper.classList.add('chat-debug-message-section-content-wrapper'); + DOM.append(sectionEl, wrapper); + + setupCollapsibleToggle(chevron, header, wrapper, disposables, initiallyCollapsed); + + // Scan after toggle so scrollbar dimensions are correct when expanded + disposables.add(DOM.addDisposableListener(header, DOM.EventType.CLICK, () => { + scrollable.scanDomNode(); + })); +} + +/** + * Render a user message event with collapsible prompt sections. + */ +export function renderUserMessageContent(event: IChatDebugUserMessageEvent): { element: HTMLElement; disposables: DisposableStore } { + const disposables = new DisposableStore(); + const container = $('div.chat-debug-message-content'); + container.tabIndex = 0; + + DOM.append(container, $('div.chat-debug-message-content-title', undefined, localize('chatDebug.userMessage', "User Message"))); + DOM.append(container, $('div.chat-debug-message-content-summary', undefined, event.message)); + + if (event.sections.length > 0) { + const sectionsContainer = DOM.append(container, $('div.chat-debug-message-sections')); + DOM.append(sectionsContainer, $('div.chat-debug-message-sections-label', undefined, + localize('chatDebug.promptSections', "Prompt Sections ({0})", event.sections.length))); + + for (const section of event.sections) { + renderCollapsibleSection(sectionsContainer, section, disposables); + } + } + + return { element: container, disposables }; +} + +/** + * Render an agent response event with collapsible response sections. + */ +export function renderAgentResponseContent(event: IChatDebugAgentResponseEvent): { element: HTMLElement; disposables: DisposableStore } { + const disposables = new DisposableStore(); + const container = $('div.chat-debug-message-content'); + container.tabIndex = 0; + + DOM.append(container, $('div.chat-debug-message-content-title', undefined, localize('chatDebug.agentResponse', "Agent Response"))); + DOM.append(container, $('div.chat-debug-message-content-summary', undefined, event.message)); + + if (event.sections.length > 0) { + const sectionsContainer = DOM.append(container, $('div.chat-debug-message-sections')); + DOM.append(sectionsContainer, $('div.chat-debug-message-sections-label', undefined, + localize('chatDebug.responseSections', "Response Sections ({0})", event.sections.length))); + + for (const section of event.sections) { + renderCollapsibleSection(sectionsContainer, section, disposables); + } + } + + return { element: container, disposables }; +} + +/** + * Convert a user message or agent response event to plain text for clipboard / editor output. + */ +export function messageEventToPlainText(event: IChatDebugUserMessageEvent | IChatDebugAgentResponseEvent): string { + const lines: string[] = []; + const label = event.kind === 'userMessage' ? localize('chatDebug.userMessage', "User Message") : localize('chatDebug.agentResponse', "Agent Response"); + lines.push(`${label}: ${event.message}`); + lines.push(''); + + for (const section of event.sections) { + lines.push(`--- ${section.name} ---`); + lines.push(section.content); + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Render a resolved message content (from resolveChatDebugLogEvent) with collapsible sections. + */ +export function renderResolvedMessageContent(content: IChatDebugEventMessageContent): { element: HTMLElement; disposables: DisposableStore } { + const disposables = new DisposableStore(); + const container = $('div.chat-debug-message-content'); + container.tabIndex = 0; + + const title = content.type === 'user' + ? localize('chatDebug.userMessage', "User Message") + : localize('chatDebug.agentResponse', "Agent Response"); + DOM.append(container, $('div.chat-debug-message-content-title', undefined, title)); + DOM.append(container, $('div.chat-debug-message-content-summary', undefined, content.message)); + + if (content.sections.length > 0) { + const sectionsContainer = DOM.append(container, $('div.chat-debug-message-sections')); + const label = content.type === 'user' + ? localize('chatDebug.promptSections', "Prompt Sections ({0})", content.sections.length) + : localize('chatDebug.responseSections', "Response Sections ({0})", content.sections.length); + DOM.append(sectionsContainer, $('div.chat-debug-message-sections-label', undefined, label)); + + for (const section of content.sections) { + renderCollapsibleSection(sectionsContainer, section, disposables); + } + } + + return { element: container, disposables }; +} + +/** + * Convert a resolved message content to plain text. + */ +export function resolvedMessageToPlainText(content: IChatDebugEventMessageContent): string { + const lines: string[] = []; + const label = content.type === 'user' + ? localize('chatDebug.userMessage', "User Message") + : localize('chatDebug.agentResponse', "Agent Response"); + lines.push(`${label}: ${content.message}`); + lines.push(''); + + for (const section of content.sections) { + lines.push(`--- ${section.name} ---`); + lines.push(section.content); + lines.push(''); + } + + return lines.join('\n'); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts new file mode 100644 index 00000000000..87a59577dc1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts @@ -0,0 +1,292 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { BreadcrumbsWidget } from '../../../../../base/browser/ui/breadcrumbs/breadcrumbsWidget.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { defaultBreadcrumbsWidgetStyles, defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { ChatAgentLocation } from '../../common/constants.js'; +import { IChatSessionsService } from '../../common/chatSessionsService.js'; +import { getChatSessionType, LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { IChatWidgetService } from '../chat.js'; +import { setupBreadcrumbKeyboardNavigation, TextBreadcrumbItem } from './chatDebugTypes.js'; + +const $ = DOM.$; + +export const enum OverviewNavigation { + Home = 'home', + Logs = 'logs', + FlowChart = 'flowchart', +} + +export class ChatDebugOverviewView extends Disposable { + + private readonly _onNavigate = this._register(new Emitter()); + readonly onNavigate = this._onNavigate.event; + + readonly container: HTMLElement; + private readonly content: HTMLElement; + private readonly scrollable: DomScrollableElement; + private readonly breadcrumbWidget: BreadcrumbsWidget; + private readonly loadDisposables = this._register(new DisposableStore()); + + private currentSessionResource: URI | undefined; + private metricsContainer: HTMLElement | undefined; + private isFirstLoad: boolean = true; + + constructor( + parent: HTMLElement, + @IChatService private readonly chatService: IChatService, + @IChatDebugService private readonly chatDebugService: IChatDebugService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + ) { + super(); + this.container = DOM.append(parent, $('.chat-debug-overview')); + DOM.hide(this.container); + + // Breadcrumb + const breadcrumbContainer = DOM.append(this.container, $('.chat-debug-breadcrumb')); + this.breadcrumbWidget = this._register(new BreadcrumbsWidget(breadcrumbContainer, 3, undefined, Codicon.chevronRight, defaultBreadcrumbsWidgetStyles)); + this._register(setupBreadcrumbKeyboardNavigation(breadcrumbContainer, this.breadcrumbWidget)); + this._register(this.breadcrumbWidget.onDidSelectItem(e => { + if (e.type === 'select' && e.item instanceof TextBreadcrumbItem) { + this.breadcrumbWidget.setSelection(undefined); + const items = this.breadcrumbWidget.getItems(); + const idx = items.indexOf(e.item); + if (idx === 0) { + this._onNavigate.fire(OverviewNavigation.Home); + } + } + })); + + this.content = $('.chat-debug-overview-content'); + this.scrollable = this._register(new DomScrollableElement(this.content, {})); + const scrollDom = this.scrollable.getDomNode(); + scrollDom.style.flex = '1'; + scrollDom.style.minHeight = '0'; + DOM.append(this.container, scrollDom); + } + + setSession(sessionResource: URI): void { + this.currentSessionResource = sessionResource; + this.isFirstLoad = true; + } + + show(): void { + DOM.show(this.container); + this.load(); + } + + hide(): void { + DOM.hide(this.container); + } + + refresh(): void { + if (this.container.style.display !== 'none') { + // On refresh, only update the metrics section in-place + if (this.metricsContainer && this.currentSessionResource) { + DOM.clearNode(this.metricsContainer); + const events = this.chatDebugService.getEvents(this.currentSessionResource); + this.renderMetricsContent(this.metricsContainer, events); + this.isFirstLoad = false; + } else { + this.load(); + } + } + } + + updateBreadcrumb(): void { + if (!this.currentSessionResource) { + return; + } + const sessionTitle = this.chatService.getSessionTitle(this.currentSessionResource) || LocalChatSessionUri.parseLocalSessionId(this.currentSessionResource) || this.currentSessionResource.toString(); + this.breadcrumbWidget.setItems([ + new TextBreadcrumbItem(localize('chatDebug.title', "Chat Debug Panel"), true), + new TextBreadcrumbItem(sessionTitle), + ]); + } + + private load(): void { + DOM.clearNode(this.content); + this.loadDisposables.clear(); + this.updateBreadcrumb(); + + if (!this.currentSessionResource) { + return; + } + + const sessionTitle = this.chatService.getSessionTitle(this.currentSessionResource) || LocalChatSessionUri.parseLocalSessionId(this.currentSessionResource) || this.currentSessionResource.toString(); + + const titleRow = DOM.append(this.content, $('.chat-debug-overview-title-row')); + const titleEl = DOM.append(titleRow, $('h2.chat-debug-overview-title')); + DOM.append(titleEl, $(`span${ThemeIcon.asCSSSelector(Codicon.comment)}`)); + titleEl.append(sessionTitle); + + const titleActions = DOM.append(titleRow, $('.chat-debug-overview-title-actions')); + + const revealSessionBtn = this.loadDisposables.add(new Button(titleActions, { ariaLabel: localize('chatDebug.revealChatSession', "Reveal Chat Session"), title: localize('chatDebug.revealChatSession', "Reveal Chat Session") })); + revealSessionBtn.element.classList.add('chat-debug-icon-button'); + revealSessionBtn.icon = Codicon.goToFile; + this.loadDisposables.add(revealSessionBtn.onDidClick(() => { + if (this.currentSessionResource) { + this.chatWidgetService.openSession(this.currentSessionResource); + } + })); + + // Session details section + this.renderSessionDetails(this.currentSessionResource); + + // Derived overview metrics + const events = this.chatDebugService.getEvents(this.currentSessionResource); + this.renderDerivedOverview(events, this.isFirstLoad); + this.isFirstLoad = false; + this.scrollable.scanDomNode(); + } + + private renderSessionDetails(sessionUri: URI): void { + const model = this.chatService.getSession(sessionUri); + + interface DetailItem { label: string; value: string } + const details: DetailItem[] = []; + + // Session type + const sessionType = getChatSessionType(sessionUri); + const contribution = this.chatSessionsService.getChatSessionContribution(sessionType); + const sessionTypeName = contribution?.displayName || (sessionType === 'local' + ? localize('chatDebug.sessionType.local', "Local") + : sessionType); + details.push({ label: localize('chatDebug.detail.sessionType', "Session Type"), value: sessionTypeName }); + + if (model) { + const locationLabel = this.getLocationLabel(model.initialLocation); + details.push({ label: localize('chatDebug.detail.location', "Location"), value: locationLabel }); + + const inProgress = model.requestInProgress.get(); + const statusLabel = inProgress + ? localize('chatDebug.status.inProgress', "In Progress") + : localize('chatDebug.status.idle', "Idle"); + details.push({ label: localize('chatDebug.detail.status', "Status"), value: statusLabel }); + + const timing = model.timing; + details.push({ label: localize('chatDebug.detail.created', "Created"), value: new Date(timing.created).toLocaleString() }); + + if (timing.lastRequestEnded) { + details.push({ label: localize('chatDebug.detail.lastActivity', "Last Activity"), value: new Date(timing.lastRequestEnded).toLocaleString() }); + } else if (timing.lastRequestStarted) { + details.push({ label: localize('chatDebug.detail.lastActivity', "Last Activity"), value: new Date(timing.lastRequestStarted).toLocaleString() }); + } + } + + if (details.length > 0) { + const section = DOM.append(this.content, $('.chat-debug-overview-section')); + DOM.append(section, $('h3.chat-debug-overview-section-label', undefined, localize('chatDebug.sessionDetails', "Session Details"))); + + const detailsGrid = DOM.append(section, $('.chat-debug-overview-details')); + for (const detail of details) { + const row = DOM.append(detailsGrid, $('.chat-debug-overview-detail-row')); + DOM.append(row, $('span.chat-debug-overview-detail-label', undefined, detail.label)); + DOM.append(row, $('span.chat-debug-overview-detail-value', undefined, detail.value)); + } + } + } + + private getLocationLabel(location: ChatAgentLocation): string { + switch (location) { + case ChatAgentLocation.Chat: return localize('chatDebug.location.chat', "Chat Panel"); + case ChatAgentLocation.Terminal: return localize('chatDebug.location.terminal', "Terminal"); + case ChatAgentLocation.Notebook: return localize('chatDebug.location.notebook', "Notebook"); + case ChatAgentLocation.EditorInline: return localize('chatDebug.location.editor', "Editor Inline"); + default: return String(location); + } + } + + private renderDerivedOverview(events: readonly IChatDebugEvent[], showShimmer: boolean): void { + const metricsSection = DOM.append(this.content, $('.chat-debug-overview-section')); + DOM.append(metricsSection, $('h3.chat-debug-overview-section-label', undefined, localize('chatDebug.summary', "Summary"))); + + this.metricsContainer = DOM.append(metricsSection, $('.chat-debug-overview-metrics')); + + if (showShimmer) { + this.renderMetricsShimmer(this.metricsContainer); + } else { + this.renderMetricsContent(this.metricsContainer, events); + } + + // Explore actions + const actionsSection = DOM.append(this.content, $('.chat-debug-overview-section')); + DOM.append(actionsSection, $('h3.chat-debug-overview-section-label', undefined, localize('chatDebug.exploreTraceData', "Explore Trace Data"))); + + const row = DOM.append(actionsSection, $('.chat-debug-overview-actions')); + + const viewLogsBtn = this.loadDisposables.add(new Button(row, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: localize('chatDebug.viewLogs', "View Logs") })); + viewLogsBtn.element.classList.add('chat-debug-overview-action-button'); + viewLogsBtn.label = `$(list-flat) ${localize('chatDebug.viewLogs', "View Logs")}`; + this.loadDisposables.add(viewLogsBtn.onDidClick(() => { + this._onNavigate.fire(OverviewNavigation.Logs); + })); + + const flowChartBtn = this.loadDisposables.add(new Button(row, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: localize('chatDebug.agentFlowChart', "Agent Flow Chart") })); + flowChartBtn.element.classList.add('chat-debug-overview-action-button'); + flowChartBtn.label = `$(type-hierarchy) ${localize('chatDebug.agentFlowChart', "Agent Flow Chart")}`; + this.loadDisposables.add(flowChartBtn.onDidClick(() => { + this._onNavigate.fire(OverviewNavigation.FlowChart); + })); + + } + + private renderMetricsShimmer(container: HTMLElement): void { + // Show placeholder shimmer cards while provider data is loading + const placeholderLabels = [ + localize('chatDebug.metric.modelTurns', "Model Turns"), + localize('chatDebug.metric.toolCalls', "Tool Calls"), + localize('chatDebug.metric.totalTokens', "Total Tokens"), + localize('chatDebug.metric.errors', "Errors"), + localize('chatDebug.metric.totalEvents', "Total Events"), + ]; + for (const label of placeholderLabels) { + const card = DOM.append(container, $('.chat-debug-overview-metric-card')); + DOM.append(card, $('div.chat-debug-overview-metric-label', undefined, label)); + const valueEl = DOM.append(card, $('div.chat-debug-overview-metric-value')); + const shimmer = DOM.append(valueEl, $('span.chat-debug-overview-metric-shimmer')); + shimmer.textContent = '\u00A0'; // non-breaking space for height + } + } + + private renderMetricsContent(container: HTMLElement, events: readonly IChatDebugEvent[]): void { + const modelTurns = events.filter(e => e.kind === 'modelTurn'); + const toolCalls = events.filter(e => e.kind === 'toolCall'); + const errors = events.filter(e => + (e.kind === 'generic' && e.level === ChatDebugLogLevel.Error) || + (e.kind === 'toolCall' && e.result === 'error') + ); + + const totalTokens = modelTurns.reduce((sum, e) => sum + (e.totalTokens ?? 0), 0); + + interface OverviewMetric { label: string; value: string } + const metrics: OverviewMetric[] = [ + { label: localize('chatDebug.metric.modelTurns', "Model Turns"), value: String(modelTurns.length) }, + { label: localize('chatDebug.metric.toolCalls', "Tool Calls"), value: String(toolCalls.length) }, + { label: localize('chatDebug.metric.totalTokens', "Total Tokens"), value: totalTokens.toLocaleString() }, + { label: localize('chatDebug.metric.errors', "Errors"), value: String(errors.length) }, + { label: localize('chatDebug.metric.totalEvents', "Total Events"), value: String(events.length) }, + ]; + + for (const metric of metrics) { + const card = DOM.append(container, $('.chat-debug-overview-metric-card')); + DOM.append(card, $('div.chat-debug-overview-metric-label', undefined, metric.label)); + DOM.append(card, $('div.chat-debug-overview-metric-value', undefined, metric.value)); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts new file mode 100644 index 00000000000..aab9e46a938 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { BreadcrumbsItem, BreadcrumbsWidget } from '../../../../../base/browser/ui/breadcrumbs/breadcrumbsWidget.js'; +import { IDisposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IEditorOptions } from '../../../../../platform/editor/common/editor.js'; + +const $ = DOM.$; + +/** + * Options passed to the chat debug editor pane to control + * which session and view to navigate to. + */ +export interface IChatDebugEditorOptions extends IEditorOptions { + readonly sessionResource?: URI; + readonly viewHint?: 'home' | 'overview' | 'logs' | 'flowchart'; +} + +export const enum ViewState { + Home = 'home', + Overview = 'overview', + Logs = 'logs', + FlowChart = 'flowchart', +} + +export const enum LogsViewMode { + List = 'list', + Tree = 'tree', +} + +export const CHAT_DEBUG_FILTER_ACTIVE = new RawContextKey('chatDebugFilterActive', false); +export const CHAT_DEBUG_KIND_TOOL_CALL = new RawContextKey('chatDebug.kindToolCall', true); +export const CHAT_DEBUG_KIND_MODEL_TURN = new RawContextKey('chatDebug.kindModelTurn', true); +export const CHAT_DEBUG_KIND_GENERIC = new RawContextKey('chatDebug.kindGeneric', true); +export const CHAT_DEBUG_KIND_SUBAGENT = new RawContextKey('chatDebug.kindSubagent', true); +export const CHAT_DEBUG_KIND_USER_MESSAGE = new RawContextKey('chatDebug.kindUserMessage', true); +export const CHAT_DEBUG_KIND_AGENT_RESPONSE = new RawContextKey('chatDebug.kindAgentResponse', true); +export const CHAT_DEBUG_LEVEL_TRACE = new RawContextKey('chatDebug.levelTrace', true); +export const CHAT_DEBUG_LEVEL_INFO = new RawContextKey('chatDebug.levelInfo', true); +export const CHAT_DEBUG_LEVEL_WARNING = new RawContextKey('chatDebug.levelWarning', true); +export const CHAT_DEBUG_LEVEL_ERROR = new RawContextKey('chatDebug.levelError', true); + +// Filter toggle command IDs +export const CHAT_DEBUG_CMD_TOGGLE_TOOL_CALL = 'chatDebug.filter.toggleToolCall'; +export const CHAT_DEBUG_CMD_TOGGLE_MODEL_TURN = 'chatDebug.filter.toggleModelTurn'; +export const CHAT_DEBUG_CMD_TOGGLE_GENERIC = 'chatDebug.filter.toggleGeneric'; +export const CHAT_DEBUG_CMD_TOGGLE_SUBAGENT = 'chatDebug.filter.toggleSubagent'; +export const CHAT_DEBUG_CMD_TOGGLE_USER_MESSAGE = 'chatDebug.filter.toggleUserMessage'; +export const CHAT_DEBUG_CMD_TOGGLE_AGENT_RESPONSE = 'chatDebug.filter.toggleAgentResponse'; +export const CHAT_DEBUG_CMD_TOGGLE_TRACE = 'chatDebug.filter.toggleTrace'; +export const CHAT_DEBUG_CMD_TOGGLE_INFO = 'chatDebug.filter.toggleInfo'; +export const CHAT_DEBUG_CMD_TOGGLE_WARNING = 'chatDebug.filter.toggleWarning'; +export const CHAT_DEBUG_CMD_TOGGLE_ERROR = 'chatDebug.filter.toggleError'; + +export class TextBreadcrumbItem extends BreadcrumbsItem { + constructor( + private readonly _text: string, + private readonly _isLink: boolean = false, + ) { + super(); + } + + equals(other: BreadcrumbsItem): boolean { + return other instanceof TextBreadcrumbItem && other._text === this._text; + } + + dispose(): void { + // Nothing to dispose + } + + render(container: HTMLElement): void { + container.classList.add('chat-debug-breadcrumb-item'); + if (this._isLink) { + container.classList.add('chat-debug-breadcrumb-item-link'); + } + DOM.append(container, $('span.chat-debug-breadcrumb-item-label', undefined, this._text)); + } +} + +/** + * Wire up Left/Right arrow, Home/End, and Enter keyboard navigation + * on a BreadcrumbsWidget container. + */ +export function setupBreadcrumbKeyboardNavigation(container: HTMLElement, widget: BreadcrumbsWidget): IDisposable { + return DOM.addDisposableListener(container, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + widget.focusPrev(); + break; + case 'ArrowRight': + e.preventDefault(); + widget.focusNext(); + break; + case 'Home': + e.preventDefault(); + widget.setFocused(widget.getItems()[0]); + break; + case 'End': { + e.preventDefault(); + const items = widget.getItems(); + widget.setFocused(items[items.length - 1]); + break; + } + case 'Enter': + case ' ': { + e.preventDefault(); + const focused = widget.getFocused(); + if (focused) { + widget.setSelection(focused); + } + break; + } + } + }); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css new file mode 100644 index 00000000000..8eee2975cf6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css @@ -0,0 +1,759 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-debug-editor { + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ---- Home view ---- */ +.chat-debug-home { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} +.chat-debug-home-content { + display: flex; + flex-direction: column; + align-items: center; + padding: 48px 24px; +} +.chat-debug-home-title { + font-size: 18px; + font-weight: 600; + margin: 0 0 8px; +} +.chat-debug-home-subtitle { + font-size: 13px; + color: var(--vscode-descriptionForeground); + margin: 0 0 24px; +} +.chat-debug-home-empty { + font-size: 13px; + color: var(--vscode-descriptionForeground); + margin: 0; +} +.chat-debug-home-session-list { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + max-width: 400px; +} +.chat-debug-home-session-item { + display: flex; + align-items: center; + width: 100%; + text-align: left; + padding: 8px 12px; + border: 1px solid var(--vscode-widget-border, transparent); + background: transparent; + color: var(--vscode-foreground); + border-radius: 4px; + cursor: pointer; + font-size: 13px; + gap: 8px; +} +.chat-debug-home-session-item:hover { + background: var(--vscode-list-hoverBackground); +} +.chat-debug-home-session-item-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chat-debug-home-session-item-shimmer { + height: 14px; + min-width: 160px; + border-radius: 3px; + background: linear-gradient( + 90deg, + var(--vscode-descriptionForeground) 25%, + var(--vscode-chat-thinkingShimmer, rgba(255, 255, 255, 0.3)) 50%, + var(--vscode-descriptionForeground) 75% + ); + background-size: 200% 100%; + animation: chat-debug-shimmer 2s linear infinite; + opacity: 0.15; +} +.chat-debug-home-session-badge { + flex-shrink: 0; + padding: 2px 8px; + border-radius: 10px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: 11px; + font-weight: 500; +} + +@keyframes chat-debug-shimmer { + 0% { background-position: 120% 0; } + 100% { background-position: -120% 0; } +} + +/* ---- Breadcrumb ---- */ +.chat-debug-breadcrumb { + flex-shrink: 0; + border-bottom: 1px solid var(--vscode-widget-border, transparent); +} +.chat-debug-breadcrumb .monaco-breadcrumbs { + height: 22px; +} +.chat-debug-breadcrumb .monaco-breadcrumb-item { + display: flex; + align-items: center; + font-size: 12px; +} +.chat-debug-breadcrumb .monaco-breadcrumb-item::before { + width: 16px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; +} +.chat-debug-breadcrumb-item-link { + cursor: pointer; +} +.chat-debug-breadcrumb .monaco-breadcrumb-item:last-child .codicon:last-child { + display: none; +} + +/* ---- Overview view ---- */ +.chat-debug-overview { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; +} +.chat-debug-overview-content { + padding: 16px 24px; +} +.chat-debug-overview-title-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} +.chat-debug-overview-title { + font-size: 16px; + font-weight: 600; + margin: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: flex; + align-items: center; + gap: 6px; +} +.chat-debug-overview-title-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} +.chat-debug-overview-section { + margin-bottom: 24px; +} +.chat-debug-overview-section-label { + font-size: 13px; + font-weight: 600; + margin: 0 0 10px; + color: var(--vscode-foreground); +} +.chat-debug-overview-metrics { + display: flex; + gap: 12px; + flex-wrap: wrap; +} +.chat-debug-overview-metric-card { + border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border)); + border-radius: 4px; + padding: 12px 16px; + min-width: 120px; +} +.chat-debug-overview-metric-label { + font-size: 11px; + color: var(--vscode-descriptionForeground); + margin-bottom: 4px; +} +.chat-debug-overview-metric-value { + font-size: 16px; + font-weight: 600; +} +.chat-debug-overview-metric-shimmer { + display: inline-block; + height: 16px; + min-width: 48px; + border-radius: 3px; + background: linear-gradient( + 90deg, + var(--vscode-descriptionForeground) 25%, + var(--vscode-chat-thinkingShimmer, rgba(255, 255, 255, 0.3)) 50%, + var(--vscode-descriptionForeground) 75% + ); + background-size: 200% 100%; + animation: chat-debug-shimmer 2s linear infinite; + opacity: 0.15; +} +.chat-debug-overview-details { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 16px; + font-size: 13px; +} +.chat-debug-overview-detail-row { + display: contents; +} +.chat-debug-overview-detail-label { + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} +.chat-debug-overview-detail-value { + color: var(--vscode-foreground); +} +.chat-debug-overview-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.chat-debug-overview-action-button.monaco-button { + width: auto; +} +.chat-debug-icon-button.monaco-button { + width: auto; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 26px; + height: 26px; + border: none; + background: transparent; + color: var(--vscode-foreground); + border-radius: 4px; + cursor: pointer; + opacity: 0.7; + flex-shrink: 0; + padding: 0; +} +.chat-debug-icon-button.monaco-button:hover { + opacity: 1; + background: var(--vscode-toolbar-hoverBackground); +} +.chat-debug-overview-action-button-primary { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} +.chat-debug-overview-action-button-primary:hover { + background: var(--vscode-button-hoverBackground); +} + +/* ---- Logs view ---- */ +.chat-debug-logs { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; +} +.chat-debug-editor-header { + display: flex; + align-items: center; + padding: 8px 16px; + gap: 12px; + flex-shrink: 0; +} +.chat-debug-editor-header .viewpane-filter-container { + flex: 1; + max-width: 500px; +} +.chat-debug-view-mode-toggle.monaco-button { + width: auto; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 2px; + font-size: 12px; + cursor: pointer; +} +.chat-debug-view-mode-toggle.monaco-button:focus { + border-color: var(--vscode-focusBorder); +} +.chat-debug-view-mode-labels { + display: grid; +} +.chat-debug-view-mode-label { + grid-row: 1; + grid-column: 1; +} +.chat-debug-view-mode-label.hidden { + visibility: hidden; +} +.chat-debug-table-header { + display: flex; + padding: 4px 16px; + font-weight: 600; + font-size: 12px; + border-bottom: 1px solid var(--vscode-widget-border, var(--vscode-panel-border)); + flex-shrink: 0; + color: var(--vscode-foreground); + opacity: 0.8; +} +.chat-debug-table-header .chat-debug-col-created { + width: 160px; + flex-shrink: 0; +} +.chat-debug-table-header .chat-debug-col-name { + width: 200px; + flex-shrink: 0; +} +.chat-debug-table-header .chat-debug-col-details { + flex: 1; +} +.chat-debug-logs-content { + display: flex; + flex-direction: row; + flex: 1; + overflow: hidden; +} +.chat-debug-logs-main { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} +.chat-debug-logs-body { + position: relative; + display: flex; + flex-direction: row; + flex: 1; + overflow: hidden; +} +.chat-debug-list-container { + flex: 1; + overflow: hidden; +} +.chat-debug-log-row { + display: flex; + align-items: center; + padding: 0 16px; + height: 28px; + border-bottom: 1px solid var(--vscode-widget-border, transparent); + font-size: 12px; +} +.chat-debug-log-row .chat-debug-log-created { + width: 160px; + flex-shrink: 0; + color: var(--vscode-descriptionForeground); +} +.chat-debug-log-row .chat-debug-log-name { + width: 200px; + flex-shrink: 0; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chat-debug-log-row .chat-debug-log-details { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chat-debug-log-row.chat-debug-log-error, +.chat-debug-log-row.chat-debug-log-error:hover { + background-color: var(--vscode-inputValidation-errorBackground, rgba(255, 0, 0, 0.1)) !important; + color: var(--vscode-errorForeground) !important; +} +.monaco-tl-row:has(.chat-debug-log-row.chat-debug-log-error) { + background-color: var(--vscode-inputValidation-errorBackground, rgba(255, 0, 0, 0.1)) !important; +} +.chat-debug-log-row.chat-debug-log-warning { + background-color: var(--vscode-inputValidation-warningBackground, rgba(255, 204, 0, 0.1)) !important; +} +.monaco-tl-row:has(.chat-debug-log-row.chat-debug-log-warning) { + background-color: var(--vscode-inputValidation-warningBackground, rgba(255, 204, 0, 0.1)) !important; +} +.chat-debug-log-row.chat-debug-log-trace { + opacity: 0.7; +} +.chat-debug-logs-shimmer-row { + position: absolute; + left: 0; + right: 0; + display: flex; + align-items: center; + padding: 0 16px; + height: 28px; + gap: 40px; + pointer-events: none; +} +.chat-debug-logs-shimmer-bar { + flex: 1; + height: 10px; + border-radius: 3px; + background: linear-gradient( + 90deg, + var(--vscode-descriptionForeground) 25%, + var(--vscode-chat-thinkingShimmer, rgba(255, 255, 255, 0.3)) 50%, + var(--vscode-descriptionForeground) 75% + ); + background-size: 200% 100%; + animation: chat-debug-shimmer 2s linear infinite; + opacity: 0.15; +} +.chat-debug-detail-panel { + flex-shrink: 0; + width: 350px; + display: flex; + flex-direction: column; + border-left: 1px solid var(--vscode-widget-border, var(--vscode-panel-border)); + border-top: 1px solid var(--vscode-widget-border, var(--vscode-panel-border)); + box-shadow: -6px 0 6px -6px var(--vscode-widget-shadow); + background: var(--vscode-editorWidget-background); + font-size: 12px; + position: relative; + overflow: hidden; +} +.chat-debug-detail-header { + display: flex; + justify-content: flex-end; + padding: 8px 16px 0; + flex-shrink: 0; +} +.chat-debug-detail-button.monaco-button { + width: auto; + border: none; + background: transparent; + color: var(--vscode-foreground); + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 2px 6px; + border-radius: 4px; + opacity: 0.7; +} +.chat-debug-detail-button.monaco-button:hover { + opacity: 1; + background: var(--vscode-toolbar-hoverBackground); +} +.chat-debug-detail-panel > pre { + flex: 1; + min-height: 0; +} +.chat-debug-detail-panel pre { + margin: 0; + padding: 8px 16px; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + user-select: text; + -webkit-user-select: text; + cursor: text; + outline: none; +} +.chat-debug-detail-panel pre:focus { + outline: 1px solid var(--vscode-focusBorder); +} + +/* ---- File List Content ---- */ +.chat-debug-detail-panel > .chat-debug-file-list { + flex: 1; + min-height: 0; +} +.chat-debug-file-list { + padding: 8px 16px; + font-size: 12px; + user-select: text; + -webkit-user-select: text; +} +.chat-debug-file-list-title { + font-weight: 600; + font-size: 14px; + margin-bottom: 4px; +} +.chat-debug-file-list-summary { + color: var(--vscode-descriptionForeground); + margin-bottom: 8px; +} +.chat-debug-file-list-section { + margin-bottom: 8px; +} +.chat-debug-file-list-section-title { + font-weight: 600; + margin-bottom: 4px; + display: flex; + align-items: center; + gap: 4px; +} +.chat-debug-file-list-status-icon { + flex-shrink: 0; + width: 16px; + text-align: center; +} +.chat-debug-file-list-status-icon.status-loaded { + color: var(--vscode-testing-iconPassed, #73c991); +} +.chat-debug-file-list-status-icon.status-skipped { + color: var(--vscode-descriptionForeground); +} +.chat-debug-file-list-status-icon.status-error { + color: var(--vscode-testing-iconFailed, #f14c4c); +} +.chat-debug-file-list-row { + display: flex; + align-items: center; + padding: 2px 4px; + gap: 4px; + overflow: hidden; +} +.chat-debug-file-list-row .chat-inline-anchor-widget { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 0; +} +.chat-debug-file-list-icon { + flex-shrink: 0; + width: 16px; + text-align: center; +} +.chat-debug-file-link { + color: var(--vscode-textLink-foreground); + text-decoration: none; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.chat-debug-file-link:hover { + text-decoration: underline; + color: var(--vscode-textLink-activeForeground); +} +.chat-debug-file-list-badge { + color: var(--vscode-descriptionForeground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 1; + min-width: 0; +} +.chat-debug-file-list-detail { + color: var(--vscode-descriptionForeground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 1; + min-width: 0; +} + +/* ---- Message Content (User Message / Agent Response) ---- */ +.chat-debug-detail-panel > .chat-debug-message-content { + flex: 1; + min-height: 0; +} +.chat-debug-message-content { + padding: 8px 16px; + font-size: 12px; + user-select: text; + -webkit-user-select: text; + outline: none; +} +.chat-debug-message-content:focus { + outline: 1px solid var(--vscode-focusBorder); +} +.chat-debug-message-content-title { + font-weight: 600; + font-size: 14px; + margin-bottom: 4px; +} +.chat-debug-message-content-summary { + color: var(--vscode-descriptionForeground); + margin-bottom: 8px; + white-space: pre-wrap; + word-break: break-word; +} +.chat-debug-message-sections { + margin-top: 4px; +} +.chat-debug-message-sections-label { + font-weight: 600; + margin-bottom: 4px; +} +.chat-debug-message-section { + margin-bottom: 4px; + border: 1px solid var(--vscode-widget-border, transparent); + border-radius: 4px; + overflow: hidden; +} +.chat-debug-message-section-header { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: var(--vscode-editorWidget-background); + border-radius: 3px; + user-select: none; + font-weight: 600; + color: var(--vscode-foreground); + cursor: pointer; +} +.chat-debug-message-section-header:hover { + background: var(--vscode-list-hoverBackground); +} +.chat-debug-message-section-chevron { + flex-shrink: 0; + width: 16px; + text-align: center; +} +.chat-debug-message-section-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chat-debug-message-section-content-wrapper { + border-top: 1px solid var(--vscode-widget-border, transparent); + max-height: 300px; + overflow: hidden; +} +.chat-debug-message-section-content { + margin: 0; + padding: 8px 12px; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + font-family: var(--vscode-chat-font-family, monospace); + background: transparent; + outline: none; +} +.chat-debug-message-section-content:focus { + outline: 1px solid var(--vscode-focusBorder); +} + +/* ---- File list: inline file link ---- */ +.chat-debug-file-list-row a.chat-inline-anchor-widget { + cursor: pointer; +} + +/* ---- File list: settings gear button ---- */ +.chat-debug-settings-gear.monaco-button { + flex-shrink: 0; + opacity: 0.7; + border-radius: 3px; + padding: 2px; + background: none; + border: none; + color: inherit; + min-width: auto; +} +.chat-debug-settings-gear.monaco-button:hover { + opacity: 1; + background: var(--vscode-toolbar-hoverBackground); +} +.chat-debug-settings-gear-header-passthrough { + pointer-events: none; +} +.chat-debug-settings-gear-header-passthrough .chat-debug-settings-gear { + pointer-events: auto; +} + +/* ---- File list: source folder section ---- */ +.chat-debug-source-folder-content { + padding: 8px 12px; + border-top: 1px solid var(--vscode-widget-border, transparent); +} +.chat-debug-source-folder-note { + margin-bottom: 4px; +} +.chat-debug-source-folder-row { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 1px 0; +} +.chat-debug-source-folder-index { + color: var(--vscode-descriptionForeground); + flex-shrink: 0; + min-width: 16px; + text-align: right; +} +.chat-debug-source-folder-label { + word-break: break-all; +} + +/* ---- File list: location badge link ---- */ +.chat-debug-file-list-badge-link { + cursor: pointer; +} + +/* ---- Flow chart view ---- */ +.chat-debug-flowchart { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; +} +.chat-debug-flowchart-content-wrapper { + display: flex; + flex: 1; + overflow: hidden; +} +.chat-debug-flowchart-content { + flex: 1; + overflow: hidden; + position: relative; + cursor: grab; + background: var(--vscode-editor-background); +} +.chat-debug-flowchart-content:active { + cursor: grabbing; +} +.chat-debug-flowchart-svg-wrapper { + transform-origin: 0 0; + position: absolute; + top: 0; + left: 0; +} +.chat-debug-flowchart-svg { + display: block; +} +.chat-debug-flowchart-node rect { + transition: opacity 0.15s; +} +.chat-debug-flowchart-node:hover rect:first-child { + opacity: 0.85; +} +.chat-debug-flowchart-subgraph-header { + cursor: pointer; +} +.chat-debug-flowchart-subgraph:hover rect.chat-debug-flowchart-subgraph-header { + opacity: 0.25; +} +.chat-debug-flowchart-node:focus { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 2px; + border-radius: 6px; +} +.chat-debug-flowchart-subgraph-header:focus { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 1px; +} +.chat-debug-flowchart-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: 13px; + color: var(--vscode-descriptionForeground); +} +.chat-debug-flowchart-show-more { + display: flex; + justify-content: center; + margin: 12px 0 0; +} diff --git a/src/vs/workbench/contrib/chat/browser/promptsDebugContribution.ts b/src/vs/workbench/contrib/chat/browser/promptsDebugContribution.ts new file mode 100644 index 00000000000..9620797fe5e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/promptsDebugContribution.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IChatDebugResolvedEventContent, IChatDebugService } from '../common/chatDebugService.js'; +import { LocalChatSessionUri } from '../common/model/chatUri.js'; +import { IPromptDiscoveryInfo, IPromptsService } from '../common/promptSyntax/service/promptsService.js'; + +/** + * Bridges {@link IPromptsService} discovery log events to {@link IChatDebugService}. + * + * This contribution listens for discovery events emitted by the prompts service + * and forwards them as debug log entries. It also registers a resolve provider + * so expanding a discovery event in the debug panel shows the full file list. + */ +export class PromptsDebugContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.promptsDebug'; + + private static readonly MAX_DISCOVERY_DETAILS = 10_000; + + /** + * Maps debug event IDs to their discovery info, so that + * {@link IChatDebugService.resolveEvent} can return rich details. + */ + private readonly _discoveryEventDetails = new Map(); + + constructor( + @IPromptsService promptsService: IPromptsService, + @IChatDebugService chatDebugService: IChatDebugService, + ) { + super(); + + // Forward discovery log events to the debug service. + this._register(promptsService.onDidLogDiscovery(entry => { + let eventId: string | undefined; + + if (entry.discoveryInfo) { + eventId = generateUuid(); + this._discoveryEventDetails.set(eventId, entry.discoveryInfo); + + // Evict oldest entries when the map exceeds the cap. + if (this._discoveryEventDetails.size > PromptsDebugContribution.MAX_DISCOVERY_DETAILS) { + const first = this._discoveryEventDetails.keys().next().value; + if (first !== undefined) { + this._discoveryEventDetails.delete(first); + } + } + } + + chatDebugService.log( + LocalChatSessionUri.forSession(entry.sessionId), + entry.name, + entry.details, + undefined, + { id: eventId, category: entry.category }, + ); + })); + + // Register a resolve provider so expanding a discovery event + // in the debug panel shows the full file list. + this._register(chatDebugService.registerProvider({ + provideChatDebugLog: async () => undefined, + resolveChatDebugLogEvent: async (eventId) => { + return this._resolveDiscoveryEvent(eventId); + } + })); + } + + private _resolveDiscoveryEvent(eventId: string): IChatDebugResolvedEventContent | undefined { + const info = this._discoveryEventDetails.get(eventId); + if (!info) { + return undefined; + } + + return { + kind: 'fileList', + discoveryType: info.type, + files: info.files.map(f => ({ + uri: f.uri, + name: f.name, + status: f.status, + storage: f.storage, + extensionId: f.extensionId, + skipReason: f.skipReason, + errorMessage: f.errorMessage, + duplicateOf: f.duplicateOf, + })), + sourceFolders: info.sourceFolders?.map(sf => ({ + uri: sf.uri, + storage: sf.storage, + exists: sf.exists, + fileCount: sf.fileCount, + errorMessage: sf.errorMessage, + })), + }; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 49ad922d263..9b1dea8a0be 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -86,6 +86,7 @@ import { IChatTipService } from '../chatTipService.js'; import { ChatTipContentPart } from './chatContentParts/chatTipContentPart.js'; import { ChatContentMarkdownRenderer } from './chatContentMarkdownRenderer.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; +import { IChatDebugService } from '../../common/chatDebugService.js'; const $ = dom.$; @@ -289,6 +290,7 @@ export class ChatWidget extends Disposable implements IChatWidget { private readonly _agentSupportsAttachmentsContextKey: IContextKey; private readonly _sessionIsEmptyContextKey: IContextKey; private readonly _hasPendingRequestsContextKey: IContextKey; + private readonly _sessionHasDebugDataContextKey: IContextKey; private _attachmentCapabilities: IChatAgentAttachmentCapabilities = supportsAllAttachments; // Cache for prompt file descriptions to avoid async calls during rendering @@ -401,6 +403,7 @@ export class ChatWidget extends Disposable implements IChatWidget { @ILifecycleService private readonly lifecycleService: ILifecycleService, @IChatAttachmentResolveService private readonly chatAttachmentResolveService: IChatAttachmentResolveService, @IChatTipService private readonly chatTipService: IChatTipService, + @IChatDebugService private readonly chatDebugService: IChatDebugService, ) { super(); @@ -408,6 +411,14 @@ export class ChatWidget extends Disposable implements IChatWidget { this._agentSupportsAttachmentsContextKey = ChatContextKeys.agentSupportsAttachments.bindTo(this.contextKeyService); this._sessionIsEmptyContextKey = ChatContextKeys.chatSessionIsEmpty.bindTo(this.contextKeyService); this._hasPendingRequestsContextKey = ChatContextKeys.hasPendingRequests.bindTo(this.contextKeyService); + this._sessionHasDebugDataContextKey = ChatContextKeys.chatSessionHasDebugData.bindTo(this.contextKeyService); + + this._register(this.chatDebugService.onDidAddEvent(e => { + const sessionResource = this.viewModel?.sessionResource; + if (sessionResource && e.sessionResource.toString() === sessionResource.toString()) { + this._sessionHasDebugDataContextKey.set(true); + } + })); this.viewContext = viewContext ?? {}; @@ -2114,6 +2125,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.onDidChangeItems(); })); this._sessionIsEmptyContextKey.set(model.getRequests().length === 0); + this._sessionHasDebugDataContextKey.set(this.chatDebugService.getEvents(model.sessionResource).length > 0); let lastSteeringCount = 0; const updatePendingRequestKeys = (announceSteering: boolean) => { const pendingRequests = model.getPendingRequests(); @@ -2806,7 +2818,8 @@ export class ChatWidget extends Disposable implements IChatWidget { this.logService.debug(`ChatWidget#_autoAttachInstructions: prompt files are always enabled`); const enabledTools = this.input.currentModeKind === ChatModeKind.Agent ? this.input.selectedToolsModel.userSelectedTools.get() : undefined; const enabledSubAgents = this.input.currentModeKind === ChatModeKind.Agent ? this.input.currentModeObs.get().agents?.get() : undefined; - const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, this.input.currentModeKind, enabledTools, enabledSubAgents); + const sessionId = this._viewModel?.model.sessionId; + const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, this.input.currentModeKind, enabledTools, enabledSubAgents, sessionId); await computer.collect(attachedContext, CancellationToken.None); } diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index fd23452fa9b..118eff3f41d 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -87,6 +87,7 @@ export namespace ChatContextKeys { export const hasFileAttachments = new RawContextKey('chatHasFileAttachments', false, { type: 'boolean', description: localize('chatHasFileAttachments', "True when the chat has file attachments.") }); export const chatSessionIsEmpty = new RawContextKey('chatSessionIsEmpty', true, { type: 'boolean', description: localize('chatSessionIsEmpty', "True when the current chat session has no requests.") }); export const hasPendingRequests = new RawContextKey('chatHasPendingRequests', false, { type: 'boolean', description: localize('chatHasPendingRequests', "True when there are pending requests in the queue.") }); + export const chatSessionHasDebugData = new RawContextKey('chatSessionHasDebugData', false, { type: 'boolean', description: localize('chatSessionHasDebugData', "True when the current chat session has debug log data.") }); export const remoteJobCreating = new RawContextKey('chatRemoteJobCreating', false, { type: 'boolean', description: localize('chatRemoteJobCreating', "True when a remote coding agent job is being created.") }); export const hasRemoteCodingAgent = new RawContextKey('hasRemoteCodingAgent', false, localize('hasRemoteCodingAgent', "Whether any remote coding agent is available")); diff --git a/src/vs/workbench/contrib/chat/common/chatDebugService.ts b/src/vs/workbench/contrib/chat/common/chatDebugService.ts new file mode 100644 index 00000000000..4e83565a941 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/chatDebugService.ts @@ -0,0 +1,260 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; + +/** + * The severity level of a chat debug log event. + */ +export enum ChatDebugLogLevel { + Trace = 0, + Info = 1, + Warning = 2, + Error = 3 +} + +/** + * Common properties shared by all chat debug event types. + */ +export interface IChatDebugEventCommon { + readonly id?: string; + readonly sessionResource: URI; + readonly created: Date; + readonly parentEventId?: string; +} + +/** + * A tool call event in the chat debug log. + */ +export interface IChatDebugToolCallEvent extends IChatDebugEventCommon { + readonly kind: 'toolCall'; + readonly toolName: string; + readonly toolCallId?: string; + readonly input?: string; + readonly output?: string; + readonly result?: 'success' | 'error'; + readonly durationInMillis?: number; +} + +/** + * A model turn event representing an LLM request/response. + */ +export interface IChatDebugModelTurnEvent extends IChatDebugEventCommon { + readonly kind: 'modelTurn'; + readonly model?: string; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly totalTokens?: number; + readonly durationInMillis?: number; +} + +/** + * A generic log event for unstructured or miscellaneous messages. + */ +export interface IChatDebugGenericEvent extends IChatDebugEventCommon { + readonly kind: 'generic'; + readonly name: string; + readonly details?: string; + readonly level: ChatDebugLogLevel; + readonly category?: string; +} + +/** + * A subagent invocation event, representing a spawned sub-agent within a session. + */ +export interface IChatDebugSubagentInvocationEvent extends IChatDebugEventCommon { + readonly kind: 'subagentInvocation'; + readonly agentName: string; + readonly description?: string; + readonly status?: 'running' | 'completed' | 'failed'; + readonly durationInMillis?: number; + readonly toolCallCount?: number; + readonly modelTurnCount?: number; +} + +/** + * A named section within a user message or agent response. + */ +export interface IChatDebugMessageSection { + readonly name: string; + readonly content: string; +} + +/** + * A user message event, representing the full prompt sent by the user. + */ +export interface IChatDebugUserMessageEvent extends IChatDebugEventCommon { + readonly kind: 'userMessage'; + readonly message: string; + readonly sections: readonly IChatDebugMessageSection[]; +} + +/** + * An agent response event, representing the agent's response. + */ +export interface IChatDebugAgentResponseEvent extends IChatDebugEventCommon { + readonly kind: 'agentResponse'; + readonly message: string; + readonly sections: readonly IChatDebugMessageSection[]; +} + +/** + * Union of all internal chat debug event types. + */ +export type IChatDebugEvent = IChatDebugToolCallEvent | IChatDebugModelTurnEvent | IChatDebugGenericEvent | IChatDebugSubagentInvocationEvent | IChatDebugUserMessageEvent | IChatDebugAgentResponseEvent; + +export const IChatDebugService = createDecorator('chatDebugService'); + +/** + * Service for collecting and exposing chat debug events. + * Internal components can log events, + * and the debug editor pane can display them. + */ +export interface IChatDebugService extends IDisposable { + readonly _serviceBrand: undefined; + + /** + * Fired when a new event is added. + */ + readonly onDidAddEvent: Event; + + /** + * Log a generic event to the debug service. + */ + log(sessionResource: URI, name: string, details?: string, level?: ChatDebugLogLevel, options?: { id?: string; category?: string; parentEventId?: string }): void; + + /** + * Add a typed event to the debug service. + */ + addEvent(event: IChatDebugEvent): void; + + /** + * Add an event sourced from an external provider. + * These events are cleared before re-invoking providers to avoid duplicates. + */ + addProviderEvent(event: IChatDebugEvent): void; + + /** + * Get all events for a specific session. + */ + getEvents(sessionResource?: URI): readonly IChatDebugEvent[]; + + /** + * Get all session resources that have logged events. + */ + getSessionResources(): readonly URI[]; + + /** + * The currently active session resource for debugging. + */ + activeSessionResource: URI | undefined; + + /** + * Clear all logged events. + */ + clear(): void; + + /** + * Register an external provider that can supply additional debug events. + * This is used by the extension API (ChatDebugLogProvider). + */ + registerProvider(provider: IChatDebugLogProvider): IDisposable; + + /** + * Invoke all registered providers for a given session resource. + * Called when the Debug View is opened to fetch events from extensions. + */ + invokeProviders(sessionResource: URI): Promise; + + /** + * End a debug session: cancels any in-flight provider invocation, + * disposes the associated CancellationTokenSource, and removes it. + * Called when the chat session is disposed/archived. + */ + endSession(sessionResource: URI): void; + + /** + * Resolve the full details of an event by its id. + * Delegates to the registered provider's resolveChatDebugLogEvent. + */ + resolveEvent(eventId: string): Promise; +} + +/** + * Plain text content for a resolved debug event. + */ +export interface IChatDebugEventTextContent { + readonly kind: 'text'; + readonly value: string; +} + +/** + * The status of a file in a file list content. + */ +export type ChatDebugFileStatus = 'loaded' | 'skipped'; + +/** + * A single file entry in a file list content. + */ +export interface IChatDebugFileEntry { + readonly uri: URI; + readonly name?: string; + readonly status: ChatDebugFileStatus; + readonly storage?: string; + readonly extensionId?: string; + readonly skipReason?: string; + readonly errorMessage?: string; + readonly duplicateOf?: URI; +} + +/** + * A source folder entry in a file list content. + */ +export interface IChatDebugSourceFolderEntry { + readonly uri: URI; + readonly storage: string; + readonly exists: boolean; + readonly fileCount: number; + readonly errorMessage?: string; +} + +/** + * Structured file list content for a resolved debug event. + * Contains resolved files and skipped/failed paths for rich rendering. + */ +export interface IChatDebugEventFileListContent { + readonly kind: 'fileList'; + readonly discoveryType: string; + readonly files: readonly IChatDebugFileEntry[]; + readonly sourceFolders?: readonly IChatDebugSourceFolderEntry[]; +} + +/** + * Structured message content for a resolved debug event, + * containing collapsible sections. + */ +export interface IChatDebugEventMessageContent { + readonly kind: 'message'; + readonly type: 'user' | 'agent'; + readonly message: string; + readonly sections: readonly IChatDebugMessageSection[]; +} + +/** + * Union of all resolved event content types. + */ +export type IChatDebugResolvedEventContent = IChatDebugEventTextContent | IChatDebugEventFileListContent | IChatDebugEventMessageContent; + +/** + * Provider interface for debug events. + */ +export interface IChatDebugLogProvider { + provideChatDebugLog(sessionResource: URI, token: CancellationToken): Promise; + resolveChatDebugLogEvent?(eventId: string, token: CancellationToken): Promise; +} diff --git a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts new file mode 100644 index 00000000000..f9787ed7978 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts @@ -0,0 +1,224 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugLogProvider, IChatDebugResolvedEventContent, IChatDebugService } from './chatDebugService.js'; + +export class ChatDebugServiceImpl extends Disposable implements IChatDebugService { + declare readonly _serviceBrand: undefined; + + private static readonly MAX_EVENTS = 10_000; + + // Circular buffer: fixed-size array with head/size tracking for O(1) append. + private readonly _buffer: (IChatDebugEvent | undefined)[] = new Array(ChatDebugServiceImpl.MAX_EVENTS); + private _head = 0; // index of the oldest element + private _size = 0; // number of elements currently stored + + private readonly _onDidAddEvent = this._register(new Emitter()); + readonly onDidAddEvent: Event = this._onDidAddEvent.event; + + private readonly _providers = new Set(); + private readonly _invocationCts = new ResourceMap(); + + /** Events that were returned by providers (not internally logged). */ + private readonly _providerEvents = new WeakSet(); + + activeSessionResource: URI | undefined; + + log(sessionResource: URI, name: string, details?: string, level: ChatDebugLogLevel = ChatDebugLogLevel.Info, options?: { id?: string; category?: string; parentEventId?: string }): void { + this.addEvent({ + kind: 'generic', + id: options?.id, + sessionResource, + created: new Date(), + name, + details, + level, + category: options?.category, + parentEventId: options?.parentEventId, + }); + } + + addEvent(event: IChatDebugEvent): void { + const idx = (this._head + this._size) % ChatDebugServiceImpl.MAX_EVENTS; + this._buffer[idx] = event; + if (this._size < ChatDebugServiceImpl.MAX_EVENTS) { + this._size++; + } else { + this._head = (this._head + 1) % ChatDebugServiceImpl.MAX_EVENTS; + } + this._onDidAddEvent.fire(event); + } + + addProviderEvent(event: IChatDebugEvent): void { + this._providerEvents.add(event); + this.addEvent(event); + } + + getEvents(sessionResource?: URI): readonly IChatDebugEvent[] { + const result: IChatDebugEvent[] = []; + const key = sessionResource?.toString(); + for (let i = 0; i < this._size; i++) { + const event = this._buffer[(this._head + i) % ChatDebugServiceImpl.MAX_EVENTS]; + if (!event) { + continue; + } + if (!key || event.sessionResource.toString() === key) { + result.push(event); + } + } + result.sort((a, b) => a.created.getTime() - b.created.getTime()); + return result; + } + + getSessionResources(): readonly URI[] { + const seen = new ResourceMap(); + const result: URI[] = []; + for (let i = 0; i < this._size; i++) { + const event = this._buffer[(this._head + i) % ChatDebugServiceImpl.MAX_EVENTS]; + if (!event) { + continue; + } + if (!seen.has(event.sessionResource)) { + seen.set(event.sessionResource, true); + result.push(event.sessionResource); + } + } + return result; + } + + clear(): void { + this._buffer.fill(undefined); + this._head = 0; + this._size = 0; + } + + registerProvider(provider: IChatDebugLogProvider): IDisposable { + this._providers.add(provider); + + // Invoke the new provider for all sessions that already have active + // pipelines. This handles the case where invokeProviders() was called + // before this provider was registered (e.g. extension activated late). + for (const [sessionResource, cts] of this._invocationCts) { + if (!cts.token.isCancellationRequested) { + this._invokeProvider(provider, sessionResource, cts.token).catch(onUnexpectedError); + } + } + + return toDisposable(() => { + this._providers.delete(provider); + }); + } + + async invokeProviders(sessionResource: URI): Promise { + // 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. + const existingCts = this._invocationCts.get(sessionResource); + if (existingCts) { + existingCts.cancel(); + existingCts.dispose(); + } + + // Clear only provider-sourced events for this session to avoid + // duplicates when re-invoking (e.g. navigating back to a session). + // Internally-logged events (e.g. prompt discovery) are preserved. + this._clearProviderEvents(sessionResource); + + const cts = new CancellationTokenSource(); + this._invocationCts.set(sessionResource, cts); + + try { + const promises = [...this._providers].map(provider => + this._invokeProvider(provider, sessionResource, cts.token) + ); + await Promise.allSettled(promises); + } catch (err) { + onUnexpectedError(err); + } + // Note: do NOT dispose the CTS here - the token is used by the + // extension-side progress pipeline which stays alive for streaming. + // It will be cancelled+disposed when re-invoking the same session + // or when the service is disposed. + } + + private async _invokeProvider(provider: IChatDebugLogProvider, sessionResource: URI, token: CancellationToken): Promise { + try { + const events = await provider.provideChatDebugLog(sessionResource, token); + if (events) { + for (const event of events) { + this.addProviderEvent({ + ...event, + sessionResource: event.sessionResource ?? sessionResource, + }); + } + } + } catch (err) { + onUnexpectedError(err); + } + } + + endSession(sessionResource: URI): void { + const cts = this._invocationCts.get(sessionResource); + if (cts) { + cts.cancel(); + cts.dispose(); + this._invocationCts.delete(sessionResource); + } + } + + private _clearProviderEvents(sessionResource: URI): void { + const key = sessionResource.toString(); + // Compact the ring buffer in-place, removing matching provider events. + let write = 0; + for (let i = 0; i < this._size; i++) { + const idx = (this._head + i) % ChatDebugServiceImpl.MAX_EVENTS; + const event = this._buffer[idx]; + if (event && this._providerEvents.has(event) && event.sessionResource.toString() === key) { + continue; // skip — this event is removed + } + if (write !== i) { + const writeIdx = (this._head + write) % ChatDebugServiceImpl.MAX_EVENTS; + this._buffer[writeIdx] = event; + } + write++; + } + // Clear trailing slots and update size + for (let i = write; i < this._size; i++) { + this._buffer[(this._head + i) % ChatDebugServiceImpl.MAX_EVENTS] = undefined; + } + this._size = write; + } + + async resolveEvent(eventId: string): Promise { + for (const provider of this._providers) { + if (provider.resolveChatDebugLogEvent) { + try { + const resolved = await provider.resolveChatDebugLogEvent(eventId, CancellationToken.None); + if (resolved !== undefined) { + return resolved; + } + } catch (err) { + onUnexpectedError(err); + } + } + } + return undefined; + } + + override dispose(): void { + for (const cts of this._invocationCts.values()) { + cts.cancel(); + cts.dispose(); + } + this._invocationCts.clear(); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 73852603265..0743b82d5f2 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -30,6 +30,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { IChatDebugService } from '../chatDebugService.js'; import { InlineChatConfigKeys } from '../../../inlineChat/common/inlineChat.js'; import { IMcpService } from '../../../mcp/common/mcpTypes.js'; import { awaitStatsForSession } from '../chat.js'; @@ -167,6 +168,7 @@ export class ChatService extends Disposable implements IChatService { @IPromptsService private readonly promptsService: IPromptsService, @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @IChatDebugService private readonly chatDebugService: IChatDebugService, ) { super(); @@ -187,6 +189,7 @@ export class ChatService extends Disposable implements IChatService { } })); this._register(this._sessionModels.onDidDisposeModel(model => { + this.chatDebugService.endSession(model.sessionResource); this._onDidDisposeSession.fire({ sessionResource: [model.sessionResource], reason: 'cleared' }); })); @@ -951,7 +954,7 @@ export class ChatService extends Disposable implements IChatService { let collectedHooks: IChatRequestHooks | undefined; let hasDisabledClaudeHooks = false; try { - const hooksInfo = await this.promptsService.getHooks(token); + const hooksInfo = await this.promptsService.getHooks(token, model.sessionId); if (hooksInfo) { collectedHooks = hooksInfo.hooks; hasDisabledClaudeHooks = hooksInfo.hasDisabledClaudeHooks; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 3c3e2597382..62b01c294e5 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -22,7 +22,6 @@ import { ChatRequestVariableSet, IChatRequestVariableEntry, isPromptFileVariable import { ILanguageModelToolsService, IToolData, VSCodeToolReference } from '../tools/languageModelToolsService.js'; import { PromptsConfig } from './config/config.js'; import { isInClaudeAgentsFolder, isInClaudeRulesFolder, isPromptOrInstructionsFile } from './config/promptFileLocations.js'; -import { PromptsType } from './promptTypes.js'; import { ParsedPromptFile } from './promptFileParser.js'; import { AgentFileType, ICustomAgent, IPromptPath, IPromptsService } from './service/promptsService.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; @@ -64,6 +63,7 @@ export class ComputeAutomaticInstructions { private readonly _modeKind: ChatModeKind, private readonly _enabledTools: UserSelectedTools | undefined, private readonly _enabledSubagents: (readonly string[]) | undefined, + private readonly _sessionId: string | undefined, @IPromptsService private readonly _promptsService: IPromptsService, @ILogService public readonly _logService: ILogService, @ILabelService private readonly _labelService: ILabelService, @@ -93,7 +93,7 @@ export class ComputeAutomaticInstructions { public async collect(variables: ChatRequestVariableSet, token: CancellationToken): Promise { - const instructionFiles = await this._promptsService.listPromptFiles(PromptsType.instructions, token); + const instructionFiles = await this._promptsService.getInstructionFiles(token, this._sessionId); this._logService.trace(`[InstructionsContextComputer] ${instructionFiles.length} instruction files available.`); @@ -354,7 +354,7 @@ export class ComputeAutomaticInstructions { entries.push('', '', ''); // add trailing newline } - const agentSkills = await this._promptsService.findAgentSkills(token); + const agentSkills = await this._promptsService.findAgentSkills(token, this._sessionId); // Filter out skills with disableModelInvocation=true (they can only be triggered manually via /name) const modelInvocableSkills = agentSkills?.filter(skill => !skill.disableModelInvocation); if (modelInvocableSkills && modelInvocableSkills.length > 0) { @@ -400,7 +400,7 @@ export class ComputeAutomaticInstructions { return (agent: ICustomAgent) => subagents.includes(agent.name); } })(); - const agents = await this._promptsService.getCustomAgents(token); + const agents = await this._promptsService.getCustomAgents(token, this._sessionId); if (agents.length > 0) { entries.push(''); entries.push('Here is a list of agents that can be used when running a subagent.'); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index ce3cc0de193..23113daea69 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -17,6 +17,19 @@ import { ResourceSet } from '../../../../../../base/common/map.js'; import { IChatRequestHooks } from '../hookSchema.js'; import { IResolvedPromptSourceFolder } from '../config/promptFileLocations.js'; +/** + * Entry emitted by the prompts service when discovery logging occurs. + * A debug bridge (e.g. contribution) can listen and forward these to IChatDebugService. + */ +export interface IPromptDiscoveryLogEntry { + readonly sessionId: string; + readonly name: string; + readonly details?: string; + readonly category?: string; + /** When present, the bridge should store this for later event resolution. */ + readonly discoveryInfo?: IPromptDiscoveryInfo; +} + /** * Activation events for prompt file providers. */ @@ -307,12 +320,28 @@ export interface IPromptFileDiscoveryResult { readonly disableModelInvocation?: boolean; } +/** + * Diagnostic information about a source folder that was searched during discovery. + */ +export interface IPromptSourceFolderResult { + readonly uri: URI; + readonly storage: PromptsStorage; + /** Whether the folder exists on disk */ + readonly exists: boolean; + /** Number of matching files found in this folder */ + readonly fileCount: number; + /** Error message if resolution failed */ + readonly errorMessage?: string; +} + /** * Summary of prompt file discovery for a specific type. */ export interface IPromptDiscoveryInfo { readonly type: PromptsType; readonly files: readonly IPromptFileDiscoveryResult[]; + /** Source folders that were searched, with their existence and file count */ + readonly sourceFolders?: readonly IPromptSourceFolderResult[]; } export interface IConfiguredHooksInfo { @@ -372,8 +401,9 @@ export interface IPromptsService extends IDisposable { /** * Returns a prompt command if the command name is valid. + * @param sessionId Optional session ID to scope debug logging to a specific session. */ - getPromptSlashCommands(token: CancellationToken): Promise; + getPromptSlashCommands(token: CancellationToken, sessionId?: string): Promise; /** * Returns the prompt command name for the given URI. @@ -387,8 +417,9 @@ export interface IPromptsService extends IDisposable { /** * Finds all available custom agents + * @param sessionId Optional session ID to scope debug logging to a specific session. */ - getCustomAgents(token: CancellationToken): Promise; + getCustomAgents(token: CancellationToken, sessionId?: string): Promise; /** * Parses the provided URI @@ -446,19 +477,34 @@ export interface IPromptsService extends IDisposable { /** * Gets list of agent skills files. + * @param sessionId Optional session ID to scope debug logging to a specific session. */ - findAgentSkills(token: CancellationToken): Promise; + findAgentSkills(token: CancellationToken, sessionId?: string): Promise; /** * Gets detailed discovery information for a prompt type. * This includes all files found and their load/skip status with reasons. * Used for diagnostics and config-info displays. + * @param sessionId Optional session ID to scope debug logging to a specific session. */ - getPromptDiscoveryInfo(type: PromptsType, token: CancellationToken): Promise; + getPromptDiscoveryInfo(type: PromptsType, token: CancellationToken, sessionId?: string): Promise; /** * Gets all hooks collected from hooks.json files. * The result is cached and invalidated when hook files change. + * @param sessionId Optional session ID to scope debug logging to a specific session. */ - getHooks(token: CancellationToken): Promise; + getHooks(token: CancellationToken, sessionId?: string): Promise; + + /** + * Gets all instruction files, logging discovery info to the debug log. + * @param sessionId Optional session ID to scope debug logging to a specific session. + */ + getInstructionFiles(token: CancellationToken, sessionId?: string): Promise; + + /** + * Fired when a discovery-related log entry is produced. + * Listeners (such as a debug bridge) can forward these to IChatDebugService. + */ + readonly onDidLogDiscovery: Event; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 505d761a3c4..e42b4fde1f9 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -33,7 +33,7 @@ import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAU import { PROMPT_LANGUAGE_ID, PromptsType, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; -import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, Target } from './promptsService.js'; +import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, Target, IPromptDiscoveryLogEntry } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { IChatRequestHooks, IHookCommand, HookType } from '../hookSchema.js'; @@ -41,6 +41,7 @@ import { HookSourceFormat, getHookSourceFormat, parseHooksFromFile } from '../ho import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; import { getTarget, mapClaudeModels, mapClaudeTools } from '../languageProviders/promptValidator.js'; +import { StopWatch } from '../../../../../../base/common/stopwatch.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { getCanonicalPluginCommandId, IAgentPlugin, IAgentPluginService } from '../../plugins/agentPluginService.js'; @@ -112,6 +113,13 @@ export class PromptsService extends Disposable implements IPromptsService { */ private readonly cachedParsedPromptFromModels = new ResourceMap<[number, ParsedPromptFile]>(); + /** + * Emitter for discovery log events. Listeners (e.g. a debug bridge + * contribution) can forward these to IChatDebugService. + */ + private readonly _onDidLogDiscovery = this._register(new Emitter()); + public readonly onDidLogDiscovery: Event = this._onDidLogDiscovery.event; + /** * Cached file locations commands. Caching only happens if the corresponding `fileLocatorEvents` event is used. */ @@ -308,6 +316,45 @@ export class PromptsService extends Disposable implements IPromptsService { return [...prompts.flat()]; } + /** + * Collects diagnostic information about which source folders were searched + * and whether they exist, for display in the debug panel. + */ + private async _collectSourceFolderDiagnostics(type: PromptsType, foundFiles: readonly { uri: URI }[]): Promise { + const resolvedFolders = await this.fileLocator.getSourceFoldersInDiscoveryOrder(type); + const results: IPromptSourceFolderResult[] = []; + + for (const folder of resolvedFolders) { + const fileCount = foundFiles.filter(f => f.uri.path.startsWith(folder.uri.path + '/')).length; + let exists = fileCount > 0; + let errorMessage: string | undefined; + + if (!exists) { + try { + const stat = await this.fileService.stat(folder.uri); + exists = stat.isDirectory; + } catch (e) { + if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { + exists = false; + } else { + exists = false; + errorMessage = e instanceof Error ? e.message : String(e); + } + } + } + + results.push({ + uri: folder.uri, + storage: folder.storage, + exists, + fileCount, + errorMessage, + }); + } + + return results; + } + /** * Registry of prompt file provider instances (custom agents, instructions, prompt files). * Extensions can register providers via the proposed API. @@ -519,8 +566,8 @@ export class PromptsService extends Disposable implements IPromptsService { return this.cachedSlashCommands.onDidChange; } - public async getPromptSlashCommands(token: CancellationToken): Promise { - return this.cachedSlashCommands.get(token); + public async getPromptSlashCommands(token: CancellationToken, sessionId?: string): Promise { + return await this.cachedSlashCommands.get(token); } private async computePromptSlashCommands(token: CancellationToken): Promise { @@ -593,8 +640,24 @@ export class PromptsService extends Disposable implements IPromptsService { return this.cachedCustomAgents.onDidChange; } - public async getCustomAgents(token: CancellationToken): Promise { - return this.cachedCustomAgents.get(token); + public async getCustomAgents(token: CancellationToken, sessionId?: string): Promise { + const sw = StopWatch.create(); + const result = await this.cachedCustomAgents.get(token); + if (sessionId) { + const elapsed = sw.elapsed(); + const discoveryInfo = await this.getAgentDiscoveryInfo(token); + const details = result.length === 1 + ? localize("promptsService.resolvedAgent", "Resolved {0} agent in {1}ms", result.length, elapsed.toFixed(1)) + : localize("promptsService.resolvedAgents", "Resolved {0} agents in {1}ms", result.length, elapsed.toFixed(1)); + this._onDidLogDiscovery.fire({ + sessionId, + name: localize("promptsService.loadAgents", "Load Agents"), + details, + discoveryInfo, + category: 'discovery', + }); + } + return result; } private async computeCustomAgents(token: CancellationToken): Promise { @@ -970,13 +1033,29 @@ export class PromptsService extends Disposable implements IPromptsService { return this.cachedSkills.onDidChange; } - public async findAgentSkills(token: CancellationToken): Promise { + public async findAgentSkills(token: CancellationToken, sessionId?: string): Promise { const useAgentSkills = this.configurationService.getValue(PromptsConfig.USE_AGENT_SKILLS); if (!useAgentSkills) { return undefined; } - return this.cachedSkills.get(token); + const sw = StopWatch.create(); + const result = await this.cachedSkills.get(token); + if (sessionId) { + const elapsed = sw.elapsed(); + const discoveryInfo = await this.getSkillDiscoveryInfo(token); + const details = result.length === 1 + ? localize("promptsService.resolvedSkill", "Resolved {0} skill in {1}ms", result.length, elapsed.toFixed(1)) + : localize("promptsService.resolvedSkills", "Resolved {0} skills in {1}ms", result.length, elapsed.toFixed(1)); + this._onDidLogDiscovery.fire({ + sessionId, + name: localize("promptsService.loadSkills", "Load Skills"), + details, + discoveryInfo, + category: 'discovery', + }); + } + return result; } private async computeAgentSkills(token: CancellationToken): Promise { @@ -1079,8 +1158,45 @@ export class PromptsService extends Disposable implements IPromptsService { return result; } - public async getHooks(token: CancellationToken): Promise { - return this.cachedHooks.get(token); + public async getHooks(token: CancellationToken, sessionId?: string): Promise { + const sw = StopWatch.create(); + const result = await this.cachedHooks.get(token); + if (sessionId) { + const elapsed = sw.elapsed(); + const hookCount = result ? Object.values(result.hooks).reduce((sum, arr) => sum + arr.length, 0) : 0; + const discoveryInfo = await this.getHookDiscoveryInfo(token); + const details = hookCount === 1 + ? localize("promptsService.resolvedHook", "Resolved {0} hook in {1}ms", hookCount, elapsed.toFixed(1)) + : localize("promptsService.resolvedHooks", "Resolved {0} hooks in {1}ms", hookCount, elapsed.toFixed(1)); + this._onDidLogDiscovery.fire({ + sessionId, + name: localize("promptsService.loadHooks", "Load Hooks"), + details, + discoveryInfo, + category: 'discovery', + }); + } + return result; + } + + public async getInstructionFiles(token: CancellationToken, sessionId?: string): Promise { + const sw = StopWatch.create(); + const result = await this.listPromptFiles(PromptsType.instructions, token); + if (sessionId) { + const elapsed = sw.elapsed(); + const discoveryInfo = await this.getInstructionsDiscoveryInfo(token); + const details = result.length === 1 + ? localize("promptsService.resolvedInstruction", "Resolved {0} instruction in {1}ms", result.length, elapsed.toFixed(1)) + : localize("promptsService.resolvedInstructions", "Resolved {0} instructions in {1}ms", result.length, elapsed.toFixed(1)); + this._onDidLogDiscovery.fire({ + sessionId, + name: localize("promptsService.loadInstructions", "Load Instructions"), + details, + discoveryInfo, + category: 'discovery', + }); + } + return result; } private async computeHooks(token: CancellationToken): Promise { @@ -1176,22 +1292,56 @@ export class PromptsService extends Disposable implements IPromptsService { return { hooks: result, hasDisabledClaudeHooks }; } - public async getPromptDiscoveryInfo(type: PromptsType, token: CancellationToken): Promise { + public async getPromptDiscoveryInfo(type: PromptsType, token: CancellationToken, sessionId?: string): Promise { + if (sessionId) { + this._onDidLogDiscovery.fire({ + sessionId, + name: localize("promptsService.discoveryStart", "Discovery {0} (Start)", type), + category: 'discovery', + }); + } const files: IPromptFileDiscoveryResult[] = []; + let result: IPromptDiscoveryInfo; if (type === PromptsType.skill) { - return this.getSkillDiscoveryInfo(token); + result = await this.getSkillDiscoveryInfo(token); } else if (type === PromptsType.agent) { - return this.getAgentDiscoveryInfo(token); + result = await this.getAgentDiscoveryInfo(token); } else if (type === PromptsType.prompt) { - return this.getPromptSlashCommandDiscoveryInfo(token); + result = await this.getPromptSlashCommandDiscoveryInfo(token); } else if (type === PromptsType.instructions) { - return this.getInstructionsDiscoveryInfo(token); + result = await this.getInstructionsDiscoveryInfo(token); } else if (type === PromptsType.hook) { - return this.getHookDiscoveryInfo(token); + result = await this.getHookDiscoveryInfo(token); + } else { + result = { type, files }; } - return { type, files }; + const loadedCount = result.files.filter(f => f.status === 'loaded').length; + const skippedCount = result.files.filter(f => f.status === 'skipped').length; + + // Add source folder diagnostics if not already present + if (!result.sourceFolders) { + const sourceFolders = await this._collectSourceFolderDiagnostics(type, result.files.filter(f => f.status === 'loaded')); + result = { ...result, sourceFolders }; + } + + if (sessionId) { + const details = localize( + "promptsService.discoveryResult", + "{0} loaded, {1} skipped", + loadedCount, + skippedCount, + ); + this._onDidLogDiscovery.fire({ + sessionId, + name: localize("promptsService.discoveryEnd", "Discovery {0} (End)", type), + details, + discoveryInfo: result, + category: 'discovery', + }); + } + return result; } private async getSkillDiscoveryInfo(token: CancellationToken): Promise { @@ -1207,11 +1357,13 @@ export class PromptsService extends Disposable implements IPromptsService { skipReason: 'disabled' as const, extensionId: promptPath.extension?.identifier?.value })); - return { type: PromptsType.skill, files }; + const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.skill, []); + return { type: PromptsType.skill, files, sourceFolders }; } const { files } = await this.computeSkillDiscoveryInfo(token); - return { type: PromptsType.skill, files }; + const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.skill, files.filter(f => f.status === 'loaded')); + return { type: PromptsType.skill, files, sourceFolders }; } /** @@ -1355,8 +1507,8 @@ export class PromptsService extends Disposable implements IPromptsService { } } - - return { type: PromptsType.agent, files }; + const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.agent, files.filter(f => f.status === 'loaded')); + return { type: PromptsType.agent, files, sourceFolders }; } private async getPromptSlashCommandDiscoveryInfo(token: CancellationToken): Promise { @@ -1384,7 +1536,8 @@ export class PromptsService extends Disposable implements IPromptsService { } } - return { type: PromptsType.prompt, files }; + const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.prompt, files.filter(f => f.status === 'loaded')); + return { type: PromptsType.prompt, files, sourceFolders }; } private async getInstructionsDiscoveryInfo(token: CancellationToken): Promise { @@ -1412,7 +1565,8 @@ export class PromptsService extends Disposable implements IPromptsService { } } - return { type: PromptsType.instructions, files }; + const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.instructions, files.filter(f => f.status === 'loaded')); + return { type: PromptsType.instructions, files, sourceFolders }; } private async getHookDiscoveryInfo(token: CancellationToken): Promise { @@ -1497,7 +1651,8 @@ export class PromptsService extends Disposable implements IPromptsService { } } - return { type: PromptsType.hook, files }; + const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.hook, files.filter(f => f.status === 'loaded')); + return { type: PromptsType.hook, files, sourceFolders }; } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 2b7485a0b05..c626f883a74 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -322,6 +322,9 @@ export class PromptFilesLocator { * This method merges configured locations with default locations and resolves them * to absolute paths, including displayPath and isDefault information. * + * The returned order prefers workspace (local) folders first, then user folders. + * This is used for UX like the "Create Prompt" command where workspace is preferred. + * * @param type The type of prompt files. * @returns List of resolved source folders with metadata. */ @@ -331,6 +334,18 @@ export class PromptFilesLocator { return this.dedupeSourceFolders([...localFolders, ...userFolders]); } + /** + * Gets all resolved source folders in the same order that file discovery + * searches them (user folders first, then local/workspace folders). + * This matches the order used by {@link listFiles} and should be used + * for debug/diagnostic output so the displayed order is accurate. + */ + public async getSourceFoldersInDiscoveryOrder(type: PromptsType): Promise { + const userFolders = await this.getUserStorageFolders(type); + const localFolders = await this.getLocalStorageFolders(type); + return this.dedupeSourceFolders([...userFolders, ...localFolders]); + } + /** * Gets all local (workspace) storage folders for the given prompt type. * This merges default folders with configured locations. diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 4e9eacf9ae3..fe31a470b1d 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -20,6 +20,7 @@ import { IChatProgress, IChatService } from '../../chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../constants.js'; import { ILanguageModelsService } from '../../languageModels.js'; import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; +import { chatSessionResourceToId } from '../../model/chatUri.js'; import { IChatAgentRequest, IChatAgentService } from '../../participants/chatAgents.js'; import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; import { IChatRequestHooks } from '../../promptSyntax/hookSchema.js'; @@ -244,13 +245,14 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } const variableSet = new ChatRequestVariableSet(); - const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, undefined); // agents can not call subagents + const sessionId = chatSessionResourceToId(invocation.context.sessionResource); + const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, undefined, sessionId); // agents can not call subagents await computer.collect(variableSet, token); // Collect hooks from hook .json files let collectedHooks: IChatRequestHooks | undefined; try { - const info = await this.promptsService.getHooks(token); + const info = await this.promptsService.getHooks(token, chatSessionResourceToId(invocation.context.sessionResource)); collectedHooks = info?.hooks; } catch (error) { this.logService.warn('[ChatService] Failed to collect hooks:', error); diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatCustomizationDiagnosticsAction.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatCustomizationDiagnosticsAction.test.ts deleted file mode 100644 index 38cca580357..00000000000 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatCustomizationDiagnosticsAction.test.ts +++ /dev/null @@ -1,527 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { Schemas } from '../../../../../../base/common/network.js'; -import { URI } from '../../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { formatStatusOutput, IFileStatusInfo, IPathInfo, ITypeStatusInfo } from '../../../browser/actions/chatCustomizationDiagnosticsAction.js'; -import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; -import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; - -suite('formatStatusOutput', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - const emptySpecialFiles = { - agentsMd: { enabled: false, files: [] }, - copilotInstructions: { enabled: false, files: [] }, - claudeMd: { enabled: false, files: [] } - }; - - function createPath(displayPath: string, exists: boolean, storage: PromptsStorage = PromptsStorage.local, isDefault = true): IPathInfo { - return { - uri: URI.file(`/workspace/${displayPath.replace(/^~\//, 'home/')}`), - exists, - storage, - scanOrder: 1, - displayPath, - isDefault - }; - } - - function createFile(name: string, status: 'loaded' | 'skipped' | 'overwritten', parentPath: string, storage: PromptsStorage = PromptsStorage.local, options?: { reason?: string; extensionId?: string; overwrittenBy?: string }): IFileStatusInfo { - return { - uri: URI.file(`/workspace/${parentPath}/${name}`), - status, - name, - storage, - reason: options?.reason, - extensionId: options?.extensionId, - overwrittenBy: options?.overwrittenBy - }; - } - - - - /** - * Returns the fsPath of a file URI for use in test expectations. - * Normalizes to forward slashes for cross-platform consistency in markdown links. - */ - function filePath(relativePath: string): string { - return URI.file(`/workspace/${relativePath}`).fsPath.replace(/\\/g, '/'); - } - - /** - * Builds expected output from lines array to avoid hygiene issues with template literal indentation. - */ - function lines(...parts: string[]): string { - return parts.join('\n'); - } - - // Tree prefixes - // allow-any-unicode-next-line - const TREE_BRANCH = '├─'; - // allow-any-unicode-next-line - const TREE_END = '└─'; - // allow-any-unicode-next-line - const ICON_ERROR = '❌'; - // allow-any-unicode-next-line - const ICON_WARN = '⚠️'; - - test('agents with loaded files', () => { - const statusInfos: ITypeStatusInfo[] = [{ - type: PromptsType.agent, - paths: [createPath('.github/agents', true)], - files: [ - createFile('code-reviewer.agent.md', 'loaded', '.github/agents'), - createFile('test-helper.agent.md', 'loaded', '.github/agents') - ], - enabled: true - }]; - - const output = formatStatusOutput(statusInfos, emptySpecialFiles, []); - - assert.strictEqual(output, lines( - '## Chat Customization Diagnostics', - '*WARNING: This file may contain sensitive information.*', - '', - '**Custom Agents**
', - '*2 files loaded*', - '', - '.github/agents
', - `${TREE_BRANCH} [\`code-reviewer.agent.md\`](${filePath('.github/agents/code-reviewer.agent.md')})
`, - `${TREE_END} [\`test-helper.agent.md\`](${filePath('.github/agents/test-helper.agent.md')})
`, - '' - )); - }); - - test('agents with loaded and skipped files', () => { - const statusInfos: ITypeStatusInfo[] = [{ - type: PromptsType.agent, - paths: [createPath('.github/agents', true)], - files: [ - createFile('good-agent.agent.md', 'loaded', '.github/agents'), - createFile('broken-agent.agent.md', 'skipped', '.github/agents', PromptsStorage.local, { reason: 'Missing name attribute' }) - ], - enabled: true - }]; - - const output = formatStatusOutput(statusInfos, emptySpecialFiles, []); - - assert.strictEqual(output, lines( - '## Chat Customization Diagnostics', - '*WARNING: This file may contain sensitive information.*', - '', - '**Custom Agents**
', - '*1 file loaded, 1 skipped*', - '', - '.github/agents
', - `${TREE_BRANCH} [\`good-agent.agent.md\`](${filePath('.github/agents/good-agent.agent.md')})
`, - `${TREE_END} ${ICON_ERROR} [\`broken-agent.agent.md\`](${filePath('.github/agents/broken-agent.agent.md')}) - *Missing name attribute*
`, - '' - )); - }); - - test('agents with overwritten files', () => { - const statusInfos: ITypeStatusInfo[] = [{ - type: PromptsType.agent, - paths: [ - createPath('.github/agents', true), - createPath('~/.copilot/agents', true, PromptsStorage.user) - ], - files: [ - createFile('my-agent.agent.md', 'loaded', '.github/agents'), - createFile('my-agent.agent.md', 'overwritten', 'home/.copilot/agents', PromptsStorage.user, { overwrittenBy: 'my-agent.agent.md' }) - ], - enabled: true - }]; - - const output = formatStatusOutput(statusInfos, emptySpecialFiles, []); - - assert.strictEqual(output, lines( - '## Chat Customization Diagnostics', - '*WARNING: This file may contain sensitive information.*', - '', - '**Custom Agents**
', - '*1 file loaded, 1 skipped*', - '', - '.github/agents
', - `${TREE_END} [\`my-agent.agent.md\`](${filePath('.github/agents/my-agent.agent.md')})
`, - '~/.copilot/agents
', - `${TREE_END} ${ICON_WARN} [\`my-agent.agent.md\`](${filePath('home/.copilot/agents/my-agent.agent.md')}) - *Overwritten by higher priority file*
`, - '' - )); - }); - - test('disabled skills shows setting hint', () => { - const statusInfos: ITypeStatusInfo[] = [{ - type: PromptsType.skill, - paths: [], - files: [], - enabled: false - }]; - - const output = formatStatusOutput(statusInfos, emptySpecialFiles, []); - - assert.strictEqual(output, lines( - '## Chat Customization Diagnostics', - '*WARNING: This file may contain sensitive information.*', - '', - '**Skills**', - '*Skills are disabled. Enable them by setting `chat.useAgentSkills` to `true` in your settings.*', - '' - )); - }); - - test('skills with loaded files uses "skills loaded"', () => { - const statusInfos: ITypeStatusInfo[] = [{ - type: PromptsType.skill, - paths: [createPath('.github/skills', true)], - files: [ - createFile('search', 'loaded', '.github/skills'), - createFile('refactor', 'loaded', '.github/skills') - ], - enabled: true - }]; - - const output = formatStatusOutput(statusInfos, emptySpecialFiles, []); - - assert.strictEqual(output, lines( - '## Chat Customization Diagnostics', - '*WARNING: This file may contain sensitive information.*', - '', - '**Skills**
', - '*2 skills loaded*', - '', - '.github/skills
', - `${TREE_BRANCH} [\`search\`](${filePath('.github/skills/search')})
`, - `${TREE_END} [\`refactor\`](${filePath('.github/skills/refactor')})
`, - '' - )); - }); - - test('instructions with copilot-instructions.md enabled', () => { - const statusInfos: ITypeStatusInfo[] = [{ - type: PromptsType.instructions, - paths: [createPath('.github/instructions', true)], - files: [ - createFile('testing.instructions.md', 'loaded', '.github/instructions') - ], - enabled: true - }]; - - const specialFiles = { - agentsMd: { enabled: false, files: [] }, - copilotInstructions: { enabled: true, files: [URI.file('/workspace/.github/copilot-instructions.md')] }, - claudeMd: { enabled: false, files: [] } - }; - - const output = formatStatusOutput(statusInfos, specialFiles, []); - - assert.strictEqual(output, lines( - '## Chat Customization Diagnostics', - '*WARNING: This file may contain sensitive information.*', - '', - '**Instructions**
', - '*2 files loaded*', - '', - '.github/instructions
', - `${TREE_END} [\`testing.instructions.md\`](${filePath('.github/instructions/testing.instructions.md')})
`, - 'AGENTS.md -
', - 'copilot-instructions.md
', - `${TREE_END} [\`copilot-instructions.md\`](${filePath('.github/copilot-instructions.md')})
`, - '' - )); - }); - - test('instructions with AGENTS.md enabled', () => { - const statusInfos: ITypeStatusInfo[] = [{ - type: PromptsType.instructions, - paths: [createPath('.github/instructions', true)], - files: [], - enabled: true - }]; - - const specialFiles = { - agentsMd: { enabled: true, files: [URI.file('/workspace/AGENTS.md'), URI.file('/workspace/docs/AGENTS.md')] }, - copilotInstructions: { enabled: false, files: [] }, - claudeMd: { enabled: false, files: [] } - }; - - const output = formatStatusOutput(statusInfos, specialFiles, []); - - assert.strictEqual(output, lines( - '## Chat Customization Diagnostics', - '*WARNING: This file may contain sensitive information.*', - '', - '**Instructions**
', - '*2 files loaded*', - '', - '.github/instructions
', - 'AGENTS.md
', - `${TREE_BRANCH} [\`AGENTS.md\`](${filePath('AGENTS.md')})
`, - `${TREE_END} [\`AGENTS.md\`](${filePath('docs/AGENTS.md')})
`, - 'copilot-instructions.md -
', - '' - )); - }); - - test('custom folder that does not exist shows error', () => { - const statusInfos: ITypeStatusInfo[] = [{ - type: PromptsType.agent, - paths: [ - createPath('.github/agents', true), - createPath('custom/agents', false, PromptsStorage.local, false) - ], - files: [], - enabled: true - }]; - - const output = formatStatusOutput(statusInfos, emptySpecialFiles, []); - - assert.strictEqual(output, lines( - '## Chat Customization Diagnostics', - '*WARNING: This file may contain sensitive information.*', - '', - '**Custom Agents**
', - '', - '.github/agents
', - `${ICON_ERROR} custom/agents - *Folder does not exist*
`, - '' - )); - }); - - test('default folder that does not exist shows no error', () => { - const statusInfos: ITypeStatusInfo[] = [{ - type: PromptsType.agent, - paths: [ - createPath('.github/agents', false, PromptsStorage.local, true) - ], - files: [], - enabled: true - }]; - - const output = formatStatusOutput(statusInfos, emptySpecialFiles, []); - - assert.strictEqual(output, lines( - '## Chat Customization Diagnostics', - '*WARNING: This file may contain sensitive information.*', - '', - '**Custom Agents**
', - '', - '.github/agents
', - '' - )); - }); - - test('extension files grouped separately', () => { - const extFile = createFile('ext-agent.agent.md', 'loaded', 'extensions/my-publisher.my-extension/agents', PromptsStorage.extension, { extensionId: 'my-publisher.my-extension' }); - - const statusInfos: ITypeStatusInfo[] = [{ - type: PromptsType.agent, - paths: [createPath('.github/agents', true)], - files: [ - createFile('local-agent.agent.md', 'loaded', '.github/agents'), - extFile - ], - enabled: true - }]; - - const output = formatStatusOutput(statusInfos, emptySpecialFiles, []); - - assert.strictEqual(output, lines( - '## Chat Customization Diagnostics', - '*WARNING: This file may contain sensitive information.*', - '', - '**Custom Agents**
', - '*2 files loaded*', - '', - '.github/agents
', - `${TREE_END} [\`local-agent.agent.md\`](${filePath('.github/agents/local-agent.agent.md')})
`, - 'Extension: my-publisher.my-extension
', - `${TREE_END} [\`ext-agent.agent.md\`](${filePath('extensions/my-publisher.my-extension/agents/ext-agent.agent.md')})
`, - '' - )); - }); - - test('prompts with no files shows message', () => { - const statusInfos: ITypeStatusInfo[] = [{ - type: PromptsType.prompt, - paths: [], - files: [], - enabled: true - }]; - - const output = formatStatusOutput(statusInfos, emptySpecialFiles, []); - - assert.strictEqual(output, lines( - '## Chat Customization Diagnostics', - '*WARNING: This file may contain sensitive information.*', - '', - '**Prompt Files**
', - '', - '*No files loaded*', - '' - )); - }); - - test('full output with all four types', () => { - const statusInfos: ITypeStatusInfo[] = [ - { - type: PromptsType.agent, - paths: [createPath('.github/agents', true)], - files: [createFile('helper.agent.md', 'loaded', '.github/agents')], - enabled: true - }, - { - type: PromptsType.instructions, - paths: [createPath('.github/instructions', true)], - files: [createFile('code-style.instructions.md', 'loaded', '.github/instructions')], - enabled: true - }, - { - type: PromptsType.prompt, - paths: [createPath('.github/prompts', true)], - files: [createFile('fix-bug.prompt.md', 'loaded', '.github/prompts')], - enabled: true - }, - { - type: PromptsType.skill, - paths: [createPath('.github/skills', true)], - files: [createFile('search', 'loaded', '.github/skills')], - enabled: true - } - ]; - - const output = formatStatusOutput(statusInfos, emptySpecialFiles, []); - - assert.strictEqual(output, lines( - '## Chat Customization Diagnostics', - '*WARNING: This file may contain sensitive information.*', - '', - '**Custom Agents**
', - '*1 file loaded*', - '', - '.github/agents
', - `${TREE_END} [\`helper.agent.md\`](${filePath('.github/agents/helper.agent.md')})
`, - '', - '**Instructions**
', - '*1 file loaded*', - '', - '.github/instructions
', - `${TREE_END} [\`code-style.instructions.md\`](${filePath('.github/instructions/code-style.instructions.md')})
`, - 'AGENTS.md -
', - 'copilot-instructions.md -
', - '', - '**Prompt Files**
', - '*1 file loaded*', - '', - '.github/prompts
', - `${TREE_END} [\`fix-bug.prompt.md\`](${filePath('.github/prompts/fix-bug.prompt.md')})
`, - '', - '**Skills**
', - '*1 skill loaded*', - '', - '.github/skills
', - `${TREE_END} [\`search\`](${filePath('.github/skills/search')})
`, - '' - )); - }); - - test('paths with spaces are URL encoded in markdown links', () => { - const statusInfos: ITypeStatusInfo[] = [{ - type: PromptsType.agent, - paths: [{ - uri: URI.file('/workspace/my folder/agents'), - exists: true, - storage: PromptsStorage.local, - scanOrder: 1, - displayPath: 'my folder/agents', - isDefault: false - }], - files: [{ - uri: URI.file('/workspace/my folder/agents/my agent.agent.md'), - status: 'loaded', - name: 'my agent.agent.md', - storage: PromptsStorage.local - }], - enabled: true - }]; - - const output = formatStatusOutput(statusInfos, emptySpecialFiles, []); - - // Verify that spaces in paths are URL encoded (%20) - assert.ok(output.includes('my%20folder/agents/my%20agent.agent.md'), 'Path should have URL-encoded spaces'); - assert.ok(output.includes('[`my agent.agent.md`]'), 'Display name should not be encoded'); - }); - - test('paths with special characters are URL encoded in markdown links', () => { - const statusInfos: ITypeStatusInfo[] = [{ - type: PromptsType.prompt, - paths: [{ - uri: URI.file('/workspace/docs & notes/prompts'), - exists: true, - storage: PromptsStorage.local, - scanOrder: 1, - displayPath: 'docs & notes/prompts', - isDefault: false - }], - files: [{ - uri: URI.file('/workspace/docs & notes/prompts/test[1].prompt.md'), - status: 'loaded', - name: 'test[1].prompt.md', - storage: PromptsStorage.local - }], - enabled: true - }]; - - const output = formatStatusOutput(statusInfos, emptySpecialFiles, []); - - // Verify that special characters in paths are URL encoded - assert.ok(output.includes('docs%20%26%20notes'), 'Ampersand should be URL-encoded'); - assert.ok(output.includes('test%5B1%5D.prompt.md'), 'Brackets should be URL-encoded'); - }); - - test('vscode-userdata scheme URIs are converted to file scheme for relative paths', () => { - // Create a workspace folder - const workspaceFolderUri = URI.file('/Users/test/workspace'); - const workspaceFolder = { - uri: workspaceFolderUri, - name: 'workspace', - index: 0, - toResource: (relativePath: string) => URI.joinPath(workspaceFolderUri, relativePath) - }; - - // Create a vscode-userdata URI that maps to a path under the workspace - const userDataUri = URI.file('/Users/test/workspace/.github/agents/my-agent.agent.md').with({ scheme: Schemas.vscodeUserData }); - - const statusInfos: ITypeStatusInfo[] = [{ - type: PromptsType.agent, - paths: [{ - uri: URI.file('/Users/test/workspace/.github/agents'), - exists: true, - storage: PromptsStorage.local, - scanOrder: 1, - displayPath: '.github/agents', - isDefault: true - }], - files: [{ - uri: userDataUri, - status: 'loaded', - name: 'my-agent.agent.md', - storage: PromptsStorage.local - }], - enabled: true - }]; - - const output = formatStatusOutput(statusInfos, emptySpecialFiles, [workspaceFolder]); - - // The vscode-userdata URI should be converted to file scheme internally, - // allowing relative path computation against workspace folders - assert.ok(output.includes('.github/agents/my-agent.agent.md'), 'Should use relative path from workspace folder'); - // Should not contain the full absolute path - assert.ok(!output.includes('/Users/test/workspace/.github'), 'Should not contain absolute path when relative path is available'); - }); -}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatDebugEventDetailRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatDebugEventDetailRenderer.test.ts new file mode 100644 index 00000000000..3a0f01975d1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatDebugEventDetailRenderer.test.ts @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ChatDebugLogLevel, IChatDebugAgentResponseEvent, IChatDebugGenericEvent, IChatDebugModelTurnEvent, IChatDebugSubagentInvocationEvent, IChatDebugToolCallEvent, IChatDebugUserMessageEvent } from '../../common/chatDebugService.js'; +import { formatEventDetail } from '../../browser/chatDebug/chatDebugEventDetailRenderer.js'; + +suite('formatEventDetail', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('toolCall - minimal', () => { + const event: IChatDebugToolCallEvent = { + kind: 'toolCall', + sessionResource: URI.parse('test://s1'), + created: new Date(), + toolName: 'readFile', + }; + const result = formatEventDetail(event); + assert.ok(result.includes('readFile')); + }); + + test('toolCall - with all fields', () => { + const event: IChatDebugToolCallEvent = { + kind: 'toolCall', + sessionResource: URI.parse('test://s1'), + created: new Date(), + toolName: 'grep_search', + toolCallId: 'tc-123', + input: '{"query": "test"}', + output: '5 results', + result: 'success', + durationInMillis: 250, + }; + const result = formatEventDetail(event); + assert.ok(result.includes('grep_search')); + assert.ok(result.includes('tc-123')); + assert.ok(result.includes('success')); + assert.ok(result.includes('250')); + assert.ok(result.includes('{"query": "test"}')); + assert.ok(result.includes('5 results')); + }); + + test('modelTurn - minimal', () => { + const event: IChatDebugModelTurnEvent = { + kind: 'modelTurn', + sessionResource: URI.parse('test://s1'), + created: new Date(), + }; + const result = formatEventDetail(event); + assert.ok(result.length > 0); + }); + + test('modelTurn - with all fields', () => { + const event: IChatDebugModelTurnEvent = { + kind: 'modelTurn', + sessionResource: URI.parse('test://s1'), + created: new Date(), + model: 'gpt-4o', + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + durationInMillis: 3200, + }; + const result = formatEventDetail(event); + assert.ok(result.includes('gpt-4o')); + assert.ok(result.includes('1000')); + assert.ok(result.includes('500')); + assert.ok(result.includes('1500')); + assert.ok(result.includes('3200')); + }); + + test('generic event', () => { + const event: IChatDebugGenericEvent = { + kind: 'generic', + sessionResource: URI.parse('test://s1'), + created: new Date(), + name: 'Discovery Start', + details: 'Loading instructions', + level: ChatDebugLogLevel.Info, + }; + const result = formatEventDetail(event); + assert.ok(result.includes('Discovery Start')); + assert.ok(result.includes('Loading instructions')); + }); + + test('generic event without details', () => { + const event: IChatDebugGenericEvent = { + kind: 'generic', + sessionResource: URI.parse('test://s1'), + created: new Date(), + name: 'Something', + level: ChatDebugLogLevel.Trace, + }; + const result = formatEventDetail(event); + assert.ok(result.includes('Something')); + }); + + test('subagentInvocation - minimal', () => { + const event: IChatDebugSubagentInvocationEvent = { + kind: 'subagentInvocation', + sessionResource: URI.parse('test://s1'), + created: new Date(), + agentName: 'Explore', + }; + const result = formatEventDetail(event); + assert.ok(result.includes('Explore')); + }); + + test('subagentInvocation - with all fields', () => { + const event: IChatDebugSubagentInvocationEvent = { + kind: 'subagentInvocation', + sessionResource: URI.parse('test://s1'), + created: new Date(), + agentName: 'Data', + description: 'Querying KQL', + status: 'completed', + durationInMillis: 5000, + toolCallCount: 3, + modelTurnCount: 2, + }; + const result = formatEventDetail(event); + assert.ok(result.includes('Data')); + assert.ok(result.includes('Querying KQL')); + assert.ok(result.includes('completed')); + assert.ok(result.includes('5000')); + assert.ok(result.includes('3')); + assert.ok(result.includes('2')); + }); + + test('userMessage', () => { + const event: IChatDebugUserMessageEvent = { + kind: 'userMessage', + sessionResource: URI.parse('test://s1'), + created: new Date(), + message: 'Help me fix this bug', + sections: [ + { name: 'System Prompt', content: 'You are a helpful assistant.' }, + { name: 'Context', content: 'file.ts attached' }, + ], + }; + const result = formatEventDetail(event); + assert.ok(result.includes('Help me fix this bug')); + assert.ok(result.includes('System Prompt')); + assert.ok(result.includes('You are a helpful assistant.')); + assert.ok(result.includes('Context')); + assert.ok(result.includes('file.ts attached')); + }); + + test('userMessage with empty sections', () => { + const event: IChatDebugUserMessageEvent = { + kind: 'userMessage', + sessionResource: URI.parse('test://s1'), + created: new Date(), + message: 'Simple prompt', + sections: [], + }; + const result = formatEventDetail(event); + assert.ok(result.includes('Simple prompt')); + }); + + test('agentResponse', () => { + const event: IChatDebugAgentResponseEvent = { + kind: 'agentResponse', + sessionResource: URI.parse('test://s1'), + created: new Date(), + message: 'Here is the fix', + sections: [ + { name: 'Code', content: 'const x = 1;' }, + ], + }; + const result = formatEventDetail(event); + assert.ok(result.includes('Here is the fix')); + assert.ok(result.includes('Code')); + assert.ok(result.includes('const x = 1;')); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts index adf87b80a57..6aa07cd5a06 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts @@ -50,6 +50,8 @@ import { IPromptsService } from '../../../common/promptSyntax/service/promptsSer import { NullLanguageModelsService } from '../../common/languageModels.js'; import { MockChatVariablesService } from '../../common/mockChatVariables.js'; import { MockPromptsService } from '../../common/promptSyntax/service/mockPromptsService.js'; +import { IChatDebugService } from '../../../common/chatDebugService.js'; +import { ChatDebugServiceImpl } from '../../../common/chatDebugServiceImpl.js'; function getAgentData(id: string): IChatAgentData { return { @@ -89,6 +91,7 @@ suite('ChatEditingService', function () { collection.set(IMcpService, new TestMcpService()); collection.set(IPromptsService, new MockPromptsService()); collection.set(ILanguageModelsService, new SyncDescriptor(NullLanguageModelsService)); + collection.set(IChatDebugService, new ChatDebugServiceImpl()); collection.set(IMultiDiffSourceResolverService, new class extends mock() { override registerResolver(_resolver: IMultiDiffSourceResolver): IDisposable { return Disposable.None; diff --git a/src/vs/workbench/contrib/chat/test/browser/promptsDebugContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptsDebugContribution.test.ts new file mode 100644 index 00000000000..2233f9cd992 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/promptsDebugContribution.test.ts @@ -0,0 +1,182 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../../base/common/event.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugGenericEvent, IChatDebugService } from '../../common/chatDebugService.js'; +import { ChatDebugServiceImpl } from '../../common/chatDebugServiceImpl.js'; +import { PromptsDebugContribution } from '../../browser/promptsDebugContribution.js'; +import { IPromptDiscoveryLogEntry, IPromptDiscoveryInfo, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; + +suite('PromptsDebugContribution', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let chatDebugService: ChatDebugServiceImpl; + let promptsOnDidLogDiscovery: Emitter; + let instaService: TestInstantiationService; + + setup(() => { + instaService = disposables.add(new TestInstantiationService()); + + chatDebugService = disposables.add(new ChatDebugServiceImpl()); + instaService.stub(IChatDebugService, chatDebugService); + + promptsOnDidLogDiscovery = disposables.add(new Emitter()); + instaService.stub(IPromptsService, { onDidLogDiscovery: promptsOnDidLogDiscovery.event } as Partial); + }); + + test('should forward discovery events to chat debug service', () => { + disposables.add(instaService.createInstance(PromptsDebugContribution)); + + const firedEvents: IChatDebugEvent[] = []; + disposables.add(chatDebugService.onDidAddEvent(e => firedEvents.push(e))); + + promptsOnDidLogDiscovery.fire({ + sessionId: 'session-1', + name: 'Load Instructions', + details: 'Resolved 3 instructions in 12.5ms', + category: 'discovery', + }); + + assert.strictEqual(firedEvents.length, 1); + const event = firedEvents[0] as IChatDebugGenericEvent; + assert.strictEqual(event.kind, 'generic'); + assert.ok(event.sessionResource); + assert.strictEqual(event.name, 'Load Instructions'); + assert.strictEqual(event.details, 'Resolved 3 instructions in 12.5ms'); + assert.strictEqual(event.category, 'discovery'); + }); + + test('should store discoveryInfo and resolve via resolveEvent', async () => { + disposables.add(instaService.createInstance(PromptsDebugContribution)); + + const firedEvents: IChatDebugEvent[] = []; + disposables.add(chatDebugService.onDidAddEvent(e => firedEvents.push(e))); + + const discoveryInfo: IPromptDiscoveryInfo = { + type: PromptsType.instructions, + files: [{ + uri: URI.file('/workspace/.github/instructions/test.instructions.md'), + name: 'test.instructions.md', + status: 'loaded' as const, + storage: PromptsStorage.local, + }], + sourceFolders: [{ + uri: URI.file('/workspace/.github/instructions'), + storage: PromptsStorage.local, + exists: true, + fileCount: 1, + }], + }; + + promptsOnDidLogDiscovery.fire({ + sessionId: 'session-1', + name: 'Discovery End', + details: '1 loaded, 0 skipped', + category: 'discovery', + discoveryInfo, + }); + + assert.strictEqual(firedEvents.length, 1); + const eventId = firedEvents[0].id; + assert.ok(eventId, 'Event should have an ID for resolution'); + + const resolved = await chatDebugService.resolveEvent(eventId); + assert.ok(resolved); + assert.strictEqual(resolved.kind, 'fileList'); + if (resolved.kind === 'fileList') { + assert.strictEqual(resolved.discoveryType, 'instructions'); + assert.strictEqual(resolved.files.length, 1); + assert.strictEqual(resolved.files[0].name, 'test.instructions.md'); + assert.strictEqual(resolved.files[0].status, 'loaded'); + assert.strictEqual(resolved.sourceFolders?.length, 1); + assert.strictEqual(resolved.sourceFolders?.[0].exists, true); + } + }); + + test('should return undefined for unknown event ids', async () => { + disposables.add(instaService.createInstance(PromptsDebugContribution)); + + const resolved = await chatDebugService.resolveEvent('nonexistent-id'); + assert.strictEqual(resolved, undefined); + }); + + test('should not assign event id when no discoveryInfo', () => { + disposables.add(instaService.createInstance(PromptsDebugContribution)); + + const firedEvents: IChatDebugEvent[] = []; + disposables.add(chatDebugService.onDidAddEvent(e => firedEvents.push(e))); + + promptsOnDidLogDiscovery.fire({ + sessionId: 'session-1', + name: 'Discovery Start', + category: 'discovery', + }); + + assert.strictEqual(firedEvents.length, 1); + assert.strictEqual(firedEvents[0].id, undefined, 'Event without discoveryInfo should have no id'); + }); + + test('should handle discoveryInfo with skipped files', async () => { + disposables.add(instaService.createInstance(PromptsDebugContribution)); + + const firedEvents: IChatDebugEvent[] = []; + disposables.add(chatDebugService.onDidAddEvent(e => firedEvents.push(e))); + + const discoveryInfo: IPromptDiscoveryInfo = { + type: PromptsType.instructions, + files: [ + { + uri: URI.file('/workspace/.github/instructions/loaded.instructions.md'), + name: 'loaded.instructions.md', + status: 'loaded' as const, + storage: PromptsStorage.local, + }, + { + uri: URI.file('/workspace/.github/instructions/skipped.instructions.md'), + name: 'skipped.instructions.md', + status: 'skipped' as const, + storage: PromptsStorage.local, + skipReason: 'disabled', + }, + ], + }; + + promptsOnDidLogDiscovery.fire({ + sessionId: 'session-1', + name: 'Discovery End', + discoveryInfo, + }); + + const eventId = firedEvents[0].id!; + const resolved = await chatDebugService.resolveEvent(eventId); + assert.ok(resolved); + if (resolved.kind === 'fileList') { + assert.strictEqual(resolved.files.length, 2); + assert.strictEqual(resolved.files[0].status, 'loaded'); + assert.strictEqual(resolved.files[1].status, 'skipped'); + assert.strictEqual(resolved.files[1].skipReason, 'disabled'); + } + }); + + test('should handle level as undefined (defaults to Info)', () => { + disposables.add(instaService.createInstance(PromptsDebugContribution)); + + const firedEvents: IChatDebugEvent[] = []; + disposables.add(chatDebugService.onDidAddEvent(e => firedEvents.push(e))); + + promptsOnDidLogDiscovery.fire({ + sessionId: 'session-1', + name: 'Test', + }); + + const event = firedEvents[0] as IChatDebugGenericEvent; + assert.strictEqual(event.level, ChatDebugLogLevel.Info, 'Default level should be Info'); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts b/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts new file mode 100644 index 00000000000..a0df375b4c3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts @@ -0,0 +1,486 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugGenericEvent, IChatDebugLogProvider, IChatDebugModelTurnEvent, IChatDebugResolvedEventContent, IChatDebugToolCallEvent } from '../../common/chatDebugService.js'; +import { ChatDebugServiceImpl } from '../../common/chatDebugServiceImpl.js'; + +suite('ChatDebugServiceImpl', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let service: ChatDebugServiceImpl; + + const session1 = URI.parse('vscode-chat-session://local/session-1'); + const session2 = URI.parse('vscode-chat-session://local/session-2'); + const sessionA = URI.parse('vscode-chat-session://local/a'); + const sessionB = URI.parse('vscode-chat-session://local/b'); + const sessionGeneric = URI.parse('vscode-chat-session://local/session'); + + setup(() => { + service = disposables.add(new ChatDebugServiceImpl()); + }); + + suite('addEvent and getEvents', () => { + test('should add and retrieve events', () => { + const event: IChatDebugGenericEvent = { + kind: 'generic', + sessionResource: session1, + created: new Date(), + name: 'test-event', + level: ChatDebugLogLevel.Info, + }; + + service.addEvent(event); + + assert.deepStrictEqual(service.getEvents(), [event]); + }); + + test('should filter events by sessionResource', () => { + const event1: IChatDebugGenericEvent = { + kind: 'generic', + sessionResource: session1, + created: new Date(), + name: 'event-1', + level: ChatDebugLogLevel.Info, + }; + const event2: IChatDebugGenericEvent = { + kind: 'generic', + sessionResource: session2, + created: new Date(), + name: 'event-2', + level: ChatDebugLogLevel.Warning, + }; + + service.addEvent(event1); + service.addEvent(event2); + + assert.deepStrictEqual(service.getEvents(session1), [event1]); + assert.deepStrictEqual(service.getEvents(session2), [event2]); + assert.strictEqual(service.getEvents().length, 2); + }); + + test('should fire onDidAddEvent when event is added', () => { + const firedEvents: IChatDebugEvent[] = []; + disposables.add(service.onDidAddEvent(e => firedEvents.push(e))); + + const event: IChatDebugGenericEvent = { + kind: 'generic', + sessionResource: session1, + created: new Date(), + name: 'test', + level: ChatDebugLogLevel.Info, + }; + service.addEvent(event); + + assert.deepStrictEqual(firedEvents, [event]); + }); + + test('should handle different event kinds', () => { + const toolCall: IChatDebugToolCallEvent = { + kind: 'toolCall', + sessionResource: session1, + created: new Date(), + toolName: 'readFile', + toolCallId: 'call-1', + input: '{"path": "/foo.ts"}', + output: 'file contents', + result: 'success', + durationInMillis: 42, + }; + const modelTurn: IChatDebugModelTurnEvent = { + kind: 'modelTurn', + sessionResource: session1, + created: new Date(), + model: 'gpt-4', + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + durationInMillis: 1200, + }; + + service.addEvent(toolCall); + service.addEvent(modelTurn); + + const events = service.getEvents(session1); + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0].kind, 'toolCall'); + assert.strictEqual(events[1].kind, 'modelTurn'); + }); + }); + + suite('log', () => { + test('should create a generic event with defaults', () => { + const firedEvents: IChatDebugEvent[] = []; + disposables.add(service.onDidAddEvent(e => firedEvents.push(e))); + + service.log(session1, 'Some name', 'Some details'); + + assert.strictEqual(firedEvents.length, 1); + const event = firedEvents[0]; + assert.strictEqual(event.kind, 'generic'); + assert.strictEqual(event.sessionResource.toString(), session1.toString()); + assert.strictEqual((event as IChatDebugGenericEvent).name, 'Some name'); + assert.strictEqual((event as IChatDebugGenericEvent).details, 'Some details'); + assert.strictEqual((event as IChatDebugGenericEvent).level, ChatDebugLogLevel.Info); + }); + + test('should accept custom level and options', () => { + const firedEvents: IChatDebugEvent[] = []; + disposables.add(service.onDidAddEvent(e => firedEvents.push(e))); + + service.log(session1, 'warning-event', 'oh no', ChatDebugLogLevel.Warning, { + id: 'my-id', + category: 'testing', + parentEventId: 'parent-1', + }); + + const event = firedEvents[0] as IChatDebugGenericEvent; + assert.strictEqual(event.level, ChatDebugLogLevel.Warning); + assert.strictEqual(event.id, 'my-id'); + assert.strictEqual(event.category, 'testing'); + assert.strictEqual(event.parentEventId, 'parent-1'); + }); + }); + + suite('getSessionResources', () => { + test('should return unique session resources', () => { + service.addEvent({ kind: 'generic', sessionResource: sessionA, created: new Date(), name: 'e1', level: ChatDebugLogLevel.Info }); + service.addEvent({ kind: 'generic', sessionResource: sessionB, created: new Date(), name: 'e2', level: ChatDebugLogLevel.Info }); + service.addEvent({ kind: 'generic', sessionResource: sessionA, created: new Date(), name: 'e3', level: ChatDebugLogLevel.Info }); + + const resources = service.getSessionResources(); + assert.strictEqual(resources.length, 2); + }); + + test('should return empty array when no events', () => { + assert.deepStrictEqual(service.getSessionResources(), []); + }); + }); + + suite('clear', () => { + test('should clear all events', () => { + service.addEvent({ kind: 'generic', sessionResource: sessionA, created: new Date(), name: 'e', level: ChatDebugLogLevel.Info }); + service.addEvent({ kind: 'generic', sessionResource: sessionB, created: new Date(), name: 'e', level: ChatDebugLogLevel.Info }); + + service.clear(); + + assert.strictEqual(service.getEvents().length, 0); + }); + }); + + suite('MAX_EVENTS cap', () => { + test('should evict oldest events when exceeding cap', () => { + // The max is 10_000. Add more than that and verify trimming. + // We'll test with a smaller count by adding events and checking boundary behavior. + for (let i = 0; i < 10_001; i++) { + service.addEvent({ kind: 'generic', sessionResource: sessionGeneric, created: new Date(), name: `event-${i}`, level: ChatDebugLogLevel.Info }); + } + + const events = service.getEvents(); + assert.ok(events.length <= 10_000, 'Should not exceed MAX_EVENTS'); + // The first event should have been evicted + assert.ok(!(events as IChatDebugGenericEvent[]).find(e => e.name === 'event-0'), 'Event-0 should have been evicted'); + // The last event should be present + assert.ok((events as IChatDebugGenericEvent[]).find(e => e.name === 'event-10000'), 'Last event should be present'); + }); + }); + + suite('activeSessionResource', () => { + test('should default to undefined', () => { + assert.strictEqual(service.activeSessionResource, undefined); + }); + + test('should be settable', () => { + service.activeSessionResource = session1; + + assert.strictEqual(service.activeSessionResource, session1); + }); + }); + + suite('registerProvider', () => { + test('should register and unregister a provider', async () => { + const extSession = URI.parse('vscode-chat-session://local/ext-session'); + const provider: IChatDebugLogProvider = { + provideChatDebugLog: async () => [{ + kind: 'generic', + sessionResource: extSession, + created: new Date(), + name: 'from-provider', + level: ChatDebugLogLevel.Info, + }], + }; + + const reg = service.registerProvider(provider); + await service.invokeProviders(extSession); + + const events = service.getEvents(extSession); + assert.ok(events.some(e => e.kind === 'generic' && (e as IChatDebugGenericEvent).name === 'from-provider')); + + reg.dispose(); + }); + + test('provider returning undefined should not add events', async () => { + const emptySession = URI.parse('vscode-chat-session://local/empty-session'); + const provider: IChatDebugLogProvider = { + provideChatDebugLog: async () => undefined, + }; + + disposables.add(service.registerProvider(provider)); + await service.invokeProviders(emptySession); + + assert.strictEqual(service.getEvents(emptySession).length, 0); + }); + + test('provider errors should be handled gracefully', async () => { + const errorSession = URI.parse('vscode-chat-session://local/error-session'); + const provider: IChatDebugLogProvider = { + provideChatDebugLog: async () => { throw new Error('boom'); }, + }; + + disposables.add(service.registerProvider(provider)); + // Should not throw + await service.invokeProviders(errorSession); + assert.strictEqual(service.getEvents(errorSession).length, 0); + }); + }); + + suite('invokeProviders', () => { + test('should invoke multiple providers and merge events', async () => { + const providerA: IChatDebugLogProvider = { + provideChatDebugLog: async () => [{ + kind: 'generic', + sessionResource: sessionGeneric, + created: new Date(), + name: 'from-A', + level: ChatDebugLogLevel.Info, + }], + }; + const providerB: IChatDebugLogProvider = { + provideChatDebugLog: async () => [{ + kind: 'generic', + sessionResource: sessionGeneric, + created: new Date(), + name: 'from-B', + level: ChatDebugLogLevel.Info, + }], + }; + + disposables.add(service.registerProvider(providerA)); + disposables.add(service.registerProvider(providerB)); + await service.invokeProviders(sessionGeneric); + + const names = (service.getEvents(sessionGeneric) as IChatDebugGenericEvent[]).map(e => e.name); + assert.ok(names.includes('from-A')); + assert.ok(names.includes('from-B')); + }); + + test('should cancel previous invocation for same session', async () => { + let cancelledToken: CancellationToken | undefined; + + const provider: IChatDebugLogProvider = { + provideChatDebugLog: async (_sessionResource, token) => { + cancelledToken = token; + return []; + }, + }; + + disposables.add(service.registerProvider(provider)); + + // First invocation + await service.invokeProviders(sessionGeneric); + const firstToken = cancelledToken!; + + // Second invocation for same session should cancel the first + await service.invokeProviders(sessionGeneric); + assert.strictEqual(firstToken.isCancellationRequested, true); + }); + + test('should not cancel invocations for different sessions', async () => { + const tokens: Map = new Map(); + + const provider: IChatDebugLogProvider = { + provideChatDebugLog: async (sessionResource, token) => { + tokens.set(sessionResource.toString(), token); + return []; + }, + }; + + disposables.add(service.registerProvider(provider)); + + await service.invokeProviders(sessionA); + await service.invokeProviders(sessionB); + + const tokenA = tokens.get(sessionA.toString())!; + assert.strictEqual(tokenA.isCancellationRequested, false, 'session-a token should not be cancelled'); + }); + + test('newly registered provider should be invoked for active sessions', async () => { + // Start an invocation before the provider is registered + const firstProvider: IChatDebugLogProvider = { + provideChatDebugLog: async () => [], + }; + disposables.add(service.registerProvider(firstProvider)); + await service.invokeProviders(sessionGeneric); + + // Now register a new provider — it should be invoked for the active session + const lateEvents: IChatDebugEvent[] = []; + const lateProvider: IChatDebugLogProvider = { + provideChatDebugLog: async () => { + const event: IChatDebugGenericEvent = { + kind: 'generic', + sessionResource: sessionGeneric, + created: new Date(), + name: 'late-provider-event', + level: ChatDebugLogLevel.Info, + }; + lateEvents.push(event); + return [event]; + }, + }; + + disposables.add(service.registerProvider(lateProvider)); + + // Give it a tick to let the async invocation complete + await new Promise(resolve => setTimeout(resolve, 10)); + + assert.ok(lateEvents.length > 0, 'Late provider should have been invoked'); + }); + }); + + suite('resolveEvent', () => { + test('should delegate to provider with resolveChatDebugLogEvent', async () => { + const resolved: IChatDebugResolvedEventContent = { + kind: 'text', + value: 'resolved detail text', + }; + + const provider: IChatDebugLogProvider = { + provideChatDebugLog: async () => undefined, + resolveChatDebugLogEvent: async (eventId) => { + if (eventId === 'my-event') { + return resolved; + } + return undefined; + }, + }; + + disposables.add(service.registerProvider(provider)); + + const result = await service.resolveEvent('my-event'); + assert.deepStrictEqual(result, resolved); + }); + + test('should return undefined if no provider resolves the event', async () => { + const provider: IChatDebugLogProvider = { + provideChatDebugLog: async () => undefined, + resolveChatDebugLogEvent: async () => undefined, + }; + + disposables.add(service.registerProvider(provider)); + + const result = await service.resolveEvent('nonexistent'); + assert.strictEqual(result, undefined); + }); + + test('should return undefined when no providers registered', async () => { + const result = await service.resolveEvent('any-id'); + assert.strictEqual(result, undefined); + }); + + test('should return first non-undefined resolution from multiple providers', async () => { + const provider1: IChatDebugLogProvider = { + provideChatDebugLog: async () => undefined, + resolveChatDebugLogEvent: async () => undefined, + }; + const provider2: IChatDebugLogProvider = { + provideChatDebugLog: async () => undefined, + resolveChatDebugLogEvent: async () => ({ kind: 'text', value: 'from provider 2' }), + }; + + disposables.add(service.registerProvider(provider1)); + disposables.add(service.registerProvider(provider2)); + + const result = await service.resolveEvent('any'); + assert.deepStrictEqual(result, { kind: 'text', value: 'from provider 2' }); + }); + }); + + suite('endSession', () => { + test('should cancel and remove the CTS for a session', async () => { + let capturedToken: CancellationToken | undefined; + + const provider: IChatDebugLogProvider = { + provideChatDebugLog: async (_sessionResource, token) => { + capturedToken = token; + return []; + }, + }; + + disposables.add(service.registerProvider(provider)); + await service.invokeProviders(sessionGeneric); + + assert.ok(capturedToken); + assert.strictEqual(capturedToken.isCancellationRequested, false); + + service.endSession(sessionGeneric); + + assert.strictEqual(capturedToken.isCancellationRequested, true); + }); + + test('should be safe to call for unknown session', () => { + // Should not throw + service.endSession(URI.parse('vscode-chat-session://local/nonexistent')); + }); + + test('late provider should not be invoked for ended session', async () => { + const firstProvider: IChatDebugLogProvider = { + provideChatDebugLog: async () => [], + }; + disposables.add(service.registerProvider(firstProvider)); + await service.invokeProviders(sessionGeneric); + + service.endSession(sessionGeneric); + + let lateCalled = false; + const lateProvider: IChatDebugLogProvider = { + provideChatDebugLog: async () => { + lateCalled = true; + return []; + }, + }; + disposables.add(service.registerProvider(lateProvider)); + + await new Promise(resolve => setTimeout(resolve, 10)); + assert.strictEqual(lateCalled, false, 'Late provider should not be invoked for ended session'); + }); + }); + + suite('dispose', () => { + test('should cancel active invocations on dispose', async () => { + let capturedToken: CancellationToken | undefined; + + const provider: IChatDebugLogProvider = { + provideChatDebugLog: async (_sessionResource, token) => { + capturedToken = token; + return []; + }, + }; + + disposables.add(service.registerProvider(provider)); + await service.invokeProviders(sessionGeneric); + + const cts = new CancellationTokenSource(); + disposables.add(cts); + + service.dispose(); + + assert.ok(capturedToken); + assert.strictEqual(capturedToken.isCancellationRequested, true); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 58a78d6b691..9adc29af139 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -49,6 +49,8 @@ import { MockChatService } from './mockChatService.js'; import { MockChatVariablesService } from '../mockChatVariables.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { MockPromptsService } from '../promptSyntax/service/mockPromptsService.js'; +import { IChatDebugService } from '../../../common/chatDebugService.js'; +import { ChatDebugServiceImpl } from '../../../common/chatDebugServiceImpl.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { MockLanguageModelToolsService } from '../tools/mockLanguageModelToolsService.js'; @@ -182,6 +184,7 @@ suite('ChatService', () => { instantiationService.stub(IEnvironmentService, { workspaceStorageHome: URI.file('/test/path/to/workspaceStorage') }); instantiationService.stub(ILifecycleService, { onWillShutdown: Event.None }); instantiationService.stub(IWorkspaceEditingService, { onDidEnterWorkspace: Event.None }); + instantiationService.stub(IChatDebugService, testDisposables.add(new ChatDebugServiceImpl())); instantiationService.stub(IChatEditingService, new class extends mock() { override startOrContinueGlobalEditingSession(): IChatEditingSession { return { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index 2b0c19af801..8a0dcd1bef3 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -239,7 +239,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); { - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -256,7 +256,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, false); testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -273,7 +273,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true); testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, false); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -290,7 +290,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true); testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, false); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -340,7 +340,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -375,7 +375,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Edit, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Edit, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -410,7 +410,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -452,7 +452,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -488,7 +488,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/component.tsx'))); @@ -524,7 +524,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file1.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file2.ts'))); @@ -558,7 +558,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -590,7 +590,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -638,7 +638,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/api/handler.ts'))); @@ -686,7 +686,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/component.tsx'))); @@ -726,7 +726,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'lib/utils.ts'))); @@ -773,7 +773,7 @@ suite('ComputeAutomaticInstructions', () => { const mainUri = URI.joinPath(rootFolderUri, '.github/instructions/main.instructions.md'); const referencedUri = URI.joinPath(rootFolderUri, '.github/instructions/referenced.instructions.md'); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -809,7 +809,7 @@ suite('ComputeAutomaticInstructions', () => { const mainUri = URI.joinPath(rootFolderUri, '.github/instructions/main.instructions.md'); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -858,7 +858,7 @@ suite('ComputeAutomaticInstructions', () => { const level2Uri = URI.joinPath(rootFolderUri, '.github/instructions/level2.instructions.md'); const level3Uri = URI.joinPath(rootFolderUri, '.github/instructions/level3.instructions.md'); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -914,7 +914,7 @@ suite('ComputeAutomaticInstructions', () => { } as unknown as ITelemetryService; instaService.stub(ITelemetryService, mockTelemetryService); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -971,7 +971,7 @@ suite('ComputeAutomaticInstructions', () => { } as unknown as ITelemetryService; instaService.stub(ITelemetryService, mockTelemetryService); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1017,7 +1017,7 @@ suite('ComputeAutomaticInstructions', () => { } as unknown as ITelemetryService; instaService.stub(ITelemetryService, mockTelemetryService); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1079,7 +1079,8 @@ suite('ComputeAutomaticInstructions', () => { ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_runSubagent': true }, - ['*'] + ['*'], + undefined ); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1132,6 +1133,7 @@ suite('ComputeAutomaticInstructions', () => { ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool + undefined, undefined ); const variables = new ChatRequestVariableSet(); @@ -1222,7 +1224,8 @@ suite('ComputeAutomaticInstructions', () => { ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_runSubagent': true }, // Enable runSubagent tool - ['*'] // Enable all subagents + ['*'], // Enable all subagents + undefined ); const variables = new ChatRequestVariableSet(); @@ -1284,6 +1287,7 @@ suite('ComputeAutomaticInstructions', () => { ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool + undefined, undefined ); const variables = new ChatRequestVariableSet(); @@ -1334,6 +1338,7 @@ suite('ComputeAutomaticInstructions', () => { ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, // No tools available + undefined, undefined ); const variables = new ChatRequestVariableSet(); @@ -1370,6 +1375,7 @@ suite('ComputeAutomaticInstructions', () => { ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool + undefined, undefined ); const variables = new ChatRequestVariableSet(); @@ -1423,6 +1429,7 @@ suite('ComputeAutomaticInstructions', () => { ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool + undefined, undefined ); const variables = new ChatRequestVariableSet(); @@ -1454,7 +1461,7 @@ suite('ComputeAutomaticInstructions', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); await contextComputer.collect(variables, CancellationToken.None); @@ -1486,7 +1493,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1509,7 +1516,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1549,7 +1556,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is true testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1561,7 +1568,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is false testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, false); - const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables2 = new ChatRequestVariableSet(); variables2.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1596,7 +1603,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is true testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1608,7 +1615,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is false testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, false); - const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables2 = new ChatRequestVariableSet(); variables2.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1643,7 +1650,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is true testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1655,7 +1662,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is false testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, false); - const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables2 = new ChatRequestVariableSet(); variables2.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1706,7 +1713,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); @@ -1753,7 +1760,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is true testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); @@ -1799,7 +1806,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is true testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); @@ -1853,7 +1860,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is true testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); @@ -1901,7 +1908,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is false testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, false); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); @@ -1955,7 +1962,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is true testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); @@ -2011,7 +2018,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index 525d116ff65..d222b92eb68 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -11,7 +11,7 @@ import { ITextModel } from '../../../../../../../editor/common/model.js'; import { IExtensionDescription } from '../../../../../../../platform/extensions/common/extensions.js'; import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { ParsedPromptFile } from '../../../../common/promptSyntax/promptFileParser.js'; -import { IAgentSkill, ICustomAgent, IPromptFileContext, IPromptFileResource, IPromptPath, IPromptsService, IResolvedAgentFile, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { IAgentSkill, ICustomAgent, IPromptFileContext, IPromptFileResource, IPromptPath, IPromptsService, IResolvedAgentFile, PromptsStorage, IPromptDiscoveryLogEntry } from '../../../../common/promptSyntax/service/promptsService.js'; import { ResourceSet } from '../../../../../../../base/common/map.js'; export class MockPromptsService implements IPromptsService { @@ -21,6 +21,9 @@ export class MockPromptsService implements IPromptsService { private readonly _onDidChangeCustomChatModes = new Emitter(); readonly onDidChangeCustomAgents = this._onDidChangeCustomChatModes.event; + private readonly _onDidLogDiscovery = new Emitter(); + readonly onDidLogDiscovery: Event = this._onDidLogDiscovery.event; + private _customModes: ICustomAgent[] = []; setCustomModes(modes: ICustomAgent[]): void { @@ -28,7 +31,7 @@ export class MockPromptsService implements IPromptsService { this._onDidChangeCustomChatModes.fire(); } - async getCustomAgents(token: CancellationToken): Promise { + async getCustomAgents(token: CancellationToken, sessionId?: string): Promise { return this._customModes; } @@ -47,7 +50,7 @@ export class MockPromptsService implements IPromptsService { resolvePromptSlashCommand(command: string, _token: CancellationToken): Promise { throw new Error('Not implemented'); } get onDidChangeSlashCommands(): Event { throw new Error('Not implemented'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any - getPromptSlashCommands(_token: CancellationToken): Promise { throw new Error('Not implemented'); } + getPromptSlashCommands(_token: CancellationToken, _sessionId?: string): Promise { throw new Error('Not implemented'); } getPromptSlashCommandName(uri: URI, _token: CancellationToken): Promise { throw new Error('Not implemented'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any parse(_uri: URI, _type: any, _token: CancellationToken): Promise { throw new Error('Not implemented'); } @@ -62,10 +65,11 @@ export class MockPromptsService implements IPromptsService { getDisabledPromptFiles(type: PromptsType): ResourceSet { throw new Error('Method not implemented.'); } setDisabledPromptFiles(type: PromptsType, uris: ResourceSet): void { throw new Error('Method not implemented.'); } registerPromptFileProvider(extension: IExtensionDescription, type: PromptsType, provider: { providePromptFiles: (context: IPromptFileContext, token: CancellationToken) => Promise }): IDisposable { throw new Error('Method not implemented.'); } - findAgentSkills(token: CancellationToken): Promise { throw new Error('Method not implemented.'); } + findAgentSkills(token: CancellationToken, sessionId?: string): Promise { throw new Error('Method not implemented.'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any - getPromptDiscoveryInfo(_type: any, _token: CancellationToken): Promise { throw new Error('Method not implemented.'); } + getPromptDiscoveryInfo(_type: any, _token: CancellationToken, _sessionId?: string): Promise { throw new Error('Method not implemented.'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any getHooks(_token: CancellationToken): Promise { throw new Error('Method not implemented.'); } + getInstructionFiles(_token: CancellationToken, _sessionId?: string): Promise { throw new Error('Method not implemented.'); } dispose(): void { } } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 181505d2801..5a784486c32 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -488,7 +488,7 @@ suite('PromptsService', () => { ]); const instructionFiles = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const context = { files: new ResourceSet([ URI.joinPath(rootFolderUri, 'folder1/main.tsx'), @@ -659,7 +659,7 @@ suite('PromptsService', () => { ]); const instructionFiles = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const context = { files: new ResourceSet([ URI.joinPath(rootFolderUri, 'folder1/main.tsx'), @@ -733,7 +733,7 @@ suite('PromptsService', () => { ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, undefined); const context = new ChatRequestVariableSet(); context.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'README.md'))); diff --git a/src/vscode-dts/vscode.proposed.chatDebug.d.ts b/src/vscode-dts/vscode.proposed.chatDebug.d.ts new file mode 100644 index 00000000000..604cc0f0ef7 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.chatDebug.d.ts @@ -0,0 +1,509 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// version: 1 + +declare module 'vscode' { + /** + * The severity level of a chat debug log event. + */ + export enum ChatDebugLogLevel { + Trace = 0, + Info = 1, + Warning = 2, + Error = 3 + } + + /** + * The outcome of a tool call. + */ + export enum ChatDebugToolCallResult { + Success = 0, + Error = 1 + } + + /** + * A tool call event in the chat debug log, representing the invocation + * of a tool (e.g., file search, terminal command, code edit). + */ + export class ChatDebugToolCallEvent { + /** + * A unique identifier for this event. + */ + id?: string; + + /** + * The chat session this event belongs to. When provided, the event + * is attributed to this session even if it arrives through a progress + * pipeline opened for a different session. + */ + sessionResource?: Uri; + + /** + * The timestamp when the event was created. + */ + created: Date; + + /** + * The id of a parent event, used to build a hierarchical tree + * (e.g., tool calls nested under a model turn). + */ + parentEventId?: string; + + /** + * The name of the tool that was called. + */ + toolName: string; + + /** + * An optional identifier for the tool call, as assigned by the model. + */ + toolCallId?: string; + + /** + * The serialized input (arguments) passed to the tool. + */ + input?: string; + + /** + * The serialized output (result) returned by the tool. + */ + output?: string; + + /** + * The outcome of the tool call. + */ + result?: ChatDebugToolCallResult; + + /** + * How long the tool call took to complete, in milliseconds. + */ + durationInMillis?: number; + + /** + * Create a new ChatDebugToolCallEvent. + * @param toolName The name of the tool that was called. + * @param created The timestamp when the event was created. + */ + constructor(toolName: string, created: Date); + } + + /** + * A model turn event in the chat debug log, representing a single + * request/response exchange with a language model. + */ + export class ChatDebugModelTurnEvent { + /** + * A unique identifier for this event. + */ + id?: string; + + /** + * The chat session this event belongs to. When provided, the event + * is attributed to this session even if it arrives through a progress + * pipeline opened for a different session. + */ + sessionResource?: Uri; + + /** + * The timestamp when the event was created. + */ + created: Date; + + /** + * The id of a parent event, used to build a hierarchical tree. + */ + parentEventId?: string; + + /** + * The identifier of the model used (e.g., "gpt-4o"). + */ + model?: string; + + /** + * The number of tokens in the input/prompt. + */ + inputTokens?: number; + + /** + * The number of tokens in the model's output/completion. + */ + outputTokens?: number; + + /** + * The total number of tokens consumed (input + output). + */ + totalTokens?: number; + + /** + * How long the model turn took to complete, in milliseconds. + */ + durationInMillis?: number; + + /** + * Create a new ChatDebugModelTurnEvent. + * @param created The timestamp when the event was created. + */ + constructor(created: Date); + } + + /** + * A generic log event in the chat debug log, for unstructured or + * miscellaneous messages that don't fit a more specific event type. + */ + export class ChatDebugGenericEvent { + /** + * A unique identifier for this event. + */ + id?: string; + + /** + * The chat session this event belongs to. When provided, the event + * is attributed to this session even if it arrives through a progress + * pipeline opened for a different session. + */ + sessionResource?: Uri; + + /** + * The timestamp when the event was created. + */ + created: Date; + + /** + * The id of a parent event, used to build a hierarchical tree. + */ + parentEventId?: string; + + /** + * A short name describing the event (e.g., "Resolved skills (start)"). + */ + name: string; + + /** + * Optional details of the event. + */ + details?: string; + + /** + * The severity level of the event. + */ + level: ChatDebugLogLevel; + + /** + * The category classifying the kind of event. + */ + category?: string; + + /** + * Create a new ChatDebugGenericEvent. + * @param name A short name describing the event. + * @param level The severity level. + * @param created The timestamp when the event was created. + */ + constructor(name: string, level: ChatDebugLogLevel, created: Date); + } + + /** + * The status of a sub-agent invocation. + */ + export enum ChatDebugSubagentStatus { + Running = 0, + Completed = 1, + Failed = 2 + } + + /** + * A subagent invocation event in the chat debug log, representing + * a spawned sub-agent within a chat session. + */ + export class ChatDebugSubagentInvocationEvent { + /** + * A unique identifier for this event. + */ + id?: string; + + /** + * The chat session this event belongs to. When provided, the event + * is attributed to this session even if it arrives through a progress + * pipeline opened for a different session. + */ + sessionResource?: Uri; + + /** + * The timestamp when the event was created. + */ + created: Date; + + /** + * The id of a parent event, used to build a hierarchical tree. + */ + parentEventId?: string; + + /** + * The name of the sub-agent that was invoked. + */ + agentName: string; + + /** + * A short description of the task assigned to the sub-agent. + */ + description?: string; + + /** + * The current status of the sub-agent invocation. + */ + status?: ChatDebugSubagentStatus; + + /** + * How long the sub-agent took to complete, in milliseconds. + */ + durationInMillis?: number; + + /** + * The number of tool calls made by this sub-agent. + */ + toolCallCount?: number; + + /** + * The number of model turns within this sub-agent. + */ + modelTurnCount?: number; + + /** + * Create a new ChatDebugSubagentInvocationEvent. + * @param agentName The name of the sub-agent. + * @param created The timestamp when the event was created. + */ + constructor(agentName: string, created: Date); + } + + /** + * A user message event in the chat debug log, representing the prompt + * sent by the user (including system context, instructions, etc.). + */ + export class ChatDebugUserMessageEvent { + /** + * A unique identifier for this event. + */ + id?: string; + + /** + * The chat session this event belongs to. When provided, the event + * is attributed to this session even if it arrives through a progress + * pipeline opened for a different session. + */ + sessionResource?: Uri; + + /** + * The timestamp when the event was created. + */ + created: Date; + + /** + * The id of a parent event, used to build a hierarchical tree. + */ + parentEventId?: string; + + /** + * A short summary of the user's request for display in the event list. + */ + message: string; + + /** + * The structured sections of the full prompt (e.g., userRequest, context, + * reminderInstructions). Rendered as collapsible sections in the detail view. + */ + sections: ChatDebugMessageSection[]; + + /** + * Create a new ChatDebugUserMessageEvent. + * @param message A short summary of the user's request. + * @param created The timestamp when the event was created. + */ + constructor(message: string, created: Date); + } + + /** + * An agent response event in the chat debug log, representing the + * response produced by the agent (including reasoning, if available). + */ + export class ChatDebugAgentResponseEvent { + /** + * A unique identifier for this event. + */ + id?: string; + + /** + * The chat session this event belongs to. When provided, the event + * is attributed to this session even if it arrives through a progress + * pipeline opened for a different session. + */ + sessionResource?: Uri; + + /** + * The timestamp when the event was created. + */ + created: Date; + + /** + * The id of a parent event, used to build a hierarchical tree. + */ + parentEventId?: string; + + /** + * A short summary of the agent's response for display in the event list. + */ + message: string; + + /** + * The structured sections of the response (e.g., response text, reasoning). + * Rendered as collapsible sections in the detail view. + */ + sections: ChatDebugMessageSection[]; + + /** + * Create a new ChatDebugAgentResponseEvent. + * @param message A short summary of the agent's response. + * @param created The timestamp when the event was created. + */ + constructor(message: string, created: Date); + } + + /** + * A named section within a user message or agent response, + * used to display collapsible parts of the prompt or response. + */ + export class ChatDebugMessageSection { + /** + * The display name of the section (e.g., "User Request", "Context", "Reasoning"). + */ + name: string; + + /** + * The text content of the section. + */ + content: string; + + /** + * Create a new ChatDebugMessageSection. + * @param name The display name of the section. + * @param content The text content. + */ + constructor(name: string, content: string); + } + + /** + * Plain text content for a resolved chat debug event. + */ + export class ChatDebugEventTextContent { + /** + * The text value. + */ + value: string; + + /** + * Create a new ChatDebugEventTextContent. + * @param value The text value. + */ + constructor(value: string); + } + + /** + * The type of a debug message content. + */ + export enum ChatDebugMessageContentType { + User = 0, + Agent = 1 + } + + /** + * Structured message content for a resolved chat debug event, + * containing collapsible sections (e.g., prompt parts or response parts). + */ + export class ChatDebugEventMessageContent { + /** + * The type of message. + */ + type: ChatDebugMessageContentType; + + /** + * A short summary of the message. + */ + message: string; + + /** + * The structured sections of the message. + */ + sections: ChatDebugMessageSection[]; + + /** + * Create a new ChatDebugEventMessageContent. + * @param type The type of message. + * @param message A short summary. + * @param sections The structured sections. + */ + constructor(type: ChatDebugMessageContentType, message: string, sections: ChatDebugMessageSection[]); + } + + /** + * Union of all resolved event content types. + * Extensions may also return {@link ChatDebugUserMessageEvent} or + * {@link ChatDebugAgentResponseEvent} from resolve, which will be + * automatically converted to structured message content. + */ + export type ChatDebugResolvedEventContent = ChatDebugEventTextContent | ChatDebugEventMessageContent | ChatDebugUserMessageEvent | ChatDebugAgentResponseEvent; + + /** + * Union of all chat debug event types. Each type is a class, + * following the same pattern as {@link ChatResponsePart}. + */ + export type ChatDebugEvent = ChatDebugToolCallEvent | ChatDebugModelTurnEvent | ChatDebugGenericEvent | ChatDebugSubagentInvocationEvent | ChatDebugUserMessageEvent | ChatDebugAgentResponseEvent; + + /** + * A provider that supplies debug events for a chat session. + */ + export interface ChatDebugLogProvider { + /** + * Called when the debug view is opened for a chat session. + * The provider should return initial events and can use + * the progress callback to stream additional events over time. + * + * @param sessionResource The resource URI of the chat session being debugged. + * @param progress A progress callback to stream events. + * @param token A cancellation token. + * @returns Initial events, if any. + */ + provideChatDebugLog( + sessionResource: Uri, + progress: Progress, + token: CancellationToken + ): ProviderResult; + + /** + * Optionally resolve the full contents of a debug event by its id. + * Called when the user expands an event in the debug view, allowing + * the provider to defer expensive detail loading until needed. + * + * @param eventId The id of the event to resolve. + * @param token A cancellation token. + * @returns The resolved event content to be displayed in the debug detail view. + */ + resolveChatDebugLogEvent?( + eventId: string, + token: CancellationToken + ): ProviderResult; + } + + export namespace chat { + /** + * Register a provider that supplies debug events for chat sessions. + * Only one provider can be registered at a time. + * + * @param provider The chat debug log provider. + * @returns A disposable that unregisters the provider. + */ + export function registerChatDebugLogProvider(provider: ChatDebugLogProvider): Disposable; + } +}