From bc93ffb347f84bd8a0dec37282fbd50098d8fb73 Mon Sep 17 00:00:00 2001 From: Wilson Date: Sat, 6 Jun 2026 12:17:53 -0300 Subject: [PATCH] fix(sse): harden against empty responses causing Copilot Chat failures (#3297) Integrated into release/v3.8.13 --- open-sse/utils/stream.ts | 38 ++++++++ tests/unit/empty-response-hardening.test.ts | 83 +++++++++++++++++ tests/unit/stream-utils.test.ts | 99 +++++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 tests/unit/empty-response-hardening.test.ts diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 407b0600f1..173e958422 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -1600,6 +1600,36 @@ export function createSSEStream(options: StreamOptions = {}) { } else { // Chat Completions: full sanitization pipeline + // Hardening: detect upstream returning empty choices array + // which breaks OpenAI-compatible clients (e.g. Copilot Chat) + if (Array.isArray(parsed.choices) && parsed.choices.length === 0) { + console.warn( + `[STREAM] Upstream returned empty choices array (${provider || "provider"}:${model || "unknown"}) — emitting error chunk` + ); + const errorChunk = { + id: parsed.id || `omniroute-empty-choices-${Date.now()}`, + object: "chat.completion.chunk", + created: parsed.created || Math.floor(Date.now() / 1000), + model: parsed.model || model || "unknown", + choices: [ + { + index: 0, + delta: { + role: "assistant", + content: "[OmniRoute] Upstream returned an empty response. Please retry.", + }, + finish_reason: "stop", + }, + ], + }; + output = `data: ${JSON.stringify(errorChunk)}\n`; + injectedUsage = true; + clientPayload = errorChunk; + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(encoder.encode(output)); + continue; + } + // Detect reasoning alias before sanitization strips it const hadReasoningAlias = !!( parsed.choices?.[0]?.delta?.reasoning && @@ -2118,6 +2148,14 @@ export function createSSEStream(options: StreamOptions = {}) { (a, b) => a.index - b.index ); } + // Hardening: log empty assistant response after tool completion + // for observability — helps diagnose Copilot "Sorry, no response was returned" + if (passthroughHasToolCalls && !content.trim() && !reasoning.trim()) { + console.warn( + `[STREAM] Empty assistant response after tool_calls completion (${provider || "provider"}:${model || "unknown"}) — sessionId=${sessionId}` + ); + } + const responseBody = { choices: [ { diff --git a/tests/unit/empty-response-hardening.test.ts b/tests/unit/empty-response-hardening.test.ts new file mode 100644 index 0000000000..0f7ace6296 --- /dev/null +++ b/tests/unit/empty-response-hardening.test.ts @@ -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); + }); + }); +}); diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index 7a2bd35034..f8ab86f723 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -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); +});