feat(vocero): add linked content and TALKFIRST greetings (#2)
Opening a report or podcast link now establishes an authenticated starting reference for the conversation. Users can select other accessible content or compare reports while retaining the original link, and TALKFIRST greetings can name the verified report or podcast. Release notes: - Added authenticated report and episode link resolution with bounded lookups and unavailable-content handling - Added explicit conversation selection without restricting searches or changing the reference during comparisons - Added contextual TALKFIRST greetings through both voice-proxy and HTTP reasoning paths - Preserved backend permission checks, conversation isolation, cancellation, and the existing speech protocol Reviewed-on: AquaPortal/s2s#2
This commit is contained in:
@@ -123,11 +123,20 @@ checks reject stale HTTP results and speech callbacks even if cancellation is la
|
||||
With MCP enabled, `services/conversations.py` owns bounded server-side caller
|
||||
credentials. `providers/mcp.py` owns the official SDK transport, while
|
||||
`services/mcp_tools.py` validates selections and enforces per-turn limits.
|
||||
`services/content_context.py` resolves linked metadata and implements the local
|
||||
`select_content` action. Each conversation retains separate original-link and
|
||||
current-selection references; reading or comparing reports does not change the
|
||||
selection. The current podcast is passed as an MCP request header, with every
|
||||
retrieval still subject to backend authorization. TALKFIRST uses the verified
|
||||
title in the opening instructions after link resolution completes.
|
||||
`services/mcp_reasoning.py` and `realtime/mcp_proxy.py` share that executor for
|
||||
HTTP and voice reasoning respectively. The browser stores only its conversation
|
||||
ID in memory and uses an HttpOnly owner cookie. Legacy public content preloads
|
||||
are disabled in this mode; the AquaPortal backend remains the authority for
|
||||
content access. The speech pipeline and its audio protocol are unchanged.
|
||||
In OPENAI mode, MCP TALKFIRST greetings enter the HTTP reasoning path after the
|
||||
speech connection is ready, and use the existing external-answer playback and
|
||||
cancellation lifecycle.
|
||||
|
||||
Read [reasoning](reasoning.md), [context injection](context-injection.md), and
|
||||
[tool calling](tool-calling.md) for the conditions and limitations of each path.
|
||||
|
||||
@@ -7,6 +7,19 @@ in the server's [context services](../../vocero-api/vocero_api/services/context.
|
||||
[context.ts](../../vocero-frontend/src/lib/context.ts), and the
|
||||
[WebSocket client](../../vocero-frontend/src/realtime/client.ts).
|
||||
|
||||
## Authenticated MCP context
|
||||
|
||||
When `S2S_MCP_URL` is configured, the browser submits the page's `report_id` and
|
||||
`episode_id` during conversation creation. The server resolves them through the
|
||||
authenticated MCP client and keeps compact verified references separately from
|
||||
the current conversational selection. No legacy profile or episode preload is
|
||||
used. TALKFIRST can mention the verified title after this preparation finishes.
|
||||
The link never restricts searches or access to other permitted content.
|
||||
|
||||
See [tool calling](tool-calling.md#authenticated-aquaportal-mcp) for selection,
|
||||
authorization, timeout, and missing-content behavior. The controls and public
|
||||
episode fetch below describe the legacy path used when MCP is disabled.
|
||||
|
||||
## Independent controls
|
||||
|
||||
`S2S_INJECT_PROFILE` defaults to false and gates server loading of caller,
|
||||
|
||||
@@ -16,7 +16,7 @@ fallback on this path. AquaPortal checks current permissions on every request,
|
||||
including its administrator and assigned report-manager access rules.
|
||||
|
||||
The backend phone login must return `sessionToken` plus `user.id` and `user.name`.
|
||||
The only allowed tool names are:
|
||||
The allowed backend tool names are:
|
||||
|
||||
| Tool | Content |
|
||||
|---|---|
|
||||
@@ -64,11 +64,47 @@ Generation checks reject late results even if the transport does not stop in
|
||||
time. The browser receives `vocero.tool.started` / `vocero.tool.failed` status
|
||||
events, without backend credentials or raw tool arguments.
|
||||
|
||||
Automatic current-episode context from `episode_id` is not wired yet. The model
|
||||
must resolve a podcast with the catalog rather than fetching an unauthenticated
|
||||
episode URL. See [usage](usage.md#authenticated-aquaportal-mcp) for configuration,
|
||||
conversation endpoints, single-worker constraints, and the retained unsafe
|
||||
phone selector.
|
||||
Conversation creation accepts optional `report_id` and `episode_id` selectors.
|
||||
The server resolves them using the authenticated MCP client, retaining only
|
||||
verified IDs, titles, dates, and podcast availability. Report podcasts use
|
||||
`report:<full-report-id>`; daily episodes use `daily:<episode-id>`. When both
|
||||
selectors are present, the explicit daily episode determines the podcast and
|
||||
the report remains a separate reference. A missing daily episode never falls
|
||||
back to the report podcast. Link resolution has a ten-second total budget;
|
||||
unavailable content permits a generic conversation, while invalid authentication
|
||||
still prevents startup. No public episode endpoint is used.
|
||||
|
||||
The original link and current selection are separate, immutable metadata
|
||||
objects owned by each conversation. The model uses the current selection for
|
||||
"this report/episode" and the original link for "the report from the link".
|
||||
`get_podcast` and `read_podcast_transcript` receive the current podcast through
|
||||
`X-Vocero-Podcast-Id`; explicit `podcast_id` arguments override the header.
|
||||
Every retrieval still checks backend permissions. Search and comparison calls
|
||||
receive only their requested filters and do not change the current selection.
|
||||
|
||||
Vocero adds one local tool, `select_content`, to the discovered catalog:
|
||||
|
||||
```json
|
||||
{"request":{"kind":"report","id":"report-example"}}
|
||||
```
|
||||
|
||||
`kind` is `report` or `podcast`; podcast selections use the prefixed ID. This
|
||||
action performs an authorized lookup before replacing the current selection.
|
||||
It preserves the original link and changes no backend data. Choosing a report
|
||||
without a podcast or a daily podcast clears the previous, unrelated reference.
|
||||
Failed, ambiguous, or cancelled lookups do not replace the selection. Reading
|
||||
another report for comparison alone never invokes this action. Both reasoning
|
||||
paths refresh their instructions after a successful selection.
|
||||
|
||||
TALKFIRST opening instructions mention the linked report's verified title, or
|
||||
the linked podcast's title when no report is available. They invite questions
|
||||
about this or other accessible content without an unsolicited summary. Missing
|
||||
content gets a brief explanation without exposing an inaccessible title. The
|
||||
opening instruction is consumed after the first completed answer. TALK and
|
||||
manual startup retain their existing greeting behavior.
|
||||
|
||||
See [usage](usage.md#authenticated-aquaportal-mcp) for configuration, conversation
|
||||
endpoints, single-worker constraints, and the retained unsafe phone selector.
|
||||
|
||||
## Native realtime function calls
|
||||
|
||||
|
||||
@@ -66,6 +66,16 @@ code before claiming a root cause is resolved.
|
||||
|
||||
## The assistant misses caller or report context
|
||||
|
||||
With MCP enabled, check that `/api/conversations` receives `report_id` or
|
||||
`episode_id` from the page and completes before voice startup. Report IDs retain
|
||||
their full prefix: `report-example` selects podcast `report:report-example`.
|
||||
Link resolution uses the same user's backend session and has a ten-second
|
||||
budget. An inaccessible or unavailable link produces a generic conversation,
|
||||
not a fallback to the latest report. Login or catalog failures still block
|
||||
startup. A report may exist without a podcast. To change the conversational
|
||||
subject, the agent uses `select_content`; search and comparison calls do not
|
||||
change it. MCP ignores the legacy injection flags below.
|
||||
|
||||
- Verify the page has an encoded `phoneNumber` or the intended `episode_id`.
|
||||
- Inspect `/api/user/phone` and context-fetch logs for HTTP/JSON failures.
|
||||
- Confirm profile injection is enabled; a visible profile alone does not prove
|
||||
|
||||
+40
-11
@@ -131,13 +131,14 @@ not returned to the browser. Dynamic ApiPilot tools are configured through
|
||||
|
||||
Operator RAG text is applied through the RAG dialog and updates live
|
||||
instructions. Caller links may include `phoneNumber=%2B...`; episode links may
|
||||
include `episode_id=...`. See [context injection](context-injection.md) for
|
||||
include `episode_id=...`; MCP report links may include `report_id=...`.
|
||||
See [context injection](context-injection.md) for
|
||||
enabled data sources and limits.
|
||||
|
||||
### Authenticated AquaPortal MCP
|
||||
|
||||
`S2S_MCP_URL` enables the MCP client transport. Set it to the backend's complete
|
||||
Streamable HTTP endpoint, for example `https://backend.example.test/api/v1/mcp`.
|
||||
Streamable HTTP endpoint, for example `https://backend.example.test/api/v1/mcp/`.
|
||||
Set `S2S_LOGIN_BY_PHONE` to that backend's `POST /api/v1/auth/phone-login` URL.
|
||||
The phone parameter remains an identity selector without proof of ownership.
|
||||
MCP uses the returned opaque `sessionToken`, never an ApiPilot API key or service token.
|
||||
@@ -165,7 +166,8 @@ and caller HTTP routes return 409 while MCP is enabled.
|
||||
| `S2S_CONVERSATION_TTL_SEC` | `3600` | Absolute conversation lifetime, clamped to 60–86400 seconds |
|
||||
| `S2S_MAX_CONVERSATIONS` | `1000` | Process-local conversation capacity, clamped to 1–10000 |
|
||||
|
||||
`POST /api/conversations` accepts `{"phoneNumber":"+56000000000"}` and returns
|
||||
`POST /api/conversations` accepts `{"phoneNumber":"+56000000000"}` with optional
|
||||
`report_id` and `episode_id` fields, and returns
|
||||
`conversationId`, `expiresIn`, and a minimal caller profile. An HttpOnly,
|
||||
SameSite=Strict cookie binds the browser to its conversations; HTTPS sets Secure.
|
||||
Backend credentials stay in memory on the API server. HTTP conversation requests
|
||||
@@ -191,12 +193,29 @@ reasoning retains up to six completed turns, bounded to 96,000 characters,
|
||||
within that conversation; the speech service keeps its existing conversation
|
||||
history. These histories are cleared when their sessions end.
|
||||
|
||||
The browser waits for successful phone login and discovery before starting an
|
||||
MCP conversation. Failure blocks startup and displays a retryable error. Phone
|
||||
and `episode_id` page parameters remain supported, but automatic selection of
|
||||
the episode from the link is deferred: resolve it explicitly with the podcast
|
||||
tools for now. Changing browser-supplied profile, phone, or episode values on a
|
||||
reasoning request cannot change the stored backend identity.
|
||||
The browser waits for phone login, linked-content resolution, and tool discovery
|
||||
before starting an MCP conversation. Login or discovery failure blocks startup
|
||||
and displays a retryable error. Link resolution is limited to ten seconds and
|
||||
uses only authenticated MCP tools. Missing, inaccessible, or temporarily
|
||||
unavailable linked content permits a generic conversation; an expired backend
|
||||
session still prevents startup. The browser allows thirty seconds for login
|
||||
and link resolution together.
|
||||
|
||||
For example, `?phoneNumber=%2B56000000000&report_id=report-example` selects the
|
||||
report and its podcast, `report:report-example`, when available. The full report
|
||||
ID is preserved. An `episode_id` selects a daily podcast, `daily:<episode_id>`.
|
||||
When both are present, the explicit daily episode determines the podcast and
|
||||
`report_id` remains a separate report reference. No unavailable episode is
|
||||
silently replaced with a different one.
|
||||
|
||||
The link provides the initial subject, not a search filter or an access grant.
|
||||
Users can ask about any accessible report, compare multiple reports, or choose
|
||||
another subject. The local `select_content` action validates that choice and
|
||||
updates the current reference while preserving the original link for follow-ups.
|
||||
Merely searching or reading content does not change the selection. A new
|
||||
conversation, including after restart, resolves the page's selectors again.
|
||||
Changing browser-supplied profile, phone, report, or episode values on a
|
||||
reasoning request cannot change the stored identity or selection.
|
||||
|
||||
## Theme and session startup
|
||||
|
||||
@@ -213,7 +232,7 @@ gets an eight-second loading window before the interface allows a generic sessio
|
||||
|---|---|
|
||||
| Empty/default | User taps to start |
|
||||
| `TALK` | Attempt automatic start and inject the Spanish wake phrase as soon as configuration and caller context are ready |
|
||||
| `TALKFIRST` | Same automatic startup behavior as `TALK` |
|
||||
| `TALKFIRST` | Same startup trigger as `TALK`; with MCP, greet using the linked report or podcast title after authenticated resolution |
|
||||
|
||||
Automatic startup runs once per page load, without a startup dialog or artificial
|
||||
delay; optional reports and tools can still be loading. Browser permission and
|
||||
@@ -221,7 +240,17 @@ audio policies can still require a gesture. When audio is blocked, an inline hin
|
||||
asks the user to tap the microphone button. Permission or connection failures
|
||||
show the error and leave the same button available to retry. Stopping a session
|
||||
does not start it again automatically.
|
||||
The greeting uses the same `hola vocero` input as wake-word handling.
|
||||
The greeting uses the same `hola vocero` input as wake-word handling. With MCP
|
||||
and TALKFIRST, the greeting names the linked report, or the podcast when no
|
||||
report is available, and invites questions about this or other reports. It does
|
||||
not summarize automatically. A missing reference gets a brief explanation;
|
||||
links without a content selector receive a generic greeting.
|
||||
|
||||
In `PROXY`, the relay supplies the verified metadata in the initial speech
|
||||
instructions. In `OPENAI`, the controller sends the opening input through the
|
||||
authenticated HTTP reasoning path after the speech connection is ready, then
|
||||
uses the existing external-answer playback. Speaking first or stopping the
|
||||
session cancels a pending greeting. Neither path changes the speech service.
|
||||
|
||||
On browsers with speech recognition, **Activar con «hola vocero»** opts into
|
||||
wake-word listening for the current page session. The interface shows when the
|
||||
|
||||
@@ -9,6 +9,9 @@ the implementation at the recorded date; current operating instructions live in
|
||||
|
||||
### Added
|
||||
|
||||
- Added authenticated report and episode link context, explicit conversation subject
|
||||
selection, and report-title greetings in TALKFIRST. Searches and comparisons remain
|
||||
unrestricted by the link; missing content does not select an unrelated podcast.
|
||||
- Added optional cookie-bound Vocero conversations that keep each caller's AquaPortal
|
||||
session on the server, with expiry, cleanup, and isolated MCP discovery and execution.
|
||||
- Added bounded MCP tool loops to HTTP reasoning and the voice relay, preserving full
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Linked content, explicit conversation selection, and authenticated greeting context."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
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, mcp
|
||||
from vocero_api.realtime.mcp_proxy import McpRelay
|
||||
from vocero_api.services import content_context, conversations, mcp_reasoning, mcp_tools
|
||||
|
||||
REPORT_ID = "report-11111111-2222-4333-8444-555555555555"
|
||||
TITLE = "Alimentación de La Leona"
|
||||
OPENING_INSTRUCTION = "For the opening greeting only"
|
||||
|
||||
|
||||
def report_result(report_id=REPORT_ID, title=TITLE, has_podcast=True):
|
||||
return {
|
||||
"data": {
|
||||
"result": {
|
||||
"status": "found",
|
||||
"detail": {
|
||||
"report": {"id": report_id, "title": title, "date": "2026-10-08", "has_podcast": has_podcast},
|
||||
"summary": "Full details must not be copied to the startup prompt.",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def podcast_result(podcast_id="daily:episode-1", title="Resumen diario"):
|
||||
return {
|
||||
"data": {"podcast": {"id": podcast_id, "kind": podcast_id.split(":")[0], "title": title, "date": "2026-10-08"}}
|
||||
}
|
||||
|
||||
|
||||
def marker(name, arguments):
|
||||
return f"<<<VOCERO_MCP_CALL:NAME={name},ARGUMENTS={json.dumps(arguments)}>>>"
|
||||
|
||||
|
||||
@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, "S2S_START_SESSION_MODE", "TALKFIRST")
|
||||
monkeypatch.setattr(settings, "S2S_PRIMARY_PROMPT", "")
|
||||
monkeypatch.setattr(settings, "OPENAI_API_KEY", "fixture-key")
|
||||
monkeypatch.setattr(
|
||||
backend_login,
|
||||
"login",
|
||||
AsyncMock(return_value=BackendLogin(sessionToken="private-alice", user={"id": "alice", "name": "Alice"})),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mcp,
|
||||
"discover",
|
||||
AsyncMock(return_value=[McpToolDefinition(name=name, parameters={}) for name in sorted(mcp.ALLOWED_TOOLS)]),
|
||||
)
|
||||
execute = AsyncMock(return_value=report_result())
|
||||
monkeypatch.setattr(mcp, "execute", execute)
|
||||
conversations.store.clear()
|
||||
yield execute
|
||||
conversations.store.clear()
|
||||
|
||||
|
||||
def bootstrap(client, **selectors):
|
||||
response = client.post("/api/conversations", json={"phoneNumber": "+56000000001", **selectors})
|
||||
assert response.status_code == 200, response.text
|
||||
item = conversations.store.get(client.cookies.get(conversations.COOKIE_NAME), response.json()["conversationId"])
|
||||
return item
|
||||
|
||||
|
||||
def test_report_link_is_resolved_once_with_the_caller_token_and_only_metadata_is_retained(enabled):
|
||||
with TestClient(app) as client:
|
||||
item = bootstrap(client, report_id=REPORT_ID)
|
||||
enabled.assert_awaited_once_with(
|
||||
"private-alice", "get_report", {"request": {"report_id": REPORT_ID, "limit": 1}}
|
||||
)
|
||||
assert item.linked_content.report.title == TITLE
|
||||
assert item.current_content.podcast.id == f"report:{REPORT_ID}"
|
||||
assert item.linked_content == item.current_content
|
||||
prompt = mcp_tools.instructions("Base instructions", item)
|
||||
assert TITLE in prompt and REPORT_ID in prompt and OPENING_INSTRUCTION in prompt
|
||||
assert "Full details" not in prompt and "private-alice" not in prompt
|
||||
catalog = client.get("/api/mcp/tools", headers={conversations.CONVERSATION_HEADER: item.id}).json()["tools"]
|
||||
assert {tool["name"] for tool in catalog} == mcp.ALLOWED_TOOLS | {"select_content"}
|
||||
|
||||
|
||||
def test_no_link_needs_no_lookup_and_existing_clients_keep_working(enabled):
|
||||
with TestClient(app) as client:
|
||||
item = bootstrap(client)
|
||||
enabled.assert_not_called()
|
||||
assert item.current_content.report is None and item.current_content.podcast is None
|
||||
assert not item.current_content.unavailable
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"result",
|
||||
[
|
||||
{"error": "Denied: never expose a private title"},
|
||||
{"data": {"result": {"status": "ambiguous", "choices": []}}},
|
||||
report_result(report_id="some-other-report"),
|
||||
{"data": {"result": {"status": "found", "detail": {"report": {"id": REPORT_ID}}}}},
|
||||
],
|
||||
)
|
||||
def test_unavailable_or_invalid_links_allow_a_generic_conversation_without_leaking_details(enabled, result):
|
||||
enabled.return_value = result
|
||||
with TestClient(app) as client:
|
||||
item = bootstrap(client, report_id=REPORT_ID)
|
||||
assert item.current_content.unavailable
|
||||
assert item.current_content.report is None and item.current_content.podcast is None
|
||||
prompt = mcp_tools.instructions("", item)
|
||||
assert TITLE not in prompt and "private title" not in prompt
|
||||
assert OPENING_INSTRUCTION in prompt
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 403, 502])
|
||||
def test_link_transport_failures_preserve_authentication_boundary(enabled, status):
|
||||
enabled.side_effect = IntegrationError(status, "Upstream detail must stay private")
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/conversations", json={"phoneNumber": "+56000000001", "report_id": REPORT_ID})
|
||||
if status == 401:
|
||||
assert response.status_code == 401
|
||||
assert not conversations.store._items
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
item = conversations.store.get(
|
||||
client.cookies.get(conversations.COOKIE_NAME), response.json()["conversationId"]
|
||||
)
|
||||
assert item.current_content.unavailable
|
||||
assert "Upstream detail" not in mcp_tools.instructions("", item)
|
||||
|
||||
|
||||
def test_link_lookup_timeout_does_not_block_the_whole_conversation(enabled, monkeypatch):
|
||||
cancelled = []
|
||||
|
||||
async def slow_lookup(*args):
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
finally:
|
||||
cancelled.append(True)
|
||||
|
||||
enabled.side_effect = slow_lookup
|
||||
monkeypatch.setattr(content_context, "LINK_LOOKUP_TIMEOUT_SEC", 0.01)
|
||||
with TestClient(app) as client:
|
||||
assert bootstrap(client, report_id=REPORT_ID).current_content.unavailable
|
||||
assert cancelled == [True]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("episode_available", [True, False])
|
||||
def test_explicit_daily_episode_wins_without_falling_back_to_the_report_podcast(enabled, episode_available):
|
||||
enabled.side_effect = [report_result(), podcast_result() if episode_available else {"error": "Unavailable"}]
|
||||
with TestClient(app) as client:
|
||||
item = bootstrap(client, report_id=REPORT_ID, episode_id="episode-1")
|
||||
assert item.linked_content.report.id == REPORT_ID
|
||||
assert (item.linked_content.podcast.id if item.linked_content.podcast else None) == (
|
||||
"daily:episode-1" if episode_available else None
|
||||
)
|
||||
assert item.linked_content.unavailable is not episode_available
|
||||
assert enabled.await_args_list[1].args == (
|
||||
"private-alice",
|
||||
"get_podcast",
|
||||
{"request": {"podcast_id": "daily:episode-1"}},
|
||||
)
|
||||
|
||||
|
||||
def test_linked_content_is_isolated_between_tabs_and_cleared_when_closed(enabled):
|
||||
enabled.side_effect = [report_result(), report_result("report-b", "Mortalidad", False)]
|
||||
with TestClient(app) as client:
|
||||
first = bootstrap(client, report_id=REPORT_ID)
|
||||
second = bootstrap(client, report_id="report-b")
|
||||
assert second.current_content.podcast is None
|
||||
client.delete("/api/conversations/current", headers={conversations.CONVERSATION_HEADER: first.id})
|
||||
assert first.linked_content.report is None and first.current_content.podcast is None
|
||||
assert second.current_content.report.id == "report-b"
|
||||
|
||||
|
||||
def test_search_and_comparison_leave_selection_unchanged_but_explicit_selection_replaces_it(enabled):
|
||||
async def scenario():
|
||||
linked = await content_context.from_link("private-alice", REPORT_ID, None)
|
||||
item = conversations.store.create("browser", await backend_login.login("+56000000001"), linked)
|
||||
item.catalog = await content_context.catalog(item.backend_token)
|
||||
turn = mcp_tools.ToolTurn(item, item.generation)
|
||||
enabled.return_value = report_result("report-b", "Mortalidad", False)
|
||||
await turn.call("list_reports", {"request": {"start_date": "2026-10-01"}})
|
||||
assert enabled.await_args.args == ("private-alice", "list_reports", {"request": {"start_date": "2026-10-01"}})
|
||||
await turn.call("get_report", {"request": {"report_id": "report-b"}})
|
||||
assert item.current_content == linked
|
||||
await turn.call("select_content", {"request": {"kind": "report", "id": "report-b"}})
|
||||
assert item.current_content.report.id == "report-b" and item.current_content.podcast is None
|
||||
assert item.linked_content == linked
|
||||
enabled.return_value = {"error": "Denied"}
|
||||
assert "error" in await turn.call("select_content", {"request": {"kind": "report", "id": "private-report"}})
|
||||
assert item.current_content.report.id == "report-b"
|
||||
await turn.call("get_podcast", {})
|
||||
assert enabled.await_args.kwargs == {"podcast_id": None}
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_explicit_podcast_arguments_are_preserved_and_reading_does_not_change_the_default(enabled):
|
||||
async def scenario():
|
||||
linked = await content_context.from_link("private-alice", REPORT_ID, None)
|
||||
item = conversations.store.create("browser", await backend_login.login("+56000000001"), linked)
|
||||
item.catalog = await content_context.catalog(item.backend_token)
|
||||
enabled.return_value = podcast_result()
|
||||
turn = mcp_tools.ToolTurn(item, item.generation)
|
||||
await turn.call("get_podcast", {"request": {"podcast_id": "daily:episode-1"}})
|
||||
assert enabled.await_args.args[2] == {"request": {"podcast_id": "daily:episode-1"}}
|
||||
assert enabled.await_args.kwargs == {"podcast_id": f"report:{REPORT_ID}"}
|
||||
assert item.current_content == linked
|
||||
await turn.call("select_content", {"request": {"kind": "podcast", "id": "daily:episode-1"}})
|
||||
assert item.current_content.podcast.id == "daily:episode-1" and item.current_content.report is None
|
||||
await turn.call("read_podcast_transcript", {"request": {"query": "oxígeno"}})
|
||||
assert enabled.await_args.kwargs == {"podcast_id": "daily:episode-1"}
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_cancelled_selection_cannot_replace_the_current_reference(enabled):
|
||||
async def scenario():
|
||||
linked = await content_context.from_link("private-alice", REPORT_ID, None)
|
||||
item = conversations.store.create("browser", await backend_login.login("+56000000001"), linked)
|
||||
item.catalog = await content_context.catalog(item.backend_token)
|
||||
started, release = asyncio.Event(), asyncio.Event()
|
||||
|
||||
async def slow_result(*args):
|
||||
started.set()
|
||||
try:
|
||||
await release.wait()
|
||||
except asyncio.CancelledError:
|
||||
await release.wait()
|
||||
return report_result("report-b", "Mortalidad")
|
||||
|
||||
enabled.side_effect = slow_result
|
||||
turn = mcp_tools.ToolTurn(item, item.generation)
|
||||
item.pending = asyncio.create_task(
|
||||
turn.call("select_content", {"request": {"kind": "report", "id": "report-b"}})
|
||||
)
|
||||
task = item.pending
|
||||
await started.wait()
|
||||
item.interrupt()
|
||||
release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert item.current_content == linked
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_implicit_podcast_reads_use_the_new_selection_within_the_same_turn(enabled):
|
||||
async def scenario():
|
||||
linked = await content_context.from_link("private-alice", REPORT_ID, None)
|
||||
item = conversations.store.create("browser", await backend_login.login("+56000000001"), linked)
|
||||
item.catalog = await content_context.catalog(item.backend_token)
|
||||
turn = mcp_tools.ToolTurn(item, item.generation)
|
||||
await turn.call("get_podcast", {})
|
||||
assert enabled.await_args.kwargs == {"podcast_id": f"report:{REPORT_ID}"}
|
||||
enabled.return_value = podcast_result()
|
||||
await turn.call("select_content", {"request": {"kind": "podcast", "id": "daily:episode-1"}})
|
||||
await turn.call("get_podcast", {})
|
||||
assert enabled.await_args.kwargs == {"podcast_id": "daily:episode-1"}
|
||||
with pytest.raises(IntegrationError):
|
||||
await turn.call("get_podcast", {})
|
||||
enabled.reset_mock()
|
||||
fresh = mcp_tools.ToolTurn(item, item.generation)
|
||||
assert "error" in await fresh.call("select_content", {"request": {"kind": "report", "id": " "}})
|
||||
enabled.assert_not_called()
|
||||
assert item.current_content.podcast.id == "daily:episode-1"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_revoked_authentication_stops_remaining_link_lookups(enabled):
|
||||
enabled.side_effect = IntegrationError(401, "La sesión expiró.")
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/conversations",
|
||||
json={"phoneNumber": "+56000000001", "report_id": REPORT_ID, "episode_id": "episode-1"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert enabled.await_count == 1
|
||||
assert not conversations.store._items
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["TALKFIRST", "TALK", ""])
|
||||
def test_http_greeting_and_followup_use_server_context_and_refresh_after_selection(enabled, monkeypatch, mode):
|
||||
monkeypatch.setattr(settings, "S2S_START_SESSION_MODE", mode)
|
||||
model = AsyncMock(
|
||||
side_effect=[
|
||||
"Hola, Alice.",
|
||||
marker("select_content", {"request": {"kind": "report", "id": "report-b"}}),
|
||||
"Revisemos mortalidad.",
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(mcp_reasoning, "response_text", model)
|
||||
with TestClient(app) as client:
|
||||
item = bootstrap(client, report_id=REPORT_ID)
|
||||
headers = {conversations.CONVERSATION_HEADER: item.id, "X-Vocero-Turn": "1"}
|
||||
assert client.post("/api/reasoning", headers=headers, json={"transcript": "hola vocero"}).status_code == 200
|
||||
opening = model.await_args_list[0].args[0][0]["content"]
|
||||
assert TITLE in opening
|
||||
assert (OPENING_INSTRUCTION in opening) is (mode == "TALKFIRST")
|
||||
assert not item.greeting_pending
|
||||
enabled.return_value = report_result("report-b", "Mortalidad")
|
||||
headers["X-Vocero-Turn"] = "2"
|
||||
assert (
|
||||
client.post(
|
||||
"/api/reasoning",
|
||||
headers=headers,
|
||||
json={"transcript": "Ahora revisemos el otro reporte", "report_id": "browser-spoof"},
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
current_prompt = model.await_args.args[0][0]["content"]
|
||||
assert '"id": "report-b"' in current_prompt and TITLE in current_prompt
|
||||
assert "browser-spoof" not in current_prompt and OPENING_INSTRUCTION not in current_prompt
|
||||
assert item.linked_content.report.id == REPORT_ID
|
||||
|
||||
|
||||
def test_voice_selection_refreshes_instructions_before_the_next_response_without_new_speech_protocol(enabled):
|
||||
async def scenario():
|
||||
linked = await content_context.from_link("private-alice", REPORT_ID, None)
|
||||
item = conversations.store.create("browser", await backend_login.login("+56000000001"), linked)
|
||||
item.catalog = await content_context.catalog(item.backend_token)
|
||||
item.greeting_pending = False
|
||||
browser, speech = AsyncMock(), AsyncMock()
|
||||
relay = McpRelay(browser, speech, item)
|
||||
relay.base_instructions = "Existing voice instructions"
|
||||
enabled.return_value = report_result("report-b", "Mortalidad")
|
||||
await relay.tool_call("select_content", {"request": {"kind": "report", "id": "report-b"}}, relay.turn)
|
||||
events = [json.loads(call.args[0]) for call in speech.send.await_args_list]
|
||||
assert [event["type"] for event in events] == ["session.update", "conversation.item.create", "response.create"]
|
||||
assert "Existing voice instructions" in events[0]["session"]["instructions"]
|
||||
assert '"id": "report-b"' in events[0]["session"]["instructions"]
|
||||
assert TITLE in events[0]["session"]["instructions"]
|
||||
assert "private-alice" not in json.dumps(events)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("available", [True, False])
|
||||
def test_voice_talkfirst_has_verified_link_context_before_greeting_without_a_session_ack(
|
||||
enabled, monkeypatch, available
|
||||
):
|
||||
monkeypatch.setattr(settings, "S2S_REASONING_TYPE", "PROXY")
|
||||
enabled.return_value = report_result() if available else {"error": "Inaccessible title must not be disclosed"}
|
||||
received, errors = [], []
|
||||
refreshed = threading.Event()
|
||||
|
||||
def voice(socket):
|
||||
try:
|
||||
socket.send(json.dumps({"type": "session.created"}))
|
||||
for raw in socket:
|
||||
event = json.loads(raw)
|
||||
received.append(event)
|
||||
if event["type"] == "session.update" and OPENING_INSTRUCTION not in event["session"]["instructions"]:
|
||||
refreshed.set()
|
||||
if event["type"] == "response.create":
|
||||
socket.send(json.dumps({"type": "response.created", "response": {"id": "opening"}}))
|
||||
socket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "response.done",
|
||||
"response": {
|
||||
"id": "opening",
|
||||
"status": "completed",
|
||||
"output": [{"content": [{"text": "Hola, Alice."}]}],
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
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:
|
||||
item = bootstrap(client, report_id=REPORT_ID)
|
||||
with client.websocket_connect(f"/v1/realtime?conversation_id={item.id}") as socket:
|
||||
assert socket.receive_json()["type"] == "session.created"
|
||||
socket.send_json({"type": "session.update", "session": {"instructions": "Existing instructions"}})
|
||||
socket.send_json(
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"role": "user",
|
||||
"type": "message",
|
||||
"content": [{"type": "input_text", "text": "hola vocero"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
socket.send_json({"type": "response.create"})
|
||||
assert socket.receive_json()["type"] == "response.created"
|
||||
assert socket.receive_json()["type"] == "response.done"
|
||||
assert refreshed.wait(2)
|
||||
assert not item.greeting_pending
|
||||
thread.join(3)
|
||||
assert not errors
|
||||
assert [event["type"] for event in received[:3]] == [
|
||||
"session.update",
|
||||
"conversation.item.create",
|
||||
"response.create",
|
||||
]
|
||||
initial = received[0]["session"]["instructions"]
|
||||
assert OPENING_INSTRUCTION in initial and "Existing instructions" in initial
|
||||
assert (TITLE in initial) is available
|
||||
assert "Inaccessible title" not in initial
|
||||
assert "private-alice" not in json.dumps(received)
|
||||
@@ -113,6 +113,7 @@ def test_backend_login_validates_exact_contract_without_leaking_credentials(enab
|
||||
@pytest.mark.parametrize("use_sse", [False, True])
|
||||
def test_sdk_discovery_and_execution_preserve_nested_schema_results_and_per_user_headers(enabled, monkeypatch, use_sse):
|
||||
calls = []
|
||||
original = httpx.AsyncClient
|
||||
|
||||
def upstream(request):
|
||||
calls.append(request)
|
||||
@@ -141,6 +142,8 @@ def test_sdk_discovery_and_execution_preserve_nested_schema_results_and_per_user
|
||||
else:
|
||||
assert method == "tools/call"
|
||||
assert message["params"]["arguments"] == {"request": {"report_id": "r1"}}
|
||||
user = request.headers["authorization"].removeprefix("Bearer ")
|
||||
assert request.headers["x-vocero-podcast-id"] == f"report:{user}"
|
||||
result = {
|
||||
"content": [],
|
||||
"structuredContent": {"result": {"status": "found", "caller": request.headers["authorization"]}},
|
||||
@@ -153,11 +156,7 @@ def test_sdk_discovery_and_execution_preserve_nested_schema_results_and_per_user
|
||||
return httpx.Response(200, json=body)
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp,
|
||||
"http_client",
|
||||
lambda token: httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(upstream), headers={"Authorization": f"Bearer {token}"}
|
||||
),
|
||||
mcp.httpx, "AsyncClient", lambda **kwargs: original(transport=httpx.MockTransport(upstream), **kwargs)
|
||||
)
|
||||
|
||||
async def scenario():
|
||||
@@ -165,7 +164,10 @@ def test_sdk_discovery_and_execution_preserve_nested_schema_results_and_per_user
|
||||
assert [tool.name for tool in catalog] == ["get_report"]
|
||||
assert catalog[0].parameters["properties"]["request"] == {"type": "object"}
|
||||
outputs = await asyncio.gather(
|
||||
*(mcp.execute(user, "get_report", {"request": {"report_id": "r1"}}) for user in ("alice", "bob"))
|
||||
*(
|
||||
mcp.execute(user, "get_report", {"request": {"report_id": "r1"}}, podcast_id=f"report:{user}")
|
||||
for user in ("alice", "bob")
|
||||
)
|
||||
)
|
||||
assert [output["data"]["result"]["caller"] for output in outputs] == ["Bearer alice", "Bearer bob"]
|
||||
|
||||
@@ -179,7 +181,7 @@ def test_mcp_transport_errors_are_safe_and_preserve_authentication_status(enable
|
||||
monkeypatch.setattr(
|
||||
mcp,
|
||||
"http_client",
|
||||
lambda token: httpx.AsyncClient(
|
||||
lambda token, podcast_id=None: httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(lambda req: httpx.Response(status, text="secret-upstream-body"))
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,8 +6,8 @@ from fastapi import APIRouter, HTTPException, Request, Response
|
||||
|
||||
from vocero_api.core import config as settings
|
||||
from vocero_api.models.mcp import ConversationRequest, ConversationResponse, IntegrationError, McpCatalogResponse
|
||||
from vocero_api.providers import backend_login, mcp
|
||||
from vocero_api.services import conversations
|
||||
from vocero_api.providers import backend_login
|
||||
from vocero_api.services import content_context, conversations
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -23,8 +23,11 @@ async def create_conversation(body: ConversationRequest, request: Request, respo
|
||||
try:
|
||||
conversations.check_origin(request)
|
||||
login = await backend_login.login(body.phoneNumber.strip())
|
||||
linked_content = await content_context.from_link(
|
||||
login.sessionToken.get_secret_value(), body.report_id, body.episode_id
|
||||
)
|
||||
browser = request.cookies.get(conversations.COOKIE_NAME) or secrets.token_urlsafe(32)
|
||||
item = conversations.store.create(browser, login)
|
||||
item = conversations.store.create(browser, login, linked_content)
|
||||
except IntegrationError as exc:
|
||||
raise HTTPException(exc.status, exc.message) from None
|
||||
response.set_cookie(
|
||||
@@ -69,7 +72,7 @@ async def list_tools(request: Request, response: Response) -> McpCatalogResponse
|
||||
item: conversations.Conversation | None = None
|
||||
try:
|
||||
item = conversations.resolve(request)
|
||||
catalog = await mcp.discover(item.backend_token)
|
||||
catalog = await content_context.catalog(item.backend_token)
|
||||
item.require_active()
|
||||
item.catalog = catalog
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Compact, immutable references returned by authenticated content lookups."""
|
||||
|
||||
from datetime import date
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
|
||||
|
||||
ContentId = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=200)]
|
||||
PodcastId = Annotated[str, StringConstraints(pattern=r"^(daily|report):\S.{0,199}$")]
|
||||
|
||||
|
||||
class ContentReference(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
id: ContentId
|
||||
title: str = Field(min_length=1, max_length=2000)
|
||||
date: date
|
||||
|
||||
|
||||
class ReportReference(ContentReference):
|
||||
has_podcast: bool
|
||||
|
||||
|
||||
class PodcastReference(ContentReference):
|
||||
id: PodcastId
|
||||
kind: Literal["daily", "report"]
|
||||
|
||||
|
||||
class ConversationContent(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
report: ReportReference | None = None
|
||||
podcast: PodcastReference | None = None
|
||||
unavailable: bool = False
|
||||
|
||||
|
||||
class ContentSelection(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
kind: Literal["report", "podcast"]
|
||||
id: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=207)]
|
||||
|
||||
|
||||
class SelectContentRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
request: ContentSelection
|
||||
@@ -4,10 +4,14 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||
|
||||
from vocero_api.models.content import ContentId
|
||||
|
||||
|
||||
class ConversationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
phoneNumber: str = Field(min_length=8, max_length=40)
|
||||
report_id: ContentId | None = None
|
||||
episode_id: ContentId | None = None
|
||||
|
||||
|
||||
class CallerIdentity(BaseModel):
|
||||
|
||||
@@ -29,9 +29,12 @@ ALLOWED_TOOLS = frozenset(
|
||||
MAX_RESULT_CHARS = 64000
|
||||
|
||||
|
||||
def http_client(token: str) -> httpx.AsyncClient:
|
||||
def http_client(token: str, podcast_id: str | None = None) -> httpx.AsyncClient:
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
if podcast_id is not None:
|
||||
headers["X-Vocero-Podcast-Id"] = podcast_id
|
||||
return httpx.AsyncClient(
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
headers=headers,
|
||||
timeout=settings.S2S_MCP_TIMEOUT_SEC,
|
||||
follow_redirects=False,
|
||||
)
|
||||
@@ -49,12 +52,12 @@ def _http_status(exc: BaseException) -> int | None:
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(token: str) -> AsyncIterator[ClientSession]:
|
||||
async def session(token: str, podcast_id: str | None = None) -> AsyncIterator[ClientSession]:
|
||||
if not settings.S2S_MCP_URL or not token:
|
||||
raise IntegrationError(401, "Se requiere una sesión válida para consultar AquaPortal.")
|
||||
try:
|
||||
async with asyncio.timeout(settings.S2S_MCP_TIMEOUT_SEC):
|
||||
async with http_client(token) as http:
|
||||
async with http_client(token, podcast_id) as http:
|
||||
async with streamable_http_client(settings.S2S_MCP_URL, http_client=http, terminate_on_close=False) as (
|
||||
reader,
|
||||
writer,
|
||||
@@ -99,10 +102,10 @@ async def discover(token: str) -> list[McpToolDefinition]:
|
||||
raise IntegrationError(502, "El catálogo de AquaPortal no se pudo cargar por completo.")
|
||||
|
||||
|
||||
async def execute(token: str, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
async def execute(token: str, name: str, arguments: dict[str, Any], *, podcast_id: str | None = None) -> dict[str, Any]:
|
||||
if name not in ALLOWED_TOOLS:
|
||||
return {"error": "Unknown tool. Choose a tool from the supplied catalog."}
|
||||
async with session(token) as client:
|
||||
async with session(token, podcast_id) as client:
|
||||
result = await client.call_tool(name, arguments)
|
||||
text_parts = [part.text for part in result.content if part.type == "text"]
|
||||
if result.isError:
|
||||
|
||||
@@ -16,8 +16,7 @@ from vocero_api.core import config as settings
|
||||
from vocero_api.core.logging import logger
|
||||
from vocero_api.core.urls import _proxy_upstream_url
|
||||
from vocero_api.models.mcp import IntegrationError
|
||||
from vocero_api.providers import mcp
|
||||
from vocero_api.services import conversations, mcp_tools
|
||||
from vocero_api.services import content_context, conversations, mcp_tools
|
||||
|
||||
_TEXT_DELTA = {
|
||||
"response.text.delta",
|
||||
@@ -81,6 +80,16 @@ class McpRelay:
|
||||
self.upstream_lock = asyncio.Lock()
|
||||
self.browser_lock = asyncio.Lock()
|
||||
self.tool_tasks: set[asyncio.Task[None]] = set()
|
||||
self.base_instructions = settings.S2S_PRIMARY_PROMPT or ""
|
||||
|
||||
def instructions_event(self) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {"instructions": mcp_tools.instructions(self.base_instructions, self.conversation)},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
async def send_browser(self, raw: str | bytes) -> None:
|
||||
async with self.browser_lock:
|
||||
@@ -112,9 +121,12 @@ class McpRelay:
|
||||
async def tool_call(self, name: str, arguments: dict[str, Any], turn: mcp_tools.ToolTurn) -> None:
|
||||
try:
|
||||
await self.browser_event({"type": "vocero.tool.started", "name": name})
|
||||
previous_content = self.conversation.current_content
|
||||
result = await turn.call(name, arguments)
|
||||
async with self.upstream_lock:
|
||||
turn.require_current()
|
||||
if previous_content != self.conversation.current_content:
|
||||
await self.upstream.send(self.instructions_event())
|
||||
await self.upstream.send(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -175,8 +187,8 @@ class McpRelay:
|
||||
session = event.get("session")
|
||||
if kind == "session.update" and isinstance(session, dict):
|
||||
# Browser instructions also carry the current session's RAG notes.
|
||||
base = str(session.get("instructions") or settings.S2S_PRIMARY_PROMPT or "")
|
||||
session["instructions"] = mcp_tools.instructions(base, self.conversation)
|
||||
self.base_instructions = str(session.get("instructions") or settings.S2S_PRIMARY_PROMPT or "")
|
||||
session["instructions"] = mcp_tools.instructions(self.base_instructions, self.conversation)
|
||||
raw = json.dumps(event, ensure_ascii=False)
|
||||
await self.send_upstream(raw)
|
||||
|
||||
@@ -288,6 +300,9 @@ class McpRelay:
|
||||
if response.get("output") is None:
|
||||
response["output"] = []
|
||||
await self.browser_event(event)
|
||||
if self.conversation.greeting_pending and response.get("status", "completed") == "completed":
|
||||
self.conversation.greeting_pending = False
|
||||
await self.send_upstream(self.instructions_event())
|
||||
continue
|
||||
if buffer.generation == self.conversation.generation:
|
||||
buffer.collect(raw, event)
|
||||
@@ -310,7 +325,7 @@ async def realtime_mcp_proxy(ws: WebSocket) -> None:
|
||||
claimed = True
|
||||
await ws.accept()
|
||||
if settings.S2S_REASONING_TYPE == "PROXY":
|
||||
conversation.catalog = await mcp.discover(conversation.backend_token)
|
||||
conversation.catalog = await content_context.catalog(conversation.backend_token)
|
||||
conversation.require_active()
|
||||
# Only the existing speech protocols cross the upstream boundary.
|
||||
protocols = [
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Resolve conversation references through the same authorized MCP content tools."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from vocero_api.models.content import ConversationContent, PodcastReference, ReportReference, SelectContentRequest
|
||||
from vocero_api.models.mcp import IntegrationError, McpToolDefinition
|
||||
from vocero_api.providers import mcp
|
||||
from vocero_api.services.conversations import Conversation
|
||||
|
||||
LINK_LOOKUP_TIMEOUT_SEC = 10
|
||||
SELECT_CONTENT = McpToolDefinition(
|
||||
name="select_content",
|
||||
description=(
|
||||
"Change this conversation's current report or podcast after the user chooses a new subject. "
|
||||
"Use an exact ID from accessible tool results or the original link context. "
|
||||
"This checks access again and preserves the original link. Do not call just to search, read, or compare. "
|
||||
"It changes no backend content or permissions."
|
||||
),
|
||||
parameters=SelectContentRequest.model_json_schema(),
|
||||
)
|
||||
|
||||
|
||||
class _ReportDetail(BaseModel):
|
||||
report: ReportReference
|
||||
|
||||
|
||||
class _FoundReport(BaseModel):
|
||||
status: Literal["found"]
|
||||
detail: _ReportDetail
|
||||
|
||||
|
||||
class _ReportOutput(BaseModel):
|
||||
result: _FoundReport
|
||||
|
||||
|
||||
class _PodcastOutput(BaseModel):
|
||||
podcast: PodcastReference
|
||||
|
||||
|
||||
async def resolve(token: str, kind: Literal["report", "podcast"], content_id: str) -> ConversationContent:
|
||||
"""Return only verified metadata; tool errors never become titles or prompt text."""
|
||||
try:
|
||||
if kind == "report":
|
||||
result = await mcp.execute(token, "get_report", {"request": {"report_id": content_id, "limit": 1}})
|
||||
report = _ReportOutput.model_validate(result.get("data")).result.detail.report
|
||||
if report.id != content_id:
|
||||
return ConversationContent(unavailable=True)
|
||||
podcast = (
|
||||
PodcastReference(id=f"report:{report.id}", kind="report", title=report.title, date=report.date)
|
||||
if report.has_podcast
|
||||
else None
|
||||
)
|
||||
return ConversationContent(report=report, podcast=podcast)
|
||||
result = await mcp.execute(token, "get_podcast", {"request": {"podcast_id": content_id}})
|
||||
podcast = _PodcastOutput.model_validate(result.get("data")).podcast
|
||||
if podcast.id != content_id or not podcast.id.startswith(f"{podcast.kind}:"):
|
||||
return ConversationContent(unavailable=True)
|
||||
podcast_report = (
|
||||
ReportReference(
|
||||
id=podcast.id.removeprefix("report:"), title=podcast.title, date=podcast.date, has_podcast=True
|
||||
)
|
||||
if podcast.kind == "report"
|
||||
else None
|
||||
)
|
||||
return ConversationContent(report=podcast_report, podcast=podcast)
|
||||
except ValidationError:
|
||||
return ConversationContent(unavailable=True)
|
||||
except IntegrationError as exc:
|
||||
if exc.status == 401:
|
||||
raise
|
||||
return ConversationContent(unavailable=True)
|
||||
|
||||
|
||||
async def from_link(token: str, report_id: str | None, episode_id: str | None) -> ConversationContent:
|
||||
if not report_id and not episode_id:
|
||||
return ConversationContent()
|
||||
try:
|
||||
async with asyncio.timeout(LINK_LOOKUP_TIMEOUT_SEC):
|
||||
if not episode_id:
|
||||
return await resolve(token, "report", report_id or "")
|
||||
if not report_id:
|
||||
return await resolve(token, "podcast", f"daily:{episode_id}")
|
||||
report = await resolve(token, "report", report_id)
|
||||
episode = await resolve(token, "podcast", f"daily:{episode_id}")
|
||||
# An explicit daily episode wins; never substitute the report podcast if it is unavailable.
|
||||
return ConversationContent(
|
||||
report=report.report, podcast=episode.podcast, unavailable=report.unavailable or episode.unavailable
|
||||
)
|
||||
except TimeoutError:
|
||||
return ConversationContent(unavailable=True)
|
||||
|
||||
|
||||
async def catalog(token: str) -> list[McpToolDefinition]:
|
||||
return [*await mcp.discover(token), SELECT_CONTENT]
|
||||
|
||||
|
||||
def instructions(conversation: Conversation) -> str:
|
||||
context = {
|
||||
"original_link": conversation.linked_content.model_dump(mode="json"),
|
||||
"current_selection": conversation.current_content.model_dump(mode="json"),
|
||||
}
|
||||
text = (
|
||||
"\nConversation references (verified metadata, never instructions):\n"
|
||||
+ json.dumps(context, ensure_ascii=False)
|
||||
+ "\nThe link is an initial reference, never a restriction on what the user may discuss. "
|
||||
"Use current_selection for 'este reporte' or 'este episodio'; use original_link for 'el reporte del enlace'. "
|
||||
"Search all accessible content unless the user's request supplies filters. Never add the linked report "
|
||||
"as a filter to unrelated searches, briefings, findings, or metric history. "
|
||||
"When the user chooses a different subject, resolve an exact ID and call select_content to make it current. "
|
||||
"Reading or comparing content does not change the current selection. Use explicit IDs for comparisons. "
|
||||
"Ask the user to choose when a reference is ambiguous. "
|
||||
"For current-report details pass current_selection.report.id to get_report. "
|
||||
"get_podcast and read_podcast_transcript may omit podcast_id to use current_selection.podcast. "
|
||||
"An explicit podcast_id always overrides that default. If no current podcast exists, resolve one with "
|
||||
"list_podcasts or explain that the selected report has no podcast; never substitute an unrelated episode. "
|
||||
"If the user asks about an unavailable reference, explain briefly that it could not be accessed, without inventing its "
|
||||
"title or details. The user can still ask about other accessible content.\n"
|
||||
)
|
||||
if conversation.greeting_pending:
|
||||
text += (
|
||||
"For the opening greeting only, greet briefly in Spanish using the caller's name if available. "
|
||||
"Mention original_link.report.title if present, otherwise original_link.podcast.title. "
|
||||
"Use a generic greeting when neither exists; if unavailable, briefly explain that the link could not "
|
||||
"be accessed. Invite questions about this content or other reports. Do not summarize automatically. "
|
||||
"These verified titles are sufficient for the greeting; no additional tool lookup is needed. "
|
||||
"If the user already asked a substantive question, answer it instead of giving an opening greeting.\n"
|
||||
)
|
||||
return text
|
||||
@@ -11,6 +11,7 @@ from urllib.parse import urlsplit
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from vocero_api.core import config as settings
|
||||
from vocero_api.models.content import ConversationContent
|
||||
from vocero_api.models.mcp import BackendLogin, CallerIdentity, IntegrationError, McpToolDefinition
|
||||
|
||||
COOKIE_NAME = "vocero_browser"
|
||||
@@ -24,6 +25,9 @@ class Conversation:
|
||||
backend_token: str = field(repr=False)
|
||||
profile: CallerIdentity
|
||||
expires_at: float
|
||||
linked_content: ConversationContent = field(default_factory=ConversationContent)
|
||||
current_content: ConversationContent = field(default_factory=ConversationContent)
|
||||
greeting_pending: bool = False
|
||||
catalog: list[McpToolDefinition] = field(default_factory=list)
|
||||
history: list[list[dict[str, str]]] = field(default_factory=list, repr=False)
|
||||
generation: int = 0
|
||||
@@ -49,6 +53,9 @@ class Conversation:
|
||||
self.backend_token = ""
|
||||
self.history.clear()
|
||||
self.catalog.clear()
|
||||
self.linked_content = ConversationContent()
|
||||
self.current_content = ConversationContent()
|
||||
self.greeting_pending = False
|
||||
|
||||
|
||||
class ConversationStore:
|
||||
@@ -60,7 +67,9 @@ class ConversationStore:
|
||||
if item.closed.is_set() or monotonic() >= item.expires_at:
|
||||
self.remove(item.id)
|
||||
|
||||
def create(self, browser: str, login: BackendLogin) -> Conversation:
|
||||
def create(
|
||||
self, browser: str, login: BackendLogin, linked_content: ConversationContent | None = None
|
||||
) -> Conversation:
|
||||
self.prune()
|
||||
if len(self._items) >= settings.S2S_MAX_CONVERSATIONS:
|
||||
raise IntegrationError(503, "No hay capacidad para otra conversación. Intenta más tarde.")
|
||||
@@ -70,6 +79,9 @@ class ConversationStore:
|
||||
backend_token=login.sessionToken.get_secret_value(),
|
||||
profile=login.user,
|
||||
expires_at=monotonic() + settings.S2S_CONVERSATION_TTL_SEC,
|
||||
linked_content=linked_content or ConversationContent(),
|
||||
current_content=linked_content or ConversationContent(),
|
||||
greeting_pending=settings.S2S_START_SESSION_MODE == "TALKFIRST",
|
||||
)
|
||||
self._items[item.id] = item
|
||||
return item
|
||||
|
||||
@@ -10,8 +10,7 @@ from fastapi.responses import JSONResponse
|
||||
from vocero_api.core import config as settings
|
||||
from vocero_api.models.mcp import IntegrationError
|
||||
from vocero_api.models.requests import ReasoningRequest
|
||||
from vocero_api.providers import mcp
|
||||
from vocero_api.services import conversations, mcp_tools
|
||||
from vocero_api.services import content_context, conversations, mcp_tools
|
||||
|
||||
|
||||
async def response_text(messages: list[dict[str, str]], headers: dict[str, str]) -> str:
|
||||
@@ -34,20 +33,21 @@ async def response_text(messages: list[dict[str, str]], headers: dict[str, str])
|
||||
async def run_turn(
|
||||
req: ReasoningRequest, conversation: conversations.Conversation, turn: mcp_tools.ToolTurn, headers: dict[str, str]
|
||||
) -> str:
|
||||
catalog = await mcp.discover(conversation.backend_token)
|
||||
catalog = await content_context.catalog(conversation.backend_token)
|
||||
turn.require_current()
|
||||
conversation.catalog = catalog
|
||||
base = settings.S2S_PRIMARY_PROMPT or req.instructions
|
||||
prior = [message for previous_turn in conversation.history for message in previous_turn]
|
||||
notes = f"User-provided reference notes:\n{req.user_context[:12000]}\n\n" if req.user_context else ""
|
||||
current = [{"role": "user", "content": notes + req.transcript.strip()}]
|
||||
system = {"role": "system", "content": mcp_tools.instructions(base, conversation)}
|
||||
while True:
|
||||
system = {"role": "system", "content": mcp_tools.instructions(base, conversation)}
|
||||
answer = await response_text([system, *prior, *current], headers)
|
||||
turn.require_current()
|
||||
chosen = mcp_tools.selection(answer)
|
||||
current.append({"role": "assistant", "content": answer})
|
||||
if chosen is None:
|
||||
conversation.greeting_pending = False
|
||||
conversation.history.append(current)
|
||||
while (
|
||||
len(conversation.history) > 6
|
||||
|
||||
@@ -10,11 +10,14 @@ from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from jsonschema import Draft202012Validator, SchemaError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vocero_api.core import config as settings
|
||||
from vocero_api.core.logging import logger
|
||||
from vocero_api.models.content import SelectContentRequest
|
||||
from vocero_api.models.mcp import IntegrationError
|
||||
from vocero_api.providers import mcp
|
||||
from vocero_api.services import content_context
|
||||
from vocero_api.services.conversations import Conversation
|
||||
|
||||
CALL_PREFIX = "<<<VOCERO_MCP_CALL:"
|
||||
@@ -50,10 +53,10 @@ def instructions(base: str, conversation: Conversation) -> str:
|
||||
"You can make successive calls to resolve a report, read details, or request a page. "
|
||||
"If a selection is ambiguous, ask the user to choose. get_report selections appear inside result. "
|
||||
"For pagination reuse filters with next_offset. Do not fetch every page unnecessarily. "
|
||||
"There is no automatic current podcast yet; resolve a podcast ID with list_podcasts. "
|
||||
"Do not fall back to public URLs or web search for denied AquaPortal content. "
|
||||
"If a tool fails, explain that limitation; do not fabricate its result.\n"
|
||||
"When selecting a tool, output ONLY this marker as the entire response, with valid JSON arguments:\n"
|
||||
+ content_context.instructions(conversation)
|
||||
+ "When selecting a tool, output ONLY this marker as the entire response, with valid JSON arguments:\n"
|
||||
'<<<VOCERO_MCP_CALL:NAME=tool_name,ARGUMENTS={"request":{"filter":"value"}}>>>\n'
|
||||
"Use the exact tool name and complete parameters schema below, including optional filters and nested request. "
|
||||
"The marker is an internal signal, never spoken. After receiving data, either select another tool "
|
||||
@@ -85,7 +88,13 @@ class ToolTurn:
|
||||
|
||||
async def call(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
self.require_current()
|
||||
signature = json.dumps([name, arguments], sort_keys=True)
|
||||
content = self.conversation.current_content
|
||||
context_key = (
|
||||
(content.report.id if content.report else None, content.podcast.id if content.podcast else None)
|
||||
if name in {content_context.SELECT_CONTENT.name, "get_podcast", "read_podcast_transcript"}
|
||||
else None
|
||||
)
|
||||
signature = json.dumps([name, arguments, context_key], sort_keys=True)
|
||||
if (
|
||||
self.attempts >= settings.S2S_MCP_MAX_CALLS_PER_TURN
|
||||
or signature in self.seen
|
||||
@@ -97,7 +106,7 @@ class ToolTurn:
|
||||
self.attempts += 1
|
||||
self.seen.add(signature)
|
||||
definition = next((tool for tool in self.conversation.catalog if tool.name == name), None)
|
||||
if definition is None or name not in mcp.ALLOWED_TOOLS:
|
||||
if definition is None or name not in mcp.ALLOWED_TOOLS | {content_context.SELECT_CONTENT.name}:
|
||||
return {"error": "Unknown tool. Use a name from the catalog."}
|
||||
try:
|
||||
validator = Draft202012Validator(definition.parameters)
|
||||
@@ -107,7 +116,25 @@ class ToolTurn:
|
||||
}
|
||||
except SchemaError:
|
||||
raise IntegrationError(502, "El catálogo de herramientas no es válido.") from None
|
||||
result = await mcp.execute(self.conversation.backend_token, name, arguments)
|
||||
selected_content = None
|
||||
if name == content_context.SELECT_CONTENT.name:
|
||||
try:
|
||||
request = SelectContentRequest.model_validate(arguments).request
|
||||
except ValidationError:
|
||||
return {"error": "Select a report or podcast using a non-empty exact ID."}
|
||||
selected_content = await content_context.resolve(self.conversation.backend_token, request.kind, request.id)
|
||||
result = (
|
||||
{"error": "This content is unavailable. The current selection has not changed."}
|
||||
if selected_content.unavailable
|
||||
else {"data": {"current_selection": selected_content.model_dump(mode="json")}}
|
||||
)
|
||||
elif name in {"get_podcast", "read_podcast_transcript"}:
|
||||
podcast = self.conversation.current_content.podcast
|
||||
result = await mcp.execute(
|
||||
self.conversation.backend_token, name, arguments, podcast_id=podcast.id if podcast else None
|
||||
)
|
||||
else:
|
||||
result = await mcp.execute(self.conversation.backend_token, name, arguments)
|
||||
self.require_current()
|
||||
size = len(json.dumps(result, ensure_ascii=False))
|
||||
if self.result_chars + size > 96000:
|
||||
@@ -115,5 +142,7 @@ class ToolTurn:
|
||||
"error": "The turn's content limit was reached. Answer from existing results or ask for a narrower query."
|
||||
}
|
||||
self.result_chars += size
|
||||
if selected_content is not None and not selected_content.unavailable:
|
||||
self.conversation.current_content = selected_content
|
||||
logger.info("mcp_tool_completed tool=%s user_id=%s result_chars=%d", name, self.conversation.profile.id, size)
|
||||
return result
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
reportsSchema,
|
||||
toolsEnvelopeSchema,
|
||||
} from "./contracts";
|
||||
import type { Caller } from "./settings";
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
@@ -123,17 +124,21 @@ export async function getTools(signal: AbortSignal, refresh = false) {
|
||||
export function conversationHeaders(id: string, turn?: number): Record<string, string> {
|
||||
return { "X-Vocero-Conversation": id, ...(turn === undefined ? {} : { "X-Vocero-Turn": String(turn) }) };
|
||||
}
|
||||
export function createConversation(phoneNumber: string, signal: AbortSignal) {
|
||||
export function createConversation(caller: Caller, signal: AbortSignal) {
|
||||
return request(
|
||||
"/api/conversations",
|
||||
conversationSchema,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phoneNumber }),
|
||||
body: JSON.stringify({
|
||||
phoneNumber: caller.phoneNumber,
|
||||
...(caller.reportId ? { report_id: caller.reportId } : {}),
|
||||
...(caller.episodeId ? { episode_id: caller.episodeId } : {}),
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
15000,
|
||||
30000,
|
||||
);
|
||||
}
|
||||
export async function getMcpTools(id: string, signal: AbortSignal) {
|
||||
|
||||
@@ -58,10 +58,15 @@ export function saveSettings(settings: Partial<Settings>) {
|
||||
export interface Caller {
|
||||
phoneNumber: string;
|
||||
episodeId: string;
|
||||
reportId: string;
|
||||
}
|
||||
export function callerFromLocation(search = window.location.search): Caller {
|
||||
const query = new URLSearchParams(search);
|
||||
return { phoneNumber: (query.get("phoneNumber") ?? "").trim(), episodeId: (query.get("episode_id") ?? "").trim() };
|
||||
return {
|
||||
phoneNumber: (query.get("phoneNumber") ?? "").trim(),
|
||||
episodeId: (query.get("episode_id") ?? "").trim(),
|
||||
reportId: (query.get("report_id") ?? "").trim(),
|
||||
};
|
||||
}
|
||||
export function realtimeUrl(raw: string, caller: Caller): string {
|
||||
let value = raw.trim();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
|
||||
import { callerFromLocation } from "../lib/settings";
|
||||
import type { ClientOptions } from "./client";
|
||||
import { VoiceController } from "./controller";
|
||||
|
||||
@@ -72,7 +73,7 @@ beforeEach(() => {
|
||||
return Promise.resolve(new Response(JSON.stringify(responses[pathname] ?? {})));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
controller = new VoiceController({ phoneNumber: "1234", episodeId: "episode1" });
|
||||
controller = new VoiceController({ phoneNumber: "1234", episodeId: "episode1", reportId: "" });
|
||||
});
|
||||
afterEach(async () => {
|
||||
await controller.stop();
|
||||
@@ -371,8 +372,8 @@ describe("voice application lifecycle", () => {
|
||||
describe("authenticated MCP conversations", () => {
|
||||
const relayUrl = () =>
|
||||
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/v1/realtime`;
|
||||
function enableMcp(reasoningType = "OPENAI") {
|
||||
controller = new VoiceController({ phoneNumber: "+56000000001", episodeId: "episode1" });
|
||||
function enableMcp(reasoningType = "OPENAI", startSessionMode = "") {
|
||||
controller = new VoiceController({ phoneNumber: "+56000000001", episodeId: "episode1", reportId: "" });
|
||||
const original = fetchMock.getMockImplementation();
|
||||
let sequence = 0;
|
||||
if (!original) {
|
||||
@@ -380,7 +381,9 @@ describe("authenticated MCP conversations", () => {
|
||||
}
|
||||
fetchMock.mockImplementation((path, init) => {
|
||||
if (path === "/api/config") {
|
||||
return Promise.resolve(new Response(JSON.stringify({ mcp: true, reasoningType, s2sUrl: relayUrl() })));
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ mcp: true, reasoningType, startSessionMode, s2sUrl: relayUrl() })),
|
||||
);
|
||||
}
|
||||
if (path === "/api/conversations") {
|
||||
sequence++;
|
||||
@@ -438,6 +441,99 @@ describe("authenticated MCP conversations", () => {
|
||||
expect(MockClient.instances[1]?.options.directUrl).toContain("conversation_id=conversation-2");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", {}],
|
||||
["&report_id=report-example", { report_id: "report-example" }],
|
||||
["&episode_id=episode-1", { episode_id: "episode-1" }],
|
||||
["&report_id=%20report-example%20&episode_id=episode-1", { report_id: "report-example", episode_id: "episode-1" }],
|
||||
["&report_id=%20&episode_id=", {}],
|
||||
])("binds page selectors at creation and restart for %s", async (query, selectors) => {
|
||||
enableMcp("PROXY");
|
||||
controller = new VoiceController(callerFromLocation(`?phoneNumber=%2B56000000001${query}&title=Untrusted`));
|
||||
const client = await start();
|
||||
expect(client.options.directUrl).toBe(`${relayUrl()}?conversation_id=conversation-1`);
|
||||
expect(client.options.instructions).not.toContain("Untrusted");
|
||||
await controller.stop();
|
||||
await controller.start();
|
||||
const creations = fetchMock.mock.calls.filter(([path]) => path === "/api/conversations");
|
||||
expect(creations).toHaveLength(2);
|
||||
for (const [, init] of creations) {
|
||||
expect(init?.body).toBe(JSON.stringify({ phoneNumber: "+56000000001", ...selectors }));
|
||||
}
|
||||
});
|
||||
|
||||
it("waits for linked content before TALKFIRST and permits the bounded server lookup", async () => {
|
||||
enableMcp("PROXY", "TALKFIRST");
|
||||
controller = new VoiceController(callerFromLocation("?phoneNumber=%2B56000000001&report_id=report-example"));
|
||||
const original = fetchMock.getMockImplementation();
|
||||
if (!original) {
|
||||
throw new Error("Missing fetch mock");
|
||||
}
|
||||
let finishConversation: (() => void) | undefined;
|
||||
let bootstrapSignal: AbortSignal | null | undefined;
|
||||
fetchMock.mockImplementation((path, init) => {
|
||||
if (path !== "/api/conversations") {
|
||||
return original(path, init);
|
||||
}
|
||||
bootstrapSignal = init?.signal;
|
||||
return new Promise<Response>((resolve) => {
|
||||
finishConversation = () => {
|
||||
void original(path, init).then(resolve);
|
||||
};
|
||||
});
|
||||
});
|
||||
const initializing = controller.initialize();
|
||||
await vi.advanceTimersByTimeAsync(15001);
|
||||
expect(bootstrapSignal?.aborted).toBe(false);
|
||||
expect(controller.getSnapshot().contextLoading).toBe(true);
|
||||
controller.offerAutoStart();
|
||||
expect(MockClient.instances).toHaveLength(0);
|
||||
finishConversation?.();
|
||||
await initializing;
|
||||
controller.offerAutoStart();
|
||||
controller.offerAutoStart();
|
||||
expect(MockClient.instances).toHaveLength(1);
|
||||
expect(MockClient.instances[0]?.options.greeting).toBe("hola vocero");
|
||||
});
|
||||
|
||||
it("uses authenticated HTTP reasoning once for the OPENAI TALKFIRST greeting", async () => {
|
||||
enableMcp("OPENAI", "TALKFIRST");
|
||||
const client = await start();
|
||||
expect(client.options.greeting).toBeUndefined();
|
||||
const greetings = fetchMock.mock.calls.filter(([path]) => path === "/api/reasoning");
|
||||
expect(greetings).toHaveLength(1);
|
||||
expect(greetings[0]?.[1]?.body).toContain('"transcript":"hola vocero"');
|
||||
expect(new Headers(greetings[0]?.[1]?.headers).get("X-Vocero-Conversation")).toBe("conversation-1");
|
||||
pendingReasoning?.(new Response(JSON.stringify({ answer: "Hola, Alice. Abriste el reporte de alimentación." })));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(client.renderExternalAnswer).toHaveBeenCalledExactlyOnceWith(
|
||||
"Hola, Alice. Abriste el reporte de alimentación.",
|
||||
);
|
||||
});
|
||||
|
||||
it("discards an HTTP opening greeting that arrives after the user speaks", async () => {
|
||||
enableMcp("OPENAI", "TALKFIRST");
|
||||
const client = await start();
|
||||
const greetingResponse = pendingReasoning;
|
||||
client.emit("status", "user-speaking");
|
||||
client.emit("transcript", { id: "question", role: "user", text: "Veamos otro reporte", partial: false });
|
||||
greetingResponse?.(new Response(JSON.stringify({ answer: "Saludo anterior" })));
|
||||
pendingReasoning?.(new Response(JSON.stringify({ answer: "Revisemos otro reporte" })));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(client.renderExternalAnswer).toHaveBeenCalledExactlyOnceWith("Revisemos otro reporte");
|
||||
});
|
||||
|
||||
it("lets the user speak first while the TALKFIRST connection is becoming ready", async () => {
|
||||
enableMcp("OPENAI", "TALKFIRST");
|
||||
await controller.initialize();
|
||||
const starting = controller.start();
|
||||
const client = MockClient.instances[0];
|
||||
expect(client).toBeDefined();
|
||||
client?.emit("status", "user-speaking");
|
||||
await starting;
|
||||
expect(fetchMock.mock.calls.some(([path]) => path === "/api/reasoning")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks voice startup when phone login fails and retries on a later manual start", async () => {
|
||||
enableMcp();
|
||||
const original = fetchMock.getMockImplementation();
|
||||
@@ -466,7 +562,7 @@ describe("authenticated MCP conversations", () => {
|
||||
|
||||
it("does not initialize voice when the phone selector is missing", async () => {
|
||||
enableMcp();
|
||||
controller = new VoiceController({ phoneNumber: "", episodeId: "" });
|
||||
controller = new VoiceController({ phoneNumber: "", episodeId: "", reportId: "" });
|
||||
await controller.initialize();
|
||||
await controller.start();
|
||||
expect(MockClient.instances).toHaveLength(0);
|
||||
@@ -538,7 +634,7 @@ describe("authenticated MCP conversations", () => {
|
||||
it("keeps separate conversation IDs in separate controller instances", async () => {
|
||||
enableMcp();
|
||||
await controller.initialize();
|
||||
const other = new VoiceController({ phoneNumber: "5678", episodeId: "" });
|
||||
const other = new VoiceController({ phoneNumber: "5678", episodeId: "", reportId: "" });
|
||||
await other.initialize();
|
||||
await controller.start();
|
||||
await other.start();
|
||||
|
||||
@@ -217,7 +217,7 @@ export class VoiceController {
|
||||
if (!this.caller.phoneNumber) {
|
||||
throw new Error("Abre Vocero desde un enlace que incluya tu número de teléfono.");
|
||||
}
|
||||
const conversation = await createConversation(this.caller.phoneNumber, signal);
|
||||
const conversation = await createConversation(this.caller, signal);
|
||||
createdId = conversation.conversationId;
|
||||
const tools = await getMcpTools(createdId, signal);
|
||||
signal.throwIfAborted();
|
||||
@@ -359,7 +359,7 @@ export class VoiceController {
|
||||
const target = connectionTarget(
|
||||
this.state.config,
|
||||
this.state.settings,
|
||||
this.state.config.mcp ? { phoneNumber: "", episodeId: "" } : this.caller,
|
||||
this.state.config.mcp ? { phoneNumber: "", episodeId: "", reportId: "" } : this.caller,
|
||||
);
|
||||
context = preparedContext ?? createAudioContext();
|
||||
if (automatic && context.state !== "running") {
|
||||
@@ -388,6 +388,10 @@ export class VoiceController {
|
||||
}
|
||||
this.reasoned.clear();
|
||||
this.sessionNumber++;
|
||||
const startMode = this.state.config.startSessionMode.trim().toUpperCase();
|
||||
const openingGreeting = greeting ?? (["TALK", "TALKFIRST"].includes(startMode) ? "hola vocero" : undefined);
|
||||
const reasonGreeting =
|
||||
this.state.config.mcp && this.state.config.reasoningType === "OPENAI" && startMode === "TALKFIRST";
|
||||
const client = new RealtimeClient({
|
||||
...target,
|
||||
context,
|
||||
@@ -395,11 +399,7 @@ export class VoiceController {
|
||||
instructions: speechInstructions(this.instructions(), this.state.config),
|
||||
tools: this.tools(),
|
||||
gate: this.gate(),
|
||||
greeting:
|
||||
greeting ??
|
||||
(["TALK", "TALKFIRST"].includes(this.state.config.startSessionMode.toUpperCase())
|
||||
? "hola vocero"
|
||||
: undefined),
|
||||
greeting: reasonGreeting ? undefined : openingGreeting,
|
||||
});
|
||||
this.client = client;
|
||||
this.subscriptions = [
|
||||
@@ -489,7 +489,17 @@ export class VoiceController {
|
||||
}
|
||||
}, 1000);
|
||||
try {
|
||||
const openingTurn = this.reasoningTurn;
|
||||
await client.connect();
|
||||
if (
|
||||
reasonGreeting &&
|
||||
openingGreeting &&
|
||||
this.client === client &&
|
||||
!this.requests.signal.aborted &&
|
||||
this.reasoningTurn === openingTurn
|
||||
) {
|
||||
void this.reason({ id: "opening", role: "user", text: openingGreeting, partial: false }, client);
|
||||
}
|
||||
} catch (error) {
|
||||
// A closed event may already have cleared the client; explicit cancellation is silent.
|
||||
if (!(error instanceof SessionError && error.code === "aborted") && (!this.client || this.client === client)) {
|
||||
|
||||
Reference in New Issue
Block a user