fix(responses): preserve streamed tool call history - #2038
Conversation
Recover terminal function/custom tool payloads from done and completed events, keep Responses item IDs distinct from call IDs, and reject ambiguous output-index fallback so malformed streams cannot corrupt subsequent conversation history.
Greptile SummaryThis PR fixes a correctness bug in the Responses API stream transformer where tool calls could be persisted with empty arguments/input, causing downstream Codex turns to fail permanently. The fix recovers complete payloads from terminal events (
Confidence Score: 4/5Safe to merge with awareness of one gap in aggregator.go: a function_call arriving solely via output_item.done with empty arguments cannot be rescued by response.completed. The fix is well-structured and well-tested for the primary scenarios. One gap exists: finalItem's unconditional early-return for OutputItemDone items prevents response.completed from filling in a function_call whose output_item.done carried empty arguments. If a provider exhibits this pattern the call is silently dropped from history, which is the exact class of bug this PR aims to fix. All other paths — terminal-only payloads, ambiguous index rejection, namespace propagation, and reconciliation of diverging final payloads — are verified by the new tests. aggregator.go — specifically the else-if OutputItemDone early-return guard in finalItem and the length-based heuristic in applyDoneText. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Provider as Provider SSE
participant Agg as streamAggregator
participant OB as responsesOutboundStream
participant IB as responsesInboundStream
Provider->>Agg: response.output_item.added (function_call)
Agg->>Agg: "newAggregatedItem(), ToolPayloadDone=false"
Provider->>Agg: function_call_arguments.delta
Agg->>Agg: "item.Arguments += delta, ToolPayloadDeltas append"
Provider->>Agg: response.output_item.done (complete args)
Agg->>Agg: "finalItem(fromCompleted=false), ToolPayloadDone=true, OutputItemDone=true"
Provider->>Agg: response.completed (args in snapshot)
Agg->>Agg: "finalItem(fromCompleted=true), OutputItemDone=true EARLY RETURN"
Agg->>OB: completedToolCalls(), toolItem() validates payload
OB->>OB: enqueueAggregatedToolCalls(), reconcileToolPayload(deltas, finalPayload)
OB->>OB: emit identity chunk then reconciled delta chunks
OB->>IB: ToolCall identity + argument deltas (unified LLM format)
IB->>IB: resolveToolCallItemID (ResponseItemID or generated fc_/ctc_)
IB->>IB: output_item.added, function_call_arguments.delta, done events
IB->>IB: buildResponse() for response.completed snapshot
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Provider as Provider SSE
participant Agg as streamAggregator
participant OB as responsesOutboundStream
participant IB as responsesInboundStream
Provider->>Agg: response.output_item.added (function_call)
Agg->>Agg: "newAggregatedItem(), ToolPayloadDone=false"
Provider->>Agg: function_call_arguments.delta
Agg->>Agg: "item.Arguments += delta, ToolPayloadDeltas append"
Provider->>Agg: response.output_item.done (complete args)
Agg->>Agg: "finalItem(fromCompleted=false), ToolPayloadDone=true, OutputItemDone=true"
Provider->>Agg: response.completed (args in snapshot)
Agg->>Agg: "finalItem(fromCompleted=true), OutputItemDone=true EARLY RETURN"
Agg->>OB: completedToolCalls(), toolItem() validates payload
OB->>OB: enqueueAggregatedToolCalls(), reconcileToolPayload(deltas, finalPayload)
OB->>OB: emit identity chunk then reconciled delta chunks
OB->>IB: ToolCall identity + argument deltas (unified LLM format)
IB->>IB: resolveToolCallItemID (ResponseItemID or generated fc_/ctc_)
IB->>IB: output_item.added, function_call_arguments.delta, done events
IB->>IB: buildResponse() for response.completed snapshot
Reviews (3): Last reviewed commit: "refactor(responses): centralize streamed..." | Re-trigger Greptile |
| } else if item.OutputItemDone { | ||
| return item | ||
| } |
There was a problem hiding this comment.
response.completed cannot rescue a function_call that output_item.done marked done with empty arguments
The early return fires for every caller, including applyResponseSnapshot (fromCompletedResponse=true). When a provider sends output_item.done with arguments:"" and there are no prior argument deltas, finalItem marks the item OutputItemDone=true but leaves item.Arguments.Len() == 0. A subsequent response.completed snapshot carrying the full arguments hits this early return before applying them, so toolItem() — which requires Arguments.Len() > 0 for function_call — silently drops the call.
The fromCompletedResponse parameter was introduced to distinguish the two callers, but the guard does not consult it. Guarding with && !fromCompletedResponse would allow response.completed to fill in missing payload while still letting output_item.done win whenever it carries a non-empty value (since if src.Arguments != "" { ... } only overwrites when the snapshot has real data).
Summary
*.done,response.output_item.done, andresponse.completed.output[]item.idseparately fromcall_id, including custom-tool namespaces and protocol-valid generated item IDsoutput_indexfallback instead of merging or misrouting multiple tool callsRoot cause
The Responses stream transformer only tracked tool calls by
call_idand ignored several authoritative terminal payload sources. Some providers send the complete tool payload only in terminal events, so AxonHub could emit and persist a tool call with empty arguments/input. Replaying that malformed item in the next Codex turn could make the conversation fail permanently.Verification
cd llm && go test ./transformer/openai/responses -count=10cd llm && go test ./... -count=1gofmt -don all changed Go filesgit diff --check