From b25f791ec00007ff6db0d1b280af0b8e7091d7f2 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 19 Aug 2026 12:37:33 +0000 Subject: [PATCH 01/24] feat(chatd): label process_output rows with the process command The process_output tool result carried only output, exit code, and running state, so chat transcripts rendered every poll as a generic "Process output" row with no indication of which process produced it. Return the process command from the agent's process output endpoint and propagate it through the chatd tool result. The UI now renders "Checking " while the process runs and "Checked " once it exits, mirroring the execute tool's verb-first label grammar. Results without a command (older agents, historical transcripts) fall back to the previous generic label. Also align the process output body with the execute tool: tinted rounded panel with a scrollable max-h-64 viewport instead of the bordered box, and drop the bespoke secondary expand/collapse button in favor of the standard header toggle plus scrolling. --- agent/agentproc/api.go | 1 + agent/agentproc/api_test.go | 20 ++++ coderd/x/chatd/chattool/execute.go | 5 + coderd/x/chatd/chattool/execute_test.go | 36 +++++++ codersdk/workspacesdk/agentconn.go | 3 + .../agentconnmock/agentconnmock.go | 13 ++- .../ChatElements/tools/ProcessOutputTool.tsx | 100 +++++------------- .../ChatElements/tools/Tool.stories.tsx | 61 +++++++++-- .../components/ChatElements/tools/Tool.tsx | 2 + .../ChatElements/tools/utils.test.ts | 5 - .../components/ChatElements/tools/utils.ts | 3 - 11 files changed, 155 insertions(+), 94 deletions(-) diff --git a/agent/agentproc/api.go b/agent/agentproc/api.go index 4713485e1b294..b6f822e69efd5 100644 --- a/agent/agentproc/api.go +++ b/agent/agentproc/api.go @@ -215,6 +215,7 @@ func (api *API) handleProcessOutput(rw http.ResponseWriter, r *http.Request) { Truncated: truncated, Running: info.Running, ExitCode: info.ExitCode, + Command: info.Command, }) } diff --git a/agent/agentproc/api_test.go b/agent/agentproc/api_test.go index c718cf324867e..16b28f57d01ab 100644 --- a/agent/agentproc/api_test.go +++ b/agent/agentproc/api_test.go @@ -835,6 +835,26 @@ func TestProcessOutput(t *testing.T) { waitForExit(t, handler, id) }) + t.Run("IncludesCommand", func(t *testing.T) { + t.Parallel() + + handler := newTestAPI(t) + + id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{ + Command: "printf hello", + }) + waitForExit(t, handler, id) + + w := getOutput(t, handler, id) + require.Equal(t, http.StatusOK, w.Code) + + var resp workspacesdk.ProcessOutputResponse + err := json.NewDecoder(w.Body).Decode(&resp) + require.NoError(t, err) + require.False(t, resp.Running) + require.Equal(t, "printf hello", resp.Command) + }) + t.Run("NonexistentProcess", func(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chattool/execute.go b/coderd/x/chatd/chattool/execute.go index 7833d503785e7..14e32c3457d40 100644 --- a/coderd/x/chatd/chattool/execute.go +++ b/coderd/x/chatd/chattool/execute.go @@ -85,6 +85,10 @@ type ExecuteResult struct { Truncated *workspacesdk.ProcessTruncation `json:"truncated,omitempty"` Note string `json:"note,omitempty"` BackgroundProcessID string `json:"background_process_id,omitempty"` + // Command identifies the process for process_output + // results, so both the model and the UI can label the + // output without correlating against earlier calls. + Command string `json:"command,omitempty"` } // ExecuteOptions configures the execute tool. @@ -499,6 +503,7 @@ func ProcessOutput(options ProcessToolOptions) fantasy.AgentTool { Output: output, ExitCode: exitCode, Truncated: resp.Truncated, + Command: resp.Command, } if resp.Running { // Process is still running, success is not diff --git a/coderd/x/chatd/chattool/execute_test.go b/coderd/x/chatd/chattool/execute_test.go index ede6c1a957e70..553f7056f8b59 100644 --- a/coderd/x/chatd/chattool/execute_test.go +++ b/coderd/x/chatd/chattool/execute_test.go @@ -514,6 +514,42 @@ func TestExecuteTool(t *testing.T) { } }) + t.Run("ProcessOutputCommandPropagated", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + + exitCode := 1 + mockConn.EXPECT(). + ProcessOutput(gomock.Any(), "proc-1", gomock.Any()). + Return(workspacesdk.ProcessOutputResponse{ + Running: false, + ExitCode: &exitCode, + Output: "server exited: EADDRINUSE", + Command: "npm start", + }, nil) + + tool := chattool.ProcessOutput(chattool.ProcessToolOptions{ + GetWorkspaceConn: func(_ context.Context) (workspacesdk.AgentConn, error) { + return mockConn, nil + }, + }) + ctx := testutil.Context(t, testutil.WaitMedium) + resp, err := tool.Run(ctx, fantasy.ToolCall{ + ID: "call-1", + Name: "process_output", + Input: `{"process_id":"proc-1","wait_timeout":"0s"}`, + }) + require.NoError(t, err) + assert.False(t, resp.IsError) + + var result chattool.ExecuteResult + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + assert.False(t, result.Success) + assert.Equal(t, 1, result.ExitCode) + assert.Equal(t, "npm start", result.Command) + }) + t.Run("ProcessOutputError", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index f63aebfa72e78..e78c02e550cfa 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -943,6 +943,9 @@ type ProcessOutputResponse struct { Truncated *ProcessTruncation `json:"truncated,omitempty"` Running bool `json:"running"` ExitCode *int `json:"exit_code,omitempty"` + // Command identifies the process so callers can label the + // output without a separate list call. + Command string `json:"command,omitempty"` } // ProcessOutputOptions configures blocking behavior for diff --git a/codersdk/workspacesdk/agentconnmock/agentconnmock.go b/codersdk/workspacesdk/agentconnmock/agentconnmock.go index 2647f409c1202..e92dfd808a38f 100644 --- a/codersdk/workspacesdk/agentconnmock/agentconnmock.go +++ b/codersdk/workspacesdk/agentconnmock/agentconnmock.go @@ -17,19 +17,18 @@ import ( reflect "reflect" time "time" - uuid "github.com/google/uuid" - gomock "go.uber.org/mock/gomock" - ssh "golang.org/x/crypto/ssh" - gonet "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" - ipnstate "tailscale.com/ipn/ipnstate" - speedtest "tailscale.com/net/speedtest" - slog "cdr.dev/slog/v3" codersdk "github.com/coder/coder/v2/codersdk" healthsdk "github.com/coder/coder/v2/codersdk/healthsdk" workspacesdk "github.com/coder/coder/v2/codersdk/workspacesdk" wsjson "github.com/coder/coder/v2/codersdk/wsjson" tailnet "github.com/coder/coder/v2/tailnet" + uuid "github.com/google/uuid" + gomock "go.uber.org/mock/gomock" + ssh "golang.org/x/crypto/ssh" + gonet "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + ipnstate "tailscale.com/ipn/ipnstate" + speedtest "tailscale.com/net/speedtest" ) // MockAgentConn is a mock of AgentConn interface. diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx index 8b740af4e882b..74a27e81768cb 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx @@ -1,6 +1,5 @@ -import { ChevronDownIcon, OctagonXIcon } from "lucide-react"; +import { OctagonXIcon } from "lucide-react"; import type React from "react"; -import { useState } from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { CopyButton } from "#/components/CopyButton/CopyButton"; import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; @@ -12,14 +11,14 @@ import { import { cn } from "#/utils/cn"; import { type AgentDisplayState, - isAgentDisplayFullyExpanded, resolveAgentDisplayState, } from "./displayMode"; import { ToolCall } from "./ToolCall"; -import { COLLAPSED_OUTPUT_HEIGHT, signalTooltipLabel } from "./utils"; +import { signalTooltipLabel } from "./utils"; type ProcessOutputToolProps = { output: string; + command?: string; isRunning: boolean; exitCode: number | null; isError: boolean; @@ -28,60 +27,41 @@ type ProcessOutputToolProps = { shellToolDisplayMode?: TypesGen.AgentDisplayMode; }; -type ProcessOutputToolInnerProps = ProcessOutputToolProps & { - defaultView: AgentDisplayState; - outputInitiallyFullyExpanded: boolean; +const getProcessOutputLabel = ( + command: string | undefined, + isRunning: boolean, +): string => { + const trimmed = command?.trim() ?? ""; + if (!trimmed) { + return "Process output"; + } + return `${isRunning ? "Checking" : "Checked"} ${trimmed}`; }; -export const ProcessOutputTool: React.FC = (props) => { - const autoDisplayState: AgentDisplayState = - props.output.length > 0 ? "preview" : "collapsed"; - const resolvedDisplayState = resolveAgentDisplayState( - props.shellToolDisplayMode, - autoDisplayState, - ); - return ( - - ); -}; - -const ProcessOutputToolInner: React.FC = ({ +export const ProcessOutputTool: React.FC = ({ output, + command, isRunning, exitCode, isError, errorMessage, killedBySignal, - defaultView, - outputInitiallyFullyExpanded, + shellToolDisplayMode, }) => { - const [outputFullyExpanded, setOutputFullyExpanded] = useState( - outputInitiallyFullyExpanded, + const autoDisplayState: AgentDisplayState = + output.length > 0 ? "preview" : "collapsed"; + const defaultView = resolveAgentDisplayState( + shellToolDisplayMode, + autoDisplayState, ); - const hasOutput = output.length > 0; - - const [overflows, setOverflows] = useState(false); - const measureRef = (node: HTMLPreElement | null) => { - if (node) { - setOverflows(node.scrollHeight > COLLAPSED_OUTPUT_HEIGHT); - } - }; const showExitCode = exitCode !== null && exitCode !== 0; - const toggleOutputExpansion = () => { - setOutputFullyExpanded((expanded) => !expanded); - }; + const hasOutput = output.length > 0; const hasHeaderActions = Boolean(killedBySignal) || showExitCode || hasOutput; return ( = ({ - Process output + + {getProcessOutputLabel(command, isRunning)} + @@ -128,45 +110,19 @@ const ProcessOutputToolInner: React.FC = ({
 						{output}
 					
- {overflows && ( - - )}
); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index ce3b7c1b23b51..320cc59670c49 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -681,13 +681,60 @@ export const ProcessOutputAlwaysExpanded: Story = { const canvas = within(canvasElement); expect(canvas.getByText(/process output line 1/)).toBeVisible(); expect(canvas.getByText(/process output line 30/)).toBeVisible(); - await waitFor(() => { - expect( - canvas.getByRole("button", { - name: "Collapse full process output", - }), - ).toHaveAttribute("aria-expanded", "true"); - }); + }, +}; + +export const ProcessOutputChecked: Story = { + args: { + name: "process_output", + status: "completed", + args: { process_id: "process-123" }, + result: { + command: "npm start", + output: + "> Local: http://localhost:3001/\n> Server exited: EADDRINUSE :::3001", + exit_code: 1, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Checked npm start")).toBeVisible(); + expect(canvas.getByText("exit 1")).toBeVisible(); + expect(canvas.getByText(/EADDRINUSE/)).toBeVisible(); + }, +}; + +export const ProcessOutputChecking: Story = { + args: { + name: "process_output", + status: "running", + args: { process_id: "process-123" }, + result: { + command: "npm start", + output: "> Starting Vite dev server...", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Checking npm start")).toBeVisible(); + }, +}; + +/** Older transcripts carry no command; the label falls back. */ +export const ProcessOutputNoCommand: Story = { + args: { + name: "process_output", + status: "completed", + args: { process_id: "process-123" }, + result: { + output: "some output", + exit_code: 0, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Process output")).toBeVisible(); + expect(canvas.getByText("some output")).toBeVisible(); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 3d553fa5706ce..53878817689e5 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -251,6 +251,7 @@ const ProcessOutputRenderer: FC = ({ }) => { const rec = asRecord(result); const output = rec ? asString(rec.output).trim() : ""; + const command = rec ? asString(rec.command).trim() : ""; const exitCode = rec ? (asNumber(rec.exit_code, { parseString: true }) ?? null) : null; @@ -259,6 +260,7 @@ const ProcessOutputRenderer: FC = ({ return ( { }); describe("constants", () => { - it("COLLAPSED_OUTPUT_HEIGHT is 54", () => { - expect(COLLAPSED_OUTPUT_HEIGHT).toBe(54); - }); - it("COLLAPSED_REPORT_HEIGHT is 72", () => { expect(COLLAPSED_REPORT_HEIGHT).toBe(72); }); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts index ea84f619b2bc1..68d9d1f75f90e 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts @@ -508,9 +508,6 @@ export const getWriteFileDiff = ( return buildWriteFileDiff(path, content); }; -/** Height that fits roughly 3 lines of monospace text-xs output. */ -export const COLLAPSED_OUTPUT_HEIGHT = 54; - /** Height for the collapsed report preview (~3 lines of rendered markdown). */ export const COLLAPSED_REPORT_HEIGHT = 72; From 777b875142465b701eaf8306e98a4f521b734703 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 19 Aug 2026 13:08:36 +0000 Subject: [PATCH 02/24] feat(chatd): accept model_intent on process_output Mirroring the execute tool, process_output now takes an optional model_intent describing why the agent is checking the process. The chat header composes it with the command (e.g. "Waiting for the dev server to be ready on npm start"), reusing the execute intent sanitizer to strip redundant command references. Calls without an intent keep the previous Checking/Checked labels. --- coderd/x/chatd/chattool/execute.go | 1 + .../ChatElements/tools/ProcessOutputTool.tsx | 31 +++++++++---- .../ChatElements/tools/Tool.stories.tsx | 46 +++++++++++++++++++ .../components/ChatElements/tools/Tool.tsx | 2 + 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/coderd/x/chatd/chattool/execute.go b/coderd/x/chatd/chattool/execute.go index 14e32c3457d40..08d57b59729be 100644 --- a/coderd/x/chatd/chattool/execute.go +++ b/coderd/x/chatd/chattool/execute.go @@ -426,6 +426,7 @@ const ( type ProcessOutputArgs struct { ProcessID string `json:"process_id"` WaitTimeout *string `json:"wait_timeout,omitempty" description:"Override the default 10s block duration. The call blocks until the process exits or this timeout is reached. Set to '0s' for an immediate snapshot without waiting."` + ModelIntent *string `json:"model_intent,omitempty" description:"A short, natural-language, present-participle phrase describing why you are checking this process. This is shown to the user alongside the command. Use plain English with no underscores or technical jargon. Do not restate the command or include a duration. Keep it under 100 characters. Good examples: \"Waiting for the dev server to be ready\", \"Confirming the tests still pass\"."` } // ProcessOutput returns an AgentTool that retrieves the output diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx index 74a27e81768cb..d56c38bca1d2a 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx @@ -14,11 +14,12 @@ import { resolveAgentDisplayState, } from "./displayMode"; import { ToolCall } from "./ToolCall"; -import { signalTooltipLabel } from "./utils"; +import { sanitizeExecuteModelIntent, signalTooltipLabel } from "./utils"; type ProcessOutputToolProps = { output: string; command?: string; + modelIntent?: string; isRunning: boolean; exitCode: number | null; isError: boolean; @@ -27,20 +28,32 @@ type ProcessOutputToolProps = { shellToolDisplayMode?: TypesGen.AgentDisplayMode; }; -const getProcessOutputLabel = ( - command: string | undefined, - isRunning: boolean, -): string => { - const trimmed = command?.trim() ?? ""; - if (!trimmed) { +const getProcessOutputLabel = ({ + command, + modelIntent, + isRunning, +}: { + command: string | undefined; + modelIntent: string | undefined; + isRunning: boolean; +}): string => { + const trimmedCommand = command?.trim() ?? ""; + const intent = modelIntent + ? sanitizeExecuteModelIntent(modelIntent, trimmedCommand) + : ""; + if (intent) { + return trimmedCommand ? `${intent} on ${trimmedCommand}` : intent; + } + if (!trimmedCommand) { return "Process output"; } - return `${isRunning ? "Checking" : "Checked"} ${trimmed}`; + return `${isRunning ? "Checking" : "Checked"} ${trimmedCommand}`; }; export const ProcessOutputTool: React.FC = ({ output, command, + modelIntent, isRunning, exitCode, isError, @@ -76,7 +89,7 @@ export const ProcessOutputTool: React.FC = ({ - {getProcessOutputLabel(command, isRunning)} + {getProcessOutputLabel({ command, modelIntent, isRunning })} diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 320cc59670c49..c99a92c3112b0 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -704,6 +704,52 @@ export const ProcessOutputChecked: Story = { }, }; +export const ProcessOutputModelIntent: Story = { + args: { + name: "process_output", + status: "running", + args: { + process_id: "process-123", + model_intent: "Waiting for the dev server to be ready", + }, + modelIntent: "Waiting for the dev server to be ready", + result: { + command: "npm start", + output: "> Starting Vite dev server...", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByText("Waiting for the dev server to be ready on npm start"), + ).toBeVisible(); + }, +}; + +/** Redundant "using " suffixes are stripped from the intent. */ +export const ProcessOutputModelIntentRedundant: Story = { + args: { + name: "process_output", + status: "completed", + args: { + process_id: "process-123", + model_intent: "Confirming the tests pass using npm start", + }, + modelIntent: "Confirming the tests pass using npm start", + result: { + command: "npm start", + output: "all tests passed", + exit_code: 0, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByText("Confirming the tests pass on npm start"), + ).toBeVisible(); + }, +}; + export const ProcessOutputChecking: Story = { args: { name: "process_output", diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 53878817689e5..09a66ab04babd 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -247,6 +247,7 @@ const ProcessOutputRenderer: FC = ({ result, isError, killedBySignal, + modelIntent, shellToolDisplayMode, }) => { const rec = asRecord(result); @@ -261,6 +262,7 @@ const ProcessOutputRenderer: FC = ({ Date: Wed, 19 Aug 2026 13:40:29 +0000 Subject: [PATCH 03/24] chore(codersdk/workspacesdk): format mock with gci go generate emits third-party import grouping; make gen applies gci with the repo's coder/cdr.dev prefix group on top. Run the formatter so the gen freshness check passes. --- .../workspacesdk/agentconnmock/agentconnmock.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/codersdk/workspacesdk/agentconnmock/agentconnmock.go b/codersdk/workspacesdk/agentconnmock/agentconnmock.go index e92dfd808a38f..2647f409c1202 100644 --- a/codersdk/workspacesdk/agentconnmock/agentconnmock.go +++ b/codersdk/workspacesdk/agentconnmock/agentconnmock.go @@ -17,18 +17,19 @@ import ( reflect "reflect" time "time" - slog "cdr.dev/slog/v3" - codersdk "github.com/coder/coder/v2/codersdk" - healthsdk "github.com/coder/coder/v2/codersdk/healthsdk" - workspacesdk "github.com/coder/coder/v2/codersdk/workspacesdk" - wsjson "github.com/coder/coder/v2/codersdk/wsjson" - tailnet "github.com/coder/coder/v2/tailnet" uuid "github.com/google/uuid" gomock "go.uber.org/mock/gomock" ssh "golang.org/x/crypto/ssh" gonet "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" ipnstate "tailscale.com/ipn/ipnstate" speedtest "tailscale.com/net/speedtest" + + slog "cdr.dev/slog/v3" + codersdk "github.com/coder/coder/v2/codersdk" + healthsdk "github.com/coder/coder/v2/codersdk/healthsdk" + workspacesdk "github.com/coder/coder/v2/codersdk/workspacesdk" + wsjson "github.com/coder/coder/v2/codersdk/wsjson" + tailnet "github.com/coder/coder/v2/tailnet" ) // MockAgentConn is a mock of AgentConn interface. From ce0b46dd1354f563077538505c831392e0e55b77 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 19 Aug 2026 14:30:15 +0000 Subject: [PATCH 04/24] feat(site): differentiate background-process rows in chat transcripts execute and process_output rows were visually indistinguishable: same terminal icon, same body chrome, and a static layers icon that failed to communicate that a process was still running in the background. - process_output now leads with the activity (pulse) icon so act and observe rows read differently at scan distance. - The layers icon is replaced by a persistent status chip on the backgrounded execute row: pulsing dot + ticking elapsed time while the process is alive, exit state once an observation arrives. State is derived in messageParsing by correlating process_output results and process_signal calls with the execute row's background_process_id. - The misleading spawn-duration suffix ("for 0ms") is suppressed on backgrounded execute rows; the chip carries the live state instead. - process_output now shows a completion chip on clean exits (exit 0) instead of rendering no right-edge state at all. --- .../ChatConversation/MessageBlocks.tsx | 1 + .../ChatConversation/messageParsing.test.ts | 72 +++++++++++ .../ChatConversation/messageParsing.ts | 54 +++++++- .../components/ChatConversation/types.ts | 12 ++ .../tools/BackgroundProcessChip.tsx | 115 ++++++++++++++++++ .../ChatElements/tools/ExecuteTool.tsx | 34 +++--- .../ChatElements/tools/ProcessOutputTool.tsx | 16 ++- .../ChatElements/tools/Tool.stories.tsx | 75 +++++++++++- .../components/ChatElements/tools/Tool.tsx | 15 +++ .../ChatElements/tools/ToolIcon.tsx | 3 +- 10 files changed, 371 insertions(+), 26 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/ChatElements/tools/BackgroundProcessChip.tsx diff --git a/site/src/pages/AgentsPage/components/ChatConversation/MessageBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/MessageBlocks.tsx index 85ef00f2a967c..061b8ecaf54fa 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/MessageBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/MessageBlocks.tsx @@ -356,6 +356,7 @@ export const BlockList: FC = ({ status={tool.status} isError={tool.isError} killedBySignal={tool.killedBySignal} + backgroundProcess={tool.backgroundProcess} shellToolDisplayMode={shellToolDisplayMode} codeDiffDisplayMode={codeDiffDisplayMode} subagentTitles={subagentTitles} diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts index 53356484d75fd..721d66c39a45e 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts @@ -763,6 +763,78 @@ describe("parseMessagesWithMergedTools — killedBySignal annotation", () => { .find((t) => t.name === "process_output"); expect(procOut?.killedBySignal).toBe("terminate"); }); + + it("annotates backgrounded execute with running state from process_output", () => { + const PID = "proc-run"; + const parsed = parseMessagesWithMergedTools([ + msg(1, "assistant", [ + toolCall("tc1", "execute", { command: "npm start" }), + ]), + msg(2, "assistant", [ + toolResult("tc1", "execute", { + success: true, + background_process_id: PID, + }), + toolCall("tc2", "process_output", { process_id: PID }), + ]), + msg(3, "assistant", [ + toolResult("tc2", "process_output", { + output: "starting...", + running: true, + }), + ]), + ]); + + const executeTool = parsed + .flatMap((e) => e.parsed.tools) + .find((t) => t.name === "execute"); + expect(executeTool?.backgroundProcess).toEqual({ state: "running" }); + }); + + it("annotates backgrounded execute with final exit code", () => { + const PID = "proc-exit"; + const parsed = parseMessagesWithMergedTools([ + msg(1, "assistant", [ + toolCall("tc1", "execute", { command: "npm start" }), + ]), + msg(2, "assistant", [ + toolResult("tc1", "execute", { + success: true, + background_process_id: PID, + }), + toolCall("tc2", "process_output", { process_id: PID }), + ]), + msg(3, "assistant", [ + toolResult("tc2", "process_output", { + output: "boom", + running: false, + exit_code: 1, + }), + ]), + ]); + + const executeTool = parsed + .flatMap((e) => e.parsed.tools) + .find((t) => t.name === "execute"); + expect(executeTool?.backgroundProcess).toEqual({ + state: "exited", + exitCode: 1, + }); + }); + + it("does not annotate foreground execute calls", () => { + const parsed = parseMessagesWithMergedTools([ + msg(1, "assistant", [toolCall("tc1", "execute", { command: "echo hi" })]), + msg(2, "assistant", [ + toolResult("tc1", "execute", { success: true, output: "hi" }), + ]), + ]); + + const executeTool = parsed + .flatMap((e) => e.parsed.tools) + .find((t) => t.name === "execute"); + expect(executeTool?.backgroundProcess).toBeUndefined(); + }); }); describe("subagent transcript parsing", () => { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts index da90fb396b582..d9fb0f1d817a4 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts @@ -1,5 +1,5 @@ import type * as TypesGen from "#/api/typesGenerated"; -import { asRecord, asString } from "../ChatElements/runtimeTypeUtils"; +import { asNumber, asRecord, asString } from "../ChatElements/runtimeTypeUtils"; import { getProvidedSubagentTitle, getSubagentChatId, @@ -411,6 +411,58 @@ export const parseMessagesWithMergedTools = ( } } + // Annotate backgrounded execute calls with the live process + // state derived from their process_output observations. The + // execute row is the anchor readers scan for "is it still + // running"; poll rows stay chronological, and the row that + // owns the process flips in place as observations arrive. + const processStateByPid = new Map< + string, + { state: "running" | "exited"; exitCode?: number } + >(); + for (const { parsed } of rawParsed) { + for (const tool of parsed.tools) { + if (tool.name !== "process_output") continue; + const rec = asRecord(tool.result); + const toolArgs = asRecord(tool.args); + const pid = toolArgs ? asString(toolArgs.process_id) : ""; + if (!rec || !pid) continue; + // process_output reports running:true while alive; an + // exited process reports its final exit code. + if (rec.running === true) { + processStateByPid.set(pid, { state: "running" }); + } else { + const exitCode = asNumber(rec.exit_code, { parseString: true }); + processStateByPid.set(pid, { + state: "exited", + exitCode: exitCode ?? undefined, + }); + } + } + } + for (const [pid, sig] of signaledProcesses) { + // A signal is terminal even without a later observation. + if (!processStateByPid.has(pid)) { + processStateByPid.set(pid, { + state: "exited", + exitCode: sig === "kill" ? 137 : 143, + }); + } + } + if (processStateByPid.size > 0) { + for (const { parsed } of rawParsed) { + for (const tool of parsed.tools) { + if (tool.name !== "execute") continue; + const rec = asRecord(tool.result); + const pid = rec ? asString(rec.background_process_id) : ""; + const state = pid ? processStateByPid.get(pid) : undefined; + if (state) { + tool.backgroundProcess = state; + } + } + } + } + return rawParsed; }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/types.ts b/site/src/pages/AgentsPage/components/ChatConversation/types.ts index 2acdbfae67d91..69a3cd13ebd99 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/types.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/types.ts @@ -31,6 +31,18 @@ export type MergedTool = { hookRewritten?: boolean; /** Set when a process_signal killed/terminated this process. */ killedBySignal?: "kill" | "terminate"; + /** + * Live state of the background process started by an execute + * call, derived from later process_output/process_signal calls + * for the same process. Absent for foreground commands and + * when no observation of the process exists yet. + */ + backgroundProcess?: { + state: "running" | "exited"; + exitCode?: number; + /** Epoch ms when the process row first appeared (execute result time). */ + startedAtMs?: number; + }; }; export type RenderBlock = diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/BackgroundProcessChip.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/BackgroundProcessChip.tsx new file mode 100644 index 0000000000000..66bb124d21507 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/BackgroundProcessChip.tsx @@ -0,0 +1,115 @@ +import { CircleCheckIcon, OctagonXIcon } from "lucide-react"; +import type React from "react"; +import { useEffect, useState } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; +import { cn } from "#/utils/cn"; +import { signalTooltipLabel } from "./utils"; + +type BackgroundProcessChipProps = { + state: "running" | "exited"; + exitCode?: number; + killedBySignal?: "kill" | "terminate"; + /** Epoch ms when the process started, for the ticking elapsed time. */ + startedAtMs?: number; +}; + +const formatElapsed = (ms: number): string => { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${String(seconds).padStart(2, "0")}`; +}; + +/** + * Persistent status affordance for a backgrounded process, shown on + * the execute row that started it. Replaces the old static + * "running in background" icon: the chip carries live state + * (pulsing dot + ticking elapsed time while running, final exit + * state once observed) so readers can tell at a glance whether + * anything is still alive. + */ +export const BackgroundProcessChip: React.FC = ({ + state, + exitCode, + killedBySignal, + startedAtMs, +}) => { + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + if (state !== "running") { + return; + } + const interval = setInterval(() => setNowMs(Date.now()), 1000); + return () => clearInterval(interval); + }, [state]); + + if (state === "running") { + const elapsed = + startedAtMs !== undefined ? formatElapsed(nowMs - startedAtMs) : null; + const label = elapsed + ? `Running in background, ${elapsed}` + : "Running in background"; + return ( + + + + + running{elapsed ? ` ${elapsed}` : ""} + + + Background process is still running + + ); + } + + if (killedBySignal) { + return ( + + + + + killed + + + {signalTooltipLabel(killedBySignal)} + + ); + } + + const failed = exitCode !== undefined && exitCode !== 0; + return ( + + {!failed && } + exit {exitCode ?? 0} + + ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx index 2e4eb1301094c..b4af9de40a1c7 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx @@ -1,4 +1,4 @@ -import { LayersIcon, OctagonXIcon } from "lucide-react"; +import { OctagonXIcon } from "lucide-react"; import type React from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { CopyButton } from "#/components/CopyButton/CopyButton"; @@ -9,6 +9,7 @@ import { TooltipTrigger, } from "#/components/Tooltip/Tooltip"; import { cn } from "#/utils/cn"; +import { BackgroundProcessChip } from "./BackgroundProcessChip"; import { type AgentDisplayState, resolveAgentDisplayState, @@ -31,6 +32,11 @@ type ExecuteToolProps = { errorText?: string; durationMs?: number; isBackgrounded?: boolean; + backgroundProcess?: { + state: "running" | "exited"; + exitCode?: number; + startedAtMs?: number; + }; killedBySignal?: "kill" | "terminate"; modelIntent?: string; parsedCommands?: readonly string[][]; @@ -45,6 +51,7 @@ export const ExecuteTool: React.FC = ({ errorText, durationMs, isBackgrounded = false, + backgroundProcess, killedBySignal, modelIntent, parsedCommands, @@ -59,7 +66,10 @@ export const ExecuteTool: React.FC = ({ ? "preview" : "collapsed"; const isRunning = status === "running"; - const durationLabel = formatShellDurationMs(durationMs); + // A backgrounded call's duration is the spawn time (often ~0ms), + // not the process lifetime. The chip carries the live state + // instead, so the suffix is suppressed to avoid lying. + const durationLabel = isBackgrounded ? "" : formatShellDurationMs(durationMs); const { commandLabel, durationSuffix } = getShellCommandLine({ command, modelIntent, @@ -102,20 +112,14 @@ export const ExecuteTool: React.FC = ({ {isBackgrounded && !isRunning && ( - - - - - - - Running in background - + )} - {killedBySignal && !isRunning && ( + {killedBySignal && !isRunning && !isBackgrounded && ( diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx index d56c38bca1d2a..50bd260af9d6a 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx @@ -1,4 +1,4 @@ -import { OctagonXIcon } from "lucide-react"; +import { CircleCheckIcon, OctagonXIcon } from "lucide-react"; import type React from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { CopyButton } from "#/components/CopyButton/CopyButton"; @@ -68,7 +68,7 @@ export const ProcessOutputTool: React.FC = ({ autoDisplayState, ); - const showExitCode = exitCode !== null && exitCode !== 0; + const showExitCode = exitCode !== null; const hasOutput = output.length > 0; const hasHeaderActions = Boolean(killedBySignal) || showExitCode || hasOutput; @@ -107,7 +107,17 @@ export const ProcessOutputTool: React.FC = ({ )} {showExitCode && ( - + + {exitCode === 0 && ( + + )} exit {exitCode} )} diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index c99a92c3112b0..20dff23d3cf0c 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -574,20 +574,66 @@ export const ExecuteBackgrounded: Story = { output: "", wall_duration_ms: 2100, }, + backgroundProcess: { state: "running" }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const backgroundIndicator = canvas.getByRole("img", { - name: "Running in background", - }); - expect(backgroundIndicator).toBeVisible(); - await userEvent.hover(backgroundIndicator); + const chip = canvas.getByRole("status", { name: /Running in background/ }); + expect(chip).toBeVisible(); + expect(chip).toHaveTextContent("running"); + // The backgrounded spawn duration is process noise, not shown. + expect(canvas.queryByText(/for 2\.1s/)).not.toBeInTheDocument(); + await userEvent.hover(chip); expect(await screen.findByRole("tooltip")).toHaveTextContent( - "Running in background", + "Background process is still running", ); }, }; +export const ExecuteBackgroundedExited: Story = { + args: { + name: "execute", + status: "completed", + args: { command: "npm start" }, + shellToolDisplayMode: "always_collapsed", + result: { + background_process_id: "process-123", + output: "", + wall_duration_ms: 2100, + }, + backgroundProcess: { state: "exited", exitCode: 0 }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("status", { name: /exited successfully/ }), + ).toHaveTextContent("exit 0"); + }, +}; + +export const ExecuteBackgroundedExitedNonZero: Story = { + args: { + name: "execute", + status: "completed", + args: { command: "npm start" }, + shellToolDisplayMode: "always_collapsed", + result: { + background_process_id: "process-123", + output: "", + wall_duration_ms: 2100, + }, + backgroundProcess: { state: "exited", exitCode: 1 }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("status", { + name: "Background process exited with code 1", + }), + ).toHaveTextContent("exit 1"); + }, +}; + export const ExecuteAlwaysCollapsed: Story = { args: { name: "execute", @@ -704,6 +750,23 @@ export const ProcessOutputChecked: Story = { }, }; +export const ProcessOutputExitZeroChip: Story = { + args: { + name: "process_output", + status: "completed", + args: { process_id: "process-123" }, + result: { + command: "npm start", + output: "dogfood complete", + exit_code: 0, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("exit 0")).toBeVisible(); + }, +}; + export const ProcessOutputModelIntent: Story = { args: { name: "process_output", diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 09a66ab04babd..2b074ad7a2e20 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -68,6 +68,12 @@ interface ToolProps extends Omit, "children"> { result?: unknown; isError?: boolean; killedBySignal?: "kill" | "terminate"; + /** Live state of the background process this execute call started. */ + backgroundProcess?: { + state: "running" | "exited"; + exitCode?: number; + startedAtMs?: number; + }; /** Maps sub-agent chat IDs to their titles, built from transcript metadata. */ subagentTitles?: Map; /** Maps sub-agent chat IDs to their normalized variants. */ @@ -104,6 +110,11 @@ type ToolRendererProps = { result: unknown; isError: boolean; killedBySignal?: "kill" | "terminate"; + backgroundProcess?: { + state: "running" | "exited"; + exitCode?: number; + startedAtMs?: number; + }; subagentTitles?: Map; subagentVariants?: Map; showDesktopPreviews?: boolean; @@ -220,6 +231,7 @@ const ExecuteRenderer: FC = ({ result, isError, killedBySignal, + backgroundProcess, modelIntent, parsedCommands, shellToolDisplayMode, @@ -234,6 +246,7 @@ const ExecuteRenderer: FC = ({ errorText={data.errorText} durationMs={data.durationMs} isBackgrounded={data.isBackgrounded} + backgroundProcess={backgroundProcess} killedBySignal={killedBySignal} modelIntent={modelIntent} parsedCommands={parsedCommands} @@ -1168,6 +1181,7 @@ export const Tool = memo( result, isError = false, killedBySignal, + backgroundProcess, subagentTitles, subagentVariants, showDesktopPreviews, @@ -1215,6 +1229,7 @@ export const Tool = memo( result={result} isError={isError} killedBySignal={killedBySignal} + backgroundProcess={backgroundProcess} subagentTitles={subagentTitles} subagentVariants={subagentVariants} 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 aa128484eff51..d8371d5ad836f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx @@ -1,4 +1,5 @@ import { + ActivityIcon, BadgeQuestionMarkIcon, BotIcon, CompassIcon, @@ -26,7 +27,7 @@ import { cn } from "#/utils/cn"; export const toolIcons: Partial> = { execute: TerminalIcon, - process_output: TerminalIcon, + process_output: ActivityIcon, process_list: TerminalIcon, process_signal: TerminalIcon, read_file: FileTextIcon, From 6e31c63ef14615b488bd3c9110f0eda8bcacd7db Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 19 Aug 2026 14:35:48 +0000 Subject: [PATCH 05/24] fix(site): quiet success states and align shell tool actions - process_output no longer renders an "exit 0" badge on clean exits; the Checked verb carries success and only failures earn the red badge, matching the execute tool's quiet-success convention. Failed checks now read "Failed ". - The background process chip collapses to a bare check icon with a tooltip on clean exit instead of spelling out "exit 0", so the execute row's copy button stays aligned with the process_output row's copy button. --- .../tools/BackgroundProcessChip.tsx | 35 ++++++++++------- .../ChatElements/tools/ProcessOutputTool.tsx | 36 +++++++++-------- .../ChatElements/tools/Tool.stories.tsx | 39 ++++++++++++++++--- 3 files changed, 72 insertions(+), 38 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/BackgroundProcessChip.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/BackgroundProcessChip.tsx index 66bb124d21507..e63215c261e55 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/BackgroundProcessChip.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/BackgroundProcessChip.tsx @@ -1,4 +1,4 @@ -import { CircleCheckIcon, OctagonXIcon } from "lucide-react"; +import { CheckIcon, OctagonXIcon } from "lucide-react"; import type React from "react"; import { useEffect, useState } from "react"; import { @@ -6,7 +6,6 @@ import { TooltipContent, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; -import { cn } from "#/utils/cn"; import { signalTooltipLabel } from "./utils"; type BackgroundProcessChipProps = { @@ -93,23 +92,29 @@ export const BackgroundProcessChip: React.FC = ({ } const failed = exitCode !== undefined && exitCode !== 0; + if (!failed) { + return ( + + + + + + + Background process exited successfully + + ); + } return ( - {!failed && } - exit {exitCode ?? 0} + exit {exitCode} ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx index 50bd260af9d6a..43bf276a62760 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx @@ -1,4 +1,4 @@ -import { CircleCheckIcon, OctagonXIcon } from "lucide-react"; +import { OctagonXIcon } from "lucide-react"; import type React from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { CopyButton } from "#/components/CopyButton/CopyButton"; @@ -32,10 +32,12 @@ const getProcessOutputLabel = ({ command, modelIntent, isRunning, + isFailed, }: { command: string | undefined; modelIntent: string | undefined; isRunning: boolean; + isFailed: boolean; }): string => { const trimmedCommand = command?.trim() ?? ""; const intent = modelIntent @@ -47,7 +49,10 @@ const getProcessOutputLabel = ({ if (!trimmedCommand) { return "Process output"; } - return `${isRunning ? "Checking" : "Checked"} ${trimmedCommand}`; + if (isRunning) { + return `Checking ${trimmedCommand}`; + } + return `${isFailed ? "Failed" : "Checked"} ${trimmedCommand}`; }; export const ProcessOutputTool: React.FC = ({ @@ -68,9 +73,11 @@ export const ProcessOutputTool: React.FC = ({ autoDisplayState, ); - const showExitCode = exitCode !== null; + // A clean exit is the expected outcome of a check, so only + // failures earn a badge. The label verb carries the rest. + const isFailed = exitCode !== null && exitCode !== 0; const hasOutput = output.length > 0; - const hasHeaderActions = Boolean(killedBySignal) || showExitCode || hasOutput; + const hasHeaderActions = Boolean(killedBySignal) || isFailed || hasOutput; return ( = ({ - {getProcessOutputLabel({ command, modelIntent, isRunning })} + {getProcessOutputLabel({ + command, + modelIntent, + isRunning, + isFailed, + })} @@ -106,18 +118,8 @@ export const ProcessOutputTool: React.FC = ({ )} - {showExitCode && ( - - {exitCode === 0 && ( - - )} + {isFailed && ( + exit {exitCode} )} diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 20dff23d3cf0c..9050f21d72c6e 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -605,9 +605,16 @@ export const ExecuteBackgroundedExited: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect( - canvas.getByRole("status", { name: /exited successfully/ }), - ).toHaveTextContent("exit 0"); + const status = canvas.getByRole("status", { + name: "Background process exited successfully", + }); + expect(status).toBeInTheDocument(); + // Quiet success: a check icon, no "exit 0" text. + expect(status.textContent).toBe(""); + await userEvent.hover(status); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Background process exited successfully", + ); }, }; @@ -744,13 +751,13 @@ export const ProcessOutputChecked: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("Checked npm start")).toBeVisible(); + expect(canvas.getByText("Failed npm start")).toBeVisible(); expect(canvas.getByText("exit 1")).toBeVisible(); expect(canvas.getByText(/EADDRINUSE/)).toBeVisible(); }, }; -export const ProcessOutputExitZeroChip: Story = { +export const ProcessOutputExitZeroNoBadge: Story = { args: { name: "process_output", status: "completed", @@ -763,7 +770,27 @@ export const ProcessOutputExitZeroChip: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("exit 0")).toBeVisible(); + // Clean exit stays quiet: no badge, the Checked verb carries it. + expect(canvas.getByText("Checked npm start")).toBeVisible(); + expect(canvas.queryByText(/exit/)).not.toBeInTheDocument(); + }, +}; + +export const ProcessOutputFailedLabel: Story = { + args: { + name: "process_output", + status: "completed", + args: { process_id: "process-123" }, + result: { + command: "npm start", + output: "EADDRINUSE", + exit_code: 1, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Failed npm start")).toBeVisible(); + expect(canvas.getByText("exit 1")).toBeVisible(); }, }; From a273f3334ad355aab6574c6b40ab75cedfe57dc4 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 19 Aug 2026 14:49:58 +0000 Subject: [PATCH 06/24] fix(site): observe background process state from process_list too The chip annotation only read process_output results, so a process checked via process_list (or never polled) stayed "running" forever. Also populate startedAtMs from the execute message timestamp so the running chip shows elapsed time. --- .../ChatConversation/messageParsing.test.ts | 34 +++++++++- .../ChatConversation/messageParsing.ts | 65 ++++++++++++++----- 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts index 721d66c39a45e..26e4f6ef17377 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts @@ -788,7 +788,10 @@ describe("parseMessagesWithMergedTools — killedBySignal annotation", () => { const executeTool = parsed .flatMap((e) => e.parsed.tools) .find((t) => t.name === "execute"); - expect(executeTool?.backgroundProcess).toEqual({ state: "running" }); + expect(executeTool?.backgroundProcess).toEqual({ + state: "running", + startedAtMs: expect.any(Number), + }); }); it("annotates backgrounded execute with final exit code", () => { @@ -819,9 +822,38 @@ describe("parseMessagesWithMergedTools — killedBySignal annotation", () => { expect(executeTool?.backgroundProcess).toEqual({ state: "exited", exitCode: 1, + startedAtMs: expect.any(Number), }); }); + it("annotates from process_list snapshots when no poll exists", () => { + const PID = "proc-listed"; + const parsed = parseMessagesWithMergedTools([ + msg(1, "assistant", [toolCall("tc1", "execute", { command: "exit 1" })]), + msg(2, "assistant", [ + toolResult("tc1", "execute", { + success: true, + background_process_id: PID, + }), + toolCall("tc2", "process_list", {}), + ]), + msg(3, "assistant", [ + toolResult("tc2", "process_list", { + processes: [ + { id: PID, command: "exit 1", running: false, exit_code: 1 }, + { id: "other", command: "sleep 99", running: true }, + ], + }), + ]), + ]); + + const executeTool = parsed + .flatMap((e) => e.parsed.tools) + .find((t) => t.name === "execute"); + expect(executeTool?.backgroundProcess?.state).toBe("exited"); + expect(executeTool?.backgroundProcess?.exitCode).toBe(1); + }); + it("does not annotate foreground execute calls", () => { const parsed = parseMessagesWithMergedTools([ msg(1, "assistant", [toolCall("tc1", "execute", { command: "echo hi" })]), diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts index d9fb0f1d817a4..23860ea02f691 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts @@ -422,21 +422,48 @@ export const parseMessagesWithMergedTools = ( >(); for (const { parsed } of rawParsed) { for (const tool of parsed.tools) { - if (tool.name !== "process_output") continue; - const rec = asRecord(tool.result); - const toolArgs = asRecord(tool.args); - const pid = toolArgs ? asString(toolArgs.process_id) : ""; - if (!rec || !pid) continue; - // process_output reports running:true while alive; an - // exited process reports its final exit code. - if (rec.running === true) { - processStateByPid.set(pid, { state: "running" }); - } else { - const exitCode = asNumber(rec.exit_code, { parseString: true }); - processStateByPid.set(pid, { - state: "exited", - exitCode: exitCode ?? undefined, - }); + if (tool.name === "process_output") { + const rec = asRecord(tool.result); + const toolArgs = asRecord(tool.args); + const pid = toolArgs ? asString(toolArgs.process_id) : ""; + if (!rec || !pid) continue; + // process_output reports running:true while alive; an + // exited process reports its final exit code. + if (rec.running === true) { + processStateByPid.set(pid, { state: "running" }); + } else { + const exitCode = asNumber(rec.exit_code, { parseString: true }); + processStateByPid.set(pid, { + state: "exited", + exitCode: exitCode ?? undefined, + }); + } + continue; + } + // process_list returns a snapshot of every tracked + // process; it may be the only observation when the + // agent lists instead of polling a specific process. + if (tool.name === "process_list") { + const rec = asRecord(tool.result); + const processes = rec?.processes; + if (!Array.isArray(processes)) continue; + for (const proc of processes) { + const procRec = asRecord(proc); + if (!procRec) continue; + const pid = asString(procRec.id); + if (!pid) continue; + if (procRec.running === true) { + processStateByPid.set(pid, { state: "running" }); + } else { + const exitCode = asNumber(procRec.exit_code, { + parseString: true, + }); + processStateByPid.set(pid, { + state: "exited", + exitCode: exitCode ?? undefined, + }); + } + } } } } @@ -450,14 +477,18 @@ export const parseMessagesWithMergedTools = ( } } if (processStateByPid.size > 0) { - for (const { parsed } of rawParsed) { + for (const { message, parsed } of rawParsed) { for (const tool of parsed.tools) { if (tool.name !== "execute") continue; const rec = asRecord(tool.result); const pid = rec ? asString(rec.background_process_id) : ""; const state = pid ? processStateByPid.get(pid) : undefined; if (state) { - tool.backgroundProcess = state; + const createdMs = Date.parse(message.created_at); + tool.backgroundProcess = { + ...state, + startedAtMs: Number.isNaN(createdMs) ? undefined : createdMs, + }; } } } From 4878a981cc646903cc2157f96912a8583523526a Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 19 Aug 2026 15:04:15 +0000 Subject: [PATCH 07/24] fix(site): align execute row actions with other tool rows The execute row's custom grid added gap-x-2 between the header button and the actions column, insetting its copy button 8px from the right edge shared by every other tool row. Drop the gap; the header button already truncates. --- .../AgentsPage/components/ChatElements/tools/ExecuteTool.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx index b4af9de40a1c7..5e40e29dabf93 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx @@ -86,7 +86,7 @@ export const ExecuteTool: React.FC = ({ return ( Date: Wed, 19 Aug 2026 15:15:01 +0000 Subject: [PATCH 08/24] refactor(site): drop the background process status chip Following dogfooding feedback: the chip answered a question readers weren't asking. Live state was derivable-but-stale the moment it rendered (a transcript records what was observed, not what is true), success states were noise, and the ticking pill mostly advertised that the design was trying too hard. The transcript shows what the agent saw, when it saw it; cross-process liveness dashboards are a different feature. Removes BackgroundProcessChip, the backgroundProcess annotation pass in messageParsing, and their tests and stories. Keeps the durability fixes that stood on their own: the activity icon for process_output, suppressing the misleading spawn-duration on backgrounded execute rows, and quiet-exit labeling on process_output. --- .../ChatConversation/MessageBlocks.tsx | 1 - .../ChatConversation/messageParsing.test.ts | 104 --------------- .../ChatConversation/messageParsing.ts | 86 +------------ .../components/ChatConversation/types.ts | 12 -- .../tools/BackgroundProcessChip.tsx | 120 ------------------ .../ChatElements/tools/ExecuteTool.tsx | 21 +-- .../ChatElements/tools/Tool.stories.tsx | 62 +-------- .../components/ChatElements/tools/Tool.tsx | 15 --- 8 files changed, 6 insertions(+), 415 deletions(-) delete mode 100644 site/src/pages/AgentsPage/components/ChatElements/tools/BackgroundProcessChip.tsx diff --git a/site/src/pages/AgentsPage/components/ChatConversation/MessageBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/MessageBlocks.tsx index 061b8ecaf54fa..85ef00f2a967c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/MessageBlocks.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/MessageBlocks.tsx @@ -356,7 +356,6 @@ export const BlockList: FC = ({ status={tool.status} isError={tool.isError} killedBySignal={tool.killedBySignal} - backgroundProcess={tool.backgroundProcess} shellToolDisplayMode={shellToolDisplayMode} codeDiffDisplayMode={codeDiffDisplayMode} subagentTitles={subagentTitles} diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts index 26e4f6ef17377..53356484d75fd 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.test.ts @@ -763,110 +763,6 @@ describe("parseMessagesWithMergedTools — killedBySignal annotation", () => { .find((t) => t.name === "process_output"); expect(procOut?.killedBySignal).toBe("terminate"); }); - - it("annotates backgrounded execute with running state from process_output", () => { - const PID = "proc-run"; - const parsed = parseMessagesWithMergedTools([ - msg(1, "assistant", [ - toolCall("tc1", "execute", { command: "npm start" }), - ]), - msg(2, "assistant", [ - toolResult("tc1", "execute", { - success: true, - background_process_id: PID, - }), - toolCall("tc2", "process_output", { process_id: PID }), - ]), - msg(3, "assistant", [ - toolResult("tc2", "process_output", { - output: "starting...", - running: true, - }), - ]), - ]); - - const executeTool = parsed - .flatMap((e) => e.parsed.tools) - .find((t) => t.name === "execute"); - expect(executeTool?.backgroundProcess).toEqual({ - state: "running", - startedAtMs: expect.any(Number), - }); - }); - - it("annotates backgrounded execute with final exit code", () => { - const PID = "proc-exit"; - const parsed = parseMessagesWithMergedTools([ - msg(1, "assistant", [ - toolCall("tc1", "execute", { command: "npm start" }), - ]), - msg(2, "assistant", [ - toolResult("tc1", "execute", { - success: true, - background_process_id: PID, - }), - toolCall("tc2", "process_output", { process_id: PID }), - ]), - msg(3, "assistant", [ - toolResult("tc2", "process_output", { - output: "boom", - running: false, - exit_code: 1, - }), - ]), - ]); - - const executeTool = parsed - .flatMap((e) => e.parsed.tools) - .find((t) => t.name === "execute"); - expect(executeTool?.backgroundProcess).toEqual({ - state: "exited", - exitCode: 1, - startedAtMs: expect.any(Number), - }); - }); - - it("annotates from process_list snapshots when no poll exists", () => { - const PID = "proc-listed"; - const parsed = parseMessagesWithMergedTools([ - msg(1, "assistant", [toolCall("tc1", "execute", { command: "exit 1" })]), - msg(2, "assistant", [ - toolResult("tc1", "execute", { - success: true, - background_process_id: PID, - }), - toolCall("tc2", "process_list", {}), - ]), - msg(3, "assistant", [ - toolResult("tc2", "process_list", { - processes: [ - { id: PID, command: "exit 1", running: false, exit_code: 1 }, - { id: "other", command: "sleep 99", running: true }, - ], - }), - ]), - ]); - - const executeTool = parsed - .flatMap((e) => e.parsed.tools) - .find((t) => t.name === "execute"); - expect(executeTool?.backgroundProcess?.state).toBe("exited"); - expect(executeTool?.backgroundProcess?.exitCode).toBe(1); - }); - - it("does not annotate foreground execute calls", () => { - const parsed = parseMessagesWithMergedTools([ - msg(1, "assistant", [toolCall("tc1", "execute", { command: "echo hi" })]), - msg(2, "assistant", [ - toolResult("tc1", "execute", { success: true, output: "hi" }), - ]), - ]); - - const executeTool = parsed - .flatMap((e) => e.parsed.tools) - .find((t) => t.name === "execute"); - expect(executeTool?.backgroundProcess).toBeUndefined(); - }); }); describe("subagent transcript parsing", () => { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts index 23860ea02f691..41eb7554e7fb2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts @@ -1,5 +1,5 @@ import type * as TypesGen from "#/api/typesGenerated"; -import { asNumber, asRecord, asString } from "../ChatElements/runtimeTypeUtils"; +import { asRecord, asString } from "../ChatElements/runtimeTypeUtils"; import { getProvidedSubagentTitle, getSubagentChatId, @@ -410,90 +410,6 @@ export const parseMessagesWithMergedTools = ( } } } - - // Annotate backgrounded execute calls with the live process - // state derived from their process_output observations. The - // execute row is the anchor readers scan for "is it still - // running"; poll rows stay chronological, and the row that - // owns the process flips in place as observations arrive. - const processStateByPid = new Map< - string, - { state: "running" | "exited"; exitCode?: number } - >(); - for (const { parsed } of rawParsed) { - for (const tool of parsed.tools) { - if (tool.name === "process_output") { - const rec = asRecord(tool.result); - const toolArgs = asRecord(tool.args); - const pid = toolArgs ? asString(toolArgs.process_id) : ""; - if (!rec || !pid) continue; - // process_output reports running:true while alive; an - // exited process reports its final exit code. - if (rec.running === true) { - processStateByPid.set(pid, { state: "running" }); - } else { - const exitCode = asNumber(rec.exit_code, { parseString: true }); - processStateByPid.set(pid, { - state: "exited", - exitCode: exitCode ?? undefined, - }); - } - continue; - } - // process_list returns a snapshot of every tracked - // process; it may be the only observation when the - // agent lists instead of polling a specific process. - if (tool.name === "process_list") { - const rec = asRecord(tool.result); - const processes = rec?.processes; - if (!Array.isArray(processes)) continue; - for (const proc of processes) { - const procRec = asRecord(proc); - if (!procRec) continue; - const pid = asString(procRec.id); - if (!pid) continue; - if (procRec.running === true) { - processStateByPid.set(pid, { state: "running" }); - } else { - const exitCode = asNumber(procRec.exit_code, { - parseString: true, - }); - processStateByPid.set(pid, { - state: "exited", - exitCode: exitCode ?? undefined, - }); - } - } - } - } - } - for (const [pid, sig] of signaledProcesses) { - // A signal is terminal even without a later observation. - if (!processStateByPid.has(pid)) { - processStateByPid.set(pid, { - state: "exited", - exitCode: sig === "kill" ? 137 : 143, - }); - } - } - if (processStateByPid.size > 0) { - for (const { message, parsed } of rawParsed) { - for (const tool of parsed.tools) { - if (tool.name !== "execute") continue; - const rec = asRecord(tool.result); - const pid = rec ? asString(rec.background_process_id) : ""; - const state = pid ? processStateByPid.get(pid) : undefined; - if (state) { - const createdMs = Date.parse(message.created_at); - tool.backgroundProcess = { - ...state, - startedAtMs: Number.isNaN(createdMs) ? undefined : createdMs, - }; - } - } - } - } - return rawParsed; }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/types.ts b/site/src/pages/AgentsPage/components/ChatConversation/types.ts index 69a3cd13ebd99..2acdbfae67d91 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/types.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/types.ts @@ -31,18 +31,6 @@ export type MergedTool = { hookRewritten?: boolean; /** Set when a process_signal killed/terminated this process. */ killedBySignal?: "kill" | "terminate"; - /** - * Live state of the background process started by an execute - * call, derived from later process_output/process_signal calls - * for the same process. Absent for foreground commands and - * when no observation of the process exists yet. - */ - backgroundProcess?: { - state: "running" | "exited"; - exitCode?: number; - /** Epoch ms when the process row first appeared (execute result time). */ - startedAtMs?: number; - }; }; export type RenderBlock = diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/BackgroundProcessChip.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/BackgroundProcessChip.tsx deleted file mode 100644 index e63215c261e55..0000000000000 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/BackgroundProcessChip.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { CheckIcon, OctagonXIcon } from "lucide-react"; -import type React from "react"; -import { useEffect, useState } from "react"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; -import { signalTooltipLabel } from "./utils"; - -type BackgroundProcessChipProps = { - state: "running" | "exited"; - exitCode?: number; - killedBySignal?: "kill" | "terminate"; - /** Epoch ms when the process started, for the ticking elapsed time. */ - startedAtMs?: number; -}; - -const formatElapsed = (ms: number): string => { - const totalSeconds = Math.max(0, Math.floor(ms / 1000)); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}:${String(seconds).padStart(2, "0")}`; -}; - -/** - * Persistent status affordance for a backgrounded process, shown on - * the execute row that started it. Replaces the old static - * "running in background" icon: the chip carries live state - * (pulsing dot + ticking elapsed time while running, final exit - * state once observed) so readers can tell at a glance whether - * anything is still alive. - */ -export const BackgroundProcessChip: React.FC = ({ - state, - exitCode, - killedBySignal, - startedAtMs, -}) => { - const [nowMs, setNowMs] = useState(() => Date.now()); - - useEffect(() => { - if (state !== "running") { - return; - } - const interval = setInterval(() => setNowMs(Date.now()), 1000); - return () => clearInterval(interval); - }, [state]); - - if (state === "running") { - const elapsed = - startedAtMs !== undefined ? formatElapsed(nowMs - startedAtMs) : null; - const label = elapsed - ? `Running in background, ${elapsed}` - : "Running in background"; - return ( - - - - - running{elapsed ? ` ${elapsed}` : ""} - - - Background process is still running - - ); - } - - if (killedBySignal) { - return ( - - - - - killed - - - {signalTooltipLabel(killedBySignal)} - - ); - } - - const failed = exitCode !== undefined && exitCode !== 0; - if (!failed) { - return ( - - - - - - - Background process exited successfully - - ); - } - return ( - - exit {exitCode} - - ); -}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx index 5e40e29dabf93..b003dc59fabb0 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx @@ -9,7 +9,6 @@ import { TooltipTrigger, } from "#/components/Tooltip/Tooltip"; import { cn } from "#/utils/cn"; -import { BackgroundProcessChip } from "./BackgroundProcessChip"; import { type AgentDisplayState, resolveAgentDisplayState, @@ -32,11 +31,6 @@ type ExecuteToolProps = { errorText?: string; durationMs?: number; isBackgrounded?: boolean; - backgroundProcess?: { - state: "running" | "exited"; - exitCode?: number; - startedAtMs?: number; - }; killedBySignal?: "kill" | "terminate"; modelIntent?: string; parsedCommands?: readonly string[][]; @@ -51,7 +45,6 @@ export const ExecuteTool: React.FC = ({ errorText, durationMs, isBackgrounded = false, - backgroundProcess, killedBySignal, modelIntent, parsedCommands, @@ -67,8 +60,8 @@ export const ExecuteTool: React.FC = ({ : "collapsed"; const isRunning = status === "running"; // A backgrounded call's duration is the spawn time (often ~0ms), - // not the process lifetime. The chip carries the live state - // instead, so the suffix is suppressed to avoid lying. + // not the process lifetime, so the suffix is suppressed to + // avoid lying about how long the process ran for. const durationLabel = isBackgrounded ? "" : formatShellDurationMs(durationMs); const { commandLabel, durationSuffix } = getShellCommandLine({ command, @@ -111,15 +104,7 @@ export const ExecuteTool: React.FC = ({ - {isBackgrounded && !isRunning && ( - - )} - {killedBySignal && !isRunning && !isBackgrounded && ( + {killedBySignal && !isRunning && ( diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 9050f21d72c6e..5029f60d35f6d 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { chatModelConfigsKey } from "#/api/queries/chats"; import { workspaceBuildLogs } from "#/api/queries/workspaceBuilds"; @@ -574,70 +574,12 @@ export const ExecuteBackgrounded: Story = { output: "", wall_duration_ms: 2100, }, - backgroundProcess: { state: "running" }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const chip = canvas.getByRole("status", { name: /Running in background/ }); - expect(chip).toBeVisible(); - expect(chip).toHaveTextContent("running"); // The backgrounded spawn duration is process noise, not shown. expect(canvas.queryByText(/for 2\.1s/)).not.toBeInTheDocument(); - await userEvent.hover(chip); - expect(await screen.findByRole("tooltip")).toHaveTextContent( - "Background process is still running", - ); - }, -}; - -export const ExecuteBackgroundedExited: Story = { - args: { - name: "execute", - status: "completed", - args: { command: "npm start" }, - shellToolDisplayMode: "always_collapsed", - result: { - background_process_id: "process-123", - output: "", - wall_duration_ms: 2100, - }, - backgroundProcess: { state: "exited", exitCode: 0 }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const status = canvas.getByRole("status", { - name: "Background process exited successfully", - }); - expect(status).toBeInTheDocument(); - // Quiet success: a check icon, no "exit 0" text. - expect(status.textContent).toBe(""); - await userEvent.hover(status); - expect(await screen.findByRole("tooltip")).toHaveTextContent( - "Background process exited successfully", - ); - }, -}; - -export const ExecuteBackgroundedExitedNonZero: Story = { - args: { - name: "execute", - status: "completed", - args: { command: "npm start" }, - shellToolDisplayMode: "always_collapsed", - result: { - background_process_id: "process-123", - output: "", - wall_duration_ms: 2100, - }, - backgroundProcess: { state: "exited", exitCode: 1 }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect( - canvas.getByRole("status", { - name: "Background process exited with code 1", - }), - ).toHaveTextContent("exit 1"); + expect(canvas.getByText(/npm start/)).toBeInTheDocument(); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 2b074ad7a2e20..09a66ab04babd 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -68,12 +68,6 @@ interface ToolProps extends Omit, "children"> { result?: unknown; isError?: boolean; killedBySignal?: "kill" | "terminate"; - /** Live state of the background process this execute call started. */ - backgroundProcess?: { - state: "running" | "exited"; - exitCode?: number; - startedAtMs?: number; - }; /** Maps sub-agent chat IDs to their titles, built from transcript metadata. */ subagentTitles?: Map; /** Maps sub-agent chat IDs to their normalized variants. */ @@ -110,11 +104,6 @@ type ToolRendererProps = { result: unknown; isError: boolean; killedBySignal?: "kill" | "terminate"; - backgroundProcess?: { - state: "running" | "exited"; - exitCode?: number; - startedAtMs?: number; - }; subagentTitles?: Map; subagentVariants?: Map; showDesktopPreviews?: boolean; @@ -231,7 +220,6 @@ const ExecuteRenderer: FC = ({ result, isError, killedBySignal, - backgroundProcess, modelIntent, parsedCommands, shellToolDisplayMode, @@ -246,7 +234,6 @@ const ExecuteRenderer: FC = ({ errorText={data.errorText} durationMs={data.durationMs} isBackgrounded={data.isBackgrounded} - backgroundProcess={backgroundProcess} killedBySignal={killedBySignal} modelIntent={modelIntent} parsedCommands={parsedCommands} @@ -1181,7 +1168,6 @@ export const Tool = memo( result, isError = false, killedBySignal, - backgroundProcess, subagentTitles, subagentVariants, showDesktopPreviews, @@ -1229,7 +1215,6 @@ export const Tool = memo( result={result} isError={isError} killedBySignal={killedBySignal} - backgroundProcess={backgroundProcess} subagentTitles={subagentTitles} subagentVariants={subagentVariants} showDesktopPreviews={showDesktopPreviews} From d344b1cf728868cd1dd9d521f52b1e1753bfea43 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 19 Aug 2026 15:25:16 +0000 Subject: [PATCH 09/24] feat(site): frame backgrounded execute labels and simplify intent rows Backgrounded execute calls with a model intent now read " in the background using " so the launch mode is visible in the row; the intent description tells the model the framing exists so it does not write "background" itself. process_output stops appending the command after the intent (" on "): the intent is written to be self-sufficient, and the appended command rendered as a stutter against the execute row's "using " summary above it. Rows without an intent keep the command-based Checking/Checked/Failed labels. --- coderd/x/chatd/chattool/execute.go | 4 +-- .../ChatElements/tools/ExecuteTool.tsx | 8 ++++- .../ChatElements/tools/ProcessOutputTool.tsx | 2 +- .../ChatElements/tools/Tool.stories.tsx | 36 ++++++++++++++++--- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/coderd/x/chatd/chattool/execute.go b/coderd/x/chatd/chattool/execute.go index 08d57b59729be..9a5a73766fa0d 100644 --- a/coderd/x/chatd/chattool/execute.go +++ b/coderd/x/chatd/chattool/execute.go @@ -111,7 +111,7 @@ type ProcessToolOptions struct { // ExecuteArgs are the parameters accepted by the execute tool. type ExecuteArgs struct { Command string `json:"command" description:"The shell command to execute. Runs under \"sh -c\" (POSIX)."` - ModelIntent *string `json:"model_intent,omitempty" description:"A short, natural-language, present-participle phrase describing what you are doing. This is shown to the user alongside the command. Use plain English with no underscores or technical jargon. The UI appends \"using \" and \"for \" automatically, so do not repeat the command or include a duration. Keep it under 100 characters. Good examples: \"Running the unit tests\", \"Checking repository state\", \"Inspecting build output\"."` + ModelIntent *string `json:"model_intent,omitempty" description:"A short, natural-language, present-participle phrase describing what you are doing. This is shown to the user alongside the command, with backgrounded commands framed as \" in the background using \", so do not include the word \"background\" or restate the command or a duration. Use plain English with no underscores or technical jargon. Keep it under 100 characters. Good examples: \"Running the unit tests\", \"Checking repository state\", \"Inspecting build output\"."` Timeout *string `json:"timeout,omitempty" description:"How long to wait for completion (e.g. '30s', '5m'). Default is 10s. The process keeps running if this expires and you get a background_process_id to re-attach. Only applies to foreground commands."` WorkDir *string `json:"workdir,omitempty" description:"Working directory for the command."` RunInBackground *bool `json:"run_in_background,omitempty" description:"Run without blocking. Use for persistent processes (dev servers, file watchers) or when you want to continue working while a command runs and check the result later with process_output. For commands whose result you need before continuing, prefer foreground with a longer timeout. Do NOT use shell & to background processes. It will not work correctly. Always use this parameter instead."` @@ -426,7 +426,7 @@ const ( type ProcessOutputArgs struct { ProcessID string `json:"process_id"` WaitTimeout *string `json:"wait_timeout,omitempty" description:"Override the default 10s block duration. The call blocks until the process exits or this timeout is reached. Set to '0s' for an immediate snapshot without waiting."` - ModelIntent *string `json:"model_intent,omitempty" description:"A short, natural-language, present-participle phrase describing why you are checking this process. This is shown to the user alongside the command. Use plain English with no underscores or technical jargon. Do not restate the command or include a duration. Keep it under 100 characters. Good examples: \"Waiting for the dev server to be ready\", \"Confirming the tests still pass\"."` + ModelIntent *string `json:"model_intent,omitempty" description:"A short, natural-language, present-participle phrase describing why you are checking this process. This is shown as the user's primary label for the action, so make it self-sufficient: the command itself is not displayed alongside it. Use plain English with no underscores or technical jargon. Do not restate the command or include a duration. Keep it under 100 characters. Good examples: \"Waiting for the dev server to be ready\", \"Confirming the tests still pass\"."` } // ProcessOutput returns an AgentTool that retrieves the output diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx index b003dc59fabb0..fd6cef6f370af 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx @@ -70,6 +70,7 @@ export const ExecuteTool: React.FC = ({ durationLabel, isRunning, isError, + isBackgrounded, }); const defaultView = resolveAgentDisplayState( shellToolDisplayMode, @@ -139,6 +140,7 @@ type ShellCommandLineInput = { durationLabel: string; isRunning: boolean; isError: boolean; + isBackgrounded: boolean; }; const getShellCommandLine = ({ @@ -148,16 +150,20 @@ const getShellCommandLine = ({ durationLabel, isRunning, isError, + isBackgrounded, }: ShellCommandLineInput): { commandLabel: string; durationSuffix: string } => { - const intentLabel = sanitizeExecuteModelIntent(modelIntent, command); const summary = parsedCommands && parsedCommands.length > 0 ? summarizeParsedCommands(parsedCommands) : ""; const commandDisplay = summary || command; + const intentLabel = sanitizeExecuteModelIntent(modelIntent, command); let commandLabel = intentLabel ? `${intentLabel} using ${commandDisplay}` : `Ran ${commandDisplay}`; + if (intentLabel && isBackgrounded) { + commandLabel = `${intentLabel} in the background using ${commandDisplay}`; + } if (!isRunning && isError) { commandLabel = `Failed to run ${commandDisplay}`; } diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx index 43bf276a62760..f950962a8bc48 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx @@ -44,7 +44,7 @@ const getProcessOutputLabel = ({ ? sanitizeExecuteModelIntent(modelIntent, trimmedCommand) : ""; if (intent) { - return trimmedCommand ? `${intent} on ${trimmedCommand}` : intent; + return intent; } if (!trimmedCommand) { return "Process output"; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 5029f60d35f6d..5b471b17bf958 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -349,6 +349,31 @@ export const ExecuteModelIntentRunning: Story = { }, }; +export const ExecuteModelIntentBackgrounded: Story = { + args: { + name: "execute", + status: "completed", + args: { + command: executeIntentCommand, + model_intent: "Starting a sleep process", + }, + modelIntent: "Starting a sleep process", + result: { + output: "", + wall_duration_ms: 2300, + background_process_id: "process-123", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByText( + `Starting a sleep process in the background using ${executeIntentCommand}`, + ), + ).toBeVisible(); + }, +}; + export const ExecuteModelIntentLeadingUsing: Story = { args: { status: "completed", @@ -752,13 +777,15 @@ export const ProcessOutputModelIntent: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); + // Intent alone is the label; the command is not appended. expect( - canvas.getByText("Waiting for the dev server to be ready on npm start"), + canvas.getByText("Waiting for the dev server to be ready"), ).toBeVisible(); + expect(canvas.queryByText(/npm start/)).not.toBeInTheDocument(); }, }; -/** Redundant "using " suffixes are stripped from the intent. */ +/** Intent that restates the command is left as the model wrote it. */ export const ProcessOutputModelIntentRedundant: Story = { args: { name: "process_output", @@ -776,9 +803,8 @@ export const ProcessOutputModelIntentRedundant: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect( - canvas.getByText("Confirming the tests pass on npm start"), - ).toBeVisible(); + expect(canvas.getByText("Confirming the tests pass")).toBeVisible(); + expect(canvas.queryByText(/npm start/)).not.toBeInTheDocument(); }, }; From e87ab3c8b51d26b89e7d9c9ce6c0b9a72e08beac Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 19 Aug 2026 15:45:55 +0000 Subject: [PATCH 10/24] fix(chatd): report running state in process_output results Two review fixes: - process_output results now serialize running:true when the process outlived the poll's wait, instead of only embedding it in the note string. The chat UI reads the flag so a completed poll over a live process renders "Checking " (and keeps its running spinner) instead of "Checked ". - Backgrounded execute calls without a model intent now render "Started in the background" instead of "Ran ", restoring a background-specific label for the no-intent case. - Update the execute schema test for the reworded model_intent description from the background framing change. --- coderd/x/chatd/chattool/execute.go | 6 +++ coderd/x/chatd/chattool/execute_test.go | 37 ++++++++++++++++- .../ChatElements/tools/ExecuteTool.tsx | 2 + .../ChatElements/tools/Tool.stories.tsx | 41 +++++++++++++++++++ .../components/ChatElements/tools/Tool.tsx | 5 ++- 5 files changed, 89 insertions(+), 2 deletions(-) diff --git a/coderd/x/chatd/chattool/execute.go b/coderd/x/chatd/chattool/execute.go index 9a5a73766fa0d..340cb9ac47849 100644 --- a/coderd/x/chatd/chattool/execute.go +++ b/coderd/x/chatd/chattool/execute.go @@ -89,6 +89,11 @@ type ExecuteResult struct { // results, so both the model and the UI can label the // output without correlating against earlier calls. Command string `json:"command,omitempty"` + // Running reports that the process was still alive when the + // result was produced (the wait timed out or a snapshot was + // requested). The caller should keep polling; the UI uses it + // to avoid presenting the check as finished. + Running bool `json:"running,omitempty"` } // ExecuteOptions configures the execute tool. @@ -510,6 +515,7 @@ func ProcessOutput(options ProcessToolOptions) fantasy.AgentTool { // Process is still running, success is not // yet determined. result.Success = true + result.Running = true result.Note = "process is still running" } data, err := json.Marshal(result) diff --git a/coderd/x/chatd/chattool/execute_test.go b/coderd/x/chatd/chattool/execute_test.go index 553f7056f8b59..e2ece4a9fed67 100644 --- a/coderd/x/chatd/chattool/execute_test.go +++ b/coderd/x/chatd/chattool/execute_test.go @@ -29,7 +29,7 @@ func TestExecuteTool(t *testing.T) { require.True(t, ok) assert.Equal(t, "string", modelIntentParam["type"]) assert.Contains(t, modelIntentParam["description"], "alongside the command") - assert.Contains(t, modelIntentParam["description"], "do not repeat the command") + assert.Contains(t, modelIntentParam["description"], "do not include the word") assert.Contains(t, info.Required, "command") assert.NotContains(t, info.Required, "model_intent") }) @@ -514,6 +514,41 @@ func TestExecuteTool(t *testing.T) { } }) + t.Run("ProcessOutputStillRunningSetsRunningFlag", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + + mockConn.EXPECT(). + ProcessOutput(gomock.Any(), "proc-1", gomock.Any()). + Return(workspacesdk.ProcessOutputResponse{ + Running: true, + Output: "starting...", + Command: "npm start", + }, nil) + + tool := chattool.ProcessOutput(chattool.ProcessToolOptions{ + GetWorkspaceConn: func(_ context.Context) (workspacesdk.AgentConn, error) { + return mockConn, nil + }, + }) + ctx := testutil.Context(t, testutil.WaitMedium) + resp, err := tool.Run(ctx, fantasy.ToolCall{ + ID: "call-1", + Name: "process_output", + Input: `{"process_id":"proc-1","wait_timeout":"0s"}`, + }) + require.NoError(t, err) + assert.False(t, resp.IsError) + + var result chattool.ExecuteResult + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + assert.True(t, result.Success) + assert.True(t, result.Running) + assert.Equal(t, "process is still running", result.Note) + assert.Equal(t, "npm start", result.Command) + }) + t.Run("ProcessOutputCommandPropagated", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx index fd6cef6f370af..0d8406e064788 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx @@ -163,6 +163,8 @@ const getShellCommandLine = ({ : `Ran ${commandDisplay}`; if (intentLabel && isBackgrounded) { commandLabel = `${intentLabel} in the background using ${commandDisplay}`; + } else if (isBackgrounded) { + commandLabel = `Started ${commandDisplay} in the background`; } if (!isRunning && isError) { commandLabel = `Failed to run ${commandDisplay}`; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 5b471b17bf958..8528f79af11f4 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -608,6 +608,26 @@ export const ExecuteBackgrounded: Story = { }, }; +export const ExecuteBackgroundedNoIntent: Story = { + args: { + name: "execute", + status: "completed", + args: { command: "npm start" }, + shellToolDisplayMode: "always_collapsed", + result: { + background_process_id: "process-123", + output: "", + wall_duration_ms: 2100, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByText("Started npm start in the background"), + ).toBeVisible(); + }, +}; + export const ExecuteAlwaysCollapsed: Story = { args: { name: "execute", @@ -824,6 +844,27 @@ export const ProcessOutputChecking: Story = { }, }; +/** Wait timed out while the process lives on: running:true in the result. */ +export const ProcessOutputStillRunningResult: Story = { + args: { + name: "process_output", + status: "completed", + args: { process_id: "process-123" }, + result: { + command: "npm start", + output: "> Starting Vite dev server...", + running: true, + note: "process is still running", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // The call completed but the process lives; do not claim Checked. + expect(canvas.getByText("Checking npm start")).toBeVisible(); + expect(canvas.queryByText(/Checked/)).not.toBeInTheDocument(); + }, +}; + /** Older transcripts carry no command; the label falls back. */ export const ProcessOutputNoCommand: Story = { args: { diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 09a66ab04babd..13d5a7410490f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -257,13 +257,16 @@ const ProcessOutputRenderer: FC = ({ ? (asNumber(rec.exit_code, { parseString: true }) ?? null) : null; const errorMessage = rec ? asString(rec.error || rec.message) : ""; + // The process may outlive the poll that produced this result + // (wait timeout); the result flags it explicitly. + const processRunning = rec?.running === true; return ( Date: Wed, 19 Aug 2026 16:11:37 +0000 Subject: [PATCH 11/24] fix(site): let a later kill override a stale running snapshot A process_output poll that timed out with running:true kept the row labeled "Checking " and suppressed the killed-by-signal icon even when the transcript recorded a subsequent successful process_signal for the same process. The snapshot was true at poll time; the kill is newer information and wins. --- .../ChatElements/tools/Tool.stories.tsx | 25 +++++++++++++++++++ .../components/ChatElements/tools/Tool.tsx | 5 ++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 8528f79af11f4..3ccca28582cf9 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -865,6 +865,31 @@ export const ProcessOutputStillRunningResult: Story = { }, }; +/** A later kill overrides the stale running snapshot. */ +export const ProcessOutputRunningThenKilled: Story = { + args: { + name: "process_output", + status: "completed", + killedBySignal: "kill", + args: { process_id: "process-123" }, + result: { + command: "npm start", + output: "> Starting Vite dev server...", + running: true, + note: "process is still running", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Checked npm start")).toBeVisible(); + expect(canvas.queryByText(/Checking/)).not.toBeInTheDocument(); + await userEvent.hover(canvas.getByText("Checked npm start")); + expect( + canvasElement.querySelector(".lucide-octagon-x"), + ).not.toBeNull(); + }, +}; + /** Older transcripts carry no command; the label falls back. */ export const ProcessOutputNoCommand: Story = { args: { diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 13d5a7410490f..68e458e7fe4e0 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -258,8 +258,9 @@ const ProcessOutputRenderer: FC = ({ : null; const errorMessage = rec ? asString(rec.error || rec.message) : ""; // The process may outlive the poll that produced this result - // (wait timeout); the result flags it explicitly. - const processRunning = rec?.running === true; + // (wait timeout); the result flags it explicitly. A later + // successful kill overrides the stale running snapshot. + const processRunning = rec?.running === true && !killedBySignal; return ( Date: Wed, 19 Aug 2026 16:17:43 +0000 Subject: [PATCH 12/24] style(site): format Tool.stories.tsx --- .../AgentsPage/components/ChatElements/tools/Tool.stories.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 3ccca28582cf9..14bd1d3fadbda 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -884,9 +884,7 @@ export const ProcessOutputRunningThenKilled: Story = { expect(canvas.getByText("Checked npm start")).toBeVisible(); expect(canvas.queryByText(/Checking/)).not.toBeInTheDocument(); await userEvent.hover(canvas.getByText("Checked npm start")); - expect( - canvasElement.querySelector(".lucide-octagon-x"), - ).not.toBeNull(); + expect(canvasElement.querySelector(".lucide-octagon-x")).not.toBeNull(); }, }; From 356c8b60321f3f154b131244ab65179432e734fc Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 19 Aug 2026 16:30:57 +0000 Subject: [PATCH 13/24] fix(site): give the process_output kill icon an accessible name The ProcessOutputRunningThenKilled story queried the indicator by lucide's private CSS class, violating FE10. Wrap the icon in an aria-labeled span (mirroring the execute tool's existing pattern) and query it by role and accessible name instead. --- .../components/ChatElements/tools/ProcessOutputTool.tsx | 8 +++++++- .../components/ChatElements/tools/Tool.stories.tsx | 3 +-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx index f950962a8bc48..1f916785d39ca 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx @@ -111,7 +111,13 @@ export const ProcessOutputTool: React.FC = ({ {killedBySignal && !isRunning && ( - + + + {signalTooltipLabel(killedBySignal)} diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 14bd1d3fadbda..72c6e86d47455 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -883,8 +883,7 @@ export const ProcessOutputRunningThenKilled: Story = { const canvas = within(canvasElement); expect(canvas.getByText("Checked npm start")).toBeVisible(); expect(canvas.queryByText(/Checking/)).not.toBeInTheDocument(); - await userEvent.hover(canvas.getByText("Checked npm start")); - expect(canvasElement.querySelector(".lucide-octagon-x")).not.toBeNull(); + expect(canvas.getByRole("img", { name: "Killed (SIGKILL)" })).toBeVisible(); }, }; From 3a07711d0a575e8cc93473c4337e2d9e6c7a505e Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 19 Aug 2026 19:27:07 +0000 Subject: [PATCH 14/24] fix(chatd): distinguish intentional background launches from timeouts background_process_id is set on both intentional run_in_background launches and foreground commands that exceeded their timeout (so the caller can re-attach), and the UI derived "backgrounded" from its presence alone. A timed-out foreground command was therefore labeled "Started ... in the background" and lost its meaningful wall_duration_ms suffix. ExecuteResult now carries an explicit backgrounded flag set only on the intentional path, and the frontend derives the background label and duration suppression from it. --- coderd/x/chatd/chattool/execute.go | 7 ++++++ coderd/x/chatd/chattool/execute_test.go | 25 +++++++++++++++++++ .../tools/ProcessKilledIndicator.stories.tsx | 2 ++ .../ChatElements/tools/Tool.stories.tsx | 3 +++ .../ChatElements/tools/toolVisibility.test.ts | 18 +++++++++++++ .../ChatElements/tools/toolVisibility.ts | 7 +++--- 6 files changed, 59 insertions(+), 3 deletions(-) diff --git a/coderd/x/chatd/chattool/execute.go b/coderd/x/chatd/chattool/execute.go index 340cb9ac47849..19a6281c9e50d 100644 --- a/coderd/x/chatd/chattool/execute.go +++ b/coderd/x/chatd/chattool/execute.go @@ -94,6 +94,12 @@ type ExecuteResult struct { // requested). The caller should keep polling; the UI uses it // to avoid presenting the check as finished. Running bool `json:"running,omitempty"` + // Backgrounded marks an intentional run_in_background=true + // launch. background_process_id is also set on foreground + // timeout results so the caller can re-attach, so its + // presence alone does not imply an intentional background + // launch; this flag does. + Backgrounded bool `json:"backgrounded,omitempty"` } // ExecuteOptions configures the execute tool. @@ -209,6 +215,7 @@ func executeBackground( result := ExecuteResult{ Success: true, BackgroundProcessID: resp.ID, + Backgrounded: true, } data, err := json.Marshal(result) if err != nil { diff --git a/coderd/x/chatd/chattool/execute_test.go b/coderd/x/chatd/chattool/execute_test.go index e2ece4a9fed67..bfb4b7c1e9456 100644 --- a/coderd/x/chatd/chattool/execute_test.go +++ b/coderd/x/chatd/chattool/execute_test.go @@ -514,6 +514,31 @@ func TestExecuteTool(t *testing.T) { } }) + t.Run("BackgroundedFlagOnlyOnIntentionalLaunch", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + + mockConn.EXPECT(). + StartProcess(gomock.Any(), gomock.Any()). + Return(workspacesdk.StartProcessResponse{ID: "proc-bg"}, nil) + + tool := newExecuteTool(t, mockConn) + ctx := testutil.Context(t, testutil.WaitMedium) + resp, err := tool.Run(ctx, fantasy.ToolCall{ + ID: "call-1", + Name: "execute", + Input: `{"command":"npm start","run_in_background":true}`, + }) + require.NoError(t, err) + assert.False(t, resp.IsError) + + var result chattool.ExecuteResult + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + assert.True(t, result.Backgrounded) + assert.Equal(t, "proc-bg", result.BackgroundProcessID) + }) + t.Run("ProcessOutputStillRunningSetsRunningFlag", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessKilledIndicator.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessKilledIndicator.stories.tsx index abadd86f12e60..030f805ab93ba 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessKilledIndicator.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessKilledIndicator.stories.tsx @@ -27,6 +27,7 @@ export const ExecuteKilled: Story = { exit_code: -1, wall_duration_ms: 45000, background_process_id: PROCESS_ID, + backgrounded: true, }, }, play: async ({ canvasElement }) => { @@ -47,6 +48,7 @@ export const ExecuteTerminated: Story = { exit_code: 0, wall_duration_ms: 2000, background_process_id: PROCESS_ID, + backgrounded: true, }, }, play: async ({ canvasElement }) => { diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 72c6e86d47455..b2551f3c9d33d 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -362,6 +362,7 @@ export const ExecuteModelIntentBackgrounded: Story = { output: "", wall_duration_ms: 2300, background_process_id: "process-123", + backgrounded: true, }, }, play: async ({ canvasElement }) => { @@ -596,6 +597,7 @@ export const ExecuteBackgrounded: Story = { shellToolDisplayMode: "always_collapsed", result: { background_process_id: "process-123", + backgrounded: true, output: "", wall_duration_ms: 2100, }, @@ -616,6 +618,7 @@ export const ExecuteBackgroundedNoIntent: Story = { shellToolDisplayMode: "always_collapsed", result: { background_process_id: "process-123", + backgrounded: true, output: "", wall_duration_ms: 2100, }, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts index 4dc32506bac07..13a6b2464dbc6 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts @@ -14,6 +14,7 @@ describe("toolVisibility", () => { output: " fetched ", wall_duration_ms: "47200", background_process_id: "process-1", + backgrounded: true, }, ), ).toEqual({ @@ -25,6 +26,23 @@ describe("toolVisibility", () => { }); }); + it("does not treat a foreground timeout's process ID as backgrounded", () => { + // Foreground commands that exceed their timeout also return + // background_process_id so the caller can re-attach; only an + // explicit backgrounded flag marks an intentional launch. + expect( + getExecuteRenderData( + { command: "make test" }, + { + success: false, + error: "command timed out after 10s", + exit_code: -1, + background_process_id: "process-1", + }, + ).isBackgrounded, + ).toBe(false); + }); + it("normalizes execute error results into transcript blocks", () => { const data = getExecuteRenderData( { command: "ls -la" }, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts index f973b50698800..1db1d44498e6a 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts @@ -46,9 +46,10 @@ export const getExecuteRenderData = ( ? (asNumber(rec.wall_duration_ms, { parseString: true }) ?? asNumber(rec.duration_ms, { parseString: true })) : undefined; - const isBackgrounded = Boolean( - rec && asString(rec.background_process_id).trim(), - ); + // An intentional run_in_background=true launch is flagged + // explicitly; background_process_id alone is also set on + // foreground timeout results, so it cannot distinguish them. + const isBackgrounded = rec?.backgrounded === true; return { command, From 4ff5319c40af636daf82011739ab36191269c918 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 19 Aug 2026 19:42:38 +0000 Subject: [PATCH 15/24] fix(site): read legacy background launches from call args Transcripts recorded before the backgrounded result flag existed carry the launch intent in the persisted call args (run_in_background: true). Falling back to the args when the result predates the flag keeps legacy rows labeled as background launches while foreground timeouts (backgrounded omitted, not false) still render as failures with their duration. --- .../ChatElements/tools/toolVisibility.test.ts | 30 +++++++++++++++++++ .../ChatElements/tools/toolVisibility.ts | 11 +++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts index 13a6b2464dbc6..db7b30eb75797 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts @@ -43,6 +43,36 @@ describe("toolVisibility", () => { ).toBe(false); }); + it("reads legacy background launches from the call args", () => { + // Transcripts recorded before the backgrounded flag existed + // carry the launch intent in the persisted args. + expect( + getExecuteRenderData( + { command: "npm start", run_in_background: true }, + { + success: true, + background_process_id: "process-1", + }, + ).isBackgrounded, + ).toBe(true); + }); + + it("does not let legacy args override an explicit negative result", () => { + // A new-backend foreground timeout has backgrounded omitted + // (not false), so the args fallback must not resurrect it. + expect( + getExecuteRenderData( + { command: "make test", run_in_background: true }, + { + success: false, + error: "command timed out after 10s", + background_process_id: "process-1", + backgrounded: false, + }, + ).isBackgrounded, + ).toBe(false); + }); + it("normalizes execute error results into transcript blocks", () => { const data = getExecuteRenderData( { command: "ls -la" }, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts index 1db1d44498e6a..96a224f3258a2 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts @@ -47,9 +47,14 @@ export const getExecuteRenderData = ( asNumber(rec.duration_ms, { parseString: true })) : undefined; // An intentional run_in_background=true launch is flagged - // explicitly; background_process_id alone is also set on - // foreground timeout results, so it cannot distinguish them. - const isBackgrounded = rec?.backgrounded === true; + // explicitly in the result; background_process_id alone is + // also set on foreground timeout results, so it cannot + // distinguish them. Transcripts recorded before the flag + // existed carry the intent in the call args instead, so fall + // back to that when the result predates the field. + const isBackgrounded = + rec?.backgrounded === true || + (rec?.backgrounded === undefined && parsedArgs?.run_in_background === true); return { command, From 221d924e084846bf813a43c693d1f59d500a7199 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 19 Aug 2026 19:49:50 +0000 Subject: [PATCH 16/24] chore: drop self-evident comments --- coderd/x/chatd/chattool/execute.go | 14 ++------------ codersdk/workspacesdk/agentconn.go | 2 -- .../components/ChatElements/tools/ExecuteTool.tsx | 3 --- .../ChatElements/tools/toolVisibility.ts | 8 ++------ 4 files changed, 4 insertions(+), 23 deletions(-) diff --git a/coderd/x/chatd/chattool/execute.go b/coderd/x/chatd/chattool/execute.go index 19a6281c9e50d..654e651854c87 100644 --- a/coderd/x/chatd/chattool/execute.go +++ b/coderd/x/chatd/chattool/execute.go @@ -85,20 +85,10 @@ type ExecuteResult struct { Truncated *workspacesdk.ProcessTruncation `json:"truncated,omitempty"` Note string `json:"note,omitempty"` BackgroundProcessID string `json:"background_process_id,omitempty"` - // Command identifies the process for process_output - // results, so both the model and the UI can label the - // output without correlating against earlier calls. Command string `json:"command,omitempty"` - // Running reports that the process was still alive when the - // result was produced (the wait timed out or a snapshot was - // requested). The caller should keep polling; the UI uses it - // to avoid presenting the check as finished. + Running bool `json:"running,omitempty"` - // Backgrounded marks an intentional run_in_background=true - // launch. background_process_id is also set on foreground - // timeout results so the caller can re-attach, so its - // presence alone does not imply an intentional background - // launch; this flag does. + Backgrounded bool `json:"backgrounded,omitempty"` } diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index e78c02e550cfa..920f27477c27f 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -943,8 +943,6 @@ type ProcessOutputResponse struct { Truncated *ProcessTruncation `json:"truncated,omitempty"` Running bool `json:"running"` ExitCode *int `json:"exit_code,omitempty"` - // Command identifies the process so callers can label the - // output without a separate list call. Command string `json:"command,omitempty"` } diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx index 0d8406e064788..e612aac4fc848 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx @@ -59,9 +59,6 @@ export const ExecuteTool: React.FC = ({ ? "preview" : "collapsed"; const isRunning = status === "running"; - // A backgrounded call's duration is the spawn time (often ~0ms), - // not the process lifetime, so the suffix is suppressed to - // avoid lying about how long the process ran for. const durationLabel = isBackgrounded ? "" : formatShellDurationMs(durationMs); const { commandLabel, durationSuffix } = getShellCommandLine({ command, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts index 96a224f3258a2..c24c01e77aa72 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts @@ -46,12 +46,8 @@ export const getExecuteRenderData = ( ? (asNumber(rec.wall_duration_ms, { parseString: true }) ?? asNumber(rec.duration_ms, { parseString: true })) : undefined; - // An intentional run_in_background=true launch is flagged - // explicitly in the result; background_process_id alone is - // also set on foreground timeout results, so it cannot - // distinguish them. Transcripts recorded before the flag - // existed carry the intent in the call args instead, so fall - // back to that when the result predates the field. + // Foreground timeouts also set background_process_id, so fall + // back to the call args for older transcripts without the flag. const isBackgrounded = rec?.backgrounded === true || (rec?.backgrounded === undefined && parsedArgs?.run_in_background === true); From 16fcf29792091ee460754153b57715e9048268b1 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 19 Aug 2026 20:01:04 +0000 Subject: [PATCH 17/24] fix(site): recognize legacy trailing-ampersand background launches The execute tool promotes "cmd &" to background mode and strips the ampersand locally, but the persisted tool-call args keep the original command with no run_in_background. Those legacy rows have neither the backgrounded flag nor the args flag, so they lost the background label and gained a meaningless spawn-duration suffix. Detect the trailing-ampersand form (excluding && and |&) as another legacy fallback. Also drop a story comment that restated its assertion. --- .../ChatElements/tools/Tool.stories.tsx | 1 - .../ChatElements/tools/toolVisibility.test.ts | 30 +++++++++++++++++++ .../ChatElements/tools/toolVisibility.ts | 12 +++++++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index b2551f3c9d33d..1c4c032c01cea 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -604,7 +604,6 @@ export const ExecuteBackgrounded: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // The backgrounded spawn duration is process noise, not shown. expect(canvas.queryByText(/for 2\.1s/)).not.toBeInTheDocument(); expect(canvas.getByText(/npm start/)).toBeInTheDocument(); }, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts index db7b30eb75797..1067b722d7cec 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts @@ -73,6 +73,36 @@ describe("toolVisibility", () => { ).toBe(false); }); + it("recognizes legacy trailing-ampersand background launches", () => { + // The execute tool promotes `cmd &` to background mode and + // strips the ampersand, but the persisted args keep the + // original command without run_in_background. + expect( + getExecuteRenderData( + { command: "npm start &" }, + { + success: true, + background_process_id: "process-1", + }, + ).isBackgrounded, + ).toBe(true); + }); + + it("ignores ampersand chains that are not background promotions", () => { + expect( + getExecuteRenderData( + { command: "cmd1 && cmd2" }, + { success: true, background_process_id: "process-1" }, + ).isBackgrounded, + ).toBe(false); + expect( + getExecuteRenderData( + { command: "cmd |& tee log" }, + { success: true, background_process_id: "process-1" }, + ).isBackgrounded, + ).toBe(false); + }); + it("normalizes execute error results into transcript blocks", () => { const data = getExecuteRenderData( { command: "ls -la" }, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts index c24c01e77aa72..29e519e33027b 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts @@ -48,9 +48,19 @@ export const getExecuteRenderData = ( : undefined; // Foreground timeouts also set background_process_id, so fall // back to the call args for older transcripts without the flag. + // That includes trailing-& commands, which the tool promotes to + // background without adding run_in_background to the args. + const trimmedCommand = command.trimEnd(); + const hasTrailingAmp = + trimmedCommand.endsWith("&") && + !trimmedCommand.endsWith("&&") && + !trimmedCommand.endsWith("|&"); const isBackgrounded = rec?.backgrounded === true || - (rec?.backgrounded === undefined && parsedArgs?.run_in_background === true); + (rec?.backgrounded === undefined && + (parsedArgs?.run_in_background === true || + (Boolean(rec && asString(rec.background_process_id).trim()) && + hasTrailingAmp))); return { command, From ae3fa9738c0d6967a4ba174b4e585c53682d24df Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 19 Aug 2026 20:14:38 +0000 Subject: [PATCH 18/24] style: realign struct fields after comment removal --- coderd/x/chatd/chattool/execute.go | 2 +- codersdk/workspacesdk/agentconn.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/x/chatd/chattool/execute.go b/coderd/x/chatd/chattool/execute.go index 654e651854c87..6642b628348a6 100644 --- a/coderd/x/chatd/chattool/execute.go +++ b/coderd/x/chatd/chattool/execute.go @@ -85,7 +85,7 @@ type ExecuteResult struct { Truncated *workspacesdk.ProcessTruncation `json:"truncated,omitempty"` Note string `json:"note,omitempty"` BackgroundProcessID string `json:"background_process_id,omitempty"` - Command string `json:"command,omitempty"` + Command string `json:"command,omitempty"` Running bool `json:"running,omitempty"` diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index 920f27477c27f..1cdb45e4ca536 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -943,7 +943,7 @@ type ProcessOutputResponse struct { Truncated *ProcessTruncation `json:"truncated,omitempty"` Running bool `json:"running"` ExitCode *int `json:"exit_code,omitempty"` - Command string `json:"command,omitempty"` + Command string `json:"command,omitempty"` } // ProcessOutputOptions configures blocking behavior for From 3672b2cf62fd2c692439fbb88583181451915926 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 19 Aug 2026 20:35:23 +0000 Subject: [PATCH 19/24] fix(site): let only SIGKILL override a stale running snapshot SIGTERM is catchable: the agent delivers it and returns without waiting for exit, so a successful terminate must not flip a poll row's recorded running state to "Checked". Only an uncatchable kill overrides the snapshot. Also drops story comments that restated their assertions. --- .../ChatElements/tools/Tool.stories.tsx | 26 +++++++++++++++---- .../components/ChatElements/tools/Tool.tsx | 5 ++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 1c4c032c01cea..fb11edbab0063 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -759,7 +759,6 @@ export const ProcessOutputExitZeroNoBadge: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Clean exit stays quiet: no badge, the Checked verb carries it. expect(canvas.getByText("Checked npm start")).toBeVisible(); expect(canvas.queryByText(/exit/)).not.toBeInTheDocument(); }, @@ -799,7 +798,6 @@ export const ProcessOutputModelIntent: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Intent alone is the label; the command is not appended. expect( canvas.getByText("Waiting for the dev server to be ready"), ).toBeVisible(); @@ -807,7 +805,6 @@ export const ProcessOutputModelIntent: Story = { }, }; -/** Intent that restates the command is left as the model wrote it. */ export const ProcessOutputModelIntentRedundant: Story = { args: { name: "process_output", @@ -861,13 +858,11 @@ export const ProcessOutputStillRunningResult: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // The call completed but the process lives; do not claim Checked. expect(canvas.getByText("Checking npm start")).toBeVisible(); expect(canvas.queryByText(/Checked/)).not.toBeInTheDocument(); }, }; -/** A later kill overrides the stale running snapshot. */ export const ProcessOutputRunningThenKilled: Story = { args: { name: "process_output", @@ -889,6 +884,27 @@ export const ProcessOutputRunningThenKilled: Story = { }, }; +/** SIGTERM is catchable, so it leaves the running snapshot alone. */ +export const ProcessOutputRunningThenTerminated: Story = { + args: { + name: "process_output", + status: "completed", + killedBySignal: "terminate", + args: { process_id: "process-123" }, + result: { + command: "npm start", + output: "> Starting Vite dev server...", + running: true, + note: "process is still running", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Checking npm start")).toBeVisible(); + expect(canvas.queryByText(/Checked/)).not.toBeInTheDocument(); + }, +}; + /** Older transcripts carry no command; the label falls back. */ export const ProcessOutputNoCommand: Story = { args: { diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 68e458e7fe4e0..8d96c6924e3e0 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -259,8 +259,9 @@ const ProcessOutputRenderer: FC = ({ const errorMessage = rec ? asString(rec.error || rec.message) : ""; // The process may outlive the poll that produced this result // (wait timeout); the result flags it explicitly. A later - // successful kill overrides the stale running snapshot. - const processRunning = rec?.running === true && !killedBySignal; + // SIGKILL overrides the stale running snapshot; SIGTERM is + // catchable, so it does not. + const processRunning = rec?.running === true && killedBySignal !== "kill"; return ( Date: Wed, 19 Aug 2026 21:00:03 +0000 Subject: [PATCH 20/24] fix(site): require a process ID before honoring legacy background args run_in_background in the persisted args records intent, not outcome: a failed StartProcess returns an error result with neither flag nor process ID, and the legacy fallback alone labeled it "Started ... in the background". Gate the args fallback on a nonempty background_process_id so only launches that produced a process count. --- .../ChatElements/tools/toolVisibility.test.ts | 12 ++++++++++++ .../components/ChatElements/tools/toolVisibility.ts | 12 ++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts index 1067b722d7cec..9bc664ece76a0 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.test.ts @@ -103,6 +103,18 @@ describe("toolVisibility", () => { ).toBe(false); }); + it("does not treat a failed background start as launched", () => { + // A failed StartProcess returns an error result with no + // process ID, so the legacy args alone must not mark it + // backgrounded. + expect( + getExecuteRenderData( + { command: "npm start", run_in_background: true }, + { success: false, error: "start process: boom" }, + ).isBackgrounded, + ).toBe(false); + }); + it("normalizes execute error results into transcript blocks", () => { const data = getExecuteRenderData( { command: "ls -la" }, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts index 29e519e33027b..18c26c1ab145f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts @@ -49,18 +49,22 @@ export const getExecuteRenderData = ( // Foreground timeouts also set background_process_id, so fall // back to the call args for older transcripts without the flag. // That includes trailing-& commands, which the tool promotes to - // background without adding run_in_background to the args. + // background without adding run_in_background to the args. The + // args record intent, not outcome, so require a process ID as + // evidence the launch actually happened. const trimmedCommand = command.trimEnd(); const hasTrailingAmp = trimmedCommand.endsWith("&") && !trimmedCommand.endsWith("&&") && !trimmedCommand.endsWith("|&"); + const hasProcessID = Boolean( + rec && asString(rec.background_process_id).trim(), + ); const isBackgrounded = rec?.backgrounded === true || (rec?.backgrounded === undefined && - (parsedArgs?.run_in_background === true || - (Boolean(rec && asString(rec.background_process_id).trim()) && - hasTrailingAmp))); + hasProcessID && + (parsedArgs?.run_in_background === true || hasTrailingAmp)); return { command, From fa1ec43ce4a79f3e8f0db914edfc4feb71262711 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 20 Aug 2026 08:38:58 +0000 Subject: [PATCH 21/24] fix(site): make tool output scroll areas keyboard-focusable Tool output viewports were plain scrollable divs with no tab stop, so keyboard-only users in browsers that do not auto-focus overflow containers could not reach content below the fold. Add viewportTabIndex={0} to every tool ScrollArea so the regions accept focus and scroll by keyboard. The shared ScrollArea already forwards viewportTabIndex; a positive default there would break the one consumer that opts out with -1, so call sites opt in explicitly. --- .../AgentsPage/components/ChatElements/tools/AdvisorTool.tsx | 1 + .../components/ChatElements/tools/ChatSummarizedTool.tsx | 1 + .../AgentsPage/components/ChatElements/tools/EditFilesTool.tsx | 1 + .../AgentsPage/components/ChatElements/tools/ExecuteTool.tsx | 1 + .../components/ChatElements/tools/ListSubagentModelsTool.tsx | 1 + .../components/ChatElements/tools/ProcessOutputTool.tsx | 1 + .../AgentsPage/components/ChatElements/tools/ReadFileTool.tsx | 1 + .../AgentsPage/components/ChatElements/tools/ReadSkillTool.tsx | 1 + .../AgentsPage/components/ChatElements/tools/SubagentTool.tsx | 3 +++ .../pages/AgentsPage/components/ChatElements/tools/Tool.tsx | 1 + .../components/ChatElements/tools/WorkspaceBuildLogSection.tsx | 1 + .../AgentsPage/components/ChatElements/tools/WriteFileTool.tsx | 1 + 12 files changed, 14 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx index e8150923980b6..e2bea49ed44ce 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx @@ -67,6 +67,7 @@ export const AdvisorTool: React.FC = ({
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx index bca2c3a6eba90..9319c9c34189d 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx @@ -43,6 +43,7 @@ export const ChatSummarizedTool: React.FC<{
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx index 1af4bf6b8acbd..8886d957bc510 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx @@ -83,6 +83,7 @@ export const EditFilesTool: React.FC<{ ? "max-h-[80vh]" : "max-h-64" } + viewportTabIndex={0} scrollBarClassName="w-1.5" >
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ListSubagentModelsTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ListSubagentModelsTool.tsx index 7c78424f6601f..71631a27e9811 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ListSubagentModelsTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ListSubagentModelsTool.tsx @@ -74,6 +74,7 @@ const ListSubagentModelsContent: React.FC<{ models: unknown[] }> = ({
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx index 1f916785d39ca..4da029eec86b7 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx @@ -143,6 +143,7 @@ export const ProcessOutputTool: React.FC = ({
 						
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx index dad3f7362ec13..d7e730019a85d 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx @@ -279,6 +279,7 @@ export const SubagentTool: React.FC<{
@@ -291,6 +292,7 @@ export const SubagentTool: React.FC<{
@@ -303,6 +305,7 @@ export const SubagentTool: React.FC<{
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 8d96c6924e3e0..b0baa48064c44 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -845,6 +845,7 @@ const ToolFileViewer: FC = ({ label, file, options }) => ( = ({ Date: Thu, 20 Aug 2026 09:13:51 +0000 Subject: [PATCH 22/24] feat(site): name focusable tool output regions for assistive tech site/AGENTS.md requires every tabIndex={0} element to carry a semantic role; the scroll-area sweep created fourteen anonymous generic focus stops. ScrollArea now accepts viewportAriaLabel and renders role="region" with it on the viewport when set, and each tool output region supplies an accessible name (process output, command output, diffs by path, file contents by name, subagent prompt/response/report, advisor, models, summary, build log). --- site/src/components/ScrollArea/ScrollArea.tsx | 4 ++++ .../AgentsPage/components/ChatElements/tools/AdvisorTool.tsx | 1 + .../components/ChatElements/tools/ChatSummarizedTool.tsx | 1 + .../components/ChatElements/tools/EditFilesTool.tsx | 1 + .../AgentsPage/components/ChatElements/tools/ExecuteTool.tsx | 1 + .../components/ChatElements/tools/ListSubagentModelsTool.tsx | 1 + .../components/ChatElements/tools/ProcessOutputTool.tsx | 1 + .../AgentsPage/components/ChatElements/tools/ReadFileTool.tsx | 1 + .../components/ChatElements/tools/ReadSkillTool.tsx | 1 + .../AgentsPage/components/ChatElements/tools/SubagentTool.tsx | 3 +++ .../pages/AgentsPage/components/ChatElements/tools/Tool.tsx | 1 + .../ChatElements/tools/WorkspaceBuildLogSection.tsx | 1 + .../components/ChatElements/tools/WriteFileTool.tsx | 1 + 13 files changed, 18 insertions(+) diff --git a/site/src/components/ScrollArea/ScrollArea.tsx b/site/src/components/ScrollArea/ScrollArea.tsx index e9bb3458473a9..b99c445722cbe 100644 --- a/site/src/components/ScrollArea/ScrollArea.tsx +++ b/site/src/components/ScrollArea/ScrollArea.tsx @@ -13,6 +13,7 @@ interface ScrollAreaProps scrollThumbClassName?: string; viewportClassName?: string; viewportTabIndex?: number; + viewportAriaLabel?: string; /** Which scrollbar(s) to show. Defaults to "vertical". */ orientation?: "vertical" | "horizontal" | "both"; } @@ -24,6 +25,7 @@ export const ScrollArea: React.FC = ({ scrollThumbClassName, viewportClassName, viewportTabIndex, + viewportAriaLabel, orientation = "vertical", children, ...props @@ -35,6 +37,8 @@ export const ScrollArea: React.FC = ({ > {children} diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx index e2bea49ed44ce..f32274bd77fd8 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx @@ -68,6 +68,7 @@ export const AdvisorTool: React.FC = ({ className="mt-1.5 rounded-md border border-solid border-border-default" viewportClassName="max-h-64" viewportTabIndex={0} + viewportAriaLabel="Advisor response" scrollBarClassName="w-1.5" >
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx index 9319c9c34189d..11929ab07a39d 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx @@ -44,6 +44,7 @@ export const ChatSummarizedTool: React.FC<{ className="mt-1.5 rounded-md border border-solid border-border-default" viewportClassName="max-h-64" viewportTabIndex={0} + viewportAriaLabel="Conversation summary" scrollBarClassName="w-1.5" >
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx index 8886d957bc510..141c0d621afc9 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx @@ -84,6 +84,7 @@ export const EditFilesTool: React.FC<{ : "max-h-64" } viewportTabIndex={0} + viewportAriaLabel={`Diff of ${files[i].path}`} scrollBarClassName="w-1.5" >
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ListSubagentModelsTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ListSubagentModelsTool.tsx index 71631a27e9811..114e2f08319ed 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ListSubagentModelsTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ListSubagentModelsTool.tsx @@ -75,6 +75,7 @@ const ListSubagentModelsContent: React.FC<{ models: unknown[] }> = ({ className="mt-1.5 rounded-md border border-solid border-border-default" viewportClassName="max-h-64" viewportTabIndex={0} + viewportAriaLabel="Available models" scrollBarClassName="w-1.5" >
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx index 4da029eec86b7..4b607853e4057 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx @@ -144,6 +144,7 @@ export const ProcessOutputTool: React.FC = ({ className="mt-2 rounded-xl bg-surface-secondary/60 text-2xs" viewportClassName="max-h-64" viewportTabIndex={0} + viewportAriaLabel="Process output" scrollBarClassName="w-1.5" >
 						
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx index d7e730019a85d..4038dc5286594 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx @@ -280,6 +280,7 @@ export const SubagentTool: React.FC<{ className="mt-1.5 rounded-md border border-solid border-border-default" viewportClassName="max-h-64" viewportTabIndex={0} + viewportAriaLabel="Subagent prompt" scrollBarClassName="w-1.5" >
@@ -293,6 +294,7 @@ export const SubagentTool: React.FC<{ className="mt-1.5 rounded-md border border-solid border-border-default" viewportClassName="max-h-64" viewportTabIndex={0} + viewportAriaLabel="Subagent response" scrollBarClassName="w-1.5" >
@@ -306,6 +308,7 @@ export const SubagentTool: React.FC<{ className="mt-1.5 rounded-md border border-solid border-border-default" viewportClassName="max-h-64" viewportTabIndex={0} + viewportAriaLabel="Subagent report" scrollBarClassName="w-1.5" >
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index b0baa48064c44..011075e2ac4c5 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -846,6 +846,7 @@ const ToolFileViewer: FC = ({ label, file, options }) => ( className="mt-1.5 rounded-md border border-solid border-border-default text-2xs" viewportClassName="max-h-64" viewportTabIndex={0} + viewportAriaLabel={`Contents of ${file.name}`} orientation="both" scrollBarClassName="w-1.5" horizontalScrollBarClassName="h-1.5" diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.tsx index a4a6c4bdaa5a9..5d0f493908f1e 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.tsx @@ -161,6 +161,7 @@ export const WorkspaceBuildLogSection: FC = ({ className="mt-1.5 rounded-md border border-solid border-border-default text-2xs" viewportClassName="max-h-64" viewportTabIndex={0} + viewportAriaLabel="Workspace build log" scrollBarClassName="w-1.5" > Date: Thu, 20 Aug 2026 09:27:05 +0000 Subject: [PATCH 23/24] style: group ExecuteResult fields, revert stray whitespace change --- coderd/x/chatd/chattool/execute.go | 6 ++---- .../components/ChatConversation/messageParsing.ts | 1 + 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/coderd/x/chatd/chattool/execute.go b/coderd/x/chatd/chattool/execute.go index 6642b628348a6..f8c0576c947d7 100644 --- a/coderd/x/chatd/chattool/execute.go +++ b/coderd/x/chatd/chattool/execute.go @@ -86,10 +86,8 @@ type ExecuteResult struct { Note string `json:"note,omitempty"` BackgroundProcessID string `json:"background_process_id,omitempty"` Command string `json:"command,omitempty"` - - Running bool `json:"running,omitempty"` - - Backgrounded bool `json:"backgrounded,omitempty"` + Running bool `json:"running,omitempty"` + Backgrounded bool `json:"backgrounded,omitempty"` } // ExecuteOptions configures the execute tool. diff --git a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts index 41eb7554e7fb2..da90fb396b582 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts @@ -410,6 +410,7 @@ export const parseMessagesWithMergedTools = ( } } } + return rawParsed; }; From cff3316bbed8c908ae2562e73a6425690719d869 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 20 Aug 2026 09:31:06 +0000 Subject: [PATCH 24/24] test(site): trim process_output stories to their distinct behaviors Twelve stories had accreted across review rounds, several asserting the same label in different fixtures. Kept the four that pin distinct mechanisms: quiet clean exit, intent as label, running flag from a timed-out poll, and the kill-override. Dropped duplicates whose behavior is already covered by the sanitizer unit tests or by another story's play function, and merged the two signal stories into one (the terminate non-override is the default path the running-flag story already proves). --- .../ChatElements/tools/Tool.stories.tsx | 147 +----------------- 1 file changed, 2 insertions(+), 145 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index fb11edbab0063..4a74f59be0442 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -349,32 +349,6 @@ export const ExecuteModelIntentRunning: Story = { }, }; -export const ExecuteModelIntentBackgrounded: Story = { - args: { - name: "execute", - status: "completed", - args: { - command: executeIntentCommand, - model_intent: "Starting a sleep process", - }, - modelIntent: "Starting a sleep process", - result: { - output: "", - wall_duration_ms: 2300, - background_process_id: "process-123", - backgrounded: true, - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect( - canvas.getByText( - `Starting a sleep process in the background using ${executeIntentCommand}`, - ), - ).toBeVisible(); - }, -}; - export const ExecuteModelIntentLeadingUsing: Story = { args: { status: "completed", @@ -609,27 +583,6 @@ export const ExecuteBackgrounded: Story = { }, }; -export const ExecuteBackgroundedNoIntent: Story = { - args: { - name: "execute", - status: "completed", - args: { command: "npm start" }, - shellToolDisplayMode: "always_collapsed", - result: { - background_process_id: "process-123", - backgrounded: true, - output: "", - wall_duration_ms: 2100, - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect( - canvas.getByText("Started npm start in the background"), - ).toBeVisible(); - }, -}; - export const ExecuteAlwaysCollapsed: Story = { args: { name: "execute", @@ -726,26 +679,6 @@ export const ProcessOutputAlwaysExpanded: Story = { }, }; -export const ProcessOutputChecked: Story = { - args: { - name: "process_output", - status: "completed", - args: { process_id: "process-123" }, - result: { - command: "npm start", - output: - "> Local: http://localhost:3001/\n> Server exited: EADDRINUSE :::3001", - exit_code: 1, - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect(canvas.getByText("Failed npm start")).toBeVisible(); - expect(canvas.getByText("exit 1")).toBeVisible(); - expect(canvas.getByText(/EADDRINUSE/)).toBeVisible(); - }, -}; - export const ProcessOutputExitZeroNoBadge: Story = { args: { name: "process_output", @@ -764,24 +697,6 @@ export const ProcessOutputExitZeroNoBadge: Story = { }, }; -export const ProcessOutputFailedLabel: Story = { - args: { - name: "process_output", - status: "completed", - args: { process_id: "process-123" }, - result: { - command: "npm start", - output: "EADDRINUSE", - exit_code: 1, - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect(canvas.getByText("Failed npm start")).toBeVisible(); - expect(canvas.getByText("exit 1")).toBeVisible(); - }, -}; - export const ProcessOutputModelIntent: Story = { args: { name: "process_output", @@ -805,44 +720,6 @@ export const ProcessOutputModelIntent: Story = { }, }; -export const ProcessOutputModelIntentRedundant: Story = { - args: { - name: "process_output", - status: "completed", - args: { - process_id: "process-123", - model_intent: "Confirming the tests pass using npm start", - }, - modelIntent: "Confirming the tests pass using npm start", - result: { - command: "npm start", - output: "all tests passed", - exit_code: 0, - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect(canvas.getByText("Confirming the tests pass")).toBeVisible(); - expect(canvas.queryByText(/npm start/)).not.toBeInTheDocument(); - }, -}; - -export const ProcessOutputChecking: Story = { - args: { - name: "process_output", - status: "running", - args: { process_id: "process-123" }, - result: { - command: "npm start", - output: "> Starting Vite dev server...", - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect(canvas.getByText("Checking npm start")).toBeVisible(); - }, -}; - /** Wait timed out while the process lives on: running:true in the result. */ export const ProcessOutputStillRunningResult: Story = { args: { @@ -863,7 +740,8 @@ export const ProcessOutputStillRunningResult: Story = { }, }; -export const ProcessOutputRunningThenKilled: Story = { +/** A later kill overrides a stale running snapshot; SIGTERM does not. */ +export const ProcessOutputRunningThenSignaled: Story = { args: { name: "process_output", status: "completed", @@ -884,27 +762,6 @@ export const ProcessOutputRunningThenKilled: Story = { }, }; -/** SIGTERM is catchable, so it leaves the running snapshot alone. */ -export const ProcessOutputRunningThenTerminated: Story = { - args: { - name: "process_output", - status: "completed", - killedBySignal: "terminate", - args: { process_id: "process-123" }, - result: { - command: "npm start", - output: "> Starting Vite dev server...", - running: true, - note: "process is still running", - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect(canvas.getByText("Checking npm start")).toBeVisible(); - expect(canvas.queryByText(/Checked/)).not.toBeInTheDocument(); - }, -}; - /** Older transcripts carry no command; the label falls back. */ export const ProcessOutputNoCommand: Story = { args: {