mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site/src/pages/AgentsPage): show MCP tool inputs (#25568)
Generic agent chat tool cards now render an `Input` section before the existing output viewer, so MCP and workspace MCP tools expose the arguments sent to the tool. Empty inputs stay hidden, model-intent wrappers are stripped before display, and the formatted input is the single source of truth for whether an input block renders. Refs https://linear.app/codercom/issue/CODAGT-260/show-mcp-tool-inputs-in-agent-chats > Mux worked on this on Mike's behalf.
This commit is contained in:
@@ -17,6 +17,12 @@ const executeCommand = "git fetch origin";
|
|||||||
const executeIntentCommand = "npm test";
|
const executeIntentCommand = "npm test";
|
||||||
const longExecuteCommand =
|
const longExecuteCommand =
|
||||||
"docker build --no-cache --build-arg NODE_ENV=production --build-arg API_URL=https://coder.example.com/api --build-arg SENTRY_DSN=https://example.com/sentry --build-arg FEATURE_FLAGS=agents,shell-tools --tag coder-agent:latest .";
|
"docker build --no-cache --build-arg NODE_ENV=production --build-arg API_URL=https://coder.example.com/api --build-arg SENTRY_DSN=https://example.com/sentry --build-arg FEATURE_FLAGS=agents,shell-tools --tag coder-agent:latest .";
|
||||||
|
|
||||||
|
const getDiffsText = (element: HTMLElement) =>
|
||||||
|
Array.from(element.querySelectorAll("diffs-container"))
|
||||||
|
.map((container) => container.shadowRoot?.textContent ?? "")
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
const meta: Meta<typeof Tool> = {
|
const meta: Meta<typeof Tool> = {
|
||||||
title: "pages/AgentsPage/ChatElements/tools/Tool",
|
title: "pages/AgentsPage/ChatElements/tools/Tool",
|
||||||
component: Tool,
|
component: Tool,
|
||||||
@@ -1080,17 +1086,15 @@ export const MCPToolCompleted: Story = {
|
|||||||
expect(canvasElement.querySelector(".animate-spin")).toBeNull();
|
expect(canvasElement.querySelector(".animate-spin")).toBeNull();
|
||||||
// Icon should still be monochrome when completed.
|
// Icon should still be monochrome when completed.
|
||||||
expect(canvasElement.querySelector(".brightness-0")).not.toBeNull();
|
expect(canvasElement.querySelector(".brightness-0")).not.toBeNull();
|
||||||
// Result should be collapsed by default.
|
|
||||||
const toggle = canvas.getByRole("button");
|
const toggle = canvas.getByRole("button");
|
||||||
expect(toggle).toBeInTheDocument();
|
expect(toggle).toBeInTheDocument();
|
||||||
// Expand to see result content.
|
|
||||||
await userEvent.click(toggle);
|
await userEvent.click(toggle);
|
||||||
// @pierre/diffs renders inside a Shadow DOM (<diffs-container>)
|
expect(canvas.getByText("Input")).toBeVisible();
|
||||||
// so textContent on the host element can't see the content.
|
expect(canvas.getByText("Output")).toBeVisible();
|
||||||
// Query into the shadow root to verify the JSON rendered.
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const shadow = canvasElement.querySelector("diffs-container")?.shadowRoot;
|
const diffsText = getDiffsText(canvasElement);
|
||||||
expect(shadow?.textContent).toContain("Fix auth flow");
|
expect(diffsText).toContain("backend");
|
||||||
|
expect(diffsText).toContain("Fix auth flow");
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1124,8 +1128,12 @@ export const MCPToolNoResult: Story = {
|
|||||||
mcpServers: sampleMCPServers,
|
mcpServers: sampleMCPServers,
|
||||||
},
|
},
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
// No toggle button when there is no result content.
|
const canvas = within(canvasElement);
|
||||||
expect(canvasElement.querySelector("button")).toBeNull();
|
await userEvent.click(canvas.getByRole("button"));
|
||||||
|
expect(canvas.getByText("Input")).toBeVisible();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getDiffsText(canvasElement)).toContain("New issue");
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1196,6 +1204,27 @@ export const MCPToolNoServer: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const WorkspaceMCPToolCompleted: Story = {
|
||||||
|
args: {
|
||||||
|
name: "workspace-mcp__echo",
|
||||||
|
status: "completed",
|
||||||
|
args: { message: "hello from workspace MCP" },
|
||||||
|
result: { output: "hello from workspace MCP" },
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
expect(canvas.getByText("workspace-mcp__echo")).toBeInTheDocument();
|
||||||
|
await userEvent.click(canvas.getByRole("button"));
|
||||||
|
expect(canvas.getByText("Input")).toBeVisible();
|
||||||
|
expect(canvas.getByText("Output")).toBeVisible();
|
||||||
|
await waitFor(() => {
|
||||||
|
const diffsText = getDiffsText(canvasElement);
|
||||||
|
expect(diffsText).toContain("message");
|
||||||
|
expect(diffsText).toContain("hello from workspace MCP");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const MCPToolModelIntentRunning: Story = {
|
export const MCPToolModelIntentRunning: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: "linear__list_issues",
|
name: "linear__list_issues",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
DIFFS_FONT_STYLE,
|
DIFFS_FONT_STYLE,
|
||||||
formatModelIntentLabel,
|
formatModelIntentLabel,
|
||||||
formatResultOutput,
|
formatResultOutput,
|
||||||
|
formatToolInput,
|
||||||
getFileContentForViewer,
|
getFileContentForViewer,
|
||||||
getFileViewerOptions,
|
getFileViewerOptions,
|
||||||
getFileViewerOptionsNoHeader,
|
getFileViewerOptionsNoHeader,
|
||||||
@@ -822,6 +823,76 @@ const ComputerRenderer: FC<ToolRendererProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ToolFileViewerProps = {
|
||||||
|
label?: string;
|
||||||
|
file: ComponentPropsWithRef<typeof FileViewer>["file"];
|
||||||
|
options: ComponentPropsWithRef<typeof FileViewer>["options"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToolFileViewer: FC<ToolFileViewerProps> = ({ label, file, options }) => (
|
||||||
|
<>
|
||||||
|
{label && (
|
||||||
|
<div className="mt-2 text-2xs font-medium text-content-secondary">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ScrollArea
|
||||||
|
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
|
||||||
|
viewportClassName="max-h-64"
|
||||||
|
scrollBarClassName="w-1.5"
|
||||||
|
>
|
||||||
|
<FileViewer file={file} options={options} style={DIFFS_FONT_STYLE} />
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
type GenericToolContentProps = {
|
||||||
|
toolInput: string | null;
|
||||||
|
fileContent: ReturnType<typeof getFileContentForViewer>;
|
||||||
|
fileContentOptions: ComponentPropsWithRef<typeof FileViewer>["options"];
|
||||||
|
isDark: boolean;
|
||||||
|
resultOutput: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GenericToolContent: FC<GenericToolContentProps> = ({
|
||||||
|
toolInput,
|
||||||
|
fileContent,
|
||||||
|
fileContentOptions,
|
||||||
|
isDark,
|
||||||
|
resultOutput,
|
||||||
|
}) => {
|
||||||
|
const output = fileContent
|
||||||
|
? {
|
||||||
|
file: { name: fileContent.path, contents: fileContent.content },
|
||||||
|
options: fileContentOptions,
|
||||||
|
}
|
||||||
|
: resultOutput
|
||||||
|
? {
|
||||||
|
file: { name: "output.json", contents: resultOutput },
|
||||||
|
options: getFileViewerOptionsNoHeader(isDark),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{toolInput && (
|
||||||
|
<ToolFileViewer
|
||||||
|
label="Input"
|
||||||
|
file={{ name: "input.json", contents: toolInput }}
|
||||||
|
options={getFileViewerOptionsNoHeader(isDark)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{output && (
|
||||||
|
<ToolFileViewer
|
||||||
|
label={toolInput ? "Output" : undefined}
|
||||||
|
file={output.file}
|
||||||
|
options={output.options}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const GenericToolRenderer: FC<ToolRendererProps> = ({
|
const GenericToolRenderer: FC<ToolRendererProps> = ({
|
||||||
name,
|
name,
|
||||||
status,
|
status,
|
||||||
@@ -834,6 +905,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.palette.mode === "dark";
|
const isDark = theme.palette.mode === "dark";
|
||||||
|
const toolInput = formatToolInput(args);
|
||||||
const resultOutput = formatResultOutput(result);
|
const resultOutput = formatResultOutput(result);
|
||||||
const fileContent = getFileContentForViewer(name, args, result);
|
const fileContent = getFileContentForViewer(name, args, result);
|
||||||
const fileViewerOpts = getFileViewerOptions(isDark);
|
const fileViewerOpts = getFileViewerOptions(isDark);
|
||||||
@@ -850,7 +922,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
|
|||||||
? mcpServers?.find((s) => s.id === mcpServerConfigId)
|
? mcpServers?.find((s) => s.id === mcpServerConfigId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const hasContent = Boolean(fileContent || resultOutput);
|
const hasContent = Boolean(toolInput || fileContent || resultOutput);
|
||||||
const isRunning = status === "running";
|
const isRunning = status === "running";
|
||||||
const rec = asRecord(result);
|
const rec = asRecord(result);
|
||||||
const errorMessage = rec ? asString(rec.error || rec.message) : "";
|
const errorMessage = rec ? asString(rec.error || rec.message) : "";
|
||||||
@@ -891,41 +963,13 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const toolContent = (
|
const toolContent = (
|
||||||
<>
|
<GenericToolContent
|
||||||
{fileContent ? (
|
toolInput={toolInput}
|
||||||
<ScrollArea
|
fileContent={fileContent}
|
||||||
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
|
fileContentOptions={fileContentOptions}
|
||||||
viewportClassName="max-h-64"
|
isDark={isDark}
|
||||||
scrollBarClassName="w-1.5"
|
resultOutput={resultOutput}
|
||||||
>
|
/>
|
||||||
<FileViewer
|
|
||||||
file={{
|
|
||||||
name: fileContent.path,
|
|
||||||
contents: fileContent.content,
|
|
||||||
}}
|
|
||||||
options={fileContentOptions}
|
|
||||||
style={DIFFS_FONT_STYLE}
|
|
||||||
/>
|
|
||||||
</ScrollArea>
|
|
||||||
) : (
|
|
||||||
resultOutput && (
|
|
||||||
<ScrollArea
|
|
||||||
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
|
|
||||||
viewportClassName="max-h-64"
|
|
||||||
scrollBarClassName="w-1.5"
|
|
||||||
>
|
|
||||||
<FileViewer
|
|
||||||
file={{
|
|
||||||
name: "output.json",
|
|
||||||
contents: resultOutput,
|
|
||||||
}}
|
|
||||||
options={getFileViewerOptionsNoHeader(isDark)}
|
|
||||||
style={DIFFS_FONT_STYLE}
|
|
||||||
/>
|
|
||||||
</ScrollArea>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
formatModelIntentLabel,
|
formatModelIntentLabel,
|
||||||
formatResultOutput,
|
formatResultOutput,
|
||||||
formatShellDurationMs,
|
formatShellDurationMs,
|
||||||
|
formatToolInput,
|
||||||
getDiffViewerOptions,
|
getDiffViewerOptions,
|
||||||
getFileContentForViewer,
|
getFileContentForViewer,
|
||||||
getFileViewerOptions,
|
getFileViewerOptions,
|
||||||
@@ -306,6 +307,66 @@ describe("parseArgs", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("formatToolInput", () => {
|
||||||
|
it("returns null for null, undefined, and empty inputs", () => {
|
||||||
|
expect(formatToolInput(null)).toBeNull();
|
||||||
|
expect(formatToolInput(undefined)).toBeNull();
|
||||||
|
expect(formatToolInput("")).toBeNull();
|
||||||
|
expect(formatToolInput({})).toBeNull();
|
||||||
|
expect(formatToolInput([])).toBeNull();
|
||||||
|
expect(formatToolInput("{}")).toBeNull();
|
||||||
|
expect(formatToolInput("[]")).toBeNull();
|
||||||
|
expect(formatToolInput("null")).toBeNull();
|
||||||
|
expect(
|
||||||
|
formatToolInput(
|
||||||
|
JSON.stringify({
|
||||||
|
model_intent: "Reading backend issues",
|
||||||
|
properties: {},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats object input as pretty JSON", () => {
|
||||||
|
expect(formatToolInput({ project: "backend", limit: 2 })).toBe(
|
||||||
|
JSON.stringify({ project: "backend", limit: 2 }, null, 2),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats JSON string input as pretty JSON", () => {
|
||||||
|
expect(formatToolInput('{"project":"backend","limit":2}')).toBe(
|
||||||
|
JSON.stringify({ project: "backend", limit: 2 }, null, 2),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unwraps model intent input wrappers", () => {
|
||||||
|
expect(
|
||||||
|
formatToolInput({
|
||||||
|
model_intent: "Reading backend issues",
|
||||||
|
properties: { project: "backend" },
|
||||||
|
}),
|
||||||
|
).toBe(JSON.stringify({ project: "backend" }, null, 2));
|
||||||
|
expect(
|
||||||
|
formatToolInput({
|
||||||
|
model_intent: "Reading backend issues",
|
||||||
|
project: "backend",
|
||||||
|
}),
|
||||||
|
).toBe(JSON.stringify({ project: "backend" }, null, 2));
|
||||||
|
expect(
|
||||||
|
formatToolInput(
|
||||||
|
JSON.stringify({
|
||||||
|
model_intent: "Reading backend issues",
|
||||||
|
properties: { project: "backend" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(JSON.stringify({ project: "backend" }, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves non-JSON string input", () => {
|
||||||
|
expect(formatToolInput("search text")).toBe("search text");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("formatResultOutput", () => {
|
describe("formatResultOutput", () => {
|
||||||
it("returns null for null and undefined", () => {
|
it("returns null for null and undefined", () => {
|
||||||
expect(formatResultOutput(null)).toBeNull();
|
expect(formatResultOutput(null)).toBeNull();
|
||||||
|
|||||||
@@ -217,6 +217,57 @@ export const parseArgs = (args: unknown): Record<string, unknown> | null => {
|
|||||||
return asRecord(args);
|
return asRecord(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getToolInputPayload = (args: unknown): unknown => {
|
||||||
|
const rec = asRecord(args);
|
||||||
|
if (!rec || typeof rec.model_intent !== "string") {
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
if ("properties" in rec) {
|
||||||
|
return rec.properties;
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(rec).filter(([key]) => key !== "model_intent"),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEmptyObjectOrArray = (value: unknown): boolean => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length === 0;
|
||||||
|
}
|
||||||
|
const rec = asRecord(value);
|
||||||
|
return rec ? Object.keys(rec).length === 0 : false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValue = (value: unknown): string => {
|
||||||
|
if (typeof value === "object") {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2) ?? String(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatToolInput = (args: unknown): string | null => {
|
||||||
|
const input = getToolInputPayload(args);
|
||||||
|
if (input === undefined || input === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof input === "string") {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return formatToolInput(JSON.parse(trimmed));
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isEmptyObjectOrArray(input) ? null : formatValue(input);
|
||||||
|
};
|
||||||
|
|
||||||
export const formatResultOutput = (result: unknown): string | null => {
|
export const formatResultOutput = (result: unknown): string | null => {
|
||||||
if (result === undefined || result === null) {
|
if (result === undefined || result === null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -238,14 +289,7 @@ export const formatResultOutput = (result: unknown): string | null => {
|
|||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof result === "object") {
|
return formatValue(result);
|
||||||
try {
|
|
||||||
return JSON.stringify(result, null, 2);
|
|
||||||
} catch {
|
|
||||||
return String(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return String(result);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fileViewerCSS =
|
export const fileViewerCSS =
|
||||||
|
|||||||
Reference in New Issue
Block a user