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

360 lines
12 KiB
Python

from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock
import pytest
from openai.types.realtime.realtime_session_create_request import (
RealtimeSessionCreateRequest,
)
from openai.types.realtime.session_update_event import SessionUpdateEvent
from agents.handoffs import Handoff
from agents.realtime.agent import RealtimeAgent
from agents.realtime.config import RealtimeRunConfig, RealtimeSessionModelSettings
from agents.realtime.handoffs import realtime_handoff
from agents.realtime.model import RealtimeModelConfig
from agents.realtime.openai_realtime import (
OpenAIRealtimeSIPModel,
OpenAIRealtimeWebSocketModel,
_build_model_settings_from_agent,
_collect_enabled_handoffs,
)
from agents.run_context import RunContextWrapper
from agents.tool import FunctionTool, function_tool
def _disabled_billing_realtime_handoff(*, is_enabled: Any = False) -> Handoff[Any, Any]:
return realtime_handoff(
RealtimeAgent(name="billing"),
tool_name_override="transfer_to_billing",
is_enabled=is_enabled,
)
def _disabled_billing_realtime_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_collect_enabled_handoffs_filters_disabled() -> None:
parent = RealtimeAgent(name="parent")
disabled = realtime_handoff(
RealtimeAgent(name="child_disabled"),
is_enabled=lambda ctx, agent: False,
)
parent.handoffs = [disabled, RealtimeAgent(name="child_enabled")]
enabled = await _collect_enabled_handoffs(parent, RunContextWrapper(None))
assert len(enabled) == 1
assert isinstance(enabled[0], Handoff)
assert enabled[0].agent_name == "child_enabled"
@pytest.mark.asyncio
async def test_build_model_settings_from_agent_merges_agent_fields(monkeypatch: pytest.MonkeyPatch):
agent = RealtimeAgent(name="root", prompt={"id": "prompt-id"})
monkeypatch.setattr(agent, "get_system_prompt", AsyncMock(return_value="sys"))
@function_tool
def helper() -> str:
"""Helper tool for testing."""
return "ok"
monkeypatch.setattr(agent, "get_all_tools", AsyncMock(return_value=[helper]))
agent.handoffs = [RealtimeAgent(name="handoff-child")]
base_settings: RealtimeSessionModelSettings = {"model_name": "gpt-realtime-2.1"}
starting_settings: RealtimeSessionModelSettings = {"voice": "verse"}
run_config: RealtimeRunConfig = {"tracing_disabled": True}
merged = await _build_model_settings_from_agent(
agent=agent,
context_wrapper=RunContextWrapper(None),
base_settings=base_settings,
starting_settings=starting_settings,
run_config=run_config,
)
assert merged["prompt"] == {"id": "prompt-id"}
assert merged["instructions"] == "sys"
assert merged["tools"][0].name == helper.name
assert merged["handoffs"][0].agent_name == "handoff-child"
assert merged["voice"] == "verse"
assert merged["model_name"] == "gpt-realtime-2.1"
assert merged["tracing"] is None
assert base_settings == {"model_name": "gpt-realtime-2.1"}
@pytest.mark.asyncio
async def test_build_model_settings_filters_disabled_starting_handoff_name_conflict():
tool = function_tool(lambda: "ok", name_override="transfer_to_billing")
disabled_handoff = _disabled_billing_realtime_handoff()
agent = RealtimeAgent(name="parent", tools=[tool])
merged = await _build_model_settings_from_agent(
agent=agent,
context_wrapper=RunContextWrapper(None),
base_settings={},
starting_settings={"handoffs": [disabled_handoff]},
run_config=None,
)
assert merged["tools"] == [tool]
assert merged["handoffs"] == []
@pytest.mark.asyncio
async def test_build_model_settings_filters_disabled_starting_tool_name_conflict():
disabled_tool = _disabled_billing_realtime_tool()
handoff = _disabled_billing_realtime_handoff(is_enabled=True)
agent = RealtimeAgent(name="parent", handoffs=[handoff])
merged = await _build_model_settings_from_agent(
agent=agent,
context_wrapper=RunContextWrapper(None),
base_settings={},
starting_settings={"tools": [disabled_tool]},
run_config=None,
)
assert merged["tools"] == []
assert merged["handoffs"] == [handoff]
@pytest.mark.asyncio
async def test_build_model_settings_evaluates_starting_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_realtime_tool(is_enabled=is_enabled)
agent = RealtimeAgent(name="parent")
context_wrapper = RunContextWrapper(None)
merged = await _build_model_settings_from_agent(
agent=agent,
context_wrapper=context_wrapper,
base_settings={},
starting_settings={"tools": [disabled_tool]},
run_config=None,
)
assert merged["tools"] == []
assert calls == [(context_wrapper, agent)]
@pytest.mark.asyncio
async def test_build_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 = cast(
Handoff[Any, Any],
realtime_handoff(RealtimeAgent(name="billing"), is_enabled=is_enabled),
)
agent = RealtimeAgent(name="parent", handoffs=[handoff])
merged = await _build_model_settings_from_agent(
agent=agent,
context_wrapper=RunContextWrapper(None),
base_settings={},
starting_settings={"voice": "verse"},
run_config=None,
)
assert merged["handoffs"] == [handoff]
assert call_count == 1
@pytest.mark.asyncio
async def test_sip_model_build_initial_session_payload(monkeypatch: pytest.MonkeyPatch):
agent = RealtimeAgent(name="parent", prompt={"id": "prompt-99"})
child_agent = RealtimeAgent(name="child")
agent.handoffs = [child_agent]
@function_tool
def ping() -> str:
"""Ping tool used for session payload building."""
return "pong"
monkeypatch.setattr(agent, "get_system_prompt", AsyncMock(return_value="parent-system"))
monkeypatch.setattr(agent, "get_all_tools", AsyncMock(return_value=[ping]))
model_config: RealtimeModelConfig = {
"initial_model_settings": {
"model_name": "gpt-realtime-mini",
"voice": "verse",
}
}
run_config: RealtimeRunConfig = {
"model_settings": {"output_modalities": ["text"]},
"tracing_disabled": True,
}
overrides: RealtimeSessionModelSettings = {
"audio": {"input": {"format": {"type": "audio/pcmu"}}},
"output_audio_format": "g711_ulaw",
}
payload = await OpenAIRealtimeSIPModel.build_initial_session_payload(
agent,
context={"user": "abc"},
model_config=model_config,
run_config=run_config,
overrides=overrides,
)
assert isinstance(payload, RealtimeSessionCreateRequest)
assert payload.model == "gpt-realtime-mini"
assert payload.output_modalities == ["text"]
assert payload.audio is not None
audio = payload.audio
assert audio.input is not None
assert audio.input.format is not None
assert audio.input.format.type == "audio/pcmu"
assert audio.output is not None
assert audio.output.format is not None
assert audio.output.format.type == "audio/pcmu"
assert audio.output.voice == "verse"
assert payload.instructions == "parent-system"
assert payload.prompt is not None and payload.prompt.id == "prompt-99"
tool_names: set[str] = set()
for tool in payload.tools or []:
name = getattr(tool, "name", None)
if name:
tool_names.add(name)
assert ping.name in tool_names
assert f"transfer_to_{child_agent.name}" in tool_names
@pytest.mark.asyncio
async def test_sip_initial_session_payload_filters_disabled_initial_model_settings_handoff():
tool = function_tool(lambda: "ok", name_override="transfer_to_billing")
disabled_handoff = _disabled_billing_realtime_handoff()
agent = RealtimeAgent(name="parent", tools=[tool])
payload = await OpenAIRealtimeSIPModel.build_initial_session_payload(
agent,
model_config={"initial_model_settings": {"handoffs": [disabled_handoff]}},
)
tool_names = [getattr(tool, "name", None) for tool in payload.tools or []]
assert tool_names.count("transfer_to_billing") == 1
@pytest.mark.asyncio
async def test_sip_initial_session_payload_filters_disabled_initial_model_settings_tool():
disabled_tool = _disabled_billing_realtime_tool()
agent = RealtimeAgent(
name="parent",
handoffs=[_disabled_billing_realtime_handoff(is_enabled=True)],
)
payload = await OpenAIRealtimeSIPModel.build_initial_session_payload(
agent,
model_config={"initial_model_settings": {"tools": [disabled_tool]}},
)
tool_names = [getattr(tool, "name", None) for tool in payload.tools or []]
assert tool_names == ["transfer_to_billing"]
@pytest.mark.asyncio
async def test_sip_initial_session_payload_filters_disabled_override_handoff():
tool = function_tool(lambda: "ok", name_override="transfer_to_billing")
disabled_handoff = _disabled_billing_realtime_handoff()
agent = RealtimeAgent(name="parent", tools=[tool])
payload = await OpenAIRealtimeSIPModel.build_initial_session_payload(
agent,
overrides={"handoffs": [disabled_handoff]},
)
tool_names = [getattr(tool, "name", None) for tool in payload.tools or []]
assert tool_names.count("transfer_to_billing") == 1
@pytest.mark.asyncio
async def test_sip_initial_session_payload_filters_disabled_override_tool():
disabled_tool = _disabled_billing_realtime_tool()
agent = RealtimeAgent(
name="parent",
handoffs=[_disabled_billing_realtime_handoff(is_enabled=True)],
)
payload = await OpenAIRealtimeSIPModel.build_initial_session_payload(
agent,
overrides={"tools": [disabled_tool]},
)
tool_names = [getattr(tool, "name", None) for tool in payload.tools or []]
assert tool_names == ["transfer_to_billing"]
@pytest.mark.asyncio
async def test_sip_initial_session_payload_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 = cast(
Handoff[Any, Any],
realtime_handoff(RealtimeAgent(name="billing"), is_enabled=is_enabled),
)
agent = RealtimeAgent(name="parent", handoffs=[handoff])
payload = await OpenAIRealtimeSIPModel.build_initial_session_payload(
agent,
overrides={"voice": "verse"},
)
tool_names = [getattr(tool, "name", None) for tool in payload.tools or []]
assert "transfer_to_billing" in tool_names
assert call_count == 1
def test_call_id_session_update_omits_null_audio_formats() -> None:
model = OpenAIRealtimeWebSocketModel()
model._call_id = "call_123"
session_config = model._get_session_config({})
payload = SessionUpdateEvent(type="session.update", session=session_config).model_dump(
exclude_unset=True
)
audio = payload["session"]["audio"]
assert "format" not in audio["input"]
assert "format" not in audio["output"]
def test_call_id_session_update_includes_explicit_audio_formats() -> None:
model = OpenAIRealtimeWebSocketModel()
model._call_id = "call_123"
session_config = model._get_session_config(
{
"input_audio_format": "g711_ulaw",
"output_audio_format": "g711_ulaw",
}
)
payload = SessionUpdateEvent(type="session.update", session=session_config).model_dump(
exclude_unset=True
)
audio = payload["session"]["audio"]
assert audio["input"]["format"]["type"] == "audio/pcmu"
assert audio["output"]["format"]["type"] == "audio/pcmu"