Skip to content

Commit bf74d34

Browse files
committed
fix(json-parse): exclude code-context tails from Windows-path heuristic
The repairJson helper detects unescaped backslashes in Windows path strings (\bin, \users) and preserves them as literal characters instead of rewriting them as JSON escape sequences. The detector keys off a tail ending in `<non-alphanum><letter>:`, which matches genuine drive letters like `C:` but also matches Python block openers like `if x:`, `for x:`, `try:`, `def foo(x):`. When the next character was a real newline, the model output got rewritten so the shell saw a literal `\n` instead of an actual newline. This broke any agent tool call that carried multi-line Python in a heredoc, with the symptom that the shell parser threw `SyntaxError: unexpected character after line continuation character` and the cron agent burned thousands of output tokens trying to work around the corruption. Fix: reject any tail that contains code-context markers like parens, braces, equals, semicolons, single-quotes, commas, shell redirects, or pipes. Real Windows path tails do not contain those characters, but nearly every Python/JS/shell block opener does. Adds regression tests for the failing patterns and a mixed-content test that confirms a Windows path adjacent to code still survives. Refs #93139
1 parent 10d850b commit bf74d34

2 files changed

Lines changed: 60 additions & 0 deletions

File tree

src/llm/utils/json-parse.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,54 @@ describe("json-parse repairJson invalid \\u escapes", () => {
4444
},
4545
);
4646
});
47+
48+
describe("json-parse repairJson Windows-path false positives (issue #93139)", () => {
49+
// Before the code-context guard, the Windows-path heuristic matched any
50+
// tail ending in `<non-alphanum><letter>:` — which also matches Python
51+
// block openers like `if x:`, `for x:`, `try:`, `def foo(x):` — and the
52+
// newline immediately after the colon got rewritten as a literal `\\n`
53+
// instead of an actual newline. That broke any agent tool call carrying
54+
// multi-line Python in a heredoc, with the symptom that the shell saw a
55+
// line-continuation character and refused to run the script.
56+
57+
it.each([
58+
[
59+
"Python if-block in a heredoc",
60+
'{"command": "python3 << EOF\\nif x:\\n print(1)\\nEOF"}',
61+
"python3 << EOF\nif x:\n print(1)\nEOF",
62+
],
63+
[
64+
"Python try/except",
65+
'{"command": "try:\\n foo()\\nexcept Exception as e:\\n bar()"}',
66+
"try:\n foo()\nexcept Exception as e:\n bar()",
67+
],
68+
[
69+
"Python def with parens in the signature",
70+
'{"command": "def foo(x):\\n return x"}',
71+
"def foo(x):\n return x",
72+
],
73+
["Python while-True loop", '{"command": "while True:\\n break"}', "while True:\n break"],
74+
[
75+
"reproducer reported in #93139 (if r:)",
76+
'{"command": "r = re.match(p, s)\\nif r:\\n print(r)"}',
77+
"r = re.match(p, s)\nif r:\n print(r)",
78+
],
79+
[
80+
"bash for-loop with shell redirect in prior context",
81+
'{"command": "for f in *.py:\\n echo $f"}',
82+
"for f in *.py:\n echo $f",
83+
],
84+
])("preserves real newlines after Python/shell block openers: %s", (_name, input, expected) => {
85+
expect(parseJsonWithRepair(input)).toEqual({ command: expected });
86+
expect(parseStreamingJson(input)).toEqual({ command: expected });
87+
});
88+
89+
it("still preserves a Windows path that appears alongside code", () => {
90+
// Confirms the guard is scoped: the path part stays escaped, the code
91+
// part gets real newlines. Both parts of the same string survive.
92+
const input = '{"text": "see C:\\\\Users for path; if x:\\n do_stuff()"}';
93+
expect(parseJsonWithRepair(input)).toEqual({
94+
text: "see C:\\Users for path; if x:\n do_stuff()",
95+
});
96+
});
97+
});

src/llm/utils/json-parse.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ export function parseJsonWithRepair(json: string): unknown {
114114

115115
function looksLikeWindowsPathPrefix(prefix: string): boolean {
116116
const tail = prefix.slice(-160);
117+
// Reject obvious code-context markers (parentheses, braces, equals,
118+
// semicolons, single-quotes, commas, shell redirects/pipes). A Windows
119+
// path tail will not contain these, but a Python/JS/shell block opener
120+
// like "if x:" or "for x:" or "def foo(x):" will — and without this
121+
// guard we treat the next \n as a path separator and emit a literal
122+
// backslash-n instead of a real newline. See issue #93139.
123+
if (/[()=;{}',<>|]/.test(tail)) {
124+
return false;
125+
}
117126
return /(?:^|[^A-Za-z0-9])[A-Za-z]:(?:[\\/][^"\\/:*?<>|\r\n]*)*$/.test(tail);
118127
}
119128

0 commit comments

Comments
 (0)