Skip to content

fix(realtime): apply tool call item updates to session history - #4284

Merged
seratch merged 1 commit into
openai:mainfrom
hsusul:fix/realtime-history-tool-call-update
Aug 7, 2026
Merged

fix(realtime): apply tool call item updates to session history#4284
seratch merged 1 commit into
openai:mainfrom
hsusul:fix/realtime-history-tool-call-update

Conversation

@hsusul

@hsusul hsusul commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Component: src/agents/realtime/session.pyRealtimeSession._get_new_history

Problem. A realtime tool call and its output deliberately share a single conversation item. OpenAIRealtimeWebSocketModel._handle_output_item says so explicitly when it emits the call:

# We use the same item for tool call and output, so it will be completed by the
# output being added
status="in_progress",

Once the tool finishes, _send_tool_output emits a second RealtimeModelItemUpdatedEvent carrying the same item_id with status="completed" and the tool output. That second update never reaches the session history, so session.history and every history_updated event permanently show the call as status="in_progress" with output=None.

Root cause. _get_new_history guards the whole "existing item" replacement on the incoming item being a message:

if existing_index is not None:
    new_history = old_history.copy()
    if event.type == "message" and event.content is not None and len(event.content) > 0:
        ...  # merge message content
    return new_history

RealtimeItem is RealtimeMessageItem | RealtimeToolCallItem, so a function_call item falls through every branch and new_history is returned as an unmodified copy of old_history. Before #1646 this block was an unconditional new_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_history applied twice, as the transport does):

before after
history[0].status "in_progress" "completed"
history[0].output None "sunny"

Repro (no API key, no network):

from agents.realtime.items import RealtimeToolCallItem
from agents.realtime.session import RealtimeSession

call = RealtimeToolCallItem(
    item_id="fc_1", previous_item_id=None, call_id="call_1", type="function_call",
    status="in_progress", arguments="{}", name="get_weather", output=None,
)
history = RealtimeSession._get_new_history([], call)
history = RealtimeSession._get_new_history(
    history, call.model_copy(update={"status": "completed", "output": "sunny"})
)
print(history[0].status, history[0].output)  # in_progress None

Fix. Replace the existing entry when the incoming item is not a message, and keep the message branch exactly as it was:

if event.type != "message":
    new_history[existing_index] = event
elif event.content is not None and len(event.content) > 0:
    ...  # unchanged message merging

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.type discriminator 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 None content still preserve the existing history entry — that is the transcript-preservation behavior introduced with the merging code, and it is verified unchanged (an AssistantMessageItem update with content=[] still leaves the stored item, including its status, 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_call items. RealtimeItem, RealtimeToolCallItem, event shapes, and the emitted event sequence (history_added then history_updated) are unchanged. Consumers that were reading a stale in_progress / None output 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 its item_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 two RealtimeModelItemUpdatedEvents the transport actually sends, and asserts both the stored history and the emitted RealtimeHistoryUpdated payload carry the output.

All three fail on main for 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

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

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.
@seratch seratch added this to the 0.20.x milestone Aug 7, 2026
@seratch
seratch enabled auto-merge (squash) August 7, 2026 22:05
@seratch
seratch merged commit ed7fd85 into openai:main Aug 7, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants