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..0b2a42f67a 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*\(/; @@ -57,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"); } @@ -409,6 +411,93 @@ 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; + /** Saw a cooked escape (\x, \u, octal, \a…) whose value is not computed here. */ + unsupportedEscape: boolean; +} + +// 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; + 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)) { + 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, unsupportedEscape }; + } + if (quote.length === 1 && char === "\n") { + break; // single-quoted literals cannot span lines + } + value += char; + i += 1; + } + return { value, end: i, closed: false, unsupportedEscape }; +} + +// 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; + 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 (!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 || 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; + return scan.value; +} + export function previewPythonCode(code: string): CodePreview { const lines = code.split("\n"); const paths = pythonPathVars(lines); @@ -425,6 +514,13 @@ export function previewPythonCode(code: string): CodePreview { if (bestIndex !== undefined && bestScore >= 0) { const previewIndex = pythonPreviewIndex(lines, bestIndex); + // 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")); + 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..d40e0cd43d 100644 --- a/packages/coding-agent/test/code-preview.test.ts +++ b/packages/coding-agent/test/code-preview.test.ts @@ -125,6 +125,74 @@ 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); + 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("evaluates bash-skill literals the way python does", () => { + const tripleBody = `r = await bash(''' +set -e +git add packages/foo.ts +''')`; + 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", + }); + 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', + }); + 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", () => { + expect(previewIpythonCode('doc = """\nbash("git status")\n"""')).toEqual({ + language: "python", + text: 'bash("git status")', + }); + expect(previewIpythonCode(`doc = """usage"""\nr = await bash('git status')`)).toEqual({ + language: "bash", + text: "git status", + }); + }); + it("prefers a later meaningful heredoc over an earlier generic one", () => { const command = `cat <<'CFG' key=value