diff --git a/src/bridge.ts b/src/bridge.ts index 45f046f42..a401f1808 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -211,29 +211,6 @@ export function bridgeToResponsesSSE( // the completed custom_tool_call item stays authoritative). Compact `{"input":"...` // buffers get their string value progressively unescaped; anything else streams raw. const FREEFORM_WRAP_PREFIX = '{"input":"'; - const freeformPartialInput = (args: string): string => { - if (!args.startsWith(FREEFORM_WRAP_PREFIX)) return args; - const body = args.slice(FREEFORM_WRAP_PREFIX.length); - let out = ""; - for (let i = 0; i < body.length; i++) { - const c = body[i]; - if (c === '"') break; // unescaped closing quote: value complete - if (c === "\\") { - const n = body[i + 1]; - if (n === undefined) break; // escape split across chunks: wait for more - i++; - if (n === "n") out += "\n"; - else if (n === "t") out += "\t"; - else if (n === "r") out += "\r"; - else if (n === "u") { - const hex = body.slice(i + 1, i + 5); - if (hex.length === 4 && /^[0-9a-fA-F]{4}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 4; } - else break; // incomplete \uXXXX: wait for more - } else out += n; // \" \\ \/ etc. - } else out += c; - } - return out; - }; // tool_search_call carries arguments as a JSON object ({query, limit}); parse the model's arg string. const parseArgsObj = (args: string): Record => { try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; } @@ -484,7 +461,14 @@ export function bridgeToResponsesSSE( // synthetic compaction item's payload on done. let compactionText = ""; let compactionTextBytes = 0; - let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string } | null = null; + let currentToolCall: { + itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; + namespace?: string; freeform?: boolean; toolSearch?: boolean; + previewMode?: "prefix" | "wrapped" | "raw" | "done"; + previewPrefix?: string; + previewEscape?: boolean; + previewUnicode?: string; + } | null = null; // Open native web-search cell (between begin and end). Holds the output index allocated on // begin so the matching done reuses it; closed as `failed` if the stream terminates early. let currentWebSearch: { itemId: string; eventId: string; outputIndex: number } | null = null; @@ -996,19 +980,64 @@ export function bridgeToResponsesSSE( }); } if (currentToolCall.freeform) { - // Hold while the buffer is still an ambiguous prefix of the JSON wrapper, - // then stream only the unwrapped input suffix (never rewind on mode flips). - if (!FREEFORM_WRAP_PREFIX.startsWith(currentToolCall.args)) { - const full = freeformPartialInput(currentToolCall.args); - const emitted = currentToolCall.inputEmitted ?? ""; - if (full.startsWith(emitted) && full.length > emitted.length) { - emit("response.custom_tool_call_input.delta", { - item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex, - delta: full.slice(emitted.length), - }); - currentToolCall.inputEmitted = full; + // Decode only the newly arrived fragment. Re-decoding the accumulated argument + // buffer here makes one-byte upstream chunks quadratic in total input size. + let pending = event.arguments; + let decoded = ""; + currentToolCall.previewMode ??= "prefix"; + if (currentToolCall.previewMode === "prefix") { + const prefix = (currentToolCall.previewPrefix ?? "") + pending; + if (FREEFORM_WRAP_PREFIX.startsWith(prefix)) { + currentToolCall.previewPrefix = prefix; + pending = ""; + } else if (prefix.startsWith(FREEFORM_WRAP_PREFIX)) { + currentToolCall.previewMode = "wrapped"; + currentToolCall.previewPrefix = undefined; + pending = prefix.slice(FREEFORM_WRAP_PREFIX.length); + } else { + currentToolCall.previewMode = "raw"; + currentToolCall.previewPrefix = undefined; + pending = prefix; } } + if (currentToolCall.previewMode === "raw") { + decoded = pending; + } else if (currentToolCall.previewMode === "wrapped") { + for (const c of pending) { + if (currentToolCall.previewUnicode !== undefined) { + currentToolCall.previewUnicode += c; + if (currentToolCall.previewUnicode.length === 4) { + if (/^[0-9a-fA-F]{4}$/.test(currentToolCall.previewUnicode)) { + decoded += String.fromCharCode(parseInt(currentToolCall.previewUnicode, 16)); + currentToolCall.previewUnicode = undefined; + } else { + currentToolCall.previewMode = "done"; + break; + } + } + } else if (currentToolCall.previewEscape) { + currentToolCall.previewEscape = false; + if (c === "n") decoded += "\n"; + else if (c === "t") decoded += "\t"; + else if (c === "r") decoded += "\r"; + else if (c === "u") currentToolCall.previewUnicode = ""; + else decoded += c; + } else if (c === "\\") { + currentToolCall.previewEscape = true; + } else if (c === '"') { + currentToolCall.previewMode = "done"; + break; + } else { + decoded += c; + } + } + } + if (decoded) { + emit("response.custom_tool_call_input.delta", { + item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex, + delta: decoded, + }); + } } } break; diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 848029252..42245119d 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -655,6 +655,26 @@ describe("Responses bridge reasoning and usage parity", () => { expect(frames.some(f => f.event === "response.function_call_arguments.done")).toBe(false); }); + test("streaming freeform preview handles large one-byte argument streams incrementally", async () => { + const input = "x".repeat(32_000); + const wrapped = JSON.stringify({ input }); + const events: AdapterEvent[] = [ + { type: "tool_call_start", id: "c1", name: "apply_patch" }, + ...Array.from(wrapped, char => ({ type: "tool_call_delta" as const, arguments: char })), + { type: "tool_call_end" }, + { type: "done" }, + ]; + + const frames = await collectSse(bridgeToResponsesSSE( + replay(events), "model", undefined, new Set(["apply_patch"]), + )); + const preview = frames + .filter(frame => frame.event === "response.custom_tool_call_input.delta") + .map(frame => frame.data.delta) + .join(""); + expect(preview).toBe(input); + }, 3_000); + test("non-streaming error produces failed status", () => { const json = buildResponseJSON([ {