From 11f7ba2c72ddd85c392141731c4eceaae48b9fde Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Sat, 25 Jul 2026 07:52:49 +0200 Subject: [PATCH] fix(sse): record tool calls into shared state for openai->openai-responses call-log summary (#8462) The openai-responses translator tracked tool calls in its own funcCallIds/ funcNames/funcArgsBuf bookkeeping without ever writing to the shared state.toolCalls Map that stream.ts's completion-log summary builder reads. Every openai->openai-responses translated stream with a tool call was persisted with finish_reason "stop" and no tool_calls, even though the actual SSE events sent to the client were correct. --- config/quality/file-size-baseline.json | 3 +- .../translator/response/openai-responses.ts | 11 ++++ .../translator-resp-openai-responses.test.ts | 51 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index f2901f608d..4026b09859 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -224,7 +224,8 @@ "open-sse/translator/response/gemini-to-openai.ts": 821, "_rebaseline_2026_07_22_7936_namespace_roundtrip": "#7936 (@RCrushMe, Responses-Chat namespace round-trip identity seam) own growth: open-sse/translator/response/openai-responses.ts 1092->1125 (+33) and open-sse/utils/stream.ts 2814->2869 (+55) — threading the namespace-identity seam through the Responses↔Chat translation + stream paths so tool-call namespaces survive the round-trip. Cohesive translation/stream wiring at existing chokepoints, frozen at new size.", "_rebaseline_2026_07_22_8210_openrouter_midstream_error": "PR #8210 (hartmark, fix/openrouter-midstream-error-surfacing) own growth: open-sse/translator/response/openai-responses.ts 1137->1163 (+26) measured on the merged tip (release 1137 + this PR own growth). Adds a single new branch inside openaiToOpenAIResponsesResponse() that detects an OpenRouter-style mid-stream aggregator error (HTTP 200 SSE chunk with empty choices + a top-level error object) and surfaces it as state.upstreamError instead of silently falling through to the no-op/awaitingTrailingUsage path, which previously masked the failure as a false empty-success completion and skipped combo fallback. Irreducible call-site addition at the existing chunk-dispatch chokepoint (mirrors the Gemini-to-OpenAI translator's #4177 precedent for the same class of upstream error surfacing). Note: this baseline entry does NOT cover the separate pre-existing +11 drift already on the release tip from #8081/#8162 (1125->1136, unrelated reasoning-placeholder-stripping fix merged after this PR branched) — that drift belongs to the maintainer's rebaseline, not this PR.", - "open-sse/translator/response/openai-responses.ts": 1163, + "_rebaseline_2026_07_24_responses_toolcalls_log_summary": "hartmark, fix/responses-tool-calls-log-summary own growth: open-sse/translator/response/openai-responses.ts 1163->1174 (+11). closeToolCall() now also writes the completed tool call into the shared state.toolCalls Map (already populated by the openai-to-claude / claude-to-openai / gemini-to-openai response translators) so stream.ts's completion-log summary builder (which reads state.toolCalls, not this translator's own funcCallIds/funcNames/funcArgsBuf bookkeeping) reports finish_reason \"tool_calls\" and message.tool_calls for openai->openai-responses translated streams instead of always logging \"stop\" with no tool_calls — the actual client-facing SSE events were already correct; only the persisted call-log summary was wrong. Irreducible call-site addition at the existing tool-call-close chokepoint. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts.", + "open-sse/translator/response/openai-responses.ts": 1174, "open-sse/utils/cursorAgentProtobuf.ts": 1521, "_rebaseline_2026_07_23_8143_empty_catch_logging": "#8143 (@chirag127) own growth: open-sse/utils/stream.ts 2869->2887 (+18) — replacing empty catch blocks in the SSE stream subsystem with console.debug logging (Rule #6 silent-swallow fix, issues #8138-#8142). Cohesive logging additions at the existing catch chokepoints, not extractable; frozen at new size. Covered by tests/unit/stream-handler-catch-logging-8143.test.ts.", "open-sse/utils/stream.ts": 2887, diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index f649853552..22fb7d73c1 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -636,6 +636,17 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) { // superseded-call eviction where a new call replaced this one at the same index). if (recordAsCompleted) { recordCompletedItem(state, normalizedIndex, funcItem); + // Mirror into the shared state.toolCalls map (populated by the other response + // translators) so stream.ts's completion-log summary reports finish_reason + // "tool_calls" and message.tool_calls instead of "stop" with no tool calls. + if (state.toolCalls instanceof Map) { + state.toolCalls.set(idx, { + id: callId, + index: normalizedIndex, + type: isCustomTool ? "custom_tool_call" : "function", + function: { name: funcItem.name, arguments: args }, + }); + } } state.funcItemDone[idx] = true; diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts index 3659a82fb5..0e5cffc293 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -97,6 +97,57 @@ test("OpenAI -> Responses: emits lifecycle, reasoning, text, tool calls and comp assert.equal(completed.data.response.usage.input_tokens_details.cached_tokens, 2); }); +// Regression guard for the OpenRouter/nemotron "reasoning_content + tool_calls in the +// final chunk" case reported via the /dashboard/logs/timeline UI: the SSE events sent to +// the client were always correct, but stream.ts's completion-log summary builder +// (open-sse/utils/stream.ts) reads the shared `state.toolCalls` Map — populated by the +// openai-to-claude / claude-to-openai / gemini-to-openai translators — to report +// finish_reason and message.tool_calls in the persisted call-log. This translator alone +// tracked tool calls in its own funcCallIds/funcNames/funcArgsBuf bookkeeping without +// ever writing to the shared Map, so every openai->openai-responses translated stream +// with a tool call was logged as finish_reason "stop" with no tool_calls, even though the +// client received the tool call correctly. +test("OpenAI -> Responses: closing a tool call also records it in the shared state.toolCalls map", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + const chunks = [ + { + id: "chatcmpl-4", + model: "nvidia/nemotron", + choices: [{ index: 0, delta: { reasoning_content: "thinking" }, finish_reason: null }], + }, + { + id: "chatcmpl-4", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call-abc123", + type: "function", + function: { name: "openclaw", arguments: '{"message":"hi"}' }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }, + ]; + + for (const chunk of chunks) { + openaiToOpenAIResponsesResponse(chunk, state); + } + + assert.equal(state.toolCalls.size, 1, "state.toolCalls should carry the completed tool call"); + const recorded = [...state.toolCalls.values()][0]; + assert.equal(recorded.id, "call-abc123"); + assert.equal(recorded.function.name, "openclaw"); + assert.equal(recorded.function.arguments, '{"message":"hi"}'); +}); + test("OpenAI -> Responses: flush on null closes text content and emits response.completed", () => { const events = collectEvents([ {