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>
This commit is contained in:
Don Jayamanne
2026-06-16 01:24:10 +10:00
committed by GitHub
parent 42f7bd4944
commit e409efdad9
5 changed files with 116 additions and 4 deletions

View File

@@ -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 });

View File

@@ -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<string, unknown> | 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.
*/

View File

@@ -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.

View File

@@ -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 () => {

View File

@@ -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 } },