Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b25f791
feat(chatd): label process_output rows with the process command
DanielleMaywood Aug 19, 2026
777b875
feat(chatd): accept model_intent on process_output
DanielleMaywood Aug 19, 2026
2b8ad3c
chore(codersdk/workspacesdk): format mock with gci
DanielleMaywood Aug 19, 2026
ce0b46d
feat(site): differentiate background-process rows in chat transcripts
DanielleMaywood Aug 19, 2026
6e31c63
fix(site): quiet success states and align shell tool actions
DanielleMaywood Aug 19, 2026
a273f33
fix(site): observe background process state from process_list too
DanielleMaywood Aug 19, 2026
4878a98
fix(site): align execute row actions with other tool rows
DanielleMaywood Aug 19, 2026
3b10563
refactor(site): drop the background process status chip
DanielleMaywood Aug 19, 2026
d344b1c
feat(site): frame backgrounded execute labels and simplify intent rows
DanielleMaywood Aug 19, 2026
e87ab3c
fix(chatd): report running state in process_output results
DanielleMaywood Aug 19, 2026
b9b5820
fix(site): let a later kill override a stale running snapshot
DanielleMaywood Aug 19, 2026
29b7315
style(site): format Tool.stories.tsx
DanielleMaywood Aug 19, 2026
356c8b6
fix(site): give the process_output kill icon an accessible name
DanielleMaywood Aug 19, 2026
3a07711
fix(chatd): distinguish intentional background launches from timeouts
DanielleMaywood Aug 19, 2026
4ff5319
fix(site): read legacy background launches from call args
DanielleMaywood Aug 19, 2026
221d924
chore: drop self-evident comments
DanielleMaywood Aug 19, 2026
16fcf29
fix(site): recognize legacy trailing-ampersand background launches
DanielleMaywood Aug 19, 2026
ae3fa97
style: realign struct fields after comment removal
DanielleMaywood Aug 19, 2026
3672b2c
fix(site): let only SIGKILL override a stale running snapshot
DanielleMaywood Aug 19, 2026
919e7ae
fix(site): require a process ID before honoring legacy background args
DanielleMaywood Aug 19, 2026
fa1ec43
fix(site): make tool output scroll areas keyboard-focusable
DanielleMaywood Aug 20, 2026
e364060
feat(site): name focusable tool output regions for assistive tech
DanielleMaywood Aug 20, 2026
990465d
style: group ExecuteResult fields, revert stray whitespace change
DanielleMaywood Aug 20, 2026
cff3316
test(site): trim process_output stories to their distinct behaviors
DanielleMaywood Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agent/agentproc/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}

Expand Down
20 changes: 20 additions & 0 deletions agent/agentproc/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
9 changes: 8 additions & 1 deletion coderd/x/chatd/chattool/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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."`
Expand Down Expand Up @@ -200,6 +203,7 @@ func executeBackground(
result := ExecuteResult{
Success: true,
BackgroundProcessID: resp.ID,
Backgrounded: true,
}
data, err := json.Marshal(result)
if err != nil {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
98 changes: 97 additions & 1 deletion coderd/x/chatd/chattool/execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions codersdk/workspacesdk/agentconn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions site/src/components/ScrollArea/ScrollArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand All @@ -24,6 +25,7 @@ export const ScrollArea: React.FC<ScrollAreaProps> = ({
scrollThumbClassName,
viewportClassName,
viewportTabIndex,
viewportAriaLabel,
orientation = "vertical",
children,
...props
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -59,14 +59,15 @@ 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,
parsedCommands,
durationLabel,
isRunning,
isError,
isBackgrounded,
});
const defaultView = resolveAgentDisplayState(
shellToolDisplayMode,
Expand All @@ -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"}
Expand All @@ -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>
Expand Down Expand Up @@ -150,6 +137,7 @@ type ShellCommandLineInput = {
durationLabel: string;
isRunning: boolean;
isError: boolean;
isBackgrounded: boolean;
};

const getShellCommandLine = ({
Expand All @@ -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}`;
Comment thread
DanielleMaywood marked this conversation as resolved.
} else if (isBackgrounded) {
commandLabel = `Started ${commandDisplay} in the background`;
Comment thread
DanielleMaywood marked this conversation as resolved.
}
if (!isRunning && isError) {
commandLabel = `Failed to run ${commandDisplay}`;
}
Expand All @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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 }) => {
Expand Down
Loading
Loading