diff --git a/README.md b/README.md index fdbb21d..261d71e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,22 @@ Your agent runs wherever it already runs. This client sends the transcript as it happens, gets back what the analysis found, and delivers the nudge to the agent while the caller is still on the line. +## Two ways in + +**Connect, no code.** If your agents run on ElevenLabs, connect the workspace +once in the DeepTrust dashboard (Settings, Voice Agents) with an API key that +has the ElevenLabs Agents Write permission, and pick the agents to watch. +DeepTrust finds their calls, listens along, nudges the agent mid-call, and +records the transcript. Nothing to install and nothing in this package to run. +Phone calls are picked up at call setup; other channels within a few seconds. + +**This client, in your process.** For your own stack, for LiveKit, or when you +want the socket held by you rather than by DeepTrust. Append turns, call +`analyze`, deliver the nudge. The rest of this README is about this path. + +The two meet in the same place: every call, either way, lands in the Calls list +with the `voice_agent` source. + ```bash pip install deeptrust-ai ``` @@ -46,12 +62,19 @@ a nudge, which has both what was seen and what to do about it. An agent given only the first has to pick a response itself, and the one it usually picks is handing the call to a person. -## Two methods +## Three methods `analyze` reviews the transcript and returns findings. It does not block the agent, so a result arrives after the turn that caused it has been spoken, and a nudge affects what the agent says next. +`end` tells DeepTrust the call is over, so post-call processing starts now +rather than after the server's inactivity timeout. Calling it twice is harmless. + +```python +await call.end() +``` + `check` decides whether a single action may run, and does block. It is meant to be called from a tool handler before the action executes. Not implemented in this version. @@ -106,15 +129,33 @@ await monitor.watch(conversation_id, user=caller) ``` This needs no code inside your agent. ElevenLabs exposes a per-conversation -monitor socket, so DeepTrust connects from its own side with a workspace key, -reads the transcript, and sends findings back as contextual updates on the same -socket. +monitor socket, so this connects to it from your process with your workspace +key, reads the transcript, and sends findings back as contextual updates on the +same socket. Two differences from LiveKit, which the client reports rather than hides. Contextual updates are documented as non-interrupting, so a finding shapes the next turn. And the socket carries events, not audio, which suits a client that reads what was said and does not analyse the audio itself. +### Or let DeepTrust hold the socket + +If the workspace is connected in the dashboard, you do not need `Monitor` or an +ElevenLabs key here at all. DeepTrust finds live calls on its own. When your +backend already knows a conversation id, for instance from the +`conversation_initiation_metadata` client event, hand it over and the call is +watched from its first turn instead of from the next check: + +```python +from deeptrust.agents import DeepTrust + +await DeepTrust().watch(conversation_id) # platform="elevenlabs" +``` + +`watch` returns `True` when it started the monitor and `False` when DeepTrust +was already watching. It raises `ServiceError` with status 404 when the +platform is not connected for your organisation. + ## Your own stack Neither adapter is required. If your agent is somewhere else, the two verbs are @@ -123,16 +164,27 @@ your agent takes instructions. ## Keys -Keys are created per organisation in the DeepTrust dashboard. Analysis and -enforcement are separate scopes, so a team piloting analysis is not holding a -key that can block their production calls. When a key lacks a scope, the client -says which scope is missing and which the key holds. +Keys are created per organisation in the DeepTrust dashboard, under Settings +and then API Keys (the tab is offered to voice-agent organisations). A key +belongs to the organisation rather than to the person who made it, so it keeps +working when they leave, and it reaches the agent endpoints and nothing else. + +The same key authenticates the hosted path's call-start webhook, so a workspace +connected through Settings, Voice Agents needs no second credential. + +The API does not divide keys by scope today. When it does, the client already +reports which scope was missing and which the key holds, rather than a bare +403. ```bash export DEEPTRUST_API_KEY=... export DEEPTRUST_BASE_URL=... # optional, for a non-production workspace ``` +The key is sent as `X-DeepTrust-Api-Key`. The default base URL is +`https://app.deeptrust.ai/api/v1`; a `DEEPTRUST_BASE_URL` from 0.0.1 that ends +in `/api` needs `/v1` appended. + ## Development ```bash @@ -162,8 +214,14 @@ Point a client at it with `DEEPTRUST_BASE_URL`. ## Status -`0.0.1`, first release. `analyze` and both adapters work. `check` is defined -and raises `NotImplementedError`. The shapes in `deeptrust.types` are the part -most likely to move. +`0.0.2`. `analyze`, `end`, `watch` and both adapters work against the hosted +API. `check` is defined and raises `NotImplementedError`. The shapes in +`deeptrust.types` are the part most likely to move. + +Changes since 0.0.1: the key travels in `X-DeepTrust-Api-Key` (the bearer form +is still sent, and goes away in 0.1); the default base URL gained `/v1`; the +ElevenLabs adapter sends contextual updates in the monitor socket's command +envelope, which 0.0.1 got wrong, so its nudges never arrived; `Session.end` +and `DeepTrust.watch` are new. Apache 2.0. diff --git a/dev/server.py b/dev/server.py index 97eb0a7..e331207 100644 --- a/dev/server.py +++ b/dev/server.py @@ -1,8 +1,9 @@ """A local stand-in for the DeepTrust API, for developing against this client. -Implements POST /agents/analyze with the same request and response shapes the -hosted API uses, so the client, both adapters and the examples can be run end -to end with no key and no network. +Implements POST /agents/analyze, POST /agents/sessions/{id}/end and +POST /agents/conversations/{id}/watch with the same request and response +shapes the hosted API uses, so the client, both adapters and the examples can +be run end to end with no key and no network. What it is NOT is the analysis. The hosted API runs a reasoning model against an organisation's runbook, SOPs and controls. This matches a handful of @@ -151,6 +152,31 @@ def analyze(req: AnalyzeReq) -> dict[str, Any]: } +_ENDED: set[str] = set() +_WATCHED: set[str] = set() + + +class WatchReq(BaseModel): + platform: str = "elevenlabs" + agent_id: str | None = None + + +@app.post("/agents/sessions/{session_id}/end") +def end_session(session_id: str) -> dict[str, Any]: + already = session_id in _ENDED + _ENDED.add(session_id) + return {"session_id": session_id, "ended": True, "already_ended": already} + + +@app.post("/agents/conversations/{conversation_id}/watch", status_code=202) +def watch_conversation(conversation_id: str, req: WatchReq) -> dict[str, Any]: + """The hosted handoff. Here it only remembers the id; the real API starts + a monitor from its own side, which this server has no socket for.""" + started = conversation_id not in _WATCHED + _WATCHED.add(conversation_id) + return {"conversation_id": conversation_id, "watching": True, "started": started} + + @app.get("/health") def health() -> dict[str, Any]: return {"ok": True, "server": "local dev", "signals": len(PATTERNS)} diff --git a/examples/elevenlabs/README.md b/examples/elevenlabs/README.md index 11ba3b5..cfc6629 100644 --- a/examples/elevenlabs/README.md +++ b/examples/elevenlabs/README.md @@ -65,6 +65,22 @@ uv run python main.py serve For outbound calls, skip the webhook and pass the conversation id that the outbound API returns when the call is placed. +## Or let DeepTrust hold the socket + +Everything above runs the monitor in your process with your ElevenLabs key. +The other way is to connect the workspace once in the DeepTrust dashboard +(Settings, Voice Agents) and pick the agents to watch; DeepTrust then finds the +calls and holds the socket itself, and none of this example needs to run. When +your backend already knows a conversation id, hand it over so the call is +watched from its first turn rather than from the next check: + +```bash +uv run python main.py handoff conv_2601m20xqctperzspz3zzz95pmrs +``` + +That is `DeepTrust().watch(conversation_id)`, one request with the DeepTrust +key alone. + ## What it looks like The caller asserts an approval that exists somewhere other than a ticket: diff --git a/examples/elevenlabs/main.py b/examples/elevenlabs/main.py index 74ddf2f..68f8039 100644 --- a/examples/elevenlabs/main.py +++ b/examples/elevenlabs/main.py @@ -19,6 +19,11 @@ uv run python main.py serve Point the ElevenLabs conversation initiation webhook at http:///calls and every inbound call is watched automatically. + + uv run python main.py handoff + The hosted path. If the workspace is connected in the DeepTrust + dashboard, this needs no ElevenLabs key: DeepTrust holds the monitor + socket itself, and this only tells it which conversation to watch. """ import asyncio @@ -200,6 +205,24 @@ async def talk(turns: list[str]) -> None: break +async def handoff(conversation_id: str) -> None: + """Hand the conversation to DeepTrust's own monitor. + + The workspace must be connected in the dashboard (Settings, Voice Agents). + DeepTrust would find the call on its next check anyway; the handoff only + makes it immediate. + """ + dt = DeepTrust() + try: + started = await dt.watch(conversation_id) + finally: + await dt.aclose() + print( + f"{'watching' if started else 'already watching'} {conversation_id} (hosted)", + flush=True, + ) + + def serve() -> None: """The webhook receiver, for watching every call without being told.""" import uvicorn @@ -233,6 +256,8 @@ async def call_started(body: dict) -> dict: asyncio.run(watch_one(sys.argv[2])) elif command == "serve": serve() + elif command == "handoff" and len(sys.argv) > 2: + asyncio.run(handoff(sys.argv[2])) else: print(__doc__) raise SystemExit(1) diff --git a/pyproject.toml b/pyproject.toml index 48f672c..d295239 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "deeptrust-ai" -version = "0.0.1" +version = "0.0.2" description = "QA and runtime nudges for voice agents, while the call is still happening." readme = "README.md" requires-python = ">=3.11" diff --git a/src/deeptrust/_http.py b/src/deeptrust/_http.py index 6766b4f..ae31e24 100644 --- a/src/deeptrust/_http.py +++ b/src/deeptrust/_http.py @@ -1,8 +1,12 @@ """HTTP transport for the DeepTrust API. -Holds the base URL, the bearer token and the retry policy, and turns error +Holds the base URL, the API key and the retry policy, and turns error responses into the exception types in `errors`. Nothing in this module knows what an analysis or a verdict is. + +The key travels in `X-DeepTrust-Api-Key`, which is the header the agent +endpoints read. It is also sent as a bearer token for one release, so a client +pinned to an older server keeps working; the bearer form goes away in 0.1. """ from __future__ import annotations @@ -64,7 +68,7 @@ def __init__( # it twice. self.max_retries = max_retries - async def post(self, path: str, body: dict[str, Any]) -> dict[str, Any]: + async def post(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]: last: Exception | None = None for attempt in range(self.max_retries + 1): try: @@ -91,24 +95,18 @@ def _unwrap(self, r: httpx.Response) -> dict[str, Any]: body: dict[str, Any] = r.json() return body - detail = "" - payload: dict[str, Any] = {} - try: - payload = r.json() - detail = str(payload.get("detail") or payload.get("message") or "") - except Exception: - detail = r.text[:300] + detail, fields = _read_error(r) # 401 and 403 both mean the key was refused, and the reason decides # what the caller can do about it. A missing scope is fixed on the key, # a missing entitlement is not fixable by the caller at all, and # anything else means the key itself is wrong. if r.status_code in (401, 403): - code = str(payload.get("code") or "") + code = str(fields.get("code") or "") if code == "missing_scope": raise ScopeError( - needed=str(payload.get("needed") or "this operation"), - held=payload.get("scopes") or [], + needed=str(fields.get("needed") or "this operation"), + held=fields.get("scopes") or [], ) if code == "not_entitled": raise EntitlementError( @@ -126,3 +124,28 @@ def _unwrap(self, r: httpx.Response) -> dict[str, Any]: async def aclose(self) -> None: await self._client.aclose() + + +def _read_error(r: httpx.Response) -> tuple[str, dict[str, Any]]: + """The message and the structured fields of an error response. + + FastAPI puts whatever the server raised under `detail`. That is a string + for a plain refusal and a dict for a coded one, so a `code` may sit either + at the top level or inside `detail`; both are read. A body that is not JSON + is quoted as the message, truncated. + """ + try: + payload = r.json() + except Exception: + return r.text[:300], {} + if not isinstance(payload, dict): + return str(payload)[:300], {} + + detail = payload.get("detail") + fields: dict[str, Any] = dict(payload) + if isinstance(detail, dict): + fields.update(detail) + message = detail.get("message") or detail.get("detail") or detail.get("error") + else: + message = detail or payload.get("message") or payload.get("error") + return str(message or ""), fields diff --git a/src/deeptrust/_version.py b/src/deeptrust/_version.py index f102a9c..3b93d0b 100644 --- a/src/deeptrust/_version.py +++ b/src/deeptrust/_version.py @@ -1 +1 @@ -__version__ = "0.0.1" +__version__ = "0.0.2" diff --git a/src/deeptrust/agents/__init__.py b/src/deeptrust/agents/__init__.py index 453c5b3..8957acb 100644 --- a/src/deeptrust/agents/__init__.py +++ b/src/deeptrust/agents/__init__.py @@ -13,11 +13,16 @@ ... `Session.analyze` reviews the transcript and returns findings, and does not -block the agent. `Session.check` decides whether a single action may run and -does block; it is not implemented in this version. +block the agent. `Session.end` closes the call. `Session.check` decides +whether a single action may run and does block; it is not implemented in this +version. Adapters for LiveKit and ElevenLabs are in `deeptrust.agents.livekit` and `deeptrust.agents.elevenlabs`, and wire both ends up for you. + +`DeepTrust.watch` is for the hosted path: an organisation that connected its +ElevenLabs workspace in the DeepTrust dashboard can hand a live conversation +id to DeepTrust, which then holds the monitor socket itself. """ from __future__ import annotations @@ -91,5 +96,32 @@ def session( metadata=metadata or {}, ) + async def watch( + self, + conversation_id: str, + *, + platform: str = "elevenlabs", + agent_id: str | None = None, + ) -> bool: + """Hand a live platform conversation to DeepTrust to monitor. + + For the hosted path. The organisation must have connected `platform` in + the DeepTrust dashboard (Settings, Voice Agents); DeepTrust then opens + the monitor socket from its own side, so no platform key is needed + here. Call it as soon as the conversation id is known, for instance + from the `conversation_initiation_metadata` client event, and the + call is watched from its first turn instead of from the next poll. + + Returns True when this request started the monitor and False when + DeepTrust was already watching the conversation. Raises `ServiceError` + with status 404 when the platform is not connected for the + organisation. + """ + d = await self._http.post( + f"/agents/conversations/{conversation_id}/watch", + {"platform": platform, "agent_id": agent_id}, + ) + return bool(d.get("started")) + async def aclose(self) -> None: await self._http.aclose() diff --git a/src/deeptrust/agents/_session.py b/src/deeptrust/agents/_session.py index bd4da08..e6903c2 100644 --- a/src/deeptrust/agents/_session.py +++ b/src/deeptrust/agents/_session.py @@ -3,6 +3,8 @@ The session accumulates turns locally and submits the transcript when `analyze` is called. Results are not stored on the session: each call to `analyze` returns its own `Analysis`, and the full record lives server-side. +`end` closes the call so post-call processing starts now rather than after +the server's inactivity timeout. """ from __future__ import annotations @@ -138,6 +140,20 @@ async def analyze(self, *, force: bool = False) -> Analysis | None: raw=d, ) + async def end(self) -> bool: + """Tell DeepTrust the call is over. + + Post-call processing starts at once instead of after the server's + inactivity timeout, so the record is complete minutes sooner. Returns + True when this request ended the call and False when it was already + ended, or when nothing was ever analyzed (there is no call to end). + Calling it twice is harmless. + """ + if not self.id: + return False + d = await self._http.post(f"/agents/sessions/{self.id}/end") + return bool(d.get("ended")) and not bool(d.get("already_ended")) + # ── the action plane ───────────────────────────────────────────────────── async def check( diff --git a/src/deeptrust/agents/elevenlabs.py b/src/deeptrust/agents/elevenlabs.py index 9d45636..7bcc45f 100644 --- a/src/deeptrust/agents/elevenlabs.py +++ b/src/deeptrust/agents/elevenlabs.py @@ -19,6 +19,12 @@ The socket carries transcript events, not audio, so nothing here has access to the audio stream. +This is the self-hosted way to watch ElevenLabs: the socket is held by your +process, with your ElevenLabs key. The hosted alternative needs neither: connect +the workspace once in the DeepTrust dashboard (Settings, Voice Agents) and +DeepTrust holds the socket itself. A backend that already knows a conversation +id can hand it over with `DeepTrust.watch(conversation_id)`. + Install with the extra: pip install "deeptrust-ai[elevenlabs]" """ @@ -36,6 +42,19 @@ MONITOR_URL = "wss://api.elevenlabs.io/v1/convai/conversations/{cid}/monitor" +def contextual_update_command(text: str) -> dict[str, Any]: + """The monitor socket's command for a contextual update. + + The monitor socket takes commands, not the `{"type": ...}` messages the + main conversation socket takes. A message in the other shape is ignored + without an error, which is how 0.0.1 delivered nothing. + """ + return { + "command_type": "contextual_update", + "parameters": {"contextual_update": text}, + } + + class Monitor: """Watches live ElevenLabs conversations and feeds them to DeepTrust.""" @@ -46,7 +65,10 @@ def __init__( api_key: str, deliver: bool = True, on_analysis: Callable[[Any], None] | None = None, + connect: Callable[..., Any] | None = None, ) -> None: + """`connect` opens the socket; it defaults to `websockets.connect` and + exists so a test can supply a fake.""" if not api_key: raise ConfigError( "Monitor needs an ElevenLabs API key with workspace access. " @@ -56,6 +78,7 @@ def __init__( self._key = api_key self._deliver = deliver self._on_analysis = on_analysis + self._connect = connect self._watching: dict[str, asyncio.Task[None]] = {} async def watch( @@ -85,19 +108,22 @@ async def stop(self, conversation_id: str) -> None: task.cancel() async def _loop(self, cid: str, user: User | None) -> None: - try: - import websockets - except ImportError as exc: # pragma: no cover - raise ConfigError( - "the ElevenLabs adapter needs websockets. " - 'Install with: pip install "deeptrust-ai[elevenlabs]"' - ) from exc + connect = self._connect + if connect is None: + try: + import websockets + except ImportError as exc: # pragma: no cover + raise ConfigError( + "the ElevenLabs adapter needs websockets. " + 'Install with: pip install "deeptrust-ai[elevenlabs]"' + ) from exc + connect = websockets.connect call = self._dt.session(external_id=cid, user=user, platform="elevenlabs") url = MONITOR_URL.format(cid=cid) headers = {"xi-api-key": self._key} - async with websockets.connect(url, additional_headers=headers) as ws: + async with connect(url, additional_headers=headers) as ws: async for raw in ws: try: ev = json.loads(raw) @@ -119,14 +145,7 @@ async def _loop(self, cid: str, user: User | None) -> None: if not self._deliver: continue for nudge in result.nudges: - await ws.send( - json.dumps( - { - "type": "contextual_update", - "text": nudge.render(), - } - ) - ) + await ws.send(json.dumps(contextual_update_command(nudge.render()))) def _read_turn(ev: dict[str, Any]) -> tuple[str, str]: diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 0af17f9..6715c11 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -12,10 +12,10 @@ import respx from deeptrust.agents import DeepTrust -from deeptrust.agents.elevenlabs import _read_turn +from deeptrust.agents.elevenlabs import Monitor, _read_turn, contextual_update_command from deeptrust.agents.livekit import attach -BASE = "https://example.test/api" +BASE = "https://example.test/api/v1" ONE_NUDGE = { "session_id": "sess_1", @@ -148,3 +148,99 @@ def test_elevenlabs_event_reader() -> None: assert _read_turn({"type": "audio"}) == ("", "") assert _read_turn({"type": "interruption"}) == ("", "") assert _read_turn({}) == ("", "") + + +def test_elevenlabs_contextual_update_is_a_monitor_command() -> None: + """The monitor socket takes commands; the `{"type": ...}` shape of the + main socket is silently ignored there, which is how 0.0.1 delivered + nothing.""" + assert contextual_update_command("hold the line") == { + "command_type": "contextual_update", + "parameters": {"contextual_update": "hold the line"}, + } + + +class FakeMonitorSocket: + """A monitor socket that replays scripted events and records sends.""" + + def __init__(self, events: list[dict[str, Any]]) -> None: + self._events = events + self.sent: list[dict[str, Any]] = [] + self.url: str | None = None + self.headers: dict[str, str] | None = None + + def __call__( + self, url: str, *, additional_headers: dict[str, str] + ) -> FakeMonitorSocket: + self.url = url + self.headers = additional_headers + return self + + async def __aenter__(self) -> FakeMonitorSocket: + return self + + async def __aexit__(self, *exc: object) -> None: + return None + + def __aiter__(self) -> FakeMonitorSocket: + return self + + async def __anext__(self) -> str: + import json + + if not self._events: + raise StopAsyncIteration + return json.dumps(self._events.pop(0)) + + async def send(self, raw: str) -> None: + import json + + self.sent.append(json.loads(raw)) + + +@respx.mock +async def test_elevenlabs_monitor_sends_nudges_in_the_command_envelope() -> None: + import asyncio + + route = respx.post(f"{BASE}/agents/analyze").mock( + return_value=httpx.Response(200, json=ONE_NUDGE) + ) + socket = FakeMonitorSocket( + [ + { + "type": "agent_response", + "agent_response_event": {"agent_response": "IT desk, how can I help?"}, + }, + { + "type": "user_transcript", + "user_transcription_event": { + "user_transcript": "my colleague is telling me what to say" + }, + }, + {"type": "audio"}, + ] + ) + dt = DeepTrust(api_key="dt_test", base_url=BASE) + monitor = Monitor(dt, api_key="xi_test", connect=socket) + + await monitor.watch("conv_1") + for _ in range(20): + await asyncio.sleep(0.01) + if "conv_1" not in monitor._watching: + break + + assert socket.url == "wss://api.elevenlabs.io/v1/convai/conversations/conv_1/monitor" + assert socket.headers == {"xi-api-key": "xi_test"} + # One job, for the one caller turn; the agent turn and the audio frame cost nothing. + assert route.call_count == 1 + assert socket.sent == [ + { + "command_type": "contextual_update", + "parameters": { + "contextual_update": ( + "The caller referred to someone else on the line. " + "Ask one question and wait: is anyone helping them right now?" + ) + }, + } + ] diff --git a/tests/test_session.py b/tests/test_session.py index bdb5342..83df5e2 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -18,9 +18,10 @@ EntitlementError, RateLimited, ScopeError, + ServiceError, ) -BASE = "https://example.test/api" +BASE = "https://example.test/api/v1" ANALYSIS = { "session_id": "sess_1", @@ -76,6 +77,35 @@ def test_api_key_from_env(monkeypatch: pytest.MonkeyPatch) -> None: assert DeepTrust(base_url=BASE) is not None +def test_default_base_url_is_the_versioned_api(monkeypatch: pytest.MonkeyPatch) -> None: + """The agent routes live under /api/v1; a client built without a base URL + must land there, not one level up.""" + monkeypatch.delenv("DEEPTRUST_BASE_URL", raising=False) + assert ( + DeepTrust(api_key="dt_test")._http.base_url == "https://app.deeptrust.ai/api/v1" + ) + + +@respx.mock +async def test_key_travels_in_the_api_key_header() -> None: + """The server reads X-DeepTrust-Api-Key, and only that. + + Authorization is deliberately left alone: the API tells a key-authenticated + request from a session-authenticated one by which header carried the + credential, so sending both would make a key look like a user's token at + the edge.""" + route = respx.post(f"{BASE}/agents/analyze").mock( + return_value=httpx.Response(200, json=ANALYSIS) + ) + call = client().session(external_id="room-1") + call.append("user", "hello") + await call.analyze() + + headers = route.calls[0].request.headers + assert headers["x-deeptrust-api-key"] == "dt_test" + assert "authorization" not in headers + + def test_transcript_is_turns_not_prose() -> None: """The agent call is two party, so structure is free and we keep it.""" call = client().session(external_id="room-1") @@ -218,6 +248,24 @@ async def test_check_is_not_implemented_and_says_so() -> None: }, ScopeError, ), + # FastAPI wraps a structured refusal under `detail`; the code has to + # be found there too. + ( + 403, + {"detail": {"code": "not_entitled", "message": "no agent access"}}, + EntitlementError, + ), + ( + 403, + { + "detail": { + "code": "missing_scope", + "needed": "agents:analyze", + "scopes": ["read:meetings"], + } + }, + ScopeError, + ), (429, {"detail": "slow down"}, RateLimited), ], ) @@ -249,3 +297,83 @@ async def test_scope_error_names_the_scope_and_what_the_key_holds() -> None: await call.analyze() assert "agents:analyze" in str(err.value) assert "read:meetings" in str(err.value) + + +@respx.mock +async def test_nested_error_message_is_kept() -> None: + """A refusal wrapped under `detail` should read as its message, not as the + dict's repr.""" + respx.post(f"{BASE}/agents/analyze").mock( + return_value=httpx.Response( + 403, json={"detail": {"code": "not_entitled", "message": "plan lacks agents"}} + ) + ) + call = client().session(external_id="room-1") + call.append("user", "hello") + with pytest.raises(EntitlementError, match="plan lacks agents"): + await call.analyze() + + +@respx.mock +async def test_end_closes_the_call_once() -> None: + respx.post(f"{BASE}/agents/analyze").mock( + return_value=httpx.Response(200, json=ANALYSIS) + ) + end = respx.post(f"{BASE}/agents/sessions/sess_1/end").mock( + side_effect=[ + httpx.Response( + 200, json={"session_id": "sess_1", "ended": True, "already_ended": False} + ), + httpx.Response( + 200, json={"session_id": "sess_1", "ended": True, "already_ended": True} + ), + ] + ) + call = client().session(external_id="room-1") + + # Nothing analyzed, so there is no call on the server to end. + assert await call.end() is False + assert not end.called + + call.append("user", "hello") + await call.analyze() + assert await call.end() is True + # Ending again is harmless and says it changed nothing. + assert await call.end() is False + assert end.call_count == 2 + + +@respx.mock +async def test_watch_hands_a_conversation_to_the_hosted_monitor() -> None: + route = respx.post(f"{BASE}/agents/conversations/conv_1/watch").mock( + return_value=httpx.Response( + 202, json={"conversation_id": "conv_1", "watching": True, "started": True} + ) + ) + assert await client().watch("conv_1", agent_id="agent_9") is True + + body = route.calls[0].request.read().decode().replace(" ", "") + assert '"platform":"elevenlabs"' in body + assert '"agent_id":"agent_9"' in body + + +@respx.mock +async def test_watch_reports_when_already_watched() -> None: + respx.post(f"{BASE}/agents/conversations/conv_1/watch").mock( + return_value=httpx.Response( + 202, json={"conversation_id": "conv_1", "watching": True, "started": False} + ) + ) + assert await client().watch("conv_1") is False + + +@respx.mock +async def test_watch_says_when_the_platform_is_not_connected() -> None: + respx.post(f"{BASE}/agents/conversations/conv_1/watch").mock( + return_value=httpx.Response( + 404, json={"detail": "elevenlabs is not connected for this organization"} + ) + ) + with pytest.raises(ServiceError, match="not connected") as err: + await client().watch("conv_1") + assert err.value.status == 404 diff --git a/uv.lock b/uv.lock index 3dfdbcb..29de68b 100644 --- a/uv.lock +++ b/uv.lock @@ -562,7 +562,7 @@ wheels = [ [[package]] name = "deeptrust-ai" -version = "0.0.1" +version = "0.0.2" source = { editable = "." } dependencies = [ { name = "httpx" },