diff --git a/api/routes/public_agent.py b/api/routes/public_agent.py index 45263534..2e6abf5d 100644 --- a/api/routes/public_agent.py +++ b/api/routes/public_agent.py @@ -320,7 +320,7 @@ async def _execute_resolved_target( # (e.g. Telnyx, Cloudonix); without them the URL contains "None/None" and # the stream connection fails. try: - await provider.initiate_call( + result = await provider.initiate_call( to_number=request.phone_number, webhook_url=webhook_url, workflow_run_id=workflow_run.id, @@ -337,6 +337,25 @@ async def _execute_resolved_target( detail=f"Failed to initiate call: {e}", ) + gathered_context = { + "provider": provider.PROVIDER_NAME, + "triggered_by": "api", + **(result.provider_metadata or {}), + } + if target.identifier_type == "trigger_path": + gathered_context["trigger_uuid"] = target.identifier_value + + try: + await db_client.update_workflow_run( + run_id=workflow_run.id, + gathered_context=gathered_context, + ) + except Exception as e: + logger.warning( + f"Call initiated for workflow run {workflow_run.id}, but failed to " + f"persist provider metadata: {e}" + ) + logger.info( f"Call initiated successfully for workflow run {workflow_run.id} " f"via {target.identifier_type}={target.identifier_value}" diff --git a/api/services/configuration/registry.py b/api/services/configuration/registry.py index bcb18911..aba0b7c7 100644 --- a/api/services/configuration/registry.py +++ b/api/services/configuration/registry.py @@ -1332,6 +1332,7 @@ class XAITTSConfiguration(BaseServiceConfiguration): description="BCP-47 language code for synthesis (e.g. 'en', 'fr', 'de'), or 'auto' for automatic language detection.", json_schema_extra={"allow_custom_input": True}, ) + @computed_field @property def model(self) -> str: diff --git a/api/tests/integrations/_run_pipeline_helpers.py b/api/tests/integrations/_run_pipeline_helpers.py index 0d5ff6c8..90c8eb1a 100644 --- a/api/tests/integrations/_run_pipeline_helpers.py +++ b/api/tests/integrations/_run_pipeline_helpers.py @@ -236,6 +236,7 @@ async def create_workflow_run_rows( workflow_id=workflow.id, mode=WorkflowRunMode.SMALLWEBRTC.value, user_id=user.id, + definition_id=workflow.released_definition_id, ) return workflow_run, user, workflow diff --git a/api/tests/test_public_agent_routes.py b/api/tests/test_public_agent_routes.py index 241f9865..1167fe1c 100644 --- a/api/tests/test_public_agent_routes.py +++ b/api/tests/test_public_agent_routes.py @@ -45,7 +45,14 @@ def _provider(): PROVIDER_NAME="twilio", WEBHOOK_ENDPOINT="outbound", validate_config=Mock(return_value=True), - initiate_call=AsyncMock(), + initiate_call=AsyncMock( + return_value=SimpleNamespace( + call_id="CA123", + status="queued", + caller_number="+15550000000", + provider_metadata={"call_id": "CA123"}, + ) + ), ) @@ -96,6 +103,7 @@ def test_trigger_route_executes_as_workflow_owner(): return_value=SimpleNamespace(id=55) ) mock_db.create_workflow_run = AsyncMock(return_value=SimpleNamespace(id=501)) + mock_db.update_workflow_run = AsyncMock() response = client.post( "/public/agent/trigger-uuid-123", @@ -135,6 +143,15 @@ def test_trigger_route_executes_as_workflow_owner(): assert initiate_kwargs["workflow_id"] == workflow.id # The media websocket URL is keyed on the org, not the workflow owner. assert initiate_kwargs["organization_id"] == workflow.organization_id + mock_db.update_workflow_run.assert_awaited_once_with( + run_id=501, + gathered_context={ + "provider": "twilio", + "triggered_by": "api", + "call_id": "CA123", + "trigger_uuid": "trigger-uuid-123", + }, + ) def test_workflow_uuid_route_uses_scoped_lookup_and_shared_execution(): @@ -177,6 +194,7 @@ def test_workflow_uuid_route_uses_scoped_lookup_and_shared_execution(): return_value=SimpleNamespace(id=55) ) mock_db.create_workflow_run = AsyncMock(return_value=SimpleNamespace(id=601)) + mock_db.update_workflow_run = AsyncMock() response = client.post( f"/public/agent/workflow/{workflow.workflow_uuid}", @@ -207,6 +225,14 @@ def test_workflow_uuid_route_uses_scoped_lookup_and_shared_execution(): assert create_kwargs["definition_id"] == 77 assert "name" not in create_kwargs["initial_context"] assert not mock_db.get_draft_version.called + mock_db.update_workflow_run.assert_awaited_once_with( + run_id=601, + gathered_context={ + "provider": "twilio", + "triggered_by": "api", + "call_id": "CA123", + }, + ) def test_trigger_test_route_uses_draft_and_template_context_with_api_override(): @@ -262,6 +288,7 @@ def test_trigger_test_route_uses_draft_and_template_context_with_api_override(): return_value=SimpleNamespace(id=55) ) mock_db.create_workflow_run = AsyncMock(return_value=SimpleNamespace(id=501)) + mock_db.update_workflow_run = AsyncMock() response = client.post( "/public/agent/test/trigger-uuid-123", @@ -280,6 +307,15 @@ def test_trigger_test_route_uses_draft_and_template_context_with_api_override(): assert create_kwargs["initial_context"]["age"] == 10 assert create_kwargs["initial_context"]["rank"] == 2 assert create_kwargs["initial_context"]["trigger_mode"] == "test" + mock_db.update_workflow_run.assert_awaited_once_with( + run_id=501, + gathered_context={ + "provider": "twilio", + "triggered_by": "api", + "call_id": "CA123", + "trigger_uuid": "trigger-uuid-123", + }, + ) def test_workflow_uuid_test_route_uses_draft_and_template_context(): @@ -328,6 +364,7 @@ def test_workflow_uuid_test_route_uses_draft_and_template_context(): return_value=SimpleNamespace(id=55) ) mock_db.create_workflow_run = AsyncMock(return_value=SimpleNamespace(id=501)) + mock_db.update_workflow_run = AsyncMock() response = client.post( f"/public/agent/test/workflow/{workflow.workflow_uuid}", @@ -346,6 +383,75 @@ def test_workflow_uuid_test_route_uses_draft_and_template_context(): assert create_kwargs["initial_context"]["age"] == 12 assert create_kwargs["initial_context"]["rank"] == 2 assert create_kwargs["initial_context"]["trigger_mode"] == "test" + mock_db.update_workflow_run.assert_awaited_once_with( + run_id=501, + gathered_context={ + "provider": "twilio", + "triggered_by": "api", + "call_id": "CA123", + }, + ) + + +def test_trigger_route_still_returns_success_when_metadata_persistence_fails(): + app = _make_test_app() + client = TestClient(app) + + workflow = _active_workflow(trigger_path="trigger-uuid-123") + provider = _provider() + quota_mock = AsyncMock( + return_value=SimpleNamespace(has_quota=True, error_message="") + ) + + with ( + patch("api.routes.public_agent.db_client") as mock_db, + patch("api.routes.public_agent.call_concurrency") as mock_concurrency, + patch( + "api.routes.public_agent.authorize_workflow_run_start", + new=quota_mock, + ), + patch( + "api.routes.public_agent.get_default_telephony_provider", + new=AsyncMock(return_value=provider), + ), + patch( + "api.routes.public_agent.get_backend_endpoints", + new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")), + ), + ): + slot = object() + mock_concurrency.acquire_org_slot = AsyncMock(return_value=slot) + mock_concurrency.bind_workflow_run = AsyncMock() + mock_concurrency.release_workflow_run_slot = AsyncMock() + mock_concurrency.release_slot = AsyncMock() + + mock_db.validate_api_key = AsyncMock( + return_value=SimpleNamespace(id=7, organization_id=11, created_by=22) + ) + mock_db.get_agent_trigger_by_path = AsyncMock( + return_value=SimpleNamespace( + workflow_id=workflow.id, + organization_id=11, + state="active", + ) + ) + mock_db.get_workflow = AsyncMock(return_value=workflow) + mock_db.get_default_telephony_configuration = AsyncMock( + return_value=SimpleNamespace(id=55) + ) + mock_db.create_workflow_run = AsyncMock(return_value=SimpleNamespace(id=501)) + mock_db.update_workflow_run = AsyncMock(side_effect=Exception("db down")) + + response = client.post( + "/public/agent/trigger-uuid-123", + headers={"X-API-Key": "test-api-key"}, + json={"phone_number": "+15551234567"}, + ) + + assert response.status_code == 200 + provider.initiate_call.assert_awaited_once() + mock_db.update_workflow_run.assert_awaited_once() + mock_concurrency.release_workflow_run_slot.assert_not_awaited() def test_trigger_route_rejects_when_concurrency_limit_reached(): diff --git a/api/tests/test_workflow_text_chat.py b/api/tests/test_workflow_text_chat.py index 4929fae9..185432c9 100644 --- a/api/tests/test_workflow_text_chat.py +++ b/api/tests/test_workflow_text_chat.py @@ -251,6 +251,10 @@ async def test_text_chat_session_creation_executes_initial_assistant_turn( assert workflow_run.initial_context == { "name": "explicit", "draft_only": "kept", + "runtime_configuration": { + "llm_provider": "openai", + "llm_model": "gpt-4.1", + }, } assert "call_duration_seconds" in workflow_run.usage_info assert _log_texts(run_payload["logs"], "rtf-bot-text") == [