From 916e78fa881a245d402024ae34768821927b5e2d Mon Sep 17 00:00:00 2001 From: Johan Vaz Date: Wed, 4 Nov 2026 15:17:15 +0800 Subject: [PATCH] fix(fx): preserve clipped literal Unicode escape text Decode readable JSON string fragments before repairing incomplete Unicode escapes. Cover literal suffixes, backslash parity, and existing normalization at the decoder layer. Implemented by GPT-6 Astra and finished by GPT-6.1 Sol through the Codex harness. --- .../provider-fx/src/server/adapter.test.ts | 44 +++++++++++++++++++ packages/provider-fx/src/server/adapter.ts | 7 ++- 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 packages/provider-fx/src/server/adapter.test.ts diff --git a/packages/provider-fx/src/server/adapter.test.ts b/packages/provider-fx/src/server/adapter.test.ts new file mode 100644 index 0000000000000..d69d584602d8b --- /dev/null +++ b/packages/provider-fx/src/server/adapter.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { fxSubagentOutcome } from "./adapter.ts"; + +describe("fx subagent replies", () => { + it.each(["", "1", "12", "123"])("preserves literal Unicode spelling ending in u%s", (digits) => { + for (const slashes of [1, 2]) { + const suffix = "\\".repeat(slashes) + "u" + digits; + const padding = "x".repeat( + 200 - '{"ok":true,"result":"'.length - JSON.stringify(suffix).length + 2, + ); + const reply = padding + suffix + "4 more text"; + const whole = JSON.stringify({ ok: true, result: reply }); + expect(fxSubagentOutcome(whole)).toEqual({ ok: true, result: reply }); + expect(fxSubagentOutcome(Buffer.from(whole).subarray(0, 200).toString("utf8"))).toEqual({ + ok: true, + result: padding + suffix + "…", + }); + } + }); + + it.each([0, 1, 2])( + "recovers incomplete Unicode escapes after %i literal backslashes", + (slashes) => { + for (const digits of ["", "0", "00", "000"]) { + const prefix = "reply" + "\\".repeat(slashes * 2); + expect(fxSubagentOutcome('{"ok":true,"result":"' + prefix + "\\u" + digits)).toEqual({ + ok: true, + result: "reply" + "\\".repeat(slashes) + "…", + }); + } + }, + ); + + it.each([ + ['{"ok":true,"result":"hello', { ok: true, result: "hello…" }], + ['{"ok":false,"result":null}', { ok: false, result: null }], + ['{"ok":true,"result":"hello"', { ok: true, result: "hello" }], + ['{"unexpected":"body"}', undefined], + ['{"ok":true,"result":"\\q', { ok: true, result: null }], + ])("retains existing normalization for %s", (text, expected) => { + expect(fxSubagentOutcome(text)).toEqual(expected); + }); +}); diff --git a/packages/provider-fx/src/server/adapter.ts b/packages/provider-fx/src/server/adapter.ts index 868b13f497de3..b48293f4fb986 100644 --- a/packages/provider-fx/src/server/adapter.ts +++ b/packages/provider-fx/src/server/adapter.ts @@ -102,14 +102,17 @@ const decodeJsonString = Schema.decodeUnknownOption(Schema.fromJsonString(Schema * fx clips every tool result to 199 bytes, so a long sub-agent reply arrives * as cut-off `{"ok":…,"result":"…` JSON. Keeps the readable prefix and marks the cut. */ -function fxSubagentOutcome( +export function fxSubagentOutcome( text: string, ): { readonly ok: boolean; readonly result: string | null } | undefined { const whole = decodeFxSubagentResult(text); if (Option.isSome(whole)) return whole.value; const clipped = /^\{"ok":(true|false),"result":"((?:[^"\\]|\\.)*)/.exec(text); if (clipped === null) return undefined; - const prefix = decodeJsonString(`"${(clipped[2] ?? "").replace(/\\u[0-9a-fA-F]{0,3}$/, "")}"`); + const fragment = clipped[2] ?? ""; + const prefix = decodeJsonString(`"${fragment}"`).pipe( + Option.orElse(() => decodeJsonString(`"${fragment.replace(/\\u[0-9a-fA-F]{0,3}$/, "")}"`)), + ); const cut = text[clipped[0].length] !== '"'; return { ok: clipped[1] === "true",