chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:17 +08:00
commit 4ed4e9ff99
1368 changed files with 334957 additions and 0 deletions
View File
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from typing import Any
import pytest
from agents import RunContextWrapper
from agents.realtime.agent import RealtimeAgent
def test_can_initialize_realtime_agent():
agent = RealtimeAgent(name="test", instructions="Hello")
assert agent.name == "test"
assert agent.instructions == "Hello"
@pytest.mark.asyncio
async def test_dynamic_instructions():
agent = RealtimeAgent(name="test")
assert agent.instructions is None
def _instructions(ctx, agt) -> str:
assert ctx.context is None
assert agt == agent
return "Dynamic"
agent = RealtimeAgent(name="test", instructions=_instructions)
instructions = await agent.get_system_prompt(RunContextWrapper(context=None))
assert instructions == "Dynamic"
def test_post_init_rejects_invalid_field_types() -> None:
with pytest.raises(TypeError, match="RealtimeAgent name must be a string"):
RealtimeAgent(name=1) # type: ignore[arg-type]
with pytest.raises(TypeError, match="RealtimeAgent tools must be a list"):
RealtimeAgent(name="x", tools="nope") # type: ignore[arg-type]
with pytest.raises(TypeError, match="RealtimeAgent handoffs must be a list"):
RealtimeAgent(name="x", handoffs="nope") # type: ignore[arg-type]
with pytest.raises(TypeError, match="RealtimeAgent instructions must be"):
RealtimeAgent(name="x", instructions=123) # type: ignore[arg-type]
def test_clone_does_not_mutate_original_lists() -> None:
"""Cloning with a new list must not affect the original agent's lists."""
original = RealtimeAgent(name="orig", tools=[], handoffs=[])
new_tools: list[Any] = ["t1"]
cloned = original.clone(tools=new_tools)
assert original.tools == []
assert len(cloned.tools) == 1
assert cloned.tools is not original.tools
# Shared reference when not overridden (documented shallow-copy behavior).
assert cloned.handoffs is original.handoffs
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
import importlib
import logging
from pathlib import Path
from types import ModuleType
import pytest
from agents.realtime.events import RealtimeEventInfo, RealtimeRawModelEvent
from agents.realtime.items import InputText, UserMessageItem
from agents.realtime.model_events import RealtimeModelItemUpdatedEvent
from agents.run_context import RunContextWrapper
@pytest.fixture
def app_server(monkeypatch: pytest.MonkeyPatch) -> ModuleType:
app_dir = Path(__file__).parents[2] / "examples" / "realtime" / "app"
monkeypatch.chdir(app_dir)
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
module = importlib.import_module("examples.realtime.app.server")
return importlib.reload(module)
def test_item_updated_debug_summary_uses_concrete_event_type(
app_server: ModuleType,
caplog: pytest.LogCaptureFixture,
) -> None:
item = UserMessageItem(
item_id="item-1",
content=[InputText(text="sensitive transcript")],
)
event = RealtimeRawModelEvent(
data=RealtimeModelItemUpdatedEvent(item=item),
info=RealtimeEventInfo(context=RunContextWrapper(None)),
)
with caplog.at_level(logging.DEBUG, logger=app_server.__name__):
app_server.manager._log_debug_event("session-1", event)
assert "item_updated" in caplog.text
assert "item-1" in caplog.text
assert "input_text" in caplog.text
assert "sensitive transcript" not in caplog.text
+53
View File
@@ -0,0 +1,53 @@
from openai.types.realtime.realtime_audio_formats import AudioPCM, AudioPCMA, AudioPCMU
from agents.realtime.audio_formats import to_realtime_audio_format
def test_to_realtime_audio_format_from_strings():
assert to_realtime_audio_format("pcm").type == "audio/pcm" # type: ignore[union-attr]
assert to_realtime_audio_format("pcm16").type == "audio/pcm" # type: ignore[union-attr]
assert to_realtime_audio_format("audio/pcm").type == "audio/pcm" # type: ignore[union-attr]
assert to_realtime_audio_format("pcmu").type == "audio/pcmu" # type: ignore[union-attr]
assert to_realtime_audio_format("audio/pcmu").type == "audio/pcmu" # type: ignore[union-attr]
assert to_realtime_audio_format("g711_ulaw").type == "audio/pcmu" # type: ignore[union-attr]
assert to_realtime_audio_format("pcma").type == "audio/pcma" # type: ignore[union-attr]
assert to_realtime_audio_format("audio/pcma").type == "audio/pcma" # type: ignore[union-attr]
assert to_realtime_audio_format("g711_alaw").type == "audio/pcma" # type: ignore[union-attr]
def test_to_realtime_audio_format_passthrough_and_unknown_logs():
fmt = AudioPCM(type="audio/pcm", rate=24000)
# Passing a RealtimeAudioFormats should return the same instance
assert to_realtime_audio_format(fmt) is fmt
# Unknown string returns None (and logs at debug level internally)
assert to_realtime_audio_format("something_else") is None
def test_to_realtime_audio_format_none():
assert to_realtime_audio_format(None) is None
def test_to_realtime_audio_format_from_mapping():
pcm_exact_rate = to_realtime_audio_format({"type": "audio/pcm", "rate": 24000})
assert isinstance(pcm_exact_rate, AudioPCM)
assert pcm_exact_rate.rate == 24000
pcm = to_realtime_audio_format({"type": "audio/pcm", "rate": 16000})
assert isinstance(pcm, AudioPCM)
assert pcm.type == "audio/pcm"
assert pcm.rate == 24000
pcm_default_rate = to_realtime_audio_format({"type": "audio/pcm"})
assert isinstance(pcm_default_rate, AudioPCM)
assert pcm_default_rate.rate == 24000
ulaw = to_realtime_audio_format({"type": "audio/pcmu"})
assert isinstance(ulaw, AudioPCMU)
assert ulaw.type == "audio/pcmu"
alaw = to_realtime_audio_format({"type": "audio/pcma"})
assert isinstance(alaw, AudioPCMA)
assert alaw.type == "audio/pcma"
assert to_realtime_audio_format({"type": "audio/unknown", "rate": 8000}) is None
+380
View File
@@ -0,0 +1,380 @@
from __future__ import annotations
import base64
from unittest.mock import Mock
import pytest
from openai.types.realtime.conversation_item_create_event import ConversationItemCreateEvent
from openai.types.realtime.conversation_item_truncate_event import ConversationItemTruncateEvent
from openai.types.realtime.input_audio_buffer_append_event import InputAudioBufferAppendEvent
from openai.types.realtime.realtime_conversation_item_function_call_output import (
RealtimeConversationItemFunctionCallOutput,
)
from pydantic import ValidationError
from agents.realtime.config import RealtimeModelTracingConfig
from agents.realtime.model_inputs import (
RealtimeModelSendAudio,
RealtimeModelSendRawMessage,
RealtimeModelSendToolOutput,
RealtimeModelSendUserInput,
RealtimeModelUserInputMessage,
)
from agents.realtime.openai_realtime import _ConversionHelper
class TestConversionHelperTryConvertRawMessage:
"""Test suite for _ConversionHelper.try_convert_raw_message method."""
def test_try_convert_raw_message_valid_session_update(self):
"""Test converting a valid session.update raw message."""
raw_message = RealtimeModelSendRawMessage(
message={
"type": "session.update",
"other_data": {
"session": {
"model": "gpt-realtime-2.1",
"type": "realtime",
"modalities": ["text", "audio"],
"voice": "ash",
}
},
}
)
result = _ConversionHelper.try_convert_raw_message(raw_message)
assert result is not None
assert result.type == "session.update"
def test_try_convert_raw_message_valid_response_create(self):
"""Test converting a valid response.create raw message."""
raw_message = RealtimeModelSendRawMessage(
message={
"type": "response.create",
"other_data": {},
}
)
result = _ConversionHelper.try_convert_raw_message(raw_message)
assert result is not None
assert result.type == "response.create"
def test_try_convert_raw_message_invalid_type(self):
"""Test converting an invalid message type returns None."""
raw_message = RealtimeModelSendRawMessage(
message={
"type": "invalid.message.type",
"other_data": {},
}
)
result = _ConversionHelper.try_convert_raw_message(raw_message)
assert result is None
def test_try_convert_raw_message_malformed_data(self):
"""Test converting malformed message data returns None."""
raw_message = RealtimeModelSendRawMessage(
message={
"type": "session.update",
"other_data": {
"session": "invalid_session_data" # Should be dict
},
}
)
result = _ConversionHelper.try_convert_raw_message(raw_message)
assert result is None
def test_try_convert_raw_message_missing_type(self):
"""Test converting message without type returns None."""
raw_message = RealtimeModelSendRawMessage(
message={
"type": "missing.type.test",
"other_data": {"some": "data"},
}
)
result = _ConversionHelper.try_convert_raw_message(raw_message)
assert result is None
class TestConversionHelperTracingConfig:
"""Test suite for _ConversionHelper.convert_tracing_config method."""
def test_convert_tracing_config_none(self):
"""Test converting None tracing config."""
result = _ConversionHelper.convert_tracing_config(None)
assert result is None
def test_convert_tracing_config_auto(self):
"""Test converting 'auto' tracing config."""
result = _ConversionHelper.convert_tracing_config("auto")
assert result == "auto"
def test_convert_tracing_config_dict_full(self):
"""Test converting full tracing config dict."""
tracing_config: RealtimeModelTracingConfig = {
"group_id": "test-group",
"metadata": {"env": "test"},
"workflow_name": "test-workflow",
}
result = _ConversionHelper.convert_tracing_config(tracing_config)
assert result is not None
assert result != "auto"
assert result.group_id == "test-group"
assert result.metadata == {"env": "test"}
assert result.workflow_name == "test-workflow"
def test_convert_tracing_config_dict_partial(self):
"""Test converting partial tracing config dict."""
tracing_config: RealtimeModelTracingConfig = {
"group_id": "test-group",
}
result = _ConversionHelper.convert_tracing_config(tracing_config)
assert result is not None
assert result != "auto"
assert result.group_id == "test-group"
assert result.metadata is None
assert result.workflow_name is None
def test_convert_tracing_config_empty_dict(self):
"""Test converting empty tracing config dict."""
tracing_config: RealtimeModelTracingConfig = {}
result = _ConversionHelper.convert_tracing_config(tracing_config)
assert result is not None
assert result != "auto"
assert result.group_id is None
assert result.metadata is None
assert result.workflow_name is None
class TestConversionHelperUserInput:
"""Test suite for _ConversionHelper user input conversion methods."""
def test_convert_user_input_to_conversation_item_string(self):
"""Test converting string user input to conversation item."""
event = RealtimeModelSendUserInput(user_input="Hello, world!")
result = _ConversionHelper.convert_user_input_to_conversation_item(event)
assert result.type == "message"
assert result.role == "user"
assert result.content is not None
assert len(result.content) == 1
assert result.content[0].type == "input_text"
assert result.content[0].text == "Hello, world!"
def test_convert_user_input_to_conversation_item_dict(self):
"""Test converting dict user input to conversation item."""
user_input_dict: RealtimeModelUserInputMessage = {
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "Hello"},
{"type": "input_text", "text": "World"},
],
}
event = RealtimeModelSendUserInput(user_input=user_input_dict)
result = _ConversionHelper.convert_user_input_to_conversation_item(event)
assert result.type == "message"
assert result.role == "user"
assert result.content is not None
assert len(result.content) == 2
assert result.content[0].type == "input_text"
assert result.content[0].text == "Hello"
assert result.content[1].type == "input_text"
assert result.content[1].text == "World"
def test_convert_user_input_to_conversation_item_dict_empty_content(self):
"""Test converting dict user input with empty content."""
user_input_dict: RealtimeModelUserInputMessage = {
"type": "message",
"role": "user",
"content": [],
}
event = RealtimeModelSendUserInput(user_input=user_input_dict)
result = _ConversionHelper.convert_user_input_to_conversation_item(event)
assert result.type == "message"
assert result.role == "user"
assert result.content is not None
assert len(result.content) == 0
def test_convert_user_input_to_item_create(self):
"""Test converting user input to item create event."""
event = RealtimeModelSendUserInput(user_input="Test message")
result = _ConversionHelper.convert_user_input_to_item_create(event)
assert isinstance(result, ConversationItemCreateEvent)
assert result.type == "conversation.item.create"
assert result.item.type == "message"
assert result.item.role == "user"
class TestConversionHelperAudio:
"""Test suite for _ConversionHelper.convert_audio_to_input_audio_buffer_append."""
def test_convert_audio_to_input_audio_buffer_append(self):
"""Test converting audio data to input audio buffer append event."""
audio_data = b"test audio data"
event = RealtimeModelSendAudio(audio=audio_data, commit=False)
result = _ConversionHelper.convert_audio_to_input_audio_buffer_append(event)
assert isinstance(result, InputAudioBufferAppendEvent)
assert result.type == "input_audio_buffer.append"
# Verify base64 encoding
expected_b64 = base64.b64encode(audio_data).decode("utf-8")
assert result.audio == expected_b64
def test_convert_audio_to_input_audio_buffer_append_empty(self):
"""Test converting empty audio data."""
audio_data = b""
event = RealtimeModelSendAudio(audio=audio_data, commit=True)
result = _ConversionHelper.convert_audio_to_input_audio_buffer_append(event)
assert isinstance(result, InputAudioBufferAppendEvent)
assert result.type == "input_audio_buffer.append"
assert result.audio == ""
def test_convert_audio_to_input_audio_buffer_append_large_data(self):
"""Test converting large audio data."""
audio_data = b"x" * 10000 # Large audio buffer
event = RealtimeModelSendAudio(audio=audio_data, commit=False)
result = _ConversionHelper.convert_audio_to_input_audio_buffer_append(event)
assert isinstance(result, InputAudioBufferAppendEvent)
assert result.type == "input_audio_buffer.append"
# Verify it can be decoded back
decoded = base64.b64decode(result.audio)
assert decoded == audio_data
class TestConversionHelperToolOutput:
"""Test suite for _ConversionHelper.convert_tool_output method."""
def test_convert_tool_output(self):
"""Test converting tool output to conversation item create event."""
mock_tool_call = Mock()
mock_tool_call.call_id = "call_123"
event = RealtimeModelSendToolOutput(
tool_call=mock_tool_call,
output="Function executed successfully",
start_response=False,
)
result = _ConversionHelper.convert_tool_output(event)
assert isinstance(result, ConversationItemCreateEvent)
assert result.type == "conversation.item.create"
assert result.item.type == "function_call_output"
assert isinstance(result.item, RealtimeConversationItemFunctionCallOutput)
tool_output_item = result.item
assert tool_output_item.output == "Function executed successfully"
assert tool_output_item.call_id == "call_123"
def test_convert_tool_output_no_call_id(self):
"""Test converting tool output with None call_id."""
mock_tool_call = Mock()
mock_tool_call.call_id = None
event = RealtimeModelSendToolOutput(
tool_call=mock_tool_call,
output="Output without call ID",
start_response=False,
)
with pytest.raises(
ValidationError,
match="1 validation error for RealtimeConversationItemFunctionCallOutput",
):
_ConversionHelper.convert_tool_output(event)
def test_convert_tool_output_empty_output(self):
"""Test converting tool output with empty output."""
mock_tool_call = Mock()
mock_tool_call.call_id = "call_456"
event = RealtimeModelSendToolOutput(
tool_call=mock_tool_call,
output="",
start_response=True,
)
result = _ConversionHelper.convert_tool_output(event)
assert isinstance(result, ConversationItemCreateEvent)
assert result.type == "conversation.item.create"
assert isinstance(result.item, RealtimeConversationItemFunctionCallOutput)
assert result.item.output == ""
assert result.item.call_id == "call_456"
class TestConversionHelperInterrupt:
"""Test suite for _ConversionHelper.convert_interrupt method."""
def test_convert_interrupt(self):
"""Test converting interrupt parameters to conversation item truncate event."""
current_item_id = "item_789"
current_audio_content_index = 2
elapsed_time_ms = 1500
result = _ConversionHelper.convert_interrupt(
current_item_id, current_audio_content_index, elapsed_time_ms
)
assert isinstance(result, ConversationItemTruncateEvent)
assert result.type == "conversation.item.truncate"
assert result.item_id == "item_789"
assert result.content_index == 2
assert result.audio_end_ms == 1500
def test_convert_interrupt_zero_time(self):
"""Test converting interrupt with zero elapsed time."""
result = _ConversionHelper.convert_interrupt("item_1", 0, 0)
assert isinstance(result, ConversationItemTruncateEvent)
assert result.type == "conversation.item.truncate"
assert result.item_id == "item_1"
assert result.content_index == 0
assert result.audio_end_ms == 0
def test_convert_interrupt_large_values(self):
"""Test converting interrupt with large values."""
result = _ConversionHelper.convert_interrupt("item_xyz", 99, 999999)
assert isinstance(result, ConversationItemTruncateEvent)
assert result.type == "conversation.item.truncate"
assert result.item_id == "item_xyz"
assert result.content_index == 99
assert result.audio_end_ms == 999999
def test_convert_interrupt_empty_item_id(self):
"""Test converting interrupt with empty item ID."""
result = _ConversionHelper.convert_interrupt("", 1, 100)
assert isinstance(result, ConversationItemTruncateEvent)
assert result.type == "conversation.item.truncate"
assert result.item_id == ""
assert result.content_index == 1
assert result.audio_end_ms == 100
@@ -0,0 +1,35 @@
from __future__ import annotations
from typing import Any, cast
import pytest
from websockets.asyncio.client import ClientConnection
from agents.realtime.openai_realtime import OpenAIRealtimeWebSocketModel
class _DummyWS:
def __init__(self) -> None:
self.sent: list[str] = []
async def send(self, data: str) -> None:
self.sent.append(data)
@pytest.mark.asyncio
async def test_no_auto_interrupt_on_vad_speech_started(monkeypatch: Any) -> None:
model = OpenAIRealtimeWebSocketModel()
called = {"interrupt": False}
async def _fake_interrupt(event: Any) -> None:
called["interrupt"] = True
# Prevent network use; _websocket only needed for other paths
model._websocket = cast(ClientConnection, _DummyWS())
monkeypatch.setattr(model, "_send_interrupt", _fake_interrupt)
# This event previously triggered an interrupt; now it should be ignored
await model._handle_ws_event({"type": "input_audio_buffer.speech_started"})
assert called["interrupt"] is False
+80
View File
@@ -0,0 +1,80 @@
from openai.types.realtime.realtime_conversation_item_assistant_message import (
Content as AssistantMessageContent,
RealtimeConversationItemAssistantMessage,
)
from openai.types.realtime.realtime_conversation_item_system_message import (
Content as SystemMessageContent,
RealtimeConversationItemSystemMessage,
)
from openai.types.realtime.realtime_conversation_item_user_message import (
Content as UserMessageContent,
RealtimeConversationItemUserMessage,
)
from agents.realtime.items import (
AssistantMessageItem,
RealtimeMessageItem,
SystemMessageItem,
UserMessageItem,
)
from agents.realtime.openai_realtime import _ConversionHelper
def test_user_message_conversion() -> None:
item = RealtimeConversationItemUserMessage(
id="123",
type="message",
role="user",
content=[
UserMessageContent(type="input_text", text=None),
],
)
converted: RealtimeMessageItem = _ConversionHelper.conversation_item_to_realtime_message_item(
item, None
)
assert isinstance(converted, UserMessageItem)
item = RealtimeConversationItemUserMessage(
id="123",
type="message",
role="user",
content=[
UserMessageContent(type="input_audio", audio=None),
],
)
converted = _ConversionHelper.conversation_item_to_realtime_message_item(item, None)
assert isinstance(converted, UserMessageItem)
def test_assistant_message_conversion() -> None:
item = RealtimeConversationItemAssistantMessage(
id="123",
type="message",
role="assistant",
content=[AssistantMessageContent(type="output_text", text=None)],
)
converted: RealtimeMessageItem = _ConversionHelper.conversation_item_to_realtime_message_item(
item, None
)
assert isinstance(converted, AssistantMessageItem)
def test_system_message_conversion() -> None:
item = RealtimeConversationItemSystemMessage(
id="123",
type="message",
role="system",
content=[SystemMessageContent(type="input_text", text=None)],
)
converted: RealtimeMessageItem = _ConversionHelper.conversation_item_to_realtime_message_item(
item, None
)
assert isinstance(converted, SystemMessageItem)
+51
View File
@@ -0,0 +1,51 @@
from typing import get_args
import agents.realtime as realtime
from agents.realtime.model_events import RealtimeModelEvent
from agents.usage import Usage
def test_all_events_have_type() -> None:
"""Test that all events have a type."""
events = get_args(RealtimeModelEvent)
assert len(events) > 0
for event in events:
assert event.type is not None
assert isinstance(event.type, str)
def test_usage_event_types_are_publicly_exported() -> None:
expected_exports = {
"RealtimeModelCachedTokensDetails",
"RealtimeModelInputTokensDetails",
"RealtimeModelOutputTokensDetails",
"RealtimeModelUsageEvent",
}
assert expected_exports <= set(realtime.__all__)
for name in expected_exports:
assert getattr(realtime, name) is not None
def test_custom_model_can_construct_typed_usage_without_openai_types() -> None:
event = realtime.RealtimeModelUsageEvent(
usage=Usage(requests=1, input_tokens=8, output_tokens=5, total_tokens=13),
input_tokens_details=realtime.RealtimeModelInputTokensDetails(
text_tokens=2,
audio_tokens=6,
cached_tokens=3,
cached_tokens_details=realtime.RealtimeModelCachedTokensDetails(
text_tokens=1,
audio_tokens=2,
),
),
output_tokens_details=realtime.RealtimeModelOutputTokensDetails(
text_tokens=1,
audio_tokens=4,
),
)
assert event.input_tokens_details is not None
assert event.input_tokens_details.audio_tokens == 6
assert event.output_tokens_details is not None
assert event.output_tokens_details.audio_tokens == 4
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,295 @@
from typing import cast
import pytest
from openai.types.realtime.realtime_conversation_item_user_message import (
RealtimeConversationItemUserMessage,
)
from openai.types.realtime.realtime_response_usage import RealtimeResponseUsage
from openai.types.realtime.realtime_tracing_config import (
TracingConfiguration,
)
from agents import Agent, function_tool, tool_namespace
from agents.exceptions import UserError
from agents.handoffs import handoff
from agents.realtime.config import RealtimeModelTracingConfig
from agents.realtime.model_inputs import (
RealtimeModelSendRawMessage,
RealtimeModelSendUserInput,
RealtimeModelUserInputMessage,
)
from agents.realtime.openai_realtime import (
OpenAIRealtimeWebSocketModel,
_ConversionHelper,
get_api_key,
)
from agents.tool import Tool
@pytest.mark.asyncio
async def test_get_api_key_from_env(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "env-key")
assert await get_api_key(None) == "env-key"
@pytest.mark.asyncio
async def test_get_api_key_from_callable_async():
async def f():
return "k"
assert await get_api_key(f) == "k"
def test_try_convert_raw_message_invalid_returns_none():
msg = RealtimeModelSendRawMessage(message={"type": "invalid.event", "other_data": {}})
assert _ConversionHelper.try_convert_raw_message(msg) is None
def test_convert_response_usage_preserves_modality_details():
event = _ConversionHelper.convert_response_usage(
RealtimeResponseUsage.model_validate(
{
"total_tokens": 253,
"input_tokens": 132,
"output_tokens": 121,
"input_token_details": {
"text_tokens": 119,
"audio_tokens": 13,
"image_tokens": 0,
"cached_tokens": 64,
"cached_tokens_details": {
"text_tokens": 60,
"audio_tokens": 4,
"image_tokens": 0,
},
},
"output_token_details": {"text_tokens": 30, "audio_tokens": 91},
}
)
)
assert event.usage.requests == 1
assert event.usage.input_tokens == 132
assert event.usage.output_tokens == 121
assert event.usage.total_tokens == 253
assert event.usage.input_tokens_details.cached_tokens == 64
assert event.input_tokens_details is not None
assert event.input_tokens_details.text_tokens == 119
assert event.input_tokens_details.audio_tokens == 13
assert event.input_tokens_details.image_tokens == 0
assert event.input_tokens_details.cached_tokens == 64
assert event.input_tokens_details.cached_tokens_details is not None
assert event.input_tokens_details.cached_tokens_details.text_tokens == 60
assert event.input_tokens_details.cached_tokens_details.audio_tokens == 4
assert event.input_tokens_details.cached_tokens_details.image_tokens == 0
assert event.output_tokens_details is not None
assert event.output_tokens_details.text_tokens == 30
assert event.output_tokens_details.audio_tokens == 91
def test_convert_response_usage_preserves_missing_details_and_derives_total():
event = _ConversionHelper.convert_response_usage(
RealtimeResponseUsage.model_validate(
{
"input_tokens": 12,
"output_tokens": 3,
"input_token_details": {"audio_tokens": 0},
}
)
)
assert event.usage.total_tokens == 15
assert event.input_tokens_details is not None
assert event.input_tokens_details.audio_tokens == 0
assert event.input_tokens_details.text_tokens is None
assert event.input_tokens_details.cached_tokens is None
assert event.input_tokens_details.cached_tokens_details is None
assert event.output_tokens_details is None
def test_convert_user_input_to_conversation_item_dict_and_str():
# Dict with mixed, including unknown parts (silently skipped)
dict_input_any = {
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "hello"},
{"type": "input_image", "image_url": "http://x/y.png", "detail": "auto"},
{"type": "bogus", "x": 1},
],
}
event = RealtimeModelSendUserInput(
user_input=cast(RealtimeModelUserInputMessage, dict_input_any)
)
item_any = _ConversionHelper.convert_user_input_to_conversation_item(event)
item = cast(RealtimeConversationItemUserMessage, item_any)
assert item.role == "user"
# String input becomes input_text
event2 = RealtimeModelSendUserInput(user_input="hi")
item2_any = _ConversionHelper.convert_user_input_to_conversation_item(event2)
item2 = cast(RealtimeConversationItemUserMessage, item2_any)
assert item2.content[0].type == "input_text"
def test_convert_user_input_dict_skips_invalid_input_text_parts():
"""input_text parts with missing/non-string text must be skipped, not
forwarded as Content(text=None) which the realtime API rejects."""
dict_input_any = {
"type": "message",
"role": "user",
"content": [
{"type": "input_text"}, # missing text
{"type": "input_text", "text": 123}, # non-string text
{"type": "input_text", "text": "ok"}, # valid
],
}
event = RealtimeModelSendUserInput(
user_input=cast(RealtimeModelUserInputMessage, dict_input_any)
)
item = cast(
RealtimeConversationItemUserMessage,
_ConversionHelper.convert_user_input_to_conversation_item(event),
)
assert item.content is not None
assert len(item.content) == 1
assert item.content[0].type == "input_text"
assert item.content[0].text == "ok"
def test_convert_tracing_config_variants():
from agents.realtime.openai_realtime import _ConversionHelper as CH
assert CH.convert_tracing_config(None) is None
assert CH.convert_tracing_config("auto") == "auto"
cfg: RealtimeModelTracingConfig = {
"group_id": "g",
"metadata": {"k": "v"},
"workflow_name": "wf",
}
oc_any = CH.convert_tracing_config(cfg)
oc = cast(TracingConfiguration, oc_any)
assert oc.group_id == "g"
assert oc.workflow_name == "wf"
def test_tools_to_session_tools_raises_on_non_function_tool():
class NotFunctionTool:
def __init__(self):
self.name = "x"
m = OpenAIRealtimeWebSocketModel()
with pytest.raises(UserError):
m._tools_to_session_tools(cast(list[Tool], [NotFunctionTool()]), [])
def test_tools_to_session_tools_includes_handoffs():
a = Agent(name="a")
h = handoff(a)
m = OpenAIRealtimeWebSocketModel()
out = m._tools_to_session_tools([], [h])
assert out[0].name is not None and out[0].name.startswith("transfer_to_")
def test_tools_to_session_tools_rejects_duplicate_function_tool_names():
tool_one = function_tool(lambda: "one", name_override="lookup_account")
tool_two = function_tool(lambda: "two", name_override="lookup_account")
m = OpenAIRealtimeWebSocketModel()
with pytest.raises(
UserError,
match=("Duplicate Realtime tool name found: 'lookup_account' \\(2 function tools\\)"),
):
m._tools_to_session_tools([tool_one, tool_two], [])
def test_tools_to_session_tools_rejects_function_handoff_name_conflict():
tool = function_tool(lambda: "ok", name_override="transfer_to_billing")
h = handoff(Agent(name="billing"), tool_name_override="transfer_to_billing")
m = OpenAIRealtimeWebSocketModel()
with pytest.raises(
UserError,
match=(
"Duplicate Realtime tool name found: "
"'transfer_to_billing' \\(function tool and handoff\\)"
),
):
m._tools_to_session_tools([tool], [h])
def test_tools_to_session_tools_ignores_disabled_function_tool_name_conflict():
tool = function_tool(
lambda: "ok",
name_override="transfer_to_billing",
is_enabled=False,
)
h = handoff(Agent(name="billing"), tool_name_override="transfer_to_billing")
m = OpenAIRealtimeWebSocketModel()
out = m._tools_to_session_tools([tool], [h])
assert [tool.name for tool in out] == ["transfer_to_billing"]
def test_tools_to_session_tools_omits_disabled_function_tool():
tool = function_tool(
lambda: "ok",
name_override="hidden_tool",
is_enabled=False,
)
m = OpenAIRealtimeWebSocketModel()
out = m._tools_to_session_tools([tool], [])
assert out == []
def test_tools_to_session_tools_ignores_disabled_handoff_name_conflict():
tool = function_tool(lambda: "ok", name_override="transfer_to_billing")
h = handoff(
Agent(name="billing"),
tool_name_override="transfer_to_billing",
is_enabled=False,
)
m = OpenAIRealtimeWebSocketModel()
out = m._tools_to_session_tools([tool], [h])
assert [tool.name for tool in out] == ["transfer_to_billing"]
def test_tools_to_session_tools_rejects_duplicate_handoff_names():
handoff_one = handoff(Agent(name="billing"), tool_name_override="transfer_to_support")
handoff_two = handoff(Agent(name="technical"), tool_name_override="transfer_to_support")
m = OpenAIRealtimeWebSocketModel()
with pytest.raises(
UserError,
match=("Duplicate Realtime tool name found: 'transfer_to_support' \\(2 handoffs\\)"),
):
m._tools_to_session_tools([], [handoff_one, handoff_two])
def test_tools_to_session_tools_rejects_namespaced_function_tools():
tool = tool_namespace(
name="crm",
description="CRM tools",
tools=[function_tool(lambda customer_id: customer_id, name_override="lookup_account")],
)[0]
m = OpenAIRealtimeWebSocketModel()
with pytest.raises(UserError, match="tool_namespace\\(\\)"):
m._tools_to_session_tools([tool], [])
def test_tools_to_session_tools_rejects_deferred_function_tools():
tool = function_tool(
lambda customer_id: customer_id,
name_override="lookup_account",
defer_loading=True,
)
m = OpenAIRealtimeWebSocketModel()
with pytest.raises(UserError, match="defer_loading=True"):
m._tools_to_session_tools([tool], [])
@@ -0,0 +1,56 @@
from __future__ import annotations
import asyncio
import pytest
from agents.exceptions import UserError
from agents.realtime.openai_realtime import OpenAIRealtimeSIPModel
class _DummyWebSocket:
def __init__(self) -> None:
self.sent_messages: list[str] = []
self.closed = False
def __aiter__(self):
return self
async def __anext__(self): # pragma: no cover - simple termination
raise StopAsyncIteration
async def send(self, data: str) -> None:
self.sent_messages.append(data)
async def close(self) -> None:
self.closed = True
@pytest.mark.asyncio
async def test_sip_model_uses_call_id_in_url(monkeypatch: pytest.MonkeyPatch) -> None:
dummy_ws = _DummyWebSocket()
captured: dict[str, object] = {}
async def fake_connect(url: str, **kwargs):
captured["url"] = url
captured["kwargs"] = kwargs
return dummy_ws
monkeypatch.setattr("agents.realtime.openai_realtime.websockets.connect", fake_connect)
model = OpenAIRealtimeSIPModel()
await model.connect({"api_key": "sk-test", "call_id": "call_789", "initial_model_settings": {}})
assert captured["url"] == "wss://api.openai.com/v1/realtime?call_id=call_789"
await asyncio.sleep(0) # allow listener task to start and finish
await model.close()
assert dummy_ws.closed
@pytest.mark.asyncio
async def test_sip_model_requires_call_id() -> None:
model = OpenAIRealtimeSIPModel()
with pytest.raises(UserError):
await model.connect({"api_key": "sk-test", "initial_model_settings": {}})
+193
View File
@@ -0,0 +1,193 @@
from unittest.mock import AsyncMock, patch
import pytest
from agents.realtime._default_tracker import ModelAudioTracker
from agents.realtime.model import RealtimePlaybackTracker
from agents.realtime.model_inputs import RealtimeModelSendInterrupt
from agents.realtime.openai_realtime import OpenAIRealtimeWebSocketModel
class TestPlaybackTracker:
"""Test playback tracker functionality for interrupt timing."""
@pytest.fixture
def model(self):
"""Create a fresh model instance for each test."""
return OpenAIRealtimeWebSocketModel()
@pytest.mark.asyncio
async def test_interrupt_timing_with_custom_playback_tracker(self, model):
"""Test interrupt uses custom playback tracker elapsed time instead of default timing."""
# Create custom tracker and set elapsed time
custom_tracker = RealtimePlaybackTracker()
custom_tracker.set_audio_format("pcm16")
custom_tracker.on_play_ms("item_1", 1, 500.0) # content_index 1, 500ms played
# Set up model with custom tracker directly
model._playback_tracker = custom_tracker
# Mock send_raw_message to capture interrupt
model._send_raw_message = AsyncMock()
# Send interrupt
await model._send_interrupt(RealtimeModelSendInterrupt())
# Should use custom tracker's 500ms elapsed time
truncate_events = [
call.args[0]
for call in model._send_raw_message.await_args_list
if getattr(call.args[0], "type", None) == "conversation.item.truncate"
]
assert truncate_events
assert truncate_events[0].audio_end_ms == 500
@pytest.mark.asyncio
async def test_interrupt_skipped_when_no_audio_playing(self, model):
"""Test interrupt returns early when no audio is currently playing."""
model._send_raw_message = AsyncMock()
# No audio playing (default state)
await model._send_interrupt(RealtimeModelSendInterrupt())
# Should not send any interrupt message
model._send_raw_message.assert_not_called()
@pytest.mark.asyncio
async def test_interrupt_skips_when_elapsed_exceeds_audio_length(self, model):
"""Test interrupt skips truncation when playback appears complete."""
model._send_raw_message = AsyncMock()
model._audio_state_tracker.set_audio_format("pcm16")
# 48_000 bytes of PCM16 at 24kHz equals ~1000ms of audio.
model._audio_state_tracker.on_audio_delta("item_1", 0, b"a" * 48_000)
model._playback_tracker = RealtimePlaybackTracker()
model._playback_tracker.on_play_ms("item_1", 0, 2000.0)
await model._send_interrupt(RealtimeModelSendInterrupt())
truncate_events = [
call.args[0]
for call in model._send_raw_message.await_args_list
if getattr(call.args[0], "type", None) == "conversation.item.truncate"
]
assert truncate_events == []
@pytest.mark.asyncio
async def test_interrupt_sends_truncate_when_ongoing_response(self, model):
"""Test interrupt still truncates while response is ongoing."""
model._ongoing_response = True
model._send_raw_message = AsyncMock()
model._audio_state_tracker.set_audio_format("pcm16")
# 48_000 bytes of PCM16 at 24kHz equals ~1000ms of audio.
model._audio_state_tracker.on_audio_delta("item_1", 0, b"a" * 48_000)
model._playback_tracker = RealtimePlaybackTracker()
model._playback_tracker.on_play_ms("item_1", 0, 2000.0)
await model._send_interrupt(RealtimeModelSendInterrupt())
truncate_events = [
call.args[0]
for call in model._send_raw_message.await_args_list
if getattr(call.args[0], "type", None) == "conversation.item.truncate"
]
assert truncate_events
assert truncate_events[0].audio_end_ms == 2000
def test_audio_delta_before_set_audio_format_does_not_raise(self):
"""ModelAudioTracker must tolerate audio deltas before a format is negotiated.
For transcription-only sessions or session payloads that omit an audio
format, ``set_audio_format`` is never called. Previously, the first
``on_audio_delta`` call raised ``AttributeError`` because ``self._format``
was unset. The length calculator already accepts ``None`` as the
unknown-format fallback, so the tracker should pass that through.
"""
tracker = ModelAudioTracker()
# Intentionally do NOT call set_audio_format here.
tracker.on_audio_delta("item_1", 0, b"test")
state = tracker.get_state("item_1", 0)
assert state is not None
# With no format, calculate_audio_length_ms falls back to PCM math.
expected_length = (4 / (24_000 * 2)) * 1000
assert state.audio_length_ms == pytest.approx(expected_length, rel=0, abs=1e-6)
assert tracker.get_last_audio_item() == ("item_1", 0)
def test_audio_state_accumulation_across_deltas(self):
"""Test ModelAudioTracker accumulates audio length across multiple deltas."""
tracker = ModelAudioTracker()
tracker.set_audio_format("pcm16")
# Send multiple deltas for same item
tracker.on_audio_delta("item_1", 0, b"test") # 4 bytes
tracker.on_audio_delta("item_1", 0, b"more") # 4 bytes
state = tracker.get_state("item_1", 0)
assert state is not None
# Should accumulate: 8 bytes -> 4 samples -> (4 / 24000) * 1000 ≈ 0.167ms
expected_length = (8 / (24_000 * 2)) * 1000
assert state.audio_length_ms == pytest.approx(expected_length, rel=0, abs=1e-6)
def test_default_playback_timing_uses_monotonic_clock(self, model):
model._audio_state_tracker.set_audio_format("pcm16")
with patch("agents.realtime._default_tracker.time.monotonic", return_value=42.0):
model._audio_state_tracker.on_audio_delta("item_1", 0, b"test")
with patch("agents.realtime.openai_realtime.time.monotonic", return_value=42.25):
state = model._get_playback_state()
assert state["current_item_id"] == "item_1"
assert state["current_item_content_index"] == 0
assert state["elapsed_ms"] == pytest.approx(250.0)
def test_state_cleanup_on_interruption(self):
"""Test both trackers properly reset state on interruption."""
# Test ModelAudioTracker cleanup
model_tracker = ModelAudioTracker()
model_tracker.set_audio_format("pcm16")
model_tracker.on_audio_delta("item_1", 0, b"test")
assert model_tracker.get_last_audio_item() == ("item_1", 0)
model_tracker.on_interrupted()
assert model_tracker.get_last_audio_item() is None
# Test RealtimePlaybackTracker cleanup
playback_tracker = RealtimePlaybackTracker()
playback_tracker.on_play_ms("item_1", 0, 100.0)
state = playback_tracker.get_state()
assert state["current_item_id"] == "item_1"
assert state["elapsed_ms"] == 100.0
playback_tracker.on_interrupted()
state = playback_tracker.get_state()
assert state["current_item_id"] is None
assert state["elapsed_ms"] is None
def test_audio_length_calculation_with_different_formats(self):
"""Test calculate_audio_length_ms handles g711 and PCM formats correctly."""
from agents.realtime._util import calculate_audio_length_ms
# Test g711 format (8kHz)
g711_bytes = b"12345678" # 8 bytes
g711_length = calculate_audio_length_ms("g711_ulaw", g711_bytes)
assert g711_length == 1 # (8 / 8000) * 1000
# Test PCM format (24kHz, default)
pcm_bytes = b"test" # 4 bytes
pcm_length = calculate_audio_length_ms("pcm16", pcm_bytes)
expected_pcm = (len(pcm_bytes) / (24_000 * 2)) * 1000
assert pcm_length == pytest.approx(expected_pcm, rel=0, abs=1e-6)
# Test None format (defaults to PCM)
none_length = calculate_audio_length_ms(None, pcm_bytes)
assert none_length == pytest.approx(expected_pcm, rel=0, abs=1e-6)
@@ -0,0 +1,23 @@
from agents.realtime.model import RealtimePlaybackTracker
def test_playback_tracker_on_play_bytes_and_state():
tr = RealtimePlaybackTracker()
tr.set_audio_format("pcm16") # PCM path
# 48k bytes -> (48000 / (24000 * 2)) * 1000 = 1_000ms
tr.on_play_bytes("item1", 0, b"x" * 48000)
st = tr.get_state()
assert st["current_item_id"] == "item1"
assert st["elapsed_ms"] and abs(st["elapsed_ms"] - 1_000.0) < 1e-6
# Subsequent play on same item accumulates
tr.on_play_ms("item1", 0, 500.0)
st2 = tr.get_state()
assert st2["elapsed_ms"] and abs(st2["elapsed_ms"] - 1_500.0) < 1e-6
# Interruption clears state
tr.on_interrupted()
st3 = tr.get_state()
assert st3["current_item_id"] is None
assert st3["elapsed_ms"] is None
+290
View File
@@ -0,0 +1,290 @@
"""Tests for realtime handoff functionality."""
import asyncio
import inspect
from collections.abc import Awaitable, Coroutine
from typing import Any, cast
from unittest.mock import Mock
import pytest
from pydantic import BaseModel
from agents import Agent
from agents.exceptions import ModelBehaviorError, UserError
from agents.realtime import RealtimeAgent, realtime_handoff
from agents.run_context import RunContextWrapper
def test_realtime_handoff_creation():
"""Test basic realtime handoff creation."""
realtime_agent = RealtimeAgent(name="test_agent")
handoff_obj = realtime_handoff(realtime_agent)
assert handoff_obj.agent_name == "test_agent"
assert handoff_obj.tool_name == "transfer_to_test_agent"
assert handoff_obj.input_filter is None # Should not support input filters
assert handoff_obj.is_enabled is True
def test_realtime_handoff_with_custom_params():
"""Test realtime handoff with custom parameters."""
realtime_agent = RealtimeAgent(
name="helper_agent",
handoff_description="Helps with general tasks",
)
handoff_obj = realtime_handoff(
realtime_agent,
tool_name_override="custom_handoff",
tool_description_override="Custom handoff description",
is_enabled=False,
)
assert handoff_obj.agent_name == "helper_agent"
assert handoff_obj.tool_name == "custom_handoff"
assert handoff_obj.tool_description == "Custom handoff description"
assert handoff_obj.is_enabled is False
@pytest.mark.asyncio
async def test_realtime_handoff_execution():
"""Test that realtime handoff returns the correct agent."""
realtime_agent = RealtimeAgent(name="target_agent")
handoff_obj = realtime_handoff(realtime_agent)
# Mock context
mock_context = Mock()
# Execute handoff
result = await handoff_obj.on_invoke_handoff(mock_context, "")
assert result is realtime_agent
assert isinstance(result, RealtimeAgent)
def test_realtime_handoff_with_on_handoff_callback():
"""Test realtime handoff with custom on_handoff callback."""
realtime_agent = RealtimeAgent(name="callback_agent")
callback_called = []
def on_handoff_callback(ctx):
callback_called.append(True)
handoff_obj = realtime_handoff(
realtime_agent,
on_handoff=on_handoff_callback,
)
asyncio.run(
cast(
Coroutine[Any, Any, RealtimeAgent[Any]],
handoff_obj.on_invoke_handoff(RunContextWrapper(None), ""),
)
)
assert callback_called == [True]
assert handoff_obj.agent_name == "callback_agent"
def test_regular_agent_handoff_still_works():
"""Test that regular Agent handoffs still work with the new generic types."""
from agents import handoff
regular_agent = Agent(name="regular_agent")
handoff_obj = handoff(regular_agent)
assert handoff_obj.agent_name == "regular_agent"
assert handoff_obj.tool_name == "transfer_to_regular_agent"
# Regular agent handoffs should support input filters
assert hasattr(handoff_obj, "input_filter")
def test_type_annotations_work():
"""Test that type annotations work correctly."""
from agents.handoffs import Handoff
from agents.realtime.handoffs import realtime_handoff
realtime_agent = RealtimeAgent(name="typed_agent")
handoff_obj = realtime_handoff(realtime_agent)
# This should be typed as Handoff[Any, RealtimeAgent[Any]]
assert isinstance(handoff_obj, Handoff)
def test_realtime_handoff_invalid_param_counts_raise():
rt = RealtimeAgent(name="x")
# on_handoff with input_type but wrong param count
def bad2(a): # only one parameter
return None
assert bad2(None) is None
with pytest.raises(UserError):
realtime_handoff(rt, on_handoff=bad2, input_type=int) # type: ignore[arg-type]
# on_handoff without input but wrong param count
def bad1(a, b): # two parameters
return None
assert bad1(None, None) is None
with pytest.raises(UserError):
realtime_handoff(rt, on_handoff=bad1) # type: ignore[arg-type]
def test_realtime_handoff_input_type_requires_on_handoff():
"""input_type without on_handoff must raise UserError, not silently produce a broken handoff."""
rt = RealtimeAgent(name="x")
with pytest.raises(UserError):
realtime_handoff(rt, input_type=int) # type: ignore[call-overload]
def test_realtime_handoff_non_callable_on_handoff_raises_error():
"""Providing a non-callable on_handoff with input_type should raise UserError."""
rt = RealtimeAgent(name="x")
with pytest.raises(UserError, match="on_handoff must be callable"):
realtime_handoff(rt, on_handoff="not_a_function", input_type=int) # type: ignore[call-overload]
@pytest.mark.asyncio
async def test_realtime_handoff_missing_input_json_raises_model_error():
rt = RealtimeAgent(name="x")
async def with_input(ctx: RunContextWrapper[Any], data: int): # simple non-object type
return None
h = realtime_handoff(rt, on_handoff=with_input, input_type=int)
with pytest.raises(ModelBehaviorError):
await h.on_invoke_handoff(RunContextWrapper(None), "null")
await with_input(RunContextWrapper(None), 1)
@pytest.mark.asyncio
async def test_realtime_handoff_is_enabled_async(monkeypatch):
rt = RealtimeAgent(name="x")
async def is_enabled(ctx, agent):
return True
h = realtime_handoff(rt, is_enabled=is_enabled)
assert callable(h.is_enabled)
result = h.is_enabled(RunContextWrapper(None), rt)
assert isinstance(result, Awaitable)
assert await result
@pytest.mark.asyncio
async def test_realtime_handoff_rejects_none_input() -> None:
rt = RealtimeAgent(name="x")
async def with_input(ctx: RunContextWrapper[Any], data: int) -> None:
return None
handoff_obj = realtime_handoff(rt, on_handoff=with_input, input_type=int)
with pytest.raises(ModelBehaviorError):
await handoff_obj.on_invoke_handoff(RunContextWrapper(None), cast(str, None))
await with_input(RunContextWrapper(None), 2)
@pytest.mark.asyncio
async def test_realtime_handoff_sync_is_enabled_callable() -> None:
rt = RealtimeAgent(name="x")
calls: list[bool] = []
def is_enabled(ctx: RunContextWrapper[Any], agent: RealtimeAgent[Any]) -> bool:
calls.append(True)
assert agent is rt
return False
handoff_obj = realtime_handoff(rt, is_enabled=is_enabled)
assert callable(handoff_obj.is_enabled)
enabled_result = handoff_obj.is_enabled(RunContextWrapper(None), rt)
if inspect.isawaitable(enabled_result):
assert await enabled_result is False
else:
assert enabled_result is False
assert calls, "is_enabled callback should be invoked"
def test_realtime_handoff_sync_on_handoff_executes() -> None:
rt = RealtimeAgent(name="sync")
called: list[int] = []
def on_handoff(ctx: RunContextWrapper[Any], value: int) -> None:
called.append(value)
handoff_obj = realtime_handoff(rt, on_handoff=on_handoff, input_type=int)
result: RealtimeAgent[Any] = asyncio.run(
cast(
Coroutine[Any, Any, RealtimeAgent[Any]],
handoff_obj.on_invoke_handoff(RunContextWrapper(None), "5"),
)
)
assert result is rt
assert called == [5]
def test_realtime_handoff_on_handoff_without_input_runs() -> None:
rt = RealtimeAgent(name="no_input")
called: list[bool] = []
def on_handoff(ctx: RunContextWrapper[Any]) -> None:
called.append(True)
handoff_obj = realtime_handoff(rt, on_handoff=on_handoff)
result: RealtimeAgent[Any] = asyncio.run(
cast(
Coroutine[Any, Any, RealtimeAgent[Any]],
handoff_obj.on_invoke_handoff(RunContextWrapper(None), ""),
)
)
assert result is rt
assert called == [True]
@pytest.mark.asyncio
async def test_realtime_handoff_async_on_handoff_without_input_runs() -> None:
rt = RealtimeAgent(name="async_no_input")
called: list[bool] = []
async def on_handoff(ctx: RunContextWrapper[Any]) -> None:
called.append(True)
handoff_obj = realtime_handoff(rt, on_handoff=on_handoff)
result = await handoff_obj.on_invoke_handoff(RunContextWrapper(None), "")
assert result is rt
assert called == [True]
class StrictInput(BaseModel):
name: str
age: int
@pytest.mark.asyncio
async def test_realtime_handoff_strict_json_rejects_type_coercion():
"""With strict_json_schema=True (always on for realtime handoffs), string input for an
int field must raise ModelBehaviorError instead of being silently coerced."""
rt = RealtimeAgent(name="strict_test")
async def _on_handoff(ctx: RunContextWrapper[Any], data: StrictInput) -> None:
pass # pragma: no cover
handoff_obj = realtime_handoff(rt, on_handoff=_on_handoff, input_type=StrictInput)
# age is a string "25" — strict mode should reject this
malformed_json = '{"name": "Alice", "age": "25"}'
with pytest.raises(ModelBehaviorError, match="Invalid JSON"):
await handoff_obj.on_invoke_handoff(RunContextWrapper(None), malformed_json)
# Correctly typed input should still be accepted
valid_json = '{"name": "Alice", "age": 25}'
result = await handoff_obj.on_invoke_handoff(RunContextWrapper(None), valid_json)
assert result is rt
@@ -0,0 +1,359 @@
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"
+249
View File
@@ -0,0 +1,249 @@
from unittest.mock import AsyncMock, Mock, patch
import pytest
from agents.realtime.agent import RealtimeAgent
from agents.realtime.config import RealtimeRunConfig, RealtimeSessionModelSettings
from agents.realtime.model import RealtimeModel, RealtimeModelConfig
from agents.realtime.runner import RealtimeRunner
from agents.realtime.session import RealtimeSession
from agents.tool import function_tool
class MockRealtimeModel(RealtimeModel):
def __init__(self):
self.connect_args = None
async def connect(self, options=None):
self.connect_args = options
def add_listener(self, listener):
pass
def remove_listener(self, listener):
pass
async def send_event(self, event):
pass
async def send_message(self, message, other_event_data=None):
pass
async def send_audio(self, audio, commit=False):
pass
async def send_tool_output(self, tool_call, output, start_response=True):
pass
async def interrupt(self):
pass
async def close(self):
pass
@pytest.fixture
def mock_agent():
agent = Mock(spec=RealtimeAgent)
agent.get_system_prompt = AsyncMock(return_value="Test instructions")
agent.get_all_tools = AsyncMock(return_value=[{"type": "function", "name": "test_tool"}])
return agent
@pytest.fixture
def mock_model():
return MockRealtimeModel()
@pytest.mark.asyncio
async def test_run_creates_session_with_no_settings(
mock_agent: Mock, mock_model: MockRealtimeModel
):
"""Test that run() creates a session correctly if no settings are provided"""
runner = RealtimeRunner(mock_agent, model=mock_model)
with patch("agents.realtime.runner.RealtimeSession") as mock_session_class:
mock_session = Mock(spec=RealtimeSession)
mock_session_class.return_value = mock_session
session = await runner.run()
# Verify session was created with correct parameters
mock_session_class.assert_called_once()
call_args = mock_session_class.call_args
assert call_args[1]["model"] == mock_model
assert call_args[1]["agent"] == mock_agent
assert call_args[1]["context"] is None
# With no settings provided, model_config should be None
model_config = call_args[1]["model_config"]
assert model_config is None
assert session == mock_session
@pytest.mark.asyncio
async def test_run_creates_session_with_settings_only_in_init(
mock_agent: Mock, mock_model: MockRealtimeModel
):
"""Test that it creates a session with the right settings if they are provided only in init"""
config = RealtimeRunConfig(
model_settings=RealtimeSessionModelSettings(model_name="gpt-4o-realtime", voice="nova")
)
runner = RealtimeRunner(mock_agent, model=mock_model, config=config)
with patch("agents.realtime.runner.RealtimeSession") as mock_session_class:
mock_session = Mock(spec=RealtimeSession)
mock_session_class.return_value = mock_session
_ = await runner.run()
# Verify session was created - runner no longer processes settings
call_args = mock_session_class.call_args
model_config = call_args[1]["model_config"]
# Runner should pass None for model_config when none provided to run()
assert model_config is None
@pytest.mark.asyncio
async def test_run_creates_session_with_settings_in_both_init_and_run_overrides(
mock_agent: Mock, mock_model: MockRealtimeModel
):
"""Test settings provided in run() parameter are passed through"""
init_config = RealtimeRunConfig(
model_settings=RealtimeSessionModelSettings(model_name="gpt-4o-realtime", voice="nova")
)
runner = RealtimeRunner(mock_agent, model=mock_model, config=init_config)
run_model_config: RealtimeModelConfig = {
"initial_model_settings": RealtimeSessionModelSettings(
voice="alloy", input_audio_format="pcm16"
)
}
with patch("agents.realtime.runner.RealtimeSession") as mock_session_class:
mock_session = Mock(spec=RealtimeSession)
mock_session_class.return_value = mock_session
_ = await runner.run(model_config=run_model_config)
# Verify run() model_config is passed through as-is
call_args = mock_session_class.call_args
model_config = call_args[1]["model_config"]
# Runner should pass the model_config from run() parameter directly
assert model_config == run_model_config
@pytest.mark.asyncio
async def test_run_creates_session_with_settings_only_in_run(
mock_agent: Mock, mock_model: MockRealtimeModel
):
"""Test settings provided only in run()"""
runner = RealtimeRunner(mock_agent, model=mock_model)
run_model_config: RealtimeModelConfig = {
"initial_model_settings": RealtimeSessionModelSettings(
model_name="gpt-4o-realtime-preview", voice="shimmer", modalities=["text", "audio"]
)
}
with patch("agents.realtime.runner.RealtimeSession") as mock_session_class:
mock_session = Mock(spec=RealtimeSession)
mock_session_class.return_value = mock_session
_ = await runner.run(model_config=run_model_config)
# Verify run() model_config is passed through as-is
call_args = mock_session_class.call_args
model_config = call_args[1]["model_config"]
# Runner should pass the model_config from run() parameter directly
assert model_config == run_model_config
@pytest.mark.asyncio
async def test_run_with_context_parameter(mock_agent: Mock, mock_model: MockRealtimeModel):
"""Test that context parameter is passed through to session"""
runner = RealtimeRunner(mock_agent, model=mock_model)
test_context = {"user_id": "test123"}
with patch("agents.realtime.runner.RealtimeSession") as mock_session_class:
mock_session = Mock(spec=RealtimeSession)
mock_session_class.return_value = mock_session
await runner.run(context=test_context)
call_args = mock_session_class.call_args
assert call_args[1]["context"] == test_context
@pytest.mark.asyncio
async def test_run_with_none_values_from_agent_does_not_crash(mock_model: MockRealtimeModel):
"""Test that runner handles agents with None values without crashing"""
agent = Mock(spec=RealtimeAgent)
agent.get_system_prompt = AsyncMock(return_value=None)
agent.get_all_tools = AsyncMock(return_value=None)
runner = RealtimeRunner(agent, model=mock_model)
with patch("agents.realtime.runner.RealtimeSession") as mock_session_class:
mock_session = Mock(spec=RealtimeSession)
mock_session_class.return_value = mock_session
session = await runner.run()
# Should not crash and return session
assert session == mock_session
# Runner no longer calls agent methods directly - session does that
agent.get_system_prompt.assert_not_called()
agent.get_all_tools.assert_not_called()
@pytest.mark.asyncio
async def test_tool_and_handoffs_are_correct(mock_model: MockRealtimeModel):
@function_tool
def tool_one():
return "result_one"
agent_1 = RealtimeAgent(
name="one",
instructions="instr_one",
)
agent_2 = RealtimeAgent(
name="two",
instructions="instr_two",
tools=[tool_one],
handoffs=[agent_1],
)
session = RealtimeSession(
model=mock_model,
agent=agent_2,
context=None,
model_config=None,
run_config=None,
)
async with session:
pass
# Assert that the model.connect() was called with the correct settings
connect_args = mock_model.connect_args
assert connect_args is not None
assert isinstance(connect_args, dict)
initial_model_settings = connect_args["initial_model_settings"]
assert initial_model_settings is not None
assert isinstance(initial_model_settings, dict)
assert initial_model_settings["instructions"] == "instr_two"
assert len(initial_model_settings["tools"]) == 1
tool = initial_model_settings["tools"][0]
assert tool.name == "tool_one"
handoffs = initial_model_settings["handoffs"]
assert len(handoffs) == 1
handoff = handoffs[0]
assert handoff.tool_name == "transfer_to_one"
assert handoff.agent_name == "one"
File diff suppressed because it is too large Load Diff
+304
View File
@@ -0,0 +1,304 @@
from __future__ import annotations
import asyncio
import json
from typing import Any
from unittest.mock import AsyncMock, Mock
import pytest
import websockets.exceptions
from agents.realtime.events import RealtimeError
from agents.realtime.model import RealtimeModel, RealtimeModelConfig, RealtimeModelListener
from agents.realtime.model_events import (
RealtimeModelErrorEvent,
RealtimeModelEvent,
RealtimeModelExceptionEvent,
)
from agents.realtime.session import RealtimeSession
class FakeRealtimeModel(RealtimeModel):
"""Fake model for testing that forwards events to listeners."""
def __init__(self):
self._listeners: list[RealtimeModelListener] = []
self._events_to_send: list[RealtimeModelEvent] = []
self._is_connected = False
self._send_task: asyncio.Task[None] | None = None
def set_next_events(self, events: list[RealtimeModelEvent]) -> None:
"""Set events to be sent to listeners."""
self._events_to_send = events.copy()
async def connect(self, options: RealtimeModelConfig) -> None:
"""Fake connection that starts sending events."""
self._is_connected = True
self._send_task = asyncio.create_task(self._send_events())
async def _send_events(self) -> None:
"""Send queued events to all listeners."""
for event in self._events_to_send:
await asyncio.sleep(0.001) # Small delay to simulate async behavior
for listener in self._listeners:
await listener.on_event(event)
def add_listener(self, listener: RealtimeModelListener) -> None:
"""Add a listener."""
self._listeners.append(listener)
def remove_listener(self, listener: RealtimeModelListener) -> None:
"""Remove a listener."""
if listener in self._listeners:
self._listeners.remove(listener)
async def close(self) -> None:
"""Close the fake model."""
self._is_connected = False
if self._send_task and not self._send_task.done():
self._send_task.cancel()
try:
await self._send_task
except asyncio.CancelledError:
pass
async def send_message(
self, message: Any, other_event_data: dict[str, Any] | None = None
) -> None:
"""Fake send message."""
pass
async def send_audio(self, audio: bytes, *, commit: bool = False) -> None:
"""Fake send audio."""
pass
async def send_event(self, event: Any) -> None:
"""Fake send event."""
pass
async def send_tool_output(self, tool_call: Any, output: str, start_response: bool) -> None:
"""Fake send tool output."""
pass
async def interrupt(self) -> None:
"""Fake interrupt."""
pass
@pytest.fixture
def fake_agent():
"""Create a fake agent for testing."""
agent = Mock()
agent.get_all_tools = AsyncMock(return_value=[])
agent.get_system_prompt = AsyncMock(return_value="test instructions")
agent.handoffs = []
return agent
@pytest.fixture
def fake_model():
"""Create a fake model for testing."""
return FakeRealtimeModel()
class TestSessionExceptions:
"""Test exception handling in RealtimeSession."""
@pytest.mark.asyncio
async def test_end_to_end_exception_propagation_and_cleanup(
self, fake_model: FakeRealtimeModel, fake_agent
):
"""Test that exceptions are stored, trigger cleanup, and are raised in __aiter__."""
# Create test exception
test_exception = ValueError("Test error")
exception_event = RealtimeModelExceptionEvent(
exception=test_exception, context="Test context"
)
# Set up session
session = RealtimeSession(fake_model, fake_agent, None)
# Set events to send
fake_model.set_next_events([exception_event])
# Start session
async with session:
# Try to iterate and expect exception
with pytest.raises(ValueError, match="Test error"):
async for _ in session:
pass # Should never reach here
# Verify cleanup occurred
assert session._closed is True
assert session._stored_exception == test_exception
assert fake_model._is_connected is False
assert len(fake_model._listeners) == 0
@pytest.mark.asyncio
async def test_websocket_connection_closure_type_distinction(
self, fake_model: FakeRealtimeModel, fake_agent
):
"""Test different WebSocket closure types generate appropriate events."""
# Test ConnectionClosed (should create exception event)
error_closure = websockets.exceptions.ConnectionClosed(None, None)
error_event = RealtimeModelExceptionEvent(
exception=error_closure, context="WebSocket connection closed unexpectedly"
)
session = RealtimeSession(fake_model, fake_agent, None)
fake_model.set_next_events([error_event])
with pytest.raises(websockets.exceptions.ConnectionClosed):
async with session:
async for _event in session:
pass
# Verify error closure triggered cleanup
assert session._closed is True
assert isinstance(session._stored_exception, websockets.exceptions.ConnectionClosed)
@pytest.mark.asyncio
async def test_json_parsing_error_handling(self, fake_model: FakeRealtimeModel, fake_agent):
"""Test JSON parsing errors are properly handled and contextualized."""
# Create JSON decode error
json_error = json.JSONDecodeError("Invalid JSON", "bad json", 0)
json_exception_event = RealtimeModelExceptionEvent(
exception=json_error, context="Failed to parse WebSocket message as JSON"
)
session = RealtimeSession(fake_model, fake_agent, None)
fake_model.set_next_events([json_exception_event])
with pytest.raises(json.JSONDecodeError):
async with session:
async for _event in session:
pass
# Verify context is preserved
assert session._stored_exception == json_error
assert session._closed is True
@pytest.mark.asyncio
async def test_exception_context_preservation(self, fake_model: FakeRealtimeModel, fake_agent):
"""Test that exception context information is preserved through the handling process."""
test_contexts = [
("Failed to send audio", RuntimeError("Audio encoding failed")),
("WebSocket error in message listener", ConnectionError("Network error")),
("Failed to send event: response.create", OSError("Socket closed")),
]
for context, exception in test_contexts:
exception_event = RealtimeModelExceptionEvent(exception=exception, context=context)
session = RealtimeSession(fake_model, fake_agent, None)
fake_model.set_next_events([exception_event])
with pytest.raises(type(exception)):
async with session:
async for _event in session:
pass
# Verify the exact exception is stored
assert session._stored_exception == exception
assert session._closed is True
# Reset for next iteration
fake_model._is_connected = False
fake_model._listeners.clear()
@pytest.mark.asyncio
async def test_multiple_exception_handling_behavior(
self, fake_model: FakeRealtimeModel, fake_agent
):
"""Test behavior when multiple exceptions occur before consumption."""
# Create multiple exceptions
first_exception = ValueError("First error")
second_exception = RuntimeError("Second error")
first_event = RealtimeModelExceptionEvent(
exception=first_exception, context="First context"
)
second_event = RealtimeModelExceptionEvent(
exception=second_exception, context="Second context"
)
session = RealtimeSession(fake_model, fake_agent, None)
fake_model.set_next_events([first_event, second_event])
# Start session and let events process
async with session:
# Give time for events to be processed
await asyncio.sleep(0.05)
# The first exception should be stored (second should overwrite, but that's
# the current behavior). In practice, once an exception occurs, cleanup
# should prevent further processing
assert session._stored_exception is not None
assert session._closed is True
@pytest.mark.asyncio
async def test_exception_during_guardrail_processing(
self, fake_model: FakeRealtimeModel, fake_agent
):
"""Test that exceptions don't interfere with guardrail task cleanup."""
# Create exception event
test_exception = RuntimeError("Processing error")
exception_event = RealtimeModelExceptionEvent(
exception=test_exception, context="Processing failed"
)
session = RealtimeSession(fake_model, fake_agent, None)
async def running_task() -> None:
await asyncio.Event().wait()
async def completed_task() -> None:
return None
pending = asyncio.create_task(running_task())
completed = asyncio.create_task(completed_task())
await asyncio.sleep(0)
await completed
session._guardrail_tasks = {pending, completed}
fake_model.set_next_events([exception_event])
with pytest.raises(RuntimeError, match="Processing error"):
async with session:
async for _event in session:
pass
# Verify guardrail tasks were properly cleaned up.
assert pending.cancelled()
assert completed.done()
assert not completed.cancelled()
assert len(session._guardrail_tasks) == 0
@pytest.mark.asyncio
async def test_normal_events_still_work_before_exception(
self, fake_model: FakeRealtimeModel, fake_agent
):
"""Test that normal events are processed before an exception occurs."""
# Create normal event followed by exception
normal_event = RealtimeModelErrorEvent(error={"message": "Normal error"})
exception_event = RealtimeModelExceptionEvent(
exception=ValueError("Fatal error"), context="Fatal context"
)
session = RealtimeSession(fake_model, fake_agent, None)
fake_model.set_next_events([normal_event, exception_event])
events_received = []
with pytest.raises(ValueError, match="Fatal error"):
async with session:
async for event in session:
events_received.append(event)
# Should have received events before exception
assert len(events_received) >= 1
# Look for the error event (might not be first due to history_updated
# being emitted initially)
error_events = [e for e in events_received if hasattr(e, "type") and e.type == "error"]
assert len(error_events) >= 1
assert isinstance(error_events[0], RealtimeError)
@@ -0,0 +1,93 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, cast
import pydantic
from openai.types.realtime.realtime_audio_config import RealtimeAudioConfig
from openai.types.realtime.realtime_audio_formats import (
AudioPCM,
AudioPCMA,
AudioPCMU,
)
from openai.types.realtime.realtime_session_create_request import (
RealtimeSessionCreateRequest,
)
from openai.types.realtime.realtime_transcription_session_create_request import (
RealtimeTranscriptionSessionCreateRequest,
)
from agents.realtime.openai_realtime import OpenAIRealtimeWebSocketModel as Model
class _DummyModel(pydantic.BaseModel):
type: str
def _session_with_output(fmt: Any | None) -> RealtimeSessionCreateRequest:
if fmt is None:
return RealtimeSessionCreateRequest(type="realtime", model="gpt-realtime-2.1")
return RealtimeSessionCreateRequest(
type="realtime",
model="gpt-realtime-2.1",
# Use dict for output to avoid importing non-exported symbols in tests
audio=RealtimeAudioConfig(output=cast(Any, {"format": fmt})),
)
def test_normalize_session_payload_variants() -> None:
# Passthrough: already a realtime session model
rt = _session_with_output(AudioPCM(type="audio/pcm"))
assert Model._normalize_session_payload(rt) is rt
# Transcription session instance should be ignored
ts = RealtimeTranscriptionSessionCreateRequest(type="transcription")
assert Model._normalize_session_payload(ts) is None
# Transcription-like mapping should be ignored
transcription_mapping: Mapping[str, object] = {"type": "transcription"}
assert Model._normalize_session_payload(transcription_mapping) is None
# Valid realtime mapping should be converted to model
realtime_mapping: Mapping[str, object] = {"type": "realtime", "model": "gpt-realtime-2.1"}
as_model = Model._normalize_session_payload(realtime_mapping)
assert isinstance(as_model, RealtimeSessionCreateRequest)
assert as_model.type == "realtime"
# Invalid mapping returns None
invalid_mapping: Mapping[str, object] = {"type": "bogus"}
assert Model._normalize_session_payload(invalid_mapping) is None
def test_extract_audio_format_from_session_objects() -> None:
# Known OpenAI audio format models -> normalized names
s_pcm = _session_with_output(AudioPCM(type="audio/pcm"))
assert Model._extract_audio_format(s_pcm) == "pcm16"
s_ulaw = _session_with_output(AudioPCMU(type="audio/pcmu"))
assert Model._extract_audio_format(s_ulaw) == "g711_ulaw"
s_alaw = _session_with_output(AudioPCMA(type="audio/pcma"))
assert Model._extract_audio_format(s_alaw) == "g711_alaw"
# Missing/None output format -> None
s_none = _session_with_output(None)
assert Model._extract_audio_format(s_none) is None
def test_normalize_audio_format_fallbacks() -> None:
# String passthrough
assert Model._normalize_audio_format("pcm24") == "pcm24"
# Mapping with type field
assert Model._normalize_audio_format({"type": "g711_ulaw"}) == "g711_ulaw"
# Pydantic model with type field
assert Model._normalize_audio_format(_DummyModel(type="custom")) == "custom"
# Object with attribute 'type'
class HasType:
def __init__(self) -> None:
self.type = "weird"
assert Model._normalize_audio_format(HasType()) == "weird"
+265
View File
@@ -0,0 +1,265 @@
from typing import cast
from unittest.mock import AsyncMock, Mock, patch
import pytest
from openai.types.realtime.realtime_session_create_request import (
RealtimeSessionCreateRequest,
)
from openai.types.realtime.realtime_tracing_config import TracingConfiguration
from agents.realtime.agent import RealtimeAgent
from agents.realtime.model import RealtimeModel
from agents.realtime.openai_realtime import OpenAIRealtimeWebSocketModel
from agents.realtime.session import RealtimeSession
class TestRealtimeTracingIntegration:
"""Test tracing configuration and session.update integration."""
@pytest.fixture
def model(self):
"""Create a fresh model instance for each test."""
return OpenAIRealtimeWebSocketModel()
@pytest.fixture
def mock_websocket(self):
"""Create a mock websocket connection."""
mock_ws = AsyncMock()
mock_ws.send = AsyncMock()
mock_ws.close = AsyncMock()
return mock_ws
@pytest.mark.asyncio
async def test_tracing_config_storage_and_defaults(self, model, mock_websocket):
"""Test that tracing config is stored correctly and defaults to 'auto'."""
# Test with explicit tracing config
config_with_tracing = {
"api_key": "test-key",
"initial_model_settings": {
"tracing": {
"workflow_name": "test_workflow",
"group_id": "group_123",
"metadata": {"version": "1.0"},
}
},
}
async def async_websocket(*args, **kwargs):
return mock_websocket
with patch("websockets.connect", side_effect=async_websocket):
with patch("asyncio.create_task") as mock_create_task:
mock_task = AsyncMock()
mock_create_task.return_value = mock_task
mock_create_task.side_effect = lambda coro: (coro.close(), mock_task)[1]
await model.connect(config_with_tracing)
# Should store the tracing config
assert model._tracing_config == {
"workflow_name": "test_workflow",
"group_id": "group_123",
"metadata": {"version": "1.0"},
}
# Test without tracing config - should default to "auto"
model2 = OpenAIRealtimeWebSocketModel()
config_no_tracing = {
"api_key": "test-key",
"initial_model_settings": {},
}
with patch("websockets.connect", side_effect=async_websocket):
with patch("asyncio.create_task") as mock_create_task:
mock_create_task.side_effect = lambda coro: (coro.close(), mock_task)[1]
await model2.connect(config_no_tracing) # type: ignore[arg-type]
assert model2._tracing_config == "auto"
@pytest.mark.asyncio
async def test_send_tracing_config_on_session_created(self, model, mock_websocket):
"""Test that tracing config is sent when session.created event is received."""
config = {
"api_key": "test-key",
"initial_model_settings": {
"tracing": {"workflow_name": "test_workflow", "group_id": "group_123"}
},
}
async def async_websocket(*args, **kwargs):
return mock_websocket
with patch("websockets.connect", side_effect=async_websocket):
with patch("asyncio.create_task") as mock_create_task:
mock_task = AsyncMock()
mock_create_task.side_effect = lambda coro: (coro.close(), mock_task)[1]
await model.connect(config)
# Simulate session.created event
session_created_event = {
"type": "session.created",
"event_id": "event_123",
"session": {
"id": "session_456",
"type": "realtime",
"model": "gpt-realtime-2.1",
},
}
with patch.object(model, "_send_raw_message") as mock_send_raw_message:
await model._handle_ws_event(session_created_event)
# Should send session.update with tracing config
from openai.types.realtime.session_update_event import (
SessionUpdateEvent,
)
mock_send_raw_message.assert_called_once()
call_args = mock_send_raw_message.call_args[0][0]
assert isinstance(call_args, SessionUpdateEvent)
assert call_args.type == "session.update"
session_req = cast(RealtimeSessionCreateRequest, call_args.session)
assert isinstance(session_req.tracing, TracingConfiguration)
assert session_req.tracing.workflow_name == "test_workflow"
assert session_req.tracing.group_id == "group_123"
@pytest.mark.asyncio
async def test_send_tracing_config_auto_mode(self, model, mock_websocket):
"""Test that 'auto' tracing config is sent correctly."""
config = {
"api_key": "test-key",
"initial_model_settings": {}, # No tracing config - defaults to "auto"
}
async def async_websocket(*args, **kwargs):
return mock_websocket
with patch("websockets.connect", side_effect=async_websocket):
with patch("asyncio.create_task") as mock_create_task:
mock_task = AsyncMock()
mock_create_task.side_effect = lambda coro: (coro.close(), mock_task)[1]
await model.connect(config)
session_created_event = {
"type": "session.created",
"event_id": "event_123",
"session": {
"id": "session_456",
"type": "realtime",
"model": "gpt-realtime-2.1",
},
}
with patch.object(model, "_send_raw_message") as mock_send_raw_message:
await model._handle_ws_event(session_created_event)
# Should send session.update with "auto"
from openai.types.realtime.session_update_event import SessionUpdateEvent
mock_send_raw_message.assert_called_once()
call_args = mock_send_raw_message.call_args[0][0]
assert isinstance(call_args, SessionUpdateEvent)
assert call_args.type == "session.update"
session_req = cast(RealtimeSessionCreateRequest, call_args.session)
assert session_req.tracing == "auto"
@pytest.mark.asyncio
async def test_tracing_config_none_skips_session_update(self, model, mock_websocket):
"""Test that None tracing config skips sending session.update."""
# Manually set tracing config to None (this would happen if explicitly set)
model._tracing_config = None
session_created_event = {
"type": "session.created",
"event_id": "event_123",
"session": {"id": "session_456", "type": "realtime", "model": "gpt-realtime-2.1"},
}
with patch.object(model, "send_event") as mock_send_event:
await model._handle_ws_event(session_created_event)
# Should not send any session.update
mock_send_event.assert_not_called()
@pytest.mark.asyncio
async def test_tracing_config_with_metadata_serialization(self, model, mock_websocket):
"""Test that complex metadata in tracing config is handled correctly."""
complex_metadata = {
"user_id": "user_123",
"session_type": "demo",
"features": ["audio", "tools"],
"config": {"timeout": 30, "retries": 3},
}
config = {
"api_key": "test-key",
"initial_model_settings": {
"tracing": {"workflow_name": "complex_workflow", "metadata": complex_metadata}
},
}
async def async_websocket(*args, **kwargs):
return mock_websocket
with patch("websockets.connect", side_effect=async_websocket):
with patch("asyncio.create_task") as mock_create_task:
mock_task = AsyncMock()
mock_create_task.side_effect = lambda coro: (coro.close(), mock_task)[1]
await model.connect(config)
session_created_event = {
"type": "session.created",
"event_id": "event_123",
"session": {
"id": "session_456",
"type": "realtime",
"model": "gpt-realtime-2.1",
},
}
with patch.object(model, "_send_raw_message") as mock_send_raw_message:
await model._handle_ws_event(session_created_event)
# Should send session.update with complete tracing config including metadata
from openai.types.realtime.session_update_event import (
SessionUpdateEvent,
)
mock_send_raw_message.assert_called_once()
call_args = mock_send_raw_message.call_args[0][0]
assert isinstance(call_args, SessionUpdateEvent)
assert call_args.type == "session.update"
session_req = cast(RealtimeSessionCreateRequest, call_args.session)
assert isinstance(session_req.tracing, TracingConfiguration)
assert session_req.tracing.workflow_name == "complex_workflow"
assert session_req.tracing.metadata == complex_metadata
@pytest.mark.asyncio
async def test_tracing_disabled_prevents_tracing(self, mock_websocket):
"""Test that tracing_disabled=True prevents tracing configuration."""
# Create a test agent and mock model
agent = RealtimeAgent(name="test_agent", instructions="test")
agent.handoffs = []
mock_model = Mock(spec=RealtimeModel)
# Create session with tracing disabled
session = RealtimeSession(
model=mock_model,
agent=agent,
context=None,
model_config=None,
run_config={"tracing_disabled": True},
)
# Test the _get_updated_model_settings_from_agent method directly
model_settings = await session._get_updated_model_settings_from_agent(
starting_settings=None, agent=agent
)
# When tracing is disabled, model settings should have tracing=None
assert model_settings["tracing"] is None
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
import importlib
from types import ModuleType
from unittest.mock import AsyncMock, Mock
import pytest
#
# This is a unit test for examples/realtime/twilio_sip/server.py
# If this is no longer relevant in the future, we can remove it.
#
@pytest.fixture
def twilio_server(monkeypatch: pytest.MonkeyPatch) -> ModuleType:
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("OPENAI_WEBHOOK_SECRET", "secret")
module = importlib.import_module("examples.realtime.twilio_sip.server")
module = importlib.reload(module)
monkeypatch.setattr(module, "active_call_tasks", {})
return module
@pytest.mark.asyncio
async def test_track_call_task_ignores_duplicate_webhooks(
monkeypatch: pytest.MonkeyPatch, twilio_server: ModuleType
) -> None:
call_id = "call-123"
existing_task = Mock()
existing_task.done.return_value = False
existing_task.cancel = Mock()
monkeypatch.setitem(twilio_server.active_call_tasks, call_id, existing_task)
create_task_mock = Mock()
def fake_create_task(coro):
coro.close()
return create_task_mock.return_value
monkeypatch.setattr(twilio_server.asyncio, "create_task", fake_create_task)
twilio_server._track_call_task(call_id)
existing_task.cancel.assert_not_called()
create_task_mock.assert_not_called()
assert twilio_server.active_call_tasks[call_id] is existing_task
@pytest.mark.asyncio
async def test_track_call_task_restarts_after_completion(
monkeypatch: pytest.MonkeyPatch, twilio_server: ModuleType
) -> None:
call_id = "call-456"
existing_task = Mock()
existing_task.done.return_value = True
existing_task.cancel = Mock()
monkeypatch.setitem(twilio_server.active_call_tasks, call_id, existing_task)
new_task = AsyncMock()
create_task_mock = Mock(return_value=new_task)
def fake_create_task(coro):
coro.close()
return create_task_mock(coro)
monkeypatch.setattr(twilio_server.asyncio, "create_task", fake_create_task)
twilio_server._track_call_task(call_id)
existing_task.cancel.assert_not_called()
create_task_mock.assert_called_once()
assert twilio_server.active_call_tasks[call_id] is new_task