feat: label process_output rows with the process command (#28300)

This commit is contained in:
Danielle Maywood
2026-08-20 11:56:14 +01:00
committed by GitHub
parent eed420a0eb
commit 4a7a0a3348
25 changed files with 460 additions and 123 deletions
+1
View File
@@ -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,
})
}
+20
View File
@@ -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()
+8 -1
View File
@@ -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 <command>\" and \"for <duration>\" 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 \"<intent> in the background using <command>\", 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)
+97 -1
View File
@@ -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)
+1
View File
@@ -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
@@ -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<ScrollAreaProps> = ({
scrollThumbClassName,
viewportClassName,
viewportTabIndex,
viewportAriaLabel,
orientation = "vertical",
children,
...props
@@ -35,6 +37,8 @@ export const ScrollArea: React.FC<ScrollAreaProps> = ({
>
<ScrollAreaPrimitive.Viewport
tabIndex={viewportTabIndex}
role={viewportAriaLabel ? "region" : undefined}
aria-label={viewportAriaLabel}
className={cn("h-full w-full rounded-[inherit]", viewportClassName)}
>
{children}
@@ -67,6 +67,8 @@ export const AdvisorTool: React.FC<AdvisorToolProps> = ({
<ScrollArea
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"
>
<div className="space-y-2 px-3 py-2">
@@ -43,6 +43,8 @@ export const ChatSummarizedTool: React.FC<{
<ScrollArea
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"
>
<div className="px-3 py-2">
@@ -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"
>
<FileDiff
@@ -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";
@@ -59,7 +59,7 @@ export const ExecuteTool: React.FC<ExecuteToolProps> = ({
? "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<ExecuteToolProps> = ({
durationLabel,
isRunning,
isError,
isBackgrounded,
});
const defaultView = resolveAgentDisplayState(
shellToolDisplayMode,
@@ -76,7 +77,7 @@ export const ExecuteTool: React.FC<ExecuteToolProps> = ({
return (
<ToolCall.Root
key={`${shellToolDisplayMode ?? "auto"}:${autoDisplayState}`}
className="group/exec grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 rounded-md bg-surface-primary font-sans font-normal text-xs leading-5"
className="group/exec grid w-full grid-cols-[minmax(0,1fr)_auto] items-start rounded-md bg-surface-primary font-sans font-normal text-xs leading-5"
status={status}
isError={isError}
errorMessage={errorText || "Command failed"}
@@ -101,20 +102,6 @@ export const ExecuteTool: React.FC<ExecuteToolProps> = ({
<ToolCall.Chevron />
</ToolCall.HeaderButton>
<ToolCall.HeaderActions>
{isBackgrounded && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label="Running in background"
role="img"
className="flex shrink-0 text-content-secondary"
>
<LayersIcon aria-hidden className="size-3.5 shrink-0" />
</span>
</TooltipTrigger>
<TooltipContent>Running in background</TooltipContent>
</Tooltip>
)}
{killedBySignal && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
@@ -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<{
<ScrollArea
className="col-start-1 col-span-2 mt-2 rounded-xl bg-surface-secondary/60 text-2xs"
viewportClassName="max-h-64"
viewportTabIndex={0}
viewportAriaLabel="Command output"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2.5">
@@ -74,6 +74,8 @@ const ListSubagentModelsContent: React.FC<{ models: unknown[] }> = ({
<ScrollArea
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"
>
<div className="px-1 py-1">
@@ -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 }) => {
@@ -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;
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}`;
};
export const ProcessOutputTool: React.FC<ProcessOutputToolProps> = (props) => {
const autoDisplayState: AgentDisplayState =
props.output.length > 0 ? "preview" : "collapsed";
const resolvedDisplayState = resolveAgentDisplayState(
props.shellToolDisplayMode,
autoDisplayState,
);
return (
<ProcessOutputToolInner
key={`${props.shellToolDisplayMode ?? "auto"}:${autoDisplayState}`}
{...props}
defaultView={resolvedDisplayState}
outputInitiallyFullyExpanded={isAgentDisplayFullyExpanded(
resolvedDisplayState,
)}
/>
);
};
const ProcessOutputToolInner: React.FC<ProcessOutputToolInnerProps> = ({
export const ProcessOutputTool: React.FC<ProcessOutputToolProps> = ({
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,
);
// 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 [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;
const hasHeaderActions = Boolean(killedBySignal) || isFailed || hasOutput;
return (
<ToolCall.Root
key={`${shellToolDisplayMode ?? "auto"}:${autoDisplayState}`}
className="group/proc w-full"
status={isRunning ? "running" : isError ? "error" : "completed"}
isError={isError}
@@ -95,7 +95,14 @@ const ProcessOutputToolInner: React.FC<ProcessOutputToolInnerProps> = ({
<ToolCall.HeaderLayout>
<ToolCall.HeaderButton>
<ToolCall.LeadingIcon name="process_output" />
<ToolCall.Label>Process output</ToolCall.Label>
<ToolCall.Label>
{getProcessOutputLabel({
command,
modelIntent,
isRunning,
isFailed,
})}
</ToolCall.Label>
<ToolCall.Status />
<ToolCall.Chevron />
</ToolCall.HeaderButton>
@@ -104,14 +111,20 @@ const ProcessOutputToolInner: React.FC<ProcessOutputToolInnerProps> = ({
{killedBySignal && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
<span
aria-label={signalTooltipLabel(killedBySignal)}
role="img"
className="flex shrink-0 items-center text-content-secondary"
>
<OctagonXIcon aria-hidden className="size-3.5 shrink-0" />
</span>
</TooltipTrigger>
<TooltipContent>
{signalTooltipLabel(killedBySignal)}
</TooltipContent>
</Tooltip>
)}
{showExitCode && (
{isFailed && (
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive">
exit {exitCode}
</span>
@@ -128,45 +141,21 @@ const ProcessOutputToolInner: React.FC<ProcessOutputToolInnerProps> = ({
</ToolCall.HeaderLayout>
<ToolCall.Content>
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName={outputFullyExpanded ? "max-h-64" : ""}
className="mt-2 rounded-xl bg-surface-secondary/60 text-2xs"
viewportClassName="max-h-64"
viewportTabIndex={0}
viewportAriaLabel="Process output"
scrollBarClassName="w-1.5"
>
<pre
ref={measureRef}
style={
outputFullyExpanded
? undefined
: { maxHeight: COLLAPSED_OUTPUT_HEIGHT, overflow: "hidden" }
}
className={cn(
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-3 py-2 font-mono text-xs",
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-3 py-2.5 font-mono text-xs leading-5",
isError ? "text-content-destructive" : "text-content-secondary",
)}
>
{output}
</pre>
</ScrollArea>
{overflows && (
<button
type="button"
aria-expanded={outputFullyExpanded}
onClick={toggleOutputExpansion}
className="border-0 bg-transparent m-0 mt-0.5 font-[inherit] text-[inherit] flex w-full cursor-pointer items-center justify-center rounded-md py-0.5 text-content-secondary transition-colors hover:bg-surface-secondary hover:text-content-primary"
aria-label={
outputFullyExpanded
? "Collapse full process output"
: "Expand full process output"
}
>
<ChevronDownIcon
className={cn(
"size-3 transition-transform",
outputFullyExpanded && "rotate-180",
)}
/>
</button>
)}
</ToolCall.Content>
</ToolCall.Root>
);
@@ -23,6 +23,8 @@ const ReadFileContent: React.FC<{
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
viewportTabIndex={0}
viewportAriaLabel={`Contents of ${path}`}
orientation="both"
scrollBarClassName="w-1.5"
horizontalScrollBarClassName="h-1.5"
@@ -31,6 +31,8 @@ export const ReadSkillTool: React.FC<{
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default"
viewportClassName="max-h-64"
viewportTabIndex={0}
viewportAriaLabel="Skill contents"
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2">
@@ -279,6 +279,8 @@ export const SubagentTool: React.FC<{
<ScrollArea
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"
>
<div className="px-3 py-2">
@@ -291,6 +293,8 @@ export const SubagentTool: React.FC<{
<ScrollArea
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"
>
<div className="px-3 py-2">
@@ -303,6 +307,8 @@ export const SubagentTool: React.FC<{
<ScrollArea
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"
>
<div className="px-3 py-2">
@@ -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();
},
};
@@ -247,19 +247,28 @@ const ProcessOutputRenderer: FC<ToolRendererProps> = ({
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 (
<ProcessOutputTool
output={output}
isRunning={status === "running"}
command={command || undefined}
modelIntent={modelIntent}
isRunning={status === "running" || processRunning}
exitCode={exitCode}
isError={isError}
errorMessage={errorMessage || undefined}
@@ -836,6 +845,8 @@ const ToolFileViewer: FC<ToolFileViewerProps> = ({ label, file, options }) => (
<ScrollArea
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"
@@ -1,4 +1,5 @@
import {
ActivityIcon,
BadgeQuestionMarkIcon,
BotIcon,
CompassIcon,
@@ -26,7 +27,7 @@ import { cn } from "#/utils/cn";
export const toolIcons: Partial<Record<string, LucideIcon>> = {
execute: TerminalIcon,
process_output: TerminalIcon,
process_output: ActivityIcon,
process_list: TerminalIcon,
process_signal: TerminalIcon,
read_file: FileTextIcon,
@@ -160,6 +160,8 @@ export const WorkspaceBuildLogSection: FC<WorkspaceBuildLogSectionProps> = ({
<ScrollArea
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"
>
<WorkspaceBuildLogs
@@ -76,6 +76,8 @@ export const WriteFileTool: React.FC<{
? "max-h-[80vh]"
: "max-h-64"
}
viewportTabIndex={0}
viewportAriaLabel={`Diff of ${path}`}
scrollBarClassName="w-1.5"
>
<FileDiff
@@ -14,6 +14,7 @@ describe("toolVisibility", () => {
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" },
@@ -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,
@@ -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);
});
@@ -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;