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",