fix(acp): strip command echo when terminal resolves a relative path to absolute (#1975)

This commit is contained in:
Clément Fiere
2026-07-27 13:30:56 +02:00
committed by GitHub
parent d0d084bdc6
commit 43559c79bf
5 changed files with 374 additions and 15 deletions
+20 -5
View File
@@ -222,12 +222,20 @@ func executeSimulatedToolResult(e *emitter, line string) {
// the actual output - the ACP shell-output normalizer must strip that
// leading echo so the transcript's command header and Output disclosure
// don't show the command twice.
// Format: e2e:shell_result("<command>", "<stdout>")
// Format: e2e:shell_result("<command>", "<stdout>"[, "<cwd>"])
//
// rawInputCommandKey/rawOutputKey mirror the "command"/"output" keys a real
// shell provider's tool_call/tool_call_update JSON uses.
// The optional third argument reproduces a provider that resolves a
// relative file-path argument in the command to an absolute (cwd-joined)
// one before actually invoking the real terminal, while the reported
// "command" keeps the original relative form - the normalizer's workDir-
// aware echo stripping must still recognize that echo.
//
// rawInputCommandKey/rawInputCwdKey/rawOutputKey mirror the
// "command"/"cwd"/"output" keys a real shell provider's
// tool_call/tool_call_update JSON uses.
const (
rawInputCommandKey = "command"
rawInputCwdKey = "cwd"
rawOutputKey = "output"
)
@@ -235,10 +243,17 @@ func executeSimulatedShellResult(e *emitter, line string) {
inner := extractParenContent(line, "e2e:shell_result(")
command, rest := extractFirstStringArg(inner)
rest = strings.TrimPrefix(strings.TrimSpace(rest), ",")
stdout, _ := extractFirstStringArg(strings.TrimSpace(rest))
stdout, rest := extractFirstStringArg(strings.TrimSpace(rest))
rest = strings.TrimPrefix(strings.TrimSpace(rest), ",")
cwd, _ := extractFirstStringArg(strings.TrimSpace(rest))
rawInput := map[string]any{rawInputCommandKey: command}
if cwd != "" {
rawInput[rawInputCwdKey] = cwd
}
toolID := nextToolID()
e.startTool(toolID, command, acp.ToolKindExecute, map[string]any{rawInputCommandKey: command})
e.startTool(toolID, command, acp.ToolKindExecute, rawInput)
e.completeTool(toolID, map[string]any{rawOutputKey: stdout})
}
@@ -504,6 +504,59 @@ func TestExecuteScriptShellResult(t *testing.T) {
}
}
// TestExecuteScriptShellResultWithCwd verifies the optional third
// e2e:shell_result argument populates rawInput["cwd"], reproducing a
// provider that resolves a relative file-path argument in the command to
// an absolute (cwd-joined) one before actually invoking the real terminal
// (see the ACP shell-output normalizer's workDir-aware echo stripping in
// shell_output.go).
func TestExecuteScriptShellResultWithCwd(t *testing.T) {
e, mock := newTestEmitter()
resetState()
executeScript(e, "", `e2e:shell_result("cat notes.txt", "$ cat /work/notes.txt\nhello\n", "/work")`)
updates := mock.getUpdates()
if len(updates) != 2 {
t.Fatalf("expected 2 updates, got %d", len(updates))
}
tc := updates[0].notification.Update.ToolCall
input, ok := tc.RawInput.(map[string]any)
if !ok || input["command"] != "cat notes.txt" || input["cwd"] != "/work" {
t.Errorf("raw input = %#v, want command %q and cwd %q", tc.RawInput, "cat notes.txt", "/work")
}
tcu := updates[1].notification.Update.ToolCallUpdate
output, ok := tcu.RawOutput.(map[string]any)
if !ok || output["output"] != "$ cat /work/notes.txt\nhello\n" {
t.Errorf("raw output = %#v, want the echoed stdout", tcu.RawOutput)
}
}
// TestExecuteScriptShellResultWithoutCwdOmitsCwdKey confirms the
// two-argument form (no cwd) keeps working exactly as before: rawInput
// carries no "cwd" key at all.
func TestExecuteScriptShellResultWithoutCwdOmitsCwdKey(t *testing.T) {
e, mock := newTestEmitter()
resetState()
executeScript(e, "", `e2e:shell_result("cat file.txt", "cat file.txt=== marker ===\n")`)
updates := mock.getUpdates()
if len(updates) != 2 {
t.Fatalf("expected 2 updates, got %d", len(updates))
}
tc := updates[0].notification.Update.ToolCall
input, ok := tc.RawInput.(map[string]any)
if !ok {
t.Fatalf("raw input = %#v, want a map", tc.RawInput)
}
if _, present := input["cwd"]; present {
t.Errorf("raw input = %#v, want no cwd key", tc.RawInput)
}
}
// TestExecuteScriptToolUseNoInput verifies tool_use with no input arg.
func TestExecuteScriptToolUseNoInput(t *testing.T) {
e, mock := newTestEmitter()
@@ -28,7 +28,7 @@ func applyFinalShellResult(payload *streams.ShellExecPayload, result any) {
}
output := ensureShellOutput(payload)
if normalized.hasStdout {
output.Stdout = stripLeadingCommandEcho(payload.Command, normalized.stdout)
output.Stdout = stripLeadingCommandEcho(payload.Command, payload.WorkDir, normalized.stdout)
output.StdoutTruncated = normalized.stdoutTruncated
}
if normalized.hasStderr {
@@ -56,8 +56,8 @@ func applyFinalShellResult(payload *streams.ShellExecPayload, result any) {
// Used for final/terminal results, where the buffer is known to be complete:
// a stdout consisting of nothing but the echoed command commits to an empty
// result rather than leaving the redundant echo visible.
func stripLeadingCommandEcho(command, stdout string) string {
return stripCommandEcho(command, stdout, true)
func stripLeadingCommandEcho(command, workDir, stdout string) string {
return stripCommandEcho(command, workDir, stdout, true)
}
// stripPendingCommandEcho is the live-update counterpart of
@@ -67,18 +67,54 @@ func stripLeadingCommandEcho(command, stdout string) string {
// currently equals nothing but the echo is left untouched - the separator or
// real output may still be in flight - instead of collapsing to empty and
// orphaning a later chunk's leading newline.
func stripPendingCommandEcho(command, stdout string) string {
return stripCommandEcho(command, stdout, false)
func stripPendingCommandEcho(command, workDir, stdout string) string {
return stripCommandEcho(command, workDir, stdout, false)
}
func stripCommandEcho(command, stdout string, commitExactMatch bool) string {
func stripCommandEcho(command, workDir, stdout string, commitExactMatch bool) string {
if command == "" || stdout == "" {
return stdout
}
rest, ok := strings.CutPrefix(stdout, "$ "+command)
if !ok {
if rest, ok := strings.CutPrefix(stdout, "$ "+command); ok {
return finishCommandEchoStrip(rest, stdout, commitExactMatch)
}
return stripCommandEchoWithWorkDir(command, workDir, stdout, commitExactMatch)
}
// stripCommandEchoWithWorkDir is stripCommandEcho's fallback for providers
// that resolve a relative file-path argument in the command to an absolute
// (workDir-joined) one before actually invoking the real terminal, while the
// tool call's reported "command" field - the same text the chat header
// renders - keeps the original relative form. The captured echo line then no
// longer matches literally. Retry against just the first echoed line with
// the workDir prefix collapsed back out, so real output later in stdout
// (which may legitimately contain workDir-prefixed paths of its own) is
// never touched.
func stripCommandEchoWithWorkDir(command, workDir, stdout string, commitExactMatch bool) string {
if workDir == "" {
return stdout
}
// workDir's trailing slashes are collapsed to exactly one first: a
// provider can report cwd already "/"-terminated (e.g. "/repo/"), and
// the root directory "/" is inherently "/"-terminated. Appending "/"
// unconditionally would search for a doubled separator ("/repo//" or
// "//") that the actually-joined path never contains, silently
// declining to strip a real echo.
firstLine, remainder, hasMore := cutFirstLine(stdout)
workDirPrefix := strings.TrimRight(workDir, "/") + "/"
if strings.ReplaceAll(firstLine, workDirPrefix, "") != "$ "+command {
return stdout
}
if !hasMore {
if !commitExactMatch {
return stdout
}
return ""
}
return remainder
}
func finishCommandEchoStrip(rest, stdout string, commitExactMatch bool) string {
if rest == "" && !commitExactMatch {
return stdout
}
@@ -88,6 +124,16 @@ func stripCommandEcho(command, stdout string, commitExactMatch bool) string {
return strings.TrimPrefix(rest, "\n")
}
// cutFirstLine splits s at its first newline, reporting whether one was
// found. The returned line excludes both the newline and any preceding "\r".
func cutFirstLine(s string) (line, remainder string, hasMore bool) {
idx := strings.IndexByte(s, '\n')
if idx < 0 {
return s, "", false
}
return strings.TrimSuffix(s[:idx], "\r"), s[idx+1:], true
}
// NormalizeShellToolUpdate merges live and final ACP shell result fields into
// the active normalized payload. It reports whether the update contained a
// recognized shell output field so statusless updates can be persisted.
@@ -153,9 +199,9 @@ func (n *Normalizer) NormalizeShellToolUpdate(
// A definitive completion signal arrived, and Stdout hasn't
// already been committed-stripped this call: commit now, even
// if the buffer is nothing but the echo.
shell.Output.Stdout = stripLeadingCommandEcho(shell.Command, shell.Output.Stdout)
shell.Output.Stdout = stripLeadingCommandEcho(shell.Command, shell.WorkDir, shell.Output.Stdout)
case !isFinal:
shell.Output.Stdout = stripPendingCommandEcho(shell.Command, shell.Output.Stdout)
shell.Output.Stdout = stripPendingCommandEcho(shell.Command, shell.WorkDir, shell.Output.Stdout)
}
}
@@ -365,6 +365,192 @@ func TestNormalizeShellToolResultStripsLeadingCommandEcho(t *testing.T) {
}
}
// TestNormalizeShellToolResultStripsLeadingCommandEchoWithWorkDirResolvedPath
// is a regression for a real-world report: PR #1898's fix only strips the
// echo when the captured terminal line matches the tool call's reported
// "command" byte-for-byte. Some providers resolve a relative file-path
// argument to an absolute one (workDir-joined) before actually invoking the
// real terminal, while the "command" field reported on the tool call - the
// same text the chat header renders - keeps the original relative form.
// The literal prefix match then never fires, and the full echoed line
// (with the resolved absolute path) leaks into the persisted Output.
func TestNormalizeShellToolResultStripsLeadingCommandEchoWithWorkDirResolvedPath(t *testing.T) {
t.Parallel()
command := `grep -n "^const STRATEGY\|STRATEGY =" apps/web/components/task/chat/message-list.tsx`
workDir := "/home/clem/.kandev/tasks/new-message-line_0vc/kdlbs-kandev"
absolutePath := workDir + "/apps/web/components/task/chat/message-list.tsx"
resolvedCommand := strings.Replace(command, "apps/web/components/task/chat/message-list.tsx", absolutePath, 1)
normalizer := NewNormalizer("")
payload := normalizer.NormalizeToolCall("execute", map[string]any{
"kind": "execute",
"raw_input": map[string]any{"command": command, "cwd": workDir},
})
normalizer.NormalizeToolResult(payload, "$ "+resolvedCommand+"\n24:const STRATEGY = \"native\";\n")
require.Equal(t, "24:const STRATEGY = \"native\";\n", payload.ShellExec().Output.Stdout)
}
// TestNormalizeShellToolUpdateStripsLeadingCommandEchoWithWorkDirResolvedPath
// covers the same workDir-resolved-path mismatch on the live-update path
// (terminal_output plus a terminal_exit completion, no rawOutput).
func TestNormalizeShellToolUpdateStripsLeadingCommandEchoWithWorkDirResolvedPath(t *testing.T) {
t.Parallel()
command := "cat notes.txt"
workDir := "/repo"
resolvedCommand := "cat /repo/notes.txt"
normalizer := NewNormalizer("")
payload := normalizer.NormalizeToolCall("execute", map[string]any{
"kind": "execute",
"raw_input": map[string]any{"command": command, "cwd": workDir},
})
normalizer.NormalizeShellToolUpdate(
payload,
map[string]any{
"terminal_output": map[string]any{"data": "$ " + resolvedCommand + "\nhello\n"},
"terminal_exit": map[string]any{"exit_code": float64(0)},
},
nil,
nil,
)
require.Equal(t, "hello\n", payload.ShellExec().Output.Stdout)
}
// TestNormalizeShellToolUpdateWorkDirFallbackDefersPendingEchoWithoutNewline
// covers the pending (non-final) counterpart of the workDir fallback: a
// buffer that is exactly the resolved echo with no trailing newline yet
// must be left untouched, since the real output (or even just the
// separator newline) may still be in flight.
func TestNormalizeShellToolUpdateWorkDirFallbackDefersPendingEchoWithoutNewline(t *testing.T) {
t.Parallel()
normalizer := NewNormalizer("")
payload := normalizer.NormalizeToolCall("execute", map[string]any{
"kind": "execute",
"raw_input": map[string]any{"command": "cat notes.txt", "cwd": "/repo"},
})
normalizer.NormalizeShellToolUpdate(
payload,
map[string]any{"terminal_output": map[string]any{"data": "$ cat /repo/notes.txt"}},
nil,
nil,
)
require.Equal(t, "$ cat /repo/notes.txt", payload.ShellExec().Output.Stdout)
}
// TestNormalizeShellToolUpdateWorkDirFallbackCommitsPendingEchoWithNewline
// covers the fallback's other pending-path branch: once a trailing newline
// confirms the echo line is complete (hasMore == true), the strip commits
// immediately even though commitExactMatch is false - the same behavior
// finishCommandEchoStrip already applies once its literal-match "rest"
// carries a newline.
func TestNormalizeShellToolUpdateWorkDirFallbackCommitsPendingEchoWithNewline(t *testing.T) {
t.Parallel()
normalizer := NewNormalizer("")
payload := normalizer.NormalizeToolCall("execute", map[string]any{
"kind": "execute",
"raw_input": map[string]any{"command": "cat notes.txt", "cwd": "/repo"},
})
normalizer.NormalizeShellToolUpdate(
payload,
map[string]any{"terminal_output": map[string]any{"data": "$ cat /repo/notes.txt\n"}},
nil,
nil,
)
require.Equal(t, "", payload.ShellExec().Output.Stdout)
}
// TestNormalizeShellToolResultWorkDirFallbackNeverCorruptsNearMissOutput is a
// safety regression for the workDir-collapse fallback added alongside the
// above test: ReplaceAll(firstLine, workDirPrefix, "") strips every
// occurrence of that prefix from the echoed line, including one embedded in
// a quoted argument that isn't a resolved file path (e.g. a grep pattern
// that happens to contain workDir-looking text). If the collapsed line
// still doesn't reconstruct "$ "+command exactly - because the real
// terminal echo genuinely differs (different args, or the workDir text was
// incidental rather than a resolved path) - the fallback must leave stdout
// untouched rather than mangling legitimate output.
func TestNormalizeShellToolResultWorkDirFallbackNeverCorruptsNearMissOutput(t *testing.T) {
t.Parallel()
command := `grep -n "/repo/notes" apps/foo.txt`
workDir := "/repo"
// The real terminal echo resolves the file argument to an absolute path
// (workDir-joined) AND happens to also carry a coincidental "/repo/"
// substring inside the quoted pattern - unrelated to path resolution.
// Collapsing every "/repo/" occurrence therefore also eats the pattern's
// own "/repo/" text, so the result can never exactly reconstruct
// "$ "+command: the fallback must decline to strip rather than guess.
stdout := `$ grep -n "/repo/notes" /repo/apps/foo.txt` + "\n1:a match\n"
normalizer := NewNormalizer("")
payload := normalizer.NormalizeToolCall("execute", map[string]any{
"kind": "execute",
"raw_input": map[string]any{"command": command, "cwd": workDir},
})
normalizer.NormalizeToolResult(payload, stdout)
require.Equal(t, stdout, payload.ShellExec().Output.Stdout)
}
// TestNormalizeShellToolResultStripsLeadingCommandEchoWithTrailingSlashWorkDir
// is a regression for a Codex review finding on this PR: a provider can
// report "cwd" already "/"-terminated (e.g. "/repo/"). Appending "/"
// unconditionally would then search for "/repo//" - a doubled separator
// the actually-joined path ("/repo/notes.txt") never contains - silently
// declining to strip a real echo.
func TestNormalizeShellToolResultStripsLeadingCommandEchoWithTrailingSlashWorkDir(t *testing.T) {
t.Parallel()
command := "cat notes.txt"
workDir := "/repo/"
resolvedCommand := "cat /repo/notes.txt"
normalizer := NewNormalizer("")
payload := normalizer.NormalizeToolCall("execute", map[string]any{
"kind": "execute",
"raw_input": map[string]any{"command": command, "cwd": workDir},
})
normalizer.NormalizeToolResult(payload, "$ "+resolvedCommand+"\nhello\n")
require.Equal(t, "hello\n", payload.ShellExec().Output.Stdout)
}
// TestNormalizeShellToolResultStripsLeadingCommandEchoWithRootWorkDir covers
// the same Codex finding's other reported case: the root directory "/" is
// itself already "/"-terminated, so the joined path is single- not
// double-slashed ("/notes.txt", not "//notes.txt").
func TestNormalizeShellToolResultStripsLeadingCommandEchoWithRootWorkDir(t *testing.T) {
t.Parallel()
command := "cat notes.txt"
workDir := "/"
resolvedCommand := "cat /notes.txt"
normalizer := NewNormalizer("")
payload := normalizer.NormalizeToolCall("execute", map[string]any{
"kind": "execute",
"raw_input": map[string]any{"command": command, "cwd": workDir},
})
normalizer.NormalizeToolResult(payload, "$ "+resolvedCommand+"\nhello\n")
require.Equal(t, "hello\n", payload.ShellExec().Output.Stdout)
}
func TestNormalizeShellToolUpdateStripsLeadingCommandEchoFromLiveOutput(t *testing.T) {
t.Parallel()
@@ -58,4 +58,63 @@ test.describe("shell command output echo stripping", () => {
// disclosure - it's already shown once in the command row above.
await expect(outputRegion).not.toContainText(command);
});
test("does not repeat a workDir-resolved absolute-path echo inside the expanded Output disclosure", async ({
testPage,
apiClient,
seedData,
}) => {
// Some providers resolve a relative file-path argument in the command
// to an absolute (cwd-joined) one before actually invoking the real
// terminal, while the reported "command" - the same text the chat
// header renders - keeps the original relative form. The captured echo
// line then no longer matches the command byte-for-byte.
const command = "cat notes.txt";
const cwd = "/workspace/example-repo";
const resolvedCommand = `cat ${cwd}/notes.txt`;
const echoedOutput = `$ ${resolvedCommand}\nhello\n`;
const script = [
`e2e:shell_result("${command}", "${echoedOutput.replace(/\n/g, "\\n")}", "${cwd}")`,
'e2e:message("done")',
].join("\n");
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Shell echo workDir regression",
seedData.agentProfileId,
{
description: script,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
const chat = session.activeChat();
const commandRow = chat.getByTestId("tool-execute-command").filter({ hasText: command });
await expect(commandRow).toBeVisible();
// The header keeps rendering the original relative command, unaffected
// by the provider's internal absolute-path resolution.
await expect(commandRow).toHaveText(command);
const disclosure = chat.getByRole("button", { name: "Show command output" });
const responsePromise = testPage.waitForResponse(
(response) => response.url().endsWith("/shell-output") && response.status() === 200,
);
await disclosure.click();
await responsePromise;
const outputRegion = chat.getByTestId("tool-execute-output");
await expect(outputRegion).toContainText("hello");
// Critical: neither the workDir-prefixed absolute path nor a repeat of
// the resolved command may leak into the Output disclosure.
await expect(outputRegion).not.toContainText(cwd);
await expect(outputRegion).not.toContainText(resolvedCommand);
});
});