Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 64 additions & 35 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> => {
try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; }
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions tests/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down
Loading