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>
This commit is contained in:
Benjamin Christopher Simmonds
2026-07-01 14:40:26 +02:00
committed by GitHub
parent 155cf0a562
commit 07a63f4c0e
6 changed files with 226 additions and 8 deletions
@@ -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__<name>` (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__<server>__<name>` 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
@@ -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';
@@ -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<void> => {
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);
@@ -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);
@@ -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. */
@@ -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', () => {