mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): humanize process_signal and show killed on processes (#23590)
Replace the raw JSON dump for process_signal with the standard ToolCollapsible + ToolIcon + ToolLabel pipeline, matching process_list and other generic tools. A thin ProcessSignalRenderer promotes soft failures (success=false, isError=false) so the generic renderer shows the error indicator. ToolLabel distinguishes running, success, and failure states. TerminalIcon used for consistency with other process tools. When a process is killed via process_signal, the execute and process_output blocks show a red OctagonX icon with signal details on hover. The killedBySignal field is set on MergedTool during the existing cross-message parsing pass, no new abstractions. Stories for process_signal (10) and killed indicators (8). Unit tests for the cross-tool annotation logic (3). Humanized labels and TerminalIcon for process_list.
This commit is contained in:
@@ -353,6 +353,7 @@ export const BlockList: FC<{
|
||||
result={tool.result}
|
||||
status={tool.status}
|
||||
isError={tool.isError}
|
||||
killedBySignal={tool.killedBySignal}
|
||||
subagentTitles={subagentTitles}
|
||||
computerUseSubagentIds={computerUseSubagentIds}
|
||||
showDesktopPreviews={showDesktopPreviews}
|
||||
@@ -394,6 +395,7 @@ export const BlockList: FC<{
|
||||
result={tool.result}
|
||||
status={tool.status}
|
||||
isError={tool.isError}
|
||||
killedBySignal={tool.killedBySignal}
|
||||
subagentTitles={subagentTitles}
|
||||
computerUseSubagentIds={computerUseSubagentIds}
|
||||
showDesktopPreviews={showDesktopPreviews}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatMessage, ChatMessagePart } from "#/api/typesGenerated";
|
||||
import {
|
||||
mergeTools,
|
||||
parseMessageContent,
|
||||
parseMessagesWithMergedTools,
|
||||
parseToolResultIsError,
|
||||
} from "./messageParsing";
|
||||
|
||||
@@ -375,3 +377,137 @@ describe("mergeTools", () => {
|
||||
expect(merged[0].status).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMessagesWithMergedTools — killedBySignal annotation", () => {
|
||||
const msg = (
|
||||
id: number,
|
||||
role: "assistant" | "user",
|
||||
parts: ChatMessagePart[],
|
||||
): ChatMessage => ({
|
||||
id,
|
||||
chat_id: "chat-1",
|
||||
created_at: new Date().toISOString(),
|
||||
role,
|
||||
content: parts,
|
||||
});
|
||||
|
||||
// The generated types use Record<string, string> for args/result,
|
||||
// but real tool data contains booleans and nulls. We widen the
|
||||
// parameter types and cast back to ChatMessagePart.
|
||||
const toolCall = (
|
||||
id: string,
|
||||
name: string,
|
||||
args: Record<string, string>,
|
||||
): ChatMessagePart => ({
|
||||
type: "tool-call" as const,
|
||||
tool_call_id: id,
|
||||
tool_name: name,
|
||||
args,
|
||||
});
|
||||
const toolResult = (
|
||||
id: string,
|
||||
name: string,
|
||||
result: Record<string, unknown>,
|
||||
isError = false,
|
||||
): ChatMessagePart => ({
|
||||
type: "tool-result" as const,
|
||||
tool_call_id: id,
|
||||
tool_name: name,
|
||||
// The generated type uses Record<string, string> but real
|
||||
// tool results contain booleans and nulls.
|
||||
result: result as Record<string, string>,
|
||||
is_error: isError,
|
||||
});
|
||||
|
||||
it("annotates execute tool with killedBySignal from a later process_signal", () => {
|
||||
const PID = "abc-123";
|
||||
const parsed = parseMessagesWithMergedTools([
|
||||
msg(1, "assistant", [
|
||||
toolCall("tc1", "execute", { command: "make build" }),
|
||||
]),
|
||||
msg(2, "assistant", [
|
||||
toolResult("tc1", "execute", {
|
||||
success: true,
|
||||
output: "",
|
||||
background_process_id: PID,
|
||||
}),
|
||||
toolCall("tc2", "process_signal", {
|
||||
process_id: PID,
|
||||
signal: "kill",
|
||||
}),
|
||||
]),
|
||||
msg(3, "assistant", [
|
||||
toolResult("tc2", "process_signal", {
|
||||
success: true,
|
||||
message: `signal "kill" sent to process ${PID}`,
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
|
||||
const executeTool = parsed
|
||||
.flatMap((e) => e.parsed.tools)
|
||||
.find((t) => t.name === "execute");
|
||||
expect(executeTool?.killedBySignal).toBe("kill");
|
||||
});
|
||||
|
||||
it("does not annotate when process_signal failed", () => {
|
||||
const PID = "abc-123";
|
||||
const parsed = parseMessagesWithMergedTools([
|
||||
msg(1, "assistant", [
|
||||
toolCall("tc1", "execute", { command: "make build" }),
|
||||
]),
|
||||
msg(2, "assistant", [
|
||||
toolResult("tc1", "execute", {
|
||||
success: true,
|
||||
output: "",
|
||||
background_process_id: PID,
|
||||
}),
|
||||
toolCall("tc2", "process_signal", {
|
||||
process_id: PID,
|
||||
signal: "kill",
|
||||
}),
|
||||
]),
|
||||
msg(3, "assistant", [
|
||||
toolResult("tc2", "process_signal", {
|
||||
success: false,
|
||||
error: "process not found",
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
|
||||
const executeTool = parsed
|
||||
.flatMap((e) => e.parsed.tools)
|
||||
.find((t) => t.name === "execute");
|
||||
expect(executeTool?.killedBySignal).toBeUndefined();
|
||||
});
|
||||
|
||||
it("annotates process_output via args.process_id", () => {
|
||||
const PID = "def-456";
|
||||
const parsed = parseMessagesWithMergedTools([
|
||||
msg(1, "assistant", [
|
||||
toolCall("tc1", "process_output", { process_id: PID }),
|
||||
]),
|
||||
msg(2, "assistant", [
|
||||
toolResult("tc1", "process_output", {
|
||||
output: "some output",
|
||||
exit_code: null,
|
||||
}),
|
||||
toolCall("tc2", "process_signal", {
|
||||
process_id: PID,
|
||||
signal: "terminate",
|
||||
}),
|
||||
]),
|
||||
msg(3, "assistant", [
|
||||
toolResult("tc2", "process_signal", {
|
||||
success: true,
|
||||
message: "signal sent",
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
|
||||
const procOut = parsed
|
||||
.flatMap((e) => e.parsed.tools)
|
||||
.find((t) => t.name === "process_output");
|
||||
expect(procOut?.killedBySignal).toBe("terminate");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,6 +290,37 @@ export const parseMessagesWithMergedTools = (
|
||||
);
|
||||
}
|
||||
|
||||
// Annotate execute/process_output tools whose process was
|
||||
// later killed or terminated via process_signal.
|
||||
const signaledProcesses = new Map<string, "kill" | "terminate">();
|
||||
for (const { parsed } of rawParsed) {
|
||||
for (const tool of parsed.tools) {
|
||||
if (tool.name !== "process_signal") continue;
|
||||
const args = asRecord(tool.args);
|
||||
const result = asRecord(tool.result);
|
||||
if (!args || !result || !result.success) continue;
|
||||
const pid = asString(args.process_id);
|
||||
const sig = asString(args.signal);
|
||||
if (pid && (sig === "kill" || sig === "terminate"))
|
||||
signaledProcesses.set(pid, sig);
|
||||
}
|
||||
}
|
||||
if (signaledProcesses.size > 0) {
|
||||
for (const { parsed } of rawParsed) {
|
||||
for (const tool of parsed.tools) {
|
||||
if (tool.name !== "execute" && tool.name !== "process_output") continue;
|
||||
const rec = asRecord(tool.result);
|
||||
const args = asRecord(tool.args);
|
||||
const pid =
|
||||
(rec ? asString(rec.background_process_id) : "") ||
|
||||
(rec ? asString(rec.process_id) : "") ||
|
||||
(args ? asString(args.process_id) : "");
|
||||
const sig = pid ? signaledProcesses.get(pid) : undefined;
|
||||
if (sig) tool.killedBySignal = sig;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rawParsed;
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export type MergedTool = {
|
||||
status: "completed" | "error" | "running";
|
||||
mcpServerConfigId?: string;
|
||||
modelIntent?: string;
|
||||
/** Set when a process_signal killed/terminated this process. */
|
||||
killedBySignal?: "kill" | "terminate";
|
||||
};
|
||||
|
||||
export type RenderBlock =
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ExternalLinkIcon,
|
||||
LayersIcon,
|
||||
LoaderIcon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
@@ -21,6 +22,7 @@ import { cn } from "#/utils/cn";
|
||||
import {
|
||||
BORDER_BG_STYLE,
|
||||
COLLAPSED_OUTPUT_HEIGHT,
|
||||
signalTooltipLabel,
|
||||
type ToolStatus,
|
||||
} from "./utils";
|
||||
|
||||
@@ -35,7 +37,8 @@ export const ExecuteTool: React.FC<{
|
||||
status: ToolStatus;
|
||||
isError: boolean;
|
||||
isBackgrounded?: boolean;
|
||||
}> = ({ command, output, status, isBackgrounded = false }) => {
|
||||
killedBySignal?: "kill" | "terminate";
|
||||
}> = ({ command, output, status, isBackgrounded = false, killedBySignal }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const outputRef = useRef<HTMLPreElement | null>(null);
|
||||
const hasOutput = output.length > 0;
|
||||
@@ -132,6 +135,16 @@ export const ExecuteTool: React.FC<{
|
||||
<TooltipContent>Running in background</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{killedBySignal && !isRunning && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{signalTooltipLabel(killedBySignal)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<CopyButton
|
||||
text={command}
|
||||
label="Copy command"
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { Tool } from "./Tool";
|
||||
|
||||
const PROCESS_ID = "376b2458-e318-4442-8b87-51a0f9727f0e";
|
||||
|
||||
const meta: Meta<typeof Tool> = {
|
||||
title: "components/ai-elements/tool/ProcessKilledIndicator",
|
||||
component: Tool,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-3xl rounded-lg border border-solid border-border-default bg-surface-primary p-4">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Tool>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute tool — killed indicator via killedBySignal prop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ExecuteKilled: Story = {
|
||||
args: {
|
||||
name: "execute",
|
||||
status: "completed",
|
||||
killedBySignal: "kill",
|
||||
args: { command: "make pre-push 2>&1" },
|
||||
result: {
|
||||
success: true,
|
||||
output: "pre-push (/tmp/coder-pre-push.CZ6K9A)\ntest + build site:",
|
||||
exit_code: -1,
|
||||
wall_duration_ms: 45000,
|
||||
background_process_id: PROCESS_ID,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("make pre-push 2>&1")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ExecuteTerminated: Story = {
|
||||
args: {
|
||||
name: "execute",
|
||||
status: "completed",
|
||||
killedBySignal: "terminate",
|
||||
args: { command: "npm start" },
|
||||
result: {
|
||||
success: true,
|
||||
output: "Starting dev server...",
|
||||
exit_code: 0,
|
||||
wall_duration_ms: 2000,
|
||||
background_process_id: PROCESS_ID,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("npm start")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** Execute NOT signaled — no indicator. */
|
||||
export const ExecuteNotSignaled: Story = {
|
||||
args: {
|
||||
name: "execute",
|
||||
status: "completed",
|
||||
args: { command: "echo hello" },
|
||||
result: {
|
||||
success: true,
|
||||
output: "hello",
|
||||
exit_code: 0,
|
||||
wall_duration_ms: 100,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("echo hello")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** Running execute — killed indicator should NOT appear yet. */
|
||||
export const ExecuteRunningNotYetKilled: Story = {
|
||||
args: {
|
||||
name: "execute",
|
||||
status: "running",
|
||||
killedBySignal: "kill",
|
||||
args: { command: "make pre-push 2>&1" },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("make pre-push 2>&1")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProcessOutput tool — killed indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ProcessOutputKilled: Story = {
|
||||
args: {
|
||||
name: "process_output",
|
||||
status: "completed",
|
||||
killedBySignal: "kill",
|
||||
args: { process_id: PROCESS_ID },
|
||||
result: {
|
||||
output: "pre-push (/tmp/coder-pre-push.CZ6K9A)\ntest + build site:",
|
||||
exit_code: null,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText(/pre-push/)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ProcessOutputTerminated: Story = {
|
||||
args: {
|
||||
name: "process_output",
|
||||
status: "completed",
|
||||
killedBySignal: "terminate",
|
||||
args: { process_id: PROCESS_ID },
|
||||
result: {
|
||||
output: "server output",
|
||||
exit_code: null,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("server output")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** ProcessOutput with no output and killed — indicator in empty state. */
|
||||
export const ProcessOutputKilledNoOutput: Story = {
|
||||
args: {
|
||||
name: "process_output",
|
||||
status: "completed",
|
||||
killedBySignal: "kill",
|
||||
args: { process_id: PROCESS_ID },
|
||||
result: {
|
||||
output: "",
|
||||
exit_code: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** ProcessOutput NOT signaled. */
|
||||
export const ProcessOutputNotSignaled: Story = {
|
||||
args: {
|
||||
name: "process_output",
|
||||
status: "completed",
|
||||
args: { process_id: PROCESS_ID },
|
||||
result: {
|
||||
output: "some output",
|
||||
exit_code: 0,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("some output")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -1,10 +1,15 @@
|
||||
import { ChevronDownIcon, LoaderIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, LoaderIcon, OctagonXIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { CopyButton } from "#/components/CopyButton/CopyButton";
|
||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { COLLAPSED_OUTPUT_HEIGHT } from "./utils";
|
||||
import { COLLAPSED_OUTPUT_HEIGHT, signalTooltipLabel } from "./utils";
|
||||
|
||||
/**
|
||||
* Specialized rendering for `process_output` tool calls. Shows
|
||||
@@ -16,7 +21,8 @@ export const ProcessOutputTool: React.FC<{
|
||||
isRunning: boolean;
|
||||
exitCode: number | null;
|
||||
isError: boolean;
|
||||
}> = ({ output, isRunning, exitCode, isError }) => {
|
||||
killedBySignal?: "kill" | "terminate";
|
||||
}> = ({ output, isRunning, exitCode, isError, killedBySignal }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const outputRef = useRef<HTMLPreElement | null>(null);
|
||||
const hasOutput = output.length > 0;
|
||||
@@ -62,6 +68,16 @@ export const ProcessOutputTool: React.FC<{
|
||||
{isRunning && (
|
||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
||||
)}
|
||||
{killedBySignal && !isRunning && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{signalTooltipLabel(killedBySignal)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showExitCode && (
|
||||
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive">
|
||||
exit {exitCode}
|
||||
@@ -94,6 +110,16 @@ export const ProcessOutputTool: React.FC<{
|
||||
{isRunning && (
|
||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
||||
)}
|
||||
{killedBySignal && !isRunning && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{signalTooltipLabel(killedBySignal)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showExitCode && (
|
||||
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive">
|
||||
exit {exitCode}
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { Tool } from "./Tool";
|
||||
|
||||
const PROCESS_ID = "376b2458-e318-4442-8b87-51a0f9727f0e";
|
||||
|
||||
const meta: Meta<typeof Tool> = {
|
||||
title: "components/ai-elements/tool/ProcessSignal",
|
||||
component: Tool,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-3xl rounded-lg border border-solid border-border-default bg-surface-primary p-4">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: { name: "process_signal" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Tool>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Running states
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RunningKill: Story = {
|
||||
args: {
|
||||
status: "running",
|
||||
args: { process_id: PROCESS_ID, signal: "kill" },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("Killing process…")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const RunningTerminate: Story = {
|
||||
args: {
|
||||
status: "running",
|
||||
args: { process_id: PROCESS_ID, signal: "terminate" },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("Terminating process…")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const RunningUnknownSignal: Story = {
|
||||
args: {
|
||||
status: "running",
|
||||
args: { process_id: PROCESS_ID, signal: "" },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("Sending signal…")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Success states
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SuccessKill: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
args: { process_id: PROCESS_ID, signal: "kill" },
|
||||
result: {
|
||||
success: true,
|
||||
message: `signal "kill" sent to process ${PROCESS_ID}`,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("Killed process 376b2458")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const SuccessTerminate: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
args: { process_id: PROCESS_ID, signal: "terminate" },
|
||||
result: {
|
||||
success: true,
|
||||
message: `signal "terminate" sent to process ${PROCESS_ID}`,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("Terminated process 376b2458")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Failure states
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Backend errorResult() wraps failures in NewTextResponse (not
|
||||
* NewTextErrorResponse), so isError stays false. The renderer
|
||||
* detects this via the success=false field.
|
||||
*/
|
||||
export const SoftFailureKill: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
args: { process_id: PROCESS_ID, signal: "kill" },
|
||||
result: {
|
||||
success: false,
|
||||
error: "signal process: process not found",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
canvas.getByText("Failed to kill process 376b2458"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const SoftFailureTerminate: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
args: { process_id: PROCESS_ID, signal: "terminate" },
|
||||
result: {
|
||||
success: false,
|
||||
error: "signal process: process not found",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
canvas.getByText("Failed to terminate process 376b2458"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Protocol-level error from NewTextErrorResponse (e.g. missing
|
||||
* args). isError is true, result is a plain string.
|
||||
*/
|
||||
export const ProtocolError: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
isError: true,
|
||||
args: { process_id: "", signal: "kill" },
|
||||
result: "process_id is required",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("Failed to kill process")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Protocol-level error with a structured result body.
|
||||
*/
|
||||
export const ProtocolErrorStructured: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
isError: true,
|
||||
args: { process_id: PROCESS_ID, signal: "terminate" },
|
||||
result: {
|
||||
success: false,
|
||||
error: "workspace connection resolver is not configured",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
canvas.getByText("Failed to terminate process 376b2458"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** No args parsed yet (streamed tool call with partial data). */
|
||||
export const NoArgs: Story = {
|
||||
args: {
|
||||
status: "running",
|
||||
args: undefined,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("Sending signal…")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -56,6 +56,7 @@ interface ToolProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
killedBySignal?: "kill" | "terminate";
|
||||
/** Maps sub-agent chat IDs to their titles, built from spawn tool results. */
|
||||
subagentTitles?: Map<string, string>;
|
||||
/** Set of chat IDs spawned by `spawn_computer_use_agent`. */
|
||||
@@ -81,6 +82,7 @@ type ToolRendererProps = {
|
||||
args: unknown;
|
||||
result: unknown;
|
||||
isError: boolean;
|
||||
killedBySignal?: "kill" | "terminate";
|
||||
subagentTitles?: Map<string, string>;
|
||||
computerUseSubagentIds?: Set<string>;
|
||||
showDesktopPreviews?: boolean;
|
||||
@@ -99,6 +101,7 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
|
||||
args,
|
||||
result,
|
||||
isError,
|
||||
killedBySignal,
|
||||
}) => {
|
||||
const parsedArgs = parseArgs(args);
|
||||
const command = parsedArgs ? asString(parsedArgs.command) : "";
|
||||
@@ -128,6 +131,7 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
|
||||
output={output}
|
||||
status={status}
|
||||
isError={isError}
|
||||
killedBySignal={killedBySignal}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -136,6 +140,7 @@ const ProcessOutputRenderer: FC<ToolRendererProps> = ({
|
||||
status,
|
||||
result,
|
||||
isError,
|
||||
killedBySignal,
|
||||
}) => {
|
||||
const rec = asRecord(result);
|
||||
const output = rec ? asString(rec.output).trim() : "";
|
||||
@@ -151,6 +156,7 @@ const ProcessOutputRenderer: FC<ToolRendererProps> = ({
|
||||
isRunning={status === "running"}
|
||||
exitCode={exitCode}
|
||||
isError={isError}
|
||||
killedBySignal={killedBySignal}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -614,6 +620,24 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// process_signal — thin wrapper that promotes soft failures (success=false
|
||||
// in the result body, isError=false at protocol level) so the generic
|
||||
// renderer shows the error indicator and tooltip.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ProcessSignalRenderer: FC<ToolRendererProps> = (props) => {
|
||||
const rec = asRecord(props.result);
|
||||
const isSoftFailure =
|
||||
!props.isError &&
|
||||
props.status !== "running" &&
|
||||
rec !== null &&
|
||||
!rec.success;
|
||||
return (
|
||||
<GenericToolRenderer {...props} isError={props.isError || isSoftFailure} />
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Renderer lookup map — maps tool names to their specialized renderers.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -621,6 +645,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
|
||||
const toolRenderers: Record<string, FC<ToolRendererProps>> = {
|
||||
execute: ExecuteRenderer,
|
||||
process_output: ProcessOutputRenderer,
|
||||
process_signal: ProcessSignalRenderer,
|
||||
wait_for_external_auth: WaitForExternalAuthRenderer,
|
||||
read_file: ReadFileRenderer,
|
||||
write_file: WriteFileRenderer,
|
||||
@@ -650,6 +675,7 @@ export const Tool = memo(
|
||||
args,
|
||||
result,
|
||||
isError = false,
|
||||
killedBySignal,
|
||||
subagentTitles,
|
||||
computerUseSubagentIds,
|
||||
showDesktopPreviews,
|
||||
@@ -681,6 +707,7 @@ export const Tool = memo(
|
||||
args={args}
|
||||
result={result}
|
||||
isError={isError}
|
||||
killedBySignal={killedBySignal}
|
||||
subagentTitles={subagentTitles}
|
||||
computerUseSubagentIds={computerUseSubagentIds}
|
||||
showDesktopPreviews={showDesktopPreviews}
|
||||
|
||||
@@ -70,6 +70,8 @@ export const ToolIcon: React.FC<{
|
||||
switch (name) {
|
||||
case "execute":
|
||||
case "process_output":
|
||||
case "process_list":
|
||||
case "process_signal":
|
||||
return <TerminalIcon className={base} />;
|
||||
case "read_file":
|
||||
case "list_templates":
|
||||
|
||||
@@ -32,6 +32,49 @@ export const ToolLabel: React.FC<{
|
||||
Reading process output
|
||||
</span>
|
||||
);
|
||||
case "process_signal": {
|
||||
const signal = parsed ? asString(parsed.signal) : "";
|
||||
const processId = parsed ? asString(parsed.process_id) : "";
|
||||
const shortId = processId ? processId.slice(0, 8) : "";
|
||||
const hasResult = result !== undefined && result !== null;
|
||||
const success = parsedResult ? Boolean(parsedResult.success) : false;
|
||||
if (hasResult && success) {
|
||||
const verb = signal === "kill" ? "Killed" : "Terminated";
|
||||
return (
|
||||
<span className="truncate text-sm text-content-secondary">
|
||||
{verb} process{shortId ? ` ${shortId}` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (hasResult && !success) {
|
||||
const verb =
|
||||
signal === "kill"
|
||||
? "kill"
|
||||
: signal === "terminate"
|
||||
? "terminate"
|
||||
: "signal";
|
||||
return (
|
||||
<span className="truncate text-sm text-content-secondary">
|
||||
Failed to {verb} process{shortId ? ` ${shortId}` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="truncate text-sm text-content-secondary">
|
||||
{signal === "kill"
|
||||
? "Killing process…"
|
||||
: signal === "terminate"
|
||||
? "Terminating process…"
|
||||
: "Sending signal…"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case "process_list":
|
||||
return (
|
||||
<span className="truncate text-sm text-content-secondary">
|
||||
Listing processes
|
||||
</span>
|
||||
);
|
||||
case "read_file":
|
||||
return (
|
||||
<span className="truncate text-sm text-content-secondary">
|
||||
|
||||
@@ -580,3 +580,9 @@ export function humanizeMCPToolName(
|
||||
// Re-export runtime type utils used by sub-components so they
|
||||
// can import from a single location.
|
||||
export { asNumber, asRecord, asString } from "../runtimeTypeUtils";
|
||||
|
||||
/**
|
||||
* Returns the tooltip label for a killed/terminated process signal.
|
||||
*/
|
||||
export const signalTooltipLabel = (signal: "kill" | "terminate"): string =>
|
||||
signal === "kill" ? "Killed (SIGKILL)" : "Terminated (SIGTERM)";
|
||||
|
||||
Reference in New Issue
Block a user