chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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": {}})
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user