Vocero needs to query AquaPortal on behalf of the caller selected by the existing phone link. This adds server-owned conversations and MCP tool execution for voice and HTTP reasoning while leaving the speech pipeline unchanged. Release notes: - Added browser-bound conversations that keep backend credentials server-side and isolate callers. - Connected the eight-tool AquaPortal catalog through authenticated Streamable HTTP with full input schemas and structured results. - Added bounded multi-step tool execution with cancellation, expiry, and stale-result protection. - Updated browser bootstrap, tool progress, reasoning headers, and conversation cleanup. - Disabled legacy unscoped content preloads in MCP mode and documented configuration and deployment constraints. - Preserved disabled-mode contracts and added local SDK, WebSocket, and browser regression coverage. Reviewed-on: AquaPortal/s2s#1
367 lines
16 KiB
Python
367 lines
16 KiB
Python
"""Caller isolation, bounded tool loops, and the unchanged speech protocol."""
|
|
|
|
import ast
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import re
|
|
import threading
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from websockets.sync.server import serve
|
|
|
|
with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False):
|
|
from vocero_api.core import config as settings
|
|
from vocero_api.main import app
|
|
from vocero_api.models.mcp import BackendLogin, IntegrationError, McpToolDefinition
|
|
from vocero_api.providers import backend_login, caller, mcp
|
|
from vocero_api.services import conversations, mcp_reasoning, mcp_tools
|
|
|
|
|
|
def catalog():
|
|
return [
|
|
McpToolDefinition(
|
|
name=name,
|
|
description=f"Published {name} content",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"request": {
|
|
"type": "object",
|
|
"properties": {"report_id": {"type": "string"}, "offset": {"type": "integer"}},
|
|
"additionalProperties": False,
|
|
}
|
|
},
|
|
"additionalProperties": False,
|
|
},
|
|
)
|
|
for name in ("list_reports", "get_report")
|
|
]
|
|
|
|
|
|
def marker(name, arguments):
|
|
return f"<<<VOCERO_MCP_CALL:NAME={name},ARGUMENTS={json.dumps(arguments)}>>>"
|
|
|
|
|
|
def test_mcp_marker_survives_the_existing_speech_sanitizer_without_model_dependencies():
|
|
# Execute only the pure sanitizer and its constants, not the GPU pipeline imports.
|
|
path = Path(__file__).resolve().parents[2] / "speech_to_speech/LLM/lm_output_processor.py"
|
|
tree = ast.parse(path.read_text())
|
|
selected = [
|
|
node
|
|
for node in tree.body
|
|
if (
|
|
isinstance(node, ast.Assign)
|
|
and isinstance(node.targets[0], ast.Name)
|
|
and node.targets[0].id.startswith("_APIPILOT_")
|
|
)
|
|
or (isinstance(node, ast.FunctionDef) and node.name == "_sanitize_apipilot_before_client_and_tts")
|
|
]
|
|
namespace = {"re": re}
|
|
exec(compile(ast.Module(body=selected, type_ignores=[]), str(path), "exec"), namespace)
|
|
sanitize = namespace["_sanitize_apipilot_before_client_and_tts"]
|
|
selection = marker("list_reports", {})
|
|
assert sanitize(selection) == (selection, False, "")
|
|
assert sanitize(selection.replace("VOCERO_MCP", "APIPILOT"))[1] is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"text",
|
|
["<<<VOC", "<<<VOCERO_MCP_CALL:NAME=get_report,ARGUMENTS=not-json>>>", "Before " + marker("list_reports", {})],
|
|
)
|
|
def test_malformed_markers_are_not_treated_as_user_facing_answers(text):
|
|
with pytest.raises(IntegrationError):
|
|
mcp_tools.selection(text)
|
|
|
|
|
|
@pytest.fixture
|
|
def enabled(monkeypatch):
|
|
monkeypatch.setattr(settings, "S2S_MCP_URL", "http://mcp.test/mcp")
|
|
monkeypatch.setattr(settings, "S2S_REASONING_TYPE", "OPENAI")
|
|
monkeypatch.setattr(settings, "OPENAI_API_KEY", "local-model-key")
|
|
monkeypatch.setattr(settings, "S2S_PRIMARY_PROMPT", "")
|
|
login = AsyncMock(
|
|
return_value=BackendLogin.model_validate(
|
|
{"sessionToken": "demo-private-alice", "user": {"id": "alice", "name": "Alice"}}
|
|
)
|
|
)
|
|
monkeypatch.setattr(backend_login, "login", login)
|
|
monkeypatch.setattr(mcp, "discover", AsyncMock(return_value=catalog()))
|
|
monkeypatch.setattr(caller, "_login_by_phone", AsyncMock(side_effect=AssertionError("Legacy login")))
|
|
monkeypatch.setattr(caller, "_fetch_podcast_episode", AsyncMock(side_effect=AssertionError("Unscoped episode")))
|
|
conversations.store.clear()
|
|
yield login
|
|
conversations.store.clear()
|
|
|
|
|
|
def bootstrap(client):
|
|
response = client.post("/api/conversations", json={"phoneNumber": "+56000000001"})
|
|
assert response.status_code == 200
|
|
return response.json()["conversationId"]
|
|
|
|
|
|
def test_http_chains_tools_retains_history_and_ignores_browser_identity(enabled, monkeypatch):
|
|
model = AsyncMock(
|
|
side_effect=[
|
|
marker("list_reports", {}),
|
|
marker("get_report", {"request": {"report_id": "r1"}}),
|
|
"Resumen r1",
|
|
"Seguimiento r1",
|
|
]
|
|
)
|
|
execute = AsyncMock(return_value={"data": {"result": {"id": "r1", "summary": "Accessible finding"}}})
|
|
monkeypatch.setattr(mcp_reasoning, "response_text", model)
|
|
monkeypatch.setattr(mcp, "execute", execute)
|
|
with TestClient(app) as client:
|
|
identity = bootstrap(client)
|
|
headers = {conversations.CONVERSATION_HEADER: identity, "x-vocero-turn": "1"}
|
|
body = {
|
|
"transcript": "Resume el último reporte",
|
|
"phoneNumber": "another-user",
|
|
"episode_id": "private-episode",
|
|
}
|
|
assert client.post("/api/reasoning", headers=headers, json=body).json() == {"answer": "Resumen r1"}
|
|
assert execute.await_args_list[0].args == ("demo-private-alice", "list_reports", {})
|
|
assert execute.await_args_list[1].args == ("demo-private-alice", "get_report", {"request": {"report_id": "r1"}})
|
|
assert client.post("/api/reasoning", headers=headers, json=body).status_code == 409
|
|
headers["x-vocero-turn"] = "2"
|
|
assert (
|
|
client.post("/api/reasoning", headers=headers, json={"transcript": "¿Y la recomendación?"}).status_code
|
|
== 200
|
|
)
|
|
messages = model.await_args_list[-1].args[0]
|
|
assert any(message["content"] == "Resumen r1" for message in messages)
|
|
prompt = messages[0]["content"]
|
|
assert '"additionalProperties": false' in prompt
|
|
assert '"request"' in prompt
|
|
assert "Alice" in prompt
|
|
assert "demo-private" not in json.dumps(messages)
|
|
assert "private-episode" not in json.dumps(messages)
|
|
assert client.post("/api/reasoning", json=body).status_code == 401
|
|
assert client.get("/api/user/phone?phoneNumber=123").status_code == 409
|
|
assert client.get("/api/user/reports").status_code == 409
|
|
assert client.get("/api/apipilot/tools").status_code == 409
|
|
assert client.post("/api/apipilot/execute", json={"name": "get_report"}).status_code == 409
|
|
enabled.assert_awaited_once_with("+56000000001")
|
|
|
|
|
|
@pytest.mark.parametrize("status", [401, 403, 502])
|
|
def test_http_errors_fail_closed_and_revocation_removes_the_conversation(enabled, monkeypatch, status):
|
|
monkeypatch.setattr(mcp, "discover", AsyncMock(side_effect=IntegrationError(status, "Consulta no disponible.")))
|
|
model = AsyncMock()
|
|
monkeypatch.setattr(mcp_reasoning, "response_text", model)
|
|
with TestClient(app) as client:
|
|
identity = bootstrap(client)
|
|
response = client.post(
|
|
"/api/reasoning",
|
|
headers={conversations.CONVERSATION_HEADER: identity, "x-vocero-turn": "1"},
|
|
json={"transcript": "Hola"},
|
|
)
|
|
assert response.status_code == status
|
|
model.assert_not_called()
|
|
if status == 401:
|
|
with pytest.raises(IntegrationError):
|
|
conversations.store.get(client.cookies.get(conversations.COOKIE_NAME), identity)
|
|
|
|
|
|
def test_tool_arguments_duplicates_and_budgets_are_checked_before_execution(enabled, monkeypatch):
|
|
execute = AsyncMock(return_value={"data": {"items": [], "next_offset": None}})
|
|
monkeypatch.setattr(mcp, "execute", execute)
|
|
monkeypatch.setattr(settings, "S2S_MCP_MAX_CALLS_PER_TURN", 3)
|
|
|
|
async def scenario():
|
|
login = await backend_login.login("+56000000001")
|
|
item = conversations.store.create("browser", login)
|
|
item.catalog = catalog()
|
|
turn = mcp_tools.ToolTurn(item, item.generation)
|
|
assert "error" in await turn.call("delete_user", {})
|
|
assert "error" in await turn.call("get_report", {"request": {"user_id": "bob"}})
|
|
execute.assert_not_called()
|
|
await turn.call("list_reports", {"request": {"offset": 0}})
|
|
with pytest.raises(IntegrationError):
|
|
await turn.call("list_reports", {"request": {"offset": 10}})
|
|
fresh = mcp_tools.ToolTurn(item, item.interrupt())
|
|
await fresh.call("list_reports", {})
|
|
with pytest.raises(IntegrationError):
|
|
await fresh.call("list_reports", {})
|
|
assert execute.await_count == 2
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_cancelled_tool_result_cannot_reach_a_new_turn_even_if_transport_returns_late(enabled, monkeypatch):
|
|
async def scenario():
|
|
started = asyncio.Event()
|
|
cancelled = asyncio.Event()
|
|
release = asyncio.Event()
|
|
|
|
async def execute(*args):
|
|
started.set()
|
|
try:
|
|
await release.wait()
|
|
except asyncio.CancelledError:
|
|
cancelled.set()
|
|
await release.wait()
|
|
return {"data": "stale result"}
|
|
|
|
monkeypatch.setattr(mcp, "execute", execute)
|
|
item = conversations.store.create("browser", await backend_login.login("+56000000001"))
|
|
item.catalog = catalog()
|
|
turn = mcp_tools.ToolTurn(item, item.generation)
|
|
item.pending = asyncio.create_task(turn.call("get_report", {}))
|
|
pending = item.pending
|
|
await started.wait()
|
|
item.interrupt()
|
|
await cancelled.wait()
|
|
release.set()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await pending
|
|
assert item.history == []
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_interrupt_for_an_old_http_turn_cannot_cancel_a_new_turn(enabled):
|
|
with TestClient(app) as client:
|
|
identity = bootstrap(client)
|
|
item = conversations.store.get(client.cookies.get(conversations.COOKIE_NAME), identity)
|
|
item.client_turn = 2
|
|
headers = {conversations.CONVERSATION_HEADER: identity, "x-vocero-turn": "1"}
|
|
assert client.post("/api/conversations/current/interrupt", headers=headers).status_code == 204
|
|
assert item.generation == 0
|
|
headers["x-vocero-turn"] = "2"
|
|
assert client.post("/api/conversations/current/interrupt", headers=headers).status_code == 204
|
|
assert item.generation == 1
|
|
|
|
|
|
@pytest.mark.parametrize("speech_order", [False, True])
|
|
def test_voice_relay_chains_tools_across_turns_hides_markers_and_preserves_audio(enabled, monkeypatch, speech_order):
|
|
monkeypatch.setattr(settings, "S2S_REASONING_TYPE", "PROXY")
|
|
monkeypatch.setattr(settings, "S2S_PRIMARY_PROMPT", "Deployment prompt")
|
|
execute = AsyncMock(return_value={"data": {"id": "r1", "summary": "Accessible finding"}})
|
|
monkeypatch.setattr(mcp, "execute", execute)
|
|
received = []
|
|
paths = []
|
|
errors = []
|
|
|
|
def voice(socket):
|
|
try:
|
|
paths.append(socket.request.path)
|
|
socket.send(json.dumps({"type": "session.created"}))
|
|
count = 0
|
|
for raw in socket:
|
|
if isinstance(raw, bytes):
|
|
socket.send(raw)
|
|
continue
|
|
event = json.loads(raw)
|
|
received.append(event)
|
|
if event["type"] == "session.update":
|
|
socket.send(raw)
|
|
if event["type"] != "response.create":
|
|
continue
|
|
step = count % 3
|
|
name = "list_reports" if step == 0 else "get_report"
|
|
text = (
|
|
marker(name, {} if step == 0 else {"request": {"report_id": "r1"}})
|
|
if step < 2
|
|
else "Resultado publicado"
|
|
)
|
|
response_id = f"response-{count}"
|
|
count += 1
|
|
if speech_order:
|
|
socket.send(
|
|
json.dumps(
|
|
{
|
|
"type": "response.output_audio_transcript.done",
|
|
"response_id": response_id,
|
|
"transcript": text,
|
|
}
|
|
)
|
|
)
|
|
socket.send(json.dumps({"type": "response.created", "response": {"id": response_id, "output": None}}))
|
|
# Audio before text must wait until a normal answer is identified.
|
|
socket.send(
|
|
json.dumps(
|
|
{
|
|
"type": "response.audio.delta",
|
|
"response_id": response_id,
|
|
"delta": "hidden-tool-audio" if step < 2 else "answer-audio",
|
|
}
|
|
)
|
|
)
|
|
if not speech_order:
|
|
for part in (text[:3], text[3:]):
|
|
socket.send(
|
|
json.dumps({"type": "response.text.delta", "response_id": response_id, "delta": part})
|
|
)
|
|
done = {
|
|
"type": "response.done",
|
|
"response": {
|
|
"id": response_id,
|
|
"status": "completed",
|
|
"output": None if speech_order else [{"content": [{"text": text}]}],
|
|
},
|
|
}
|
|
socket.send(json.dumps(done))
|
|
socket.send(json.dumps(done))
|
|
except Exception as exc:
|
|
errors.append(exc)
|
|
|
|
with serve(voice, "127.0.0.1", 0) as upstream:
|
|
thread = threading.Thread(target=upstream.serve_forever, daemon=True)
|
|
thread.start()
|
|
monkeypatch.setattr(
|
|
settings, "S2S_PROXY_TARGET", f"ws://127.0.0.1:{upstream.socket.getsockname()[1]}/v1/realtime"
|
|
)
|
|
with TestClient(app) as client:
|
|
identity = bootstrap(client)
|
|
with client.websocket_connect(f"/v1/realtime?conversation_id={identity}") as socket:
|
|
assert socket.receive_json() == {"type": "session.created"}
|
|
socket.send_json(
|
|
{"type": "session.update", "session": {"instructions": "Deployment prompt\nSession RAG notes"}}
|
|
)
|
|
prompt = socket.receive_json()["session"]["instructions"]
|
|
assert '"request"' in prompt and '"offset"' in prompt
|
|
assert "Alice" in prompt and "demo-private" not in prompt
|
|
assert "Session RAG notes" in prompt
|
|
socket.send_bytes(b"\x00\x01")
|
|
assert socket.receive_bytes() == b"\x00\x01"
|
|
for _ in range(5):
|
|
socket.send_json(
|
|
{
|
|
"type": "conversation.item.create",
|
|
"item": {
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "Resume el reporte"}],
|
|
},
|
|
}
|
|
)
|
|
socket.send_json({"type": "response.create"})
|
|
events = []
|
|
while True:
|
|
event = socket.receive_json()
|
|
events.append(event)
|
|
assert "hidden-tool-audio" not in json.dumps(event)
|
|
assert "VOCERO_MCP" not in json.dumps(event)
|
|
if event["type"] == "response.done" and any(e.get("delta") == "answer-audio" for e in events):
|
|
break
|
|
assert sum(event["type"] == "vocero.tool.started" for event in events) == 2
|
|
assert any(event.get("delta") == "answer-audio" for event in events)
|
|
assert execute.await_count == 10
|
|
assert all(call.args[0] == "demo-private-alice" for call in execute.await_args_list)
|
|
assert identity not in paths[0]
|
|
assert "demo-private" not in json.dumps(received)
|
|
thread.join(3)
|
|
assert not errors
|
|
|
|
|
|
def test_proxy_configuration_rejects_a_direct_speech_bypass(enabled, monkeypatch):
|
|
monkeypatch.setattr(settings, "S2S_REASONING_TYPE", "PROXY")
|
|
monkeypatch.setattr(settings, "SPEECH_TO_SPEECH_URL", "ws://speech.test/realtime")
|
|
with TestClient(app) as client:
|
|
assert client.get("/api/config").status_code == 503
|