fix(sse): harden against empty responses causing Copilot Chat failures (#3297)

Integrated into release/v3.8.13
This commit is contained in:
Wilson
2026-06-06 12:17:53 -03:00
committed by GitHub
parent 744d7ac5fe
commit bc93ffb347
3 changed files with 220 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { hasValuableContent } from "../../open-sse/utils/streamHelpers.ts";
import { FORMATS } from "../../open-sse/translator/formats.ts";
describe("empty-response hardening", () => {
describe("hasValuableContent edge cases", () => {
it("returns false for choices: [] (empty array)", () => {
const chunk = { choices: [] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), false);
});
it("returns false for missing choices entirely", () => {
const chunk = { id: "test-123" };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), false);
});
it("returns false for choices with null delta", () => {
const chunk = { choices: [{ delta: null, finish_reason: null }] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), false);
});
it("returns true for choices with finish_reason stop", () => {
const chunk = { choices: [{ delta: {}, finish_reason: "stop" }] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), true);
});
it("returns true for choices with finish_reason tool_calls", () => {
const chunk = { choices: [{ delta: {}, finish_reason: "tool_calls" }] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), true);
});
});
describe("empty choices array detection", () => {
it("should be detected by Array.isArray + length === 0", () => {
const parsed = { choices: [], id: "test", model: "gpt-4" };
assert.strictEqual(Array.isArray(parsed.choices), true);
assert.strictEqual(parsed.choices.length, 0);
});
it("should NOT trigger for choices with one element", () => {
const parsed = { choices: [{ delta: { content: "hi" } }], id: "test" };
assert.strictEqual(Array.isArray(parsed.choices), true);
assert.strictEqual(parsed.choices.length, 1);
});
it("should NOT trigger for choices with finish_reason only", () => {
const parsed = { choices: [{ delta: {}, finish_reason: "stop" }], id: "test" };
assert.strictEqual(Array.isArray(parsed.choices), true);
assert.strictEqual(parsed.choices.length, 1);
});
});
describe("tool completion empty response detection", () => {
it("detects empty content and reasoning after tool_calls", () => {
const passthroughHasToolCalls = true;
const content = "";
const reasoning = "";
assert.strictEqual(passthroughHasToolCalls && !content.trim() && !reasoning.trim(), true);
});
it("does NOT trigger when content exists after tool_calls", () => {
const passthroughHasToolCalls = true;
const content = "Done!";
const reasoning = "";
assert.strictEqual(passthroughHasToolCalls && !content.trim() && !reasoning.trim(), false);
});
it("does NOT trigger when reasoning exists after tool_calls", () => {
const passthroughHasToolCalls = true;
const content = "";
const reasoning = "Let me think...";
assert.strictEqual(passthroughHasToolCalls && !content.trim() && !reasoning.trim(), false);
});
it("does NOT trigger when no tool_calls occurred", () => {
const passthroughHasToolCalls = false;
const content = "";
const reasoning = "";
assert.strictEqual(passthroughHasToolCalls && !content.trim() && !reasoning.trim(), false);
});
});
});

View File

@@ -1559,3 +1559,102 @@ test("createSSEStream passthrough mode decrements pending requests on failure",
`pending request count for ${modelKey} should be 0 after failure, got ${count}`
);
});
test("createSSEStream passthrough emits synthetic error chunk for empty choices array", async () => {
let onCompletePayload = null;
const text = await readTransformed(
[
`data: ${JSON.stringify({
id: "chatcmpl_empty",
object: "chat.completion.chunk",
created: 1,
model: "kimi-k2.6",
choices: [],
})}\n\n`,
`data: ${JSON.stringify({
id: "chatcmpl_empty",
object: "chat.completion.chunk",
created: 1,
model: "kimi-k2.6",
choices: [{ index: 0, delta: { role: "assistant", content: "Hello" } }],
})}\n\n`,
`data: ${JSON.stringify({
id: "chatcmpl_empty",
object: "chat.completion.chunk",
created: 1,
model: "kimi-k2.6",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
})}\n\n`,
],
{
mode: "passthrough",
sourceFormat: FORMATS.OPENAI,
provider: "opencode-go",
model: "kimi-k2.6",
body: { messages: [{ role: "user", content: "hello" }] },
onComplete(payload) {
onCompletePayload = payload;
},
}
);
// The empty choices chunk should have been replaced with a synthetic error chunk
assert.match(text, /\[OmniRoute\] Upstream returned an empty response/);
assert.match(text, /"finish_reason":"stop"/);
// Subsequent valid chunks should still be present
assert.match(text, /"content":"Hello"/);
assert.equal(onCompletePayload.status, 200);
});
test("createSSEStream passthrough logs empty response after tool_calls completion", async () => {
let onCompletePayload = null;
const text = await readTransformed(
[
`data: ${JSON.stringify({
id: "chatcmpl_tool_then_empty",
object: "chat.completion.chunk",
created: 1,
model: "gpt-5.5-xhigh",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: "call_tc",
type: "function",
function: { name: "task_complete", arguments: '{}' },
},
],
},
},
],
})}\n\n`,
`data: ${JSON.stringify({
id: "chatcmpl_tool_then_empty",
object: "chat.completion.chunk",
created: 1,
model: "gpt-5.5-xhigh",
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
})}\n\n`,
],
{
mode: "passthrough",
sourceFormat: FORMATS.OPENAI,
provider: "codex",
model: "gpt-5.5-xhigh",
body: { messages: [{ role: "user", content: "do task" }] },
onComplete(payload) {
onCompletePayload = payload;
},
}
);
assert.match(text, /"finish_reason":"tool_calls"/);
assert.equal(onCompletePayload.status, 200);
assert.equal(onCompletePayload.responseBody.choices[0].finish_reason, "tool_calls");
assert.equal(onCompletePayload.responseBody.choices[0].message.tool_calls[0].function.name, "task_complete");
// Content should be null (empty) since no text was generated
assert.equal(onCompletePayload.responseBody.choices[0].message.content, null);
});