Files
2026-07-13 12:39:17 +08:00

4165 lines
152 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import dataclasses
import json
import threading
from typing import Any, cast
from unittest.mock import AsyncMock, Mock, PropertyMock, patch
import pytest
from pydantic import BaseModel, ConfigDict
from agents.exceptions import ToolTimeoutError, UserError
from agents.guardrail import GuardrailFunctionOutput, OutputGuardrail
from agents.handoffs import Handoff
from agents.realtime.agent import RealtimeAgent
from agents.realtime.config import RealtimeRunConfig, RealtimeSessionModelSettings
from agents.realtime.events import (
RealtimeAgentEndEvent,
RealtimeAgentStartEvent,
RealtimeAudio,
RealtimeAudioEnd,
RealtimeAudioInterrupted,
RealtimeError,
RealtimeGuardrailTripped,
RealtimeHistoryAdded,
RealtimeHistoryUpdated,
RealtimeRawModelEvent,
RealtimeToolApprovalRequired,
RealtimeToolEnd,
RealtimeToolStart,
)
from agents.realtime.items import (
AssistantAudio,
AssistantMessageItem,
AssistantText,
InputAudio,
InputText,
RealtimeItem,
UserMessageItem,
)
from agents.realtime.model import RealtimeModel, RealtimeModelConfig
from agents.realtime.model_events import (
RealtimeModelAudioDoneEvent,
RealtimeModelAudioEvent,
RealtimeModelAudioInterruptedEvent,
RealtimeModelConnectionStatusEvent,
RealtimeModelErrorEvent,
RealtimeModelInputAudioTranscriptionCompletedEvent,
RealtimeModelItemDeletedEvent,
RealtimeModelItemUpdatedEvent,
RealtimeModelOtherEvent,
RealtimeModelToolCallEvent,
RealtimeModelTranscriptDeltaEvent,
RealtimeModelTurnEndedEvent,
RealtimeModelTurnStartedEvent,
RealtimeModelUsageEvent,
)
from agents.realtime.model_inputs import (
RealtimeModelSendAudio,
RealtimeModelSendInterrupt,
RealtimeModelSendSessionUpdate,
RealtimeModelSendToolOutput,
RealtimeModelSendUserInput,
)
from agents.realtime.session import (
REJECTION_MESSAGE,
RealtimeSession,
_PendingToolOutputSendError,
_serialize_tool_output,
)
from agents.run_context import RunContextWrapper
from agents.tool import FunctionTool, function_tool, tool_namespace
from agents.tool_context import ToolContext
from agents.tool_guardrails import (
ToolGuardrailFunctionOutput,
ToolInputGuardrailData,
tool_input_guardrail,
)
from agents.usage import Usage
class _DummyModel(RealtimeModel):
def __init__(self) -> None:
super().__init__()
self.events: list[Any] = []
self.listeners: list[Any] = []
self.connect_options: Any | None = None
async def connect(self, options=None):
self.connect_options = options
async def close(self): # pragma: no cover - not used here
pass
async def send_event(self, event):
self.events.append(event)
def add_listener(self, listener):
self.listeners.append(listener)
def remove_listener(self, listener):
if listener in self.listeners:
self.listeners.remove(listener)
class _FailingConnectModel(_DummyModel):
def __init__(self, exc: BaseException) -> None:
super().__init__()
self.exc = exc
self.connect_options: Any | None = None
async def connect(self, options=None):
self.connect_options = options
raise self.exc
def _agent_with_ambiguous_realtime_tools(name: str = "invalid_agent") -> RealtimeAgent:
tool = function_tool(lambda: "ok", name_override="transfer_to_billing")
target = RealtimeAgent(name=f"{name}_target")
handoff = Handoff(
tool_name="transfer_to_billing",
tool_description="Transfer to billing",
input_json_schema={},
on_invoke_handoff=AsyncMock(return_value=target),
input_filter=None,
agent_name=target.name,
is_enabled=True,
)
return RealtimeAgent(name=name, tools=[tool], handoffs=[handoff])
def _disabled_billing_handoff(*, is_enabled: Any = False) -> Handoff[Any, Any]:
target = RealtimeAgent(name="billing")
return Handoff(
tool_name="transfer_to_billing",
tool_description="Transfer to billing",
input_json_schema={},
on_invoke_handoff=AsyncMock(return_value=target),
input_filter=None,
agent_name=target.name,
is_enabled=is_enabled,
)
def _disabled_billing_tool(*, is_enabled: Any = False) -> FunctionTool:
return function_tool(
lambda: "ok",
name_override="transfer_to_billing",
is_enabled=is_enabled,
)
@pytest.mark.asyncio
async def test_property_and_send_helpers_and_enter_alias():
model = _DummyModel()
agent = RealtimeAgent(name="agent")
session = RealtimeSession(model, agent, None)
# property
assert session.model is model
# enter alias calls __aenter__
async with await session.enter():
# send helpers
await session.send_message("hi")
await session.send_audio(b"abc", commit=True)
await session.interrupt()
# verify sent events
assert any(isinstance(e, RealtimeModelSendUserInput) for e in model.events)
assert any(isinstance(e, RealtimeModelSendAudio) and e.commit for e in model.events)
assert any(isinstance(e, RealtimeModelSendInterrupt) for e in model.events)
@pytest.mark.asyncio
async def test_aiter_cancel_propagates_cancelled_error():
model = _DummyModel()
agent = RealtimeAgent(name="agent")
session = RealtimeSession(model, agent, None)
async def consume():
async for _ in session:
pass
consumer = asyncio.create_task(consume())
await asyncio.sleep(0.01)
consumer.cancel()
with pytest.raises(asyncio.CancelledError):
await consumer
assert session._event_iterator_waiters == 0
@pytest.mark.asyncio
async def test_aiter_exits_waiting_iterators_when_session_closes():
model = _DummyModel()
agent = RealtimeAgent(name="agent")
session = RealtimeSession(model, agent, None)
iterators = [session.__aiter__(), session.__aiter__()]
next_events = [asyncio.ensure_future(iterator.__anext__()) for iterator in iterators]
await asyncio.sleep(0.01)
await session.close()
done, pending = await asyncio.wait(set(next_events), timeout=0.1)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
assert done == set(next_events)
assert not pending
for task in next_events:
with pytest.raises(StopAsyncIteration):
task.result()
@pytest.mark.asyncio
async def test_close_waits_for_background_finalizers_before_model_close():
order: list[str] = []
class OrderingModel(_DummyModel):
async def close(self):
order.append("model")
session = RealtimeSession(OrderingModel(), RealtimeAgent(name="agent"), None)
guardrail_started = asyncio.Event()
tool_started = asyncio.Event()
async def background_task(label: str, started: asyncio.Event) -> None:
started.set()
try:
await asyncio.Event().wait()
finally:
await asyncio.sleep(0)
order.append(label)
guardrail = asyncio.create_task(background_task("guardrail", guardrail_started))
tool = asyncio.create_task(background_task("tool", tool_started))
session._guardrail_tasks.add(guardrail)
session._tool_call_tasks.add(tool)
await guardrail_started.wait()
await tool_started.wait()
await session.close()
assert order[-1] == "model"
assert set(order[:-1]) == {"guardrail", "tool"}
assert guardrail.done()
assert tool.done()
assert session._guardrail_tasks == set()
assert session._tool_call_tasks == set()
@pytest.mark.asyncio
async def test_concurrent_close_callers_share_failure_and_retry():
class FailOnceModel(_DummyModel):
def __init__(self) -> None:
super().__init__()
self.close_started = asyncio.Event()
self.release_close = asyncio.Event()
self.close_calls = 0
async def close(self):
self.close_calls += 1
self.close_started.set()
await self.release_close.wait()
if self.close_calls == 1:
raise RuntimeError("close failed")
model = FailOnceModel()
session = RealtimeSession(model, RealtimeAgent(name="agent"), None)
first = asyncio.create_task(session.close())
await model.close_started.wait()
second = asyncio.create_task(session.close())
await asyncio.sleep(0)
assert not second.done()
model.release_close.set()
first_result, second_result = await asyncio.gather(first, second, return_exceptions=True)
assert isinstance(first_result, RuntimeError)
assert second_result is first_result
assert model.close_calls == 1
assert session._closing
assert not session._closed
await session.close()
assert model.close_calls == 2
assert session._closed
@pytest.mark.asyncio
async def test_cancelling_one_close_waiter_does_not_cancel_cleanup():
class BlockingCloseModel(_DummyModel):
def __init__(self) -> None:
super().__init__()
self.close_started = asyncio.Event()
self.release_close = asyncio.Event()
self.close_calls = 0
async def close(self):
self.close_calls += 1
self.close_started.set()
await self.release_close.wait()
model = BlockingCloseModel()
session = RealtimeSession(model, RealtimeAgent(name="agent"), None)
surviving_waiter = asyncio.create_task(session.close())
await model.close_started.wait()
cancelled_waiter = asyncio.create_task(session.close())
await asyncio.sleep(0)
assert not cancelled_waiter.done()
cancelled_waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await cancelled_waiter
model.release_close.set()
await surviving_waiter
assert model.close_calls == 1
assert session._closed
@pytest.mark.asyncio
async def test_close_sets_closing_before_cleanup_task_runs(monkeypatch):
model = _DummyModel()
session = RealtimeSession(model, RealtimeAgent(name="agent"), None)
release_cleanup = asyncio.Event()
original_cleanup = session._cleanup
async def delayed_cleanup() -> None:
await release_cleanup.wait()
await original_cleanup()
monkeypatch.setattr(session, "_cleanup", delayed_cleanup)
close_task = asyncio.create_task(session.close())
await asyncio.sleep(0)
try:
assert session._cleanup_task is not None
assert session._closing
await session.on_event(
RealtimeModelInputAudioTranscriptionCompletedEvent(
item_id="late-item",
transcript="late transcript",
)
)
assert session._history == []
assert session._event_queue.empty()
finally:
release_cleanup.set()
await close_task
assert session._closed
@pytest.mark.asyncio
async def test_tracked_task_reentering_active_cleanup_does_not_create_wait_cycle():
class CountingCloseModel(_DummyModel):
def __init__(self) -> None:
super().__init__()
self.close_calls = 0
async def close(self):
self.close_calls += 1
model = CountingCloseModel()
session = RealtimeSession(model, RealtimeAgent(name="agent"), None)
task_started = asyncio.Event()
close_reentered = asyncio.Event()
async def close_during_cancellation() -> None:
task_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
close_reentered.set()
await session.close()
tracked = asyncio.create_task(close_during_cancellation())
session._tool_call_tasks.add(tracked)
tracked.add_done_callback(session._on_tool_call_task_done)
await task_started.wait()
await asyncio.wait_for(session.close(), timeout=0.5)
assert close_reentered.is_set()
assert tracked.cancelled()
assert model.close_calls == 1
assert session._closed
assert tracked not in session._tool_call_tasks
@pytest.mark.asyncio
async def test_tracked_task_can_start_cleanup_without_self_await():
class CountingCloseModel(_DummyModel):
def __init__(self) -> None:
super().__init__()
self.close_calls = 0
async def close(self):
self.close_calls += 1
model = CountingCloseModel()
session = RealtimeSession(model, RealtimeAgent(name="agent"), None)
close_started = asyncio.Event()
tracked_finally_ran = asyncio.Event()
async def close_from_tracked_task() -> None:
close_started.set()
try:
await session.close()
finally:
tracked_finally_ran.set()
tracked = asyncio.create_task(close_from_tracked_task())
session._tool_call_tasks.add(tracked)
tracked.add_done_callback(session._on_tool_call_task_done)
await close_started.wait()
cleanup_task = session._cleanup_task
assert cleanup_task is not None
await asyncio.shield(cleanup_task)
result = (await asyncio.gather(tracked, return_exceptions=True))[0]
assert isinstance(result, asyncio.CancelledError)
assert tracked_finally_ran.is_set()
assert model.close_calls == 1
assert session._closed
assert tracked not in session._tool_call_tasks
@pytest.mark.asyncio
async def test_late_tool_completion_stays_tracked_and_cannot_send_after_close(monkeypatch):
monkeypatch.setattr(
"agents.realtime.session._BACKGROUND_TASK_CANCEL_GRACE_SECONDS",
0.01,
)
tool_started = asyncio.Event()
cancellation_seen = asyncio.Event()
release_tool = asyncio.Event()
@function_tool
async def cancellation_resistant_tool() -> str:
tool_started.set()
try:
await asyncio.Event().wait()
return "unreachable output"
except asyncio.CancelledError:
cancellation_seen.set()
await release_tool.wait()
return "late output"
model = _DummyModel()
agent = RealtimeAgent(name="agent", tools=[cancellation_resistant_tool])
session = RealtimeSession(model, agent, None)
await session.on_event(
RealtimeModelToolCallEvent(
name=cancellation_resistant_tool.name,
call_id="late-call",
arguments="{}",
)
)
await tool_started.wait()
tracked = next(iter(session._tool_call_tasks))
await session.close()
assert cancellation_seen.is_set()
assert session._closed
assert tracked in session._tool_call_tasks
assert not any(isinstance(event, RealtimeModelSendToolOutput) for event in model.events)
release_tool.set()
await tracked
await asyncio.sleep(0)
assert tracked not in session._tool_call_tasks
assert session._stored_exception is None
assert not any(isinstance(event, RealtimeModelSendToolOutput) for event in model.events)
@pytest.mark.asyncio
async def test_in_flight_model_event_cannot_enqueue_work_after_close(monkeypatch):
model = _DummyModel()
session = RealtimeSession(model, RealtimeAgent(name="agent"), None)
put_started = asyncio.Event()
release_put = asyncio.Event()
original_put_event = session._put_event
async def blocked_put_event(event):
put_started.set()
await release_put.wait()
return await original_put_event(event)
monkeypatch.setattr(session, "_put_event", blocked_put_event)
event_task = asyncio.create_task(
session.on_event(
RealtimeModelToolCallEvent(
name="late-tool",
call_id="late-event",
arguments="{}",
)
)
)
await put_started.wait()
await session.close()
release_put.set()
await event_task
assert session._closed
assert session._tool_call_tasks == set()
@pytest.mark.asyncio
async def test_model_event_cannot_mutate_history_after_raw_event_enqueue_and_close(monkeypatch):
model = _DummyModel()
session = RealtimeSession(model, RealtimeAgent(name="agent"), None)
raw_event_enqueued = asyncio.Event()
release_raw_put = asyncio.Event()
original_put_event = session._put_event
async def blocked_put_event(event):
was_enqueued = await original_put_event(event)
if isinstance(event, RealtimeRawModelEvent):
raw_event_enqueued.set()
await release_raw_put.wait()
return was_enqueued
monkeypatch.setattr(session, "_put_event", blocked_put_event)
event_task = asyncio.create_task(
session.on_event(
RealtimeModelInputAudioTranscriptionCompletedEvent(
item_id="late-item",
transcript="late transcript",
)
)
)
await raw_event_enqueued.wait()
await session.close()
release_raw_put.set()
await event_task
assert session._closed
assert session._history == []
@pytest.mark.asyncio
async def test_transcription_completed_adds_new_user_item():
model = _DummyModel()
agent = RealtimeAgent(name="agent")
session = RealtimeSession(model, agent, None)
event = RealtimeModelInputAudioTranscriptionCompletedEvent(item_id="item1", transcript="hello")
await session.on_event(event)
# Should have appended a new user item
assert len(session._history) == 1
assert session._history[0].type == "message"
assert session._history[0].role == "user"
class _FakeAudio:
# Looks like an audio part but is not an InputAudio/AssistantAudio instance
type = "audio"
transcript = None
@pytest.mark.asyncio
async def test_item_updated_merge_exception_path_logs_error(monkeypatch):
model = _DummyModel()
agent = RealtimeAgent(name="agent")
session = RealtimeSession(model, agent, None)
# existing assistant message with transcript to preserve
existing = AssistantMessageItem(
item_id="a1", role="assistant", content=[AssistantAudio(audio=None, transcript="t")]
)
session._history = [existing]
# incoming message with a deliberately bogus content entry to trigger assertion path
incoming = AssistantMessageItem(
item_id="a1", role="assistant", content=[AssistantAudio(audio=None, transcript=None)]
)
incoming.content[0] = cast(Any, _FakeAudio())
with patch("agents.realtime.session.logger") as mock_logger:
await session.on_event(RealtimeModelItemUpdatedEvent(item=incoming))
# error branch should be hit
assert mock_logger.error.called
@pytest.mark.asyncio
async def test_handle_tool_call_handoff_invalid_result_raises():
model = _DummyModel()
target = RealtimeAgent(name="target")
bad_handoff = Handoff(
tool_name="switch",
tool_description="",
input_json_schema={},
on_invoke_handoff=AsyncMock(return_value=123), # invalid return
input_filter=None,
agent_name=target.name,
is_enabled=True,
)
agent = RealtimeAgent(name="agent", handoffs=[bad_handoff])
session = RealtimeSession(model, agent, None)
with pytest.raises(UserError):
await session._handle_tool_call(
RealtimeModelToolCallEvent(name="switch", call_id="c1", arguments="{}")
)
@pytest.mark.asyncio
async def test_handle_tool_call_rejects_ambiguous_function_handoff_name():
model = _DummyModel()
target = RealtimeAgent(name="billing")
tool = function_tool(lambda: "ok", name_override="transfer_to_billing")
handoff = Handoff(
tool_name="transfer_to_billing",
tool_description="Transfer to billing",
input_json_schema={},
on_invoke_handoff=AsyncMock(return_value=target),
input_filter=None,
agent_name=target.name,
is_enabled=True,
)
agent = RealtimeAgent(name="agent", tools=[tool], handoffs=[handoff])
session = RealtimeSession(model, agent, None)
with pytest.raises(UserError, match="function tool and handoff"):
await session._handle_tool_call(
RealtimeModelToolCallEvent(
name="transfer_to_billing",
call_id="c1",
arguments="{}",
)
)
@pytest.mark.asyncio
async def test_on_guardrail_task_done_emits_error_event():
model = _DummyModel()
agent = RealtimeAgent(name="agent")
session = RealtimeSession(model, agent, None)
async def failing_task():
raise ValueError("task failed")
task = asyncio.create_task(failing_task())
# Wait for it to finish so exception() is available
try:
await task
except Exception: # noqa: S110
pass
session._on_guardrail_task_done(task)
err = session._event_queue.get_nowait()
assert isinstance(err, RealtimeError)
@pytest.mark.parametrize("state_name", ["_closing", "_closed"])
def test_put_event_nowait_skips_events_during_cleanup(state_name: str):
session = RealtimeSession(_DummyModel(), RealtimeAgent(name="agent"), None)
setattr(session, state_name, True)
enqueued = session._put_event_nowait(
RealtimeError(info=session._event_info, error={"message": "late error"})
)
assert not enqueued
assert session._event_queue.empty()
@pytest.mark.parametrize(
("exception", "expected_message"),
[
(RuntimeError("tool failed"), "Tool call task failed: tool failed"),
(
_PendingToolOutputSendError("call-1", RuntimeError("send failed")),
"Tool output send failed; cached output will be retried: send failed",
),
],
)
@pytest.mark.asyncio
async def test_on_tool_call_task_done_emits_error_event_immediately(
exception: Exception,
expected_message: str,
):
session = RealtimeSession(_DummyModel(), RealtimeAgent(name="agent"), None)
async def failing_task() -> None:
raise exception
task = asyncio.create_task(failing_task())
await asyncio.gather(task, return_exceptions=True)
session._on_tool_call_task_done(task)
err = session._event_queue.get_nowait()
assert isinstance(err, RealtimeError)
assert err.error["message"] == expected_message
@pytest.mark.asyncio
async def test_get_handoffs_async_is_enabled(monkeypatch):
# Agent includes both a direct Handoff and a RealtimeAgent (auto-converted)
target = RealtimeAgent(name="target")
other = RealtimeAgent(name="other")
async def is_enabled(ctx, agent):
return True
# direct handoff with async is_enabled
direct = Handoff(
tool_name="to_target",
tool_description="",
input_json_schema={},
on_invoke_handoff=AsyncMock(return_value=target),
input_filter=None,
agent_name=target.name,
is_enabled=is_enabled,
)
a = RealtimeAgent(name="a", handoffs=[direct, other])
session = RealtimeSession(_DummyModel(), a, None)
enabled = await RealtimeSession._get_handoffs(a, session._context_wrapper)
# Both should be enabled
assert len(enabled) == 2
@pytest.mark.asyncio
async def test_updated_model_settings_ignores_disabled_handoff_name_conflict():
tool = function_tool(lambda: "ok", name_override="transfer_to_billing")
disabled_handoff = Handoff(
tool_name="transfer_to_billing",
tool_description="Transfer to billing",
input_json_schema={},
on_invoke_handoff=AsyncMock(return_value=RealtimeAgent(name="billing")),
input_filter=None,
agent_name="billing",
is_enabled=False,
)
agent = RealtimeAgent(name="agent", tools=[tool], handoffs=[disabled_handoff])
session = RealtimeSession(_DummyModel(), agent, None)
settings = await session._get_updated_model_settings_from_agent(None, agent)
assert settings["tools"] == [tool]
assert settings["handoffs"] == []
@pytest.mark.asyncio
async def test_updated_model_settings_does_not_reevaluate_agent_handoff_without_override():
call_count = 0
async def is_enabled(ctx: RunContextWrapper[Any], agent_arg: RealtimeAgent[Any]) -> bool:
nonlocal call_count
call_count += 1
return call_count == 1
handoff = _disabled_billing_handoff(is_enabled=is_enabled)
agent = RealtimeAgent(name="agent", handoffs=[handoff])
session = RealtimeSession(_DummyModel(), agent, None)
settings = await session._get_updated_model_settings_from_agent(
{"voice": "verse"},
agent,
)
assert settings["handoffs"] == [handoff]
assert call_count == 1
@pytest.mark.asyncio
async def test_updated_model_settings_validates_final_tool_names_after_overrides():
agent_tool = function_tool(lambda: "ok", name_override="transfer_to_billing")
agent_handoff = Handoff(
tool_name="transfer_to_billing",
tool_description="Transfer to billing",
input_json_schema={},
on_invoke_handoff=AsyncMock(return_value=RealtimeAgent(name="billing")),
input_filter=None,
agent_name="billing",
is_enabled=True,
)
override_tool = function_tool(lambda: "ok", name_override="lookup_account")
agent = RealtimeAgent(name="agent", tools=[agent_tool], handoffs=[agent_handoff])
session = RealtimeSession(_DummyModel(), agent, None)
settings = await session._get_updated_model_settings_from_agent(
{"tools": [override_tool], "handoffs": []},
agent,
)
assert settings["tools"] == [override_tool]
assert settings["handoffs"] == []
@pytest.mark.asyncio
async def test_updated_model_settings_filters_disabled_override_handoff_name_conflict():
tool = function_tool(lambda: "ok", name_override="transfer_to_billing")
disabled_handoff = _disabled_billing_handoff()
agent = RealtimeAgent(name="agent", tools=[tool])
session = RealtimeSession(_DummyModel(), agent, None)
settings = await session._get_updated_model_settings_from_agent(
{"handoffs": [disabled_handoff]},
agent,
)
assert settings["tools"] == [tool]
assert settings["handoffs"] == []
@pytest.mark.asyncio
async def test_updated_model_settings_filters_disabled_override_tool_name_conflict():
disabled_tool = _disabled_billing_tool()
handoff = _disabled_billing_handoff(is_enabled=True)
agent = RealtimeAgent(name="agent", handoffs=[handoff])
session = RealtimeSession(_DummyModel(), agent, None)
settings = await session._get_updated_model_settings_from_agent(
{"tools": [disabled_tool]},
agent,
)
assert settings["tools"] == []
assert settings["handoffs"] == [handoff]
@pytest.mark.asyncio
async def test_updated_model_settings_evaluates_override_handoff_is_enabled_callable():
tool = function_tool(lambda: "ok", name_override="transfer_to_billing")
calls: list[tuple[RunContextWrapper[Any], RealtimeAgent[Any]]] = []
async def is_enabled(ctx: RunContextWrapper[Any], agent_arg: RealtimeAgent[Any]) -> bool:
calls.append((ctx, agent_arg))
return False
disabled_handoff = _disabled_billing_handoff(is_enabled=is_enabled)
agent = RealtimeAgent(name="agent", tools=[tool])
session = RealtimeSession(_DummyModel(), agent, {"account_id": "acct_123"})
settings = await session._get_updated_model_settings_from_agent(
{"handoffs": [disabled_handoff]},
agent,
)
assert settings["handoffs"] == []
assert calls == [(session._context_wrapper, agent)]
@pytest.mark.asyncio
async def test_updated_model_settings_evaluates_override_tool_is_enabled_callable():
calls: list[tuple[RunContextWrapper[Any], RealtimeAgent[Any]]] = []
async def is_enabled(ctx: RunContextWrapper[Any], agent_arg: RealtimeAgent[Any]) -> bool:
calls.append((ctx, agent_arg))
return False
disabled_tool = _disabled_billing_tool(is_enabled=is_enabled)
agent = RealtimeAgent(name="agent")
session = RealtimeSession(_DummyModel(), agent, {"account_id": "acct_123"})
settings = await session._get_updated_model_settings_from_agent(
{"tools": [disabled_tool]},
agent,
)
assert settings["tools"] == []
assert calls == [(session._context_wrapper, agent)]
@pytest.mark.asyncio
async def test_aenter_filters_disabled_override_handoff_name_conflict():
model = _DummyModel()
tool = function_tool(lambda: "ok", name_override="transfer_to_billing")
agent = RealtimeAgent(name="agent", tools=[tool])
session = RealtimeSession(
model,
agent,
None,
model_config={"initial_model_settings": {"handoffs": [_disabled_billing_handoff()]}},
)
await session.__aenter__()
assert model.connect_options is not None
initial_settings = model.connect_options["initial_model_settings"]
assert initial_settings["tools"] == [tool]
assert initial_settings["handoffs"] == []
await session.__aexit__(None, None, None)
@pytest.mark.asyncio
async def test_aenter_filters_disabled_override_tool_name_conflict():
model = _DummyModel()
disabled_tool = _disabled_billing_tool()
agent = RealtimeAgent(
name="agent",
handoffs=[_disabled_billing_handoff(is_enabled=True)],
)
session = RealtimeSession(
model,
agent,
None,
model_config={"initial_model_settings": {"tools": [disabled_tool]}},
)
await session.__aenter__()
assert model.connect_options is not None
initial_settings = model.connect_options["initial_model_settings"]
assert initial_settings["tools"] == []
assert [handoff.tool_name for handoff in initial_settings["handoffs"]] == [
"transfer_to_billing"
]
await session.__aexit__(None, None, None)
@pytest.mark.asyncio
async def test_aenter_validates_initial_model_settings_before_listener_registration():
model = _DummyModel()
tool = function_tool(lambda: "ok", name_override="transfer_to_billing")
handoff = Handoff(
tool_name="transfer_to_billing",
tool_description="Transfer to billing",
input_json_schema={},
on_invoke_handoff=AsyncMock(return_value=RealtimeAgent(name="billing")),
input_filter=None,
agent_name="billing",
is_enabled=True,
)
agent = RealtimeAgent(name="agent", tools=[tool], handoffs=[handoff])
session = RealtimeSession(model, agent, None)
with pytest.raises(UserError, match="Duplicate Realtime tool"):
await session.__aenter__()
assert model.listeners == []
@pytest.mark.parametrize(
"exc",
[RuntimeError("connect failed"), asyncio.CancelledError()],
ids=["runtime-error", "cancelled-error"],
)
@pytest.mark.asyncio
async def test_aenter_removes_listener_when_connect_fails(exc: BaseException):
model = _FailingConnectModel(exc)
agent = RealtimeAgent(name="agent")
session = RealtimeSession(model, agent, None)
with pytest.raises(type(exc)):
await session.__aenter__()
assert model.connect_options is not None
assert model.listeners == []
class MockRealtimeModel(RealtimeModel):
def __init__(self):
super().__init__()
self.listeners = []
self.connect_called = False
self.close_called = False
self.sent_events = []
# Legacy tracking for tests that haven't been updated yet
self.sent_messages = []
self.sent_audio = []
self.sent_tool_outputs = []
self.interrupts_called = 0
async def connect(self, options=None):
self.connect_called = True
def add_listener(self, listener):
self.listeners.append(listener)
def remove_listener(self, listener):
if listener in self.listeners:
self.listeners.remove(listener)
async def send_event(self, event):
from agents.realtime.model_inputs import (
RealtimeModelSendAudio,
RealtimeModelSendInterrupt,
RealtimeModelSendToolOutput,
RealtimeModelSendUserInput,
)
self.sent_events.append(event)
# Update legacy tracking for compatibility
if isinstance(event, RealtimeModelSendUserInput):
self.sent_messages.append(event.user_input)
elif isinstance(event, RealtimeModelSendAudio):
self.sent_audio.append((event.audio, event.commit))
elif isinstance(event, RealtimeModelSendToolOutput):
self.sent_tool_outputs.append((event.tool_call, event.output, event.start_response))
elif isinstance(event, RealtimeModelSendInterrupt):
self.interrupts_called += 1
async def close(self):
self.close_called = True
@pytest.fixture
def mock_agent():
agent = Mock(spec=RealtimeAgent)
agent.get_all_tools = AsyncMock(return_value=[])
type(agent).handoffs = PropertyMock(return_value=[])
type(agent).output_guardrails = PropertyMock(return_value=[])
return agent
@pytest.fixture
def mock_model():
return MockRealtimeModel()
def _set_default_timeout_fields(tool: Mock) -> Mock:
tool.timeout_seconds = None
tool.timeout_behavior = "error_as_result"
tool.timeout_error_function = None
return tool
def _named_function_tool(
name: str,
output: str,
*,
needs_approval: bool = False,
) -> FunctionTool:
def tool_func() -> str:
return output
tool = function_tool(tool_func, name_override=name)
tool.needs_approval = needs_approval
return tool
def _sent_tool_output_strings(model: MockRealtimeModel) -> list[str]:
return [output for _call, output, _start_response in model.sent_tool_outputs]
@pytest.fixture
def mock_function_tool():
tool = _set_default_timeout_fields(Mock(spec=FunctionTool))
tool.name = "test_function"
tool.on_invoke_tool = AsyncMock(return_value="function_result")
tool.needs_approval = False
return tool
@pytest.fixture
def mock_handoff():
handoff = Mock(spec=Handoff)
handoff.name = "test_handoff"
return handoff
class TestEventHandling:
"""Test suite for event handling and transformation in RealtimeSession.on_event"""
@pytest.mark.asyncio
async def test_error_event_transformation(self, mock_model, mock_agent):
"""Test that error events are properly transformed and queued"""
session = RealtimeSession(
mock_model, mock_agent, None, run_config={"async_tool_calls": False}
)
error_event = RealtimeModelErrorEvent(error="Test error")
await session.on_event(error_event)
# Check that events were queued
assert session._event_queue.qsize() == 2
# First event should be raw model event
raw_event = await session._event_queue.get()
assert isinstance(raw_event, RealtimeRawModelEvent)
assert raw_event.data == error_event
# Second event should be transformed error event
error_session_event = await session._event_queue.get()
assert isinstance(error_session_event, RealtimeError)
assert error_session_event.error == "Test error"
@pytest.mark.asyncio
async def test_audio_events_transformation(self, mock_model, mock_agent):
"""Test that audio-related events are properly transformed"""
session = RealtimeSession(
mock_model, mock_agent, None, run_config={"async_tool_calls": False}
)
# Test audio event
audio_event = RealtimeModelAudioEvent(
data=b"audio_data", response_id="resp_1", item_id="item_1", content_index=0
)
await session.on_event(audio_event)
# Test audio interrupted event
interrupted_event = RealtimeModelAudioInterruptedEvent(item_id="item_1", content_index=0)
await session.on_event(interrupted_event)
# Test audio done event
done_event = RealtimeModelAudioDoneEvent(item_id="item_1", content_index=0)
await session.on_event(done_event)
# Should have 6 events total (2 per event: raw + transformed)
assert session._event_queue.qsize() == 6
# Check audio event transformation
await session._event_queue.get() # raw event
audio_session_event = await session._event_queue.get()
assert isinstance(audio_session_event, RealtimeAudio)
assert audio_session_event.audio == audio_event
# Check audio interrupted transformation
await session._event_queue.get() # raw event
interrupted_session_event = await session._event_queue.get()
assert isinstance(interrupted_session_event, RealtimeAudioInterrupted)
# Check audio done transformation
await session._event_queue.get() # raw event
done_session_event = await session._event_queue.get()
assert isinstance(done_session_event, RealtimeAudioEnd)
@pytest.mark.asyncio
async def test_turn_events_transformation(self, mock_model, mock_agent):
"""Test that turn start/end events are properly transformed"""
session = RealtimeSession(
mock_model, mock_agent, None, run_config={"async_tool_calls": False}
)
# Test turn started event
turn_started = RealtimeModelTurnStartedEvent()
await session.on_event(turn_started)
# Test turn ended event
turn_ended = RealtimeModelTurnEndedEvent()
await session.on_event(turn_ended)
# Should have 4 events total (2 per event: raw + transformed)
assert session._event_queue.qsize() == 4
# Check turn started transformation
await session._event_queue.get() # raw event
start_session_event = await session._event_queue.get()
assert isinstance(start_session_event, RealtimeAgentStartEvent)
assert start_session_event.agent == mock_agent
# Check turn ended transformation
await session._event_queue.get() # raw event
end_session_event = await session._event_queue.get()
assert isinstance(end_session_event, RealtimeAgentEndEvent)
assert end_session_event.agent == mock_agent
@pytest.mark.asyncio
async def test_usage_events_accumulate_in_session_context(self, mock_model, mock_agent):
session = RealtimeSession(
mock_model, mock_agent, None, run_config={"async_tool_calls": False}
)
first = RealtimeModelUsageEvent(
usage=Usage(requests=1, input_tokens=10, output_tokens=4, total_tokens=14)
)
second = RealtimeModelUsageEvent(
usage=Usage(requests=1, input_tokens=7, output_tokens=3, total_tokens=10)
)
await session.on_event(first)
await session.on_event(second)
assert session._event_queue.qsize() == 2
first_raw = await session._event_queue.get()
second_raw = await session._event_queue.get()
assert isinstance(first_raw, RealtimeRawModelEvent)
assert isinstance(second_raw, RealtimeRawModelEvent)
assert first_raw.data is first
assert second_raw.data is second
assert first_raw.info.context.usage.requests == 2
assert first_raw.info.context.usage.input_tokens == 17
assert first_raw.info.context.usage.output_tokens == 7
assert first_raw.info.context.usage.total_tokens == 24
assert len(first_raw.info.context.usage.request_usage_entries) == 2
@pytest.mark.asyncio
async def test_transcription_completed_event_updates_history(self, mock_model, mock_agent):
"""Test that transcription completed events update history and emit events"""
session = RealtimeSession(
mock_model, mock_agent, None, run_config={"async_tool_calls": False}
)
# Set up initial history with an audio message
initial_item = UserMessageItem(
item_id="item_1", role="user", content=[InputAudio(transcript=None)]
)
session._history = [initial_item]
# Create transcription completed event
transcription_event = RealtimeModelInputAudioTranscriptionCompletedEvent(
item_id="item_1", transcript="Hello world"
)
await session.on_event(transcription_event)
# Check that history was updated
assert len(session._history) == 1
updated_item = session._history[0]
assert updated_item.content[0].transcript == "Hello world" # type: ignore
assert updated_item.status == "completed" # type: ignore
# Should have 2 events: raw + history updated
assert session._event_queue.qsize() == 2
await session._event_queue.get() # raw event
history_event = await session._event_queue.get()
assert isinstance(history_event, RealtimeHistoryUpdated)
assert len(history_event.history) == 1
@pytest.mark.asyncio
async def test_item_updated_event_adds_new_item(self, mock_model, mock_agent):
"""Test that item_updated events add new items to history"""
session = RealtimeSession(
mock_model,
mock_agent,
None,
run_config={"async_tool_calls": False},
)
new_item = AssistantMessageItem(
item_id="new_item", role="assistant", content=[AssistantText(text="Hello")]
)
item_updated_event = RealtimeModelItemUpdatedEvent(item=new_item)
await session.on_event(item_updated_event)
# Check that item was added to history
assert len(session._history) == 1
assert session._history[0] == new_item
# Should have 2 events: raw + history added
assert session._event_queue.qsize() == 2
await session._event_queue.get() # raw event
history_event = await session._event_queue.get()
assert isinstance(history_event, RealtimeHistoryAdded)
assert history_event.item == new_item
@pytest.mark.asyncio
async def test_item_updated_event_updates_existing_item(self, mock_model, mock_agent):
"""Test that item_updated events update existing items in history"""
session = RealtimeSession(
mock_model,
mock_agent,
None,
run_config={"async_tool_calls": False},
)
# Set up initial history
initial_item = AssistantMessageItem(
item_id="existing_item", role="assistant", content=[AssistantText(text="Initial")]
)
session._history = [initial_item]
# Create updated version
updated_item = AssistantMessageItem(
item_id="existing_item", role="assistant", content=[AssistantText(text="Updated")]
)
item_updated_event = RealtimeModelItemUpdatedEvent(item=updated_item)
await session.on_event(item_updated_event)
# Check that item was updated
assert len(session._history) == 1
updated_item = cast(AssistantMessageItem, session._history[0])
assert updated_item.content[0].text == "Updated" # type: ignore
# Should have 2 events: raw + history updated (not added)
assert session._event_queue.qsize() == 2
await session._event_queue.get() # raw event
history_event = await session._event_queue.get()
assert isinstance(history_event, RealtimeHistoryUpdated)
@pytest.mark.asyncio
async def test_item_deleted_event_removes_item(self, mock_model, mock_agent):
"""Test that item_deleted events remove items from history"""
session = RealtimeSession(mock_model, mock_agent, None)
# Set up initial history with multiple items
item1 = AssistantMessageItem(
item_id="item_1", role="assistant", content=[AssistantText(text="First")]
)
item2 = AssistantMessageItem(
item_id="item_2", role="assistant", content=[AssistantText(text="Second")]
)
session._history = [item1, item2]
# Delete first item
delete_event = RealtimeModelItemDeletedEvent(item_id="item_1")
await session.on_event(delete_event)
# Check that item was removed
assert len(session._history) == 1
assert session._history[0].item_id == "item_2"
# Should have 2 events: raw + history updated
assert session._event_queue.qsize() == 2
await session._event_queue.get() # raw event
history_event = await session._event_queue.get()
assert isinstance(history_event, RealtimeHistoryUpdated)
assert len(history_event.history) == 1
@pytest.mark.asyncio
async def test_ignored_events_only_generate_raw_events(self, mock_model, mock_agent):
"""Test that ignored events (transcript_delta, connection_status, other) only generate raw
events"""
session = RealtimeSession(mock_model, mock_agent, None)
# Test transcript delta (should be ignored per TODO comment)
transcript_event = RealtimeModelTranscriptDeltaEvent(
item_id="item_1", delta="hello", response_id="resp_1"
)
await session.on_event(transcript_event)
# Test connection status (should be ignored)
connection_event = RealtimeModelConnectionStatusEvent(status="connected")
await session.on_event(connection_event)
# Test other event (should be ignored)
other_event = RealtimeModelOtherEvent(data={"custom": "data"})
await session.on_event(other_event)
# Should only have 3 raw events (no transformed events)
assert session._event_queue.qsize() == 3
for _ in range(3):
event = await session._event_queue.get()
assert isinstance(event, RealtimeRawModelEvent)
@pytest.mark.asyncio
async def test_function_call_event_triggers_tool_handling(self, mock_model, mock_agent):
"""Test that function_call events trigger tool call handling synchronously when disabled"""
session = RealtimeSession(
mock_model,
mock_agent,
None,
run_config={"async_tool_calls": False},
)
# Create function call event
function_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_123", arguments='{"param": "value"}'
)
# We'll test the detailed tool handling in a separate test class
# Here we just verify that it gets to the handler
with pytest.MonkeyPatch().context() as m:
handle_tool_call_mock = AsyncMock()
m.setattr(session, "_handle_tool_call", handle_tool_call_mock)
await session.on_event(function_call_event)
# Should have called the tool handler
handle_tool_call_mock.assert_called_once_with(
function_call_event, agent_snapshot=mock_agent
)
# Should still have raw event
assert session._event_queue.qsize() == 1
raw_event = await session._event_queue.get()
assert isinstance(raw_event, RealtimeRawModelEvent)
assert raw_event.data == function_call_event
@pytest.mark.asyncio
async def test_function_call_event_runs_async_by_default(self, mock_model, mock_agent):
"""Function call handling should be scheduled asynchronously by default"""
session = RealtimeSession(mock_model, mock_agent, None)
function_call_event = RealtimeModelToolCallEvent(
name="test_function",
call_id="call_async",
arguments='{"param": "value"}',
)
with pytest.MonkeyPatch().context() as m:
handle_tool_call_mock = AsyncMock()
m.setattr(session, "_handle_tool_call", handle_tool_call_mock)
await session.on_event(function_call_event)
# Let the background task run
await asyncio.sleep(0)
handle_tool_call_mock.assert_awaited_once_with(
function_call_event, agent_snapshot=mock_agent
)
# Raw event still enqueued
assert session._event_queue.qsize() == 1
raw_event = await session._event_queue.get()
assert isinstance(raw_event, RealtimeRawModelEvent)
assert raw_event.data == function_call_event
class TestHistoryManagement:
"""Test suite for history management and audio transcription in
RealtimeSession._get_new_history"""
def test_merge_transcript_into_existing_audio_message(self):
"""Test merging audio transcript into existing placeholder input_audio message"""
# Create initial history with audio message without transcript
initial_item = UserMessageItem(
item_id="item_1",
role="user",
content=[
InputText(text="Before audio"),
InputAudio(transcript=None, audio="audio_data"),
InputText(text="After audio"),
],
)
old_history = [initial_item]
# Create transcription completed event
transcription_event = RealtimeModelInputAudioTranscriptionCompletedEvent(
item_id="item_1", transcript="Hello world"
)
# Apply the history update
new_history = RealtimeSession._get_new_history(
cast(list[RealtimeItem], old_history), transcription_event
)
# Verify the transcript was merged
assert len(new_history) == 1
updated_item = cast(UserMessageItem, new_history[0])
assert updated_item.item_id == "item_1"
assert hasattr(updated_item, "status") and updated_item.status == "completed"
assert len(updated_item.content) == 3
# Check that audio content got transcript but other content unchanged
assert cast(InputText, updated_item.content[0]).text == "Before audio"
assert cast(InputAudio, updated_item.content[1]).transcript == "Hello world"
# Should preserve audio data
assert cast(InputAudio, updated_item.content[1]).audio == "audio_data"
assert cast(InputText, updated_item.content[2]).text == "After audio"
def test_merge_transcript_preserves_other_items(self):
"""Test that merging transcript preserves other items in history"""
# Create history with multiple items
item1 = UserMessageItem(
item_id="item_1", role="user", content=[InputText(text="First message")]
)
item2 = UserMessageItem(
item_id="item_2", role="user", content=[InputAudio(transcript=None)]
)
item3 = AssistantMessageItem(
item_id="item_3", role="assistant", content=[AssistantText(text="Third message")]
)
old_history = [item1, item2, item3]
# Create transcription event for item_2
transcription_event = RealtimeModelInputAudioTranscriptionCompletedEvent(
item_id="item_2", transcript="Transcribed audio"
)
new_history = RealtimeSession._get_new_history(
cast(list[RealtimeItem], old_history), transcription_event
)
# Should have same number of items
assert len(new_history) == 3
# First and third items should be unchanged
assert new_history[0] == item1
assert new_history[2] == item3
# Second item should have transcript
updated_item2 = cast(UserMessageItem, new_history[1])
assert updated_item2.item_id == "item_2"
assert cast(InputAudio, updated_item2.content[0]).transcript == "Transcribed audio"
assert hasattr(updated_item2, "status") and updated_item2.status == "completed"
def test_merge_transcript_only_affects_matching_audio_content(self):
"""Test that transcript merge only affects audio content, not text content"""
# Create item with mixed content including multiple audio items
item = UserMessageItem(
item_id="item_1",
role="user",
content=[
InputText(text="Text content"),
InputAudio(transcript=None, audio="audio1"),
InputAudio(transcript="existing", audio="audio2"),
InputText(text="More text"),
],
)
old_history = [item]
transcription_event = RealtimeModelInputAudioTranscriptionCompletedEvent(
item_id="item_1", transcript="New transcript"
)
new_history = RealtimeSession._get_new_history(
cast(list[RealtimeItem], old_history), transcription_event
)
updated_item = cast(UserMessageItem, new_history[0])
# Text content should be unchanged
assert cast(InputText, updated_item.content[0]).text == "Text content"
assert cast(InputText, updated_item.content[3]).text == "More text"
# All audio content should have the new transcript (current implementation overwrites all)
assert cast(InputAudio, updated_item.content[1]).transcript == "New transcript"
assert (
cast(InputAudio, updated_item.content[2]).transcript == "New transcript"
) # Implementation overwrites existing
def test_update_existing_item_by_id(self):
"""Test updating an existing item by item_id"""
# Create initial history
original_item = AssistantMessageItem(
item_id="item_1", role="assistant", content=[AssistantText(text="Original")]
)
old_history = [original_item]
# Create updated version of same item
updated_item = AssistantMessageItem(
item_id="item_1", role="assistant", content=[AssistantText(text="Updated")]
)
new_history = RealtimeSession._get_new_history(
cast(list[RealtimeItem], old_history), updated_item
)
# Should have same number of items
assert len(new_history) == 1
# Item should be updated
result_item = cast(AssistantMessageItem, new_history[0])
assert result_item.item_id == "item_1"
assert result_item.content[0].text == "Updated" # type: ignore
def test_update_existing_item_preserves_order(self):
"""Test that updating existing item preserves its position in history"""
# Create history with multiple items
item1 = AssistantMessageItem(
item_id="item_1", role="assistant", content=[AssistantText(text="First")]
)
item2 = AssistantMessageItem(
item_id="item_2", role="assistant", content=[AssistantText(text="Second")]
)
item3 = AssistantMessageItem(
item_id="item_3", role="assistant", content=[AssistantText(text="Third")]
)
old_history = [item1, item2, item3]
# Update middle item
updated_item2 = AssistantMessageItem(
item_id="item_2", role="assistant", content=[AssistantText(text="Updated Second")]
)
new_history = RealtimeSession._get_new_history(
cast(list[RealtimeItem], old_history), updated_item2
)
# Should have same number of items in same order
assert len(new_history) == 3
assert new_history[0].item_id == "item_1"
assert new_history[1].item_id == "item_2"
assert new_history[2].item_id == "item_3"
# Middle item should be updated
updated_result = cast(AssistantMessageItem, new_history[1])
assert updated_result.content[0].text == "Updated Second" # type: ignore
# Other items should be unchanged
item1_result = cast(AssistantMessageItem, new_history[0])
item3_result = cast(AssistantMessageItem, new_history[2])
assert item1_result.content[0].text == "First" # type: ignore
assert item3_result.content[0].text == "Third" # type: ignore
def test_insert_new_item_after_previous_item(self):
"""Test inserting new item after specified previous_item_id"""
# Create initial history
item1 = AssistantMessageItem(
item_id="item_1", role="assistant", content=[AssistantText(text="First")]
)
item3 = AssistantMessageItem(
item_id="item_3", role="assistant", content=[AssistantText(text="Third")]
)
old_history = [item1, item3]
# Create new item to insert between them
new_item = AssistantMessageItem(
item_id="item_2",
previous_item_id="item_1",
role="assistant",
content=[AssistantText(text="Second")],
)
new_history = RealtimeSession._get_new_history(
cast(list[RealtimeItem], old_history), new_item
)
# Should have one more item
assert len(new_history) == 3
# Items should be in correct order
assert new_history[0].item_id == "item_1"
assert new_history[1].item_id == "item_2"
assert new_history[2].item_id == "item_3"
# Content should be correct
item2_result = cast(AssistantMessageItem, new_history[1])
assert item2_result.content[0].text == "Second" # type: ignore
def test_insert_new_item_after_nonexistent_previous_item(self):
"""Test that item with nonexistent previous_item_id gets added to end"""
# Create initial history
item1 = AssistantMessageItem(
item_id="item_1", role="assistant", content=[AssistantText(text="First")]
)
old_history = [item1]
# Create new item with nonexistent previous_item_id
new_item = AssistantMessageItem(
item_id="item_2",
previous_item_id="nonexistent",
role="assistant",
content=[AssistantText(text="Second")],
)
new_history = RealtimeSession._get_new_history(
cast(list[RealtimeItem], old_history), new_item
)
# Should add to end when previous_item_id not found
assert len(new_history) == 2
assert new_history[0].item_id == "item_1"
assert new_history[1].item_id == "item_2"
def test_add_new_item_to_end_when_no_previous_item_id(self):
"""Test adding new item to end when no previous_item_id is specified"""
# Create initial history
item1 = AssistantMessageItem(
item_id="item_1", role="assistant", content=[AssistantText(text="First")]
)
old_history = [item1]
# Create new item without previous_item_id
new_item = AssistantMessageItem(
item_id="item_2", role="assistant", content=[AssistantText(text="Second")]
)
new_history = RealtimeSession._get_new_history(
cast(list[RealtimeItem], old_history), new_item
)
# Should add to end
assert len(new_history) == 2
assert new_history[0].item_id == "item_1"
assert new_history[1].item_id == "item_2"
def test_add_first_item_to_empty_history(self):
"""Test adding first item to empty history"""
old_history: list[RealtimeItem] = []
new_item = AssistantMessageItem(
item_id="item_1", role="assistant", content=[AssistantText(text="First")]
)
new_history = RealtimeSession._get_new_history(old_history, new_item)
assert len(new_history) == 1
assert new_history[0].item_id == "item_1"
def test_complex_insertion_scenario(self):
"""Test complex scenario with multiple insertions and updates"""
# Start with items A and C
itemA = AssistantMessageItem(
item_id="A", role="assistant", content=[AssistantText(text="A")]
)
itemC = AssistantMessageItem(
item_id="C", role="assistant", content=[AssistantText(text="C")]
)
history: list[RealtimeItem] = [itemA, itemC]
# Insert B after A
itemB = AssistantMessageItem(
item_id="B", previous_item_id="A", role="assistant", content=[AssistantText(text="B")]
)
history = RealtimeSession._get_new_history(history, itemB)
# Should be A, B, C
assert len(history) == 3
assert [item.item_id for item in history] == ["A", "B", "C"]
# Insert D after B
itemD = AssistantMessageItem(
item_id="D", previous_item_id="B", role="assistant", content=[AssistantText(text="D")]
)
history = RealtimeSession._get_new_history(history, itemD)
# Should be A, B, D, C
assert len(history) == 4
assert [item.item_id for item in history] == ["A", "B", "D", "C"]
# Update B
updated_itemB = AssistantMessageItem(
item_id="B", role="assistant", content=[AssistantText(text="Updated B")]
)
history = RealtimeSession._get_new_history(history, updated_itemB)
# Should still be A, B, D, C but B is updated
assert len(history) == 4
assert [item.item_id for item in history] == ["A", "B", "D", "C"]
itemB_result = cast(AssistantMessageItem, history[1])
assert itemB_result.content[0].text == "Updated B" # type: ignore
# Test 3: Tool call execution flow (_handle_tool_call method)
class TestToolCallExecution:
"""Test suite for tool call execution flow in RealtimeSession._handle_tool_call"""
@pytest.mark.asyncio
async def test_function_tool_execution_success(
self, mock_model, mock_agent, mock_function_tool
):
"""Test successful function tool execution"""
# Set up agent to return our mock tool
mock_agent.get_all_tools.return_value = [mock_function_tool]
session = RealtimeSession(mock_model, mock_agent, None)
# Create function call event
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_123", arguments='{"param": "value"}'
)
await session._handle_tool_call(tool_call_event)
# Verify the flow
mock_agent.get_all_tools.assert_called_once()
mock_function_tool.on_invoke_tool.assert_called_once()
# Check the tool context was created correctly
call_args = mock_function_tool.on_invoke_tool.call_args
tool_context = call_args[0][0]
assert isinstance(tool_context, ToolContext)
assert tool_context.agent == mock_agent
assert call_args[0][1] == '{"param": "value"}'
# Verify tool output was sent to model
assert len(mock_model.sent_tool_outputs) == 1
sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0]
assert sent_call == tool_call_event
assert sent_output == "function_result"
assert start_response is True
# Verify events were queued
assert session._event_queue.qsize() == 2
# Check tool start event
tool_start_event = await session._event_queue.get()
assert isinstance(tool_start_event, RealtimeToolStart)
assert tool_start_event.tool == mock_function_tool
assert tool_start_event.agent == mock_agent
assert tool_start_event.arguments == '{"param": "value"}'
# Check tool end event
tool_end_event = await session._event_queue.get()
assert isinstance(tool_end_event, RealtimeToolEnd)
assert tool_end_event.tool == mock_function_tool
assert tool_end_event.output == "function_result"
assert tool_end_event.agent == mock_agent
assert tool_end_event.arguments == '{"param": "value"}'
@pytest.mark.asyncio
async def test_initial_settings_handoff_override_does_not_block_function_dispatch(
self, mock_model
):
tool = _named_function_tool("transfer_to_billing", "function ok")
agent = RealtimeAgent(
name="agent",
tools=[tool],
handoffs=[_disabled_billing_handoff(is_enabled=True)],
)
session = RealtimeSession(
mock_model,
agent,
None,
model_config={"initial_model_settings": {"handoffs": []}},
run_config={"async_tool_calls": False},
)
await session.__aenter__()
try:
await session._handle_tool_call(
RealtimeModelToolCallEvent(
name="transfer_to_billing",
call_id="call_initial_handoff_override",
arguments="{}",
)
)
assert _sent_tool_output_strings(mock_model) == ["function ok"]
finally:
await session.__aexit__(None, None, None)
@pytest.mark.asyncio
async def test_initial_settings_function_tool_override_is_dispatchable(self, mock_model):
override_tool = _named_function_tool("override_tool", "override ok")
agent = RealtimeAgent(name="agent", tools=[], handoffs=[])
session = RealtimeSession(
mock_model,
agent,
None,
model_config={"initial_model_settings": {"tools": [override_tool]}},
run_config={"async_tool_calls": False},
)
await session.__aenter__()
try:
await session._handle_tool_call(
RealtimeModelToolCallEvent(
name="override_tool",
call_id="call_initial_tool_override",
arguments="{}",
)
)
assert _sent_tool_output_strings(mock_model) == ["override ok"]
finally:
await session.__aexit__(None, None, None)
@pytest.mark.asyncio
async def test_initial_settings_handoff_override_is_dispatchable(self, mock_model):
target_agent = RealtimeAgent(name="billing", tools=[], handoffs=[])
override_handoff = Handoff(
tool_name="transfer_to_billing",
tool_description="Transfer to billing",
input_json_schema={},
on_invoke_handoff=AsyncMock(return_value=target_agent),
input_filter=None,
agent_name=target_agent.name,
is_enabled=True,
)
agent = RealtimeAgent(name="agent", tools=[], handoffs=[])
session = RealtimeSession(
mock_model,
agent,
None,
model_config={"initial_model_settings": {"handoffs": [override_handoff]}},
run_config={"async_tool_calls": False},
)
await session.__aenter__()
try:
await session._handle_tool_call(
RealtimeModelToolCallEvent(
name="transfer_to_billing",
call_id="call_initial_handoff_override_dispatch",
arguments="{}",
)
)
assert session._current_agent is target_agent
assert _sent_tool_output_strings(mock_model) == [
json.dumps({"assistant": target_agent.name})
]
assert any(
isinstance(event, RealtimeModelSendSessionUpdate)
for event in mock_model.sent_events
)
finally:
await session.__aexit__(None, None, None)
@pytest.mark.asyncio
async def test_initial_settings_same_name_function_tool_override_is_dispatched(
self, mock_model
):
agent_tool = _named_function_tool("shared_tool", "agent implementation")
override_tool = _named_function_tool("shared_tool", "override implementation")
agent = RealtimeAgent(name="agent", tools=[agent_tool], handoffs=[])
session = RealtimeSession(
mock_model,
agent,
None,
model_config={"initial_model_settings": {"tools": [override_tool]}},
run_config={"async_tool_calls": False},
)
await session.__aenter__()
try:
await session._handle_tool_call(
RealtimeModelToolCallEvent(
name="shared_tool",
call_id="call_same_name_override",
arguments="{}",
)
)
assert _sent_tool_output_strings(mock_model) == ["override implementation"]
finally:
await session.__aexit__(None, None, None)
@pytest.mark.asyncio
async def test_dispatch_rechecks_dynamic_function_tool_enablement(self, mock_model):
enabled = True
tool_calls: list[str] = []
def is_enabled(
_ctx: RunContextWrapper[Any],
_agent: Any,
) -> bool:
return enabled
def dynamic_tool() -> str:
tool_calls.append("called")
return "should not run"
tool = function_tool(
dynamic_tool,
name_override="dynamic_tool",
is_enabled=is_enabled,
)
agent = RealtimeAgent(name="agent", tools=[tool], handoffs=[])
session = RealtimeSession(
mock_model,
agent,
None,
run_config={"async_tool_calls": False},
)
await session.__aenter__()
try:
enabled = False
await session._handle_tool_call(
RealtimeModelToolCallEvent(
name="dynamic_tool",
call_id="call_dynamic_tool_disabled",
arguments="{}",
)
)
assert tool_calls == []
assert _sent_tool_output_strings(mock_model) == ["Tool dynamic_tool not found"]
finally:
await session.__aexit__(None, None, None)
@pytest.mark.asyncio
async def test_dispatch_rechecks_dynamic_handoff_enablement(self, mock_model):
enabled = True
def is_enabled(
_ctx: RunContextWrapper[Any],
_agent: Any,
) -> bool:
return enabled
target_agent = RealtimeAgent(name="target", tools=[], handoffs=[])
on_invoke_handoff = AsyncMock(return_value=target_agent)
handoff = Handoff(
tool_name="transfer_to_target",
tool_description="Transfer to target",
input_json_schema={},
on_invoke_handoff=on_invoke_handoff,
input_filter=None,
agent_name=target_agent.name,
is_enabled=is_enabled,
)
agent = RealtimeAgent(name="agent", tools=[], handoffs=[handoff])
session = RealtimeSession(
mock_model,
agent,
None,
run_config={"async_tool_calls": False},
)
await session.__aenter__()
try:
enabled = False
await session._handle_tool_call(
RealtimeModelToolCallEvent(
name="transfer_to_target",
call_id="call_dynamic_handoff_disabled",
arguments="{}",
)
)
assert on_invoke_handoff.await_count == 0
assert session._current_agent is agent
assert _sent_tool_output_strings(mock_model) == ["Tool transfer_to_target not found"]
finally:
await session.__aexit__(None, None, None)
@pytest.mark.asyncio
async def test_approval_resume_uses_pending_initial_settings_dispatch_snapshot(
self, mock_model
):
approved_tool = _named_function_tool(
"approval_tool",
"approved implementation",
needs_approval=True,
)
replacement_tool = _named_function_tool("approval_tool", "replacement implementation")
initial_agent = RealtimeAgent(name="initial", tools=[], handoffs=[])
replacement_agent = RealtimeAgent(name="replacement", tools=[replacement_tool], handoffs=[])
session = RealtimeSession(
mock_model,
initial_agent,
None,
model_config={"initial_model_settings": {"tools": [approved_tool]}},
run_config={"async_tool_calls": False},
)
tool_call_event = RealtimeModelToolCallEvent(
name="approval_tool",
call_id="call_pending_snapshot",
arguments="{}",
)
await session.__aenter__()
try:
await session._handle_tool_call(tool_call_event)
assert list(session._pending_tool_calls) == [tool_call_event.call_id]
await session.update_agent(replacement_agent)
await session.approve_tool_call(tool_call_event.call_id)
assert _sent_tool_output_strings(mock_model) == ["approved implementation"]
finally:
await session.__aexit__(None, None, None)
@pytest.mark.asyncio
async def test_async_tool_call_uses_event_initial_settings_dispatch_snapshot(
self, mock_model, monkeypatch
):
initial_tool = _named_function_tool("snapshot_tool", "initial implementation")
replacement_tool = _named_function_tool("snapshot_tool", "replacement implementation")
initial_agent = RealtimeAgent(name="initial", tools=[], handoffs=[])
replacement_agent = RealtimeAgent(name="replacement", tools=[replacement_tool], handoffs=[])
session = RealtimeSession(
mock_model,
initial_agent,
None,
model_config={"initial_model_settings": {"tools": [initial_tool]}},
)
tool_call_event = RealtimeModelToolCallEvent(
name="snapshot_tool",
call_id="call_async_snapshot",
arguments="{}",
)
resolve_started = asyncio.Event()
release_resolve = asyncio.Event()
original_resolve_dispatch_snapshot = session._resolve_dispatch_snapshot
async def gated_resolve_dispatch_snapshot(agent, dispatch_snapshot):
resolve_started.set()
await release_resolve.wait()
return await original_resolve_dispatch_snapshot(agent, dispatch_snapshot)
monkeypatch.setattr(
session,
"_resolve_dispatch_snapshot",
gated_resolve_dispatch_snapshot,
)
await session.__aenter__()
try:
await session.on_event(tool_call_event)
tool_call_tasks = list(session._tool_call_tasks)
assert len(tool_call_tasks) == 1
await asyncio.wait_for(resolve_started.wait(), timeout=1)
await session.update_agent(replacement_agent)
release_resolve.set()
await asyncio.gather(*tool_call_tasks)
assert _sent_tool_output_strings(mock_model) == ["initial implementation"]
finally:
release_resolve.set()
await session.__aexit__(None, None, None)
@pytest.mark.asyncio
async def test_duplicate_function_tool_call_id_is_ignored(
self, mock_model, mock_agent, mock_function_tool
):
"""Duplicate function call IDs should not re-run side-effecting tools."""
mock_agent.get_all_tools.return_value = [mock_function_tool]
session = RealtimeSession(mock_model, mock_agent, None)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_duplicate", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
await session._handle_tool_call(tool_call_event)
mock_function_tool.on_invoke_tool.assert_called_once()
assert len(mock_model.sent_tool_outputs) == 1
@pytest.mark.asyncio
async def test_function_tool_send_failure_retries_cached_output_without_rerun(
self, mock_agent, mock_function_tool
):
"""A post-execution send failure should retry output without rerunning the tool."""
class FailingToolOutputModel(MockRealtimeModel):
def __init__(self):
super().__init__()
self.fail_next_tool_output = True
async def send_event(self, event):
if isinstance(event, RealtimeModelSendToolOutput) and self.fail_next_tool_output:
self.fail_next_tool_output = False
raise RuntimeError("send failed")
await super().send_event(event)
mock_agent.get_all_tools.return_value = [mock_function_tool]
mock_model = FailingToolOutputModel()
session = RealtimeSession(mock_model, mock_agent, None)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_retry_output", arguments="{}"
)
with pytest.raises(RuntimeError, match="send failed"):
await session._handle_tool_call(tool_call_event)
mock_function_tool.on_invoke_tool.assert_called_once()
assert len(mock_model.sent_tool_outputs) == 0
await session._handle_tool_call(tool_call_event)
mock_function_tool.on_invoke_tool.assert_called_once()
assert len(mock_model.sent_tool_outputs) == 1
@pytest.mark.asyncio
async def test_async_function_tool_send_failure_retries_cached_output_without_rerun(
self, mock_agent, mock_function_tool
):
"""The async task path should keep cached outputs retryable after send failure."""
class FailingToolOutputModel(MockRealtimeModel):
def __init__(self):
super().__init__()
self.fail_next_tool_output = True
async def send_event(self, event):
if isinstance(event, RealtimeModelSendToolOutput) and self.fail_next_tool_output:
self.fail_next_tool_output = False
raise RuntimeError("send failed")
await super().send_event(event)
mock_agent.get_all_tools.return_value = [mock_function_tool]
mock_model = FailingToolOutputModel()
session = RealtimeSession(mock_model, mock_agent, None)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_async_retry_output", arguments="{}"
)
await session.on_event(tool_call_event)
tool_call_tasks = list(session._tool_call_tasks)
assert len(tool_call_tasks) == 1
task_results = await asyncio.gather(*tool_call_tasks, return_exceptions=True)
await asyncio.sleep(0)
assert len(task_results) == 1
assert isinstance(task_results[0], RuntimeError)
assert session._stored_exception is None
assert tool_call_event.call_id in session._pending_tool_outputs
mock_function_tool.on_invoke_tool.assert_called_once()
assert len(mock_model.sent_tool_outputs) == 0
await session.on_event(tool_call_event)
tool_call_tasks = list(session._tool_call_tasks)
assert len(tool_call_tasks) == 1
await asyncio.gather(*tool_call_tasks)
assert session._stored_exception is None
assert tool_call_event.call_id not in session._pending_tool_outputs
mock_function_tool.on_invoke_tool.assert_called_once()
assert len(mock_model.sent_tool_outputs) == 1
@pytest.mark.asyncio
async def test_function_tool_timeout_returns_result_message(self, mock_model, mock_agent):
async def invoke_slow_tool(_ctx: ToolContext[Any], _arguments: str) -> str:
await asyncio.sleep(0.2)
return "done"
timeout_tool = FunctionTool(
name="slow_tool",
description="slow",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=invoke_slow_tool,
timeout_seconds=0.01,
)
mock_agent.get_all_tools.return_value = [timeout_tool]
session = RealtimeSession(mock_model, mock_agent, None)
tool_call_event = RealtimeModelToolCallEvent(
name="slow_tool",
call_id="call_timeout",
arguments="{}",
)
await session._handle_tool_call(tool_call_event)
assert len(mock_model.sent_tool_outputs) == 1
sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0]
assert sent_call == tool_call_event
assert start_response is True
assert "timed out" in sent_output.lower()
@pytest.mark.asyncio
async def test_function_tool_timeout_raise_exception_propagates(self, mock_model, mock_agent):
async def invoke_slow_tool(_ctx: ToolContext[Any], _arguments: str) -> str:
await asyncio.sleep(0.2)
return "done"
timeout_tool = FunctionTool(
name="slow_tool",
description="slow",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=invoke_slow_tool,
timeout_seconds=0.01,
timeout_behavior="raise_exception",
)
mock_agent.get_all_tools.return_value = [timeout_tool]
session = RealtimeSession(mock_model, mock_agent, None)
tool_call_event = RealtimeModelToolCallEvent(
name="slow_tool",
call_id="call_timeout_raise",
arguments="{}",
)
with pytest.raises(ToolTimeoutError, match="timed out"):
await session._handle_tool_call(tool_call_event)
assert len(mock_model.sent_tool_outputs) == 0
assert session._event_queue.qsize() == 1
tool_start_event = await session._event_queue.get()
assert isinstance(tool_start_event, RealtimeToolStart)
assert tool_start_event.tool == timeout_tool
assert tool_start_event.arguments == "{}"
@pytest.mark.asyncio
async def test_function_tool_timeout_uses_async_error_function_result(
self, mock_model, mock_agent
):
async def invoke_slow_tool(_ctx: ToolContext[Any], _arguments: str) -> str:
await asyncio.sleep(0.2)
return "done"
async def format_timeout_error(ctx: RunContextWrapper[Any], error: Exception) -> str:
assert isinstance(error, ToolTimeoutError)
assert isinstance(ctx, ToolContext)
assert ctx.tool_name == "slow_tool"
assert ctx.tool_call_id == "call_timeout_custom"
return f"async-timeout:{error.tool_name}:{error.timeout_seconds:g}"
timeout_tool = FunctionTool(
name="slow_tool",
description="slow",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=invoke_slow_tool,
timeout_seconds=0.01,
timeout_error_function=format_timeout_error,
)
mock_agent.get_all_tools.return_value = [timeout_tool]
session = RealtimeSession(mock_model, mock_agent, None)
tool_call_event = RealtimeModelToolCallEvent(
name="slow_tool",
call_id="call_timeout_custom",
arguments="{}",
)
await session._handle_tool_call(tool_call_event)
assert len(mock_model.sent_tool_outputs) == 1
sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0]
assert sent_call == tool_call_event
assert sent_output == "async-timeout:slow_tool:0.01"
assert start_response is True
assert session._event_queue.qsize() == 2
await session._event_queue.get()
tool_end_event = await session._event_queue.get()
assert isinstance(tool_end_event, RealtimeToolEnd)
assert tool_end_event.output == "async-timeout:slow_tool:0.01"
@pytest.mark.asyncio
async def test_function_call_event_timeout_raise_exception_enqueues_error(
self, mock_model, mock_agent
):
async def invoke_slow_tool(_ctx: ToolContext[Any], _arguments: str) -> str:
await asyncio.sleep(0.2)
return "done"
timeout_tool = FunctionTool(
name="slow_tool",
description="slow",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=invoke_slow_tool,
timeout_seconds=0.01,
timeout_behavior="raise_exception",
)
mock_agent.get_all_tools.return_value = [timeout_tool]
session = RealtimeSession(mock_model, mock_agent, None)
tool_call_event = RealtimeModelToolCallEvent(
name="slow_tool",
call_id="call_timeout_async",
arguments="{}",
)
await session.on_event(tool_call_event)
tool_call_tasks = list(session._tool_call_tasks)
assert len(tool_call_tasks) == 1
await asyncio.gather(*tool_call_tasks, return_exceptions=True)
assert isinstance(session._stored_exception, ToolTimeoutError)
assert session._stored_exception.tool_name == "slow_tool"
assert len(mock_model.sent_tool_outputs) == 0
events = []
while True:
event = await asyncio.wait_for(session._event_queue.get(), timeout=1)
events.append(event)
if isinstance(event, RealtimeError):
break
assert any(
isinstance(event, RealtimeRawModelEvent) and event.data == tool_call_event
for event in events
)
assert any(isinstance(event, RealtimeToolStart) for event in events)
error_event = next(event for event in events if isinstance(event, RealtimeError))
assert "Tool call task failed" in error_event.error["message"]
assert "timed out" in error_event.error["message"]
@pytest.mark.asyncio
async def test_function_tool_with_multiple_tools_available(self, mock_model, mock_agent):
"""Test function tool execution when multiple tools are available"""
# Create multiple mock tools
tool1 = _set_default_timeout_fields(Mock(spec=FunctionTool))
tool1.name = "tool_one"
tool1.on_invoke_tool = AsyncMock(return_value="result_one")
tool1.needs_approval = False
tool2 = _set_default_timeout_fields(Mock(spec=FunctionTool))
tool2.name = "tool_two"
tool2.on_invoke_tool = AsyncMock(return_value="result_two")
tool2.needs_approval = False
handoff = Mock(spec=Handoff)
handoff.name = "handoff_tool"
# Set up agent to return all tools
mock_agent.get_all_tools.return_value = [tool1, tool2, handoff]
session = RealtimeSession(mock_model, mock_agent, None)
# Call tool_two
tool_call_event = RealtimeModelToolCallEvent(
name="tool_two", call_id="call_456", arguments='{"test": "data"}'
)
await session._handle_tool_call(tool_call_event)
# Only tool2 should have been called
tool1.on_invoke_tool.assert_not_called()
tool2.on_invoke_tool.assert_called_once()
# Verify correct result was sent
sent_call, sent_output, _ = mock_model.sent_tool_outputs[0]
assert sent_output == "result_two"
@pytest.mark.asyncio
async def test_handoff_tool_handling(self, mock_model):
first_agent = RealtimeAgent(
name="first_agent",
instructions="first_agent_instructions",
tools=[],
handoffs=[],
)
second_agent = RealtimeAgent(
name="second_agent",
instructions="second_agent_instructions",
tools=[],
handoffs=[],
)
first_agent.handoffs = [second_agent]
session = RealtimeSession(mock_model, first_agent, None)
tool_call_event = RealtimeModelToolCallEvent(
name=Handoff.default_tool_name(second_agent), call_id="call_789", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
# Should have sent session update and tool output
assert len(mock_model.sent_events) >= 2
# Should have sent handoff event
assert session._event_queue.qsize() >= 1
# Verify agent was updated
assert session._current_agent == second_agent
@pytest.mark.asyncio
async def test_handoff_validation_failure_keeps_current_agent(self, mock_model):
first_agent = RealtimeAgent(
name="first_agent",
instructions="first_agent_instructions",
tools=[],
handoffs=[],
)
invalid_agent = _agent_with_ambiguous_realtime_tools("invalid_agent")
invalid_handoff = Handoff(
tool_name="transfer_to_invalid_agent",
tool_description="Transfer to invalid agent",
input_json_schema={},
on_invoke_handoff=AsyncMock(return_value=invalid_agent),
input_filter=None,
agent_name=invalid_agent.name,
is_enabled=True,
)
first_agent.handoffs = [invalid_handoff]
session = RealtimeSession(mock_model, first_agent, None)
with pytest.raises(UserError, match="Duplicate Realtime tool"):
await session._handle_tool_call(
RealtimeModelToolCallEvent(
name=invalid_handoff.tool_name,
call_id="call_invalid",
arguments="{}",
)
)
assert session._current_agent is first_agent
assert mock_model.sent_events == []
assert mock_model.sent_tool_outputs == []
assert "call_invalid" not in session._active_tool_call_ids
assert "call_invalid" not in session._completed_tool_call_ids
@pytest.mark.asyncio
async def test_handoff_session_update_preserves_custom_voice(self, mock_model):
custom_voice = {"id": "voice_test"}
first_agent = RealtimeAgent(
name="first_agent",
instructions="first_agent_instructions",
tools=[],
handoffs=[],
)
second_agent = RealtimeAgent(
name="second_agent",
instructions="second_agent_instructions",
tools=[],
handoffs=[],
)
first_agent.handoffs = [second_agent]
session = RealtimeSession(
mock_model,
first_agent,
None,
model_config={"initial_model_settings": {"voice": custom_voice}},
)
await session._handle_tool_call(
RealtimeModelToolCallEvent(
name=Handoff.default_tool_name(second_agent),
call_id="call_789",
arguments="{}",
)
)
session_update_event = mock_model.sent_events[0]
assert isinstance(session_update_event, RealtimeModelSendSessionUpdate)
assert session_update_event.session_settings["voice"] == custom_voice
assert mock_model.sent_events[1].start_response is True
@pytest.mark.asyncio
async def test_unknown_tool_handling(self, mock_model, mock_agent, mock_function_tool):
"""Test that unknown tools complete the model call without starting a response."""
# Set up agent to return different tool than what's called
mock_function_tool.name = "known_tool"
mock_agent.get_all_tools.return_value = [mock_function_tool]
session = RealtimeSession(mock_model, mock_agent, None)
# Call unknown tool
tool_call_event = RealtimeModelToolCallEvent(
name="unknown_tool", call_id="call_unknown", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
# Should complete the model-visible tool call with an error output
assert len(mock_model.sent_tool_outputs) == 1
sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0]
assert sent_call == tool_call_event
assert "Tool unknown_tool not found" in sent_output
assert start_response is False
# Should have emitted a RealtimeError event
assert session._event_queue.qsize() >= 1
error_event = await session._event_queue.get()
assert isinstance(error_event, RealtimeError)
assert "Tool unknown_tool not found" in error_event.error.get("message", "")
# Should not have called any tools
mock_function_tool.on_invoke_tool.assert_not_called()
@pytest.mark.asyncio
async def test_function_tool_needs_approval_emits_event(
self, mock_model, mock_agent, mock_function_tool
):
"""Tools marked as needs_approval should pause and emit an approval request."""
mock_function_tool.needs_approval = True
mock_agent.get_all_tools.return_value = [mock_function_tool]
session = RealtimeSession(mock_model, mock_agent, None)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_needs_approval", arguments='{"param": "value"}'
)
await session._handle_tool_call(tool_call_event)
assert tool_call_event.call_id in session._pending_tool_calls
assert mock_function_tool.on_invoke_tool.call_count == 0
approval_event = await session._event_queue.get()
assert isinstance(approval_event, RealtimeToolApprovalRequired)
assert approval_event.call_id == tool_call_event.call_id
assert approval_event.tool == mock_function_tool
@pytest.mark.asyncio
async def test_tool_input_guardrail_rejects_before_realtime_function_execution(
self, mock_model
):
"""Tool input guardrails should run before regular realtime function tool execution."""
executed = False
@tool_input_guardrail
def reject_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
return ToolGuardrailFunctionOutput.reject_content("blocked before execution")
async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str:
nonlocal executed
executed = True
return "ok"
guarded_tool = FunctionTool(
name="test_function",
description="guarded",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=invoke_tool,
tool_input_guardrails=[reject_guardrail],
)
agent = RealtimeAgent(name="agent", tools=[guarded_tool])
session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False})
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_guardrail_reject", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
assert executed is False
assert len(mock_model.sent_tool_outputs) == 1
_sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0]
assert sent_output == "blocked before execution"
assert start_response is True
@pytest.mark.asyncio
async def test_realtime_pending_approval_skips_tool_input_guardrails_by_default(
self, mock_model
):
guardrail_runs = 0
@tool_input_guardrail
def count_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
nonlocal guardrail_runs
guardrail_runs += 1
return ToolGuardrailFunctionOutput.allow()
async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str:
return "ok"
guarded_tool = FunctionTool(
name="test_function",
description="guarded",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=invoke_tool,
needs_approval=True,
tool_input_guardrails=[count_guardrail],
)
agent = RealtimeAgent(name="agent", tools=[guarded_tool])
session = RealtimeSession(mock_model, agent, None, run_config={"async_tool_calls": False})
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_guardrail_pending", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
assert tool_call_event.call_id in session._pending_tool_calls
assert guardrail_runs == 0
@pytest.mark.asyncio
async def test_realtime_pre_approval_tool_input_guardrail_rejects_pending_approval(
self, mock_model
):
executed = False
@tool_input_guardrail
def reject_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
return ToolGuardrailFunctionOutput.reject_content("blocked before approval")
async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str:
nonlocal executed
executed = True
return "ok"
guarded_tool = FunctionTool(
name="test_function",
description="guarded",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=invoke_tool,
needs_approval=True,
tool_input_guardrails=[reject_guardrail],
)
agent = RealtimeAgent(name="agent", tools=[guarded_tool])
session = RealtimeSession(
mock_model,
agent,
None,
run_config={
"async_tool_calls": False,
"tool_execution": {"pre_approval_tool_input_guardrails": True},
},
)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_pre_approval_reject", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
assert executed is False
assert tool_call_event.call_id not in session._pending_tool_calls
assert len(mock_model.sent_tool_outputs) == 1
_sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0]
assert sent_output == "blocked before approval"
assert start_response is True
@pytest.mark.asyncio
async def test_realtime_pre_approval_tool_input_guardrails_rerun_after_approval(
self, mock_model
):
guardrail_runs = 0
executed = 0
@tool_input_guardrail
def count_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
nonlocal guardrail_runs
guardrail_runs += 1
return ToolGuardrailFunctionOutput.allow()
async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str:
nonlocal executed
executed += 1
return "ok"
guarded_tool = FunctionTool(
name="test_function",
description="guarded",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=invoke_tool,
needs_approval=True,
tool_input_guardrails=[count_guardrail],
)
agent = RealtimeAgent(name="agent", tools=[guarded_tool])
session = RealtimeSession(
mock_model,
agent,
None,
run_config={
"async_tool_calls": False,
"tool_execution": {"pre_approval_tool_input_guardrails": True},
},
)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_pre_approval_rerun", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
assert guardrail_runs == 1
assert executed == 0
await session.approve_tool_call(tool_call_event.call_id)
assert guardrail_runs == 2
assert executed == 1
assert len(mock_model.sent_tool_outputs) == 1
_sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0]
assert sent_output == "ok"
assert start_response is True
@pytest.mark.asyncio
async def test_duplicate_pending_approval_call_id_is_ignored_and_approval_runs_once(
self, mock_model, mock_agent, mock_function_tool
):
"""A duplicate approval-gated call should not enqueue another approval or run twice."""
mock_function_tool.needs_approval = True
mock_agent.get_all_tools.return_value = [mock_function_tool]
session = RealtimeSession(
mock_model,
mock_agent,
None,
run_config={"async_tool_calls": False},
)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_duplicate_approval", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
await session._handle_tool_call(tool_call_event)
assert list(session._pending_tool_calls) == [tool_call_event.call_id]
approval_events = []
while not session._event_queue.empty():
event = await session._event_queue.get()
if isinstance(event, RealtimeToolApprovalRequired):
approval_events.append(event)
assert len(approval_events) == 1
await session.approve_tool_call(tool_call_event.call_id)
await session._handle_tool_call(tool_call_event)
mock_function_tool.on_invoke_tool.assert_called_once()
assert len(mock_model.sent_tool_outputs) == 1
@pytest.mark.asyncio
async def test_approve_pending_tool_call_runs_tool(
self, mock_model, mock_agent, mock_function_tool
):
"""Approving a pending tool call should resume execution."""
mock_function_tool.needs_approval = True
mock_agent.get_all_tools.return_value = [mock_function_tool]
session = RealtimeSession(
mock_model,
mock_agent,
None,
run_config={"async_tool_calls": False},
)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_approve", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
await session.approve_tool_call(tool_call_event.call_id)
assert mock_function_tool.on_invoke_tool.call_count == 1
assert len(mock_model.sent_tool_outputs) == 1
assert session._pending_tool_calls == {}
events = []
while not session._event_queue.empty():
events.append(await session._event_queue.get())
assert any(isinstance(ev, RealtimeToolStart) for ev in events)
assert any(isinstance(ev, RealtimeToolEnd) for ev in events)
@pytest.mark.asyncio
async def test_async_approve_pending_tool_call_reserves_call_id_before_task_runs(
self, mock_model
):
"""A duplicate event after approval should not outrun the approved async task."""
approved_calls: list[str] = []
duplicate_calls: list[str] = []
async def invoke_approved_tool(_ctx: ToolContext[Any], _arguments: str) -> str:
approved_calls.append("approved")
return "approved_result"
async def invoke_duplicate_tool(_ctx: ToolContext[Any], _arguments: str) -> str:
duplicate_calls.append("duplicate")
return "duplicate_result"
approved_tool = FunctionTool(
name="test_function",
description="approved",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=invoke_approved_tool,
needs_approval=True,
)
duplicate_tool = FunctionTool(
name="test_function",
description="duplicate",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=invoke_duplicate_tool,
needs_approval=False,
)
approved_agent = RealtimeAgent(name="approved_agent", tools=[approved_tool])
duplicate_agent = RealtimeAgent(name="duplicate_agent", tools=[duplicate_tool])
session = RealtimeSession(mock_model, approved_agent, None)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_async_approval_race", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
await session.approve_tool_call(tool_call_event.call_id)
assert tool_call_event.call_id in session._active_tool_call_ids
await session._handle_tool_call(tool_call_event, agent_snapshot=duplicate_agent)
tool_call_tasks = list(session._tool_call_tasks)
assert len(tool_call_tasks) == 1
await asyncio.gather(*tool_call_tasks)
assert approved_calls == ["approved"]
assert duplicate_calls == []
assert len(mock_model.sent_tool_outputs) == 1
_sent_call, sent_output, _start_response = mock_model.sent_tool_outputs[0]
assert sent_output == "approved_result"
@pytest.mark.asyncio
async def test_always_approve_namespaced_tool_call_does_not_approve_bare_tool(self, mock_model):
"""Always approval should stay scoped to the namespaced tool key."""
tool_calls: list[str] = []
async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str:
tool_calls.append("called")
return "account"
namespaced_tool = tool_namespace(
name="crm",
description="CRM tools",
tools=[
FunctionTool(
name="lookup_account",
description="Look up account",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=invoke_tool,
needs_approval=True,
)
],
)[0]
bare_tool = FunctionTool(
name="lookup_account",
description="Look up account",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=invoke_tool,
needs_approval=True,
)
namespaced_agent = RealtimeAgent(name="crm_agent", tools=[namespaced_tool])
bare_agent = RealtimeAgent(name="bare_agent", tools=[bare_tool])
session = RealtimeSession(
mock_model,
namespaced_agent,
None,
run_config={"async_tool_calls": False},
)
first_call = RealtimeModelToolCallEvent(
name="lookup_account", call_id="call_first", arguments="{}"
)
second_call = RealtimeModelToolCallEvent(
name="lookup_account", call_id="call_second", arguments="{}"
)
await session._handle_tool_call(first_call)
await session.approve_tool_call(first_call.call_id, always=True)
await session._handle_tool_call(second_call, agent_snapshot=bare_agent)
assert (
session._context_wrapper.get_approval_status(
"lookup_account",
second_call.call_id,
)
is None
)
assert "crm.lookup_account" in session._context_wrapper._approvals
assert "lookup_account" not in session._context_wrapper._approvals
assert sorted(session._pending_tool_calls) == [second_call.call_id]
assert len(mock_model.sent_tool_outputs) == 1
assert tool_calls == ["called"]
@pytest.mark.asyncio
async def test_reject_pending_tool_call_sends_rejection_output(
self, mock_model, mock_agent, mock_function_tool
):
"""Rejecting a pending tool call should notify the model and skip execution."""
mock_function_tool.needs_approval = True
mock_agent.get_all_tools.return_value = [mock_function_tool]
session = RealtimeSession(mock_model, mock_agent, None)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_reject", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
await session.reject_tool_call(tool_call_event.call_id)
await session._handle_tool_call(tool_call_event)
assert mock_function_tool.on_invoke_tool.call_count == 0
assert len(mock_model.sent_tool_outputs) == 1
_sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0]
assert sent_output == REJECTION_MESSAGE
assert start_response is True
assert session._pending_tool_calls == {}
events = []
while not session._event_queue.empty():
events.append(await session._event_queue.get())
assert any(
isinstance(ev, RealtimeToolEnd) and ev.output == REJECTION_MESSAGE for ev in events
)
@pytest.mark.asyncio
async def test_reject_pending_tool_call_reserves_call_id_before_sending(
self, mock_agent, mock_function_tool
):
"""A duplicate event during rejection output sending should not emit a second output."""
class BlockingToolOutputModel(MockRealtimeModel):
def __init__(self):
super().__init__()
self.started = asyncio.Event()
self.release = asyncio.Event()
self.block_next_tool_output = True
async def send_event(self, event):
if isinstance(event, RealtimeModelSendToolOutput) and self.block_next_tool_output:
self.block_next_tool_output = False
self.started.set()
await self.release.wait()
await super().send_event(event)
mock_function_tool.needs_approval = True
mock_agent.get_all_tools.return_value = [mock_function_tool]
mock_model = BlockingToolOutputModel()
session = RealtimeSession(mock_model, mock_agent, None)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_reject_race", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
reject_task = asyncio.create_task(session.reject_tool_call(tool_call_event.call_id))
await asyncio.wait_for(mock_model.started.wait(), timeout=1)
await session._handle_tool_call(tool_call_event)
mock_model.release.set()
await reject_task
assert len(mock_model.sent_tool_outputs) == 1
@pytest.mark.asyncio
async def test_reject_pending_tool_call_uses_run_level_formatter(
self, mock_model, mock_agent, mock_function_tool
):
"""Rejecting a pending tool call should use the run-level formatter output."""
mock_function_tool.needs_approval = True
mock_agent.get_all_tools.return_value = [mock_function_tool]
session = RealtimeSession(
mock_model,
mock_agent,
None,
run_config={
"tool_error_formatter": (
lambda args: f"run-level {args.tool_name} denied ({args.call_id})"
)
},
)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_reject_custom", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
await session.reject_tool_call(tool_call_event.call_id)
_sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0]
assert sent_output == "run-level test_function denied (call_reject_custom)"
assert start_response is True
events = []
while not session._event_queue.empty():
events.append(await session._event_queue.get())
assert any(
isinstance(ev, RealtimeToolEnd)
and ev.output == "run-level test_function denied (call_reject_custom)"
for ev in events
)
@pytest.mark.asyncio
async def test_reject_pending_tool_call_prefers_explicit_message(
self, mock_model, mock_agent, mock_function_tool
):
"""Rejecting a pending tool call should prefer the explicit rejection message."""
mock_function_tool.needs_approval = True
mock_agent.get_all_tools.return_value = [mock_function_tool]
session = RealtimeSession(
mock_model,
mock_agent,
None,
run_config={
"tool_error_formatter": (
lambda args: f"run-level {args.tool_name} denied ({args.call_id})"
)
},
)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_reject_explicit", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
await session.reject_tool_call(
tool_call_event.call_id,
rejection_message="explicit rejection message",
)
_sent_call, sent_output, start_response = mock_model.sent_tool_outputs[0]
assert sent_output == "explicit rejection message"
assert start_response is True
events = []
while not session._event_queue.empty():
events.append(await session._event_queue.get())
assert any(
isinstance(ev, RealtimeToolEnd) and ev.output == "explicit rejection message"
for ev in events
)
@pytest.mark.asyncio
async def test_always_reject_namespaced_tool_call_reuses_explicit_message(self, mock_model):
"""Always rejection should reuse explicit messages through the qualified tool key."""
tool_calls: list[str] = []
async def invoke_tool(_ctx: ToolContext[Any], _arguments: str) -> str:
tool_calls.append("called")
return "account"
namespaced_tool = tool_namespace(
name="crm",
description="CRM tools",
tools=[
FunctionTool(
name="lookup_account",
description="Look up account",
params_json_schema={"type": "object", "properties": {}},
on_invoke_tool=invoke_tool,
needs_approval=True,
)
],
)[0]
agent = RealtimeAgent(name="crm_agent", tools=[namespaced_tool])
session = RealtimeSession(mock_model, agent, None)
first_call = RealtimeModelToolCallEvent(
name="lookup_account", call_id="call_reject_first", arguments="{}"
)
second_call = RealtimeModelToolCallEvent(
name="lookup_account", call_id="call_reject_second", arguments="{}"
)
await session._handle_tool_call(first_call)
await session.reject_tool_call(
first_call.call_id,
always=True,
rejection_message="explicit crm rejection",
)
await session._handle_tool_call(second_call)
assert "crm.lookup_account" in session._context_wrapper._approvals
assert "lookup_account" not in session._context_wrapper._approvals
assert session._pending_tool_calls == {}
assert [output for _call, output, _start in mock_model.sent_tool_outputs] == [
"explicit crm rejection",
"explicit crm rejection",
]
assert tool_calls == []
@pytest.mark.asyncio
async def test_function_tool_exception_handling(
self, mock_model, mock_agent, mock_function_tool
):
"""Test that exceptions in function tools are handled (currently they propagate)"""
# Set up tool to raise exception
mock_function_tool.on_invoke_tool.side_effect = ValueError("Tool error")
mock_agent.get_all_tools.return_value = [mock_function_tool]
session = RealtimeSession(mock_model, mock_agent, None)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_error", arguments="{}"
)
# Currently exceptions propagate (no error handling implemented)
with pytest.raises(ValueError, match="Tool error"):
await session._handle_tool_call(tool_call_event)
# Tool start event should have been queued before the error
assert session._event_queue.qsize() == 1
tool_start_event = await session._event_queue.get()
assert isinstance(tool_start_event, RealtimeToolStart)
assert tool_start_event.arguments == "{}"
# But no tool output should have been sent and no end event queued
assert len(mock_model.sent_tool_outputs) == 0
@pytest.mark.asyncio
async def test_tool_call_with_complex_arguments(
self, mock_model, mock_agent, mock_function_tool
):
"""Test tool call with complex JSON arguments"""
mock_agent.get_all_tools.return_value = [mock_function_tool]
session = RealtimeSession(mock_model, mock_agent, None)
# Complex arguments
complex_args = '{"nested": {"data": [1, 2, 3]}, "bool": true, "null": null}'
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_complex", arguments=complex_args
)
await session._handle_tool_call(tool_call_event)
# Verify arguments were passed correctly to tool
call_args = mock_function_tool.on_invoke_tool.call_args
assert call_args[0][1] == complex_args
# Verify tool_start event includes arguments
tool_start_event = await session._event_queue.get()
assert isinstance(tool_start_event, RealtimeToolStart)
assert tool_start_event.arguments == complex_args
# Verify tool_end event includes arguments
tool_end_event = await session._event_queue.get()
assert isinstance(tool_end_event, RealtimeToolEnd)
assert tool_end_event.arguments == complex_args
@pytest.mark.asyncio
async def test_tool_call_with_custom_call_id(self, mock_model, mock_agent, mock_function_tool):
"""Test that tool context receives correct call_id"""
mock_agent.get_all_tools.return_value = [mock_function_tool]
session = RealtimeSession(mock_model, mock_agent, None)
custom_call_id = "custom_call_id_12345"
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id=custom_call_id, arguments="{}"
)
await session._handle_tool_call(tool_call_event)
# Verify tool context was created with correct call_id
call_args = mock_function_tool.on_invoke_tool.call_args
tool_context = call_args[0][0]
# The call_id is used internally in ToolContext.from_agent_context
# We can't directly access it, but we can verify the context was created
assert isinstance(tool_context, ToolContext)
@pytest.mark.asyncio
async def test_tool_result_conversion_to_string(self, mock_model, mock_agent):
"""Test that structured tool results are serialized to JSON for model output."""
# Create tool that returns non-string result
tool = _set_default_timeout_fields(Mock(spec=FunctionTool))
tool.name = "test_function"
tool.on_invoke_tool = AsyncMock(return_value={"result": "data", "count": 42})
tool.needs_approval = False
mock_agent.get_all_tools.return_value = [tool]
session = RealtimeSession(mock_model, mock_agent, None)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_conversion", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
# Verify result was serialized to JSON
sent_call, sent_output, _ = mock_model.sent_tool_outputs[0]
assert isinstance(sent_output, str)
assert sent_output == json.dumps({"result": "data", "count": 42})
@pytest.mark.asyncio
async def test_tool_result_conversion_serializes_pydantic_models(self, mock_model, mock_agent):
"""Test that pydantic tool results are serialized to JSON for model output."""
class ToolResult(BaseModel):
name: str
score: int
tool = _set_default_timeout_fields(Mock(spec=FunctionTool))
tool.name = "test_function"
tool.on_invoke_tool = AsyncMock(return_value=ToolResult(name="demo", score=7))
tool.needs_approval = False
mock_agent.get_all_tools.return_value = [tool]
session = RealtimeSession(mock_model, mock_agent, None)
tool_call_event = RealtimeModelToolCallEvent(
name="test_function", call_id="call_pydantic_conversion", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
_sent_call, sent_output, _ = mock_model.sent_tool_outputs[0]
assert sent_output == json.dumps({"name": "demo", "score": 7})
def test_serialize_tool_output_ignores_non_pydantic_model_dump_objects(self) -> None:
class FakeModelDump:
def model_dump(self, *_args: Any, **_kwargs: Any) -> dict[str, Any]:
raise AssertionError("non-pydantic objects should not use model_dump")
def __str__(self) -> str:
return "fake-model-dump-object"
assert _serialize_tool_output(FakeModelDump()) == "fake-model-dump-object"
def test_serialize_tool_output_falls_back_when_pydantic_json_dump_fails(self) -> None:
class FallbackModel(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
payload: object
def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
if kwargs.get("mode") == "json":
raise ValueError("json mode failed")
return {"payload": "ok"}
assert _serialize_tool_output(FallbackModel(payload=object())) == json.dumps(
{"payload": "ok"}
)
def test_serialize_tool_output_returns_string_when_pydantic_dump_fails(self) -> None:
class BrokenModel(BaseModel):
value: int
def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
raise ValueError("dump failed")
def __str__(self) -> str:
return "broken-model"
assert _serialize_tool_output(BrokenModel(value=1)) == "broken-model"
def test_serialize_tool_output_returns_string_when_dataclass_asdict_fails(self) -> None:
@dataclasses.dataclass
class BrokenDataclass:
lock: Any
def __str__(self) -> str:
return "broken-dataclass"
assert _serialize_tool_output(BrokenDataclass(lock=threading.Lock())) == "broken-dataclass"
@dataclasses.dataclass
class ToolResult:
label: str
values: list[int]
@pytest.mark.parametrize(
("value", "expected"),
[
pytest.param(None, "null", id="none"),
pytest.param(
["hello", 1, True, None],
json.dumps(["hello", 1, True, None]),
id="list",
),
pytest.param(
ToolResult(label="demo", values=[1, 2]),
json.dumps({"label": "demo", "values": [1, 2]}),
id="dataclass",
),
pytest.param(b"abc", "b'abc'", id="bytes"),
],
)
def test_serialize_tool_output_edge_cases(self, value: Any, expected: str) -> None:
assert _serialize_tool_output(value) == expected
@pytest.mark.asyncio
async def test_mixed_tool_types_filtering(self, mock_model, mock_agent):
"""Test that function tools and handoffs are properly separated"""
# Create mixed tools
func_tool1 = _set_default_timeout_fields(Mock(spec=FunctionTool))
func_tool1.name = "func1"
func_tool1.on_invoke_tool = AsyncMock(return_value="result1")
func_tool1.needs_approval = False
handoff1 = Mock(spec=Handoff)
handoff1.name = "handoff1"
func_tool2 = _set_default_timeout_fields(Mock(spec=FunctionTool))
func_tool2.name = "func2"
func_tool2.on_invoke_tool = AsyncMock(return_value="result2")
func_tool2.needs_approval = False
handoff2 = Mock(spec=Handoff)
handoff2.name = "handoff2"
# Add some other object that's neither (should be ignored)
other_tool = Mock()
other_tool.name = "other"
all_tools = [func_tool1, handoff1, func_tool2, handoff2, other_tool]
mock_agent.get_all_tools.return_value = all_tools
session = RealtimeSession(mock_model, mock_agent, None)
# Call a function tool
tool_call_event = RealtimeModelToolCallEvent(
name="func2", call_id="call_filtering", arguments="{}"
)
await session._handle_tool_call(tool_call_event)
# Only func2 should have been called
func_tool1.on_invoke_tool.assert_not_called()
func_tool2.on_invoke_tool.assert_called_once()
# Verify result
sent_call, sent_output, _ = mock_model.sent_tool_outputs[0]
assert sent_output == "result2"
class TestGuardrailFunctionality:
"""Test suite for output guardrail functionality in RealtimeSession"""
async def _wait_for_guardrail_tasks(self, session):
"""Wait for all pending guardrail tasks to complete."""
import asyncio
if session._guardrail_tasks:
await asyncio.gather(*session._guardrail_tasks, return_exceptions=True)
@pytest.fixture
def triggered_guardrail(self):
"""Creates a guardrail that always triggers"""
def guardrail_func(context, agent, output):
return GuardrailFunctionOutput(
output_info={"reason": "test trigger"}, tripwire_triggered=True
)
return OutputGuardrail(guardrail_function=guardrail_func, name="triggered_guardrail")
@pytest.fixture
def safe_guardrail(self):
"""Creates a guardrail that never triggers"""
def guardrail_func(context, agent, output):
return GuardrailFunctionOutput(
output_info={"reason": "safe content"}, tripwire_triggered=False
)
return OutputGuardrail(guardrail_function=guardrail_func, name="safe_guardrail")
@pytest.mark.asyncio
async def test_transcript_delta_triggers_guardrail_at_threshold(
self, mock_model, mock_agent, triggered_guardrail
):
"""Test that guardrails run when transcript delta reaches debounce threshold"""
run_config: RealtimeRunConfig = {
"output_guardrails": [triggered_guardrail],
"guardrails_settings": {"debounce_text_length": 10},
}
session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config)
# Send transcript delta that exceeds threshold (10 chars)
transcript_event = RealtimeModelTranscriptDeltaEvent(
item_id="item_1", delta="this is more than ten characters", response_id="resp_1"
)
await session.on_event(transcript_event)
# Wait for async guardrail tasks to complete
await self._wait_for_guardrail_tasks(session)
# Should have triggered guardrail and interrupted
assert mock_model.interrupts_called == 1
interrupt_event = next(
event
for event in mock_model.sent_events
if isinstance(event, RealtimeModelSendInterrupt)
)
assert interrupt_event.force_response_cancel is True
assert len(mock_model.sent_messages) == 1
assert mock_model.sent_messages[0] == "guardrail triggered: triggered_guardrail"
# Should have emitted guardrail_tripped event
events = []
while not session._event_queue.empty():
events.append(await session._event_queue.get())
guardrail_events = [e for e in events if isinstance(e, RealtimeGuardrailTripped)]
assert len(guardrail_events) == 1
assert guardrail_events[0].message == "this is more than ten characters"
@pytest.mark.asyncio
async def test_agent_and_run_config_guardrails_not_run_twice(self, mock_model):
"""Guardrails shared by agent and run config should execute once."""
call_count = 0
def guardrail_func(context, agent, output):
nonlocal call_count
call_count += 1
return GuardrailFunctionOutput(output_info={}, tripwire_triggered=False)
shared_guardrail = OutputGuardrail(
guardrail_function=guardrail_func, name="shared_guardrail"
)
agent = RealtimeAgent(name="agent", output_guardrails=[shared_guardrail])
run_config: RealtimeRunConfig = {
"output_guardrails": [shared_guardrail],
"guardrails_settings": {"debounce_text_length": 5},
}
session = RealtimeSession(mock_model, agent, None, run_config=run_config)
await session.on_event(
RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="hello", response_id="resp_1")
)
await self._wait_for_guardrail_tasks(session)
assert call_count == 1
@pytest.mark.asyncio
async def test_transcript_delta_multiple_thresholds_same_item(
self, mock_model, mock_agent, triggered_guardrail
):
"""Test guardrails run at 1x, 2x, 3x thresholds for same item_id"""
run_config: RealtimeRunConfig = {
"output_guardrails": [triggered_guardrail],
"guardrails_settings": {"debounce_text_length": 5},
}
session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config)
# First delta - reaches 1x threshold (5 chars)
await session.on_event(
RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="12345", response_id="resp_1")
)
# Second delta - reaches 2x threshold (10 chars total)
await session.on_event(
RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="67890", response_id="resp_1")
)
# Wait for async guardrail tasks to complete
await self._wait_for_guardrail_tasks(session)
# Should only trigger once due to interrupted_by_guardrail flag
assert mock_model.interrupts_called == 1
assert len(mock_model.sent_messages) == 1
@pytest.mark.asyncio
async def test_transcript_delta_different_items_tracked_separately(
self, mock_model, mock_agent, safe_guardrail
):
"""Test that different item_ids are tracked separately for debouncing"""
run_config: RealtimeRunConfig = {
"output_guardrails": [safe_guardrail],
"guardrails_settings": {"debounce_text_length": 10},
}
session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config)
# Add text to item_1 (8 chars - below threshold)
await session.on_event(
RealtimeModelTranscriptDeltaEvent(
item_id="item_1", delta="12345678", response_id="resp_1"
)
)
# Add text to item_2 (8 chars - below threshold)
await session.on_event(
RealtimeModelTranscriptDeltaEvent(
item_id="item_2", delta="abcdefgh", response_id="resp_2"
)
)
# Neither should trigger guardrails yet
assert mock_model.interrupts_called == 0
# Add more text to item_1 (total 12 chars - above threshold)
await session.on_event(
RealtimeModelTranscriptDeltaEvent(item_id="item_1", delta="90ab", response_id="resp_1")
)
# item_1 should have triggered guardrail run (but not interrupted since safe)
assert session._item_guardrail_run_counts["item_1"] == 1
assert (
"item_2" not in session._item_guardrail_run_counts
or session._item_guardrail_run_counts["item_2"] == 0
)
@pytest.mark.asyncio
async def test_turn_ended_clears_guardrail_state(
self, mock_model, mock_agent, triggered_guardrail
):
"""Test that turn_ended event clears guardrail state for next turn"""
run_config: RealtimeRunConfig = {
"output_guardrails": [triggered_guardrail],
"guardrails_settings": {"debounce_text_length": 5},
}
session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config)
# Trigger guardrail
await session.on_event(
RealtimeModelTranscriptDeltaEvent(
item_id="item_1", delta="trigger", response_id="resp_1"
)
)
# Wait for async guardrail tasks to complete
await self._wait_for_guardrail_tasks(session)
assert len(session._item_transcripts) == 1
# End turn
await session.on_event(RealtimeModelTurnEndedEvent())
# State should be cleared
assert len(session._item_transcripts) == 0
assert len(session._item_guardrail_run_counts) == 0
@pytest.mark.asyncio
async def test_multiple_guardrails_all_triggered(self, mock_model, mock_agent):
"""Test that all triggered guardrails are included in the event"""
def create_triggered_guardrail(name):
def guardrail_func(context, agent, output):
return GuardrailFunctionOutput(output_info={"name": name}, tripwire_triggered=True)
return OutputGuardrail(guardrail_function=guardrail_func, name=name)
guardrail1 = create_triggered_guardrail("guardrail_1")
guardrail2 = create_triggered_guardrail("guardrail_2")
run_config: RealtimeRunConfig = {
"output_guardrails": [guardrail1, guardrail2],
"guardrails_settings": {"debounce_text_length": 5},
}
session = RealtimeSession(mock_model, mock_agent, None, run_config=run_config)
await session.on_event(
RealtimeModelTranscriptDeltaEvent(
item_id="item_1", delta="trigger", response_id="resp_1"
)
)
# Wait for async guardrail tasks to complete
await self._wait_for_guardrail_tasks(session)
# Should have interrupted and sent message with both guardrail names
assert mock_model.interrupts_called == 1
assert len(mock_model.sent_messages) == 1
message = mock_model.sent_messages[0]
assert "guardrail_1" in message and "guardrail_2" in message
# Should have emitted event with both guardrail results
events = []
while not session._event_queue.empty():
events.append(await session._event_queue.get())
guardrail_events = [e for e in events if isinstance(e, RealtimeGuardrailTripped)]
assert len(guardrail_events) == 1
assert len(guardrail_events[0].guardrail_results) == 2
@pytest.mark.asyncio
async def test_agent_output_guardrails_triggered(self, mock_model, triggered_guardrail):
"""Test that guardrails defined on the agent are executed."""
agent = RealtimeAgent(name="agent", output_guardrails=[triggered_guardrail])
run_config: RealtimeRunConfig = {
"guardrails_settings": {"debounce_text_length": 10},
}
session = RealtimeSession(mock_model, agent, None, run_config=run_config)
transcript_event = RealtimeModelTranscriptDeltaEvent(
item_id="item_1", delta="this is more than ten characters", response_id="resp_1"
)
await session.on_event(transcript_event)
await self._wait_for_guardrail_tasks(session)
assert mock_model.interrupts_called == 1
assert len(mock_model.sent_messages) == 1
assert "triggered_guardrail" in mock_model.sent_messages[0]
events = []
while not session._event_queue.empty():
events.append(await session._event_queue.get())
guardrail_events = [e for e in events if isinstance(e, RealtimeGuardrailTripped)]
assert len(guardrail_events) == 1
assert guardrail_events[0].message == "this is more than ten characters"
@pytest.mark.asyncio
async def test_concurrent_guardrail_tasks_interrupt_once_per_response(self, mock_model):
"""Even if multiple guardrail tasks trigger concurrently for the same response_id,
only the first should interrupt and send a message."""
import asyncio
# Barrier to release both guardrail tasks at the same time
start_event = asyncio.Event()
async def async_trigger_guardrail(context, agent, output):
await start_event.wait()
return GuardrailFunctionOutput(
output_info={"reason": "concurrent"}, tripwire_triggered=True
)
concurrent_guardrail = OutputGuardrail(
guardrail_function=async_trigger_guardrail, name="concurrent_trigger"
)
run_config: RealtimeRunConfig = {
"output_guardrails": [concurrent_guardrail],
"guardrails_settings": {"debounce_text_length": 5},
}
# Use a minimal agent (guardrails from run_config)
agent = RealtimeAgent(name="agent")
session = RealtimeSession(mock_model, agent, None, run_config=run_config)
# Two deltas for same item and response to enqueue two guardrail tasks
await session.on_event(
RealtimeModelTranscriptDeltaEvent(
item_id="item_1", delta="12345", response_id="resp_same"
)
)
await session.on_event(
RealtimeModelTranscriptDeltaEvent(
item_id="item_1", delta="67890", response_id="resp_same"
)
)
# Wait until both tasks are enqueued
for _ in range(50):
if len(session._guardrail_tasks) >= 2:
break
await asyncio.sleep(0.01)
# Release both tasks concurrently
start_event.set()
# Wait for completion
if session._guardrail_tasks:
await asyncio.gather(*session._guardrail_tasks, return_exceptions=True)
# Only one interrupt and one message should be sent
assert mock_model.interrupts_called == 1
assert len(mock_model.sent_messages) == 1
class TestModelSettingsIntegration:
"""Test suite for model settings integration in RealtimeSession."""
@pytest.mark.asyncio
async def test_session_gets_model_settings_from_agent_during_connection(self):
"""Test that session properly gets model settings from agent during __aenter__."""
# Create mock model that records the config passed to connect()
mock_model = Mock(spec=RealtimeModel)
mock_model.connect = AsyncMock()
mock_model.add_listener = Mock()
# Create agent with specific settings
agent = Mock(spec=RealtimeAgent)
agent.get_system_prompt = AsyncMock(return_value="Test agent instructions")
agent.get_all_tools = AsyncMock(return_value=[{"type": "function", "name": "test_tool"}])
agent.handoffs = []
session = RealtimeSession(mock_model, agent, None)
# Connect the session
await session.__aenter__()
# Verify model.connect was called with settings from agent
mock_model.connect.assert_called_once()
connect_config = mock_model.connect.call_args[0][0]
initial_settings = connect_config["initial_model_settings"]
assert initial_settings["instructions"] == "Test agent instructions"
assert initial_settings["tools"] == [{"type": "function", "name": "test_tool"}]
assert initial_settings["handoffs"] == []
await session.__aexit__(None, None, None)
@pytest.mark.asyncio
async def test_model_config_overrides_model_settings_not_agent(self):
"""Test that initial_model_settings from model_config override model settings
but not agent-derived settings."""
mock_model = Mock(spec=RealtimeModel)
mock_model.connect = AsyncMock()
mock_model.add_listener = Mock()
agent = Mock(spec=RealtimeAgent)
agent.get_system_prompt = AsyncMock(return_value="Agent instructions")
agent.get_all_tools = AsyncMock(return_value=[{"type": "function", "name": "agent_tool"}])
agent.handoffs = []
# Provide model config with settings
model_config: RealtimeModelConfig = {
"initial_model_settings": {
"voice": "nova",
"model_name": "gpt-4o-realtime",
}
}
session = RealtimeSession(mock_model, agent, None, model_config=model_config)
await session.__aenter__()
# Verify model config settings were applied
connect_config = mock_model.connect.call_args[0][0]
initial_settings = connect_config["initial_model_settings"]
# Agent-derived settings should come from agent
assert initial_settings["instructions"] == "Agent instructions"
assert initial_settings["tools"] == [{"type": "function", "name": "agent_tool"}]
# Model config settings should be applied
assert initial_settings["voice"] == "nova"
assert initial_settings["model_name"] == "gpt-4o-realtime"
await session.__aexit__(None, None, None)
@pytest.mark.asyncio
async def test_handoffs_are_included_in_model_settings(self):
"""Test that handoffs from agent are properly processed into model settings."""
mock_model = Mock(spec=RealtimeModel)
mock_model.connect = AsyncMock()
mock_model.add_listener = Mock()
# Create agent with handoffs
agent = Mock(spec=RealtimeAgent)
agent.get_system_prompt = AsyncMock(return_value="Agent with handoffs")
agent.get_all_tools = AsyncMock(return_value=[])
# Create a mock handoff
handoff_agent = Mock(spec=RealtimeAgent)
handoff_agent.name = "handoff_target"
mock_handoff = Mock(spec=Handoff)
mock_handoff.tool_name = "transfer_to_specialist"
mock_handoff.is_enabled = True
agent.handoffs = [handoff_agent] # Agent handoff
# Mock the _get_handoffs method since it's complex
with pytest.MonkeyPatch().context() as m:
async def mock_get_handoffs(cls, agent, context_wrapper):
return [mock_handoff]
m.setattr("agents.realtime.session.RealtimeSession._get_handoffs", mock_get_handoffs)
session = RealtimeSession(mock_model, agent, None)
await session.__aenter__()
# Verify handoffs were included
connect_config = mock_model.connect.call_args[0][0]
initial_settings = connect_config["initial_model_settings"]
assert initial_settings["handoffs"] == [mock_handoff]
await session.__aexit__(None, None, None)
# Test: Model settings precedence
class TestModelSettingsPrecedence:
"""Test suite for model settings precedence in RealtimeSession"""
@pytest.mark.asyncio
async def test_model_settings_precedence_order(self):
"""Test that model settings follow correct precedence:
run_config -> agent -> model_config"""
# Create a test agent
agent = RealtimeAgent(name="test_agent", instructions="agent_instructions")
agent.handoffs = []
# Mock the agent methods to return known values
agent.get_system_prompt = AsyncMock(return_value="agent_system_prompt") # type: ignore
agent.get_all_tools = AsyncMock(return_value=[]) # type: ignore
# Mock model
mock_model = Mock(spec=RealtimeModel)
mock_model.connect = AsyncMock()
# Define settings at each level with different values
run_config_settings: RealtimeSessionModelSettings = {
"voice": "run_config_voice",
"modalities": ["text"],
}
model_config_initial_settings: RealtimeSessionModelSettings = {
"voice": "model_config_voice", # Should override run_config
"tool_choice": "auto", # New setting not in run_config
}
run_config: RealtimeRunConfig = {"model_settings": run_config_settings}
model_config: RealtimeModelConfig = {
"initial_model_settings": model_config_initial_settings
}
# Create session with both configs
session = RealtimeSession(
model=mock_model,
agent=agent,
context=None,
model_config=model_config,
run_config=run_config,
)
# Mock the _get_handoffs method
async def mock_get_handoffs(cls, agent, context_wrapper):
return []
with pytest.MonkeyPatch().context() as m:
m.setattr("agents.realtime.session.RealtimeSession._get_handoffs", mock_get_handoffs)
# Test the method directly
model_settings = await session._get_updated_model_settings_from_agent(
starting_settings=model_config_initial_settings, agent=agent
)
# Verify precedence order:
# 1. Agent settings should always be set (highest precedence for these)
assert model_settings["instructions"] == "agent_system_prompt"
assert model_settings["tools"] == []
assert model_settings["handoffs"] == []
# 2. model_config settings should override run_config settings
assert model_settings["voice"] == "model_config_voice" # model_config wins
# 3. run_config settings should be preserved when not overridden
assert model_settings["modalities"] == ["text"] # only in run_config
# 4. model_config-only settings should be present
assert model_settings["tool_choice"] == "auto" # only in model_config
@pytest.mark.asyncio
async def test_model_settings_with_run_config_only(self):
"""Test that run_config model_settings are used when no model_config provided"""
agent = RealtimeAgent(name="test_agent", instructions="test")
agent.handoffs = []
agent.get_system_prompt = AsyncMock(return_value="test_prompt") # type: ignore
agent.get_all_tools = AsyncMock(return_value=[]) # type: ignore
mock_model = Mock(spec=RealtimeModel)
run_config_settings: RealtimeSessionModelSettings = {
"voice": "run_config_only_voice",
"modalities": ["text", "audio"],
"input_audio_format": "pcm16",
}
session = RealtimeSession(
model=mock_model,
agent=agent,
context=None,
model_config=None, # No model config
run_config={"model_settings": run_config_settings},
)
async def mock_get_handoffs(cls, agent, context_wrapper):
return []
with pytest.MonkeyPatch().context() as m:
m.setattr("agents.realtime.session.RealtimeSession._get_handoffs", mock_get_handoffs)
model_settings = await session._get_updated_model_settings_from_agent(
starting_settings=None, # No initial settings
agent=agent,
)
# Agent settings should be present
assert model_settings["instructions"] == "test_prompt"
assert model_settings["tools"] == []
assert model_settings["handoffs"] == []
# All run_config settings should be preserved (no overrides)
assert model_settings["voice"] == "run_config_only_voice"
assert model_settings["modalities"] == ["text", "audio"]
assert model_settings["input_audio_format"] == "pcm16"
@pytest.mark.asyncio
async def test_model_settings_with_model_config_only(self):
"""Test that model_config settings are used when no run_config model_settings"""
agent = RealtimeAgent(name="test_agent", instructions="test")
agent.handoffs = []
agent.get_system_prompt = AsyncMock(return_value="test_prompt") # type: ignore
agent.get_all_tools = AsyncMock(return_value=[]) # type: ignore
mock_model = Mock(spec=RealtimeModel)
model_config_settings: RealtimeSessionModelSettings = {
"voice": "model_config_only_voice",
"tool_choice": "required",
"output_audio_format": "g711_ulaw",
}
session = RealtimeSession(
model=mock_model,
agent=agent,
context=None,
model_config={"initial_model_settings": model_config_settings},
run_config={}, # No model_settings in run_config
)
async def mock_get_handoffs(cls, agent, context_wrapper):
return []
with pytest.MonkeyPatch().context() as m:
m.setattr("agents.realtime.session.RealtimeSession._get_handoffs", mock_get_handoffs)
model_settings = await session._get_updated_model_settings_from_agent(
starting_settings=model_config_settings, agent=agent
)
# Agent settings should be present
assert model_settings["instructions"] == "test_prompt"
assert model_settings["tools"] == []
assert model_settings["handoffs"] == []
# All model_config settings should be preserved
assert model_settings["voice"] == "model_config_only_voice"
assert model_settings["tool_choice"] == "required"
assert model_settings["output_audio_format"] == "g711_ulaw"
@pytest.mark.asyncio
async def test_model_settings_preserve_initial_settings_on_updates(self):
"""Initial model settings should persist when we recompute settings for updates."""
agent = RealtimeAgent(name="test_agent", instructions="test")
agent.handoffs = []
agent.get_system_prompt = AsyncMock(return_value="test_prompt") # type: ignore
agent.get_all_tools = AsyncMock(return_value=[]) # type: ignore
mock_model = Mock(spec=RealtimeModel)
initial_settings: RealtimeSessionModelSettings = {
"voice": "initial_voice",
"output_audio_format": "pcm16",
}
session = RealtimeSession(
model=mock_model,
agent=agent,
context=None,
model_config={"initial_model_settings": initial_settings},
run_config={},
)
async def mock_get_handoffs(cls, agent, context_wrapper):
return []
with pytest.MonkeyPatch().context() as m:
m.setattr(
"agents.realtime.session.RealtimeSession._get_handoffs",
mock_get_handoffs,
)
model_settings = await session._get_updated_model_settings_from_agent(
starting_settings=None,
agent=agent,
)
assert model_settings["voice"] == "initial_voice"
assert model_settings["output_audio_format"] == "pcm16"
class TestUpdateAgentFunctionality:
"""Tests for update agent functionality in RealtimeSession"""
@pytest.mark.asyncio
async def test_update_agent_creates_handoff_and_session_update_event(self, mock_model):
first_agent = RealtimeAgent(name="first", instructions="first", tools=[], handoffs=[])
second_agent = RealtimeAgent(name="second", instructions="second", tools=[], handoffs=[])
session = RealtimeSession(mock_model, first_agent, None)
await session.update_agent(second_agent)
# Should have sent session update
session_update_event = mock_model.sent_events[0]
assert isinstance(session_update_event, RealtimeModelSendSessionUpdate)
assert session_update_event.session_settings["instructions"] == "second"
# Check that the current agent and session settings are updated
assert session._current_agent == second_agent
@pytest.mark.asyncio
async def test_update_agent_validation_failure_keeps_current_agent(self, mock_model):
first_agent = RealtimeAgent(name="first", instructions="first", tools=[], handoffs=[])
invalid_agent = _agent_with_ambiguous_realtime_tools()
session = RealtimeSession(mock_model, first_agent, None)
with pytest.raises(UserError, match="Duplicate Realtime tool"):
await session.update_agent(invalid_agent)
assert session._current_agent is first_agent
assert mock_model.sent_events == []
class TestTranscriptPreservation:
"""Tests ensuring assistant transcripts are preserved across updates."""
@pytest.mark.asyncio
async def test_assistant_transcript_preserved_on_item_update(self, mock_model, mock_agent):
session = RealtimeSession(mock_model, mock_agent, None)
# Initial assistant message with audio transcript present (e.g., from first turn)
initial_item = AssistantMessageItem(
item_id="assist_1",
role="assistant",
content=[AssistantAudio(audio=None, transcript="Hello there")],
)
session._history = [initial_item]
# Later, the platform retrieves/updates the same item but without transcript populated
updated_without_transcript = AssistantMessageItem(
item_id="assist_1",
role="assistant",
content=[AssistantAudio(audio=None, transcript=None)],
)
await session.on_event(RealtimeModelItemUpdatedEvent(item=updated_without_transcript))
# Transcript should be preserved from existing history
assert len(session._history) == 1
preserved_item = cast(AssistantMessageItem, session._history[0])
assert isinstance(preserved_item.content[0], AssistantAudio)
assert preserved_item.content[0].transcript == "Hello there"
@pytest.mark.asyncio
async def test_assistant_transcript_can_fallback_to_deltas(self, mock_model, mock_agent):
session = RealtimeSession(mock_model, mock_agent, None)
# Simulate transcript deltas accumulated for an assistant item during generation
await session.on_event(
RealtimeModelTranscriptDeltaEvent(
item_id="assist_2", delta="partial transcript", response_id="resp_2"
)
)
# Add initial assistant message without transcript
initial_item = AssistantMessageItem(
item_id="assist_2",
role="assistant",
content=[AssistantAudio(audio=None, transcript=None)],
)
await session.on_event(RealtimeModelItemUpdatedEvent(item=initial_item))
# Later update still lacks transcript; merge should fallback to accumulated deltas
update_again = AssistantMessageItem(
item_id="assist_2",
role="assistant",
content=[AssistantAudio(audio=None, transcript=None)],
)
await session.on_event(RealtimeModelItemUpdatedEvent(item=update_again))
preserved_item = cast(AssistantMessageItem, session._history[0])
assert isinstance(preserved_item.content[0], AssistantAudio)
assert preserved_item.content[0].transcript == "partial transcript"
@pytest.mark.asyncio
async def test_existing_transcript_not_overwritten_by_stale_deltas(
self, mock_model, mock_agent
):
"""Existing transcripts must take precedence over leftover delta accumulators.
``_item_transcripts`` is keyed by item_id and persists across updates within a
turn. When the model retrieves an item without a transcript, the merge should
fall back to deltas only when no existing transcript is present otherwise
the complete transcript already in history would be clobbered by partial
(or stale) delta state.
"""
session = RealtimeSession(mock_model, mock_agent, None)
# History already has the completed transcript for the item.
initial_item = AssistantMessageItem(
item_id="assist_3",
role="assistant",
content=[AssistantAudio(audio=None, transcript="Final complete transcript")],
)
session._history = [initial_item]
# Simulate stale/leftover delta state for the same item id.
session._item_transcripts["assist_3"] = "stale partial"
# Update arrives without transcript populated; merge must keep the existing
# complete transcript rather than reverting to the stale delta accumulator.
update_without_transcript = AssistantMessageItem(
item_id="assist_3",
role="assistant",
content=[AssistantAudio(audio=None, transcript=None)],
)
await session.on_event(RealtimeModelItemUpdatedEvent(item=update_without_transcript))
preserved_item = cast(AssistantMessageItem, session._history[0])
assert isinstance(preserved_item.content[0], AssistantAudio)
assert preserved_item.content[0].transcript == "Final complete transcript"