From ba47da6109937d5ef192ccfa1e73dca09437a0bb Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 3 Feb 2026 17:19:03 +1100 Subject: [PATCH] Display background agent todo tool results (#3400) * Display background agent tool results * wip tools --- .../copilotcli/common/copilotCLITools.ts | 268 ++++++++++++++---- .../copilotcli/node/copilotcliSession.ts | 4 +- ...ode.proposed.chatParticipantAdditions.d.ts | 38 ++- 3 files changed, 251 insertions(+), 59 deletions(-) diff --git a/extensions/copilot/src/extension/agents/copilotcli/common/copilotCLITools.ts b/extensions/copilot/src/extension/agents/copilotcli/common/copilotCLITools.ts index d5a1f973d5f..8fbf738448b 100644 --- a/extensions/copilot/src/extension/agents/copilotcli/common/copilotCLITools.ts +++ b/extensions/copilot/src/extension/agents/copilotcli/common/copilotCLITools.ts @@ -5,11 +5,12 @@ import type { SessionEvent, ToolExecutionCompleteEvent, ToolExecutionStartEvent } from '@github/copilot/sdk'; import * as l10n from '@vscode/l10n'; -import type { ChatPromptReference, ChatTerminalToolInvocationData, ExtendedChatResponsePart } from 'vscode'; +import type { ChatPromptReference, ChatTerminalToolInvocationData, ChatTodoStatus, ChatTodoToolInvocationData, ExtendedChatResponsePart } from 'vscode'; import { ILogger } from '../../../../platform/log/common/logService'; import { isLocation } from '../../../../util/common/types'; import { decodeBase64 } from '../../../../util/vs/base/common/buffer'; import { ResourceSet } from '../../../../util/vs/base/common/map'; +import { isAbsolutePath } from '../../../../util/vs/base/common/resources'; import { URI } from '../../../../util/vs/base/common/uri'; import { ChatMcpToolInvocationData, ChatRequestTurn2, ChatResponseCodeblockUriPart, ChatResponseMarkdownPart, ChatResponsePullRequestPart, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn2, ChatToolInvocationPart, Location, MarkdownString, McpToolInvocationContentData, Range, Uri } from '../../../../vscodeTypes'; import type { MCP } from '../../../common/modelContextProtocol'; @@ -309,7 +310,7 @@ function extractPRMetadata(content: string): { cleanedContent: string; prPart?: * Build chat history from SDK events for VS Code chat session * Converts SDKEvents into ChatRequestTurn2 and ChatResponseTurn2 objects */ -export function buildChatHistoryFromEvents(sessionId: string, events: readonly SessionEvent[], getVSCodeRequestId: (sdkRequestId: string) => { requestId: string; toolIdEditMap: Record } | undefined, delegationSummaryService: IChatDelegationSummaryService, logger: ILogger): (ChatRequestTurn2 | ChatResponseTurn2)[] { +export function buildChatHistoryFromEvents(sessionId: string, events: readonly SessionEvent[], getVSCodeRequestId: (sdkRequestId: string) => { requestId: string; toolIdEditMap: Record } | undefined, delegationSummaryService: IChatDelegationSummaryService, logger: ILogger, workingDirectory?: URI): (ChatRequestTurn2 | ChatResponseTurn2)[] { const turns: (ChatRequestTurn2 | ChatResponseTurn2)[] = []; let currentResponseParts: ExtendedChatResponsePart[] = []; const pendingToolInvocations = new Map(); @@ -431,7 +432,7 @@ export function buildChatHistoryFromEvents(sessionId: string, events: readonly S break; } case 'tool.execution_complete': { - const [responsePart, toolCall] = processToolExecutionComplete(event, pendingToolInvocations, logger) ?? [undefined, undefined]; + const [responsePart, toolCall] = processToolExecutionComplete(event, pendingToolInvocations, logger, workingDirectory) ?? [undefined, undefined]; if (responsePart && toolCall && !(responsePart instanceof ChatResponseThinkingProgressPart)) { const editId = details?.toolIdEditMap ? details.toolIdEditMap[toolCall.toolCallId] : undefined; const editedUris = getAffectedUrisForEditTool(toolCall); @@ -563,7 +564,7 @@ export function processToolExecutionStart(event: ToolExecutionStartEvent, pendin return toolInvocation; } -export function processToolExecutionComplete(event: ToolExecutionCompleteEvent, pendingToolInvocations: Map, logger: ILogger): [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall] | undefined { +export function processToolExecutionComplete(event: ToolExecutionCompleteEvent, pendingToolInvocations: Map, logger: ILogger, workingDirectory?: URI): [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall] | undefined { const invocation = pendingToolInvocations.get(event.data.toolCallId); pendingToolInvocations.delete(event.data.toolCallId); @@ -594,26 +595,9 @@ export function processToolExecutionComplete(event: ToolExecutionCompleteEvent, } } - if (toolCall.toolName === 'bash' || toolCall.toolName === 'powershell') { - const result = event.data.result?.content || ''; - // Exit code will be at the end of the result in the last line in the form of ``, - const exitCodeStr = result ? /$/.exec(result)?.[1] : undefined; - const exitCode = exitCodeStr ? parseInt(exitCodeStr, 10) : undefined; - // Lets remove the last line containing the exit code from the output. - const text = (exitCode !== undefined ? result.replace(/$/, '').trimEnd() : result).replace(/\n/g, '\r\n'); - const toolSpecificData: ChatTerminalToolInvocationData = { - commandLine: { - original: toolCall.arguments.command, - }, - language: toolCall.toolName === 'bash' ? 'bash' : 'powershell', - state: { - exitCode - }, - output: { - text - } - }; - invocation[0].toolSpecificData = toolSpecificData; + if (Object.hasOwn(ToolFriendlyNameAndHandlers, toolCall.toolName)) { + const [, , postFormatter] = ToolFriendlyNameAndHandlers[toolCall.toolName]; + (postFormatter as PostInvocationFormatter)(invocation[0], toolCall, event.data, workingDirectory); } } @@ -655,37 +639,40 @@ export function createCopilotCLIToolInvocation(data: { toolCallId: string; toolN } type Formatter = (invocation: ChatToolInvocationPart, toolCall: ToolCall, editId?: string) => void; +type PostInvocationFormatter = (invocation: ChatToolInvocationPart, toolCall: ToolCall, result: ToolCallResult, workingDirectory?: URI) => void; type ToolCallFor = Extract; +type ToolCallResult = ToolExecutionCompleteEvent['data']; -const ToolFriendlyNameAndHandlers: { [K in ToolCall['toolName']]: [string, (invocation: ChatToolInvocationPart, toolCall: ToolCallFor) => void] } = { - 'str_replace_editor': [l10n.t('Edit File'), formatStrReplaceEditorInvocation], - 'edit': [l10n.t('Edit File'), formatEditToolInvocation], - 'str_replace': [l10n.t('Edit File'), formatEditToolInvocation], - 'create': [l10n.t('Create File'), formatCreateToolInvocation], - 'insert': [l10n.t('Edit File'), formatInsertToolInvocation], - 'undo_edit': [l10n.t('Edit File'), formatUndoEdit], - 'view': [l10n.t('Read'), formatViewToolInvocation], - 'bash': [l10n.t('Run Shell Command'), formatShellInvocation], - 'powershell': [l10n.t('Run Shell Command'), formatShellInvocation], - 'write_bash': [l10n.t('Write to Bash'), emptyInvocation], - 'write_powershell': [l10n.t('Write to PowerShell'), emptyInvocation], - 'read_bash': [l10n.t('Read Terminal'), emptyInvocation], - 'read_powershell': [l10n.t('Read Terminal'), emptyInvocation], - 'stop_bash': [l10n.t('Stop Terminal Session'), emptyInvocation], - 'stop_powershell': [l10n.t('Stop Terminal Session'), emptyInvocation], - 'search': [l10n.t('Search'), formatSearchToolInvocation], - 'grep': [l10n.t('Search'), formatSearchToolInvocation], - 'glob': [l10n.t('Search'), formatSearchToolInvocation], - 'search_bash': [l10n.t('Search'), formatSearchToolInvocation], - 'semantic_code_search': [l10n.t('Search'), formatSearchToolInvocation], - 'reply_to_comment': [l10n.t('Reply to Comment'), formatReplyToCommentInvocation], - 'code_review': [l10n.t('Code Review'), formatCodeReviewInvocation], - 'report_intent': [l10n.t('Report Intent'), emptyInvocation], - 'think': [l10n.t('Thinking'), emptyInvocation], - 'report_progress': [l10n.t('Progress update'), formatProgressToolInvocation], - 'web_fetch': [l10n.t('Fetch Web Content'), emptyInvocation], - 'web_search': [l10n.t('Web Search'), emptyInvocation], - 'update_todo': [l10n.t('Update Todo'), emptyInvocation], + +const ToolFriendlyNameAndHandlers: { [K in ToolCall['toolName']]: [title: string, pre: (invocation: ChatToolInvocationPart, toolCall: ToolCallFor) => void, post: (invocation: ChatToolInvocationPart, toolCall: ToolCallFor, result: ToolCallResult) => void] } = { + 'str_replace_editor': [l10n.t('Edit File'), formatStrReplaceEditorInvocation, emptyInvocation], + 'edit': [l10n.t('Edit File'), formatEditToolInvocation, emptyInvocation], + 'str_replace': [l10n.t('Edit File'), formatEditToolInvocation, emptyInvocation], + 'create': [l10n.t('Create File'), formatCreateToolInvocation, emptyInvocation], + 'insert': [l10n.t('Edit File'), formatInsertToolInvocation, emptyInvocation], + 'undo_edit': [l10n.t('Edit File'), formatUndoEdit, emptyInvocation], + 'view': [l10n.t('Read'), formatViewToolInvocation, emptyInvocation], + 'bash': [l10n.t('Run Shell Command'), formatShellInvocation, formatShellInvocationCompleted], + 'powershell': [l10n.t('Run Shell Command'), formatShellInvocation, formatShellInvocationCompleted], + 'write_bash': [l10n.t('Write to Bash'), emptyInvocation, emptyInvocation], + 'write_powershell': [l10n.t('Write to PowerShell'), emptyInvocation, emptyInvocation], + 'read_bash': [l10n.t('Read Terminal'), emptyInvocation, emptyInvocation], + 'read_powershell': [l10n.t('Read Terminal'), emptyInvocation, emptyInvocation], + 'stop_bash': [l10n.t('Stop Terminal Session'), emptyInvocation, emptyInvocation], + 'stop_powershell': [l10n.t('Stop Terminal Session'), emptyInvocation, emptyInvocation], + 'search': [l10n.t('Search'), formatSearchToolInvocation, genericToolInvocationCompleted], + 'grep': [l10n.t('Search'), formatSearchToolInvocation, formatSearchToolInvocationCompleted], + 'glob': [l10n.t('Search'), formatSearchToolInvocation, formatSearchToolInvocationCompleted], + 'search_bash': [l10n.t('Search'), formatSearchToolInvocation, genericToolInvocationCompleted], + 'semantic_code_search': [l10n.t('Search'), formatSearchToolInvocation, genericToolInvocationCompleted], + 'reply_to_comment': [l10n.t('Reply to Comment'), formatReplyToCommentInvocation, genericToolInvocationCompleted], + 'code_review': [l10n.t('Code Review'), formatCodeReviewInvocation, genericToolInvocationCompleted], + 'report_intent': [l10n.t('Report Intent'), emptyInvocation, emptyInvocation], + 'think': [l10n.t('Thinking'), emptyInvocation, emptyInvocation], + 'report_progress': [l10n.t('Progress update'), formatProgressToolInvocation, genericToolInvocationCompleted], + 'web_fetch': [l10n.t('Fetch Web Content'), emptyInvocation, genericToolInvocationCompleted], + 'web_search': [l10n.t('Web Search'), emptyInvocation, genericToolInvocationCompleted], + 'update_todo': [l10n.t('Update Todo'), formatUpdateTodoInvocation, formatUpdateTodoInvocationCompleted], }; @@ -810,6 +797,28 @@ function formatShellInvocation(invocation: ChatToolInvocationPart, toolCall: She language: toolCall.toolName === 'bash' ? 'bash' : 'powershell' } as ChatTerminalToolInvocationData; } +function formatShellInvocationCompleted(invocation: ChatToolInvocationPart, toolCall: ShellTool, result: ToolCallResult): void { + const resultContent = result.result?.content || ''; + // Exit code will be at the end of the result in the last line in the form of ``, + const exitCodeStr = resultContent ? /$/.exec(resultContent)?.[1] : undefined; + const exitCode = exitCodeStr ? parseInt(exitCodeStr, 10) : undefined; + // Lets remove the last line containing the exit code from the output. + const text = (exitCode !== undefined ? resultContent.replace(/$/, '').trimEnd() : resultContent).replace(/\n/g, '\r\n'); + const toolSpecificData: ChatTerminalToolInvocationData = { + commandLine: { + original: toolCall.arguments.command, + }, + language: toolCall.toolName === 'bash' ? 'bash' : 'powershell', + state: { + exitCode + }, + output: { + text + } + }; + invocation.toolSpecificData = toolSpecificData; + +} function formatSearchToolInvocation(invocation: ChatToolInvocationPart, toolCall: SearchTool | GLobTool | GrepTool | SearchBashTool | SemanticCodeSearchTool): void { if (toolCall.toolName === 'search') { invocation.invocationMessage = `Criteria: ${toolCall.arguments.question} \nReason: ${toolCall.arguments.reason}`; @@ -819,10 +828,40 @@ function formatSearchToolInvocation(invocation: ChatToolInvocationPart, toolCall invocation.invocationMessage = `Command: \`${toolCall.arguments.command}\``; } else if (toolCall.toolName === 'glob') { const searchInPath = toolCall.arguments.path ? ` in \`${toolCall.arguments.path}\`` : ''; - invocation.invocationMessage = `Search: \`${toolCall.arguments.pattern}\`${searchInPath}`; + invocation.invocationMessage = `Search for files matching \`${toolCall.arguments.pattern}\`${searchInPath}`; + invocation.pastTenseMessage = `Searched for files matching \`${toolCall.arguments.pattern}\`${searchInPath}`; } else if (toolCall.toolName === 'grep') { const searchInPath = toolCall.arguments.path ? ` in \`${toolCall.arguments.path}\`` : ''; - invocation.invocationMessage = `Search: \`${toolCall.arguments.pattern}\`${searchInPath}`; + invocation.invocationMessage = `Search for files matching \`${toolCall.arguments.pattern}\`${searchInPath}`; + invocation.pastTenseMessage = `Searched for files matching \`${toolCall.arguments.pattern}\`${searchInPath}`; + } +} + +function formatSearchToolInvocationCompleted(invocation: ChatToolInvocationPart, toolCall: SearchTool | GLobTool | GrepTool | SearchBashTool | SemanticCodeSearchTool, result: ToolCallResult, workingDirectory?: URI): void { + if (toolCall.toolName === 'search') { + // invocation.invocationMessage = `Criteria: ${toolCall.arguments.question} \nReason: ${toolCall.arguments.reason}`; + } else if (toolCall.toolName === 'semantic_code_search') { + // invocation.invocationMessage = `Criteria: ${toolCall.arguments.question}`; + } else if (toolCall.toolName === 'search_bash') { + // invocation.invocationMessage = `Command: \`${toolCall.arguments.command}\``; + } else if (toolCall.toolName === 'glob' || toolCall.toolName === 'grep') { + const noMatches = (result.result?.content || '').toLowerCase().includes('no matches found') || (result.result?.content || '').toLowerCase().includes('no files matched the pattern'); + const searchInPath = toolCall.arguments.path ? ` in \`${toolCall.arguments.path}\`` : ''; + const files = !noMatches && result.success && typeof result.result?.content === 'string' ? result.result.content.split('\n') : []; + const successMessage = files.length ? `, ${files.length} result${files.length > 1 ? 's' : ''}` : '.'; + invocation.pastTenseMessage = `Searched for files matching \`${toolCall.arguments.pattern}\`${searchInPath}${successMessage}`; + let searchPath = toolCall.arguments.path ? Uri.file(toolCall.arguments.path) : workingDirectory; + if (toolCall.arguments.path && workingDirectory && searchPath && !isAbsolutePath(searchPath)) { + searchPath = Uri.joinPath(workingDirectory, toolCall.arguments.path); + } + invocation.toolSpecificData = { + values: files.map(file => { + if (!file.startsWith('./') || !searchPath) { + return Uri.file(file); + } + return Uri.joinPath(searchPath, file.substring(2)); + }) + }; } } @@ -840,10 +879,127 @@ function formatGenericInvocation(invocation: ChatToolInvocationPart, toolCall: U invocation.invocationMessage = l10n.t("Used tool: {0}", toolCall.toolName ?? 'unknown'); } +/** + * Parse markdown todo list into structured ChatTodoToolInvocationData. + * Extracts title from first non-empty line (strips leading #), parses checklist items, + * and generates sequential numeric IDs. + */ +function parseTodoMarkdown(markdown: string): { title: string; todoList: Array<{ id: string; title: string; status: ChatTodoStatus }> } { + const lines = markdown.split('\n'); + const todoList: Array<{ id: string; title: string; status: ChatTodoStatus }> = []; + let title = 'Updated todo list'; + let inCodeBlock = false; + let currentItem: { title: string; status: ChatTodoStatus } | null = null; + + for (const line of lines) { + // Track code fences + if (line.trim().startsWith('```') || line.trim().startsWith('~~~')) { + inCodeBlock = !inCodeBlock; + continue; + } + + // Skip lines inside code blocks + if (inCodeBlock) { + continue; + } + + // Extract title from first non-empty line + if (title === 'Updated todo list' && line.trim()) { + const trimmed = line.trim(); + // Check if it's not a list item + if (!trimmed.match(/^[-*+]\s+\[.\]/) && !trimmed.match(/^\d+[.)]\s+\[.\]/)) { + // Strip leading # for headings + title = trimmed.replace(/^#+\s*/, ''); + } + } + + // Parse checklist items (unordered and ordered lists) + const unorderedMatch = line.match(/^\s*[-*+]\s+\[(.?)\]\s*(.*)$/); + const orderedMatch = line.match(/^\s*\d+[.)]\s+\[(.?)\]\s*(.*)$/); + const match = unorderedMatch || orderedMatch; + + if (match) { + // Save previous item if exists + if (currentItem && currentItem.title.trim()) { + todoList.push({ + id: String(todoList.length + 1), + title: currentItem.title.trim(), + status: currentItem.status + }); + } + + const checkboxChar = match[1]; + const itemTitle = match[2]; + + // Map checkbox character to status + let status: ChatTodoStatus; + if (checkboxChar === 'x' || checkboxChar === 'X') { + status = 3; // ChatTodoStatus.Completed + } else if (checkboxChar === '>' || checkboxChar === '~') { + status = 2; // ChatTodoStatus.InProgress + } else { + status = 1; // ChatTodoStatus.NotStarted + } + + currentItem = { title: itemTitle, status }; + } else if (currentItem && line.trim() && (line.startsWith(' ') || line.startsWith('\t'))) { + // Continuation line - append to current item + currentItem.title += ' ' + line.trim(); + } + } + + // Add the last item + if (currentItem && currentItem.title.trim()) { + todoList.push({ + id: String(todoList.length + 1), + title: currentItem.title.trim(), + status: currentItem.status + }); + } + + return { title, todoList }; +} + +function formatUpdateTodoInvocation(invocation: ChatToolInvocationPart, toolCall: UpdateTodoTool): void { + const args = toolCall.arguments; + const parsed = args.todos ? parseTodoMarkdown(args.todos) : { title: '', todoList: [] }; + if (!args.todos || !parsed) { + invocation.invocationMessage = 'Updated todo list'; + return; + } + + invocation.invocationMessage = parsed.title; + invocation.toolSpecificData = { + todoList: parsed.todoList + } as ChatTodoToolInvocationData; +} + +function formatUpdateTodoInvocationCompleted(invocation: ChatToolInvocationPart, toolCall: UpdateTodoTool, result: ToolCallResult): void { + const parsed = toolCall.arguments.todos ? parseTodoMarkdown(toolCall.arguments.todos) : { title: '', todoList: [] }; + // Re-parse todo markdown on completion to ensure UI has final state + if (parsed.todoList.length > 0) { + invocation.invocationMessage = parsed.title; + invocation.toolSpecificData = { + todoList: parsed.todoList + } as ChatTodoToolInvocationData; + } + +} + /** * No-op formatter for tool invocations that do not require custom formatting. * The `toolCall` parameter is unused and present for interface consistency. */ function emptyInvocation(_invocation: ChatToolInvocationPart, _toolCall: UnknownToolCall): void { - // + // No custom formatting needed +} + +function genericToolInvocationCompleted(_invocation: ChatToolInvocationPart, toolCall: UnknownToolCall, result: ToolCallResult): void { + if (result.success && result.result?.content && typeof result.result.content === 'string') { + _invocation.toolSpecificData = { + output: new MarkdownString(result.result.content), + input: toolCall.arguments ? `\`\`\`json\n${JSON.stringify(toolCall.arguments, null, 2)}\n\`\`\`` : '' + }; + } + } diff --git a/extensions/copilot/src/extension/agents/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/agents/copilotcli/node/copilotcliSession.ts index e046c0ffe47..73e430d1606 100644 --- a/extensions/copilot/src/extension/agents/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/agents/copilotcli/node/copilotcliSession.ts @@ -319,7 +319,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes return; } - const [responsePart,] = processToolExecutionComplete(event, pendingToolInvocations, this.logService) ?? []; + const [responsePart,] = processToolExecutionComplete(event, pendingToolInvocations, this.logService, this.options.workingDirectory) ?? []; if (responsePart && !(responsePart instanceof ChatResponseThinkingProgressPart)) { this._stream?.push(responsePart); } @@ -400,7 +400,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes const getVSCodeRequestId = (sdkRequestId: string) => { return this.copilotCLISDK.getRequestId(sdkRequestId); }; - return buildChatHistoryFromEvents(this.sessionId, events, getVSCodeRequestId, this._delegationSummaryService, this.logService); + return buildChatHistoryFromEvents(this.sessionId, events, getVSCodeRequestId, this._delegationSummaryService, this.logService, this.options.workingDirectory); } private async requestPermission( diff --git a/extensions/copilot/src/extension/vscode.proposed.chatParticipantAdditions.d.ts b/extensions/copilot/src/extension/vscode.proposed.chatParticipantAdditions.d.ts index f811901f24d..c2c8c78a0aa 100644 --- a/extensions/copilot/src/extension/vscode.proposed.chatParticipantAdditions.d.ts +++ b/extensions/copilot/src/extension/vscode.proposed.chatParticipantAdditions.d.ts @@ -266,6 +266,42 @@ declare module 'vscode' { output: McpToolInvocationContentData[]; } + export enum ChatTodoStatus { + NotStarted = 1, + InProgress = 2, + Completed = 3 + } + + export interface ChatTodoToolInvocationData { + todoList: Array<{ + id: string; + title: string; + status: ChatTodoStatus; + }>; + } + + /** + * Generic tool result data that displays input and output in collapsible sections. + * Use plain strings for unformatted text or MarkdownString for formatted markdown. + */ + export interface ChatGenericToolResultData { + /** + * The input to display. Can be a plain string (renders as text) or MarkdownString (renders with markdown formatting). + */ + input: string | MarkdownString; + /** + * The output to display. Can be a plain string (renders as text) or MarkdownString (renders with markdown formatting). + */ + output: string | MarkdownString; + } + + export interface ChatToolResourcesInvocationData { + /** + * Array of file URIs or locations to display as a collapsible list + */ + values: Array; + } + export class ChatToolInvocationPart { toolName: string; toolCallId: string; @@ -275,7 +311,7 @@ declare module 'vscode' { pastTenseMessage?: string | MarkdownString; isConfirmed?: boolean; isComplete?: boolean; - toolSpecificData?: ChatTerminalToolInvocationData | ChatMcpToolInvocationData; + toolSpecificData?: ChatTerminalToolInvocationData | ChatMcpToolInvocationData | ChatTodoToolInvocationData | ChatGenericToolResultData | ChatToolResourcesInvocationData; subAgentInvocationId?: string; presentation?: 'hidden' | 'hiddenAfterComplete' | undefined;