|
7 | 7 |
|
8 | 8 | import pytest |
9 | 9 | import websockets |
| 10 | +from pydantic import TypeAdapter |
10 | 11 |
|
11 | 12 | from agents import Agent, function_tool |
12 | 13 | from agents.exceptions import UserError |
@@ -445,6 +446,80 @@ async def test_handle_invalid_event_schema_logs_error(self, model): |
445 | 446 | error_event = mock_listener.on_event.call_args_list[1][0][0] |
446 | 447 | assert error_event.type == "error" |
447 | 448 |
|
| 449 | + @pytest.mark.asyncio |
| 450 | + async def test_custom_voice_response_events_update_response_sequencer(self, model, monkeypatch): |
| 451 | + """Dict-shaped custom voices should not block response.create sequencing.""" |
| 452 | + payload_types: list[str] = [] |
| 453 | + |
| 454 | + async def fake_send_raw(event): |
| 455 | + payload_types.append(event.type) |
| 456 | + |
| 457 | + class CustomVoiceRejectingAdapter: |
| 458 | + _string_adapter = TypeAdapter(str) |
| 459 | + |
| 460 | + def validate_python(self, event): |
| 461 | + voice = event.get("response", {}).get("audio", {}).get("output", {}).get("voice") |
| 462 | + if isinstance(voice, dict): |
| 463 | + self._string_adapter.validate_python(voice) |
| 464 | + return SimpleNamespace(type=event["type"]) |
| 465 | + |
| 466 | + monkeypatch.setattr(model, "_send_raw_message", fake_send_raw) |
| 467 | + model._server_event_type_adapter = CustomVoiceRejectingAdapter() |
| 468 | + mock_listener = AsyncMock() |
| 469 | + model.add_listener(mock_listener) |
| 470 | + |
| 471 | + await model._send_user_input(RealtimeModelSendUserInput(user_input="hi")) |
| 472 | + await asyncio.sleep(0) |
| 473 | + |
| 474 | + assert payload_types == ["conversation.item.create", "response.create"] |
| 475 | + assert model._response_control == "create_requested" |
| 476 | + |
| 477 | + response_with_custom_voice = { |
| 478 | + "type": "response.created", |
| 479 | + "response": {"audio": {"output": {"voice": {"id": "voice_test"}}}}, |
| 480 | + } |
| 481 | + await model._handle_ws_event(response_with_custom_voice) |
| 482 | + |
| 483 | + assert model._ongoing_response is True |
| 484 | + assert model._response_control == "free" |
| 485 | + |
| 486 | + await model._handle_ws_event( |
| 487 | + { |
| 488 | + "type": "response.done", |
| 489 | + "response": {"audio": {"output": {"voice": {"id": "voice_test"}}}}, |
| 490 | + } |
| 491 | + ) |
| 492 | + |
| 493 | + assert model._ongoing_response is False |
| 494 | + assert model._response_control == "free" |
| 495 | + raw_event = mock_listener.on_event.call_args_list[0][0][0] |
| 496 | + assert raw_event.data is response_with_custom_voice |
| 497 | + assert response_with_custom_voice["response"]["audio"]["output"]["voice"] == { |
| 498 | + "id": "voice_test" |
| 499 | + } |
| 500 | + |
| 501 | + await model._send_tool_output( |
| 502 | + RealtimeModelSendToolOutput( |
| 503 | + tool_call=SimpleNamespace( |
| 504 | + id="item_1", |
| 505 | + previous_item_id=None, |
| 506 | + call_id="call_1", |
| 507 | + arguments="{}", |
| 508 | + name="lookup", |
| 509 | + ), |
| 510 | + output="ok", |
| 511 | + start_response=True, |
| 512 | + ) |
| 513 | + ) |
| 514 | + await asyncio.sleep(0) |
| 515 | + |
| 516 | + assert payload_types == [ |
| 517 | + "conversation.item.create", |
| 518 | + "response.create", |
| 519 | + "conversation.item.create", |
| 520 | + "response.create", |
| 521 | + ] |
| 522 | + |
448 | 523 | @pytest.mark.asyncio |
449 | 524 | async def test_handle_unknown_event_type_ignored(self, model): |
450 | 525 | """Test that unknown event types are ignored gracefully.""" |
@@ -501,6 +576,35 @@ async def test_handle_audio_delta_event_success(self, model): |
501 | 576 | assert audio_state is not None |
502 | 577 | assert audio_state.audio_length_ms > 0 # Should have some audio length |
503 | 578 |
|
| 579 | + @pytest.mark.asyncio |
| 580 | + async def test_audio_delta_event_skips_custom_voice_normalization(self, model, monkeypatch): |
| 581 | + """High-frequency audio delta events should not pay for custom voice normalization.""" |
| 582 | + mock_listener = AsyncMock() |
| 583 | + model.add_listener(mock_listener) |
| 584 | + model._audio_state_tracker.set_audio_format("pcm16") |
| 585 | + |
| 586 | + def fail_normalize(event): |
| 587 | + raise AssertionError("custom voice normalization should not run") |
| 588 | + |
| 589 | + monkeypatch.setattr( |
| 590 | + "agents.realtime.openai_realtime._normalize_custom_voice_for_server_event_validation", |
| 591 | + fail_normalize, |
| 592 | + ) |
| 593 | + |
| 594 | + await model._handle_ws_event( |
| 595 | + { |
| 596 | + "type": "response.output_audio.delta", |
| 597 | + "event_id": "event_123", |
| 598 | + "response_id": "resp_123", |
| 599 | + "item_id": "item_456", |
| 600 | + "output_index": 0, |
| 601 | + "content_index": 0, |
| 602 | + "delta": "dGVzdCBhdWRpbw==", |
| 603 | + } |
| 604 | + ) |
| 605 | + |
| 606 | + assert mock_listener.on_event.call_count == 2 |
| 607 | + |
504 | 608 | @pytest.mark.asyncio |
505 | 609 | async def test_backward_compat_output_item_added_and_done(self, model): |
506 | 610 | """response.output_item.added/done paths emit item updates.""" |
@@ -1519,6 +1623,22 @@ def test_get_and_update_session_config(self, model): |
1519 | 1623 | assert cfg.audio is not None and cfg.audio.output is not None |
1520 | 1624 | assert cfg.audio.output.voice == "verse" |
1521 | 1625 |
|
| 1626 | + def test_session_config_accepts_custom_voice_object(self, model): |
| 1627 | + custom_voice = {"id": "voice_test"} |
| 1628 | + |
| 1629 | + cfg = model._get_session_config({"voice": custom_voice}) |
| 1630 | + payload = cfg.model_dump(exclude_unset=True) |
| 1631 | + |
| 1632 | + assert payload["audio"]["output"]["voice"] == custom_voice |
| 1633 | + |
| 1634 | + def test_session_config_accepts_nested_custom_voice_object(self, model): |
| 1635 | + custom_voice = {"id": "voice_test"} |
| 1636 | + |
| 1637 | + cfg = model._get_session_config({"audio": {"output": {"voice": custom_voice}}}) |
| 1638 | + payload = cfg.model_dump(exclude_unset=True) |
| 1639 | + |
| 1640 | + assert payload["audio"]["output"]["voice"] == custom_voice |
| 1641 | + |
1522 | 1642 | def test_session_config_defaults_audio_formats_when_not_call(self, model): |
1523 | 1643 | settings: dict[str, Any] = {} |
1524 | 1644 | cfg = model._get_session_config(settings) |
|
0 commit comments