From 2a28636ff54d3c6f00acd4c4dfd3f6a5812632e5 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 19:53:19 +0200 Subject: [PATCH 1/7] fix(coding-agent): preview bash-skill calls with literal commands as bash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the collapsed ipython cell line, python cells like r = await bash('git status') now render as bash · git status instead of the python wrapper (which redactNoise erased entirely for commands >=160 chars). Literal-first-arg bash() calls on the scorer-chosen line are routed through previewBashCommand, matching %%bash cells. Non-literal arguments keep the python preview. ENG-5802 --- .../.changes/bash-skill-preview.md | 1 + .../src/core/tools/code-preview.ts | 19 +++++++++++ .../coding-agent/test/code-preview.test.ts | 34 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 packages/coding-agent/.changes/bash-skill-preview.md diff --git a/packages/coding-agent/.changes/bash-skill-preview.md b/packages/coding-agent/.changes/bash-skill-preview.md new file mode 100644 index 0000000000..ec04075f7e --- /dev/null +++ b/packages/coding-agent/.changes/bash-skill-preview.md @@ -0,0 +1 @@ +- Collapsed ipython cells that call the bash skill with a literal command now preview as `bash · ` instead of the python wrapper. diff --git a/packages/coding-agent/src/core/tools/code-preview.ts b/packages/coding-agent/src/core/tools/code-preview.ts index a28ae0ec8b..2b042ae875 100644 --- a/packages/coding-agent/src/core/tools/code-preview.ts +++ b/packages/coding-agent/src/core/tools/code-preview.ts @@ -13,6 +13,7 @@ const PYTHON_DEFINITION_PATTERN = /^\s*(?:async\s+def|def|class)\s+/; const PYTHON_MAIN_PATTERN = /^\s*if\s+__name__\s*==\s*['"]__main__['"]\s*:/; const PYTHON_CONTROL_PATTERN = /^\s*(?:if|elif|else|for|while|with|try|except|finally)\b.*:\s*$/; const PYTHON_CALL_PATTERN = /^\s*(?:await\s+)?[A-Za-z_][A-Za-z0-9_.]*\s*\(/; +const BASH_SKILL_CALL_PATTERN = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*\s*=\s*)?(?:await\s+)?bash\s*\(\s*[rR]?("""|'''|"|')/; const PYTHON_LOW_SIGNAL_CALL_PATTERN = /^\s*(?:await\s+)?(?:print|len|str|repr|int|float|list|dict|set|tuple)\s*\(/; const PYTHON_ASSIGNMENT_CALL_PATTERN = /^\s*[A-Za-z_][A-Za-z0-9_]*(?:\s*:\s*[^=]+)?\s*=\s*(?:await\s+)?[A-Za-z_][A-Za-z0-9_.]*\s*\(/; @@ -409,6 +410,19 @@ function pythonPreviewIndex(lines: readonly string[], index: number): number { return childIndex === undefined ? index : pythonPreviewIndex(lines, childIndex); } +function extractBashSkillCommand(code: string): string | undefined { + const match = code.match(BASH_SKILL_CALL_PATTERN); + const quote = match?.[1]; + if (!quote) return undefined; + const start = match?.[0].length ?? 0; + const end = code.indexOf(quote, start); + if (end < 0) return undefined; + const rest = code.slice(end + quote.length).trimStart(); + // Require a plain literal first argument; concatenation or other expressions fall back. + if (!rest.startsWith(",") && !rest.startsWith(")")) return undefined; + return code.slice(start, end); +} + export function previewPythonCode(code: string): CodePreview { const lines = code.split("\n"); const paths = pythonPathVars(lines); @@ -425,6 +439,11 @@ export function previewPythonCode(code: string): CodePreview { if (bestIndex !== undefined && bestScore >= 0) { const previewIndex = pythonPreviewIndex(lines, bestIndex); + // The literal may span lines below the chosen one, so extract from the full tail. + const bashCommand = extractBashSkillCommand(lines.slice(previewIndex).join("\n")); + if (bashCommand) { + return previewBashCommand(bashCommand); + } return { language: "python", text: descriptor(pythonPreviewLine(lines, previewIndex, paths)), diff --git a/packages/coding-agent/test/code-preview.test.ts b/packages/coding-agent/test/code-preview.test.ts index fcf9c4df51..c1209dcc06 100644 --- a/packages/coding-agent/test/code-preview.test.ts +++ b/packages/coding-agent/test/code-preview.test.ts @@ -125,6 +125,40 @@ EOF`; expect(previewBashCommand(command)).toEqual({ language: "bash", text: "hello world" }); }); + it("routes bash-skill calls with literal commands to the bash preview", () => { + expect(previewIpythonCode("r = await bash('git status --porcelain')")).toEqual({ + language: "bash", + text: "git status --porcelain", + }); + const longCommand = `git log --oneline -- ${Array.from({ length: 8 }, (_, i) => `packages/coding-agent/src/dir-${i}`).join(" ")}`; + const longPreview = previewIpythonCode(`result = await bash("${longCommand}", timeout=120)`); + expect(longPreview.language).toBe("bash"); + expect(longPreview.text.startsWith("git log --oneline")).toBe(true); + }); + + it("passes triple-quoted bash-skill bodies through like %%bash cells", () => { + const code = `r = await bash(''' +set -e +git add packages/foo.ts +''')`; + expect(previewIpythonCode(code)).toEqual({ language: "bash", text: "git add packages/foo.ts" }); + }); + + it("keeps the python preview for non-literal bash-skill arguments", () => { + expect(previewIpythonCode("r = await bash(cmd)")).toEqual({ language: "python", text: "r = await bash(cmd)" }); + expect(previewIpythonCode('r = await bash(f"git checkout {branch}")')).toEqual({ + language: "python", + text: 'r = await bash(f"git checkout {branch}")', + }); + }); + + it("previews the bash-skill call when the scorer picks it among other lines", () => { + const code = `import json +r = await bash('git diff --stat') +print(r)`; + expect(previewIpythonCode(code)).toEqual({ language: "bash", text: "git diff --stat" }); + }); + it("prefers a later meaningful heredoc over an earlier generic one", () => { const command = `cat <<'CFG' key=value From e906b631bf2144de619698aa9c319e5ba85e6371 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 20:02:14 +0200 Subject: [PATCH 2/7] test(coding-agent): kill guard-drop mutant in bash-skill preview extraction previewIpythonCode must keep the python preview when the quoted literal is not the whole first argument (string concatenation, escaped quotes). The existing tests let a mutant that drops the comma/paren-after-close- quote guard survive; this pins the fallback behavior. --- packages/coding-agent/test/code-preview.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/coding-agent/test/code-preview.test.ts b/packages/coding-agent/test/code-preview.test.ts index c1209dcc06..8ad10587bc 100644 --- a/packages/coding-agent/test/code-preview.test.ts +++ b/packages/coding-agent/test/code-preview.test.ts @@ -152,6 +152,17 @@ git add packages/foo.ts }); }); + it("keeps the python preview when the literal is not the whole first argument", () => { + expect(previewIpythonCode("r = await bash('echo ' + name)")).toEqual({ + language: "python", + text: "r = await bash('echo ' + name)", + }); + expect(previewIpythonCode("r = await bash('echo can\\'t stop')")).toEqual({ + language: "python", + text: "r = await bash('echo can\\'t stop')", + }); + }); + it("previews the bash-skill call when the scorer picks it among other lines", () => { const code = `import json r = await bash('git diff --stat') From 9d585bca0adb8fd19e6f85634587cfb085b6738a Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 20:04:11 +0200 Subject: [PATCH 3/7] fix(coding-agent): reject escaped-quote mis-cuts in bash-skill extraction --- packages/coding-agent/src/core/tools/code-preview.ts | 3 +++ packages/coding-agent/test/code-preview.test.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/packages/coding-agent/src/core/tools/code-preview.ts b/packages/coding-agent/src/core/tools/code-preview.ts index 2b042ae875..a99efb5e76 100644 --- a/packages/coding-agent/src/core/tools/code-preview.ts +++ b/packages/coding-agent/src/core/tools/code-preview.ts @@ -417,6 +417,9 @@ function extractBashSkillCommand(code: string): string | undefined { const start = match?.[0].length ?? 0; const end = code.indexOf(quote, start); if (end < 0) return undefined; + // A backslash before the close quote means an escaped quote inside the literal; + // extracting the mis-cut prefix would preview a wrong command, so fall back. + if (quote.length === 1 && code[end - 1] === "\\") return undefined; const rest = code.slice(end + quote.length).trimStart(); // Require a plain literal first argument; concatenation or other expressions fall back. if (!rest.startsWith(",") && !rest.startsWith(")")) return undefined; diff --git a/packages/coding-agent/test/code-preview.test.ts b/packages/coding-agent/test/code-preview.test.ts index 8ad10587bc..68577abb30 100644 --- a/packages/coding-agent/test/code-preview.test.ts +++ b/packages/coding-agent/test/code-preview.test.ts @@ -161,6 +161,8 @@ git add packages/foo.ts language: "python", text: "r = await bash('echo can\\'t stop')", }); + // Escaped quote directly before the close quote: a mis-cut here would preview a wrong command. + expect(previewIpythonCode('r = await bash("grep -n \\")\\" src.c")').language).toBe("python"); }); it("previews the bash-skill call when the scorer picks it among other lines", () => { From e2c6b0f6d43383ceb15947bd928a61638efe62f8 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 10:29:51 +0200 Subject: [PATCH 4/7] fix(coding-agent): evaluate bash-skill literals and gate extraction inside strings Replace the regex-tail + indexOf extraction with a small python string-literal scanner: backslash consumes the next char (raw-string rule included), cooked strings unescape standard sequences, and an unterminated scan falls back to the python preview. The preview now shows the evaluated command (real newlines, unescaped quotes) instead of raw source text, and triple-quoted literals with escaped quotes can no longer be mis-cut. Reuse the scanner to detect when the scorer's chosen line sits inside an unterminated triple-quoted string opened earlier; skip bash-skill extraction there so string text like a docstring never previews as a bash command that did not run. --- .../src/core/tools/code-preview.ts | 91 +++++++++++++++++-- .../coding-agent/test/code-preview.test.ts | 25 ++++- 2 files changed, 103 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/src/core/tools/code-preview.ts b/packages/coding-agent/src/core/tools/code-preview.ts index a99efb5e76..fbc0916b28 100644 --- a/packages/coding-agent/src/core/tools/code-preview.ts +++ b/packages/coding-agent/src/core/tools/code-preview.ts @@ -410,20 +410,88 @@ function pythonPreviewIndex(lines: readonly string[], index: number): number { return childIndex === undefined ? index : pythonPreviewIndex(lines, childIndex); } +const PYTHON_ESCAPES: Record = { + "\n": "", // backslash-newline is a line continuation + '"': '"', + "'": "'", + "\\": "\\", + n: "\n", + r: "\r", + t: "\t", +}; + +interface PythonStringScan { + value: string; + end: number; + closed: boolean; +} + +// Walks a python string-literal body starting just after the opening delimiter. +// In both raw and cooked strings a backslash consumes the next char (python's +// raw-string rule: backslash-quote never closes the literal); only cooked +// strings unescape, and unknown escapes keep the backslash, matching python. +function scanPythonStringLiteral(code: string, start: number, quote: string, raw: boolean): PythonStringScan { + let value = ""; + let i = start; + while (i < code.length) { + const char = code[i] ?? ""; + if (char === "\\" && i + 1 < code.length) { + const next = code[i + 1] ?? ""; + value += raw ? char + next : (PYTHON_ESCAPES[next] ?? char + next); + i += 2; + continue; + } + if (code.startsWith(quote, i)) { + return { value, end: i + quote.length, closed: true }; + } + if (quote.length === 1 && char === "\n") { + break; // single-quoted literals cannot span lines + } + value += char; + i += 1; + } + return { value, end: i, closed: false }; +} + +// True when the lines end inside an unterminated triple-quoted string, meaning +// the following line is string text rather than code. +function endsInsideMultilineString(lines: readonly string[]): boolean { + const text = lines.join("\n"); + let i = 0; + while (i < text.length) { + const char = text[i] ?? ""; + if (char === "#") { + const newline = text.indexOf("\n", i); + if (newline < 0) return false; + i = newline + 1; + continue; + } + if (char === '"' || char === "'") { + const quote = text.startsWith(char.repeat(3), i) ? char.repeat(3) : char; + const scan = scanPythonStringLiteral(text, i + quote.length, quote, true); + if (!scan.closed && scan.end >= text.length) { + return quote.length === 3; + } + i = scan.end; + continue; + } + i += 1; + } + return false; +} + function extractBashSkillCommand(code: string): string | undefined { const match = code.match(BASH_SKILL_CALL_PATTERN); const quote = match?.[1]; - if (!quote) return undefined; - const start = match?.[0].length ?? 0; - const end = code.indexOf(quote, start); - if (end < 0) return undefined; - // A backslash before the close quote means an escaped quote inside the literal; - // extracting the mis-cut prefix would preview a wrong command, so fall back. - if (quote.length === 1 && code[end - 1] === "\\") return undefined; - const rest = code.slice(end + quote.length).trimStart(); + if (!match || !quote) return undefined; + const start = match[0].length; + const prefixChar = match[0][start - quote.length - 1]; + const scan = scanPythonStringLiteral(code, start, quote, prefixChar === "r" || prefixChar === "R"); + if (!scan.closed) return undefined; + const rest = code.slice(scan.end).trimStart(); // Require a plain literal first argument; concatenation or other expressions fall back. if (!rest.startsWith(",") && !rest.startsWith(")")) return undefined; - return code.slice(start, end); + return scan.value; } export function previewPythonCode(code: string): CodePreview { @@ -443,7 +511,10 @@ export function previewPythonCode(code: string): CodePreview { if (bestIndex !== undefined && bestScore >= 0) { const previewIndex = pythonPreviewIndex(lines, bestIndex); // The literal may span lines below the chosen one, so extract from the full tail. - const bashCommand = extractBashSkillCommand(lines.slice(previewIndex).join("\n")); + // A line inside a multiline string is text, not a bash-skill call: no bash ran. + const bashCommand = endsInsideMultilineString(lines.slice(0, previewIndex)) + ? undefined + : extractBashSkillCommand(lines.slice(previewIndex).join("\n")); if (bashCommand) { return previewBashCommand(bashCommand); } diff --git a/packages/coding-agent/test/code-preview.test.ts b/packages/coding-agent/test/code-preview.test.ts index 68577abb30..2590c226b6 100644 --- a/packages/coding-agent/test/code-preview.test.ts +++ b/packages/coding-agent/test/code-preview.test.ts @@ -142,6 +142,8 @@ set -e git add packages/foo.ts ''')`; expect(previewIpythonCode(code)).toEqual({ language: "bash", text: "git add packages/foo.ts" }); + // Escaped quote adjacent to the closing delimiter: the full evaluated command, never a mis-cut prefix. + expect(previewIpythonCode("r = await bash('''echo it\\'''')")).toEqual({ language: "bash", text: "echo it'" }); }); it("keeps the python preview for non-literal bash-skill arguments", () => { @@ -157,12 +159,29 @@ git add packages/foo.ts language: "python", text: "r = await bash('echo ' + name)", }); + }); + + it("evaluates escapes in bash-skill literals instead of previewing source text", () => { + // \n in the source is a real newline in the executed command; salience follows the evaluated text. + expect(previewIpythonCode("r = await bash('printf \"a\\nb\"\\ngit add -A')")).toEqual({ + language: "bash", + text: "git add -A", + }); expect(previewIpythonCode("r = await bash('echo can\\'t stop')")).toEqual({ + language: "bash", + text: "echo can't stop", + }); + expect(previewIpythonCode('r = await bash("grep -n \\")\\" src.c")')).toEqual({ + language: "bash", + text: 'grep -n ")" src.c', + }); + }); + + it("keeps the python preview for bash-looking text inside a multiline string", () => { + expect(previewIpythonCode('doc = """\nbash("git status")\n"""')).toEqual({ language: "python", - text: "r = await bash('echo can\\'t stop')", + text: 'bash("git status")', }); - // Escaped quote directly before the close quote: a mis-cut here would preview a wrong command. - expect(previewIpythonCode('r = await bash("grep -n \\")\\" src.c")').language).toBe("python"); }); it("previews the bash-skill call when the scorer picks it among other lines", () => { From 82b580fe82fc11b076401a1f3fd6232d19abafaa Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 10:40:26 +0200 Subject: [PATCH 5/7] test(coding-agent): kill scanner and gate mutants in bash-skill extraction Killer tests for three surviving mutants: raw-string backslash handling (raw close at backslash-quote), the closed-and-reopened multiline gate state, and unclosed-literal extraction of a partial value. --- .../coding-agent/test/code-preview.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/coding-agent/test/code-preview.test.ts b/packages/coding-agent/test/code-preview.test.ts index 2590c226b6..5e56a93df6 100644 --- a/packages/coding-agent/test/code-preview.test.ts +++ b/packages/coding-agent/test/code-preview.test.ts @@ -161,6 +161,19 @@ git add packages/foo.ts }); }); + it("keeps backslashes in raw bash-skill literals", () => { + // python: r'grep \'x\' f' stays open at the escaped quote and the backslash stays in the value. + expect(previewIpythonCode("r = await bash(r'grep \\'x\\' f')")).toEqual({ + language: "bash", + text: "grep \\'x\\' f", + }); + }); + + it("keeps the python preview when the literal never closes", () => { + // python: a raw newline inside a single-quoted literal is a syntax error, so no bash ran. + expect(previewIpythonCode("r = await bash('echo hi\n)").language).toBe("python"); + }); + it("evaluates escapes in bash-skill literals instead of previewing source text", () => { // \n in the source is a real newline in the executed command; salience follows the evaluated text. expect(previewIpythonCode("r = await bash('printf \"a\\nb\"\\ngit add -A')")).toEqual({ @@ -182,6 +195,11 @@ git add packages/foo.ts language: "python", text: 'bash("git status")', }); + // A triple-quoted string that closed above is code again: extraction must still work. + expect(previewIpythonCode('doc = """usage"""\nr = await bash(\'git status\')')).toEqual({ + language: "bash", + text: "git status", + }); }); it("previews the bash-skill call when the scorer picks it among other lines", () => { From 2f2298b258b77115582d666a4b3264493905ef50 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 10:42:47 +0200 Subject: [PATCH 6/7] fix(coding-agent): fall back on value-changing escapes in bash-skill literals --- .../coding-agent/src/core/tools/code-preview.ts | 14 +++++++++++--- packages/coding-agent/test/code-preview.test.ts | 6 ++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/core/tools/code-preview.ts b/packages/coding-agent/src/core/tools/code-preview.ts index fbc0916b28..6af54704b1 100644 --- a/packages/coding-agent/src/core/tools/code-preview.ts +++ b/packages/coding-agent/src/core/tools/code-preview.ts @@ -424,6 +424,8 @@ interface PythonStringScan { value: string; end: number; closed: boolean; + /** A cooked escape (\x, \u, octal, \a…) whose value the preview does not compute. */ + unsupportedEscape: boolean; } // Walks a python string-literal body starting just after the opening delimiter. @@ -433,16 +435,22 @@ interface PythonStringScan { function scanPythonStringLiteral(code: string, start: number, quote: string, raw: boolean): PythonStringScan { let value = ""; let i = start; + let unsupportedEscape = false; while (i < code.length) { const char = code[i] ?? ""; if (char === "\\" && i + 1 < code.length) { const next = code[i + 1] ?? ""; + if (!raw && /[xuUN0-7abfv]/.test(next)) { + // \x41, \u…, octal, \a\b\f\v change the value; showing the source + // form would preview a command that differs from what ran. + unsupportedEscape = true; + } value += raw ? char + next : (PYTHON_ESCAPES[next] ?? char + next); i += 2; continue; } if (code.startsWith(quote, i)) { - return { value, end: i + quote.length, closed: true }; + return { value, end: i + quote.length, closed: true, unsupportedEscape }; } if (quote.length === 1 && char === "\n") { break; // single-quoted literals cannot span lines @@ -450,7 +458,7 @@ function scanPythonStringLiteral(code: string, start: number, quote: string, raw value += char; i += 1; } - return { value, end: i, closed: false }; + return { value, end: i, closed: false, unsupportedEscape }; } // True when the lines end inside an unterminated triple-quoted string, meaning @@ -487,7 +495,7 @@ function extractBashSkillCommand(code: string): string | undefined { const start = match[0].length; const prefixChar = match[0][start - quote.length - 1]; const scan = scanPythonStringLiteral(code, start, quote, prefixChar === "r" || prefixChar === "R"); - if (!scan.closed) return undefined; + if (!scan.closed || scan.unsupportedEscape) return undefined; const rest = code.slice(scan.end).trimStart(); // Require a plain literal first argument; concatenation or other expressions fall back. if (!rest.startsWith(",") && !rest.startsWith(")")) return undefined; diff --git a/packages/coding-agent/test/code-preview.test.ts b/packages/coding-agent/test/code-preview.test.ts index 5e56a93df6..ed762a1fd9 100644 --- a/packages/coding-agent/test/code-preview.test.ts +++ b/packages/coding-agent/test/code-preview.test.ts @@ -174,6 +174,12 @@ git add packages/foo.ts expect(previewIpythonCode("r = await bash('echo hi\n)").language).toBe("python"); }); + it("keeps the python preview for value-changing escapes it does not compute", () => { + // '\x41' executes as 'A'; previewing the source form would show a command that never ran. + expect(previewIpythonCode("r = await bash('echo \\x41')").language).toBe("python"); + expect(previewIpythonCode("r = await bash('grep \\bword\\b f')").language).toBe("python"); + }); + it("evaluates escapes in bash-skill literals instead of previewing source text", () => { // \n in the source is a real newline in the executed command; salience follows the evaluated text. expect(previewIpythonCode("r = await bash('printf \"a\\nb\"\\ngit add -A')")).toEqual({ From 2402762c012ef7a6ccb836854227a31f0182e1de Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 12:23:12 +0200 Subject: [PATCH 7/7] fix(coding-agent): redact authorization headers in previews; tighten tests and comments --- .../src/core/tools/code-preview.ts | 17 ++-- .../coding-agent/test/code-preview.test.ts | 82 +++++++------------ 2 files changed, 36 insertions(+), 63 deletions(-) diff --git a/packages/coding-agent/src/core/tools/code-preview.ts b/packages/coding-agent/src/core/tools/code-preview.ts index 6af54704b1..0b2a42f67a 100644 --- a/packages/coding-agent/src/core/tools/code-preview.ts +++ b/packages/coding-agent/src/core/tools/code-preview.ts @@ -58,6 +58,7 @@ function redactNoise(text: string): string { /\b((?=\w*(?:token|key|secret|password))[A-Za-z_]\w*)\s*=\s*(?!)(?!["'])\S+/gi, "$1=", ) + .replace(/\b(authorization:\s*(?:bearer\s+)?)[^\s"']+/gi, "$1") .replace(/(["'])sk-[^"']+\1/g, "$1$1") .replace(/(["']).{160,}\1/g, "$1…$1"); } @@ -424,14 +425,12 @@ interface PythonStringScan { value: string; end: number; closed: boolean; - /** A cooked escape (\x, \u, octal, \a…) whose value the preview does not compute. */ + /** Saw a cooked escape (\x, \u, octal, \a…) whose value is not computed here. */ unsupportedEscape: boolean; } -// Walks a python string-literal body starting just after the opening delimiter. -// In both raw and cooked strings a backslash consumes the next char (python's -// raw-string rule: backslash-quote never closes the literal); only cooked -// strings unescape, and unknown escapes keep the backslash, matching python. +// Walks a python string-literal body from just after the opening delimiter, +// following python's escape rules (in raw strings backslash-quote never closes). function scanPythonStringLiteral(code: string, start: number, quote: string, raw: boolean): PythonStringScan { let value = ""; let i = start; @@ -441,8 +440,6 @@ function scanPythonStringLiteral(code: string, start: number, quote: string, raw if (char === "\\" && i + 1 < code.length) { const next = code[i + 1] ?? ""; if (!raw && /[xuUN0-7abfv]/.test(next)) { - // \x41, \u…, octal, \a\b\f\v change the value; showing the source - // form would preview a command that differs from what ran. unsupportedEscape = true; } value += raw ? char + next : (PYTHON_ESCAPES[next] ?? char + next); @@ -461,8 +458,7 @@ function scanPythonStringLiteral(code: string, start: number, quote: string, raw return { value, end: i, closed: false, unsupportedEscape }; } -// True when the lines end inside an unterminated triple-quoted string, meaning -// the following line is string text rather than code. +// True when the lines end inside an unterminated triple-quoted string. function endsInsideMultilineString(lines: readonly string[]): boolean { const text = lines.join("\n"); let i = 0; @@ -518,8 +514,7 @@ export function previewPythonCode(code: string): CodePreview { if (bestIndex !== undefined && bestScore >= 0) { const previewIndex = pythonPreviewIndex(lines, bestIndex); - // The literal may span lines below the chosen one, so extract from the full tail. - // A line inside a multiline string is text, not a bash-skill call: no bash ran. + // Extract from the full tail (literals may span lines), unless the chosen line is string text. const bashCommand = endsInsideMultilineString(lines.slice(0, previewIndex)) ? undefined : extractBashSkillCommand(lines.slice(previewIndex).join("\n")); diff --git a/packages/coding-agent/test/code-preview.test.ts b/packages/coding-agent/test/code-preview.test.ts index ed762a1fd9..d40e0cd43d 100644 --- a/packages/coding-agent/test/code-preview.test.ts +++ b/packages/coding-agent/test/code-preview.test.ts @@ -134,54 +134,22 @@ EOF`; const longPreview = previewIpythonCode(`result = await bash("${longCommand}", timeout=120)`); expect(longPreview.language).toBe("bash"); expect(longPreview.text.startsWith("git log --oneline")).toBe(true); + const scorer = `import json +r = await bash('git diff --stat') +print(r)`; + expect(previewIpythonCode(scorer)).toEqual({ language: "bash", text: "git diff --stat" }); + expect( + previewIpythonCode(`r = await bash('curl -H "Authorization: Bearer sec-abc123" https://api.example.com')`) + .text, + ).not.toContain("sec-abc123"); }); - it("passes triple-quoted bash-skill bodies through like %%bash cells", () => { - const code = `r = await bash(''' + it("evaluates bash-skill literals the way python does", () => { + const tripleBody = `r = await bash(''' set -e git add packages/foo.ts ''')`; - expect(previewIpythonCode(code)).toEqual({ language: "bash", text: "git add packages/foo.ts" }); - // Escaped quote adjacent to the closing delimiter: the full evaluated command, never a mis-cut prefix. - expect(previewIpythonCode("r = await bash('''echo it\\'''')")).toEqual({ language: "bash", text: "echo it'" }); - }); - - it("keeps the python preview for non-literal bash-skill arguments", () => { - expect(previewIpythonCode("r = await bash(cmd)")).toEqual({ language: "python", text: "r = await bash(cmd)" }); - expect(previewIpythonCode('r = await bash(f"git checkout {branch}")')).toEqual({ - language: "python", - text: 'r = await bash(f"git checkout {branch}")', - }); - }); - - it("keeps the python preview when the literal is not the whole first argument", () => { - expect(previewIpythonCode("r = await bash('echo ' + name)")).toEqual({ - language: "python", - text: "r = await bash('echo ' + name)", - }); - }); - - it("keeps backslashes in raw bash-skill literals", () => { - // python: r'grep \'x\' f' stays open at the escaped quote and the backslash stays in the value. - expect(previewIpythonCode("r = await bash(r'grep \\'x\\' f')")).toEqual({ - language: "bash", - text: "grep \\'x\\' f", - }); - }); - - it("keeps the python preview when the literal never closes", () => { - // python: a raw newline inside a single-quoted literal is a syntax error, so no bash ran. - expect(previewIpythonCode("r = await bash('echo hi\n)").language).toBe("python"); - }); - - it("keeps the python preview for value-changing escapes it does not compute", () => { - // '\x41' executes as 'A'; previewing the source form would show a command that never ran. - expect(previewIpythonCode("r = await bash('echo \\x41')").language).toBe("python"); - expect(previewIpythonCode("r = await bash('grep \\bword\\b f')").language).toBe("python"); - }); - - it("evaluates escapes in bash-skill literals instead of previewing source text", () => { - // \n in the source is a real newline in the executed command; salience follows the evaluated text. + expect(previewIpythonCode(tripleBody)).toEqual({ language: "bash", text: "git add packages/foo.ts" }); expect(previewIpythonCode("r = await bash('printf \"a\\nb\"\\ngit add -A')")).toEqual({ language: "bash", text: "git add -A", @@ -194,6 +162,24 @@ git add packages/foo.ts language: "bash", text: 'grep -n ")" src.c', }); + expect(previewIpythonCode("r = await bash('''echo it\\'''')")).toEqual({ language: "bash", text: "echo it'" }); + expect(previewIpythonCode("r = await bash(r'grep \\'x\\' f')")).toEqual({ + language: "bash", + text: "grep \\'x\\' f", + }); + }); + + it("keeps the python preview when the exact command cannot be known", () => { + for (const code of [ + "r = await bash(cmd)", + 'r = await bash(f"git checkout {branch}")', + "r = await bash('echo ' + name)", + "r = await bash('echo hi\n)", // unterminated literal: python syntax error + "r = await bash('echo \\x41')", // value-changing escape not computed here + "r = await bash('grep \\bword\\b f')", + ]) { + expect(previewIpythonCode(code).language).toBe("python"); + } }); it("keeps the python preview for bash-looking text inside a multiline string", () => { @@ -201,20 +187,12 @@ git add packages/foo.ts language: "python", text: 'bash("git status")', }); - // A triple-quoted string that closed above is code again: extraction must still work. - expect(previewIpythonCode('doc = """usage"""\nr = await bash(\'git status\')')).toEqual({ + expect(previewIpythonCode(`doc = """usage"""\nr = await bash('git status')`)).toEqual({ language: "bash", text: "git status", }); }); - it("previews the bash-skill call when the scorer picks it among other lines", () => { - const code = `import json -r = await bash('git diff --stat') -print(r)`; - expect(previewIpythonCode(code)).toEqual({ language: "bash", text: "git diff --stat" }); - }); - it("prefers a later meaningful heredoc over an earlier generic one", () => { const command = `cat <<'CFG' key=value