Display background agent todo tool results (#3400)

* Display background agent tool results

* wip tools
This commit is contained in:
Don Jayamanne
2026-02-03 17:19:03 +11:00
committed by GitHub
parent 2c2c5f19e2
commit ba47da6109
3 changed files with 251 additions and 59 deletions
@@ -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<string, string> } | undefined, delegationSummaryService: IChatDelegationSummaryService, logger: ILogger): (ChatRequestTurn2 | ChatResponseTurn2)[] {
export function buildChatHistoryFromEvents(sessionId: string, events: readonly SessionEvent[], getVSCodeRequestId: (sdkRequestId: string) => { requestId: string; toolIdEditMap: Record<string, string> } | undefined, delegationSummaryService: IChatDelegationSummaryService, logger: ILogger, workingDirectory?: URI): (ChatRequestTurn2 | ChatResponseTurn2)[] {
const turns: (ChatRequestTurn2 | ChatResponseTurn2)[] = [];
let currentResponseParts: ExtendedChatResponsePart[] = [];
const pendingToolInvocations = new Map<string, [ChatToolInvocationPart, toolData: ToolCall]>();
@@ -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<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall]>, logger: ILogger): [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall] | undefined {
export function processToolExecutionComplete(event: ToolExecutionCompleteEvent, pendingToolInvocations: Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall]>, 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 `<exited with exit code ${output.exitCode}>`,
const exitCodeStr = result ? /<exited with exit code (\d+)>$/.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(/<exited with exit code \d+>$/, '').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<T extends ToolCall['toolName']> = Extract<ToolCall, { toolName: T }>;
type ToolCallResult = ToolExecutionCompleteEvent['data'];
const ToolFriendlyNameAndHandlers: { [K in ToolCall['toolName']]: [string, (invocation: ChatToolInvocationPart, toolCall: ToolCallFor<K>) => 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<K>) => void, post: (invocation: ChatToolInvocationPart, toolCall: ToolCallFor<K>, 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 `<exited with exit code ${output.exitCode}>`,
const exitCodeStr = resultContent ? /<exited with exit code (\d+)>$/.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(/<exited with exit code \d+>$/, '').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\`\`\`` : ''
};
}
}
@@ -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(
@@ -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<Uri | Location>;
}
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;