From e409efdad9deda8d4b95324d8db17cf15f933645 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 16 Jun 2026 01:24:10 +1000 Subject: [PATCH] agentHost: render task_complete summaries as markdown (#321422) * agentHost: render task_complete as markdown Render Copilot Autopilot task_complete summaries as root markdown response parts instead of regular tool calls so they do not appear nested under the previous collapsed tool/thinking block. Applies to both live Agent Host events and restored session history.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: complete replayed task_complete turns Mark restored Copilot task_complete turns complete after converting their summaries to root markdown, matching the live idle-completion path.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/copilot/copilotAgentSession.ts | 20 ++++++++++- .../node/copilot/copilotToolDisplay.ts | 19 +++++++++++ .../node/copilot/mapSessionEvents.ts | 18 +++++++++- .../test/node/copilotAgentSession.test.ts | 29 ++++++++++++++++ .../test/node/historyRecordFixtures.test.ts | 34 +++++++++++++++++-- 5 files changed, 116 insertions(+), 4 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 3885a451084..ce92a192bd7 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -45,7 +45,7 @@ import { parseLeadingSlashCommand } from './copilotSlashCommandCompletionProvide import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './copilotShellTools.js'; import { buildSandboxConfigForSdk } from './sandboxConfigForSdk.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; -import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; +import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getTaskCompleteSummary, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; import { FileEditTracker } from '../shared/fileEditTracker.js'; import { stripProxyErrorMarker, tryBuildChatErrorMeta, tryBuildChatErrorMetaFromFields } from '../shared/forwardedChatError.js'; import { McpCustomizationController, type ISdkMcpServer } from '../shared/mcpCustomizationController.js'; @@ -1966,6 +1966,11 @@ export class CopilotAgentSession extends Disposable { } const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters, content: [], parentToolCallId, startTimeMs: Date.now(), mcpServerName: e.data.mcpServerName, meta: undefined }); + if (isTaskCompleteTool(e.data.toolName)) { + this._currentMarkdownPartIds.delete(''); + this._currentReasoningPartIds.delete(''); + return; + } const toolKind = getToolKind(e.data.toolName); const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined; @@ -2097,6 +2102,19 @@ export class CopilotAgentSession extends Disposable { const displayName = tracked.displayName; const toolOutput = e.data.error?.message ?? e.data.result?.content; + if (isTaskCompleteTool(tracked.toolName)) { + this._sendToolInvokedTelemetry(e.data.success, e.data.error?.code, tracked); + const summary = getTaskCompleteSummary(tracked.parameters, toolOutput); + if (summary) { + this._emitAction({ + type: ActionType.SessionResponsePart, + turnId: this._turnId, + part: { kind: ResponsePartKind.Markdown, id: generateUuid(), content: summary }, + }); + } + return; + } + const content: ToolResultContent[] = [...tracked.content]; if (toolOutput !== undefined) { content.push({ type: ToolResultContentType.Text, text: toolOutput }); diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index bf2d438e32b..18c6808ac25 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -379,6 +379,25 @@ export function isHiddenTool(toolName: string): boolean { return HIDDEN_TOOL_NAMES.has(toolName); } +/** + * Returns true when the tool is Copilot's internal Autopilot completion signal. + */ +export function isTaskCompleteTool(toolName: string): boolean { + return toolName === CopilotToolName.TaskComplete; +} + +/** + * Extracts the user-facing Autopilot completion summary from the tool output, + * falling back to the original `summary` argument for older/incomplete events. + */ +export function getTaskCompleteSummary(parameters: Record | undefined, toolOutput: string | undefined): string | undefined { + if (toolOutput && toolOutput.trim().length > 0) { + return toolOutput; + } + const summary = parameters?.summary; + return typeof summary === 'string' && summary.trim().length > 0 ? summary : undefined; +} + /** * Returns true if the tool executes shell commands. */ diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 632c2994430..fa030d0acf9 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -13,7 +13,7 @@ import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js'; import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type Message, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; -import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, synthesizeSkillToolCall } from './copilotToolDisplay.js'; +import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getTaskCompleteSummary, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isTaskCompleteTool, synthesizeSkillToolCall } from './copilotToolDisplay.js'; import { buildSessionDbUri } from '../shared/fileEditTracker.js'; import { getMediaMime } from '../../../../base/common/mime.js'; @@ -435,6 +435,22 @@ export async function mapSessionEvents( continue; } toolInfoByCallId.delete(d.toolCallId); + if (isTaskCompleteTool(info.toolName)) { + const summary = getTaskCompleteSummary(info.parameters, d.error?.message ?? d.result?.content); + const builder = parentBuilder ?? (parentBuilder = newTurnBuilder(generateUuid(), '')); + if (summary) { + builder.responseParts.push({ + kind: ResponsePartKind.Markdown, + id: generateUuid(), + content: summary, + }); + } + if (d.success && builder === parentBuilder) { + turns.push(finalizeTurn(parentBuilder, TurnState.Complete)); + parentBuilder = undefined; + } + continue; + } const builder = targetBuilderFor(d.parentToolCallId); if (!builder) { // No active turn to attach this completion to. diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 32c63834ac3..430248f7245 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -1505,6 +1505,35 @@ suite('CopilotAgentSession', () => { invocationTimeMs: true, }, }); + + }); + + test('live task_complete emits root markdown instead of a tool call', async () => { + const { mockSession, signals } = await createAgentSession(disposables); + + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-task-complete', + toolName: 'task_complete', + arguments: { summary: 'Completed the requested work.' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + mockSession.fire('tool.execution_complete', { + toolCallId: 'tc-task-complete', + success: true, + result: { content: 'Completed the requested work.' }, + } as SessionEventPayload<'tool.execution_complete'>['data']); + + const actions = getActions(signals); + assert.deepStrictEqual(actions.map(a => a.type), [ActionType.SessionResponsePart]); + const responsePart = actions[0] as SessionResponsePartAction; + assert.strictEqual(responsePart.part.kind, ResponsePartKind.Markdown); + if (responsePart.part.kind !== ResponsePartKind.Markdown) { + return; + } + assert.deepStrictEqual(responsePart.part, { + kind: ResponsePartKind.Markdown, + id: responsePart.part.id, + content: 'Completed the requested work.', + }); }); test('live tool_start does not rewrite when cd target differs from workingDirectory', async () => { diff --git a/src/vs/platform/agentHost/test/node/historyRecordFixtures.test.ts b/src/vs/platform/agentHost/test/node/historyRecordFixtures.test.ts index 0d44572f604..0c14d4cb328 100644 --- a/src/vs/platform/agentHost/test/node/historyRecordFixtures.test.ts +++ b/src/vs/platform/agentHost/test/node/historyRecordFixtures.test.ts @@ -8,11 +8,11 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AgentSession } from '../../common/agentService.js'; -import { FileEditKind, ToolResultContentType } from '../../common/state/sessionState.js'; +import { FileEditKind, MessageKind, ResponsePartKind, ToolResultContentType } from '../../common/state/sessionState.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { parseSessionDbUri } from '../../node/shared/fileEditTracker.js'; import { mapSessionEventsToHistoryRecords } from './historyRecordFixtures.js'; -import { type ISessionEvent } from '../../node/copilot/mapSessionEvents.js'; +import { mapSessionEvents, type ISessionEvent } from '../../node/copilot/mapSessionEvents.js'; suite('mapSessionEventsToHistoryRecords', () => { @@ -74,6 +74,36 @@ suite('mapSessionEventsToHistoryRecords', () => { assert.strictEqual(complete.result.content[0].type, ToolResultContentType.Text); }); + test('maps task_complete to a root markdown response part', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'turn-1', data: { messageId: 'msg-1', content: 'finish this' } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-read', toolName: 'view', arguments: { path: '/workspace/index.html' } } }, + { type: 'tool.execution_complete', data: { toolCallId: 'tc-read', success: true, result: { content: 'file contents' } } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-task-complete', toolName: 'task_complete', arguments: { summary: 'Reviewed index.html.' } } }, + { type: 'tool.execution_complete', data: { toolCallId: 'tc-task-complete', success: true, result: { content: 'Reviewed index.html.' } } }, + ]; + + const result = await mapSessionEvents(session, undefined, events); + assert.deepStrictEqual(result.turns.map(turn => ({ + message: turn.message, + state: turn.state, + parts: turn.responseParts.map(part => part.kind === ResponsePartKind.ToolCall ? { + kind: part.kind, + toolName: part.toolCall.toolName, + } : { + kind: part.kind, + content: part.kind === ResponsePartKind.Markdown ? part.content : undefined, + }), + })), [{ + message: { text: 'finish this', origin: { kind: MessageKind.User } }, + state: 'complete', + parts: [ + { kind: ResponsePartKind.ToolCall, toolName: 'view' }, + { kind: ResponsePartKind.Markdown, content: 'Reviewed index.html.' }, + ], + }]); + }); + test('skips tool_complete without matching tool_start', async () => { const events: ISessionEvent[] = [ { type: 'tool.execution_complete', data: { toolCallId: 'orphan', success: true } },