- 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
166 lines
6.6 KiB
JavaScript
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");
|
|
});
|
|
});
|