mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
feat: label process_output rows with the process command (#28300)
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user