diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md index 97ae86c27..189dbe91d 100644 --- a/docs/memory/architecture.md +++ b/docs/memory/architecture.md @@ -566,21 +566,25 @@ Package `services/compatibility/` mirrors the deterministic `canary/` library: **Note**: Route ordering is critical — static routes (`/context-stats`, `/autonomy-status`) must be defined BEFORE the `/{name}` catch-all (Invariant #4). -### Voice (5 endpoints) +### Voice (6 endpoints) | Method | Path | Description | |--------|------|-------------| -| POST | `/api/agents/{name}/voice/start` | Start Gemini Live voice session; `workspace_mode` enables panel tools | +| POST | `/api/agents/{name}/voice/start` | Start Gemini Live voice session; `workspace_mode` enables panel tools. Resolves voice as request override → persisted `voice_name` → `Kore` (#28) | | POST | `/api/agents/{name}/voice/stop` | Stop active voice session | | GET/PUT | `/api/agents/{name}/voice/prompt` | Get/set per-agent voice system prompt | +| GET/PUT | `/api/agents/{name}/voice/name` | Get (any accessor; returns `available_voices`/`default_voice`) / set (owner-only; 400 on a voice outside `GEMINI_VOICE_NAMES`) the persisted per-agent Gemini voice. Applies to both the voice overlay/workspace and outbound VoIP calls (#28) | | GET | `/api/agents/{name}/voice/{session_id}/panel` | Canvas panel state for workspace mode (ownership-gated; empty state when session gone, #699) | ### VoIP Telephony (VOIP-001, #1056 — flag-gated, default OFF) | Method | Path | Auth | Description | |--------|------|------|-------------| -| GET/PUT/DELETE | `/api/agents/{name}/voip` | Owner | Twilio-voice binding status / configure / remove. 404 when `voip_available` off | +| GET/PUT/DELETE | `/api/agents/{name}/voip` | Owner | Twilio-voice binding status / configure / remove. 404 when `voip_available` off. Re-PUT preserves the `enabled` flag (#28) | +| PUT | `/api/agents/{name}/voip/enabled` | Owner | Enable/disable the binding without re-entering credentials (`{enabled: bool}`); 404 when no binding exists. Disabled ⇒ outbound calls refused (#28) | | POST | `/api/agents/{name}/voip/call` | JWT/MCP (`AuthorizedAgent`) | Place outbound call; rate-limited + daily-capped; optional `Idempotency-Key`. Returns `{call_id, status:"ringing", twilio_call_sid}` | | WS | `/api/voip/voice/{call_id}` | Call-bound ticket | Twilio Media Streams audio bridge — see [VoIP](#voip-telephony-voip-001-1056) | +The per-agent VoIP config + voice-picker UI lives in the agent Settings/Sharing tab (`components/VoipChannelPanel.vue`), shown only when the platform `voip_available` flag is true (frontend reads it via `stores/sessions.js`); the underlying CRUD/voice endpoints are OSS and ungated (#28). + ### Activities (1 endpoint) | Method | Path | Description | |--------|------|-------------| @@ -862,6 +866,7 @@ CREATE TABLE agent_ownership ( max_backlog_depth INTEGER DEFAULT 50, -- BACKLOG-001 group_auth_mode TEXT DEFAULT 'none', voice_system_prompt TEXT, + voice_name TEXT, -- #28: persisted Gemini voice (NULL → 'Kore') guardrails_config TEXT, file_sharing_enabled INTEGER DEFAULT 0, -- FILES-001 circuit_breaker_enabled INTEGER DEFAULT 0, -- RELIABILITY-007 (#526): dispatch-breaker opt-in diff --git a/docs/memory/feature-flows/voice-chat.md b/docs/memory/feature-flows/voice-chat.md index a275d70f8..b67536de5 100644 --- a/docs/memory/feature-flows/voice-chat.md +++ b/docs/memory/feature-flows/voice-chat.md @@ -443,6 +443,25 @@ Returns current canvas panel state. Returns empty state (not 404) for non-existe --- +## Persisted per-agent voice (trinity-enterprise#28) + +The Gemini voice was historically hardcoded to `"Kore"` (`routers/voice.py::_get_voice_name`). +It is now a persisted per-agent setting, `agent_ownership.voice_name` (default `Kore`, +an edition-agnostic OSS primitive like `voice_system_prompt`): + +- `GET/PUT /api/agents/{name}/voice/name` — GET returns the persisted voice plus + `available_voices`/`default_voice`; PUT is owner-only and validates against the + canonical `GEMINI_VOICE_NAMES` (`config.py`), shared with the frontend picker + list (`src/constants/voices.js`) and drift-guarded by a unit test. +- **Resolution at session start** (`voice_start`): per-session request override + (`VoiceStartRequest.voice_name`) → persisted `voice_name` → `Kore`. The read + path (`db.get_voice_name`) falls back to `Kore` for an unset or no-longer-valid + persisted value. +- The **Workspace** ephemeral picker (`AgentWorkspace.vue`) now defaults its + selection to the persisted voice; the picker list is the shared + `src/constants/voices.js` module. The persisted voice also drives outbound VoIP + calls — see `voip-telephony.md`. + ## Related Documentation - [Authenticated Chat Tab](./authenticated-chat-tab.md) — Existing chat implementation diff --git a/docs/memory/feature-flows/voip-telephony.md b/docs/memory/feature-flows/voip-telephony.md index 87ed81de1..21584b0fb 100644 --- a/docs/memory/feature-flows/voip-telephony.md +++ b/docs/memory/feature-flows/voip-telephony.md @@ -1,9 +1,10 @@ # Feature: VoIP Telephony — Outbound Calls over Gemini Live (VOIP-001) -> **Status (2026-06-04)**: Phase 1 (outbound) implemented for #1056. Behind a -> feature flag that is **OFF by default** (`VOIP_ENABLED`). Inbound (Phase 2) -> and UI/observability (Phase 3) are not yet built. Real PSTN path is -> manual-verify (needs a live Twilio voice number). +> **Status (2026-06-23)**: Phase 1 (outbound) implemented for #1056. Behind a +> feature flag that is **OFF by default** (`VOIP_ENABLED`). Phase 3 config UI + +> persisted voice picker added (trinity-enterprise#28, OSS — see below). Inbound +> (Phase 2) and call cost/duration observability are not yet built. Real PSTN +> path is manual-verify (needs a live Twilio voice number). ## Overview @@ -216,6 +217,29 @@ PSTN barge-in latency and telephone-speech (8kHz μ-law) STT quality are unvalidated until real calls happen — Phase 1 logs the barge-in/transcript signals so they can be measured on the first live calls. +## Phase 3 — Config UI + persisted voice (trinity-enterprise#28) + +Delivered as a plain **OSS** feature (not entitlement-gated — a UI gate over a +money-spending OSS backend would be cosmetic; gated on the existing +`voip_available` platform flag instead): + +- **Panel**: `src/frontend/src/components/VoipChannelPanel.vue` (modeled on + `WhatsAppChannelPanel.vue`), mounted in `SharingPanel.vue` under + `v-if="sessionsStore.voipAvailable"` (`stores/sessions.js` now reads + `voip_available` from `/api/settings/feature-flags`). Create/update/remove the + binding over the existing `GET/PUT/DELETE /api/agents/{name}/voip`; the Auth + Token is write-only (never returned). +- **Enable/disable**: `PUT /api/agents/{name}/voip/enabled` (`{enabled}`, + owner-only, 404 when no binding) flips `voip_bindings.enabled` without + re-entering credentials. The upsert no longer forces `enabled=1`, so re-saving + credentials **preserves** a disabled state (the call path already refuses + disabled bindings in `voip_service.py`). +- **Persisted voice**: new OSS primitive `agent_ownership.voice_name` + (default `Kore`) via `GET/PUT /api/agents/{name}/voice/name` (PUT owner-only, + validated against `GEMINI_VOICE_NAMES`). `services/voip_service.py` reads it + per call instead of the old hardcoded `"Kore"`. See `voice-chat.md`. +- **Tests**: `tests/unit/test_28_voip_voice_config.py`. + ## Related Flows - **Upstream**: `voice-chat.md` (the Gemini Live bridge this reuses unchanged), diff --git a/docs/memory/learnings.md b/docs/memory/learnings.md new file mode 100644 index 000000000..d08cda7b3 --- /dev/null +++ b/docs/memory/learnings.md @@ -0,0 +1,8 @@ +# Learnings — durable bug-class ledger + +Append-only. One entry per *class* of error/pattern worth knowing before the next +plan or review. `/autoplan` reads this before planning; write for that reader. + +## 2026-06-23 — pitfall — Adding a column needs BOTH schema.py AND tables.py; parity test guards only one +**Context**: `agent_ownership.voice_name` (trinity-enterprise#28). `tests/unit/test_schema_parity.py` compares `db/schema.py` (raw DDL) against `db/migrations.py` (the SQLite runner) — it does **not** import `db/tables.py` (the SQLAlchemy Core MetaData that Alembic autogenerates from and that every `db/*.py` accessor binds columns against). So a change that adds a column to `schema.py` + `migrations.py` but forgets `tables.py` keeps the parity test green, yet `select(agent_ownership.c.)` blows up at runtime on the first call. +**Lesson**: A schema change touches **four** files — `db/schema.py`, `db/tables.py`, a SQLite migration in `db/migrations.py`, and an Alembic revision under `db/../migrations/versions/`. Don't trust schema-parity to catch a missing `tables.py` Column. The real guard is a db-accessor unit test that executes a live `select(table.c.)` against a migrated DB (db_harness) — it fails loudly if `tables.py` was missed. diff --git a/docs/memory/requirements.md b/docs/memory/requirements.md index a12cbabe1..0981db912 100644 --- a/docs/memory/requirements.md +++ b/docs/memory/requirements.md @@ -2840,9 +2840,41 @@ Standalone mobile-friendly admin page for managing agents on the go. Designed as Phase 2: Twilio voice webhook + `X-Twilio-Signature` + inbound number→agent resolution on the same table; an `inbound_number` column is shipped up-front so Phase 2 is additive); opt-in destination - allowlist (Phase 2); UI config surface, schedule-action trigger, and - call cost/duration observability (Phase 3). Real PSTN path is - manual-verify (needs a live Twilio voice number). + allowlist (Phase 2); schedule-action trigger and call cost/duration + observability (Phase 3). Real PSTN path is manual-verify (needs a live + Twilio voice number). (UI config surface delivered separately in §39.2.) + +### 39.2 Per-agent VoIP config UI + persisted voice (trinity-enterprise#28) +- **Status**: 🚧 In Progress +- **Implements**: Issue trinity-enterprise#28 (the Phase-3 "UI config surface" + deferred by #1056) +- **Description**: A per-agent VoIP config panel in the agent Settings/Sharing + tab (`components/VoipChannelPanel.vue`) to create/update/remove the + `voip_bindings` row over the existing OSS `GET/PUT/DELETE /api/agents/{name}/voip` + endpoints (Twilio Account SID, write-only Auth Token, From number, optional + daily call cap), an **enable/disable toggle**, and a **persisted voice picker**. +- **OSS, not entitlement-gated**: shipped as a plain OSS capability gated purely + on the existing `voip_available` platform flag (the frontend reads it via + `stores/sessions.js`). No enterprise entitlement, no `register_module`, no + `trinity-enterprise` submodule change — a deliberate simplification over the + issue's original "entitlement-gated" framing (a UI gate over an OSS, + money-spending backend would be cosmetic; gate is the platform flag instead). +- **Enable/disable toggle**: `PUT /api/agents/{name}/voip/enabled` + (`{enabled: bool}`, owner-only, 404 when no binding) flips `voip_bindings.enabled` + without re-entering credentials; the call path already refuses disabled + bindings. Re-saving credentials via the binding PUT **preserves** the current + `enabled` state (the upsert no longer forces `enabled=1`). +- **Persisted per-agent voice**: new edition-agnostic OSS primitive + `agent_ownership.voice_name` (default `Kore`, like `voice_system_prompt`) with + `GET/PUT /api/agents/{name}/voice/name` (PUT owner-only, validated against the + canonical `GEMINI_VOICE_NAMES`). Replaces the two hardcoded `"Kore"` sites + (`routers/voice.py::_get_voice_name`, `services/voip_service.py`), so the chosen + voice applies to **both** the in-app voice overlay/workspace and outbound VoIP + calls. Resolution precedence at a voice start: per-session request override → + persisted `voice_name` → `Kore`; the read path falls back to `Kore` for an + unset or no-longer-valid persisted value. The Workspace ephemeral picker + defaults to the persisted voice. Dual-track migration (SQLite `db/migrations.py` + + Alembic `0004_agent_ownership_voice_name` + `db/schema.py`/`db/tables.py`). --- diff --git a/src/backend/config.py b/src/backend/config.py index 0da9b3400..4e35e3b93 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -185,6 +185,14 @@ VOICE_MODEL = os.getenv("VOICE_MODEL") or "models/gemini-3.1-flash-live-preview" VOICE_MAX_DURATION = int(os.getenv("VOICE_MAX_DURATION", "300")) # seconds +# Per-agent voice selection (#28). Canonical set of Gemini Live prebuilt voices +# offered by Trinity; the single source of truth shared by the persisted-voice +# write validation and the read-path fallback (and mirrored by the frontend +# picker). DEFAULT_VOICE_NAME is the historical hardcoded default and the +# fallback for an unset or no-longer-valid persisted value. +DEFAULT_VOICE_NAME = "Kore" +GEMINI_VOICE_NAMES = ("Kore", "Zephyr", "Puck", "Aoede", "Charon", "Fenrir") + # Gemini text/audio models (#1130). Hardcoded `gemini-2.0-flash` was retired by # Google (404 NOT_FOUND) with no config escape hatch — these env overrides make # the next model retirement a config change instead of a code change. Same `or` diff --git a/src/backend/database.py b/src/backend/database.py index 018444ba0..b23813540 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -741,6 +741,12 @@ def get_voice_system_prompt(self, agent_name: str): def set_voice_system_prompt(self, agent_name: str, prompt: str): return self._agent_ops.set_voice_system_prompt(agent_name, prompt) + def get_voice_name(self, agent_name: str): + return self._agent_ops.get_voice_name(agent_name) + + def set_voice_name(self, agent_name: str, voice_name): + return self._agent_ops.set_voice_name(agent_name, voice_name) + # ========================================================================= # MCP API Key Management (delegated to db/mcp_keys.py) # ========================================================================= @@ -1960,6 +1966,9 @@ def update_voip_webhook_url(self, agent_name, webhook_url): def delete_voip_binding(self, agent_name): return self._voip_ops.delete_binding(agent_name) + def set_voip_enabled(self, agent_name, enabled): + return self._voip_ops.set_enabled(agent_name, enabled) + def create_voip_call_log(self, call_id, agent_name, to_number, chat_session_id=None, initiated_by_user_id=None, initiated_by_email=None, direction="outbound"): diff --git a/src/backend/db/agents.py b/src/backend/db/agents.py index 81c33d7d4..237630812 100644 --- a/src/backend/db/agents.py +++ b/src/backend/db/agents.py @@ -397,3 +397,36 @@ def set_voice_system_prompt(self, agent_name: str, prompt: Optional[str]) -> boo .values(voice_system_prompt=prompt or None) ) return result.rowcount > 0 + + # ========================================================================= + # Voice Name (#28) — persisted per-agent Gemini Live voice + # ========================================================================= + + def get_voice_name(self, agent_name: str) -> str: + """Get the persisted Gemini voice for an agent. + + Falls back to DEFAULT_VOICE_NAME ('Kore') when unset, and ALSO when the + persisted value is not in the current GEMINI_VOICE_NAMES set (a voice + removed after it was saved) — defense-in-depth so the call path never + hands Gemini an unusable voice (#28, reviewer M1). + """ + from config import DEFAULT_VOICE_NAME, GEMINI_VOICE_NAMES + + stmt = select(agent_ownership.c.voice_name).where( + agent_ownership.c.agent_name == agent_name, + agent_ownership.c.deleted_at.is_(None), + ) + with get_engine().connect() as conn: + row = conn.execute(stmt).mappings().first() + value = row["voice_name"] if row else None + return value if value in GEMINI_VOICE_NAMES else DEFAULT_VOICE_NAME + + def set_voice_name(self, agent_name: str, voice_name: Optional[str]) -> bool: + """Set the persisted Gemini voice for an agent (None clears it → default).""" + with get_engine().begin() as conn: + result = conn.execute( + update(agent_ownership) + .where(agent_ownership.c.agent_name == agent_name) + .values(voice_name=voice_name or None) + ) + return result.rowcount > 0 diff --git a/src/backend/db/migrations.py b/src/backend/db/migrations.py index ec0b3e98b..55b3ee714 100644 --- a/src/backend/db/migrations.py +++ b/src/backend/db/migrations.py @@ -971,6 +971,22 @@ def _migrate_agent_ownership_voice_prompt(cursor, conn): ) conn.commit() +def _migrate_agent_ownership_voice_name(cursor, conn): + """Add voice_name column to agent_ownership (#28). + + Persists the per-agent Gemini Live voice (e.g. 'Kore', 'Puck'). NULL means + unset — the getter falls back to DEFAULT_VOICE_NAME ('Kore'), preserving the + prior hardcoded behaviour for existing agents. + """ + _safe_add_column( + cursor, + "agent_ownership", + "voice_name", + "ALTER TABLE agent_ownership ADD COLUMN voice_name TEXT", + ) + conn.commit() + + def _migrate_slack_channel_agents(cursor, conn): """Add multi-agent Slack support: workspace table + channel-agent bindings. @@ -2564,4 +2580,5 @@ def _migrate_agent_compatibility_results_table(cursor, conn): ("operator_queue_cleared_at", _migrate_operator_queue_cleared_at), ("activities_created_index", _migrate_activities_created_index), ("agent_compatibility_results_table", _migrate_agent_compatibility_results_table), + ("agent_ownership_voice_name", _migrate_agent_ownership_voice_name), ] diff --git a/src/backend/db/schema.py b/src/backend/db/schema.py index 79257d605..051252f34 100644 --- a/src/backend/db/schema.py +++ b/src/backend/db/schema.py @@ -90,6 +90,7 @@ max_backlog_depth INTEGER DEFAULT 50, group_auth_mode TEXT DEFAULT 'none', voice_system_prompt TEXT, + voice_name TEXT, guardrails_config TEXT, file_sharing_enabled INTEGER DEFAULT 0, circuit_breaker_enabled INTEGER DEFAULT 0, diff --git a/src/backend/db/tables.py b/src/backend/db/tables.py index 2648473a6..0d026de27 100644 --- a/src/backend/db/tables.py +++ b/src/backend/db/tables.py @@ -94,6 +94,7 @@ def process_bind_param(self, value, dialect): Column("max_backlog_depth", Integer), Column("group_auth_mode", Text), Column("voice_system_prompt", Text), + Column("voice_name", Text), Column("guardrails_config", Text), Column("file_sharing_enabled", Integer), Column("circuit_breaker_enabled", Integer), diff --git a/src/backend/db/voip.py b/src/backend/db/voip.py index 64a3da960..b54cf573a 100644 --- a/src/backend/db/voip.py +++ b/src/backend/db/voip.py @@ -96,7 +96,11 @@ def create_binding( "webhook_secret": webhook_secret, "daily_call_cap": cap, "display_name": display_name, - "enabled": 1, + # NOTE: `enabled` is deliberately NOT in the conflict set_ (#28). + # A fresh binding inserts enabled=1; re-saving credentials on an + # existing binding must PRESERVE the current enable state so an + # owner who disabled VoIP doesn't get it silently re-enabled by + # editing the from-number (reviewer H3). Toggle via set_enabled(). "updated_at": now, }, ) @@ -186,6 +190,21 @@ def update_webhook_url(self, agent_name: str, webhook_url: str) -> None: with get_engine().begin() as conn: conn.execute(stmt) + def set_enabled(self, agent_name: str, enabled: bool) -> bool: + """Flip a binding's enabled flag without touching credentials (#28). + + Returns False when no binding row exists for the agent (caller 404s) so + the UI never shows a disabled-but-nonexistent state. + """ + stmt = ( + update(voip_bindings) + .where(voip_bindings.c.agent_name == agent_name) + .values(enabled=1 if enabled else 0, updated_at=utc_now_iso()) + ) + with get_engine().begin() as conn: + result = conn.execute(stmt) + return result.rowcount > 0 + def delete_binding(self, agent_name: str) -> bool: stmt = delete(voip_bindings).where(voip_bindings.c.agent_name == agent_name) with get_engine().begin() as conn: diff --git a/src/backend/migrations/versions/0004_agent_ownership_voice_name.py b/src/backend/migrations/versions/0004_agent_ownership_voice_name.py new file mode 100644 index 000000000..a335e542d --- /dev/null +++ b/src/backend/migrations/versions/0004_agent_ownership_voice_name.py @@ -0,0 +1,30 @@ +"""Add voice_name column to agent_ownership (#28) + +Persists the per-agent Gemini Live voice on the PostgreSQL backend. Mirrors the +SQLite ``agent_ownership_voice_name`` migration in ``db/migrations.py`` and the +DDL in ``db/schema.py`` / MetaData in ``db/tables.py``. + +Fresh PG builds already get the column because ``0001_baseline`` iterates +``db/schema.py:TABLES``. This revision exists so an *existing* PG deployment — +stamped at an earlier revision and never re-running baseline — also picks the +column up on ``alembic upgrade head``. + +Revision ID: 0004_agent_ownership_voice_name +Revises: 0003_agent_compatibility_results +Create Date: 2026-06-23 +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0004_agent_ownership_voice_name" +down_revision = "0003_agent_compatibility_results" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("ALTER TABLE agent_ownership ADD COLUMN IF NOT EXISTS voice_name TEXT") + + +def downgrade() -> None: + op.execute("ALTER TABLE agent_ownership DROP COLUMN IF EXISTS voice_name") diff --git a/src/backend/routers/voice.py b/src/backend/routers/voice.py index 3b35a0aaa..83466a706 100644 --- a/src/backend/routers/voice.py +++ b/src/backend/routers/voice.py @@ -19,9 +19,9 @@ from pydantic import BaseModel from models import User -from dependencies import get_current_user, get_authorized_agent +from dependencies import get_current_user, get_authorized_agent, get_owned_agent from database import db -from config import GEMINI_API_KEY, VOICE_ENABLED +from config import GEMINI_API_KEY, VOICE_ENABLED, DEFAULT_VOICE_NAME, GEMINI_VOICE_NAMES from services.gemini_voice import voice_service, WORKSPACE_PANEL_INSTRUCTIONS from services.agent_auth import agent_httpx_client from services.docker_service import get_agent_container @@ -213,6 +213,43 @@ async def set_voice_prompt( return {"ok": True, "voice_system_prompt": prompt} +@router.get("/api/agents/{name}/voice/name") +async def get_voice_name( + name: str = Depends(get_authorized_agent), + current_user: User = Depends(get_current_user), +): + """Get the agent's persisted voice + the selectable voice list (#28).""" + return { + "voice_name": db.get_voice_name(name), + "available_voices": list(GEMINI_VOICE_NAMES), + "default_voice": DEFAULT_VOICE_NAME, + } + + +@router.put("/api/agents/{name}/voice/name") +async def set_voice_name( + name: str = Depends(get_owned_agent), + current_user: User = Depends(get_current_user), + body: dict = None, +): + """Set the agent's persisted voice (owner-only) (#28). + + Validates against the canonical GEMINI_VOICE_NAMES set (400 on unknown). An + empty/None value clears the override, reverting to DEFAULT_VOICE_NAME. + """ + voice_name = (body or {}).get("voice_name") + if voice_name in (None, ""): + db.set_voice_name(name, None) + return {"ok": True, "voice_name": db.get_voice_name(name)} + if voice_name not in GEMINI_VOICE_NAMES: + raise HTTPException( + status_code=400, + detail=f"Unknown voice '{voice_name}'. Allowed: {', '.join(GEMINI_VOICE_NAMES)}", + ) + db.set_voice_name(name, voice_name) + return {"ok": True, "voice_name": voice_name} + + # ── WebSocket Handler ──────────────────────────────────────────────────────── @router.websocket("/ws/voice/{voice_session_id}") @@ -435,9 +472,13 @@ async def _get_voice_system_prompt(agent_name: str) -> str: def _get_voice_name(agent_name: str) -> str: - """Get voice name for an agent (default: Kore).""" - # For MVP, use a fixed default. Per-agent voice selection is Phase 3. - return "Kore" + """Resolve the agent's persisted Gemini voice (#28). + + Delegates to the DB accessor, which falls back to DEFAULT_VOICE_NAME ('Kore') + when unset or invalid. A per-session override (VoiceStartRequest.voice_name) + still takes precedence at the call site (see voice_start). + """ + return db.get_voice_name(agent_name) def _build_context_summary(chat_session_id: str) -> str: diff --git a/src/backend/routers/voip.py b/src/backend/routers/voip.py index af71c15f9..de0d40524 100644 --- a/src/backend/routers/voip.py +++ b/src/backend/routers/voip.py @@ -65,6 +65,10 @@ class VoipCallRequest(BaseModel): process_transcript: bool = True +class VoipEnabledRequest(BaseModel): + enabled: bool + + # ── Helpers ────────────────────────────────────────────────────────────────── def _require_enabled(): @@ -150,6 +154,33 @@ async def configure_voip_binding( ) +@auth_router.put("/{agent_name}/voip/enabled", response_model=VoipBindingResponse) +async def set_voip_enabled( + agent_name: OwnedAgentByName, + config: VoipEnabledRequest, +): + """Enable/disable an agent's VoIP without re-entering credentials (owner-only, #28). + + The call path already refuses disabled bindings (voip_service); this flips the + `enabled` flag in place. 404 when no binding exists so the UI never shows a + disabled-but-nonexistent state. + """ + _require_enabled() + updated = db.set_voip_enabled(agent_name, config.enabled) + if not updated: + raise HTTPException(status_code=404, detail="No VoIP binding configured for this agent") + binding = db.get_voip_binding(agent_name) + return VoipBindingResponse( + agent_name=agent_name, + configured=True, + account_sid=binding["account_sid"], + from_number=binding["from_number"], + daily_call_cap=binding.get("daily_call_cap"), + display_name=binding.get("display_name"), + enabled=binding.get("enabled"), + ) + + @auth_router.delete("/{agent_name}/voip") async def delete_voip_binding(agent_name: OwnedAgentByName): """Remove the Twilio voice binding for an agent (owner-only).""" diff --git a/src/backend/services/voip_service.py b/src/backend/services/voip_service.py index 82d62da54..4f598999b 100644 --- a/src/backend/services/voip_service.py +++ b/src/backend/services/voip_service.py @@ -200,7 +200,10 @@ async def place_outbound_call( "user_id": initiator_user_id, "user_email": initiator_email, "system_prompt": system_prompt, - "voice_name": "Kore", + # Persisted per-agent voice (#28); falls back to 'Kore' inside the + # accessor. Always set here so twilio_media_stream's intent.get(..., + # "Kore") fallback is dead code, not a silent override (reviewer C2). + "voice_name": db.get_voice_name(agent_name), "to_number": dest, "process_transcript": bool(process_transcript), } diff --git a/src/frontend/src/components/SharingPanel.vue b/src/frontend/src/components/SharingPanel.vue index 411cf40fc..75bda47f5 100644 --- a/src/frontend/src/components/SharingPanel.vue +++ b/src/frontend/src/components/SharingPanel.vue @@ -219,6 +219,13 @@ + + +
@@ -244,7 +251,9 @@ import PublicLinksPanel from './PublicLinksPanel.vue' import SlackChannelPanel from './SlackChannelPanel.vue' import TelegramChannelPanel from './TelegramChannelPanel.vue' import WhatsAppChannelPanel from './WhatsAppChannelPanel.vue' +import VoipChannelPanel from './VoipChannelPanel.vue' import FileSharingPanel from './FileSharingPanel.vue' +import { useSessionsStore } from '../stores/sessions' const props = defineProps({ agentName: { @@ -262,6 +271,11 @@ const emit = defineEmits(['agent-updated']) const agentsStore = useAgentsStore() const { showNotification } = useNotification() +// VoIP panel visibility (#28) — gated purely on the platform `voip_available` +// flag (VOIP_ENABLED && GEMINI_API_KEY). Cached, idempotent, fire-and-forget. +const sessionsStore = useSessionsStore() +sessionsStore.loadFeatureFlags() + // Create agent ref for composable const agent = ref({ name: props.agentName, shares: props.shares }) diff --git a/src/frontend/src/components/VoipChannelPanel.vue b/src/frontend/src/components/VoipChannelPanel.vue new file mode 100644 index 000000000..47f80da09 --- /dev/null +++ b/src/frontend/src/components/VoipChannelPanel.vue @@ -0,0 +1,341 @@ + + + diff --git a/src/frontend/src/constants/voices.js b/src/frontend/src/constants/voices.js new file mode 100644 index 000000000..f634dc30e --- /dev/null +++ b/src/frontend/src/constants/voices.js @@ -0,0 +1,16 @@ +// Canonical Gemini Live voice list (#28) — the single frontend source of truth +// for both the AgentWorkspace per-session picker and the VoIP settings picker. +// Mirrors the backend `GEMINI_VOICE_NAMES` in src/backend/config.py; a backend +// unit test asserts the two lists agree so they can't silently drift. +export const DEFAULT_VOICE_NAME = 'Kore' + +export const VOICES = [ + { id: 'Kore', label: 'Kore — Firm' }, + { id: 'Zephyr', label: 'Zephyr — Bright' }, + { id: 'Puck', label: 'Puck — Upbeat' }, + { id: 'Aoede', label: 'Aoede — Breezy' }, + { id: 'Charon', label: 'Charon — Informational' }, + { id: 'Fenrir', label: 'Fenrir — Excitable' }, +] + +export const VOICE_IDS = VOICES.map((v) => v.id) diff --git a/src/frontend/src/stores/sessions.js b/src/frontend/src/stores/sessions.js index 21bf942d5..fdfc91bd8 100644 --- a/src/frontend/src/stores/sessions.js +++ b/src/frontend/src/stores/sessions.js @@ -41,6 +41,7 @@ export const useSessionsStore = defineStore('sessions', { sessionTabEnabled: false, voiceAvailable: false, workspaceAvailable: false, + voipAvailable: false, }), getters: { @@ -69,10 +70,12 @@ export const useSessionsStore = defineStore('sessions', { this.sessionTabEnabled = !!r.data?.session_tab_enabled this.voiceAvailable = !!r.data?.voice_available this.workspaceAvailable = !!r.data?.workspace_available + this.voipAvailable = !!r.data?.voip_available } catch { this.sessionTabEnabled = false this.voiceAvailable = false this.workspaceAvailable = false + this.voipAvailable = false } finally { this.featureFlagsLoaded = true } diff --git a/src/frontend/src/views/AgentWorkspace.vue b/src/frontend/src/views/AgentWorkspace.vue index 6a90bdb01..19a24dd78 100644 --- a/src/frontend/src/views/AgentWorkspace.vue +++ b/src/frontend/src/views/AgentWorkspace.vue @@ -288,6 +288,7 @@ import { useAgentsStore } from '../stores/agents' import { useVoiceSession } from '../composables/useVoiceSession' import { renderMarkdown } from '../utils/markdown' import AgentAvatar from '../components/AgentAvatar.vue' +import { VOICES, DEFAULT_VOICE_NAME } from '../constants/voices' // Mermaid renders in-parent (not in a sandboxed iframe): the production CSP // (script-src 'self') blocks inline scripts in a srcdoc iframe, and CORP blocks // the bundle from the iframe's opaque origin (#979). securityLevel:'strict' @@ -307,7 +308,9 @@ const agentName = route.params.name const voice = useVoiceSession(agentName) const agent = ref(null) -const selectedVoice = ref('Kore') +// Defaults to the persisted per-agent voice once fetchAgent resolves (#28); the +// user can still override for this session via the picker. +const selectedVoice = ref(DEFAULT_VOICE_NAME) const panelState = ref({ type: 'empty', content: '', title: null, updated_at: null }) let panelPollTimer = null let panelFetching = false @@ -333,14 +336,8 @@ const imageBlobCache = new Map() // path → objectURL const imageObjectUrl = ref(null) const imageError = ref(false) -const VOICES = [ - { id: 'Kore', label: 'Kore — Firm' }, - { id: 'Zephyr', label: 'Zephyr — Bright' }, - { id: 'Puck', label: 'Puck — Upbeat' }, - { id: 'Aoede', label: 'Aoede — Breezy' }, - { id: 'Charon', label: 'Charon — Informational' }, - { id: 'Fenrir', label: 'Fenrir — Excitable' }, -] +// Voice list is shared with the VoIP settings picker (src/constants/voices.js) +// so the two can't drift (#28). // ── Computed ──────────────────────────────────────────────────────────────── @@ -591,6 +588,17 @@ async function fetchAgent() { } catch (_) {} } +// Default the session picker to the agent's persisted voice (#28). Best-effort: +// a failure leaves the picker on DEFAULT_VOICE_NAME. +async function fetchPersistedVoice() { + try { + const r = await axios.get(`/api/agents/${agentName}/voice/name`, { + headers: authStore.authHeader, + }) + if (r.data?.voice_name) selectedVoice.value = r.data.voice_name + } catch (_) {} +} + // ── Orb animation ──────────────────────────────────────────────────────────── // Verbatim from VoiceOverlay.vue — self-contained in this page. @@ -825,6 +833,7 @@ watch(() => voice.status.value, (s) => { targetHueShift = STATE_HUE[s] ?? 0 }) onMounted(async () => { await fetchAgent() + fetchPersistedVoice() currentSprites = buildSprites(0) initParticles() resizeCanvas() diff --git a/tests/registry.json b/tests/registry.json index 622755ce1..e7c768ba3 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -690,6 +690,20 @@ "agents" ], "description": "Agent compatibility validation (#668), fixture-driven pure layers: spec catalog consistency (100 checks, unique ids, STATIC registry == spec.STATIC_IDS, AI prompts present, AI severity capped at SOFT, every auto_fixable has a fix) + spec<->docs/agent-validation-spec.md id sync; STATIC checks over good/bad/empty snapshots (missing template/CLAUDE.md, gitignore secret exclusions, blanket .claude/ removal keeping .claude/projects/, hardcoded-secret scan never echoing the value, K-001 ${VAR} documentation, D-003 widget fields, P-006 approval-gate scan); gitignore auto-fix transforms (append/idempotent/CRLF-no-dup/comment-not-matched/G-001 blanket-removal/unknown-raises); AI batching (no-key skip, omitted-check->skipped iterate-expected, redaction strips secrets); build_report orchestration with collect monkeypatched + real tmp-DB persistence (assemble + persist, codex runtime omits claude_only checks, stopped container -> unavailable); collector in-container script executed against a temp ROOT (snapshot shape, .env content never captured, binary flagged, missing->exists:false, huge file truncated)." + }, + { + "file": "unit/test_28_voip_voice_config.py", + "feature": "trinity-enterprise#28", + "added": "2026-06-23", + "categories": ["backend", "unit", "database", "voip", "voice"], + "description": "Per-agent persisted voice + VoIP enable/disable toggle (#28). db/agents.py get_voice_name/set_voice_name: unset->'Kore' fallback, set/get roundtrip, invalid-persisted-value->default (reviewer M1 read-path validation), clear->default, set-on-missing-agent->False. db/voip.py set_enabled toggle + the create_binding 'preserve enabled on re-PUT' fix (reviewer H3 — re-saving credentials on a disabled binding must not silently re-enable it) + set_enabled on a missing binding returns False (router 404s). config.GEMINI_VOICE_NAMES default + parity guard asserting the frontend src/constants/voices.js VOICE ids and DEFAULT_VOICE_NAME mirror the backend constants (reviewer M2 cross-language drift guard). Runs on db_harness backends (SQLite always, PostgreSQL when TEST_POSTGRES_URL set); no Docker/API/Redis." + }, + { + "file": "unit/test_28_voip_voice_endpoints.py", + "feature": "trinity-enterprise#28", + "added": "2026-06-23", + "categories": ["backend", "unit", "api", "voip", "voice"], + "description": "HTTP-level tests for the #28 endpoints via FastAPI TestClient with auth deps overridden + db/voip_service stubbed (reviewer I1). PUT /api/agents/{name}/voice/name: owner-gated (403 when the get_owned_agent dep denies), 400 on a voice outside GEMINI_VOICE_NAMES, empty body clears to NULL->default, valid voice persists; GET returns voice_name + available_voices + default_voice. PUT /api/agents/{name}/voip/enabled: owner-gated (403), 404 when no binding (db.set_voip_enabled False), 404 when voip_available off, 200 reflecting new enabled state with no auth_token in the response. Mounts the real routers on minimal apps; no Docker/Redis." } ] } diff --git a/tests/unit/test_28_voip_voice_config.py b/tests/unit/test_28_voip_voice_config.py new file mode 100644 index 000000000..f9ca5b98b --- /dev/null +++ b/tests/unit/test_28_voip_voice_config.py @@ -0,0 +1,164 @@ +""" +Unit tests for #28 — persisted per-agent voice + VoIP enable/disable toggle. + +Covers: +- db/agents.py get_voice_name / set_voice_name: unset→default fallback, + roundtrip, invalid-persisted→default (reviewer M1), clear→default. +- db/voip.py set_enabled + the create_binding "preserve enabled on re-PUT" + fix (reviewer H3) + 404-shaped no-op on a missing binding. +- config.py GEMINI_VOICE_NAMES default + parity with the frontend + src/constants/voices.js list (reviewer M2 drift guard). + +Runs on the db_harness backends (SQLite always; PostgreSQL when +TEST_POSTGRES_URL is set). No Docker, no API, no Redis. + +Modules: src/backend/db/agents.py, src/backend/db/voip.py, src/backend/config.py +""" + +import os +import re +import secrets +import sys +from pathlib import Path + +os.environ.setdefault("REDIS_URL", "redis://test:test@redis:6379") +os.environ.setdefault("REDIS_PASSWORD", "test") +os.environ.setdefault("REDIS_BACKEND_PASSWORD", "test") + +import pytest + +_BACKEND = Path(__file__).resolve().parent.parent.parent / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +from db_harness import db_backend, engine_conn, seed_agent, seed_user # noqa: E402,F401 + + +@pytest.fixture +def agent_ops(db_backend): + """AgentOperations on the active backend, with one agent_ownership row.""" + from db.agents import AgentOperations + from db.users import UserOperations + + seed_user() + seed_agent("agent-voice") + yield AgentOperations(UserOperations()) + + +@pytest.fixture +def voip_ops(db_backend): + from db.voip import VoipOperations + + yield VoipOperations() + + +@pytest.fixture(autouse=True) +def _encryption_key(monkeypatch): + # create_binding encrypts the AuthToken via AES-256-GCM (needs a key). + monkeypatch.setenv("CREDENTIAL_ENCRYPTION_KEY", secrets.token_hex(32)) + yield + + +# --------------------------------------------------------------------------- +# Persisted per-agent voice (db/agents.py) +# --------------------------------------------------------------------------- + +class TestVoiceName: + def test_unset_falls_back_to_default(self, agent_ops): + # Fresh agent has no voice_name → the historical 'Kore' default. + assert agent_ops.get_voice_name("agent-voice") == "Kore" + + def test_set_then_get_roundtrip(self, agent_ops): + assert agent_ops.set_voice_name("agent-voice", "Puck") is True + assert agent_ops.get_voice_name("agent-voice") == "Puck" + + def test_invalid_persisted_value_falls_back_to_default(self, agent_ops): + # A value no longer in GEMINI_VOICE_NAMES (e.g. removed by Google) must + # not reach the call path — get_voice_name fails closed to the default. + agent_ops.set_voice_name("agent-voice", "RetiredVoice") + assert agent_ops.get_voice_name("agent-voice") == "Kore" + + def test_clear_reverts_to_default(self, agent_ops): + agent_ops.set_voice_name("agent-voice", "Charon") + assert agent_ops.get_voice_name("agent-voice") == "Charon" + assert agent_ops.set_voice_name("agent-voice", None) is True + assert agent_ops.get_voice_name("agent-voice") == "Kore" + + def test_set_on_missing_agent_is_noop(self, agent_ops): + assert agent_ops.set_voice_name("nope", "Puck") is False + + +# --------------------------------------------------------------------------- +# VoIP enable/disable toggle (db/voip.py) +# --------------------------------------------------------------------------- + +class TestVoipEnabled: + def _make_binding(self, ops): + return ops.create_binding( + agent_name="agent-voip", + account_sid="AC" + "0" * 32, + auth_token="FAKE-TOKEN-not-real", + from_number="+14155550100", + ) + + def test_new_binding_is_enabled(self, voip_ops): + b = self._make_binding(voip_ops) + assert b["enabled"] is True + + def test_toggle_disable_then_enable(self, voip_ops): + self._make_binding(voip_ops) + assert voip_ops.set_enabled("agent-voip", False) is True + assert voip_ops.get_binding_by_agent("agent-voip")["enabled"] is False + assert voip_ops.set_enabled("agent-voip", True) is True + assert voip_ops.get_binding_by_agent("agent-voip")["enabled"] is True + + def test_resave_credentials_preserves_disabled_state(self, voip_ops): + # Reviewer H3: re-PUTting credentials on a disabled binding must NOT + # silently re-enable it. + self._make_binding(voip_ops) + voip_ops.set_enabled("agent-voip", False) + # Owner edits the from-number / re-validates creds via the same upsert. + voip_ops.create_binding( + agent_name="agent-voip", + account_sid="AC" + "0" * 32, + auth_token="FAKE-TOKEN-not-real", + from_number="+14155550199", + ) + b = voip_ops.get_binding_by_agent("agent-voip") + assert b["from_number"] == "+14155550199" # edit landed + assert b["enabled"] is False # but stayed disabled + + def test_set_enabled_on_missing_binding_returns_false(self, voip_ops): + assert voip_ops.set_enabled("no-such-agent", True) is False + + +# --------------------------------------------------------------------------- +# Voice list: backend default + frontend parity (no DB) +# --------------------------------------------------------------------------- + +class TestVoiceConstants: + def test_backend_defaults(self): + import config + + assert config.DEFAULT_VOICE_NAME == "Kore" + assert config.DEFAULT_VOICE_NAME in config.GEMINI_VOICE_NAMES + assert config.GEMINI_VOICE_NAMES == ( + "Kore", "Zephyr", "Puck", "Aoede", "Charon", "Fenrir", + ) + + def test_frontend_voice_list_matches_backend(self): + """The frontend src/constants/voices.js VOICE ids must equal the + backend GEMINI_VOICE_NAMES — the two are mirrored across the language + boundary and a drift would silently desync the picker from validation + (reviewer M2).""" + import config + + js = ( + _BACKEND.parent / "frontend" / "src" / "constants" / "voices.js" + ).read_text() + # Pull each `{ id: 'X', ... }` in declaration order. + ids = re.findall(r"id:\s*'([^']+)'", js) + assert tuple(ids) == config.GEMINI_VOICE_NAMES + # And the JS default agrees with the backend default. + m = re.search(r"DEFAULT_VOICE_NAME\s*=\s*'([^']+)'", js) + assert m and m.group(1) == config.DEFAULT_VOICE_NAME diff --git a/tests/unit/test_28_voip_voice_endpoints.py b/tests/unit/test_28_voip_voice_endpoints.py new file mode 100644 index 000000000..6129cf8b9 --- /dev/null +++ b/tests/unit/test_28_voip_voice_endpoints.py @@ -0,0 +1,174 @@ +""" +HTTP-level tests for #28 — the new voice/voip config endpoints. + +Mounts the real `routers/voice.py` and `routers/voip.py` routers on minimal +FastAPI apps with the auth dependencies overridden and `db`/`voip_service` +stubbed, then drives them through a `TestClient`. This exercises the genuine +FastAPI routing + dependency injection that the db-layer unit tests in +`test_28_voip_voice_config.py` don't reach (reviewer I1): + +- `PUT /api/agents/{name}/voice/name` — owner-gated, 400 on a voice outside + GEMINI_VOICE_NAMES, empty clears, valid persists. +- `GET /api/agents/{name}/voice/name` — returns the persisted voice + the + selectable list. +- `PUT /api/agents/{name}/voip/enabled` — owner-gated, 404 when no binding, + 200 reflecting the new state. + +Modules: src/backend/routers/voice.py, src/backend/routers/voip.py +""" + +import os +import sys +import types +from pathlib import Path + +os.environ.setdefault("REDIS_URL", "redis://test:test@redis:6379") +os.environ.setdefault("REDIS_PASSWORD", "test") +os.environ.setdefault("REDIS_BACKEND_PASSWORD", "test") + +import pytest + +_BACKEND = Path(__file__).resolve().parent.parent.parent / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +from fastapi import FastAPI, HTTPException # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +import routers.voice as voice_mod # noqa: E402 +import routers.voip as voip_mod # noqa: E402 +from dependencies import ( # noqa: E402 + get_current_user, + get_authorized_agent, + get_owned_agent, + get_owned_agent_by_name, +) + +_AGENT = "myagent" +_USER = types.SimpleNamespace( + username="owner", email="owner@example.com", role="user", id=1 +) + + +# --------------------------------------------------------------------------- +# /voice/name (voice router) +# --------------------------------------------------------------------------- + +@pytest.fixture +def voice_client(monkeypatch): + app = FastAPI() + app.include_router(voice_mod.router) + app.dependency_overrides[get_authorized_agent] = lambda: _AGENT + app.dependency_overrides[get_owned_agent] = lambda: _AGENT + app.dependency_overrides[get_current_user] = lambda: _USER + # db is stubbed per-test; default to a benign persisted voice. + monkeypatch.setattr(voice_mod.db, "get_voice_name", lambda _n: "Kore", raising=False) + monkeypatch.setattr(voice_mod.db, "set_voice_name", lambda *_a: True, raising=False) + return TestClient(app), app + + +class TestVoiceNameEndpoint: + def test_get_returns_voice_and_list(self, voice_client, monkeypatch): + client, _ = voice_client + monkeypatch.setattr(voice_mod.db, "get_voice_name", lambda _n: "Puck") + r = client.get(f"/api/agents/{_AGENT}/voice/name") + assert r.status_code == 200 + body = r.json() + assert body["voice_name"] == "Puck" + assert "Kore" in body["available_voices"] + assert body["default_voice"] == "Kore" + + def test_put_valid_voice_persists(self, voice_client, monkeypatch): + client, _ = voice_client + calls = [] + monkeypatch.setattr(voice_mod.db, "set_voice_name", lambda n, v: calls.append((n, v)) or True) + r = client.put(f"/api/agents/{_AGENT}/voice/name", json={"voice_name": "Charon"}) + assert r.status_code == 200 + assert r.json()["voice_name"] == "Charon" + assert calls == [(_AGENT, "Charon")] + + def test_put_unknown_voice_400(self, voice_client): + client, _ = voice_client + r = client.put(f"/api/agents/{_AGENT}/voice/name", json={"voice_name": "Bogus"}) + assert r.status_code == 400 + assert "Bogus" in r.json()["detail"] + + def test_put_empty_clears_to_default(self, voice_client, monkeypatch): + client, _ = voice_client + cleared = [] + monkeypatch.setattr(voice_mod.db, "set_voice_name", lambda n, v: cleared.append(v) or True) + monkeypatch.setattr(voice_mod.db, "get_voice_name", lambda _n: "Kore") + r = client.put(f"/api/agents/{_AGENT}/voice/name", json={"voice_name": ""}) + assert r.status_code == 200 + assert cleared == [None] # empty → cleared to NULL + assert r.json()["voice_name"] == "Kore" + + def test_put_is_owner_gated(self, voice_client): + client, app = voice_client + + def _deny(): + raise HTTPException(status_code=403, detail="Owner access required") + + app.dependency_overrides[get_owned_agent] = _deny + r = client.put(f"/api/agents/{_AGENT}/voice/name", json={"voice_name": "Puck"}) + assert r.status_code == 403 + + +# --------------------------------------------------------------------------- +# /voip/enabled (voip router) +# --------------------------------------------------------------------------- + +_BINDING = { + "account_sid": "AC" + "0" * 32, + "from_number": "+14155550100", + "daily_call_cap": 50, + "display_name": "Acme", + "enabled": False, +} + + +@pytest.fixture +def voip_client(monkeypatch): + app = FastAPI() + app.include_router(voip_mod.auth_router) + app.dependency_overrides[get_owned_agent_by_name] = lambda: _AGENT + app.dependency_overrides[get_current_user] = lambda: _USER + # _require_enabled() gate: pretend the platform flag is on. + monkeypatch.setattr(voip_mod.voip_service, "is_available", lambda: True) + return TestClient(app), app + + +class TestVoipEnabledEndpoint: + def test_toggle_success_reflects_state(self, voip_client, monkeypatch): + client, _ = voip_client + calls = [] + monkeypatch.setattr(voip_mod.db, "set_voip_enabled", lambda n, e: calls.append((n, e)) or True) + monkeypatch.setattr(voip_mod.db, "get_voip_binding", lambda _n: dict(_BINDING, enabled=False)) + r = client.put(f"/api/agents/{_AGENT}/voip/enabled", json={"enabled": False}) + assert r.status_code == 200 + assert r.json()["enabled"] is False + assert calls == [(_AGENT, False)] + # Token never leaks in the response. + assert "auth_token" not in r.json() + + def test_toggle_missing_binding_404(self, voip_client, monkeypatch): + client, _ = voip_client + monkeypatch.setattr(voip_mod.db, "set_voip_enabled", lambda _n, _e: False) + r = client.put(f"/api/agents/{_AGENT}/voip/enabled", json={"enabled": True}) + assert r.status_code == 404 + + def test_404_when_voip_flag_off(self, voip_client, monkeypatch): + client, _ = voip_client + monkeypatch.setattr(voip_mod.voip_service, "is_available", lambda: False) + r = client.put(f"/api/agents/{_AGENT}/voip/enabled", json={"enabled": True}) + assert r.status_code == 404 + + def test_is_owner_gated(self, voip_client): + client, app = voip_client + + def _deny(): + raise HTTPException(status_code=403, detail="Owner access required") + + app.dependency_overrides[get_owned_agent_by_name] = _deny + r = client.put(f"/api/agents/{_AGENT}/voip/enabled", json={"enabled": True}) + assert r.status_code == 403