diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index adc4680bb820..caa52ecacc81 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -531,6 +531,21 @@ function withAssistantRoleOnFirstDelta(state, result) { return result; } +/** + * Resolve the terminal finish_reason for a Responses→Chat stream. + * + * `currentToolCallId` is intentionally sticky for the current turn: it is set when a + * function_call item is announced (`response.output_item.added`) and is only cleared once + * the matching `response.output_item.done` advances `toolCallIndex`. If the stream ends + * (flush or `response.completed`) after a tool call was emitted but BEFORE its + * `output_item.done` arrived, `toolCallIndex` is still 0 while `currentToolCallId` is set. + * Guarding on it as well lets us still finalize as `tool_calls` instead of `stop`, so + * OpenAI-compatible clients continue tool-result processing instead of stopping prematurely. + */ +function computeFinishReason(state): "tool_calls" | "stop" { + return (state.toolCallIndex || 0) > 0 || state.currentToolCallId ? "tool_calls" : "stop"; +} + /** * Translate OpenAI Responses API chunk to OpenAI Chat Completions format * This is for when Codex returns data and we need to send it to an OpenAI-compatible client @@ -544,7 +559,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { // Flush: send final chunk with finish_reason if (!state.finishReasonSent && state.started) { state.finishReasonSent = true; - const hadToolCalls = (state.toolCallIndex || 0) > 0; + const finishReason = computeFinishReason(state); return { id: state.chatId || `chatcmpl-${Date.now()}`, object: "chat.completion.chunk", @@ -554,7 +569,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { { index: 0, delta: {}, - finish_reason: hadToolCalls ? "tool_calls" : "stop", + finish_reason: finishReason, }, ], }; @@ -830,8 +845,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { if (!state.finishReasonSent) { state.finishReasonSent = true; - const hadToolCalls = (state.toolCallIndex || 0) > 0; - const reason = hadToolCalls ? "tool_calls" : "stop"; + const reason = computeFinishReason(state); state.finishReason = reason; // Mark for usage injection in stream.js const finalChunk: Record = { diff --git a/tests/unit/responses-translation-fixes.test.ts b/tests/unit/responses-translation-fixes.test.ts index a99ebf4d5885..7d163f26730d 100644 --- a/tests/unit/responses-translation-fixes.test.ts +++ b/tests/unit/responses-translation-fixes.test.ts @@ -459,3 +459,57 @@ test("Chat→Responses streaming: multiple tags in one chunk handled", ( const combined = textDeltas.join(""); assert.ok(!combined.includes(""), `text should not contain tag, got: ${combined}`); }); + +// Regression: a tool call was announced (response.output_item.added set currentToolCallId) +// but the stream ended before response.output_item.done could advance toolCallIndex. The +// terminal finish_reason must still be "tool_calls", not "stop", so OpenAI-compatible +// clients keep processing the tool result instead of stopping prematurely. +test("Responses→Chat streaming: flush finalizes tool_calls when currentToolCallId set but toolCallIndex==0", () => { + const state = { + started: true, + chatId: "chatcmpl-x", + created: 1_700_000_000, + model: "gpt-4", + toolCallIndex: 0, + currentToolCallId: "call_abc", + finishReasonSent: false, + }; + + const result = openaiResponsesToOpenAIResponse(null, state); + assert.ok(result, "flush should emit a final chunk"); + assert.equal(result.choices[0].finish_reason, "tool_calls"); +}); + +test("Responses→Chat streaming: response.completed finalizes tool_calls when currentToolCallId set but toolCallIndex==0", () => { + const state = { + started: true, + chatId: "chatcmpl-y", + created: 1_700_000_000, + model: "gpt-4", + toolCallIndex: 0, + currentToolCallId: "call_def", + finishReasonSent: false, + }; + + const chunk = { type: "response.completed", data: { response: {} } }; + const result = openaiResponsesToOpenAIResponse(chunk, state); + assert.ok(result, "response.completed should emit a final chunk"); + assert.equal(result.choices[0].finish_reason, "tool_calls"); + assert.equal(state.finishReason, "tool_calls"); +}); + +test("Responses→Chat streaming: flush finalizes stop when no tool call was emitted", () => { + const state = { + started: true, + chatId: "chatcmpl-z", + created: 1_700_000_000, + model: "gpt-4", + toolCallIndex: 0, + currentToolCallId: null, + finishReasonSent: false, + }; + + const result = openaiResponsesToOpenAIResponse(null, state); + assert.ok(result, "flush should emit a final chunk"); + assert.equal(result.choices[0].finish_reason, "stop"); +});