mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00: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 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 .";
|
||||
|
||||
const getDiffsText = (element: HTMLElement) =>
|
||||
Array.from(element.querySelectorAll("diffs-container"))
|
||||
.map((container) => container.shadowRoot?.textContent ?? "")
|
||||
.join("\n");
|
||||
|
||||
const meta: Meta<typeof Tool> = {
|
||||
title: "pages/AgentsPage/ChatElements/tools/Tool",
|
||||
component: Tool,
|
||||
@@ -1080,17 +1086,15 @@ export const MCPToolCompleted: Story = {
|
||||
expect(canvasElement.querySelector(".animate-spin")).toBeNull();
|
||||
// Icon should still be monochrome when completed.
|
||||
expect(canvasElement.querySelector(".brightness-0")).not.toBeNull();
|
||||
// Result should be collapsed by default.
|
||||
const toggle = canvas.getByRole("button");
|
||||
expect(toggle).toBeInTheDocument();
|
||||
// Expand to see result content.
|
||||
await userEvent.click(toggle);
|
||||
// @pierre/diffs renders inside a Shadow DOM (<diffs-container>)
|
||||
// so textContent on the host element can't see the content.
|
||||
// Query into the shadow root to verify the JSON rendered.
|
||||
expect(canvas.getByText("Input")).toBeVisible();
|
||||
expect(canvas.getByText("Output")).toBeVisible();
|
||||
await waitFor(() => {
|
||||
const shadow = canvasElement.querySelector("diffs-container")?.shadowRoot;
|
||||
expect(shadow?.textContent).toContain("Fix auth flow");
|
||||
const diffsText = getDiffsText(canvasElement);
|
||||
expect(diffsText).toContain("backend");
|
||||
expect(diffsText).toContain("Fix auth flow");
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1124,8 +1128,12 @@ export const MCPToolNoResult: Story = {
|
||||
mcpServers: sampleMCPServers,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
// No toggle button when there is no result content.
|
||||
expect(canvasElement.querySelector("button")).toBeNull();
|
||||
const canvas = within(canvasElement);
|
||||
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 = {
|
||||
args: {
|
||||
name: "linear__list_issues",
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
DIFFS_FONT_STYLE,
|
||||
formatModelIntentLabel,
|
||||
formatResultOutput,
|
||||
formatToolInput,
|
||||
getFileContentForViewer,
|
||||
getFileViewerOptions,
|
||||
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> = ({
|
||||
name,
|
||||
status,
|
||||
@@ -834,6 +905,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
const toolInput = formatToolInput(args);
|
||||
const resultOutput = formatResultOutput(result);
|
||||
const fileContent = getFileContentForViewer(name, args, result);
|
||||
const fileViewerOpts = getFileViewerOptions(isDark);
|
||||
@@ -850,7 +922,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
|
||||
? mcpServers?.find((s) => s.id === mcpServerConfigId)
|
||||
: undefined;
|
||||
|
||||
const hasContent = Boolean(fileContent || resultOutput);
|
||||
const hasContent = Boolean(toolInput || fileContent || resultOutput);
|
||||
const isRunning = status === "running";
|
||||
const rec = asRecord(result);
|
||||
const errorMessage = rec ? asString(rec.error || rec.message) : "";
|
||||
@@ -891,41 +963,13 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
|
||||
);
|
||||
|
||||
const toolContent = (
|
||||
<>
|
||||
{fileContent ? (
|
||||
<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: 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>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
<GenericToolContent
|
||||
toolInput={toolInput}
|
||||
fileContent={fileContent}
|
||||
fileContentOptions={fileContentOptions}
|
||||
isDark={isDark}
|
||||
resultOutput={resultOutput}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
formatModelIntentLabel,
|
||||
formatResultOutput,
|
||||
formatShellDurationMs,
|
||||
formatToolInput,
|
||||
getDiffViewerOptions,
|
||||
getFileContentForViewer,
|
||||
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", () => {
|
||||
it("returns null for null and undefined", () => {
|
||||
expect(formatResultOutput(null)).toBeNull();
|
||||
|
||||
@@ -217,6 +217,57 @@ export const parseArgs = (args: unknown): Record<string, unknown> | null => {
|
||||
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 => {
|
||||
if (result === undefined || result === null) {
|
||||
return null;
|
||||
@@ -238,14 +289,7 @@ export const formatResultOutput = (result: unknown): string | null => {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
if (typeof result === "object") {
|
||||
try {
|
||||
return JSON.stringify(result, null, 2);
|
||||
} catch {
|
||||
return String(result);
|
||||
}
|
||||
}
|
||||
return String(result);
|
||||
return formatValue(result);
|
||||
};
|
||||
|
||||
export const fileViewerCSS =
|
||||
|
||||
Reference in New Issue
Block a user