diff --git a/coderd/x/chatd/chattool/execute.go b/coderd/x/chatd/chattool/execute.go index e5d8033183..76acb36d95 100644 --- a/coderd/x/chatd/chattool/execute.go +++ b/coderd/x/chatd/chattool/execute.go @@ -78,9 +78,10 @@ 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."` + 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\"."` 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."` + 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."` } // Execute returns an AgentTool that runs a shell command in the diff --git a/coderd/x/chatd/chattool/execute_test.go b/coderd/x/chatd/chattool/execute_test.go index 8750b63c7c..fefbd90a12 100644 --- a/coderd/x/chatd/chattool/execute_test.go +++ b/coderd/x/chatd/chattool/execute_test.go @@ -20,6 +20,20 @@ import ( func TestExecuteTool(t *testing.T) { t.Parallel() + t.Run("SchemaIncludesOptionalModelIntent", func(t *testing.T) { + t.Parallel() + + tool := chattool.Execute(chattool.ExecuteOptions{}) + info := tool.Info() + modelIntentParam, ok := info.Parameters["model_intent"].(map[string]any) + 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, info.Required, "command") + assert.NotContains(t, info.Required, "model_intent") + }) + t.Run("EmptyCommand", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) @@ -203,6 +217,54 @@ func TestExecuteTool(t *testing.T) { assert.Equal(t, "true", capturedReq.Env["CODER_CHAT_AGENT"]) }) + t.Run("ModelIntentIgnoredByExecution", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockConn := agentconnmock.NewMockAgentConn(ctrl) + + var capturedReq workspacesdk.StartProcessRequest + mockConn.EXPECT(). + StartProcess(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, req workspacesdk.StartProcessRequest) (workspacesdk.StartProcessResponse, error) { + capturedReq = req + return workspacesdk.StartProcessResponse{ID: "proc-1"}, nil + }) + exitCode := 0 + mockConn.EXPECT(). + ProcessOutput(gomock.Any(), "proc-1", gomock.Any()). + Return(workspacesdk.ProcessOutputResponse{ + Running: false, + ExitCode: &exitCode, + Output: "hello world", + }, 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":"echo hello","model_intent":"Running a smoke test"}`, + }) + require.NoError(t, err) + assert.False(t, resp.IsError) + assert.Equal(t, "echo hello", capturedReq.Command) + assert.False(t, capturedReq.Background) + + var parsedArgs chattool.ExecuteArgs + require.NoError(t, json.Unmarshal([]byte(`{"command":"echo hello","model_intent":"Running a smoke test"}`), &parsedArgs)) + require.NotNil(t, parsedArgs.ModelIntent) + assert.Equal(t, "Running a smoke test", *parsedArgs.ModelIntent) + + var result chattool.ExecuteResult + require.NoError(t, json.Unmarshal([]byte(resp.Content), &result)) + assert.True(t, result.Success) + assert.Equal(t, "hello world", result.Output) + + var resultMap map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Content), &resultMap)) + assert.NotContains(t, resultMap, "model_intent") + }) + t.Run("ForegroundNonZeroExit", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx index 6620a39229..22d5ced009 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx @@ -27,6 +27,7 @@ import { } from "./displayMode"; import { formatShellDurationMs, + sanitizeExecuteModelIntent, signalTooltipLabel, type ToolStatus, } from "./utils"; @@ -39,6 +40,7 @@ type ExecuteToolProps = { durationMs?: number; isBackgrounded?: boolean; killedBySignal?: "kill" | "terminate"; + modelIntent?: string; shellToolDisplayMode?: TypesGen.AgentDisplayMode; }; @@ -75,6 +77,7 @@ const ExecuteToolInner: React.FC = ({ durationMs, isBackgrounded = false, killedBySignal, + modelIntent, outputInitiallyOpen, }) => { const hasCommand = command.trim().length > 0; @@ -99,6 +102,7 @@ const ExecuteToolInner: React.FC = ({ > @@ -167,17 +171,26 @@ const ExecuteToolInner: React.FC = ({ const ShellCommandLine: React.FC<{ command: string; + modelIntent?: string; durationLabel: string; expanded?: boolean; -}> = ({ command, durationLabel, expanded }) => { +}> = ({ command, modelIntent, durationLabel, expanded }) => { + const intentLabel = sanitizeExecuteModelIntent(modelIntent, command); return ( <> - Ran {command} + {intentLabel ? ( + <> + {intentLabel} using{" "} + {command} + + ) : ( + <>Ran {command} + )} {durationLabel && ( - {durationLabel} + {intentLabel ? ` for ${durationLabel}` : durationLabel} )} {expanded !== undefined && ( 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 802d114658..2ecae66679 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -14,6 +14,7 @@ import { DesktopPanelContext } from "./DesktopPanelContext"; import { Tool } from "./Tool"; const executeCommand = "git fetch origin"; +const executeIntentCommand = "npm test"; const longExecuteCommand = "docker build --no-cache --build-arg NODE_ENV=production --build-arg API_URL=https://coder.example.com/api --build-arg SENTRY_DSN=https://example.com/sentry --build-arg FEATURE_FLAGS=agents,shell-tools --tag coder-agent:latest ."; const meta: Meta = { @@ -54,6 +55,79 @@ export const ExecuteRunning: Story = { }, }; +export const ExecuteModelIntent: Story = { + args: { + status: "completed", + args: { + command: executeIntentCommand, + model_intent: "Running tests using npm for 5s", + }, + modelIntent: "Running tests using npm for 5s", + result: { + output: "", + wall_duration_ms: 2300, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const commandButton = canvas.getByRole("button", { + name: "Expand command", + }); + expect(commandButton).toHaveTextContent( + `Running tests using ${executeIntentCommand} for 2.3s`, + ); + expect(commandButton).not.toHaveTextContent("Ran"); + }, +}; + +export const ExecuteModelIntentRunning: Story = { + args: { + status: "running", + args: { + command: executeCommand, + model_intent: "checking repository state", + }, + modelIntent: "checking repository state", + result: { + output: "", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const commandButton = canvas.getByRole("button", { + name: "Collapse command", + }); + expect(commandButton).toHaveTextContent( + `Checking repository state using ${executeCommand}`, + ); + expect(commandButton).not.toHaveTextContent(" for "); + expect(commandButton).not.toHaveTextContent("Ran"); + }, +}; + +export const ExecuteModelIntentLeadingUsing: Story = { + args: { + status: "completed", + args: { + command: executeCommand, + model_intent: "using git fetch origin", + }, + modelIntent: "using git fetch origin", + result: { + output: "", + wall_duration_ms: 2300, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const commandButton = canvas.getByRole("button", { + name: "Expand command", + }); + expect(commandButton).toHaveTextContent(`Ran ${executeCommand}2.3s`); + expect(commandButton).not.toHaveTextContent("using git fetch origin using"); + }, +}; + export const ExecuteSuccess: Story = { args: { shellToolDisplayMode: "auto", @@ -1950,7 +2024,7 @@ export const WaitAgentComputerUseRunning: Story = { expect(canvasElement.querySelector(".lucide-monitor")).not.toBeNull(); // The VNC preview container should mount (the connection will // stay in "connecting" state without a real WebSocket, which - // is expected — we only verify the container renders). + // is expected; we only verify the container renders). await waitFor(() => { expect( canvas.getByRole("button", { name: "Open desktop tab" }), diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index dffc5df360..017995a820 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -48,6 +48,7 @@ import { asString, buildEditDiff, DIFFS_FONT_STYLE, + formatModelIntentLabel, formatResultOutput, getFileContentForViewer, getFileViewerOptions, @@ -267,6 +268,7 @@ const ExecuteRenderer: FC = ({ result, isError, killedBySignal, + modelIntent, shellToolDisplayMode, }) => { const data = getExecuteRenderData(args, result); @@ -294,6 +296,7 @@ const ExecuteRenderer: FC = ({ durationMs={data.durationMs} isBackgrounded={data.isBackgrounded} killedBySignal={killedBySignal} + modelIntent={modelIntent} shellToolDisplayMode={shellToolDisplayMode} /> ); @@ -922,7 +925,7 @@ const GenericToolRenderer: FC = ({ /> {modelIntent ? ( - {modelIntent.charAt(0).toUpperCase() + modelIntent.slice(1)} + {formatModelIntentLabel(modelIntent)} ) : ( = ({ }; // --------------------------------------------------------------------------- -// process_signal — thin wrapper that promotes soft failures (success=false +// process_signal promotes soft failures (success=false // in the result body, isError=false at protocol level) so the generic // renderer shows the error indicator and tooltip. // --------------------------------------------------------------------------- @@ -1035,7 +1038,7 @@ const StartWorkspaceRenderer: FC = ({ }; // --------------------------------------------------------------------------- -// Renderer lookup map — maps tool names to their specialized renderers. +// Renderer lookup map for tool names and specialized renderers. // --------------------------------------------------------------------------- const toolRenderers: Record> = { @@ -1060,7 +1063,7 @@ const toolRenderers: Record> = { }; // --------------------------------------------------------------------------- -// Public Tool component — single wrapper div + map dispatch. +// Public Tool component with a single wrapper div and map dispatch. // --------------------------------------------------------------------------- export const Tool = memo( 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 09be688333..6427e23c2b 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts @@ -7,6 +7,7 @@ import { DIFFS_FONT_STYLE, diffViewerCSS, fileViewerCSS, + formatModelIntentLabel, formatResultOutput, formatShellDurationMs, getDiffViewerOptions, @@ -24,11 +25,64 @@ import { parseEditFilesArgs, parseServerEditDiffText, parseServerEditResults, + sanitizeExecuteModelIntent, shortDurationMs, stripSvnIndexHeaders, toProviderLabel, } from "./utils"; +describe("formatModelIntentLabel", () => { + it("returns empty string for empty values", () => { + expect(formatModelIntentLabel(undefined)).toBe(""); + expect(formatModelIntentLabel("")).toBe(""); + expect(formatModelIntentLabel(" ")).toBe(""); + }); + + it("trims and capitalizes labels", () => { + expect(formatModelIntentLabel("checking repository state")).toBe( + "Checking repository state", + ); + expect(formatModelIntentLabel(" a")).toBe("A"); + expect(formatModelIntentLabel("Running tests")).toBe("Running tests"); + }); +}); + +describe("sanitizeExecuteModelIntent", () => { + it("strips redundant command and duration suffixes", () => { + expect( + sanitizeExecuteModelIntent("Running tests using npm for 5s", "npm test"), + ).toBe("Running tests"); + expect( + sanitizeExecuteModelIntent( + "checking status using git fetch origin", + "git fetch origin", + ), + ).toBe("Checking status"); + }); + + it("strips trailing durations without command suffixes", () => { + expect( + sanitizeExecuteModelIntent("Running tests for 2.5s", "npm test"), + ).toBe("Running tests"); + expect(sanitizeExecuteModelIntent("for 5s", "npm test")).toBe(""); + }); + + it("strips leading using only when it references the command", () => { + expect( + sanitizeExecuteModelIntent("using git fetch origin", "git fetch origin"), + ).toBe(""); + expect( + sanitizeExecuteModelIntent("Using environment variables", "npm test"), + ).toBe("Using environment variables"); + }); + + it("preserves using when it is not followed by a command reference", () => { + expect( + sanitizeExecuteModelIntent("Testing using mock data", "npm test"), + ).toBe("Testing using mock data"); + }); +}); + describe("toProviderLabel", () => { it("returns displayName when provided", () => { expect(toProviderLabel("GitHub", "gh-id", "oauth")).toBe("GitHub"); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts index 87d80e6891..d1193b1c09 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts @@ -26,6 +26,65 @@ const fileEntrySchema = Yup.object({ type FileEntry = Yup.InferType; +export const formatModelIntentLabel = ( + modelIntent: string | undefined, +): string => { + const trimmed = modelIntent?.trim() ?? ""; + if (!trimmed) { + return ""; + } + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); +}; + +const trailingDurationPattern = + /(^|\s+)for\s+\d+(?:\.\d+)?\s*(?:ms|s|m|h)\s*$/i; + +export const sanitizeExecuteModelIntent = ( + modelIntent: string | undefined, + command: string, +): string => { + const label = formatModelIntentLabel(modelIntent); + const withoutCommand = stripRedundantUsingSuffix(label, command); + return stripTrailingDuration(withoutCommand); +}; + +const stripRedundantUsingSuffix = (label: string, command: string): string => { + const usingMatches = Array.from(label.matchAll(/(^|\s+)using\s+/gi)); + for (let i = usingMatches.length - 1; i >= 0; i--) { + const match = usingMatches[i]; + if (match.index === undefined) { + continue; + } + + const suffix = stripTrailingDuration( + label.slice(match.index + match[0].length), + ); + if (isCommandReference(suffix, command)) { + return label.slice(0, match.index).trim(); + } + } + return label; +}; + +const stripTrailingDuration = (label: string): string => + label.replace(trailingDurationPattern, "").trim(); + +const isCommandReference = (value: string, command: string): boolean => { + const normalizedValue = normalizeCommandReference(value); + const normalizedCommand = normalizeCommandReference(command); + if (!normalizedValue || !normalizedCommand) { + return false; + } + return ( + normalizedValue === normalizedCommand || + normalizedCommand.startsWith(`${normalizedValue} `) || + normalizedValue.startsWith(`${normalizedCommand} `) + ); +}; + +const normalizeCommandReference = (value: string): string => + value.trim().toLowerCase().replace(/\s+/g, " "); + export const toProviderLabel = ( providerDisplayName: string, providerID: string,