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..f8c0576c947d7 100644 --- a/coderd/x/chatd/chattool/execute.go +++ b/coderd/x/chatd/chattool/execute.go @@ -85,6 +85,9 @@ 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"` + Running bool `json:"running,omitempty"` + Backgrounded bool `json:"backgrounded,omitempty"` } // ExecuteOptions configures the execute tool. @@ -107,7 +110,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."` @@ -200,6 +203,7 @@ func executeBackground( result := ExecuteResult{ Success: true, BackgroundProcessID: resp.ID, + Backgrounded: true, } data, err := json.Marshal(result) if err != nil { @@ -422,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 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 @@ -499,11 +504,13 @@ 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 // 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 ede6c1a957e70..bfb4b7c1e9456 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,102 @@ 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) + 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) + 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..1cdb45e4ca536 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -943,6 +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"` } // ProcessOutputOptions configures blocking behavior for 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 e8150923980b6..f32274bd77fd8 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx @@ -67,6 +67,8 @@ 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..11929ab07a39d 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ChatSummarizedTool.tsx @@ -43,6 +43,8 @@ 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..141c0d621afc9 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/EditFilesTool.tsx @@ -83,6 +83,8 @@ export const EditFilesTool: React.FC<{ ? "max-h-[80vh]" : "max-h-64" } + viewportTabIndex={0} + viewportAriaLabel={`Diff of ${files[i].path}`} scrollBarClassName="w-1.5" > = ({ ? "preview" : "collapsed"; const isRunning = status === "running"; - const durationLabel = formatShellDurationMs(durationMs); + const durationLabel = isBackgrounded ? "" : formatShellDurationMs(durationMs); const { commandLabel, durationSuffix } = getShellCommandLine({ command, modelIntent, @@ -67,6 +67,7 @@ export const ExecuteTool: React.FC = ({ durationLabel, isRunning, isError, + isBackgrounded, }); const defaultView = resolveAgentDisplayState( shellToolDisplayMode, @@ -76,7 +77,7 @@ export const ExecuteTool: React.FC = ({ return ( = ({ - {isBackgrounded && !isRunning && ( - - - - - - - Running in background - - )} {killedBySignal && !isRunning && ( @@ -150,6 +137,7 @@ type ShellCommandLineInput = { durationLabel: string; isRunning: boolean; isError: boolean; + isBackgrounded: boolean; }; const getShellCommandLine = ({ @@ -159,16 +147,22 @@ 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}`; + } else if (isBackgrounded) { + commandLabel = `Started ${commandDisplay} in the background`; + } if (!isRunning && isError) { commandLabel = `Failed to run ${commandDisplay}`; } @@ -188,6 +182,8 @@ const ShellTranscriptBody: React.FC<{
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ListSubagentModelsTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ListSubagentModelsTool.tsx index 7c78424f6601f..114e2f08319ed 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ListSubagentModelsTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ListSubagentModelsTool.tsx @@ -74,6 +74,8 @@ const ListSubagentModelsContent: React.FC<{ models: unknown[] }> = ({
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/ProcessOutputTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProcessOutputTool.tsx index 8b740af4e882b..4b607853e4057 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,15 @@ 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 { sanitizeExecuteModelIntent, signalTooltipLabel } from "./utils"; type ProcessOutputToolProps = { output: string; + command?: string; + modelIntent?: string; isRunning: boolean; exitCode: number | null; isError: boolean; @@ -28,60 +28,60 @@ type ProcessOutputToolProps = { shellToolDisplayMode?: TypesGen.AgentDisplayMode; }; -type ProcessOutputToolInnerProps = ProcessOutputToolProps & { - defaultView: AgentDisplayState; - outputInitiallyFullyExpanded: boolean; -}; - -export const ProcessOutputTool: React.FC = (props) => { - const autoDisplayState: AgentDisplayState = - props.output.length > 0 ? "preview" : "collapsed"; - const resolvedDisplayState = resolveAgentDisplayState( - props.shellToolDisplayMode, - autoDisplayState, - ); - return ( - - ); +const getProcessOutputLabel = ({ + command, + modelIntent, + isRunning, + isFailed, +}: { + command: string | undefined; + modelIntent: string | undefined; + isRunning: boolean; + isFailed: boolean; +}): string => { + const trimmedCommand = command?.trim() ?? ""; + const intent = modelIntent + ? sanitizeExecuteModelIntent(modelIntent, trimmedCommand) + : ""; + if (intent) { + return intent; + } + if (!trimmedCommand) { + return "Process output"; + } + if (isRunning) { + return `Checking ${trimmedCommand}`; + } + return `${isFailed ? "Failed" : "Checked"} ${trimmedCommand}`; }; -const ProcessOutputToolInner: React.FC = ({ +export const ProcessOutputTool: React.FC = ({ output, + command, + modelIntent, 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 hasHeaderActions = Boolean(killedBySignal) || showExitCode || hasOutput; + // 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) || isFailed || hasOutput; return ( = ({ - Process output + + {getProcessOutputLabel({ + command, + modelIntent, + isRunning, + isFailed, + })} + @@ -104,14 +111,20 @@ const ProcessOutputToolInner: React.FC = ({ {killedBySignal && !isRunning && ( - + + + {signalTooltipLabel(killedBySignal)} )} - {showExitCode && ( + {isFailed && ( exit {exitCode} @@ -128,45 +141,21 @@ const ProcessOutputToolInner: React.FC = ({
 						{output}
 					
- {overflows && ( - - )}
); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx index 88d9132f92dc7..925ac3da069a5 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ReadFileTool.tsx @@ -23,6 +23,8 @@ const ReadFileContent: 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..4038dc5286594 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/SubagentTool.tsx @@ -279,6 +279,8 @@ export const SubagentTool: React.FC<{
@@ -291,6 +293,8 @@ export const SubagentTool: React.FC<{
@@ -303,6 +307,8 @@ export const SubagentTool: React.FC<{
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..4a74f59be0442 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"; @@ -571,20 +571,15 @@ export const ExecuteBackgrounded: Story = { shellToolDisplayMode: "always_collapsed", result: { background_process_id: "process-123", + backgrounded: true, output: "", wall_duration_ms: 2100, }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const backgroundIndicator = canvas.getByRole("img", { - name: "Running in background", - }); - expect(backgroundIndicator).toBeVisible(); - await userEvent.hover(backgroundIndicator); - expect(await screen.findByRole("tooltip")).toHaveTextContent( - "Running in background", - ); + expect(canvas.queryByText(/for 2\.1s/)).not.toBeInTheDocument(); + expect(canvas.getByText(/npm start/)).toBeInTheDocument(); }, }; @@ -681,13 +676,107 @@ 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 ProcessOutputExitZeroNoBadge: 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("Checked npm start")).toBeVisible(); + expect(canvas.queryByText(/exit/)).not.toBeInTheDocument(); + }, +}; + +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"), + ).toBeVisible(); + expect(canvas.queryByText(/npm start/)).not.toBeInTheDocument(); + }, +}; + +/** 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); + expect(canvas.getByText("Checking npm start")).toBeVisible(); + expect(canvas.queryByText(/Checked/)).not.toBeInTheDocument(); + }, +}; + +/** A later kill overrides a stale running snapshot; SIGTERM does not. */ +export const ProcessOutputRunningThenSignaled: 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(); + expect(canvas.getByRole("img", { name: "Killed (SIGKILL)" })).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..011075e2ac4c5 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -247,19 +247,28 @@ const ProcessOutputRenderer: FC = ({ result, isError, killedBySignal, + modelIntent, shellToolDisplayMode, }) => { 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; 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 + // SIGKILL overrides the stale running snapshot; SIGTERM is + // catchable, so it does not. + const processRunning = rec?.running === true && killedBySignal !== "kill"; return ( = ({ label, file, options }) => ( > = { execute: TerminalIcon, - process_output: TerminalIcon, + process_output: ActivityIcon, process_list: TerminalIcon, process_signal: TerminalIcon, read_file: FileTextIcon, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.tsx index 54b7b5e6cb02b..5d0f493908f1e 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/WorkspaceBuildLogSection.tsx @@ -160,6 +160,8 @@ export const WorkspaceBuildLogSection: FC = ({ { output: " fetched ", wall_duration_ms: "47200", background_process_id: "process-1", + backgrounded: true, }, ), ).toEqual({ @@ -25,6 +26,95 @@ 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("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("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("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 f973b50698800..18c26c1ab145f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/toolVisibility.ts @@ -46,9 +46,25 @@ export const getExecuteRenderData = ( ? (asNumber(rec.wall_duration_ms, { parseString: true }) ?? asNumber(rec.duration_ms, { parseString: true })) : undefined; - const isBackgrounded = Boolean( + // 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. 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 && + hasProcessID && + (parsedArgs?.run_in_background === true || hasTrailingAmp)); return { command, diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts index 3a5ed1a472915..0855bfffdabb1 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest"; import { buildEditDiff, buildWriteFileDiff, - COLLAPSED_OUTPUT_HEIGHT, COLLAPSED_REPORT_HEIGHT, DIFFS_FONT_STYLE, diffViewerCSS, @@ -1036,10 +1035,6 @@ describe("humanizeMCPToolName", () => { }); 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;