Files
9router/tests/unit/responses-parallel-tool-calls.test.js
Sina Sadeghi 11222eff0f feat(opencode-go): muse-spark-1.2 and Responses tool fixes (#3820)
- Add muse-spark-1.2-contributor as responses-only model on OpenCode Go
- Normalize object tool schemas without properties in OpenCode Go executor
- Make fallback Responses call_ids unique across same-millisecond calls
- Make Responses output coercion fail-soft for circular and non-stringifiable values
2026-09-05 22:39:22 +07:00

166 lines
6.6 KiB
JavaScript

// Parallel function_calls from a Responses upstream must stay on separate
// chat tool_calls indices. Regression: response/openai-responses.js attributed
// every arguments delta to the positional toolCallIndex (advanced only on
// output_item.done), so all-added-then-deltas ordering concatenated N JSON
// payloads into index 0 and clients failed with InputValidationError.
import { describe, expect, it } from "vitest";
import "../translator/registerAll.js";
import { openaiResponsesToOpenAIResponse } from "../../open-sse/translator/response/openai-responses.js";
import { clampResponsesCallId, coerceResponsesOutput, MAX_RESPONSES_CALL_ID_LEN } from "../../open-sse/translator/formats/responsesApi.js";
import { initState, translateResponse } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
const added = (id, call_id, name, type = "function_call") => ({
type: "response.output_item.added",
item: { id, type, call_id, name, arguments: "" },
});
const delta = (item_id, text) => ({
type: "response.function_call_arguments.delta",
item_id,
delta: text,
});
const done = (id, call_id, name) => ({
type: "response.output_item.done",
item: { id, type: "function_call", call_id, name },
});
// Reassemble translated chunks the way an OpenAI client accumulator does.
function accumulate(calls, chunks) {
for (const chunk of chunks) {
if (!chunk) continue;
for (const tc of chunk.choices?.[0]?.delta?.tool_calls || []) {
const slot = (calls[tc.index] ??= { id: null, name: "", args: "" });
if (tc.id) slot.id = tc.id;
if (tc.function?.name) slot.name = tc.function.name;
if (tc.function?.arguments) slot.args += tc.function.arguments;
}
}
return calls;
}
function runStream(events) {
const state = {};
const chunks = [];
for (const ev of events) {
const out = openaiResponsesToOpenAIResponse(ev, state);
if (out) chunks.push(out);
}
const flush = openaiResponsesToOpenAIResponse(null, state);
if (flush) chunks.push(flush);
return { state, chunks };
}
const PAYLOADS = [
'{"file_path":"/docs/PRODUCT.md"}',
'{"file_path":"/docs/ROADMAP.md"}',
'{"file_path":"/docs/openapi.custom.yaml"}',
'{"file_path":"/docs/.gitignore"}',
];
function hostileOrdering() {
const events = PAYLOADS.map((_, i) => added(`fc_${i}`, `call_${i}`, "read_file"));
// Interleaved deltas AFTER all addeds — the ordering that used to merge all
// four payloads into index 0.
PAYLOADS.forEach((p, i) => events.push(delta(`fc_${i}`, p.slice(0, 20)), delta(`fc_${i}`, p.slice(20))));
PAYLOADS.forEach((_, i) => events.push(done(`fc_${i}`, `call_${i}`, "read_file")));
return events;
}
describe("responses parallel tool calls keep their own index", () => {
it("all-added-then-deltas ordering yields 4 separately parseable calls", () => {
const { chunks } = runStream(hostileOrdering());
const calls = accumulate({}, chunks);
expect(Object.keys(calls)).toHaveLength(4);
PAYLOADS.forEach((p, i) => {
expect(calls[i].id).toBe(`call_${i}`);
expect(calls[i].name).toBe("read_file");
expect(JSON.parse(calls[i].args)).toEqual(JSON.parse(p));
});
});
it("sequential ordering still yields indices 0,1 in order", () => {
const events = [
added("fc_0", "call_0", "read_file"),
delta("fc_0", PAYLOADS[0]),
done("fc_0", "call_0", "read_file"),
added("fc_1", "call_1", "read_file"),
delta("fc_1", PAYLOADS[1]),
done("fc_1", "call_1", "read_file"),
];
const { chunks } = runStream(events);
const calls = accumulate({}, chunks);
expect(Object.keys(calls)).toEqual(["0", "1"]);
expect(JSON.parse(calls[0].args)).toEqual(JSON.parse(PAYLOADS[0]));
expect(JSON.parse(calls[1].args)).toEqual(JSON.parse(PAYLOADS[1]));
});
it("done carrying full arguments (no deltas) emits them once", () => {
const state = {};
const out1 = openaiResponsesToOpenAIResponse(added("fc_9", "call_9", "read_file"), state);
const out2 = openaiResponsesToOpenAIResponse({
type: "response.output_item.done",
item: { id: "fc_9", type: "function_call", call_id: "call_9", name: "read_file", arguments: PAYLOADS[0] },
}, state);
const calls = accumulate({}, [out1, out2]);
expect(JSON.parse(calls[0].args)).toEqual(JSON.parse(PAYLOADS[0]));
});
it("deltas without item_id fall back to the most recent call (legacy behavior)", () => {
const events = [
added("fc_0", "call_0", "read_file"),
{ type: "response.function_call_arguments.delta", delta: PAYLOADS[0] },
done("fc_0", "call_0", "read_file"),
];
const { chunks } = runStream(events);
const calls = accumulate({}, chunks);
expect(JSON.parse(calls[0].args)).toEqual(JSON.parse(PAYLOADS[0]));
});
});
describe("responses → claude end-to-end keeps parallel tool_use blocks separate", () => {
it("four read_file calls arrive as four parseable tool_use blocks", () => {
const state = initState(FORMATS.CLAUDE);
const out = [];
for (const ev of hostileOrdering()) {
for (const r of translateResponse(FORMATS.OPENAI_RESPONSES, FORMATS.CLAUDE, ev, state)) out.push(r);
}
for (const r of translateResponse(FORMATS.OPENAI_RESPONSES, FORMATS.CLAUDE, null, state)) out.push(r);
const starts = out.filter((r) => r?.type === "content_block_start" && r?.content_block?.type === "tool_use");
expect(starts).toHaveLength(4);
const partials = out.filter((r) => r?.delta?.type === "input_json_delta");
expect(partials).toHaveLength(4);
const bodies = partials.map((r) => JSON.parse(r.delta.partial_json).file_path).sort();
expect(bodies).toEqual([
"/docs/.gitignore",
"/docs/PRODUCT.md",
"/docs/ROADMAP.md",
"/docs/openapi.custom.yaml",
]);
});
});
describe("fallback call_ids stay unique within a batch", () => {
it("same-millisecond fallbacks never collide", () => {
const ids = new Set(Array.from({ length: 50 }, () => clampResponsesCallId(undefined)));
expect(ids.size).toBe(50);
for (const id of ids) {
expect(id.startsWith("call_")).toBe(true);
expect(id.length).toBeLessThanOrEqual(MAX_RESPONSES_CALL_ID_LEN);
}
expect(new Set([clampResponsesCallId(""), clampResponsesCallId(null)]).size).toBe(2);
});
});
describe("output coercion stays fail-soft on unstringifiable values", () => {
it("never throws on BigInt/circular array elements", () => {
const circular = {};
circular.self = circular;
const input = [1n, circular, { text: "ok" }];
expect(() => coerceResponsesOutput(input)).not.toThrow();
const out = coerceResponsesOutput(input);
expect(typeof out).toBe("string");
expect(out).toContain("ok");
});
});