Skip to content

fix(responses): preserve streamed tool call history - #2038

Open
asuan-dev wants to merge 3 commits into
looplj:unstablefrom
asuan-dev:codex/fix-responses-stream-tool-history
Open

fix(responses): preserve streamed tool call history#2038
asuan-dev wants to merge 3 commits into
looplj:unstablefrom
asuan-dev:codex/fix-responses-stream-tool-history

Conversation

@asuan-dev

Copy link
Copy Markdown

Summary

  • recover complete function-call arguments and custom-tool input from *.done, response.output_item.done, and response.completed.output[]
  • preserve Responses item.id separately from call_id, including custom-tool namespaces and protocol-valid generated item IDs
  • make raw SSE aggregation follow the same terminal reconciliation rules so persisted history is not saved with empty tool payloads
  • reject ambiguous output_index fallback instead of merging or misrouting multiple tool calls

Root cause

The Responses stream transformer only tracked tool calls by call_id and 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=10
  • cd llm && go test ./... -count=1
  • gofmt -d on all changed Go files
  • git diff --check
  • independent code/protocol/architecture review: PASS

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-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This 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 (output_item.done and response.completed), introduces ToolPayloadDone/OutputItemDone state flags, and adds ResponseItemID to disambiguate the Responses item.id from call_id.

  • aggregator.go: Adds finalItem() (shared terminal-event reconciliation), toolItem() (payload-validated item projection), orderedItems(), and completedToolCalls(). Ambiguous output_index fallback now errors rather than silently misrouting.
  • outbound_stream.go: Tool call chunks are now batched and emitted at response.completed via enqueueAggregatedToolCalls(); reconcileToolPayload() handles providers whose final payload diverges from the streamed bytes.
  • inbound_stream.go / inbound.go / outbound_convert.go: ResponseItemID and Namespace propagated through all tool call conversion paths; item ID generation gains type-based prefixes (fc_, ctc_, rs_, msg_).

Confidence Score: 4/5

Safe 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

Filename Overview
llm/transformer/openai/responses/aggregator.go Core refactor introducing finalItem(), toolItem(), orderedItems(), completedToolCalls() and ToolPayloadDone/OutputItemDone flags. OutputItemDone early-return unconditionally blocks response.completed from patching empty-argument function_calls already marked done by output_item.done.
llm/transformer/openai/responses/outbound_stream.go Replaces immediate delta emission with batched enqueueAggregatedToolCalls() at response.completed; adds reconcileToolPayload() to gracefully handle final-payload divergence.
llm/transformer/openai/responses/inbound_stream.go Adds toolCallItemIDs map for stable Responses item-ID tracking and propagates ResponseItemID/Namespace through tool call lifecycle. Error propagation from aggregator now surfaced via enqueueEvent.
llm/tools.go Adds ResponseItemID to ToolCall and Namespace to ResponseCustomToolCall to preserve protocol-level identities separately from call_id.
llm/transformer/openai/responses/outbound_convert.go generateItemID gains a prefix parameter; convertToResponsesAPIResponse now uses ResponseItemID as the item ID for function_call/custom_tool_call when available and propagates Namespace.
llm/transformer/openai/responses/aggregator_test.go Seven new tests covering terminal-only payloads, ambiguous output_index rejection, empty done-input semantics, and non-completed response filtering.
llm/transformer/openai/responses/inbound.go Populates ResponseItemID and Namespace in all convertItemToMessage call sites; adds item.ID to function_call_output and custom_tool_call_output messages.
llm/transformer/openai/responses/stream_tool_roundtrip_test.go Roundtrip tests for the full inbound/outbound stream path validating consistent item IDs and payload integrity end-to-end.
llm/transformer/openai/responses/stream_tool_cross_protocol_test.go Cross-protocol tests verifying tool call fidelity across the Responses/unified-LLM boundary.
llm/transformer/openai/responses/inbound_integration_test.go Updates existing assertions to reflect type-based item ID prefixes (fc_, ctc_) rather than reusing call_id.

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
Loading
%%{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
Loading

Reviews (3): Last reviewed commit: "refactor(responses): centralize streamed..." | Re-trigger Greptile

Comment thread llm/transformer/openai/responses/outbound_stream.go Outdated
Comment on lines +232 to +234
} else if item.OutputItemDone {
return item
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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).

MrLiuGangQiang added a commit to MrLiuGangQiang/axonhub that referenced this pull request Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant