From f2845f6622c7a308736a83b2302aecaa8a3a6849 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 30 Mar 2026 15:35:39 +0300 Subject: [PATCH] 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. --- .../ChatConversation/ConversationTimeline.tsx | 2 + .../ChatConversation/messageParsing.test.ts | 136 +++++++++++++ .../ChatConversation/messageParsing.ts | 31 +++ .../components/ChatConversation/types.ts | 2 + .../ChatElements/tools/ExecuteTool.tsx | 15 +- .../tools/ProcessKilledIndicator.stories.tsx | 165 +++++++++++++++ .../ChatElements/tools/ProcessOutputTool.tsx | 32 ++- .../tools/ProcessSignalTool.stories.tsx | 188 ++++++++++++++++++ .../components/ChatElements/tools/Tool.tsx | 27 +++ .../ChatElements/tools/ToolIcon.tsx | 2 + .../ChatElements/tools/ToolLabel.tsx | 43 ++++ .../components/ChatElements/tools/utils.ts | 6 + 12 files changed, 645 insertions(+), 4 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/ChatElements/tools/ProcessKilledIndicator.stories.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatElements/tools/ProcessSignalTool.stories.tsx diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index a0225b22ab..332b2bbf20 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -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} diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts index 785ee3a510..78069dffdf 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts @@ -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 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, + ): ChatMessagePart => ({ + type: "tool-call" as const, + tool_call_id: id, + tool_name: name, + args, + }); + const toolResult = ( + id: string, + name: string, + result: Record, + isError = false, + ): ChatMessagePart => ({ + type: "tool-result" as const, + tool_call_id: id, + tool_name: name, + // The generated type uses Record but real + // tool results contain booleans and nulls. + result: result as Record, + 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"); + }); +}); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts index 19c652da7e..bbe980c49b 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts @@ -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(); + 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; }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/types.ts b/site/src/pages/AgentsPage/components/ChatConversation/types.ts index 85e69e73bd..ac4827f3bd 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/types.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/types.ts @@ -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 = diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx index 0016075b94..14288bd736 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx @@ -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(null); const hasOutput = output.length > 0; @@ -132,6 +135,16 @@ export const ExecuteTool: React.FC<{ Running in background )} + {killedBySignal && !isRunning && ( + + + + + + {signalTooltipLabel(killedBySignal)} + + + )} = { + title: "components/ai-elements/tool/ProcessKilledIndicator", + component: Tool, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +// --------------------------------------------------------------------------- +// 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(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx index d326a68885..bdddc388ac 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx @@ -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(null); const hasOutput = output.length > 0; @@ -62,6 +68,16 @@ export const ProcessOutputTool: React.FC<{ {isRunning && ( )} + {killedBySignal && !isRunning && ( + + + + + + {signalTooltipLabel(killedBySignal)} + + + )} {showExitCode && ( exit {exitCode} @@ -94,6 +110,16 @@ export const ProcessOutputTool: React.FC<{ {isRunning && ( )} + {killedBySignal && !isRunning && ( + + + + + + {signalTooltipLabel(killedBySignal)} + + + )} {showExitCode && ( exit {exitCode} diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessSignalTool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessSignalTool.stories.tsx new file mode 100644 index 0000000000..fd54caafb5 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessSignalTool.stories.tsx @@ -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 = { + title: "components/ai-elements/tool/ProcessSignal", + component: Tool, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { name: "process_signal" }, +}; +export default meta; +type Story = StoryObj; + +// --------------------------------------------------------------------------- +// 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(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index d8a7f98fec..846eaf14bf 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -56,6 +56,7 @@ interface ToolProps extends Omit, "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; /** 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; computerUseSubagentIds?: Set; showDesktopPreviews?: boolean; @@ -99,6 +101,7 @@ const ExecuteRenderer: FC = ({ args, result, isError, + killedBySignal, }) => { const parsedArgs = parseArgs(args); const command = parsedArgs ? asString(parsedArgs.command) : ""; @@ -128,6 +131,7 @@ const ExecuteRenderer: FC = ({ output={output} status={status} isError={isError} + killedBySignal={killedBySignal} /> ); }; @@ -136,6 +140,7 @@ const ProcessOutputRenderer: FC = ({ status, result, isError, + killedBySignal, }) => { const rec = asRecord(result); const output = rec ? asString(rec.output).trim() : ""; @@ -151,6 +156,7 @@ const ProcessOutputRenderer: FC = ({ isRunning={status === "running"} exitCode={exitCode} isError={isError} + killedBySignal={killedBySignal} /> ); }; @@ -614,6 +620,24 @@ const GenericToolRenderer: FC = ({ ); }; +// --------------------------------------------------------------------------- +// 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 = (props) => { + const rec = asRecord(props.result); + const isSoftFailure = + !props.isError && + props.status !== "running" && + rec !== null && + !rec.success; + return ( + + ); +}; + // --------------------------------------------------------------------------- // Renderer lookup map — maps tool names to their specialized renderers. // --------------------------------------------------------------------------- @@ -621,6 +645,7 @@ const GenericToolRenderer: FC = ({ const toolRenderers: Record> = { 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} diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx index 61402984e0..77888c8d6e 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx @@ -70,6 +70,8 @@ export const ToolIcon: React.FC<{ switch (name) { case "execute": case "process_output": + case "process_list": + case "process_signal": return ; case "read_file": case "list_templates": diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx index 4f9d4396d9..61cb2716b6 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolLabel.tsx @@ -32,6 +32,49 @@ export const ToolLabel: React.FC<{ Reading process output
); + 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 ( + + {verb} process{shortId ? ` ${shortId}` : ""} + + ); + } + if (hasResult && !success) { + const verb = + signal === "kill" + ? "kill" + : signal === "terminate" + ? "terminate" + : "signal"; + return ( + + Failed to {verb} process{shortId ? ` ${shortId}` : ""} + + ); + } + return ( + + {signal === "kill" + ? "Killing process…" + : signal === "terminate" + ? "Terminating process…" + : "Sending signal…"} + + ); + } + case "process_list": + return ( + + Listing processes + + ); case "read_file": return ( diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts index 0c58b3c092..804a1bd2ed 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts @@ -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)";