From 10a923da11767cdbec602c6b4a95682c96e55809 Mon Sep 17 00:00:00 2001 From: chisewaguri Date: Thu, 13 Aug 2026 11:40:28 +0700 Subject: [PATCH] fix(responses): don't close message on empty tool_calls array Some providers (e.g. codebuddy/cbcn) attach an empty tool_calls array to every streaming chunk. An empty array is truthy in JS, so the guard 'if (delta.tool_calls)' closed the message on the first content token and emitted response.output_text.done early, dropping the remaining deltas. Guard on a non-empty array; finish_reason still closes the message and real tool calls still close it before emitting function_call items. fixes #3234 --- .../translator/response/openai-responses.js | 4 +- .../openai-responses-empty-toolcalls.test.js | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 tests/unit/openai-responses-empty-toolcalls.test.js diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index 92e3cb52..ff55bb4e 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -99,8 +99,8 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { } } - // Handle tool_calls - if (delta.tool_calls) { + // Handle tool_calls (empty array is truthy; require a real call) + if (delta.tool_calls && delta.tool_calls.length) { closeMessage(state, emit, idx); for (const tc of delta.tool_calls) { emitToolCall(state, emit, tc); diff --git a/tests/unit/openai-responses-empty-toolcalls.test.js b/tests/unit/openai-responses-empty-toolcalls.test.js new file mode 100644 index 00000000..10455747 --- /dev/null +++ b/tests/unit/openai-responses-empty-toolcalls.test.js @@ -0,0 +1,52 @@ +/** + * Some providers (e.g. codebuddy / cbcn) attach `tool_calls: []` to every + * streaming chunk. An empty array is truthy in JS, so the guard + * `if (delta.tool_calls)` closed the message on the first content token, + * emitting `output_text.done` early and truncating the answer. This mirrors + * the real repro: `codex exec -m cbcn/kimi-k3` answered only "cod" instead + * of "codex-ok". + */ +import { describe, it, expect } from "vitest"; +import { openaiToOpenAIResponsesResponse } from "../../open-sse/translator/response/openai-responses.js"; +import { initState } from "../../open-sse/translator/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; + +describe("OpenAI Chat stream → Responses: empty tool_calls arrays", () => { + it("does not emit output_text.done early when every chunk carries tool_calls: []", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + const chunks = [ + { id: "cmb-test", choices: [{ index: 0, delta: { role: "assistant", content: "", reasoning_content: "", tool_calls: [] }, finish_reason: null }] }, + { id: "cmb-test", choices: [{ index: 0, delta: { content: "", reasoning_content: "thinking", tool_calls: [] }, finish_reason: null }] }, + { id: "cmb-test", choices: [{ index: 0, delta: { content: "cod", reasoning_content: "", tool_calls: [] }, finish_reason: null }] }, + { id: "cmb-test", choices: [{ index: 0, delta: { content: "ex", reasoning_content: "", tool_calls: [] }, finish_reason: null }] }, + { id: "cmb-test", choices: [{ index: 0, delta: { content: "-ok", reasoning_content: "", tool_calls: [] }, finish_reason: null }] }, + { id: "cmb-test", choices: [{ index: 0, delta: { content: "", reasoning_content: "", tool_calls: [] }, finish_reason: "stop" }] }, + ]; + + const events = chunks.flatMap((chunk) => openaiToOpenAIResponsesResponse(chunk, state)); + const textDone = events.filter((e) => e.event === "response.output_text.done"); + const textDeltas = events.filter((e) => e.event === "response.output_text.delta"); + + expect(textDone).toHaveLength(1); + expect(textDone[0].data.text).toBe("codex-ok"); + expect(textDeltas.map((e) => e.data.delta).join("")).toBe("codex-ok"); + // done must come after every delta + expect(events.indexOf(textDone[0])).toBe(events.indexOf(textDeltas[textDeltas.length - 1]) + 1); + }); + + it("still closes the message before a real tool call", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + const chunks = [ + { id: "cmb-test", choices: [{ index: 0, delta: { content: "Let me run that.", tool_calls: [] }, finish_reason: null }] }, + { id: "cmb-test", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "exec", arguments: "" } }] }, finish_reason: null }] }, + { id: "cmb-test", choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] }, + ]; + + const events = chunks.flatMap((chunk) => openaiToOpenAIResponsesResponse(chunk, state)); + const added = events.find((e) => e.event === "response.output_item.added" && e.data.item?.type === "function_call"); + const textDone = events.find((e) => e.event === "response.output_text.done"); + + expect(added).toBeTruthy(); + expect(textDone.data.text).toBe("Let me run that."); + }); +});