Files
diegosouzapw--omniroute/tests/unit/responses-reasoning-close-before-message-466.test.ts
2026-07-13 13:39:12 +08:00

159 lines
5.5 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
// Regression guard for the Responses-API streaming-protocol bug where native
// reasoning (`delta.reasoning_content`, no <think> tags) opened a reasoning
// item that was never closed before the message content, and the message
// reused the reasoning item's output_index — producing a protocol-invalid
// stream. After the fix, reasoning is closed at the top of the content/
// tool_calls handlers and the message is routed to `reasoningIndex + 1`.
const { createResponsesApiTransformStream } = await import(
"../../open-sse/transformer/responsesTransformer.ts"
);
const { openaiToOpenAIResponsesResponse } = await import(
"../../open-sse/translator/response/openai-responses.ts"
);
const { initState } = await import("../../open-sse/translator/index.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const encoder = new TextEncoder();
const decoder = new TextDecoder();
async function runTransformStream(chunks) {
const stream = createResponsesApiTransformStream(null);
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
const output = [];
const readerTask = (async () => {
while (true) {
const { value, done } = await reader.read();
if (done) break;
output.push(decoder.decode(value));
}
})();
for (const chunk of chunks) {
await writer.write(encoder.encode(chunk));
}
await writer.close();
await readerTask;
return output.join("");
}
function parseSseOutput(output) {
return output
.trim()
.split("\n\n")
.map((entry) => {
const lines = entry.split("\n");
const eventLine = lines.find((line) => line.startsWith("event: "));
const dataLine = lines.find((line) => line.startsWith("data: "));
return {
event: eventLine ? eventLine.slice("event: ".length) : null,
data: dataLine ? dataLine.slice("data: ".length) : null,
};
});
}
test("transformer: native reasoning is closed before message content and uses a distinct output_index", async () => {
const output = await runTransformStream([
'data: {"id":"chatcmpl_1","choices":[{"index":0,"delta":{"reasoning_content":"thinking"}}]}\n\n',
'data: {"choices":[{"index":0,"delta":{"content":"answer"}}]}\n\n',
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
]);
const events = parseSseOutput(output).map((e) => {
let data = null;
if (e.data && e.data !== "[DONE]") {
try {
data = JSON.parse(e.data);
} catch {
data = null;
}
}
return { event: e.event, data };
});
// The reasoning item must be closed (output_item.done with a reasoning item)
// BEFORE the first message output_item.added is emitted.
const reasoningDoneIdx = events.findIndex(
(e) => e.event === "response.output_item.done" && e.data?.item?.type === "reasoning"
);
const messageAddedIdx = events.findIndex(
(e) => e.event === "response.output_item.added" && e.data?.item?.type === "message"
);
assert.ok(reasoningDoneIdx >= 0, "reasoning item must be closed");
assert.ok(messageAddedIdx >= 0, "message item must be added");
assert.ok(
reasoningDoneIdx < messageAddedIdx,
"reasoning must be closed before message content begins"
);
// Reasoning lives at output_index 0; the message must NOT reuse it.
const reasoningIndex = events.find(
(e) => e.event === "response.output_item.added" && e.data?.item?.type === "reasoning"
).data.output_index;
const messageIndex = events[messageAddedIdx].data.output_index;
assert.equal(reasoningIndex, 0);
assert.equal(messageIndex, reasoningIndex + 1);
// The completed snapshot should carry both items at distinct indices.
const completed = events.find((e) => e.event === "response.completed").data.response;
assert.equal(completed.output[0].type, "reasoning");
assert.equal(completed.output[1].type, "message");
assert.equal(completed.output[1].content[0].text, "answer");
});
test("translator: native reasoning is closed before message content and uses a distinct output_index", () => {
const state = initState(FORMATS.OPENAI_RESPONSES);
const events = [];
const chunks = [
{
id: "chatcmpl-1",
model: "gpt-4.1",
choices: [{ index: 0, delta: { reasoning_content: "thinking" }, finish_reason: null }],
},
{
id: "chatcmpl-1",
model: "gpt-4.1",
choices: [{ index: 0, delta: { content: "answer" }, finish_reason: null }],
},
{
id: "chatcmpl-1",
model: "gpt-4.1",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
},
];
for (const chunk of chunks) {
const result = openaiToOpenAIResponsesResponse(chunk, state);
if (result) events.push(...result);
}
const reasoningDoneIdx = events.findIndex(
(e) => e.event === "response.output_item.done" && e.data?.item?.type === "reasoning"
);
const messageAddedIdx = events.findIndex(
(e) => e.event === "response.output_item.added" && e.data?.item?.type === "message"
);
assert.ok(reasoningDoneIdx >= 0, "reasoning item must be closed");
assert.ok(messageAddedIdx >= 0, "message item must be added");
assert.ok(
reasoningDoneIdx < messageAddedIdx,
"reasoning must be closed before message content begins"
);
const reasoningIndex = events.find(
(e) => e.event === "response.output_item.added" && e.data?.item?.type === "reasoning"
).data.output_index;
const messageIndex = events[messageAddedIdx].data.output_index;
assert.equal(reasoningIndex, 0);
assert.equal(messageIndex, reasoningIndex + 1);
});