feat: parse execute tool commands and render them in the chat UI (#25478)

When the execute tool runs a chained shell command, the UI previously
rendered the raw string. Long chains like "cd /repo && git pull &&
git add . && git commit -m fix" were hard to scan.

A new ChatMessagePart.ParsedCommands [][]string field on tool-call
parts carries one entry per simple command, parsed in chatd from args
via mvdan.cc/sh/v3/syntax. The frontend renders the joined list ("cd,
git pull, git add, git commit") in place of the raw command, and falls
back to the raw command when the field is absent.

Closes CODAGT-446
This commit is contained in:
Mathias Fredriksson
2026-05-21 11:12:34 +03:00
committed by GitHub
parent ec1e861152
commit f1b772928d
24 changed files with 749 additions and 22 deletions
+10
View File
@@ -16824,6 +16824,16 @@ const docTemplate = `{
"name": { "name": {
"type": "string" "type": "string"
}, },
"parsed_commands": {
"description": "ParsedCommands holds parsed programs from an execute tool call's\nshell command, one entry per simple command in source order. Each\nentry is [program] or [program, arg] where arg is the first non-flag\npositional argument. Only populated when ToolName is \"execute\" and\nthe command parses successfully; nil otherwise.",
"type": "array",
"items": {
"type": "array",
"items": {
"type": "string"
}
}
},
"provider_executed": { "provider_executed": {
"description": "ProviderExecuted indicates the tool call was executed by\nthe provider (e.g. Anthropic computer use).", "description": "ProviderExecuted indicates the tool call was executed by\nthe provider (e.g. Anthropic computer use).",
"type": "boolean" "type": "boolean"
+10
View File
@@ -15168,6 +15168,16 @@
"name": { "name": {
"type": "string" "type": "string"
}, },
"parsed_commands": {
"description": "ParsedCommands holds parsed programs from an execute tool call's\nshell command, one entry per simple command in source order. Each\nentry is [program] or [program, arg] where arg is the first non-flag\npositional argument. Only populated when ToolName is \"execute\" and\nthe command parses successfully; nil otherwise.",
"type": "array",
"items": {
"type": "array",
"items": {
"type": "string"
}
}
},
"provider_executed": { "provider_executed": {
"description": "ProviderExecuted indicates the tool call was executed by\nthe provider (e.g. Anthropic computer use).", "description": "ProviderExecuted indicates the tool call was executed by\nthe provider (e.g. Anthropic computer use).",
"type": "boolean" "type": "boolean"
+98
View File
@@ -0,0 +1,98 @@
// Package shellparse extracts command steps from shell scripts.
package shellparse
import (
"strings"
"mvdan.cc/sh/v3/syntax"
)
// Parse returns one slice per simple command in src, in source order.
// Each is [program] or [program, arg], where arg is the first non-flag
// positional argument.
//
// Some malformed inputs (e.g. trailing unterminated tokens after valid
// semicolon-separated commands) yield partial results alongside a
// non-nil error. Callers that show parsed output to users should treat
// a non-nil err as a signal to fall back to the raw input rather than
// display the partial.
func Parse(src string) ([][]string, error) {
if src == "" {
return nil, nil
}
f, err := syntax.NewParser().Parse(strings.NewReader(src), "")
if f == nil {
return nil, err
}
var out [][]string
syntax.Walk(f, func(node syntax.Node) bool {
call, ok := node.(*syntax.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
prog := wordLiteral(call.Args[0])
if prog == "" {
return true
}
step := []string{prog}
if arg := firstNonFlagLiteral(call.Args[1:]); arg != "" {
step = append(step, arg)
}
out = append(out, step)
return true
})
return out, err
}
// wordLiteral returns the literal content of w by concatenating the
// literal pieces of its parts. Bare literals, single-quoted strings,
// and double-quoted strings (when the inner parts are all literals)
// contribute their text. Any part involving variable expansion,
// command substitution, or arithmetic returns "" for the whole word
// because we cannot resolve those without executing the shell.
func wordLiteral(w *syntax.Word) string {
if w == nil {
return ""
}
var sb strings.Builder
for _, part := range w.Parts {
switch p := part.(type) {
case *syntax.Lit:
_, _ = sb.WriteString(p.Value)
case *syntax.SglQuoted:
_, _ = sb.WriteString(p.Value)
case *syntax.DblQuoted:
for _, inner := range p.Parts {
lit, ok := inner.(*syntax.Lit)
if !ok {
return ""
}
_, _ = sb.WriteString(lit.Value)
}
default:
return ""
}
}
return sb.String()
}
// firstNonFlagLiteral returns the literal value of the first word in
// ws that does not start with "-", or "" if none qualifies.
//
// Known limitation: no flag-arity knowledge. For programs whose global
// flags take a separate-word value ("git -C path verb", "kubectl -n ns
// verb", "docker --context X verb"), this returns the flag's value as
// the first positional, not the actual verb. Consumers that need the
// verb in those cases need per-program awareness; this function does
// not provide it.
func firstNonFlagLiteral(ws []*syntax.Word) string {
for _, w := range ws {
lit := wordLiteral(w)
if lit == "" || strings.HasPrefix(lit, "-") {
continue
}
return lit
}
return ""
}
+148
View File
@@ -0,0 +1,148 @@
package shellparse_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/util/shellparse"
)
func TestParse(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want [][]string
}{
{
name: "chained-git-workflow",
in: `cd /path && git pull && git add . && git commit -m "x"`,
want: [][]string{{"cd", "/path"}, {"git", "pull"}, {"git", "add"}, {"git", "commit"}},
},
{
name: "single-command-with-flags",
in: `ls -la /tmp`,
want: [][]string{{"ls", "/tmp"}},
},
{
name: "no-arg",
in: `pwd`,
want: [][]string{{"pwd"}},
},
{
name: "find-xargs-grep-pipeline",
in: `find /repo -type f | xargs grep "foo" 2>/dev/null | grep -i "bar" | head -30`,
want: [][]string{{"find", "/repo"}, {"xargs", "grep"}, {"grep", "bar"}, {"head"}},
},
{
name: "stash-build-pop-exit",
// "ES=$?" is a pure assignment; not a command.
in: `cd /repo && git stash && go build ./... 2>&1; ES=$?; git stash pop 2>&1 | tail -1; exit $ES`,
want: [][]string{
{"cd", "/repo"},
{"git", "stash"},
{"go", "build"},
{"git", "stash"},
{"tail"},
{"exit"},
},
},
{
name: "command-substitution-and-if",
in: `cd /repo && TOKEN=$(cat /tmp/tok || echo "") && if [ -n "$TOKEN" ]; then echo "$TOKEN" | gh auth login --with-token; else echo "missing"; fi`,
want: [][]string{
{"cd", "/repo"},
{"cat", "/tmp/tok"},
{"echo"},
{"[", "]"},
{"echo"},
{"gh", "auth"},
{"echo", "missing"},
},
},
{
name: "for-loop-with-sed",
in: `cd /repo && for line in 1 2 3; do
sed -i "${line}s|a|b|" file
done`,
want: [][]string{{"cd", "/repo"}, {"sed", "file"}},
},
{
name: "subshell-and-brace-group",
in: `(cd /tmp && ls) && { echo a; echo b; }`,
want: [][]string{{"cd", "/tmp"}, {"ls"}, {"echo", "a"}, {"echo", "b"}},
},
{
name: "variable-program-not-literal",
in: `$cmd --help && echo done`,
want: [][]string{{"echo", "done"}},
},
{
name: "double-quoted-positional",
in: `cd "/repo with spaces"`,
want: [][]string{{"cd", "/repo with spaces"}},
},
{
name: "single-quoted-positional",
in: `grep 'fix bug'`,
want: [][]string{{"grep", "fix bug"}},
},
{
name: "quoted-program-name",
in: `"/usr/bin/git" pull`,
want: [][]string{{"/usr/bin/git", "pull"}},
},
{
name: "double-quoted-with-variable-expansion-skipped",
in: `echo "hello $name"`,
// The quoted word contains a parameter expansion, so the
// parser cannot extract a literal; only the program survives.
want: [][]string{{"echo"}},
},
{
name: "empty",
in: ``,
want: nil,
},
{
name: "comment-only",
in: `# just a comment`,
want: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := shellparse.Parse(tc.in)
require.NoError(t, err, "parse failed for %q", tc.in)
assert.Equal(t, tc.want, got, "input: %q", tc.in)
})
}
}
func TestParse_ParseError(t *testing.T) {
t.Parallel()
t.Run("unterminated-string-no-results", func(t *testing.T) {
t.Parallel()
cmds, err := shellparse.Parse(`echo "unterminated`)
require.Error(t, err)
require.Nil(t, cmds)
})
t.Run("semicolon-prefix-yields-partial-results-plus-error", func(t *testing.T) {
t.Parallel()
// Some malformed inputs (e.g. trailing unterminated tokens after
// valid semicolon-separated commands) yield partial results
// alongside a non-nil error. Pin both sides of the contract so
// future mvdan.cc/sh upgrades that change partial-parse behavior
// fail this test loudly.
cmds, err := shellparse.Parse(`ls; cat; echo "unterminated`)
require.Error(t, err)
require.Equal(t, [][]string{{"ls"}, {"cat"}}, cmds)
})
}
+24 -2
View File
@@ -17,6 +17,7 @@ import (
"cdr.dev/slog/v3" "cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/util/shellparse"
"github.com/coder/coder/v2/coderd/x/chatd/chatsanitize" "github.com/coder/coder/v2/coderd/x/chatd/chatsanitize"
"github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
@@ -779,20 +780,24 @@ func sdkPartFromContent(
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata), ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
} }
case fantasy.ToolCallContent: case fantasy.ToolCallContent:
args := safeToolCallArgs(value.Input)
return codersdk.ChatMessagePart{ return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall, Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: value.ToolCallID, ToolCallID: value.ToolCallID,
ToolName: value.ToolName, ToolName: value.ToolName,
Args: safeToolCallArgs(value.Input), Args: args,
ParsedCommands: executeToolParsedCommands(value.ToolName, args),
ProviderExecuted: value.ProviderExecuted, ProviderExecuted: value.ProviderExecuted,
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata), ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
} }
case *fantasy.ToolCallContent: case *fantasy.ToolCallContent:
args := safeToolCallArgs(value.Input)
return codersdk.ChatMessagePart{ return codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeToolCall, Type: codersdk.ChatMessagePartTypeToolCall,
ToolCallID: value.ToolCallID, ToolCallID: value.ToolCallID,
ToolName: value.ToolName, ToolName: value.ToolName,
Args: safeToolCallArgs(value.Input), Args: args,
ParsedCommands: executeToolParsedCommands(value.ToolName, args),
ProviderExecuted: value.ProviderExecuted, ProviderExecuted: value.ProviderExecuted,
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata), ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
} }
@@ -1270,6 +1275,23 @@ func safeToolCallArgs(input string) json.RawMessage {
return raw return raw
} }
func executeToolParsedCommands(toolName string, args json.RawMessage) [][]string {
if toolName != chattool.ExecuteToolName || len(args) == 0 {
return nil
}
var parsed struct {
Command string `json:"command"`
}
if err := json.Unmarshal(args, &parsed); err != nil || parsed.Command == "" {
return nil
}
steps, err := shellparse.Parse(parsed.Command)
if err != nil {
return nil
}
return steps
}
// TODO: Replace filename-based detection with explicit origin metadata. // TODO: Replace filename-based detection with explicit origin metadata.
func isSyntheticPaste(name string, mediaType string) bool { func isSyntheticPaste(name string, mediaType string) bool {
if !syntheticPasteFileNamePattern.MatchString(name) { if !syntheticPasteFileNamePattern.MatchString(name) {
@@ -3237,3 +3237,71 @@ func TestToolResultContentToPart_UTF8Sanitization(t *testing.T) {
require.Contains(t, media.Text, "done") require.Contains(t, media.Text, "done")
}) })
} }
func TestPartFromContent_ExecuteToolParsedCommands(t *testing.T) {
t.Parallel()
cases := []struct {
name string
toolName string
input string
want [][]string
}{
{
name: "execute-chained-git",
toolName: chattool.ExecuteToolName,
input: `{"command":"cd /repo && git pull && git commit -m fix"}`,
want: [][]string{
{"cd", "/repo"},
{"git", "pull"},
{"git", "commit"},
},
},
{
name: "execute-empty-command",
toolName: chattool.ExecuteToolName,
input: `{"command":""}`,
want: nil,
},
{
name: "execute-no-command-key",
toolName: chattool.ExecuteToolName,
input: `{"other":"x"}`,
want: nil,
},
{
name: "execute-invalid-json-args",
toolName: chattool.ExecuteToolName,
input: `not-json`,
want: nil,
},
{
name: "execute-command-parses-to-error",
toolName: chattool.ExecuteToolName,
// Unterminated double-quoted string fails the shell parser.
// Even if shellparse returns partial results, we expect nil
// here so the UI falls back to the raw command.
input: `{"command":"echo \"unterminated"}`,
want: nil,
},
{
name: "other-tool-ignored",
toolName: "read_file",
input: `{"command":"cd /tmp && ls"}`,
want: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
part := chatprompt.PartFromContent(fantasy.ToolCallContent{
ToolCallID: "call-1",
ToolName: tc.toolName,
Input: tc.input,
})
require.Equal(t, codersdk.ChatMessagePartTypeToolCall, part.Type)
assert.Equal(t, tc.want, part.ParsedCommands)
})
}
}
+4 -1
View File
@@ -84,11 +84,14 @@ type ExecuteArgs struct {
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."`
} }
// ExecuteToolName is the registered name of the execute tool.
const ExecuteToolName = "execute"
// Execute returns an AgentTool that runs a shell command in the // Execute returns an AgentTool that runs a shell command in the
// workspace via the agent HTTP API. // workspace via the agent HTTP API.
func Execute(options ExecuteOptions) fantasy.AgentTool { func Execute(options ExecuteOptions) fantasy.AgentTool {
return fantasy.NewAgentTool( return fantasy.NewAgentTool(
"execute", ExecuteToolName,
"Execute a shell command in the workspace. Runs the command and waits for completion up to the timeout (default 10s, override with the timeout parameter e.g. '30s', '5m'). If the command exceeds the timeout, the response includes a background_process_id; use process_output with that ID to re-attach and wait for the result. Use run_in_background=true for persistent processes (dev servers, file watchers) or when you want to continue other work while the command runs. Never use shell '&' for backgrounding.", "Execute a shell command in the workspace. Runs the command and waits for completion up to the timeout (default 10s, override with the timeout parameter e.g. '30s', '5m'). If the command exceeds the timeout, the response includes a background_process_id; use process_output with that ID to re-attach and wait for the result. Use run_in_background=true for persistent processes (dev servers, file watchers) or when you want to continue other work while the command runs. Never use shell '&' for backgrounding.",
func(ctx context.Context, args ExecuteArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { func(ctx context.Context, args ExecuteArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if options.GetWorkspaceConn == nil { if options.GetWorkspaceConn == nil {
+21 -15
View File
@@ -256,21 +256,27 @@ type ChatMessagePart struct {
MCPServerConfigID uuid.NullUUID `json:"mcp_server_config_id,omitempty" format:"uuid" variants:"tool-call?,tool-result?"` MCPServerConfigID uuid.NullUUID `json:"mcp_server_config_id,omitempty" format:"uuid" variants:"tool-call?,tool-result?"`
Args json.RawMessage `json:"args,omitempty" variants:"tool-call?"` Args json.RawMessage `json:"args,omitempty" variants:"tool-call?"`
ArgsDelta string `json:"args_delta,omitempty" variants:"tool-call?"` ArgsDelta string `json:"args_delta,omitempty" variants:"tool-call?"`
Result json.RawMessage `json:"result,omitempty" variants:"tool-result?"` // ParsedCommands holds parsed programs from an execute tool call's
ResultDelta string `json:"result_delta,omitempty" variants:"tool-result?"` // shell command, one entry per simple command in source order. Each
ResultReset bool `json:"result_reset,omitempty" variants:"tool-result?"` // entry is [program] or [program, arg] where arg is the first non-flag
IsError bool `json:"is_error,omitempty" variants:"tool-result?"` // positional argument. Only populated when ToolName is "execute" and
IsMedia bool `json:"is_media,omitempty" variants:"tool-result?"` // the command parses successfully; nil otherwise.
SourceID string `json:"source_id,omitempty" variants:"source?"` ParsedCommands [][]string `json:"parsed_commands,omitempty" variants:"tool-call?"`
URL string `json:"url" variants:"source"` Result json.RawMessage `json:"result,omitempty" variants:"tool-result?"`
Title string `json:"title,omitempty" variants:"source?"` ResultDelta string `json:"result_delta,omitempty" variants:"tool-result?"`
MediaType string `json:"media_type" variants:"file"` ResultReset bool `json:"result_reset,omitempty" variants:"tool-result?"`
Name string `json:"name,omitempty" variants:"file?"` IsError bool `json:"is_error,omitempty" variants:"tool-result?"`
Data []byte `json:"data,omitempty" variants:"file?"` IsMedia bool `json:"is_media,omitempty" variants:"tool-result?"`
FileID uuid.NullUUID `json:"file_id,omitempty" format:"uuid" variants:"file?"` SourceID string `json:"source_id,omitempty" variants:"source?"`
FileName string `json:"file_name" variants:"file-reference"` URL string `json:"url" variants:"source"`
StartLine int `json:"start_line" variants:"file-reference"` Title string `json:"title,omitempty" variants:"source?"`
EndLine int `json:"end_line" variants:"file-reference"` MediaType string `json:"media_type" variants:"file"`
Name string `json:"name,omitempty" variants:"file?"`
Data []byte `json:"data,omitempty" variants:"file?"`
FileID uuid.NullUUID `json:"file_id,omitempty" format:"uuid" variants:"file?"`
FileName string `json:"file_name" variants:"file-reference"`
StartLine int `json:"start_line" variants:"file-reference"`
EndLine int `json:"end_line" variants:"file-reference"`
// The code content from the diff that was commented on. // The code content from the diff that was commented on.
Content string `json:"content" variants:"file-reference"` Content string `json:"content" variants:"file-reference"`
// ProviderMetadata holds provider-specific response metadata // ProviderMetadata holds provider-specific response metadata
+91
View File
@@ -118,6 +118,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -250,6 +255,7 @@ Status Code **200**
| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | | `»»» valid` | boolean | false | | Valid is true if UUID is not NULL |
| `»» media_type` | string | false | | | | `»» media_type` | string | false | | |
| `»» name` | string | false | | | | `»» name` | string | false | | |
| `»» parsed_commands` | array | false | | Parsed commands holds parsed programs from an execute tool call's shell command, one entry per simple command in source order. Each entry is [program] or [program, arg] where arg is the first non-flag positional argument. Only populated when ToolName is "execute" and the command parses successfully; nil otherwise. |
| `»» provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). | | `»» provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). |
| `»» provider_metadata` | array | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. | | `»» provider_metadata` | array | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. |
| `»» result` | array | false | | | | `»» result` | array | false | | |
@@ -456,6 +462,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -584,6 +595,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -863,6 +879,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -1045,6 +1066,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -1173,6 +1199,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -1436,6 +1467,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -1564,6 +1600,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -1688,6 +1729,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -1766,6 +1812,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -1896,6 +1947,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -1973,6 +2029,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -2101,6 +2162,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -2287,6 +2353,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -2361,6 +2432,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -2424,6 +2500,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -2674,6 +2755,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -2802,6 +2888,11 @@ Experimental: this endpoint is subject to change.
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
+76
View File
@@ -2308,6 +2308,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -2436,6 +2441,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -2889,6 +2899,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -2980,6 +2995,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -3029,6 +3049,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `mcp_server_config_id` | [uuid.NullUUID](#uuidnulluuid) | false | | | | `mcp_server_config_id` | [uuid.NullUUID](#uuidnulluuid) | false | | |
| `media_type` | string | false | | | | `media_type` | string | false | | |
| `name` | string | false | | | | `name` | string | false | | |
| `parsed_commands` | array of array | false | | Parsed commands holds parsed programs from an execute tool call's shell command, one entry per simple command in source order. Each entry is [program] or [program, arg] where arg is the first non-flag positional argument. Only populated when ToolName is "execute" and the command parses successfully; nil otherwise. |
| `provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). | | `provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). |
| `provider_metadata` | array of integer | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. | | `provider_metadata` | array of integer | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. |
| `result` | array of integer | false | | | | `result` | array of integer | false | | |
@@ -3145,6 +3166,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -3223,6 +3249,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -3441,6 +3472,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -3602,6 +3638,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -3676,6 +3717,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -3739,6 +3785,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -3850,6 +3901,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -4063,6 +4119,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -4507,6 +4568,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -4584,6 +4650,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
@@ -7014,6 +7085,11 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
}, },
"media_type": "string", "media_type": "string",
"name": "string", "name": "string",
"parsed_commands": [
[
"string"
]
],
"provider_executed": true, "provider_executed": true,
"provider_metadata": [ "provider_metadata": [
0 0
+1
View File
@@ -522,6 +522,7 @@ require (
github.com/sony/gobreaker/v2 v2.4.0 github.com/sony/gobreaker/v2 v2.4.0
github.com/tidwall/sjson v1.2.5 github.com/tidwall/sjson v1.2.5
gonum.org/v1/gonum v0.17.0 gonum.org/v1/gonum v0.17.0
mvdan.cc/sh/v3 v3.13.1
) )
require ( require (
+2
View File
@@ -1577,6 +1577,8 @@ kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24=
mvdan.cc/gofumpt v0.8.0 h1:nZUCeC2ViFaerTcYKstMmfysj6uhQrA2vJe+2vwGU6k= mvdan.cc/gofumpt v0.8.0 h1:nZUCeC2ViFaerTcYKstMmfysj6uhQrA2vJe+2vwGU6k=
mvdan.cc/gofumpt v0.8.0/go.mod h1:vEYnSzyGPmjvFkqJWtXkh79UwPWP9/HMxQdGEXZHjpg= mvdan.cc/gofumpt v0.8.0/go.mod h1:vEYnSzyGPmjvFkqJWtXkh79UwPWP9/HMxQdGEXZHjpg=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
+8
View File
@@ -2856,6 +2856,14 @@ export interface ChatToolCallPart {
readonly mcp_server_config_id?: string; readonly mcp_server_config_id?: string;
readonly args?: Record<string, string>; readonly args?: Record<string, string>;
readonly args_delta?: string; readonly args_delta?: string;
/**
* ParsedCommands holds parsed programs from an execute tool call's
* shell command, one entry per simple command in source order. Each
* entry is [program] or [program, arg] where arg is the first non-flag
* positional argument. Only populated when ToolName is "execute" and
* the command parses successfully; nil otherwise.
*/
readonly parsed_commands?: readonly string[][];
/** /**
* ProviderExecuted indicates the tool call was executed by * ProviderExecuted indicates the tool call was executed by
* the provider (e.g. Anthropic computer use). * the provider (e.g. Anthropic computer use).
@@ -384,6 +384,7 @@ export const BlockList: FC<{
: undefined : undefined
} }
modelIntent={tool.modelIntent} modelIntent={tool.modelIntent}
parsedCommands={tool.parsedCommands}
/> />
); );
} }
@@ -441,6 +442,7 @@ export const BlockList: FC<{
: undefined : undefined
} }
modelIntent={tool.modelIntent} modelIntent={tool.modelIntent}
parsedCommands={tool.parsedCommands}
/> />
))} ))}
</> </>
@@ -255,6 +255,30 @@ describe("parseMessageContent", () => {
expect(result.blocks).toEqual([{ type: "tool", id: "call-1" }]); expect(result.blocks).toEqual([{ type: "tool", id: "call-1" }]);
}); });
it("propagates parsed_commands from a tool-call part", () => {
const result = parseMessageContent([
{
type: "tool-call",
tool_name: "execute",
tool_call_id: "call-1",
args: { command: "cd /repo && git pull" },
parsed_commands: [
["cd", "/repo"],
["git", "pull"],
],
},
]);
expect(result.toolCalls[0].parsedCommands).toEqual([
["cd", "/repo"],
["git", "pull"],
]);
const merged = mergeTools(result.toolCalls, result.toolResults);
expect(merged[0].parsedCommands).toEqual([
["cd", "/repo"],
["git", "pull"],
]);
});
it("parses a tool-result block", () => { it("parses a tool-result block", () => {
const result = parseMessageContent([ const result = parseMessageContent([
{ {
@@ -109,6 +109,7 @@ export const mergeTools = (
status: result ? (result.isError ? "error" : "completed") : "completed", status: result ? (result.isError ? "error" : "completed") : "completed",
mcpServerConfigId: call.mcpServerConfigId || result?.mcpServerConfigId, mcpServerConfigId: call.mcpServerConfigId || result?.mcpServerConfigId,
modelIntent, modelIntent,
parsedCommands: call.parsedCommands,
}); });
} }
@@ -161,6 +162,7 @@ export const parseMessageContent = (
id, id,
name: part.tool_name || "Tool", name: part.tool_name || "Tool",
args: part.args, args: part.args,
parsedCommands: part.parsed_commands,
mcpServerConfigId: part.mcp_server_config_id, mcpServerConfigId: part.mcp_server_config_id,
}); });
parsed.blocks = ensureToolBlock(parsed.blocks, id); parsed.blocks = ensureToolBlock(parsed.blocks, id);
@@ -105,6 +105,24 @@ describe("applyMessagePartToStreamState", () => {
expect(result!.blocks).toEqual([{ type: "tool", id: "tc-1" }]); expect(result!.blocks).toEqual([{ type: "tool", id: "tc-1" }]);
}); });
it("propagates parsed_commands from a tool-call part", () => {
const result = applyMessagePartToStreamState(null, {
type: "tool-call",
tool_name: "execute",
tool_call_id: "tc-1",
args: { command: "cd /repo && git pull" },
parsed_commands: [
["cd", "/repo"],
["git", "pull"],
],
});
expect(result).not.toBeNull();
expect(result!.toolCalls["tc-1"].parsedCommands).toEqual([
["cd", "/repo"],
["git", "pull"],
]);
});
it("generates fallback tool call ID when missing", () => { it("generates fallback tool call ID when missing", () => {
const result = applyMessagePartToStreamState(null, { const result = applyMessagePartToStreamState(null, {
type: "tool-call", type: "tool-call",
@@ -83,6 +83,7 @@ export const applyMessagePartToStreamState = (
mcpServerConfigId: mcpServerConfigId:
part.mcp_server_config_id || existing?.mcpServerConfigId, part.mcp_server_config_id || existing?.mcpServerConfigId,
modelIntent, modelIntent,
parsedCommands: part.parsed_commands ?? existing?.parsedCommands,
}, },
}, },
}; };
@@ -243,6 +244,7 @@ export const buildStreamTools = (
status: getStreamToolStatus(result), status: getStreamToolStatus(result),
mcpServerConfigId: call.mcpServerConfigId || result?.mcpServerConfigId, mcpServerConfigId: call.mcpServerConfigId || result?.mcpServerConfigId,
modelIntent: call.modelIntent, modelIntent: call.modelIntent,
parsedCommands: call.parsedCommands,
}); });
} }
@@ -5,6 +5,7 @@ export type ParsedToolCall = {
id: string; id: string;
name: string; name: string;
args?: unknown; args?: unknown;
parsedCommands?: readonly string[][];
mcpServerConfigId?: string; mcpServerConfigId?: string;
}; };
@@ -25,6 +26,7 @@ export type MergedTool = {
status: "completed" | "error" | "running"; status: "completed" | "error" | "running";
mcpServerConfigId?: string; mcpServerConfigId?: string;
modelIntent?: string; modelIntent?: string;
parsedCommands?: readonly string[][];
/** Set when a process_signal killed/terminated this process. */ /** Set when a process_signal killed/terminated this process. */
killedBySignal?: "kill" | "terminate"; killedBySignal?: "kill" | "terminate";
}; };
@@ -80,6 +82,7 @@ type StreamToolCall = {
name: string; name: string;
args?: unknown; args?: unknown;
argsRaw?: string; argsRaw?: string;
parsedCommands?: readonly string[][];
mcpServerConfigId?: string; mcpServerConfigId?: string;
modelIntent?: string; modelIntent?: string;
}; };
@@ -126,3 +126,31 @@ export const ErrorOutput: Story = {
].join("\n"), ].join("\n"),
}, },
}; };
/** parsedCommands replaces the raw command in the summary line. */
export const ParsedCommands: Story = {
args: {
command: `cd /repo && git pull && git add . && git commit -m "fix bug"`,
status: "completed",
durationMs: 3200,
parsedCommands: [
["cd", "/repo"],
["git", "pull"],
["git", "add"],
["git", "commit"],
],
},
};
/** parsedCommands paired with modelIntent. */
export const ParsedCommandsWithIntent: Story = {
args: {
command: "cd /repo && go test -race ./coderd/...",
status: "running",
modelIntent: "Running the unit tests",
parsedCommands: [
["cd", "/repo"],
["go", "test"],
],
},
};
@@ -30,6 +30,7 @@ import {
formatShellDurationMs, formatShellDurationMs,
sanitizeExecuteModelIntent, sanitizeExecuteModelIntent,
signalTooltipLabel, signalTooltipLabel,
summarizeParsedCommands,
type ToolStatus, type ToolStatus,
} from "./utils"; } from "./utils";
@@ -42,6 +43,7 @@ type ExecuteToolProps = {
isBackgrounded?: boolean; isBackgrounded?: boolean;
killedBySignal?: "kill" | "terminate"; killedBySignal?: "kill" | "terminate";
modelIntent?: string; modelIntent?: string;
parsedCommands?: readonly string[][];
shellToolDisplayMode?: TypesGen.AgentDisplayMode; shellToolDisplayMode?: TypesGen.AgentDisplayMode;
}; };
@@ -79,6 +81,7 @@ const ExecuteToolInner: React.FC<ExecuteToolInnerProps> = ({
isBackgrounded = false, isBackgrounded = false,
killedBySignal, killedBySignal,
modelIntent, modelIntent,
parsedCommands,
outputInitiallyOpen, outputInitiallyOpen,
}) => { }) => {
const hasCommand = command.trim().length > 0; const hasCommand = command.trim().length > 0;
@@ -107,6 +110,7 @@ const ExecuteToolInner: React.FC<ExecuteToolInnerProps> = ({
<ShellCommandLine <ShellCommandLine
command={command} command={command}
modelIntent={modelIntent} modelIntent={modelIntent}
parsedCommands={parsedCommands}
durationLabel={durationLabel} durationLabel={durationLabel}
expanded={outputOpen} expanded={outputOpen}
/> />
@@ -177,20 +181,25 @@ const ExecuteToolInner: React.FC<ExecuteToolInnerProps> = ({
const ShellCommandLine: React.FC<{ const ShellCommandLine: React.FC<{
command: string; command: string;
modelIntent?: string; modelIntent?: string;
parsedCommands?: readonly string[][];
durationLabel: string; durationLabel: string;
expanded?: boolean; expanded?: boolean;
}> = ({ command, modelIntent, durationLabel, expanded }) => { }> = ({ command, modelIntent, parsedCommands, durationLabel, expanded }) => {
const intentLabel = sanitizeExecuteModelIntent(modelIntent, command); const intentLabel = sanitizeExecuteModelIntent(modelIntent, command);
const summary =
parsedCommands && parsedCommands.length > 0
? summarizeParsedCommands(parsedCommands)
: "";
const commandDisplay = summary || command;
return ( return (
<> <>
<span className="block min-w-0 truncate text-[13px] font-normal text-current"> <span className="block min-w-0 truncate text-[13px] font-normal text-current">
{intentLabel ? ( {intentLabel ? (
<> <>
{intentLabel} using{" "} {intentLabel} using {commandDisplay}
<code className="font-mono text-xs">{command}</code>
</> </>
) : ( ) : (
<>Ran {command}</> <>Ran {commandDisplay}</>
)} )}
</span> </span>
{durationLabel && ( {durationLabel && (
@@ -94,6 +94,8 @@ interface ToolProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
previousResponseText?: string; previousResponseText?: string;
/** Human-readable intent extracted from the model's tool-call args. */ /** Human-readable intent extracted from the model's tool-call args. */
modelIntent?: string; modelIntent?: string;
/** Parsed command tuples ([program] or [program, arg]) for execute tool calls. */
parsedCommands?: readonly string[][];
shellToolDisplayMode?: TypesGen.AgentDisplayMode; shellToolDisplayMode?: TypesGen.AgentDisplayMode;
codeDiffDisplayMode?: TypesGen.AgentDisplayMode; codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
} }
@@ -119,6 +121,7 @@ type ToolRendererProps = {
mcpServerConfigId?: string; mcpServerConfigId?: string;
mcpServers?: readonly TypesGen.MCPServerConfig[]; mcpServers?: readonly TypesGen.MCPServerConfig[];
modelIntent?: string; modelIntent?: string;
parsedCommands?: readonly string[][];
shellToolDisplayMode?: TypesGen.AgentDisplayMode; shellToolDisplayMode?: TypesGen.AgentDisplayMode;
codeDiffDisplayMode?: TypesGen.AgentDisplayMode; codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
}; };
@@ -223,6 +226,7 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
isError, isError,
killedBySignal, killedBySignal,
modelIntent, modelIntent,
parsedCommands,
shellToolDisplayMode, shellToolDisplayMode,
}) => { }) => {
const data = getExecuteRenderData(args, result); const data = getExecuteRenderData(args, result);
@@ -247,6 +251,7 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
isBackgrounded={data.isBackgrounded} isBackgrounded={data.isBackgrounded}
killedBySignal={killedBySignal} killedBySignal={killedBySignal}
modelIntent={modelIntent} modelIntent={modelIntent}
parsedCommands={parsedCommands}
shellToolDisplayMode={shellToolDisplayMode} shellToolDisplayMode={shellToolDisplayMode}
/> />
); );
@@ -1023,6 +1028,7 @@ export const Tool = memo(
isLatestAskUserQuestion, isLatestAskUserQuestion,
previousResponseText, previousResponseText,
modelIntent, modelIntent,
parsedCommands,
shellToolDisplayMode, shellToolDisplayMode,
codeDiffDisplayMode, codeDiffDisplayMode,
ref, ref,
@@ -1067,6 +1073,7 @@ export const Tool = memo(
isLatestAskUserQuestion={isLatestAskUserQuestion} isLatestAskUserQuestion={isLatestAskUserQuestion}
previousResponseText={previousResponseText} previousResponseText={previousResponseText}
modelIntent={modelIntent} modelIntent={modelIntent}
parsedCommands={parsedCommands}
shellToolDisplayMode={shellToolDisplayMode} shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode} codeDiffDisplayMode={codeDiffDisplayMode}
/> />
@@ -28,6 +28,7 @@ import {
sanitizeExecuteModelIntent, sanitizeExecuteModelIntent,
shortDurationMs, shortDurationMs,
stripSvnIndexHeaders, stripSvnIndexHeaders,
summarizeParsedCommands,
toProviderLabel, toProviderLabel,
} from "./utils"; } from "./utils";
@@ -1004,3 +1005,51 @@ describe("parseServerEditDiffText", () => {
expect(diff?.name).toBe("/abs/a.txt"); expect(diff?.name).toBe("/abs/a.txt");
}); });
}); });
describe("summarizeParsedCommands", () => {
it("renders <prog> <verb> for multi-verb tools", () => {
expect(
summarizeParsedCommands([
["git", "pull"],
["git", "add"],
["git", "commit"],
]),
).toBe("git pull, git add, git commit");
});
it("renders just <prog> for non-multi-verb tools", () => {
expect(
summarizeParsedCommands([
["cd", "/repo"],
["ls", "/tmp"],
]),
).toBe("cd, ls");
});
it("renders single-arg entries as just the program", () => {
expect(summarizeParsedCommands([["pwd"]])).toBe("pwd");
});
it("dedupes consecutive duplicates", () => {
expect(
summarizeParsedCommands([
["git", "pull"],
["git", "pull"],
]),
).toBe("git pull");
});
it("keeps non-consecutive duplicates", () => {
expect(
summarizeParsedCommands([["git", "pull"], ["ls"], ["git", "pull"]]),
).toBe("git pull, ls, git pull");
});
it("returns empty string for empty input", () => {
expect(summarizeParsedCommands([])).toBe("");
});
it("skips entries with no program", () => {
expect(summarizeParsedCommands([[""], ["git", "pull"]])).toBe("git pull");
});
});
@@ -755,3 +755,43 @@ export { asNumber, asRecord, asString } from "../runtimeTypeUtils";
*/ */
export const signalTooltipLabel = (signal: "kill" | "terminate"): string => export const signalTooltipLabel = (signal: "kill" | "terminate"): string =>
signal === "kill" ? "Killed (SIGKILL)" : "Terminated (SIGTERM)"; signal === "kill" ? "Killed (SIGKILL)" : "Terminated (SIGTERM)";
// Programs whose first positional argument is conventionally a subcommand verb.
const multiVerbTools = new Set([
"git",
"gh",
"kubectl",
"docker",
"podman",
"npm",
"pnpm",
"yarn",
"go",
"cargo",
"make",
"helm",
"terraform",
"systemctl",
"brew",
]);
/**
* Collapses parsed_commands into a comma-joined summary. Multi-verb
* tools render as "<prog> <verb>"; others render as just "<prog>".
* Consecutive duplicates are deduped.
*/
export const summarizeParsedCommands = (
parsed: readonly string[][],
): string => {
const labels: string[] = [];
for (const entry of parsed) {
const prog = entry[0];
if (!prog) continue;
const label =
multiVerbTools.has(prog) && entry[1] ? `${prog} ${entry[1]}` : prog;
if (labels[labels.length - 1] !== label) {
labels.push(label);
}
}
return labels.join(", ");
};