mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-16 11:31:04 +02:00
fix: fix speech to speech model transitions (#545)
* fix: fix transition logic for realtime providers * chore: run formatter * chore: generate SDK and fix other realtime providers * fix: fix ultravox node transitions
This commit is contained in:
parent
348cd8427b
commit
01acf6ac30
34 changed files with 1282 additions and 617 deletions
|
|
@ -86,3 +86,45 @@ async def test_tts_greeting_waits_for_session_updated_before_sending_prompt():
|
|||
assert service._pending_initial_greeting_text is None
|
||||
assert service._llm_needs_conversation_setup is False
|
||||
service._create_response.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_transition_function_call_runs_while_bot_is_speaking():
|
||||
service = _make_service()
|
||||
service._context = LLMContext()
|
||||
service.run_function_calls = AsyncMock()
|
||||
service._bot_is_speaking = True
|
||||
service._pending_function_calls["call-1"] = SimpleNamespace(name="lookup_order")
|
||||
|
||||
await service._handle_evt_function_call_arguments_done(
|
||||
SimpleNamespace(call_id="call-1", arguments='{"order_id":"123"}')
|
||||
)
|
||||
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
assert service._deferred_node_transition_function_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_transition_function_call_waits_until_bot_stops_speaking():
|
||||
service = _make_service()
|
||||
service._context = LLMContext()
|
||||
service.run_function_calls = AsyncMock()
|
||||
service._bot_is_speaking = True
|
||||
service.register_function(
|
||||
"customer_support",
|
||||
AsyncMock(),
|
||||
is_node_transition=True,
|
||||
)
|
||||
service._pending_function_calls["call-1"] = SimpleNamespace(name="customer_support")
|
||||
|
||||
await service._handle_evt_function_call_arguments_done(
|
||||
SimpleNamespace(call_id="call-1", arguments='{"department":"sales"}')
|
||||
)
|
||||
|
||||
service.run_function_calls.assert_not_awaited()
|
||||
assert len(service._deferred_node_transition_function_calls) == 1
|
||||
|
||||
await service._run_pending_node_transition_function_calls()
|
||||
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
assert service._deferred_node_transition_function_calls == []
|
||||
|
|
|
|||
|
|
@ -170,6 +170,46 @@ def test_create_azure_realtime_blocks_private_endpoint_in_saas(monkeypatch):
|
|||
assert "public IP" in exc_info.value.detail
|
||||
|
||||
|
||||
def test_create_azure_realtime_uses_ga_websocket_url_by_default(monkeypatch):
|
||||
monkeypatch.setattr("api.utils.url_security.DEPLOYMENT_MODE", "oss")
|
||||
user_config = SimpleNamespace(
|
||||
realtime=SimpleNamespace(
|
||||
provider=ServiceProviders.AZURE_REALTIME.value,
|
||||
api_key="test-key",
|
||||
endpoint="https://example.openai.azure.com",
|
||||
model="my-realtime-deployment",
|
||||
voice="alloy",
|
||||
)
|
||||
)
|
||||
|
||||
service = create_realtime_llm_service(user_config, _audio_config())
|
||||
|
||||
assert service.base_url == (
|
||||
"wss://example.openai.azure.com/openai/v1/realtime?model=my-realtime-deployment"
|
||||
)
|
||||
|
||||
|
||||
def test_create_azure_realtime_preserves_explicit_preview_websocket_url(monkeypatch):
|
||||
monkeypatch.setattr("api.utils.url_security.DEPLOYMENT_MODE", "oss")
|
||||
user_config = SimpleNamespace(
|
||||
realtime=SimpleNamespace(
|
||||
provider=ServiceProviders.AZURE_REALTIME.value,
|
||||
api_key="test-key",
|
||||
endpoint="https://example.openai.azure.com",
|
||||
api_version="2025-04-01-preview",
|
||||
model="my-preview-deployment",
|
||||
voice="alloy",
|
||||
)
|
||||
)
|
||||
|
||||
service = create_realtime_llm_service(user_config, _audio_config())
|
||||
|
||||
assert service.base_url == (
|
||||
"wss://example.openai.azure.com/openai/realtime?"
|
||||
"api-version=2025-04-01-preview&deployment=my-preview-deployment"
|
||||
)
|
||||
|
||||
|
||||
def test_azure_embedding_service_rejects_wrong_dimension():
|
||||
service = AzureOpenAIEmbeddingService(
|
||||
db_client=SimpleNamespace(),
|
||||
|
|
|
|||
|
|
@ -1415,6 +1415,7 @@ class TestCustomToolManagerUnit:
|
|||
# Verify handler was registered
|
||||
assert "api_call" in registered_handlers
|
||||
assert registered_kwargs["api_call"]["timeout_secs"] == pytest.approx(5)
|
||||
assert registered_kwargs["api_call"]["is_node_transition"] is False
|
||||
|
||||
# Now test that the handler works
|
||||
handler = registered_handlers["api_call"]
|
||||
|
|
@ -1445,6 +1446,41 @@ class TestCustomToolManagerUnit:
|
|||
# Verify result was returned
|
||||
assert result_received["status"] == "success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("category", ["end_call", "transfer_call"])
|
||||
async def test_register_handlers_marks_call_control_tools_as_node_transitions(
|
||||
self, category
|
||||
):
|
||||
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
||||
|
||||
mock_engine = Mock()
|
||||
mock_engine._get_organization_id = AsyncMock(return_value=1)
|
||||
mock_engine.llm.register_function = Mock()
|
||||
manager = CustomToolManager(mock_engine)
|
||||
tool = MockToolModel(
|
||||
tool_uuid=f"{category}-uuid",
|
||||
name=category.replace("_", " "),
|
||||
description=f"Perform {category}",
|
||||
category=category,
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": category,
|
||||
"config": {},
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine_custom_tools.db_client.get_tools_by_uuids",
|
||||
new=AsyncMock(return_value=[tool]),
|
||||
):
|
||||
await manager.register_handlers([tool.tool_uuid])
|
||||
|
||||
mock_engine.llm.register_function.assert_called_once()
|
||||
assert (
|
||||
mock_engine.llm.register_function.call_args.kwargs["is_node_transition"]
|
||||
is True
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transfer_call_renders_destination_from_initial_context(self):
|
||||
"""Transfer call tools resolve destination templates before provider calls."""
|
||||
|
|
|
|||
|
|
@ -134,6 +134,21 @@ def test_gemini_live_service_classes_use_dograh_gemini_adapter_class():
|
|||
)
|
||||
|
||||
|
||||
def test_vertex_live_inherits_dograh_node_transition_lifecycle():
|
||||
assert (
|
||||
DograhGeminiLiveVertexLLMService._requires_node_transition_context_aggregation
|
||||
is DograhGeminiLiveLLMService._requires_node_transition_context_aggregation
|
||||
)
|
||||
assert (
|
||||
DograhGeminiLiveVertexLLMService._run_or_defer_function_calls
|
||||
is DograhGeminiLiveLLMService._run_or_defer_function_calls
|
||||
)
|
||||
assert (
|
||||
DograhGeminiLiveVertexLLMService._reconnect_for_node_transition
|
||||
is DograhGeminiLiveLLMService._reconnect_for_node_transition
|
||||
)
|
||||
|
||||
|
||||
def test_gemini_live_config_accepts_json_schema_tools():
|
||||
function_schema = FunctionSchema(
|
||||
name="customer_lookup",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from pipecat.frames.frames import TranscriptionFrame, TTSSpeakFrame
|
||||
from pipecat.frames.frames import (
|
||||
NodeTransitionStartedFrame,
|
||||
TranscriptionFrame,
|
||||
TTSSpeakFrame,
|
||||
)
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMContextAggregatorPair,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.services.llm_service import FunctionCallFromLLM
|
||||
|
||||
from api.services.pipecat.realtime.gemini_live import DograhGeminiLiveLLMService
|
||||
|
||||
|
|
@ -163,3 +172,194 @@ async def test_tts_greeting_waits_for_gemini_session_before_sending_prompt():
|
|||
assert prompt.endswith('"Hello from Dograh."')
|
||||
assert service._run_llm_when_session_ready is False
|
||||
assert service._pending_initial_greeting_text is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transition_call_flushes_pending_transcription_before_execution():
|
||||
service = _make_service()
|
||||
service._NODE_TRANSITION_TRANSCRIPTION_GRACE_SECONDS = 0
|
||||
service.register_function(
|
||||
"transition_to_next_node",
|
||||
AsyncMock(),
|
||||
is_node_transition=True,
|
||||
)
|
||||
service._user_transcription_buffer = "My last answer"
|
||||
|
||||
events = []
|
||||
|
||||
async def _push_transcription(text, result=None):
|
||||
events.append(("transcription", text))
|
||||
|
||||
async def _run_function_calls(function_calls):
|
||||
events.append(("function", function_calls[0].function_name))
|
||||
|
||||
service._push_user_transcription = AsyncMock(side_effect=_push_transcription)
|
||||
service.run_function_calls = AsyncMock(side_effect=_run_function_calls)
|
||||
service.create_task = lambda coro, name=None: asyncio.create_task(coro, name=name)
|
||||
|
||||
function_call = FunctionCallFromLLM(
|
||||
context=LLMContext(),
|
||||
tool_call_id="call-transition",
|
||||
function_name="transition_to_next_node",
|
||||
arguments={},
|
||||
)
|
||||
|
||||
await service._run_or_defer_function_calls([function_call])
|
||||
transition_task = service._transition_function_call_task
|
||||
assert transition_task is not None
|
||||
await transition_task
|
||||
|
||||
assert events == [
|
||||
("transcription", "My last answer"),
|
||||
("function", "transition_to_next_node"),
|
||||
]
|
||||
assert service._user_transcription_buffer == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_transition_call_is_not_deferred_while_bot_is_responding():
|
||||
service = _make_service()
|
||||
service._bot_is_responding = True
|
||||
service.run_function_calls = AsyncMock()
|
||||
|
||||
function_call = FunctionCallFromLLM(
|
||||
context=LLMContext(),
|
||||
tool_call_id="call-ordinary",
|
||||
function_name="look_up_account",
|
||||
arguments={},
|
||||
)
|
||||
|
||||
await service._run_or_defer_function_calls([function_call])
|
||||
|
||||
service.run_function_calls.assert_awaited_once_with([function_call])
|
||||
assert service._pending_node_transition_function_calls == []
|
||||
assert service._transition_function_call_task is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_transition_call_is_deferred_while_bot_is_responding():
|
||||
service = _make_service()
|
||||
service._bot_is_responding = True
|
||||
service.register_function(
|
||||
"transition_to_next_node",
|
||||
AsyncMock(),
|
||||
is_node_transition=True,
|
||||
)
|
||||
service.run_function_calls = AsyncMock()
|
||||
|
||||
function_call = FunctionCallFromLLM(
|
||||
context=LLMContext(),
|
||||
tool_call_id="call-transition",
|
||||
function_name="transition_to_next_node",
|
||||
arguments={},
|
||||
)
|
||||
|
||||
await service._run_or_defer_function_calls([function_call])
|
||||
|
||||
service.run_function_calls.assert_not_awaited()
|
||||
assert service._pending_node_transition_function_calls == [function_call]
|
||||
assert service._transition_function_call_task is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initial_system_instruction_update_is_not_a_node_handoff():
|
||||
service = _make_service()
|
||||
service._connect = AsyncMock()
|
||||
service._disconnect = AsyncMock()
|
||||
|
||||
handled = await service._handle_changed_settings(
|
||||
{"system_instruction": "old prompt"}
|
||||
)
|
||||
|
||||
assert handled == {"system_instruction"}
|
||||
service._connect.assert_awaited_once_with()
|
||||
service._disconnect.assert_not_awaited()
|
||||
assert service._awaiting_node_transition_context is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_transition_uses_fresh_connection_instead_of_stale_handle():
|
||||
service = _make_service()
|
||||
service._session = _FakeSession()
|
||||
service._session_resumption_handle = "stale-handle"
|
||||
service._disconnect = AsyncMock()
|
||||
service._connect = AsyncMock()
|
||||
|
||||
handled = await service._handle_changed_settings(
|
||||
{"system_instruction": "old prompt"}
|
||||
)
|
||||
|
||||
assert handled == {"system_instruction"}
|
||||
assert service._session_resumption_handle is None
|
||||
assert service._awaiting_node_transition_context is True
|
||||
service._disconnect.assert_awaited_once()
|
||||
service._connect.assert_awaited_once_with(session_resumption_handle=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_transition_session_waits_for_updated_context_before_ready():
|
||||
service = _make_service()
|
||||
service._handled_initial_context = True
|
||||
service._awaiting_node_transition_context = True
|
||||
service._node_transition_context_received = False
|
||||
service._process_completed_function_calls = AsyncMock()
|
||||
service._drain_pending_tool_results = AsyncMock()
|
||||
|
||||
async def _seed_context():
|
||||
service._ready_for_realtime_input = True
|
||||
|
||||
service._create_initial_response = AsyncMock(side_effect=_seed_context)
|
||||
|
||||
session = _FakeSession()
|
||||
await service._handle_session_ready(session)
|
||||
|
||||
assert service._ready_for_realtime_input is False
|
||||
service._create_initial_response.assert_not_awaited()
|
||||
|
||||
context = _make_tool_result_context("call-transition")
|
||||
await service._handle_context(context)
|
||||
|
||||
service._process_completed_function_calls.assert_awaited_once_with(
|
||||
send_new_results=False
|
||||
)
|
||||
service._create_initial_response.assert_awaited_once()
|
||||
service._drain_pending_tool_results.assert_awaited_once()
|
||||
assert service._ready_for_realtime_input is True
|
||||
assert service._awaiting_node_transition_context is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_transition_frame_commits_user_transcript_to_context():
|
||||
context = LLMContext()
|
||||
context_aggregator = LLMContextAggregatorPair(context, realtime_service_mode=True)
|
||||
user_aggregator = context_aggregator.user()
|
||||
user_aggregator.push_context_frame = AsyncMock()
|
||||
|
||||
await user_aggregator._handle_transcription(
|
||||
TranscriptionFrame(
|
||||
text="The answer that selected this node",
|
||||
user_id="",
|
||||
timestamp="now",
|
||||
)
|
||||
)
|
||||
context_aggregation_event = asyncio.Event()
|
||||
await user_aggregator._handle_node_transition_started(
|
||||
NodeTransitionStartedFrame(
|
||||
function_calls=[
|
||||
FunctionCallFromLLM(
|
||||
context=context,
|
||||
tool_call_id="call-transition",
|
||||
function_name="transition_to_next_node",
|
||||
arguments={},
|
||||
)
|
||||
],
|
||||
context_aggregation_event=context_aggregation_event,
|
||||
)
|
||||
)
|
||||
|
||||
assert context.messages[-1] == {
|
||||
"role": "user",
|
||||
"content": "The answer that selected this node",
|
||||
}
|
||||
user_aggregator.push_context_frame.assert_awaited_once()
|
||||
assert context_aggregation_event.is_set()
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ async def test_messages_append_frame_sends_conversation_item():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_function_call_is_deferred_until_bot_stops_speaking():
|
||||
async def test_non_transition_function_call_runs_while_bot_is_speaking():
|
||||
service = _make_service()
|
||||
service._context = LLMContext()
|
||||
service.run_function_calls = AsyncMock()
|
||||
|
|
@ -145,13 +145,38 @@ async def test_function_call_is_deferred_until_bot_stops_speaking():
|
|||
)
|
||||
)
|
||||
|
||||
service.run_function_calls.assert_not_awaited()
|
||||
assert len(service._deferred_function_calls) == 1
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
assert service._deferred_node_transition_function_calls == []
|
||||
|
||||
await service._run_pending_function_calls()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_transition_function_call_waits_until_bot_stops_speaking():
|
||||
service = _make_service()
|
||||
service._context = LLMContext()
|
||||
service.run_function_calls = AsyncMock()
|
||||
service._bot_is_speaking = True
|
||||
service.register_function(
|
||||
"customer_support",
|
||||
AsyncMock(),
|
||||
is_node_transition=True,
|
||||
)
|
||||
service._pending_function_calls["call-1"] = SimpleNamespace(name="customer_support")
|
||||
|
||||
await service._handle_evt_function_call_arguments_done(
|
||||
SimpleNamespace(
|
||||
call_id="call-1",
|
||||
name="customer_support",
|
||||
arguments='{"department":"sales"}',
|
||||
)
|
||||
)
|
||||
|
||||
service.run_function_calls.assert_not_awaited()
|
||||
assert len(service._deferred_node_transition_function_calls) == 1
|
||||
|
||||
await service._run_pending_node_transition_function_calls()
|
||||
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
assert service._deferred_function_calls == []
|
||||
assert service._deferred_node_transition_function_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -190,3 +215,21 @@ def test_factory_creates_dograh_grok_realtime_service():
|
|||
)
|
||||
|
||||
assert isinstance(service, DograhGrokRealtimeLLMService)
|
||||
assert service._settings.session_properties.voice == "sal"
|
||||
assert service._settings.session_properties.audio.input.transcription.model == (
|
||||
"grok-transcribe"
|
||||
)
|
||||
|
||||
|
||||
def test_grok_audio_config_preserves_transcription_when_filling_sample_rates():
|
||||
service = _make_service()
|
||||
service._settings.session_properties.audio = events.AudioConfiguration(
|
||||
input=events.AudioInput(transcription=events.InputAudioTranscription())
|
||||
)
|
||||
|
||||
service._ensure_audio_config(input_sample_rate=16000, output_sample_rate=24000)
|
||||
|
||||
audio = service._settings.session_properties.audio
|
||||
assert audio.input.format.rate == 16000
|
||||
assert audio.input.transcription.model == "grok-transcribe"
|
||||
assert audio.output.format.rate == 24000
|
||||
|
|
|
|||
|
|
@ -127,11 +127,11 @@ async def test_function_call_executes_immediately_when_bot_is_not_speaking():
|
|||
)
|
||||
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
assert service._deferred_function_calls == []
|
||||
assert service._deferred_node_transition_function_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_function_call_is_deferred_until_bot_stops_speaking():
|
||||
async def test_non_transition_function_call_runs_while_bot_is_speaking():
|
||||
service = _make_service()
|
||||
service._context = LLMContext()
|
||||
service.run_function_calls = AsyncMock()
|
||||
|
|
@ -142,10 +142,31 @@ async def test_function_call_is_deferred_until_bot_stops_speaking():
|
|||
SimpleNamespace(call_id="call-1", arguments='{"department":"sales"}')
|
||||
)
|
||||
|
||||
service.run_function_calls.assert_not_awaited()
|
||||
assert len(service._deferred_function_calls) == 1
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
assert service._deferred_node_transition_function_calls == []
|
||||
|
||||
await service._run_pending_function_calls()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_transition_function_call_is_deferred_until_bot_stops_speaking():
|
||||
service = _make_service()
|
||||
service._context = LLMContext()
|
||||
service.run_function_calls = AsyncMock()
|
||||
service._bot_is_speaking = True
|
||||
service.register_function(
|
||||
"customer_support",
|
||||
AsyncMock(),
|
||||
is_node_transition=True,
|
||||
)
|
||||
service._pending_function_calls["call-1"] = SimpleNamespace(name="customer_support")
|
||||
|
||||
await service._handle_evt_function_call_arguments_done(
|
||||
SimpleNamespace(call_id="call-1", arguments='{"department":"sales"}')
|
||||
)
|
||||
|
||||
service.run_function_calls.assert_not_awaited()
|
||||
assert len(service._deferred_node_transition_function_calls) == 1
|
||||
|
||||
await service._run_pending_node_transition_function_calls()
|
||||
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
assert service._deferred_function_calls == []
|
||||
assert service._deferred_node_transition_function_calls == []
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ class TestPipecatEngineToolCalls:
|
|||
|
||||
# Assert that the context was updated with END_CALL_SYSTEM_PROMPT
|
||||
assert llm._settings.system_instruction == END_CALL_SYSTEM_PROMPT
|
||||
assert llm._functions["end_call"].is_node_transition is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_builtin_and_transition_calls_through_engine_1(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, call
|
||||
|
||||
|
|
@ -13,7 +14,6 @@ from websockets.frames import Close
|
|||
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
|
||||
from api.services.configuration.registry import UltravoxRealtimeLLMConfiguration
|
||||
from api.services.pipecat.realtime.ultravox_realtime import (
|
||||
_RESUMPTION_USER_MESSAGE,
|
||||
DograhUltravoxOneShotInputParams,
|
||||
DograhUltravoxRealtimeLLMService,
|
||||
)
|
||||
|
|
@ -100,50 +100,35 @@ async def test_initial_context_connects_without_replay():
|
|||
await service._handle_context(context)
|
||||
|
||||
service._connect_call.assert_awaited_once()
|
||||
assert service._connect_call.await_args.kwargs["initial_messages"] is None
|
||||
assert service._connect_call.await_args.kwargs["greeting_text"] is None
|
||||
assert service._connect_call.await_args.kwargs["agent_speaks_first"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_instruction_update_marks_reconnect_required():
|
||||
async def test_system_instruction_update_marks_stage_update_required():
|
||||
service = _make_service()
|
||||
service._has_connected_once = True
|
||||
service._socket = object()
|
||||
|
||||
changed = await service._update_settings(
|
||||
DograhUltravoxRealtimeLLMService.Settings(system_instruction="new instruction")
|
||||
)
|
||||
|
||||
assert "system_instruction" in changed
|
||||
assert service._reconnect_required is True
|
||||
assert service._stage_update_required is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_instruction_change_reconnects_with_full_initial_messages():
|
||||
async def test_node_transition_updates_native_stage_without_reconnecting():
|
||||
service = _make_service()
|
||||
service._socket = object()
|
||||
service._has_connected_once = True
|
||||
service._call_system_instruction = "old instruction"
|
||||
service._reconnect_required = True
|
||||
service._send = AsyncMock()
|
||||
service._connect_call = AsyncMock()
|
||||
service._pending_node_transition_tool_call_ids.add("call-transition")
|
||||
service._stage_update_required = True
|
||||
service._settings.system_instruction = "new instruction"
|
||||
service._reconnect_with_context = AsyncMock()
|
||||
|
||||
context = LLMContext(
|
||||
messages=[
|
||||
{"role": "user", "content": "I want to hear the pricing."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Let me check that for you.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-transition",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "transition_to_next_node",
|
||||
"arguments": '{"reason":"pricing requested"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call-transition",
|
||||
|
|
@ -155,43 +140,28 @@ async def test_system_instruction_change_reconnects_with_full_initial_messages()
|
|||
|
||||
await service._handle_context(context)
|
||||
|
||||
service._reconnect_with_context.assert_awaited_once()
|
||||
initial_messages = service._reconnect_with_context.await_args.kwargs[
|
||||
"initial_messages"
|
||||
]
|
||||
assert initial_messages == [
|
||||
{
|
||||
"role": "MESSAGE_ROLE_USER",
|
||||
"text": "I want to hear the pricing.",
|
||||
},
|
||||
{
|
||||
"role": "MESSAGE_ROLE_AGENT",
|
||||
"text": "Let me check that for you.",
|
||||
},
|
||||
{
|
||||
"role": "MESSAGE_ROLE_TOOL_CALL",
|
||||
"text": "",
|
||||
"invocationId": "call-transition",
|
||||
"toolName": "transition_to_next_node",
|
||||
},
|
||||
{
|
||||
"role": "MESSAGE_ROLE_TOOL_RESULT",
|
||||
"text": '{"status":"done"}',
|
||||
"invocationId": "call-transition",
|
||||
"toolName": "transition_to_next_node",
|
||||
},
|
||||
]
|
||||
service._connect_call.assert_not_awaited()
|
||||
service._send.assert_awaited_once()
|
||||
message = service._send.await_args.args[0]
|
||||
assert message["type"] == "client_tool_result"
|
||||
assert message["invocationId"] == "call-transition"
|
||||
assert message["responseType"] == "new-stage"
|
||||
stage = json.loads(message["result"])
|
||||
assert stage["systemPrompt"] == "new instruction"
|
||||
assert stage["toolResultText"] == '{"status":"done"}'
|
||||
assert stage["selectedTools"][0]["temporaryTool"]["modelToolName"] == (
|
||||
"transition_to_next_node"
|
||||
)
|
||||
assert "call-transition" in service._completed_tool_calls
|
||||
assert service._pending_node_transition_tool_call_ids == set()
|
||||
assert service._stage_update_required is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_context_update_does_not_reconnect_when_system_instruction_is_unchanged():
|
||||
async def test_ordinary_tool_result_uses_standard_tool_response():
|
||||
service = _make_service()
|
||||
service._socket = object()
|
||||
service._call_system_instruction = "same instruction"
|
||||
service._settings.system_instruction = "same instruction"
|
||||
service._reconnect_with_context = AsyncMock()
|
||||
service._send_tool_result = AsyncMock()
|
||||
service._send = AsyncMock()
|
||||
|
||||
context = LLMContext(
|
||||
messages=[
|
||||
|
|
@ -206,13 +176,89 @@ async def test_tool_context_update_does_not_reconnect_when_system_instruction_is
|
|||
|
||||
await service._handle_context(context)
|
||||
|
||||
service._reconnect_with_context.assert_not_awaited()
|
||||
service._send_tool_result.assert_awaited_once_with(
|
||||
"call-transition",
|
||||
'{"status":"done"}',
|
||||
service._send.assert_awaited_once_with(
|
||||
{
|
||||
"type": "client_tool_result",
|
||||
"invocationId": "call-transition",
|
||||
"result": '{"status":"done"}',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_registered_node_transition_invocations_are_tracked():
|
||||
service = _make_service()
|
||||
service.run_function_calls = AsyncMock()
|
||||
service.register_function(
|
||||
"transition_to_next_node",
|
||||
AsyncMock(),
|
||||
is_node_transition=True,
|
||||
)
|
||||
|
||||
await service._handle_tool_invocation(
|
||||
"transition_to_next_node", "call-transition", {"reason": "pricing"}
|
||||
)
|
||||
await service._handle_tool_invocation("lookup_price", "call-lookup", {})
|
||||
|
||||
assert service._pending_node_transition_tool_call_ids == {"call-transition"}
|
||||
assert service.run_function_calls.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_transition_invocation_waits_for_response_end():
|
||||
service = _make_service()
|
||||
service.run_function_calls = AsyncMock()
|
||||
service.stop_processing_metrics = AsyncMock()
|
||||
service.push_frame = AsyncMock()
|
||||
service.register_function(
|
||||
"transition_to_next_node",
|
||||
AsyncMock(),
|
||||
is_node_transition=True,
|
||||
)
|
||||
service._bot_responding = "voice"
|
||||
|
||||
await service._handle_tool_invocation(
|
||||
"transition_to_next_node", "call-transition", {"reason": "pricing"}
|
||||
)
|
||||
|
||||
service.run_function_calls.assert_not_awaited()
|
||||
assert service._deferred_node_transition_tool_invocations == [
|
||||
(
|
||||
"transition_to_next_node",
|
||||
"call-transition",
|
||||
{"reason": "pricing"},
|
||||
)
|
||||
]
|
||||
|
||||
await service._handle_response_end()
|
||||
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
function_call = service.run_function_calls.await_args.args[0][0]
|
||||
assert function_call.function_name == "transition_to_next_node"
|
||||
assert function_call.tool_call_id == "call-transition"
|
||||
assert function_call.arguments == {"reason": "pricing"}
|
||||
assert service._deferred_node_transition_tool_invocations == []
|
||||
assert service._bot_responding is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ordinary_tool_invocation_runs_while_response_is_active():
|
||||
service = _make_service()
|
||||
service.run_function_calls = AsyncMock()
|
||||
service._bot_responding = "voice"
|
||||
|
||||
await service._handle_tool_invocation("lookup_price", "call-lookup", {})
|
||||
|
||||
service.run_function_calls.assert_awaited_once()
|
||||
assert service._deferred_node_transition_tool_invocations == []
|
||||
|
||||
|
||||
def test_ultravox_requires_transition_context_aggregation():
|
||||
service = _make_service()
|
||||
|
||||
assert service._requires_node_transition_context_aggregation() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_messages_append_frame_sends_user_text():
|
||||
service = _make_service()
|
||||
|
|
@ -287,7 +333,6 @@ def test_build_one_shot_params_uses_explicit_greeting_text():
|
|||
|
||||
params = service._build_one_shot_params(
|
||||
greeting_text="Welcome to Dograh",
|
||||
initial_messages=None,
|
||||
agent_speaks_first=True,
|
||||
)
|
||||
|
||||
|
|
@ -296,85 +341,18 @@ def test_build_one_shot_params_uses_explicit_greeting_text():
|
|||
}
|
||||
|
||||
|
||||
def test_build_one_shot_params_includes_initial_messages():
|
||||
def test_build_one_shot_params_uses_current_system_instruction():
|
||||
service = _make_service()
|
||||
service._settings.system_instruction = "Base instruction"
|
||||
|
||||
params = service._build_one_shot_params(
|
||||
greeting_text=None,
|
||||
initial_messages=[
|
||||
{"role": "MESSAGE_ROLE_USER", "text": "User asked a question."},
|
||||
{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'},
|
||||
],
|
||||
agent_speaks_first=True,
|
||||
)
|
||||
|
||||
assert params.extra["initialMessages"] == [
|
||||
{"role": "MESSAGE_ROLE_USER", "text": "User asked a question."},
|
||||
{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'},
|
||||
{"role": "MESSAGE_ROLE_USER", "text": _RESUMPTION_USER_MESSAGE},
|
||||
]
|
||||
assert params.system_prompt == "Base instruction"
|
||||
|
||||
|
||||
def test_build_one_shot_params_without_tool_result_does_not_add_resumption_user_message():
|
||||
service = _make_service()
|
||||
service._settings.system_instruction = "Base instruction"
|
||||
|
||||
params = service._build_one_shot_params(
|
||||
greeting_text=None,
|
||||
initial_messages=[
|
||||
{"role": "MESSAGE_ROLE_USER", "text": "User asked a question."},
|
||||
{"role": "MESSAGE_ROLE_AGENT", "text": "Assistant replied."},
|
||||
],
|
||||
agent_speaks_first=False,
|
||||
)
|
||||
|
||||
assert params.system_prompt == "Base instruction"
|
||||
|
||||
|
||||
def test_should_agent_speak_first_when_history_ends_with_tool_result():
|
||||
service = _make_service()
|
||||
|
||||
assert (
|
||||
service._should_agent_speak_first(
|
||||
[
|
||||
{"role": "MESSAGE_ROLE_USER", "text": "Hello"},
|
||||
{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'},
|
||||
]
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_should_not_force_agent_speaks_first_when_history_ends_with_agent():
|
||||
service = _make_service()
|
||||
|
||||
assert (
|
||||
service._should_agent_speak_first(
|
||||
[{"role": "MESSAGE_ROLE_AGENT", "text": "How else can I help?"}]
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_should_add_resumption_user_message_only_when_history_ends_with_tool_result():
|
||||
service = _make_service()
|
||||
|
||||
assert (
|
||||
service._should_add_resumption_user_message(
|
||||
[{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'}]
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
service._should_add_resumption_user_message(
|
||||
[{"role": "MESSAGE_ROLE_AGENT", "text": "Assistant replied."}]
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_to_selected_tools_includes_registered_timeout():
|
||||
service = _make_service()
|
||||
service.register_function(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue