fix(realtime): apply tool call item updates to session history - #4284
Merged
Conversation
A tool call and its output share one item_id, so the transport re-sends the item with status "completed" and the tool output once execution finishes. _get_new_history only replaced an existing entry for message items, so that second update was dropped and the history kept the in_progress entry with a null output.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Component:
src/agents/realtime/session.py—RealtimeSession._get_new_historyProblem. A realtime tool call and its output deliberately share a single conversation item.
OpenAIRealtimeWebSocketModel._handle_output_itemsays so explicitly when it emits the call:Once the tool finishes,
_send_tool_outputemits a secondRealtimeModelItemUpdatedEventcarrying the sameitem_idwithstatus="completed"and the tooloutput. That second update never reaches the session history, sosession.historyand everyhistory_updatedevent permanently show the call asstatus="in_progress"withoutput=None.Root cause.
_get_new_historyguards the whole "existing item" replacement on the incoming item being a message:RealtimeItemisRealtimeMessageItem | RealtimeToolCallItem, so afunction_callitem falls through every branch andnew_historyis returned as an unmodified copy ofold_history. Before #1646 this block was an unconditionalnew_history[existing_index] = event; the message-content merging added there narrowed the guard and dropped the non-message case with it.Before / after (
_get_new_historyapplied twice, as the transport does):history[0].status"in_progress""completed"history[0].outputNone"sunny"Repro (no API key, no network):
Fix. Replace the existing entry when the incoming item is not a message, and keep the message branch exactly as it was:
Why minimal. One branch in one function. It restores the pre-#1646 replacement behavior only for the case the content-merging rewrite was never about, and derives the decision from the existing
event.typediscriminator rather than adding a new item classification. No new state, no public API change, no transport change.Non-goals / intentionally unchanged. Message items with empty or
Nonecontent still preserve the existing history entry — that is the transcript-preservation behavior introduced with the merging code, and it is verified unchanged (anAssistantMessageItemupdate withcontent=[]still leaves the stored item, including itsstatus, untouched). This PR does not synthesize or override any server-reported message status; that is a separate concern that was declined in #3765, and nothing here touches it.Compatibility. Behavior-only change to history contents for
function_callitems.RealtimeItem,RealtimeToolCallItem, event shapes, and the emitted event sequence (history_addedthenhistory_updated) are unchanged. Consumers that were reading a stalein_progress/Noneoutput now see the completed state the SDK always intended to publish.Test plan
Added to
tests/realtime/test_session.py:TestHistoryManagement::test_tool_call_item_update_replaces_existing_entry— the completed tool call replaces the in-progress entry sharing itsitem_id.TestHistoryManagement::test_tool_call_item_update_preserves_other_items— replacement is positional and leaves neighbouring user/assistant items byte-identical.TestEventHandling::test_item_updated_event_completes_tool_call— drives the real public listener path (session.on_event) with the twoRealtimeModelItemUpdatedEvents the transport actually sends, and asserts both the stored history and the emittedRealtimeHistoryUpdatedpayload carry the output.All three fail on
mainfor the right reason (assert 'in_progress' == 'completed',assert None == 'sunny') and pass with the fix. Deterministic: no sleeps, no network, no randomness; the session tests were repeat-run 5x under randomized ordering with-W error::RuntimeWarning(169 passed each time, no pending-task or unclosed-resource warnings).Verification (all from a clean checkout of this branch):
uv run pytest tests/realtime/test_session.py -k "tool_call_item_update or item_updated_event_completes_tool_call"— 3 failed before the fix, 3 passed after.uv run pytest tests/realtime— 420 passed.bash .agents/skills/code-change-verification/scripts/run.sh— format / lint / typecheck / tests all passed.make format— 862 files left unchanged.make lint— All checks passed.make typecheck— mypy: no issues in 849 source files; pyright: 0 errors, 0 warnings.make tests— 6820 passed, 29 skipped; serial suite 77 passed, 5 skipped.Not run: docs build (no docs touched) and the Python 3.10 matrix environment (local env is 3.12; the change uses no version-sensitive syntax).
Issue number
N/A
Checks
.agents/skills/code-change-verification/scripts/run.sh/reviewbefore submitting this PR