diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index 02cb55a1a6..ccdf04e8de 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -2011,7 +2011,7 @@ def conversation_item_to_realtime_message_item( "type": item.type, "role": item.role, "content": content, - "status": "in_progress", + "status": item.status or "in_progress", }, ) diff --git a/tests/realtime/test_item_parsing.py b/tests/realtime/test_item_parsing.py index e8484a58f6..60168bb3a7 100644 --- a/tests/realtime/test_item_parsing.py +++ b/tests/realtime/test_item_parsing.py @@ -78,3 +78,36 @@ def test_system_message_conversion() -> None: ) assert isinstance(converted, SystemMessageItem) + + +def test_message_status_is_preserved() -> None: + item = RealtimeConversationItemAssistantMessage( + id="123", + type="message", + role="assistant", + status="completed", + content=[AssistantMessageContent(type="output_text", text="hello")], + ) + + converted: RealtimeMessageItem = _ConversionHelper.conversation_item_to_realtime_message_item( + item, None + ) + + assert isinstance(converted, AssistantMessageItem) + assert converted.status == "completed" + + +def test_message_status_defaults_to_in_progress_when_absent() -> None: + item = RealtimeConversationItemUserMessage( + id="123", + type="message", + role="user", + content=[UserMessageContent(type="input_text", text="hello")], + ) + + converted: RealtimeMessageItem = _ConversionHelper.conversation_item_to_realtime_message_item( + item, None + ) + + assert isinstance(converted, UserMessageItem) + assert converted.status == "in_progress" diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index eaf532bee9..7d733a192c 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -15,12 +15,14 @@ from agents.exceptions import UserError from agents.handoffs import handoff from agents.realtime import RealtimeAgent, RealtimeSession, RealtimeSessionModelSettings +from agents.realtime.items import AssistantAudio, AssistantMessageItem from agents.realtime.model import RealtimeModelConfig, RealtimePlaybackTracker from agents.realtime.model_events import ( RealtimeModelAudioEvent, RealtimeModelAudioInterruptedEvent, RealtimeModelConnectionStatusEvent, RealtimeModelErrorEvent, + RealtimeModelItemUpdatedEvent, RealtimeModelOutputTextDeltaEvent, RealtimeModelRawServerEvent, RealtimeModelToolCallEvent, @@ -861,6 +863,120 @@ def validate_python(self, event): emitted = [call.args[0] for call in mock_listener.on_event.call_args_list] assert [event.type for event in emitted] == ["raw_server_event", "turn_ended"] + @pytest.mark.asyncio + async def test_retrieved_completed_item_keeps_status(self, model, monkeypatch): + """An item the server reports as completed must not flip back to in_progress. + + After assistant audio plays, the SDK retrieves that item when the user's next + input transcription completes. The retrieved payload carries the item's real + status, and discarding it would regress history entries every turn. + """ + send_raw = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + mock_listener = AsyncMock() + model.add_listener(mock_listener) + + await model._handle_ws_event( + { + "type": "response.output_audio.delta", + "event_id": "event_1", + "response_id": "resp_1", + "item_id": "item_1", + "output_index": 0, + "content_index": 0, + "delta": "dGVzdCBhdWRpbw==", + } + ) + await model._handle_ws_event( + { + "type": "conversation.item.input_audio_transcription.completed", + "event_id": "event_2", + "item_id": "item_user", + "content_index": 0, + "transcript": "hello", + "usage": { + "type": "tokens", + "input_tokens": 1, + "output_tokens": 1, + "total_tokens": 2, + }, + } + ) + + assert send_raw.await_count == 1 + retrieve_call = send_raw.await_args + assert retrieve_call is not None + retrieve_event = retrieve_call.args[0] + assert retrieve_event.type == "conversation.item.retrieve" + assert retrieve_event.item_id == "item_1" + + await model._handle_ws_event( + { + "type": "conversation.item.retrieved", + "event_id": "event_3", + "item": { + "id": "item_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_audio", "transcript": "hi there"}], + }, + } + ) + + item_updated_events = [ + call.args[0] + for call in mock_listener.on_event.call_args_list + if isinstance(call.args[0], RealtimeModelItemUpdatedEvent) + ] + assert item_updated_events, "a retrieved conversation item should update listeners" + assert item_updated_events[-1].item.status == "completed" + + @pytest.mark.asyncio + async def test_retrieved_completed_item_keeps_status_in_session_history( + self, model, monkeypatch + ): + """A retrieved item must not regress a known terminal item in session history. + + After assistant audio plays, the SDK retrieves that item when the user's next + input transcription completes. The retrieved payload reports the item's real + status but may omit the transcript, so the session must reconcile it into + history without losing either the terminal status or the stored transcript. + """ + send_raw = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + session = RealtimeSession(model, RealtimeAgent(name="agent"), None) + model.add_listener(session) + + session._history = [ + AssistantMessageItem( + item_id="item_1", + role="assistant", + status="completed", + content=[AssistantAudio(audio=None, transcript="hi there")], + ) + ] + + await model._handle_ws_event( + { + "type": "conversation.item.retrieved", + "event_id": "event_1", + "item": { + "id": "item_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_audio"}], + }, + } + ) + + assert len(session._history) == 1 + stored = cast(AssistantMessageItem, session._history[0]) + assert stored.status == "completed" + assert isinstance(stored.content[0], AssistantAudio) + assert stored.content[0].transcript == "hi there" + @pytest.mark.asyncio async def test_handle_unknown_event_type_ignored(self, model): """Test that unknown event types are ignored gracefully."""