mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-11 04:17:01 -05:00
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:
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user