From 07a63f4c0ee29ad92012c8a778bb147efa912c1c Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:40:26 +0200 Subject: [PATCH] Render addComment feedback tool call as a rich reference in chat (#323853) * Render addComment feedback tool call as a rich reference in chat Show the agent-host `addComment` tool call as a comment-icon reference with the tool name and a quoted 20-char preview of the comment body. Clicking it reveals the comment (agent feedback) in the editor via a new `_agentFeedbackReview.revealAt` command that resolves the owning session from the file resource. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback - RevealAt: prefer getSessionForFile (falls back to active session for in-scope files) before getMostRecentSessionForResource, so the rendered reference works before the resource has accumulated feedback. - isOneBasedRange: enforce integer, >= 1 coordinates so invalid ranges fall back to the server-authored message. - Use import type for IMarkdownString in the test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix compile error: narrow out Streaming state before reading toolInput ToolCallState is a union whose Streaming member has no `toolInput` (it lives on ToolCallParameterFields). Guard addCommentReference on status !== Streaming so the property access type-checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix eslint no-duplicate-imports for range.js Merge the value import of Range and the type-only import of IRange into a single import statement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/meta/agentFeedbackAnnotations.ts | 18 +++ .../node/shared/agentFeedbackServerTools.ts | 4 +- .../browser/agentFeedbackReviewCommands.ts | 27 +++++ .../agentHost/stateToProgressAdapter.ts | 113 +++++++++++++++++- .../chat/common/chatService/chatService.ts | 8 +- .../stateToProgressAdapter.test.ts | 64 ++++++++++ 6 files changed, 226 insertions(+), 8 deletions(-) diff --git a/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts b/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts index f7e1c46b12f..ad993f75654 100644 --- a/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts +++ b/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts @@ -31,6 +31,15 @@ export const FEEDBACK_ANNOTATION_META_KEY = 'vscode.agentFeedback'; */ export const VIEW_UNREVIEWED_COMMENTS_TOOL_NAME = 'viewUnreviewedComments'; +/** + * Name of the agent host server tool that adds a comment (agent feedback) to a + * file range. Shared here (in the layer-neutral `common` module) so the + * node-side server tool implementation and the browser-side chat adapter that + * renders its tool call agree on the name without drifting. The agent sees this + * name directly (Copilot) or prefixed as `mcp__host__` (Claude). + */ +export const ADD_COMMENT_TOOL_NAME = 'addComment'; + /** * Whether {@link toolName} (a tool name as seen on a tool call) refers to the * {@link VIEW_UNREVIEWED_COMMENTS_TOOL_NAME} server tool. Accepts both the bare @@ -40,6 +49,15 @@ export function isViewUnreviewedCommentsTool(toolName: string): boolean { return toolName === VIEW_UNREVIEWED_COMMENTS_TOOL_NAME || toolName.endsWith(`__${VIEW_UNREVIEWED_COMMENTS_TOOL_NAME}`); } +/** + * Whether {@link toolName} (a tool name as seen on a tool call) refers to the + * {@link ADD_COMMENT_TOOL_NAME} server tool. Accepts both the bare name and the + * Claude `mcp____` prefixed form. + */ +export function isAddCommentTool(toolName: string): boolean { + return toolName === ADD_COMMENT_TOOL_NAME || toolName.endsWith(`__${ADD_COMMENT_TOOL_NAME}`); +} + /** * Origin of a feedback item. String values match the client-side * `AgentFeedbackKind` enum so a value written by either side decodes on the diff --git a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts index 79bd90a6180..8d7580386a1 100644 --- a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts @@ -5,7 +5,7 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; -import { FEEDBACK_ANNOTATION_META_KEY, readFeedbackAnnotationMeta, VIEW_UNREVIEWED_COMMENTS_TOOL_NAME, type IFeedbackAnnotationMeta } from '../../common/meta/agentFeedbackAnnotations.js'; +import { FEEDBACK_ANNOTATION_META_KEY, readFeedbackAnnotationMeta, VIEW_UNREVIEWED_COMMENTS_TOOL_NAME, ADD_COMMENT_TOOL_NAME, type IFeedbackAnnotationMeta } from '../../common/meta/agentFeedbackAnnotations.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import type { AnnotationsAction } from '../../common/state/sessionActions.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; @@ -27,7 +27,7 @@ import type { IServerToolDisplay, IServerToolDisplayResult, IServerToolGroup } f * the actions) lives in the caller. */ -export const addCommentToolName = 'addComment'; +export const addCommentToolName = ADD_COMMENT_TOOL_NAME; export const listCommentsToolName = 'listComments'; export const deleteCommentsToolName = 'deleteComments'; export const resolveCommentsToolName = 'resolveComments'; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackReviewCommands.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackReviewCommands.ts index b8cc17b07c7..31713c4bc9c 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackReviewCommands.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackReviewCommands.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { URI, UriComponents } from '../../../../base/common/uri.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { Range, type IRange } from '../../../../editor/common/core/range.js'; import { localize } from '../../../../nls.js'; import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { AgentFeedbackReviewCommandId, IChatAgentFeedbackReviewComment } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -57,6 +59,31 @@ export function registerAgentFeedbackReviewCommands(): void { await feedbackService.revealFeedback(URI.revive(sessionResource), commentId); }); + CommandsRegistry.registerCommand(AgentFeedbackReviewCommandId.RevealAt, async (accessor, resourceUri: string, range: IRange): Promise => { + const feedbackService = accessor.get(IAgentFeedbackService); + const resource = URI.parse(resourceUri); + // A rendered `addComment` tool call links here without knowing the + // session URI, so resolve the owning session from the file it commented + // on. Prefer the session the file belongs to (which falls back to the + // active session for in-scope files, so the "open file at range" + // affordance works even before the resource has accumulated feedback); + // fall back to the most recent session that has feedback for it. + const sessionResource = feedbackService.getSessionForFile(resource)?.resource + ?? feedbackService.getMostRecentSessionForResource(resource); + if (!sessionResource) { + return; + } + // Prefer revealing via the matching feedback item so its editor widget + // expands (the navigation anchor is set from the item id); fall back to + // opening the file at the range when no item matches. + const match = feedbackService.getFeedback(sessionResource).find(item => isEqual(item.resourceUri, resource) && Range.equalsRange(item.range, range)); + if (match) { + await feedbackService.revealFeedback(sessionResource, match.id); + } else { + await feedbackService.revealSessionComment(sessionResource, '', resource, range); + } + }); + CommandsRegistry.registerCommand(AgentFeedbackReviewCommandId.Delete, (accessor, sessionResource: UriComponents, commentId: string): void => { const feedbackService = accessor.get(IAgentFeedbackService); const codeReviewService = accessor.get(ICodeReviewService); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 6ca75cf37c4..bb89a21e194 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -5,6 +5,7 @@ import { decodeBase64 } from '../../../../../../base/common/buffer.js'; import { escapeMarkdownLinkLabel, IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { escapeIcons } from '../../../../../../base/common/iconLabels.js'; import { marked, type Token, type Tokens, type TokensList } from '../../../../../../base/common/marked/marked.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; @@ -14,10 +15,10 @@ import { readToolCallMeta } from '../../../../../../platform/agentHost/common/me import { getChatErrorDetailsFromMeta, IChatErrorContext } from '../../../common/chatErrorMessages.js'; import { AGENT_HOST_SCHEME, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { getAgentFeedbackAttachmentMetadata, isAgentFeedbackAnnotationsAttachment, isAgentFeedbackAttachment } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js'; -import { isViewUnreviewedCommentsTool } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; +import { isViewUnreviewedCommentsTool, isAddCommentTool } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type StringOrMarkdown, type TextRange } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { normalizeFileEdit } from '../../../../../../platform/agentHost/common/fileEditDiff.js'; -import { formatCopilotCredits, type ChatExternalEditKind, type ChatMcpAppData, type IChatAgentFeedbackReviewConfirmationData, type IChatExternalEdit, type IChatModifiedFilesConfirmationData, type IChatProgress, type IChatResponseErrorDetails, type IChatSearchToolInvocationData, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, type IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import { formatCopilotCredits, type ChatExternalEditKind, type ChatMcpAppData, type IChatAgentFeedbackReviewConfirmationData, type IChatExternalEdit, type IChatModifiedFilesConfirmationData, type IChatProgress, type IChatResponseErrorDetails, type IChatSearchToolInvocationData, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, type IChatUsage, ToolConfirmKind, AgentFeedbackReviewCommandId } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; import { type IQuotaSnapshot } from '../../../../../services/chat/common/chatEntitlementService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; @@ -907,7 +908,7 @@ function getToolErrorString(tc: ToolCallState): string | undefined { export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentInvocationId: string | undefined, sessionResource: URI, connectionAuthority: string): IChatToolInvocationSerialized { const isTerminal = isTerminalToolCall(tc); const isSuccess = tc.status === ToolCallStatus.Completed && tc.success; - const invocationMsg = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? localize('ahp.running', "Running {0}...", tc.displayName); + let invocationMsg = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? localize('ahp.running', "Running {0}...", tc.displayName); // Check for subagent content const subagentContent = tc.status === ToolCallStatus.Completed ? getToolSubagentContent(tc) : undefined; @@ -957,9 +958,18 @@ export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentIn } } - const pastTenseMsg = isSuccess + let pastTenseMsg = isSuccess ? stringOrMarkdownToString(tc.pastTenseMessage, connectionAuthority) ?? invocationMsg : invocationMsg; + // Tools that render a bespoke, client-authored message override both the + // invocation and past-tense text here. Add new per-tool cases alongside. + if (isAddCommentTool(tc.toolName)) { + const ref = addCommentReference(tc); + if (ref) { + invocationMsg = ref; + pastTenseMsg = ref; + } + } const resultDetails = (!toolSpecificData || toolSpecificData.kind === 'input' && toolSpecificData.mcpAppData) && (tc.status !== ToolCallStatus.Completed || getToolFileEdits(tc).length === 0) ? getToolInputOutputDetails(tc, !isSuccess, getToolErrorString(tc), !!(toolSpecificData?.kind === 'input' && toolSpecificData.mcpAppData), connectionAuthority) @@ -1209,6 +1219,82 @@ export function stringOrMarkdownToString(value: StringOrMarkdown | undefined, co return rawMarkdownToString(value.markdown, connectionAuthority); } +/** + * Number of comment-body characters shown inline in the {@link addCommentReference} + * pill before it is truncated with an ellipsis. + */ +const ADD_COMMENT_PREVIEW_LENGTH = 20; + +/** + * Builds the inline preview of an `addComment` comment body: whitespace is + * collapsed to single spaces and the text is truncated to + * {@link ADD_COMMENT_PREVIEW_LENGTH} characters with a trailing ellipsis. + */ +function addCommentPreview(text: string): string { + const singleLine = text.replace(/\s+/g, ' ').trim(); + return singleLine.length > ADD_COMMENT_PREVIEW_LENGTH + ? `${singleLine.slice(0, ADD_COMMENT_PREVIEW_LENGTH)}…` + : singleLine; +} + +/** Whether {@link value} is a positive 1-based line/column coordinate. */ +function isPositiveInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 1; +} + +/** + * Whether {@link value} is a valid 1-based editor range: every coordinate must + * be an integer >= 1, since the range is later used for editor selection and + * reveal. Invalid input is treated as unparseable so the UI falls back to the + * server-authored message. + */ +function isOneBasedRange(value: unknown): value is IRange { + const range = value as IRange | undefined; + return !!range && typeof range === 'object' + && isPositiveInteger(range.startLineNumber) + && isPositiveInteger(range.startColumn) + && isPositiveInteger(range.endLineNumber) + && isPositiveInteger(range.endColumn); +} + +/** + * Builds a rich, clickable reference for the agent host `addComment` feedback + * tool call — a comment icon, the tool name, and the first + * {@link ADD_COMMENT_PREVIEW_LENGTH} characters of the comment body in quotes. + * Clicking it runs {@link AgentFeedbackReviewCommandId.RevealAt} to open the + * file and reveal the comment (agent feedback) in the editor. + * + * Only call this for the `addComment` tool (gate call sites with + * {@link isAddCommentTool}). Returns `undefined` when the arguments can't be + * parsed, so the caller falls back to the server-authored message. + */ +function addCommentReference(tc: ToolCallState): IMarkdownString | undefined { + // `toolInput` is absent while parameters are still streaming; every other + // state carries it (see `ToolCallParameterFields`). + if (tc.status === ToolCallStatus.Streaming || !tc.toolInput) { + return undefined; + } + const toolInput = tc.toolInput; + let args: { resourceUri?: unknown; range?: unknown; text?: unknown }; + try { + args = JSON.parse(toolInput); + } catch { + return undefined; + } + if (typeof args.resourceUri !== 'string' || typeof args.text !== 'string' || !isOneBasedRange(args.range)) { + return undefined; + } + const preview = escapeIcons(escapeMarkdownLinkLabel(addCommentPreview(args.text))); + // The command resolves the owning session from the file resource, so the + // link only needs the resource and range (both known here). + const commandArgs = encodeURIComponent(JSON.stringify([args.resourceUri, args.range])); + const link = `command:${AgentFeedbackReviewCommandId.RevealAt}?${commandArgs}`; + return new MarkdownString(`$(comment) [addComment "${preview}"](${link})`, { + isTrusted: { enabledCommands: [AgentFeedbackReviewCommandId.RevealAt] }, + supportThemeIcons: true, + }); +} + /** * Creates a live {@link ChatToolInvocation} from the protocol's tool-call * state. Used during active turns to represent running tool calls in the UI. @@ -1303,6 +1389,12 @@ export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationI const invocation = new ChatToolInvocation(undefined, toolData, tc.toolCallId, subAgentInvocationId, undefined); invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? localize('ahp.running', "Running {0}...", tc.displayName); + // Tools that render a bespoke, client-authored invocation message override + // the server text here. Add new per-tool cases alongside this branch. + if (isAddCommentTool(tc.toolName)) { + invocation.invocationMessage = addCommentReference(tc) ?? invocation.invocationMessage; + } + if (isTerminalToolCall(tc)) { // Set terminal toolSpecificData eagerly so the renderer shows a // terminal pill (expandable command + output area) from the start, @@ -1347,6 +1439,9 @@ export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: return; } existing.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? existing.invocationMessage; + if (isAddCommentTool(tc.toolName)) { + existing.invocationMessage = addCommentReference(tc) ?? existing.invocationMessage; + } const subagentContent = getToolSubagentContent(tc); @@ -1434,6 +1529,11 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ToolC if ((isCompleted || isCancelled) && hasKey(tc, { invocationMessage: true })) { invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? invocation.invocationMessage; } + // Tools that render a bespoke, client-authored message override the + // invocation text here. Add new per-tool cases alongside this branch. + if (isAddCommentTool(tc.toolName)) { + invocation.invocationMessage = addCommentReference(tc) ?? invocation.invocationMessage; + } // Check for subagent content — set toolSpecificData so the UI renders a subagent widget if (isCompleted) { @@ -1474,6 +1574,11 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ToolC } else if (isCompleted && tc.pastTenseMessage) { invocation.pastTenseMessage = stringOrMarkdownToString(tc.pastTenseMessage, connectionAuthority); } + // Tools that render a bespoke, client-authored message override the + // past-tense text here. Add new per-tool cases alongside this branch. + if (isCompleted && isAddCommentTool(tc.toolName)) { + invocation.pastTenseMessage = addCommentReference(tc) ?? invocation.pastTenseMessage; + } if (isCompleted) { const mcpAppData = getMcpAppData(tc, backendSession); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 46867d6a54f..227349b4f7e 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1154,14 +1154,18 @@ export interface IChatAgentFeedbackReviewComment { * Command ids the agent feedback review confirmation renderer (workbench/chat) * uses to fetch unreviewed comments and apply the user's selection. They are * implemented by the agent feedback feature in `vs/sessions`, keeping the chat - * layer decoupled from the feedback model. All take the owning session resource - * (`UriComponents`) as their first argument. + * layer decoupled from the feedback model. Most take the owning session resource + * (`UriComponents`) as their first argument; {@link AgentFeedbackReviewCommandId.RevealAt} + * instead resolves the session from the file resource so a rendered tool call + * can link to a comment without knowing the session URI. */ export const enum AgentFeedbackReviewCommandId { /** `(sessionResource)` -> `IChatAgentFeedbackReviewComment[]` (the `created` reviewable comments). */ GetComments = '_agentFeedbackReview.getComments', /** `(sessionResource, commentId)` -> opens the file and reveals the comment. */ Reveal = '_agentFeedbackReview.reveal', + /** `(resourceUri, range)` -> resolves the owning session and reveals the comment at that file range. */ + RevealAt = '_agentFeedbackReview.revealAt', /** `(sessionResource, commentId)` -> deletes the comment entirely. */ Delete = '_agentFeedbackReview.delete', /** `(sessionResource, commentIds)` -> accepts (reveals) the given comments. */ diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 13e042374c4..ac49d7eaee8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { autorun } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; +import type { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { MessageKind, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, type ActiveTurn, type ICompletedToolCall, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatToolInvocation, IChatToolInvocationSerialized, type IChatMarkdownContent, type IChatProgressMessage, type IChatThinkingPart, type IChatUsage } from '../../../common/chatService/chatService.js'; @@ -797,6 +798,69 @@ suite('stateToProgressAdapter', () => { }); }); + suite('addComment reference', () => { + + const commentRange = { startLineNumber: 3, startColumn: 1, endLineNumber: 3, endColumn: 5 }; + + function addCommentInput(text: string): string { + return JSON.stringify({ resourceUri: 'file:///workspace/a.ts', range: commentRange, text }); + } + + function markdown(message: string | IMarkdownString | undefined): IMarkdownString { + assert.ok(message && typeof message !== 'string', 'expected a markdown reference'); + return message; + } + + test('renders comment icon, tool name, truncated quoted preview and a reveal command link', () => { + const tc = createToolCallState({ toolName: 'addComment', invocationMessage: 'Adding comment', toolInput: addCommentInput('This comment is quite long and should be truncated') }); + const message = markdown(toolCallStateToInvocation(tc).invocationMessage); + + assert.deepStrictEqual( + { + value: message.value, + supportThemeIcons: message.supportThemeIcons, + isTrusted: message.isTrusted, + }, + { + value: `$(comment) [addComment "This comment is quit…"](command:_agentFeedbackReview.revealAt?${encodeURIComponent(JSON.stringify(['file:///workspace/a.ts', commentRange]))})`, + supportThemeIcons: true, + isTrusted: { enabledCommands: ['_agentFeedbackReview.revealAt'] }, + }, + ); + }); + + test('does not truncate a short comment', () => { + const tc = createToolCallState({ toolName: 'addComment', invocationMessage: 'Adding comment', toolInput: addCommentInput('Short note') }); + const message = markdown(toolCallStateToInvocation(tc).invocationMessage); + assert.ok(message.value.includes('addComment "Short note"'), message.value); + assert.ok(!message.value.includes('…'), message.value); + }); + + test('sets the same reference as the past-tense message on completion', () => { + const running = createToolCallState({ toolName: 'addComment', invocationMessage: 'Adding comment', toolInput: addCommentInput('Short note') }); + const invocation = toolCallStateToInvocation(running); + const completed = createCompletedToolCall({ toolName: 'addComment', toolInput: addCommentInput('Short note'), pastTenseMessage: 'Added comment' }); + finalizeToolInvocation(invocation, completed); + assert.strictEqual(markdown(invocation.pastTenseMessage).value, markdown(invocation.invocationMessage).value); + }); + + test('falls back to the server message when the input cannot be parsed', () => { + const tc = createToolCallState({ toolName: 'addComment', invocationMessage: 'Adding comment', toolInput: 'not json' }); + assert.strictEqual(toolCallStateToInvocation(tc).invocationMessage, 'Adding comment'); + }); + + test('falls back to the server message when the range is not a valid 1-based range', () => { + for (const range of [ + { startLineNumber: 0, startColumn: 1, endLineNumber: 1, endColumn: 1 }, + { startLineNumber: 1, startColumn: 1.5, endLineNumber: 1, endColumn: 2 }, + { startLineNumber: -1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, + ]) { + const tc = createToolCallState({ toolName: 'addComment', invocationMessage: 'Adding comment', toolInput: JSON.stringify({ resourceUri: 'file:///workspace/a.ts', range, text: 'hi' }) }); + assert.strictEqual(toolCallStateToInvocation(tc).invocationMessage, 'Adding comment', JSON.stringify(range)); + } + }); + }); + suite('finalizeToolInvocation', () => { test('rewrites markdown links in pastTenseMessage through the agent host scheme', () => {