diff --git a/examples/realtime/demo.py b/examples/realtime/demo.py index 61834a4e5..7b98850b2 100644 --- a/examples/realtime/demo.py +++ b/examples/realtime/demo.py @@ -58,55 +58,45 @@ async def run(self) -> None: self.session = session self.ui.set_is_connected(True) async for event in session: - await self.on_event(event) + await self._on_event(event) + print("done") # Wait for UI task to complete when session ends await ui_task async def on_audio_recorded(self, audio_bytes: bytes) -> None: - """Called when audio is recorded by the UI.""" - try: - # Send the audio to the session - assert self.session is not None - await self.session.send_audio(audio_bytes) - except Exception as e: - self.ui.log_message(f"Error sending audio: {e}") - - async def on_event(self, event: RealtimeSessionEvent) -> None: - # Display event in the UI - try: - if event.type == "agent_start": - self.ui.add_transcript(f"Agent started: {event.agent.name}") - elif event.type == "agent_end": - self.ui.add_transcript(f"Agent ended: {event.agent.name}") - elif event.type == "handoff": - self.ui.add_transcript( - f"Handoff from {event.from_agent.name} to {event.to_agent.name}" - ) - elif event.type == "tool_start": - self.ui.add_transcript(f"Tool started: {event.tool.name}") - elif event.type == "tool_end": - self.ui.add_transcript(f"Tool ended: {event.tool.name}; output: {event.output}") - elif event.type == "audio_end": - self.ui.add_transcript("Audio ended") - elif event.type == "audio": - np_audio = np.frombuffer(event.audio.data, dtype=np.int16) - self.ui.play_audio(np_audio) - elif event.type == "audio_interrupted": - self.ui.add_transcript("Audio interrupted") - elif event.type == "error": - self.ui.add_transcript(f"Error: {event.error}") - elif event.type == "history_updated": - pass - elif event.type == "history_added": - pass - elif event.type == "raw_model_event": - self.ui.log_message(f"Raw model event: {event.data}") - else: - self.ui.log_message(f"Unknown event type: {event.type}") - except Exception as e: - # This can happen if the UI has already exited - self.ui.log_message(f"Event handling error: {str(e)}") + # Send the audio to the session + assert self.session is not None + await self.session.send_audio(audio_bytes) + + async def _on_event(self, event: RealtimeSessionEvent) -> None: + if event.type == "agent_start": + self.ui.add_transcript(f"Agent started: {event.agent.name}") + elif event.type == "agent_end": + self.ui.add_transcript(f"Agent ended: {event.agent.name}") + elif event.type == "handoff": + self.ui.add_transcript(f"Handoff from {event.from_agent.name} to {event.to_agent.name}") + elif event.type == "tool_start": + self.ui.add_transcript(f"Tool started: {event.tool.name}") + elif event.type == "tool_end": + self.ui.add_transcript(f"Tool ended: {event.tool.name}; output: {event.output}") + elif event.type == "audio_end": + self.ui.add_transcript("Audio ended") + elif event.type == "audio": + np_audio = np.frombuffer(event.audio.data, dtype=np.int16) + self.ui.play_audio(np_audio) + elif event.type == "audio_interrupted": + self.ui.add_transcript("Audio interrupted") + elif event.type == "error": + self.ui.add_transcript(f"Error: {event.error}") + elif event.type == "history_updated": + pass + elif event.type == "history_added": + pass + elif event.type == "raw_model_event": + self.ui.log_message(f"Raw model event: {event.data}") + else: + self.ui.log_message(f"Unknown event type: {event.type}") if __name__ == "__main__": diff --git a/examples/realtime/ui.py b/examples/realtime/ui.py index 1ba055835..51a1fed41 100644 --- a/examples/realtime/ui.py +++ b/examples/realtime/ui.py @@ -239,10 +239,7 @@ async def capture_audio(self) -> None: # Call audio callback if set if self.audio_callback: - try: - await self.audio_callback(audio_bytes) - except Exception as e: - self.log_message(f"Audio callback error: {e}") + await self.audio_callback(audio_bytes) # Yield control back to event loop await asyncio.sleep(0) diff --git a/src/agents/realtime/model_inputs.py b/src/agents/realtime/model_inputs.py index eb8e8220d..df09e6697 100644 --- a/src/agents/realtime/model_inputs.py +++ b/src/agents/realtime/model_inputs.py @@ -5,6 +5,7 @@ from typing_extensions import NotRequired, TypeAlias, TypedDict +from .config import RealtimeSessionModelSettings from .model_events import RealtimeModelToolCallEvent @@ -81,10 +82,19 @@ class RealtimeModelSendInterrupt: """Send an interrupt to the model.""" +@dataclass +class RealtimeModelSendSessionUpdate: + """Send a session update to the model.""" + + session_settings: RealtimeSessionModelSettings + """The updated session settings to send.""" + + RealtimeModelSendEvent: TypeAlias = Union[ RealtimeModelSendRawMessage, RealtimeModelSendUserInput, RealtimeModelSendAudio, RealtimeModelSendToolOutput, RealtimeModelSendInterrupt, + RealtimeModelSendSessionUpdate, ] diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index b73ca8503..b79227d2e 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -8,16 +8,22 @@ from datetime import datetime from typing import Any, Callable, Literal +import pydantic import websockets from openai.types.beta.realtime.conversation_item import ConversationItem from openai.types.beta.realtime.realtime_server_event import ( RealtimeServerEvent as OpenAIRealtimeServerEvent, ) from openai.types.beta.realtime.response_audio_delta_event import ResponseAudioDeltaEvent +from openai.types.beta.realtime.session_update_event import ( + Session as OpenAISessionObject, + SessionTool as OpenAISessionTool, +) from pydantic import TypeAdapter from typing_extensions import assert_never from websockets.asyncio.client import ClientConnection +from agents.tool import FunctionTool, Tool from agents.util._types import MaybeAwaitable from ..exceptions import UserError @@ -52,10 +58,22 @@ RealtimeModelSendEvent, RealtimeModelSendInterrupt, RealtimeModelSendRawMessage, + RealtimeModelSendSessionUpdate, RealtimeModelSendToolOutput, RealtimeModelSendUserInput, ) +DEFAULT_MODEL_SETTINGS: RealtimeSessionModelSettings = { + "voice": "ash", + "modalities": ["text", "audio"], + "input_audio_format": "pcm16", + "output_audio_format": "pcm16", + "input_audio_transcription": { + "model": "gpt-4o-mini-transcribe", + }, + "turn_detection": {"type": "semantic_vad"}, +} + async def get_api_key(key: str | Callable[[], MaybeAwaitable[str]] | None) -> str | None: if isinstance(key, str): @@ -110,6 +128,7 @@ async def connect(self, options: RealtimeModelConfig) -> None: } self._websocket = await websockets.connect(url, additional_headers=headers) self._websocket_task = asyncio.create_task(self._listen_for_messages()) + await self._update_session_config(model_settings) async def _send_tracing_config( self, tracing_config: RealtimeModelTracingConfig | Literal["auto"] | None @@ -127,11 +146,13 @@ async def _send_tracing_config( def add_listener(self, listener: RealtimeModelListener) -> None: """Add a listener to the model.""" - self._listeners.append(listener) + if listener not in self._listeners: + self._listeners.append(listener) def remove_listener(self, listener: RealtimeModelListener) -> None: """Remove a listener from the model.""" - self._listeners.remove(listener) + if listener in self._listeners: + self._listeners.remove(listener) async def _emit_event(self, event: RealtimeModelEvent) -> None: """Emit an event to the listeners.""" @@ -187,6 +208,8 @@ async def send_event(self, event: RealtimeModelSendEvent) -> None: await self._send_tool_output(event) elif isinstance(event, RealtimeModelSendInterrupt): await self._send_interrupt(event) + elif isinstance(event, RealtimeModelSendSessionUpdate): + await self._send_session_update(event) else: assert_never(event) raise ValueError(f"Unknown event type: {type(event)}") @@ -195,78 +218,55 @@ async def _send_raw_message(self, event: RealtimeModelSendRawMessage) -> None: """Send a raw message to the model.""" assert self._websocket is not None, "Not connected" - try: - converted_event = { - "type": event.message["type"], - } + converted_event = { + "type": event.message["type"], + } - converted_event.update(event.message.get("other_data", {})) + converted_event.update(event.message.get("other_data", {})) - await self._websocket.send(json.dumps(converted_event)) - except Exception as e: - await self._emit_event( - RealtimeModelExceptionEvent( - exception=e, - context=f"Failed to send event: {event.message.get('type', 'unknown')}", - ) - ) + await self._websocket.send(json.dumps(converted_event)) async def _send_user_input(self, event: RealtimeModelSendUserInput) -> None: - """Send a user input to the model.""" - try: - message = ( - event.user_input - if isinstance(event.user_input, dict) - else { - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": event.user_input}], - } - ) - other_data = { - "item": message, + message = ( + event.user_input + if isinstance(event.user_input, dict) + else { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": event.user_input}], } + ) + other_data = { + "item": message, + } - await self._send_raw_message( - RealtimeModelSendRawMessage( - message={"type": "conversation.item.create", "other_data": other_data} - ) - ) - await self._send_raw_message( - RealtimeModelSendRawMessage(message={"type": "response.create"}) - ) - except Exception as e: - await self._emit_event( - RealtimeModelExceptionEvent(exception=e, context="Failed to send message") + await self._send_raw_message( + RealtimeModelSendRawMessage( + message={"type": "conversation.item.create", "other_data": other_data} ) + ) + await self._send_raw_message( + RealtimeModelSendRawMessage(message={"type": "response.create"}) + ) async def _send_audio(self, event: RealtimeModelSendAudio) -> None: - """Send audio to the model.""" - assert self._websocket is not None, "Not connected" - - try: - base64_audio = base64.b64encode(event.audio).decode("utf-8") - await self._send_raw_message( - RealtimeModelSendRawMessage( - message={ - "type": "input_audio_buffer.append", - "other_data": { - "audio": base64_audio, - }, - } - ) + base64_audio = base64.b64encode(event.audio).decode("utf-8") + await self._send_raw_message( + RealtimeModelSendRawMessage( + message={ + "type": "input_audio_buffer.append", + "other_data": { + "audio": base64_audio, + }, + } ) - if event.commit: - await self._send_raw_message( - RealtimeModelSendRawMessage(message={"type": "input_audio_buffer.commit"}) - ) - except Exception as e: - await self._emit_event( - RealtimeModelExceptionEvent(exception=e, context="Failed to send audio") + ) + if event.commit: + await self._send_raw_message( + RealtimeModelSendRawMessage(message={"type": "input_audio_buffer.commit"}) ) async def _send_tool_output(self, event: RealtimeModelSendToolOutput) -> None: - """Send tool output to the model.""" await self._send_raw_message( RealtimeModelSendRawMessage( message={ @@ -299,7 +299,6 @@ async def _send_tool_output(self, event: RealtimeModelSendToolOutput) -> None: ) async def _send_interrupt(self, event: RealtimeModelSendInterrupt) -> None: - """Send an interrupt to the model.""" if not self._current_item_id or not self._audio_start_time: return @@ -326,6 +325,10 @@ async def _send_interrupt(self, event: RealtimeModelSendInterrupt) -> None: self._audio_length_ms = 0.0 self._current_audio_content_index = None + async def _send_session_update(self, event: RealtimeModelSendSessionUpdate) -> None: + """Send a session update to the model.""" + await self._update_session_config(event.session_settings) + async def _handle_audio_delta(self, parsed: ResponseAudioDeltaEvent) -> None: """Handle audio delta events and update audio tracking state.""" self._current_audio_content_index = parsed.content_index @@ -418,8 +421,17 @@ async def _handle_ws_event(self, event: dict[str, Any]): parsed: OpenAIRealtimeServerEvent = TypeAdapter( OpenAIRealtimeServerEvent ).validate_python(event) + except pydantic.ValidationError as e: + logger.error(f"Failed to validate server event: {event}", exc_info=True) + await self._emit_event( + RealtimeModelErrorEvent( + error=e, + ) + ) + return except Exception as e: event_type = event.get("type", "unknown") if isinstance(event, dict) else "unknown" + logger.error(f"Failed to validate server event: {event}", exc_info=True) await self._emit_event( RealtimeModelExceptionEvent( exception=e, @@ -492,3 +504,66 @@ async def _handle_ws_event(self, event: dict[str, Any]): or parsed.type == "response.output_item.done" ): await self._handle_output_item(parsed.item) + + async def _update_session_config(self, model_settings: RealtimeSessionModelSettings) -> None: + session_config = self._get_session_config(model_settings) + await self._send_raw_message( + RealtimeModelSendRawMessage( + message={ + "type": "session.update", + "other_data": { + "session": session_config.model_dump(exclude_unset=True, exclude_none=True) + }, + } + ) + ) + + def _get_session_config( + self, model_settings: RealtimeSessionModelSettings + ) -> OpenAISessionObject: + """Get the session config.""" + return OpenAISessionObject( + instructions=model_settings.get("instructions", None), + model=( + model_settings.get("model_name", self.model) # type: ignore + or DEFAULT_MODEL_SETTINGS.get("model_name") + ), + voice=model_settings.get("voice", DEFAULT_MODEL_SETTINGS.get("voice")), + modalities=model_settings.get("modalities", DEFAULT_MODEL_SETTINGS.get("modalities")), + input_audio_format=model_settings.get( + "input_audio_format", + DEFAULT_MODEL_SETTINGS.get("input_audio_format"), # type: ignore + ), + output_audio_format=model_settings.get( + "output_audio_format", + DEFAULT_MODEL_SETTINGS.get("output_audio_format"), # type: ignore + ), + input_audio_transcription=model_settings.get( + "input_audio_transcription", + DEFAULT_MODEL_SETTINGS.get("input_audio_transcription"), # type: ignore + ), + turn_detection=model_settings.get( + "turn_detection", + DEFAULT_MODEL_SETTINGS.get("turn_detection"), # type: ignore + ), + tool_choice=model_settings.get( + "tool_choice", + DEFAULT_MODEL_SETTINGS.get("tool_choice"), # type: ignore + ), + tools=self._tools_to_session_tools(model_settings.get("tools", [])), + ) + + def _tools_to_session_tools(self, tools: list[Tool]) -> list[OpenAISessionTool]: + converted_tools: list[OpenAISessionTool] = [] + for tool in tools: + if not isinstance(tool, FunctionTool): + raise UserError(f"Tool {tool.name} is unsupported. Must be a function tool.") + converted_tools.append( + OpenAISessionTool( + name=tool.name, + description=tool.description, + parameters=tool.params_json_schema, + type="function", + ) + ) + return converted_tools diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py index 04ce09bea..57c58ffb7 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -7,12 +7,13 @@ from typing_extensions import assert_never from ..agent import Agent +from ..exceptions import ModelBehaviorError, UserError from ..handoffs import Handoff from ..run_context import RunContextWrapper, TContext from ..tool import FunctionTool from ..tool_context import ToolContext from .agent import RealtimeAgent -from .config import RealtimeRunConfig, RealtimeUserInput +from .config import RealtimeRunConfig, RealtimeSessionModelSettings, RealtimeUserInput from .events import ( RealtimeAgentEndEvent, RealtimeAgentStartEvent, @@ -22,6 +23,7 @@ RealtimeError, RealtimeEventInfo, RealtimeGuardrailTripped, + RealtimeHandoffEvent, RealtimeHistoryAdded, RealtimeHistoryUpdated, RealtimeRawModelEvent, @@ -39,6 +41,7 @@ from .model_inputs import ( RealtimeModelSendAudio, RealtimeModelSendInterrupt, + RealtimeModelSendSessionUpdate, RealtimeModelSendToolOutput, RealtimeModelSendUserInput, ) @@ -269,9 +272,11 @@ async def _handle_tool_call(self, event: RealtimeModelToolCallEvent) -> None: tool_context = ToolContext.from_agent_context(self._context_wrapper, event.call_id) result = await func_tool.on_invoke_tool(tool_context, event.arguments) - await self._model.send_event(RealtimeModelSendToolOutput( - tool_call=event, output=str(result), start_response=True - )) + await self._model.send_event( + RealtimeModelSendToolOutput( + tool_call=event, output=str(result), start_response=True + ) + ) await self._put_event( RealtimeToolEnd( @@ -282,11 +287,47 @@ async def _handle_tool_call(self, event: RealtimeModelToolCallEvent) -> None: ) ) elif event.name in handoff_map: - # TODO (rm) Add support for handoffs - pass + handoff = handoff_map[event.name] + tool_context = ToolContext.from_agent_context(self._context_wrapper, event.call_id) + + # Execute the handoff to get the new agent + result = await handoff.on_invoke_handoff(self._context_wrapper, event.arguments) + if not isinstance(result, RealtimeAgent): + raise UserError(f"Handoff {handoff.name} returned invalid result: {type(result)}") + + # Store previous agent for event + previous_agent = self._current_agent + + # Update current agent + self._current_agent = result + + # Get updated model settings from new agent + updated_settings = await self._get__updated_model_settings(self._current_agent) + + # Send handoff event + await self._put_event( + RealtimeHandoffEvent( + from_agent=previous_agent, + to_agent=self._current_agent, + info=self._event_info, + ) + ) + + # Send tool output to complete the handoff + await self._model.send_event( + RealtimeModelSendToolOutput( + tool_call=event, + output=f"Handed off to {self._current_agent.name}", + start_response=True, + ) + ) + + # Send session update to model + await self._model.send_event( + RealtimeModelSendSessionUpdate(session_settings=updated_settings) + ) else: - # TODO (rm) Add error handling - pass + raise ModelBehaviorError(f"Tool {event.name} not found") @classmethod def _get_new_history( @@ -379,9 +420,11 @@ async def _run_output_guardrails(self, text: str) -> bool: # Send guardrail triggered message guardrail_names = [result.guardrail.get_name() for result in triggered_results] - await self._model.send_event(RealtimeModelSendUserInput( - user_input=f"guardrail triggered: {', '.join(guardrail_names)}" - )) + await self._model.send_event( + RealtimeModelSendUserInput( + user_input=f"guardrail triggered: {', '.join(guardrail_names)}" + ) + ) return True @@ -434,3 +477,16 @@ async def _cleanup(self) -> None: # Mark as closed self._closed = True + + async def _get__updated_model_settings( + self, new_agent: RealtimeAgent + ) -> RealtimeSessionModelSettings: + updated_settings: RealtimeSessionModelSettings = {} + instructions, tools = await asyncio.gather( + new_agent.get_system_prompt(self._context_wrapper), + new_agent.get_all_tools(self._context_wrapper), + ) + updated_settings["instructions"] = instructions or "" + updated_settings["tools"] = tools or [] + + return updated_settings diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index 7cd9a5c30..9ecc433ca 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -174,22 +174,21 @@ class TestEventHandlingRobustness(TestOpenAIRealtimeWebSocketModel): @pytest.mark.asyncio async def test_handle_malformed_json_logs_error_continues(self, model): - """Test that malformed JSON emits exception event but doesn't crash.""" + """Test that malformed JSON emits error event but doesn't crash.""" mock_listener = AsyncMock() model.add_listener(mock_listener) # Malformed JSON should not crash the handler await model._handle_ws_event("invalid json {") - # Should emit exception event to listeners + # Should emit error event to listeners mock_listener.on_event.assert_called_once() - exception_event = mock_listener.on_event.call_args[0][0] - assert exception_event.type == "exception" - assert "Failed to validate server event: unknown" in exception_event.context + error_event = mock_listener.on_event.call_args[0][0] + assert error_event.type == "error" @pytest.mark.asyncio async def test_handle_invalid_event_schema_logs_error(self, model): - """Test that events with invalid schema emit exception events but don't crash.""" + """Test that events with invalid schema emit error events but don't crash.""" mock_listener = AsyncMock() model.add_listener(mock_listener) @@ -197,11 +196,10 @@ async def test_handle_invalid_event_schema_logs_error(self, model): await model._handle_ws_event(invalid_event) - # Should emit exception event to listeners + # Should emit error event to listeners mock_listener.on_event.assert_called_once() - exception_event = mock_listener.on_event.call_args[0][0] - assert exception_event.type == "exception" - assert "Failed to validate server event: response.audio.delta" in exception_event.context + error_event = mock_listener.on_event.call_args[0][0] + assert error_event.type == "error" @pytest.mark.asyncio async def test_handle_unknown_event_type_ignored(self, model): diff --git a/tests/realtime/test_session.py b/tests/realtime/test_session.py index 4cee9e537..b903ee951 100644 --- a/tests/realtime/test_session.py +++ b/tests/realtime/test_session.py @@ -794,8 +794,23 @@ async def test_function_tool_with_multiple_tools_available(self, mock_model, moc assert sent_output == "result_two" @pytest.mark.asyncio - async def test_handoff_tool_handling_todo(self, mock_model, mock_agent, mock_handoff): - """Test that handoff tools are recognized but not yet implemented""" + async def test_handoff_tool_handling(self, mock_model, mock_agent, mock_handoff): + """Test that handoff tools are properly handled""" + from unittest.mock import AsyncMock + + from agents.realtime.agent import RealtimeAgent + + # Create a mock new agent to be returned by handoff + mock_new_agent = Mock(spec=RealtimeAgent) + mock_new_agent.name = "new_agent" + mock_new_agent.instructions = "New agent instructions" + mock_new_agent.get_all_tools = AsyncMock(return_value=[]) + mock_new_agent.get_system_prompt = AsyncMock(return_value="New agent system prompt") + + # Set up handoff to return the new agent + mock_handoff.on_invoke_handoff = AsyncMock(return_value=mock_new_agent) + mock_handoff.name = "test_handoff" + # Set up agent to return handoff tool mock_agent.get_all_tools.return_value = [mock_handoff] @@ -807,15 +822,22 @@ async def test_handoff_tool_handling_todo(self, mock_model, mock_agent, mock_han await session._handle_tool_call(tool_call_event) - # Should not have sent any tool outputs (handoffs not implemented) - assert len(mock_model.sent_tool_outputs) == 0 + # Should have sent session update and tool output + assert len(mock_model.sent_events) >= 2 + + # Should have sent handoff event + assert session._event_queue.qsize() >= 1 - # Should not have queued any events (handoffs not implemented) - assert session._event_queue.qsize() == 0 + # Verify agent was updated + assert session._current_agent == mock_new_agent @pytest.mark.asyncio - async def test_unknown_tool_handling_todo(self, mock_model, mock_agent, mock_function_tool): - """Test that unknown tools are handled gracefully (TODO: add error handling)""" + async def test_unknown_tool_handling(self, mock_model, mock_agent, mock_function_tool): + """Test that unknown tools raise an error""" + import pytest + + from agents.exceptions import ModelBehaviorError + # Set up agent to return different tool than what's called mock_function_tool.name = "known_tool" mock_agent.get_all_tools.return_value = [mock_function_tool] @@ -827,17 +849,13 @@ async def test_unknown_tool_handling_todo(self, mock_model, mock_agent, mock_fun name="unknown_tool", call_id="call_unknown", arguments="{}" ) - await session._handle_tool_call(tool_call_event) + # Should raise an error for unknown tool + with pytest.raises(ModelBehaviorError, match="Tool unknown_tool not found"): + await session._handle_tool_call(tool_call_event) # Should not have called any tools mock_function_tool.on_invoke_tool.assert_not_called() - # Should not have sent any tool outputs - assert len(mock_model.sent_tool_outputs) == 0 - - # Should not have queued any events - assert session._event_queue.qsize() == 0 - @pytest.mark.asyncio async def test_function_tool_exception_handling( self, mock_model, mock_agent, mock_function_tool diff --git a/tests/realtime/test_tracing.py b/tests/realtime/test_tracing.py index 3548829dd..4cff46c49 100644 --- a/tests/realtime/test_tracing.py +++ b/tests/realtime/test_tracing.py @@ -100,6 +100,7 @@ async def async_websocket(*args, **kwargs): # Should send session.update with tracing config from agents.realtime.model_inputs import RealtimeModelSendRawMessage + expected_event = RealtimeModelSendRawMessage( message={ "type": "session.update", @@ -144,6 +145,7 @@ async def async_websocket(*args, **kwargs): # Should send session.update with "auto" from agents.realtime.model_inputs import RealtimeModelSendRawMessage + expected_event = RealtimeModelSendRawMessage( message={ "type": "session.update", @@ -208,6 +210,7 @@ async def async_websocket(*args, **kwargs): # Should send session.update with complete tracing config including metadata from agents.realtime.model_inputs import RealtimeModelSendRawMessage + expected_event = RealtimeModelSendRawMessage( message={ "type": "session.update",