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.
This commit is contained in:
Markus Hartung
2026-07-25 07:52:49 +02:00
committed by GitHub
parent 94125e09b1
commit 11f7ba2c72
3 changed files with 64 additions and 1 deletions

View File

@@ -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,

View File

@@ -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;

View File

@@ -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([
{