Skip to content

Commit 1cbdeab

Browse files
ly-wang19claude
andcommitted
fix(llm): trust control escapes in JSON strings that escape backslashes
repairJson has a Windows-path heuristic: inside a string, a backslash before a control-escape letter (b/f/n/r/t) whose decoded prefix looks like a Windows path is treated as an unescaped path separator and doubled, so a model that emits a raw path like {"path":"C:\new\file"} is repaired to the literal path instead of a newline/formfeed. That bet is deliberate and test-pinned for unescaped paths. But the heuristic inspected only the DECODED prefix, which is identical whether the wire used escaped "\\" (valid) or raw "\" (malformed). So for a string that properly escapes its path — e.g. JSON.stringify({text:"C:\\Users\\me\nDone."}), where the path uses "\\" and the \n is a genuine newline — it still fired and turned the real newline into a literal backslash-n. OpenAI/Azure/ChatGPT Responses transports feed parseStreamingJson straight into tool calls with no JSON.parse-first guard, so a model emitting a Bash/Write/Edit argument with a properly escaped Windows path plus multi-line content ran a corrupted command. Fix: once a string contains a properly escaped backslash ("\\"), the model demonstrably escapes backslashes, so its later \n/\t/etc. are genuine control escapes — skip the path heuristic for the rest of that string. Strings with no escaped backslash (the pinned unescaped-path cases) repair exactly as before; this only adds positive escaping evidence as a suppression signal. Add a regression test asserting an escaped path followed by a real newline/tab round-trips to JSON.parse. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent c6ade83 commit 1cbdeab

2 files changed

Lines changed: 32 additions & 2 deletions

File tree

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@ describe("json-parse repairJson invalid \\u escapes", () => {
3131
});
3232
});
3333

34+
it("preserves a real control escape after an escaped Windows path", () => {
35+
// Valid JSON whose path uses escaped \\ separators and whose \n is a genuine
36+
// newline. The Windows-path repair heuristic must not fire here — doing so
37+
// turned the real newline into a literal backslash-n, corrupting tool-call
38+
// arguments (e.g. a multi-line shell command containing a Windows path).
39+
const wire = JSON.stringify({ text: "Saved to C:\\Users\\me\nDone." });
40+
expect(parseJsonWithRepair(wire)).toEqual(JSON.parse(wire));
41+
expect((parseJsonWithRepair(wire) as { text: string }).text).toContain("\n");
42+
expect((parseStreamingJson(wire) as { text: string }).text).toContain("\n");
43+
// A real tab after an escaped path is likewise a genuine control escape.
44+
const tabbed = JSON.stringify({ p: "D:\\a\\b\tEND" });
45+
expect(parseJsonWithRepair(tabbed)).toEqual(JSON.parse(tabbed));
46+
});
47+
3448
it("recovers streaming tool-call arguments instead of dropping them to {}", () => {
3549
// LaTeX-style \u (\underline) is a valid string value the model may emit in args.
3650
const args = '{"cmd":"\\underline{x}"}';

src/llm/utils/json-parse.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export function repairJson(json: string): string {
3535
let repaired = "";
3636
let inString = false;
3737
let stringValuePrefix = "";
38+
// Once a string contains a properly escaped backslash, the model demonstrably
39+
// escapes correctly, so later \n/\t/etc. are genuine control escapes — not
40+
// unescaped Windows path separators. Tracking this stops the path heuristic
41+
// below from corrupting a real newline that follows an escaped path.
42+
let sawEscapedBackslash = false;
3843

3944
for (let index = 0; index < json.length; index++) {
4045
const char = json[index];
@@ -44,6 +49,7 @@ export function repairJson(json: string): string {
4449
if (char === '"') {
4550
inString = true;
4651
stringValuePrefix = "";
52+
sawEscapedBackslash = false;
4753
}
4854
continue;
4955
}
@@ -52,6 +58,7 @@ export function repairJson(json: string): string {
5258
repaired += char;
5359
inString = false;
5460
stringValuePrefix = "";
61+
sawEscapedBackslash = false;
5562
continue;
5663
}
5764

@@ -79,15 +86,24 @@ export function repairJson(json: string): string {
7986
continue;
8087
}
8188

82-
if (JSON_CONTROL_ESCAPES.has(nextChar) && looksLikeWindowsPathPrefix(stringValuePrefix)) {
89+
if (
90+
JSON_CONTROL_ESCAPES.has(nextChar) &&
91+
!sawEscapedBackslash &&
92+
looksLikeWindowsPathPrefix(stringValuePrefix)
93+
) {
8394
repaired += "\\\\";
8495
stringValuePrefix += "\\";
8596
continue;
8697
}
8798

8899
if (VALID_JSON_ESCAPES.has(nextChar)) {
89100
repaired += `\\${nextChar}`;
90-
stringValuePrefix += nextChar === "\\" ? "\\" : `\\${nextChar}`;
101+
if (nextChar === "\\") {
102+
stringValuePrefix += "\\";
103+
sawEscapedBackslash = true;
104+
} else {
105+
stringValuePrefix += `\\${nextChar}`;
106+
}
91107
index += 1;
92108
continue;
93109
}

0 commit comments

Comments
 (0)