From 23aa75ddd55af90c004ebc520b5a7fdd0dae2ee4 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:06:15 +0200 Subject: [PATCH] fix(grok-cli): sanitize function_call_output before Grok Build dispatch (#7611) (#8030) * fix(grok-cli): sanitize function_call_output before Grok Build dispatch (#7611) Grok Build cli-chat-proxy rejects Responses bodies when tool-result outputs contain incomplete \u escapes or other malformed JSON text. Sanitize function_call_output.output values in GrokCliExecutor so large agent tool transcripts no longer fail intermittently with 400 body-parse errors. * fix(grok-cli): type test credentials instead of casting through any (#7611) tests/ has no-explicit-any as an ESLint error; the 3 `{ accessToken: "tok" } as any` casts passed to transformRequest() were the proven, non-drift cause of this PR's own "No new ESLint warnings" CI failure. Replace them with a single properly-typed ProviderCredentials literal (all fields on that type are optional, so no cast is needed). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Ravi Tharuma Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- ...-grok-cli-function-call-output-sanitize.md | 1 + open-sse/executors/grok-cli.ts | 64 +++++++++++++- ...function-call-output-sanitize-7611.test.ts | 88 +++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/7611-grok-cli-function-call-output-sanitize.md create mode 100644 tests/unit/grok-cli-function-call-output-sanitize-7611.test.ts diff --git a/changelog.d/fixes/7611-grok-cli-function-call-output-sanitize.md b/changelog.d/fixes/7611-grok-cli-function-call-output-sanitize.md new file mode 100644 index 0000000000..f078f38633 --- /dev/null +++ b/changelog.d/fixes/7611-grok-cli-function-call-output-sanitize.md @@ -0,0 +1 @@ +- **grok-cli:** sanitize Responses `function_call_output.output` values before dispatch so incomplete `\u` escapes / malformed tool results no longer cause intermittent 400 body-parse failures on cli-chat-proxy (#7611). diff --git a/open-sse/executors/grok-cli.ts b/open-sse/executors/grok-cli.ts index f0e7f6e398..275e8ebfdd 100644 --- a/open-sse/executors/grok-cli.ts +++ b/open-sse/executors/grok-cli.ts @@ -33,6 +33,67 @@ const GROK_BUILD_UNSUPPORTED_PARAMS = [ "reasoning_effort", ]; + +/** + * Grok Build's cli-chat-proxy is stricter about Responses `function_call_output.output` + * than OpenAI's Responses API. Agent tool results can contain truncated / incomplete + * JSON strings or invalid `\uXXXX` escapes, which fail upstream with: + * Failed to parse the request body as JSON: input[N].output: unexpected end of hex escape + * + * Normalize outputs to valid JSON text (or plain text) before dispatch (#7611). + */ +function sanitizeGrokBuildFunctionCallOutput(output: unknown): string { + if (output == null) return ""; + if (typeof output === "string") { + const value = output; + try { + return JSON.stringify(JSON.parse(value)); + } catch { + // fall through + } + // Drop incomplete \u escapes (0-3 hex digits) that break strict JSON parsers. + const repaired = value.replace(/\\u([0-9A-Fa-f]{0,3})(?![0-9A-Fa-f])/g, ""); + try { + return JSON.stringify(JSON.parse(repaired)); + } catch { + return repaired.replace(/[\uD800-\uDFFF]/g, "\uFFFD"); + } + } + if (Array.isArray(output)) { + const textParts = output + .map((part) => { + if (part && typeof part === "object") { + const rec = part as Record; + if (typeof rec.text === "string") return rec.text; + } + return typeof part === "string" ? part : JSON.stringify(part); + }) + .join("\n"); + return sanitizeGrokBuildFunctionCallOutput(textParts); + } + try { + return JSON.stringify(output); + } catch { + return String(output); + } +} + +function sanitizeGrokBuildResponsesBody(body: Record): Record { + const input = body.input; + if (!Array.isArray(input)) return body; + let changed = false; + const nextInput = input.map((item) => { + if (!item || typeof item !== "object") return item; + const rec = item as Record; + if (rec.type !== "function_call_output") return item; + const sanitized = sanitizeGrokBuildFunctionCallOutput(rec.output); + if (sanitized === rec.output) return item; + changed = true; + return { ...rec, output: sanitized }; + }); + return changed ? { ...body, input: nextInput } : body; +} + type GrokBuildRefreshResult = Partial | null | undefined; function nonEmptyString(value: unknown): string | null { @@ -247,6 +308,7 @@ export class GrokCliExecutor extends BaseExecutor { transformed.tools = transformed.tools.slice(0, GROK_BUILD_MAX_TOOLS); } - return transformed; + // Repair tool-result payloads that would fail Grok's strict JSON body parser (#7611). + return sanitizeGrokBuildResponsesBody(transformed); } } diff --git a/tests/unit/grok-cli-function-call-output-sanitize-7611.test.ts b/tests/unit/grok-cli-function-call-output-sanitize-7611.test.ts new file mode 100644 index 0000000000..a5df9b045b --- /dev/null +++ b/tests/unit/grok-cli-function-call-output-sanitize-7611.test.ts @@ -0,0 +1,88 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { GrokCliExecutor } from "../../open-sse/executors/grok-cli.ts"; +import type { ProviderCredentials } from "../../open-sse/executors/base.ts"; + +const testCredentials: ProviderCredentials = { accessToken: "tok" }; + +test("#7611: GrokCliExecutor sanitizes incomplete hex escapes in function_call_output", () => { + const executor = new GrokCliExecutor(); + const body = { + model: "grok-4.5", + input: [ + { + type: "function_call", + call_id: "call_1", + name: "read_file", + arguments: "{}", + }, + { + type: "function_call_output", + call_id: "call_1", + // incomplete \u escape — the production failure mode + output: '{"path":"foo","snippet":"bad \\u12"}', + }, + ], + }; + + const transformed = executor.transformRequest("grok-4.5", body, true, testCredentials) as Record< + string, + unknown + >; + + const input = transformed.input as Array>; + const outputItem = input.find((item) => item.type === "function_call_output"); + assert.ok(outputItem, "function_call_output item should remain"); + assert.equal(typeof outputItem.output, "string"); + // Must be re-parseable as a JSON string payload after sanitization. + assert.doesNotThrow(() => JSON.parse(JSON.stringify({ output: outputItem.output }))); + assert.doesNotMatch(String(outputItem.output), /\\u12(?![0-9A-Fa-f])/); +}); + +test("#7611: valid JSON tool outputs are re-serialized cleanly", () => { + const executor = new GrokCliExecutor(); + const payload = { ok: true, data: ["a", "b"], note: "café" }; + const body = { + model: "grok-4.5", + input: [ + { + type: "function_call_output", + call_id: "call_2", + output: JSON.stringify(payload), + }, + ], + }; + const transformed = executor.transformRequest("grok-4.5", body, false, testCredentials) as Record< + string, + unknown + >; + const input = transformed.input as Array>; + const outputItem = input.find((item) => item.type === "function_call_output"); + assert.deepEqual(JSON.parse(String(outputItem?.output)), payload); +}); + +test("#7611: array content parts are flattened to text", () => { + const executor = new GrokCliExecutor(); + const body = { + model: "grok-4.5", + input: [ + { + type: "function_call_output", + call_id: "call_3", + output: [ + { type: "input_text", text: "hello" }, + { type: "input_text", text: "world" }, + ], + }, + ], + }; + const transformed = executor.transformRequest("grok-4.5", body, false, testCredentials) as Record< + string, + unknown + >; + const input = transformed.input as Array>; + const outputItem = input.find((item) => item.type === "function_call_output"); + assert.equal(typeof outputItem?.output, "string"); + assert.match(String(outputItem?.output), /hello/); + assert.match(String(outputItem?.output), /world/); +});