From 8df172030560245dfe159116165353e649aaa4f4 Mon Sep 17 00:00:00 2001
From: Oleksii Dolhov
Date: Fri, 11 Sep 2026 11:53:12 +0300
Subject: [PATCH 01/28] fix(workspace): the mic renders on speech-to-text
capability, not key presence (#2695)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`stt_available` was `bool(tts_service.is_available())` — a non-empty check on
the ElevenLabs key. ElevenLabs permissions are per endpoint, so a key granted
Text-to-Speech but not Speech-to-Text rendered a fully working-looking mic that
failed on every press with `401 missing_permissions`, while spoken replies
played normally on the same instance. Nothing in the UI or the admin panel could
say so: `key_configured` was honestly true.
New `services/stt_capability_service.py`: one provider probe per key — a
one-byte non-audio POST to `/v1/speech-to-text`, which authorises before it
validates, so 401/403 is `refused` (with the provider's status word), any other
definitive status is `capable`, and a transport error / 5xx is `unknown`. The
verdict is cached in Redis under a digest of the key (6h decided / 2min
unknown, per-process fallback when Redis is down), so a key change is a miss by
construction and the key resolver stays uncached across workers. Reads are
bounded (`WAIT_BUDGET_SECONDS`: a slow provider answers `unknown` now and the
probe fills the cache in the background) and fail soft: only a definitive
refusal hides the mic.
`client_portal.service._stt_ready` is now THE gate — the roster card, the
agent page and `transcribe_portal_audio` all resolve it, so the control a
client sees and the endpoint it calls still cannot disagree (#2212's rule). A
real `/stt` 401 stores `refused`, so the reported symptom heals the cache even
if the probe never ran.
`GET`/`PUT /api/settings/elevenlabs` carry `stt_capability`, `stt_detail` and
`stt_checked_at` beside `key_configured`; re-saving a key invalidates its row.
Settings → Voice renders "can transcribe" / "cannot transcribe — " /
"transcription not verified" next to the presence badge, via the pure
`utils/sttCapability.js` rule.
Tests execute every consumer of the verdict (classifier partition, cache
keying, bounded wait, roster threading, endpoint refusal, live-refusal
feedback, settings state) — none read source text.
Fixes #2695
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
---
docs/memory/architecture/backend.md | 2 +-
docs/memory/learnings.md | 4 +
src/backend/client_portal/service.py | 56 ++-
src/backend/routers/settings.py | 26 +-
.../services/stt_capability_service.py | 273 ++++++++++++++
src/frontend/src/utils/sttCapability.js | 41 ++
src/frontend/src/views/Settings.vue | 38 ++
src/frontend/tests/unit/sttCapability.spec.js | 42 +++
tests/unit/test_2695_stt_capability_probe.py | 355 ++++++++++++++++++
9 files changed, 825 insertions(+), 12 deletions(-)
create mode 100644 src/backend/services/stt_capability_service.py
create mode 100644 src/frontend/src/utils/sttCapability.js
create mode 100644 src/frontend/tests/unit/sttCapability.spec.js
create mode 100644 tests/unit/test_2695_stt_capability_probe.py
diff --git a/docs/memory/architecture/backend.md b/docs/memory/architecture/backend.md
index 067674b70..8a1ac9022 100644
--- a/docs/memory/architecture/backend.md
+++ b/docs/memory/architecture/backend.md
@@ -192,7 +192,7 @@
- `proactive_message_service.py` - Agent-to-user proactive messaging with rate limiting and audit (#321)
- `channel_completion_report.py` - Reports a delegated/background execution's terminal back to its originating channel chat/thread (ent#224 Slack, ent#265 Telegram, ent#457 Workspace): inherited-context-only (never inline turns), binding-agent consent + delivery, effect-guarded at-most-once. **The Workspace leg's consent is by construction, not by flag** — a portal session belongs to exactly one client, so there is no third party for an `allow_proactive` bit to protect, which is why the recipient is read from the SESSION ROW (the platform's own record of whose chat this is) rather than from the execution's inherited stamp alone; delivery is a persisted assistant message and the sidebar's `last_message_at` is touched like any other writer's. **Durable, not immediately visible** — the Workspace does NOT poll its threads (`refreshThreads()` is event-driven; the only interval is the 20s asks poll on a different surface), so a client sitting on the thread sees the report at their next reload or thread switch; an idle history poll is a tracked follow-up. A report landing mid-turn can also be read AS that turn's answer, since the client detects a reply by an assistant-row count delta and this is a second writer of those rows — the honest fix needs a per-row discriminator the table does not carry. `INLINE_CHANNEL_TRIGGERS` gains `"public"` so a Workspace turn's OWN execution is never reported twice — public links and x402 share that trigger and are unaffected, since they stamp no `source_channel_chat_id` and never reach the gate — see [channel-completion-report.md](../feature-flows/channel-completion-report.md)
- `channel_history.py` - Persists a delivered proactive **group/channel** broadcast into the channel session (#1649), so the agent has a record of its own outreach. Session keys are derived by driving the channel adapter's own `get_session_identifier()` (never re-implemented — that drifts). **Slack = real recall**: channel sessions are thread-scoped, so a broadcast filed at its own `ts` IS the session an in-thread reply resolves to (needs `slack_service.send_message_detailed()` to return the ts). **Telegram = bookkeeping only**: group sessions are per-(sender, chat) with no group branch, so a broadcast uses a synthetic agent-sender key nothing else writes to — recorded but NOT recalled; real recall needs a per-chat group session (a behaviour change for existing inbound groups). `#903` shared-thread attribution (`sender_email=None`); persist on confirmed delivery only; fail-soft
-- `tts_service.py` - Shared outbound-voice TTS layer (epic #24): ElevenLabs synth → ffmpeg OGG/Opus transcode; shared char cost-cap; fail-soft (any error → text fallback). Key resolved at call time via `settings_service.get_elevenlabs_api_key()` (stored setting → env, ent#117), not the frozen config value. Consumed by `voice_reply_service` (ent#117) and the STT path. Also owns the **one voice gate** every surface shares (#2157): `resolve_voice_id(agent)` / the pure `resolve_voice_from_config(...)` = platform key AND agent-level `tts_voice_replies_enabled` AND (own `tts_voice_id` else platform default) — read by the channel path, the Workspace roster card's `voice_available`, the portal `/tts` endpoint, and the narrated-surface prompt, so the surfaces can no longer disagree about whether an agent may be spoken aloud. **Voice INPUT is a separate bit (#2212):** dictation needs the platform key only — nothing is spoken back — so the Workspace card also carries `stt_available` = `tts_service.is_available()`, exactly the `/stt` gate. The two cannot be collapsed: on a key-but-no-effective-voice agent `voice_available` is false while transcription works. The client prefers the server path (MediaRecorder → `POST /stt`, which answers with real statuses and messages) over the browser Web Speech API whenever `stt_available` is set — Web Speech is a browser-hosted service that reports no event at all in Chromium (measured) and ends at the first pause; it stays the no-key fallback, and with neither path available the mic is not rendered
+- `tts_service.py` - Shared outbound-voice TTS layer (epic #24): ElevenLabs synth → ffmpeg OGG/Opus transcode; shared char cost-cap; fail-soft (any error → text fallback). Key resolved at call time via `settings_service.get_elevenlabs_api_key()` (stored setting → env, ent#117), not the frozen config value. Consumed by `voice_reply_service` (ent#117) and the STT path. Also owns the **one voice gate** every surface shares (#2157): `resolve_voice_id(agent)` / the pure `resolve_voice_from_config(...)` = platform key AND agent-level `tts_voice_replies_enabled` AND (own `tts_voice_id` else platform default) — read by the channel path, the Workspace roster card's `voice_available`, the portal `/tts` endpoint, and the narrated-surface prompt, so the surfaces can no longer disagree about whether an agent may be spoken aloud. **Voice INPUT is a separate bit (#2212):** dictation needs the platform key only — nothing is spoken back — so the Workspace card also carries `stt_available` = `client_portal.service._stt_ready` — key present AND the key's speech-to-text capability not refused — exactly the `/stt` gate. **Presence is not capability (#2695):** ElevenLabs permissions are per endpoint, so a key granted Text-to-Speech but not Speech-to-Text passed the old `is_available()` check and rendered a mic that failed on every press (`401 missing_permissions`). `services/stt_capability_service.py` asks the provider once per key — a one-byte non-audio POST to `/v1/speech-to-text`, which authorises before it validates, so a 401/403 is `refused` (with the provider's status word), any other definitive answer is `capable`, and a transport error / 5xx is `unknown` — and caches the verdict in Redis under a **digest of the key** (`stt:capability:`, 6h decided / 2min unknown; per-process fallback when Redis is down), so a key change is a cache miss by construction and the uncached key resolver stays uncached. The roster and agent page read it once per load, **bounded** (`WAIT_BUDGET_SECONDS`: a slow provider answers `unknown` now and the probe fills the cache in the background — one awaited O(1) read, not a fan-out, #2163), and **fail-soft**: only a definitive refusal hides the mic. A real `/stt` 401 stores `refused` (`record_live_refusal`), so the symptom self-heals the cache. `GET`/`PUT /api/settings/elevenlabs` carry `stt_capability` + `stt_detail` + `stt_checked_at` beside `key_configured`, and re-saving a key invalidates its row; the Settings → Voice panel renders the distinction (`utils/sttCapability.js`). The two cannot be collapsed: on a key-but-no-effective-voice agent `voice_available` is false while transcription works. The client prefers the server path (MediaRecorder → `POST /stt`, which answers with real statuses and messages) over the browser Web Speech API whenever `stt_available` is set — Web Speech is a browser-hosted service that reports no event at all in Chromium (measured) and ends at the first pause; it stays the no-key fallback, and with neither path available the mic is not rendered
- `voice_reply_service.py` - Per-message voice-reply delivery (ent#117): backs the `send_voice_reply` MCP tool. Given an agent + resolved channel destination (channel/chat id/thread from the execution) + text, gates on TTS availability + agent-level enable + the per-channel flag, wraps delivery in `effect_guard("voice_reply", …)` (#1084), synthesizes, and delivers via each channel's send primitive (Telegram `_send_voice`, Slack `slack_service.upload_file`, WhatsApp `create_share_from_bytes` + Twilio `MediaUrl`). Fail-soft → not-delivered so the agent falls back to text. Replaces the old always-voice adapter path (`_maybe_send_voice` removed) — replies are TEXT by default, voice is a per-message agent choice
- `agent_shared_files_service.py` - Outbound file sharing — see [Outbound File Sharing](integrations.md#outbound-file-sharing-files-001)
- `loop_service.py` - Sequential agent loop runner — see [Sequential Agent Loops](execution.md#sequential-agent-loops-740-ui-1106)
diff --git a/docs/memory/learnings.md b/docs/memory/learnings.md
index 46552e067..7fb52c2db 100644
--- a/docs/memory/learnings.md
+++ b/docs/memory/learnings.md
@@ -802,3 +802,7 @@ plan or review. `/autoplan` reads this before planning; write for that reader.
## 2026-09-09 — pitfall — Two Tailwind utilities of the same shape can disagree about which of an equal-specificity pair wins, and a `dark:` variant hides it
**Context**: #2662 review. The Workspace composer's shell needed its border and fill removed for the duration of a voice call (it is the parent of both inert regions and holds the chrome, so a call rendered a bright frame around `opacity-60` contents). The obvious shape — keep `border-transparent bg-transparent` in the static `class` as the "off" state and bind only the resting pair — rendered a LIGHT composer with **no border at all**, while dark was perfectly correct. Tailwind's generated sheet emits `.border-transparent` **after** `.border-gray-300` (so transparent wins) but `.bg-transparent` **before** `.bg-white` (so white wins): the palette-vs-keyword ordering is not uniform across plugins, so `border` and `bg` — written as one visual pair on one element — resolved in opposite directions. Dark was immune for a reason that guarantees the bug ships: every `dark:` variant is emitted after **both** base utilities, so `dark:border-gray-700` and `dark:bg-gray-800` won regardless, and the whole defect lived in the theme a dark-mode developer never looks at. Caught only by reading `getComputedStyle` off a live render; source review, unit specs, `npm run test:unit`, `check:tokens` and the raw-colour ratchet were all green, because every one of them reads the class string rather than the cascade.
**Lesson**: never rely on the ORDER of two Tailwind utilities in the same `class` attribute — the class attribute's order is not the stylesheet's order, and the stylesheet's order is a per-plugin implementation detail, not a rule you can reason to. When one element must carry a value in state A and a different value in state B, make the arms **mutually exclusive in the binding** (`:class="cond ? 'border-transparent bg-transparent' : 'border-gray-300 …'"`) and leave the property out of the static class entirely; then there is no ordering to get wrong. Two corollaries. (1) A `dark:` variant always beats an unvariated base utility, so any cascade bug of this shape is **light-mode-only** — "verified in dark" is not evidence about light, and this is the concrete mechanism behind the design contract's "both themes always". (2) A class-string assertion cannot see this class of defect at all. The cheapest instrument that can is a live render read back through `getComputedStyle`, in **both** themes — a handful of lines against a running stack, and the only thing that distinguishes "the classes were written" from "the classes survived to the box". **Recurrence (same issue, same review):** the shell was not the only place — the `BaseSelect` ghost recipe lost the same race twice more, and neither is light-mode-only, so the shape generalises past corollary (1). `border-transparent` in the base class string beat the error arm's `border-status-danger-500` (emitted later, equal specificity) in BOTH themes; and `disabled:hover:bg-transparent` outranks `hover:bg-gray-100` on specificity but merely TIES `dark:hover:bg-gray-750` — which compiles to `:hover:is(.dark *)` and is emitted later — so a disabled control still lit up under the cursor in dark and not in light. Third corollary, then: **a variant chain buys specificity**, so a `dark:`-scoped utility can out-rank a state reset that was written to be unconditional; when a reset must hold in both themes, spell it in both arms. Three occurrences in one diff means the rule is not "be careful with order" but "never write two utilities of one property into one class string at all" — put state-dependent colour on mutually exclusive arms and leave the property out of the base.
+
+## 2026-09-11 — pitfall — A credential's presence is not its capability when the provider permissions per endpoint; "the other direction works" proves nothing
+**Context**: #2695. The Workspace mic rendered on `bool(tts_service.is_available())` — a non-empty check on the ElevenLabs key. ElevenLabs keys carry per-endpoint permissions, so a key granted Text-to-Speech but not Speech-to-Text passed the check and every voice message failed with `401 missing_permissions`, while spoken replies played normally on the same instance. #2212 had deliberately made the card bit equal the endpoint's own gate so the two could not disagree — and they did not: both were wrong together, because both encoded *presence*. `GET /api/settings/elevenlabs` said `key_configured: true`, so nothing short of the container log could name the condition.
+**Lesson**: when one credential feeds two provider endpoints, "the key works" is a per-endpoint fact — gate each surface on a **probe of the endpoint it will call**, not on the key resolving. The cheap shape: a deliberately invalid request the provider authorises before it validates (a one-byte non-audio upload), so a 401/403 is a permission verdict, any other definitive status means "past the gate", and a transport failure is *unknown*. Cache the verdict under a **digest of the credential** — a rotated key is then a miss by construction with nothing to remember to invalidate, and the uncached resolver stays uncached across workers. Fail SOFT (only a definitive refusal hides the control), bound the wait so a sign-in path never stalls on the provider, and let the live call's own 401 write the same cache — the symptom then heals the gate even if the probe never ran. Surface the verdict beside the presence bit on the admin panel, or the operator's only diagnostic is a log line.
diff --git a/src/backend/client_portal/service.py b/src/backend/client_portal/service.py
index b4a5c167b..c2d24e7f4 100644
--- a/src/backend/client_portal/service.py
+++ b/src/backend/client_portal/service.py
@@ -625,7 +625,8 @@ async def _agent_runtime(agent_name: str) -> str:
def _row_to_card(r: dict, tts_ready: bool, default_voice_id: str | None = None,
availability: str = "unknown", *,
is_platform: bool, runtime: str,
- model_context: ModelContext) -> PortalAgentCard:
+ model_context: ModelContext,
+ stt_ready: bool | None = None) -> PortalAgentCard:
"""One roster row → one card. Shared by the roster and the single-agent
lookup (#2160) so the two cannot disagree about how a card is built.
@@ -640,6 +641,11 @@ def _row_to_card(r: dict, tts_ready: bool, default_voice_id: str | None = None,
of this function. `model_context` is resolved once per load for the same
reason `availability` is threaded: it is instance-level, and re-reading it
per card would put a settings read back on every row.
+
+ #2695: `stt_ready` is the CAPABILITY verdict (`stt_capability_service`),
+ resolved once per load like `tts_ready` — threaded in, never probed here.
+ `None` means "same as `tts_ready`", which is what the bit meant before the
+ probe existed and what a caller that has not asked the provider still gets.
"""
from services import tts_service
name = r["agent_name"]
@@ -681,10 +687,13 @@ def _row_to_card(r: dict, tts_ready: bool, default_voice_id: str | None = None,
)
),
# #2212: voice INPUT needs the platform key only — no agent voice, since
- # nothing is spoken back. `tts_ready` IS `transcribe_portal_audio`'s own
- # gate (`tts_service.is_available()`), so the mic the client sees and the
- # endpoint it would call cannot disagree.
- stt_available=bool(tts_ready),
+ # nothing is spoken back. #2695: AND that key must actually be permitted
+ # to transcribe — ElevenLabs permissions are per endpoint, and a key with
+ # Text-to-Speech but no Speech-to-Text rendered a mic that failed on
+ # every press. `stt_ready` is `transcribe_portal_audio`'s own gate (key
+ # present AND the capability verdict not `refused`), so the mic the
+ # client sees and the endpoint it would call still cannot disagree.
+ stt_available=bool(tts_ready and (stt_ready if stt_ready is not None else True)),
availability=availability,
# ent#403: `None` — no control at all — for every non-platform principal.
# The roster payload is the ONLY capability channel an external client
@@ -778,8 +787,12 @@ async def get_agent_card(email: str | None, agent_name: str,
# answer differently is the defect, not the cost. Negligible beside this
# function's existing availability read and its bounded briefing HTTP.
runtime = await _agent_runtime(agent_name)
- card = _row_to_card(row, tts_service.is_available(), _default_voice_id(),
+ tts_ready = tts_service.is_available()
+ card = _row_to_card(row, tts_ready, _default_voice_id(),
availability=availability,
+ # #2695: the same capability read the roster makes, so
+ # the page and the sidebar cannot disagree about the mic.
+ stt_ready=await _stt_ready(tts_ready),
# `include_owned` IS the platform-session bit here — the
# roster unions owned agents only for a platform session
# (ent#357), which is the same door ent#403 gates on.
@@ -834,6 +847,11 @@ async def get_roster(email: str | None, include_owned: bool = False) -> PortalRo
"""
from services import tts_service
tts_ready = tts_service.is_available() # global key check, once per roster load
+ # #2695: and whether that key may TRANSCRIBE — one cached provider verdict
+ # per key, resolved once per load beside `tts_ready`. Bounded (a slow or
+ # unreachable provider answers `unknown` within `WAIT_BUDGET_SECONDS` and
+ # the mic stays), so this is one awaited O(1) read, not a fan-out (#2163).
+ stt_ready = await _stt_ready(tts_ready)
# #2157: the platform default voice is likewise instance-level — read once,
# not once per card, so adding the fallback costs the roster no extra query.
default_voice = _default_voice_id()
@@ -876,7 +894,8 @@ async def get_roster(email: str | None, include_owned: bool = False) -> PortalRo
availability=availability.get(r["agent_name"], "unknown"),
is_platform=include_owned,
runtime=runtimes.get(r["agent_name"], _DEFAULT_RUNTIME),
- model_context=model_context)
+ model_context=model_context,
+ stt_ready=stt_ready)
for r in rows
]
# #2163: the briefing is DEFERRED, not dropped. Saying so on the card is
@@ -1425,14 +1444,30 @@ async def synthesize_portal_tts(agent_name: str, email: str, text: str,
_STT_TIMEOUT = 60.0
+async def _stt_ready(tts_ready: bool) -> bool:
+ """THE mic gate (#2212 + #2695): key present AND the key's speech-to-text
+ capability not refused by the provider. One function, read by the roster,
+ the agent page and `transcribe_portal_audio`, so the control a client sees
+ and the endpoint it calls resolve the same answer. Fail-soft by
+ construction — `allowed` is everything but a definitive refusal."""
+ if not tts_ready:
+ return False
+ from services import stt_capability_service
+ cap = await stt_capability_service.ensure_capability()
+ return cap.allowed
+
+
async def transcribe_portal_audio(agent_name: str, email: str, filename: str,
content_type: str, audio: bytes,
include_owned: bool = False) -> str:
"""Transcribe a client's recorded audio to text (portal voice input, #78).
Roster-scoped (miss → 404). Fail-soft: any provider/format problem raises a
ClientPortalError so the client just types instead of getting a 500. Gated on
- the same ElevenLabs key as TTS."""
+ the same ElevenLabs key as TTS — and, since #2695, on that key being
+ PERMITTED to transcribe (`_stt_ready`), the same gate the card's
+ `stt_available` bit is built from."""
from services import tts_service # shares the ElevenLabs key/availability check
+ from services import stt_capability_service
import config
if not agent_on_roster(agent_name, email, include_owned):
@@ -1441,7 +1476,7 @@ async def transcribe_portal_audio(agent_name: str, email: str, filename: str,
raise ClientPortalError(400, "No audio")
if len(audio) > _STT_MAX_BYTES:
raise ClientPortalError(413, "Recording is too long")
- if not tts_service.is_available():
+ if not await _stt_ready(tts_service.is_available()):
raise ClientPortalError(404, "Voice input is not available")
logger.debug("portal STT: %d bytes, content_type=%r, filename=%r",
@@ -1465,6 +1500,9 @@ async def transcribe_portal_audio(agent_name: str, email: str, filename: str,
raise ClientPortalError(502, "Voice input failed — please type instead")
if resp.status_code != 200:
logger.warning("portal STT provider error %s: %s", resp.status_code, resp.text[:500])
+ # #2695: a real refusal is the best evidence there is — remember it so
+ # the next roster load hides the mic instead of offering it again.
+ stt_capability_service.record_live_refusal(elevenlabs_key, resp.status_code, resp.text)
raise ClientPortalError(422, "Could not transcribe the audio")
text = ((resp.json() or {}).get("text") or "").strip()
if not text:
diff --git a/src/backend/routers/settings.py b/src/backend/routers/settings.py
index a6ebc26a5..b396c845a 100644
--- a/src/backend/routers/settings.py
+++ b/src/backend/routers/settings.py
@@ -2733,6 +2733,23 @@ def _elevenlabs_settings_state() -> dict:
}
+async def _elevenlabs_settings_state_with_capability() -> dict:
+ """The panel view PLUS whether the key can actually transcribe (#2695).
+
+ `key_configured` is presence; `stt_capability` is what the provider said
+ when asked — `capable` / `refused` (with the provider's status word in
+ `stt_detail`) / `unknown` (could not ask) / `unconfigured`. Kept apart so an
+ operator can tell a key that is set from a key that works, without reading
+ container logs. Bounded like the roster read: a slow provider answers
+ `unknown` now and the probe completes in the background.
+ """
+ from services import stt_capability_service
+ state = _elevenlabs_settings_state()
+ cap = await stt_capability_service.ensure_capability()
+ state.update(stt_capability_service.describe(cap))
+ return state
+
+
@router.get("/elevenlabs")
async def get_elevenlabs_settings(
request: Request,
@@ -2744,7 +2761,7 @@ async def get_elevenlabs_settings(
key is surfaced as `key_configured: bool` + `key_source` only — never echoed.
"""
assert_admin(current_user)
- return _elevenlabs_settings_state()
+ return await _elevenlabs_settings_state_with_capability()
@router.put("/elevenlabs")
@@ -2783,6 +2800,11 @@ async def update_elevenlabs_settings(
if not key:
raise HTTPException(status_code=400, detail="api_key must not be empty (use clear instead)")
settings_service.set_elevenlabs_api_key(key)
+ # #2695: a NEW key is a cache miss by construction (the verdict is keyed
+ # on the key's digest); re-saving the SAME key after fixing its
+ # permissions at the provider is the case that needs an explicit forget.
+ from services import stt_capability_service
+ stt_capability_service.invalidate(key)
changes["api_key"] = "set"
elif "api_key" in clear:
settings_service.clear_elevenlabs_api_key()
@@ -2816,7 +2838,7 @@ async def update_elevenlabs_settings(
},
)
- after = _elevenlabs_settings_state()
+ after = await _elevenlabs_settings_state_with_capability()
return {"success": True, **after}
diff --git a/src/backend/services/stt_capability_service.py b/src/backend/services/stt_capability_service.py
new file mode 100644
index 000000000..079e485af
--- /dev/null
+++ b/src/backend/services/stt_capability_service.py
@@ -0,0 +1,273 @@
+"""Does the configured ElevenLabs key actually transcribe? (#2695)
+
+`stt_available` on the Workspace card used to be `bool(tts_service.is_available())`
+— a non-empty check on the resolved key. ElevenLabs keys carry PER-ENDPOINT
+permissions, so a key granted Text-to-Speech but not Speech-to-Text passed that
+check and rendered a mic that failed on every press with "Could not transcribe
+the audio" (the provider answers `401 missing_permissions`). Voice-out working
+proved nothing about voice-in, which is exactly what the presence gate assumed.
+
+This module answers the CAPABILITY question with one provider probe per key,
+cached, and keeps the answer fail-soft:
+
+* **Probe, not presence.** `probe()` POSTs a deliberately invalid body to the
+ speech-to-text endpoint. The provider authorises BEFORE it validates, so the
+ status code partitions cleanly: a 401/403 means the key cannot transcribe
+ (`refused`, with the provider's own status word as `detail`); any other
+ definitive answer — 2xx, 400, 422, 429 — means the key got past the permission
+ gate (`capable`); a transport error, timeout or 5xx says nothing (`unknown`).
+ No audio is ever sent, so a probe costs no transcription minutes.
+
+* **Cached, keyed on the KEY.** The roster is read on every Workspace load, so
+ the verdict lives in Redis under `stt:capability:` (6 h for a
+ decided verdict, 2 min for `unknown` so an outage is re-asked soon). Keying on
+ a digest of the key — never the key itself — means a key change is a cache
+ MISS by construction: nothing has to remember to invalidate, and the resolver
+ it reads through stays uncached (the `--workers 2` rule #506 / ent#117 set).
+ Redis down ⇒ a per-process fallback with the same key and TTLs, so the two
+ workers can at worst each probe once.
+
+* **Fails SOFT.** `unknown` renders the mic. Hiding a control that would have
+ worked is the defect this exists to prevent in the other direction, so only a
+ definitive provider refusal hides it. The live `/stt` call feeds back: a real
+ 401 from a genuine transcription attempt stores `refused`, so the symptom the
+ issue describes self-heals the cache even if the probe never ran.
+"""
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+import logging
+import time
+from dataclasses import dataclass, asdict
+from typing import Optional
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+STT_URL = "https://api.elevenlabs.io/v1/speech-to-text"
+STT_MODEL = "scribe_v1"
+
+VERDICT_CAPABLE = "capable"
+VERDICT_REFUSED = "refused"
+VERDICT_UNKNOWN = "unknown"
+VERDICT_UNCONFIGURED = "unconfigured"
+
+# A decided verdict is worth keeping: per-endpoint permissions change only when
+# an admin edits the key at the provider. `unknown` is a transient (outage,
+# timeout) and is re-asked soon.
+TTL_DECIDED_SECONDS = 6 * 3600
+TTL_UNKNOWN_SECONDS = 120
+# The probe's own HTTP timeout. Below the roster's patience (`WAIT_BUDGET`),
+# so a slow provider degrades to `unknown` rather than stalling sign-in.
+PROBE_TIMEOUT_SECONDS = 8.0
+# How long a cache-missing reader (the roster, the Settings panel) waits for
+# the probe before answering `unknown` and letting it finish in the background.
+WAIT_BUDGET_SECONDS = 4.0
+
+_CACHE_PREFIX = "stt:capability:"
+
+
+@dataclass(frozen=True)
+class SttCapability:
+ verdict: str
+ detail: Optional[str] = None # the provider's own status word on a refusal
+ checked_at: Optional[float] = None
+
+ @property
+ def allowed(self) -> bool:
+ """May the mic render / may `/stt` run? Everything but a definitive
+ refusal — the fail-SOFT direction."""
+ return self.verdict != VERDICT_REFUSED
+
+ def to_json(self) -> str:
+ return json.dumps(asdict(self))
+
+ @classmethod
+ def from_json(cls, raw: str) -> Optional["SttCapability"]:
+ try:
+ d = json.loads(raw)
+ return cls(verdict=str(d["verdict"]), detail=d.get("detail"),
+ checked_at=d.get("checked_at"))
+ except Exception: # noqa: BLE001 — a corrupt row is a miss, never a 500
+ return None
+
+
+UNCONFIGURED = SttCapability(VERDICT_UNCONFIGURED)
+
+
+def cache_key(api_key: str) -> str:
+ """Digest of the key, never the key — the row name lands in Redis listings."""
+ return _CACHE_PREFIX + hashlib.sha256(api_key.encode("utf-8")).hexdigest()[:16]
+
+
+def _ttl_for(cap: SttCapability) -> int:
+ return TTL_DECIDED_SECONDS if cap.verdict in (VERDICT_CAPABLE, VERDICT_REFUSED) \
+ else TTL_UNKNOWN_SECONDS
+
+
+# ---- cache ------------------------------------------------------------------
+
+# Per-process fallback for a Redis outage: {cache_key: (expires_at, cap)}.
+_local: dict[str, tuple[float, SttCapability]] = {}
+_inflight: dict[str, "asyncio.Task[SttCapability]"] = {}
+
+
+def _redis():
+ from redis_breaker_util import get_breaker_redis
+ return get_breaker_redis()
+
+
+def read_cached(api_key: str) -> Optional[SttCapability]:
+ if not api_key:
+ return UNCONFIGURED
+ k = cache_key(api_key)
+ r = _redis()
+ if r is not None:
+ try:
+ raw = r.get(k)
+ if raw:
+ cap = SttCapability.from_json(raw)
+ if cap is not None:
+ return cap
+ except Exception as e: # noqa: BLE001
+ logger.warning("stt capability cache read failed-open (%s)", e)
+ hit = _local.get(k)
+ if hit and hit[0] > time.monotonic():
+ return hit[1]
+ return None
+
+
+def store(api_key: str, cap: SttCapability) -> None:
+ if not api_key:
+ return
+ k = cache_key(api_key)
+ ttl = _ttl_for(cap)
+ _local[k] = (time.monotonic() + ttl, cap)
+ r = _redis()
+ if r is not None:
+ try:
+ r.set(k, cap.to_json(), ex=ttl)
+ except Exception as e: # noqa: BLE001
+ logger.warning("stt capability cache write failed-open (%s)", e)
+
+
+def invalidate(api_key: str) -> None:
+ """Forget the verdict for THIS key. A changed key needs no call here — its
+ digest is a different row — but re-saving the same key after fixing its
+ permissions at the provider does."""
+ if not api_key:
+ return
+ k = cache_key(api_key)
+ _local.pop(k, None)
+ r = _redis()
+ if r is not None:
+ try:
+ r.delete(k)
+ except Exception as e: # noqa: BLE001
+ logger.warning("stt capability cache delete failed-open (%s)", e)
+
+
+# ---- probe ------------------------------------------------------------------
+
+def classify_response(status_code: int, body: str) -> SttCapability:
+ """Map one provider answer to a verdict. Pure, so the partition is testable
+ without HTTP. The provider authorises before it validates the upload, which
+ is what makes an invalid body a free permission probe."""
+ now = time.time()
+ if status_code in (401, 403):
+ detail = None
+ try:
+ d = json.loads(body or "{}")
+ det = d.get("detail") if isinstance(d, dict) else None
+ if isinstance(det, dict):
+ detail = det.get("status") or det.get("message")
+ elif isinstance(det, str):
+ detail = det
+ except Exception: # noqa: BLE001
+ detail = None
+ return SttCapability(VERDICT_REFUSED, detail=(detail or f"http_{status_code}")[:120],
+ checked_at=now)
+ if status_code < 500:
+ # 2xx would mean the provider transcribed one byte of nonsense; 400/422
+ # is the expected "bad upload"; 429 is rate-limited — all past the gate.
+ return SttCapability(VERDICT_CAPABLE, checked_at=now)
+ return SttCapability(VERDICT_UNKNOWN, detail=f"http_{status_code}", checked_at=now)
+
+
+async def probe(api_key: str, *, timeout: float = PROBE_TIMEOUT_SECONDS) -> SttCapability:
+ """Ask the provider whether THIS key may call speech-to-text. Never raises;
+ a probe that cannot complete is `unknown`, never `refused`."""
+ if not api_key:
+ return UNCONFIGURED
+ try:
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ resp = await client.post(
+ STT_URL,
+ headers={"xi-api-key": api_key},
+ data={"model_id": STT_MODEL},
+ # One byte, not audio: rejected by validation, never billed.
+ files={"file": ("probe.bin", b"\0", "application/octet-stream")},
+ )
+ except Exception as e: # noqa: BLE001
+ logger.warning("stt capability probe could not complete (%s) — treating as unknown", e)
+ return SttCapability(VERDICT_UNKNOWN, detail=type(e).__name__, checked_at=time.time())
+ cap = classify_response(resp.status_code, resp.text)
+ if cap.verdict == VERDICT_REFUSED:
+ logger.warning("ElevenLabs key cannot transcribe (%s %s) — the Workspace mic is hidden",
+ resp.status_code, cap.detail)
+ return cap
+
+
+async def _probe_and_store(api_key: str) -> SttCapability:
+ cap = await probe(api_key)
+ store(api_key, cap)
+ return cap
+
+
+async def ensure_capability(api_key: Optional[str] = None, *,
+ wait_seconds: float = WAIT_BUDGET_SECONDS) -> SttCapability:
+ """The cached verdict, probing on a miss — bounded by `wait_seconds`, past
+ which the caller gets `unknown` NOW and the probe keeps running to fill the
+ cache for the next reader. Single-flighted per key within a process so a
+ burst of roster loads costs one provider call, not one each."""
+ if api_key is None:
+ from services.settings_service import settings_service
+ api_key = settings_service.get_elevenlabs_api_key()
+ if not api_key:
+ return UNCONFIGURED
+ cached = read_cached(api_key)
+ if cached is not None:
+ return cached
+ k = cache_key(api_key)
+ task = _inflight.get(k)
+ if task is None or task.done():
+ task = asyncio.create_task(_probe_and_store(api_key))
+ _inflight[k] = task
+ task.add_done_callback(lambda t, _k=k: _inflight.pop(_k, None) if _inflight.get(_k) is t else None)
+ try:
+ return await asyncio.wait_for(asyncio.shield(task), timeout=wait_seconds)
+ except asyncio.TimeoutError:
+ return SttCapability(VERDICT_UNKNOWN, detail="probe_pending", checked_at=time.time())
+ except Exception as e: # noqa: BLE001 — the task itself never raises, belt only
+ logger.warning("stt capability probe task failed (%s)", e)
+ return SttCapability(VERDICT_UNKNOWN, detail=type(e).__name__, checked_at=time.time())
+
+
+def record_live_refusal(api_key: str, status_code: int, body: str) -> None:
+ """A genuine `/stt` call was refused by the provider: learn from it, so the
+ next roster load hides the mic without waiting for a probe. Only a 401/403
+ is a verdict about the key; anything else says nothing about permissions."""
+ if status_code in (401, 403) and api_key:
+ store(api_key, classify_response(status_code, body))
+
+
+def describe(cap: SttCapability) -> dict:
+ """The admin-panel shape: verdict + detail + when, never the key."""
+ return {
+ "stt_capability": cap.verdict,
+ "stt_detail": cap.detail,
+ "stt_checked_at": cap.checked_at,
+ }
diff --git a/src/frontend/src/utils/sttCapability.js b/src/frontend/src/utils/sttCapability.js
new file mode 100644
index 000000000..cff30470f
--- /dev/null
+++ b/src/frontend/src/utils/sttCapability.js
@@ -0,0 +1,41 @@
+// #2695 — what the Voice settings panel says about speech-to-text.
+//
+// `key_configured` is presence; `stt_capability` is what the provider answered
+// when asked. ElevenLabs permissions are per endpoint, so a key that speaks may
+// still not transcribe — and before this the panel said "configured" while every
+// Workspace voice message failed. Pure, so the wording rule is testable without
+// mounting the panel (vitest runs `environment: 'node'`).
+
+export const STT_TONE = Object.freeze({ ok: 'ok', bad: 'bad', unverified: 'unverified', none: 'none' })
+
+/**
+ * @param {{ key_configured?: boolean, stt_capability?: string, stt_detail?: string|null }} state
+ * @returns {{ tone: string, label: string, hint: string }}
+ */
+export function describeSttCapability(state) {
+ if (!state || !state.key_configured || state.stt_capability === 'unconfigured') {
+ return { tone: STT_TONE.none, label: '', hint: '' }
+ }
+ switch (state.stt_capability) {
+ case 'capable':
+ return {
+ tone: STT_TONE.ok,
+ label: 'can transcribe',
+ hint: 'Workspace voice input (dictation) is available.',
+ }
+ case 'refused':
+ return {
+ tone: STT_TONE.bad,
+ label: state.stt_detail ? `cannot transcribe — ${state.stt_detail}` : 'cannot transcribe',
+ hint: 'This key is not permitted to call speech-to-text, so the Workspace mic is hidden. '
+ + 'Grant the Speech to Text permission on the key at ElevenLabs, then save it again.',
+ }
+ default:
+ return {
+ tone: STT_TONE.unverified,
+ label: 'transcription not verified',
+ hint: 'ElevenLabs could not be reached to check the key; the Workspace mic stays available '
+ + 'until the check completes.',
+ }
+ }
+}
diff --git a/src/frontend/src/views/Settings.vue b/src/frontend/src/views/Settings.vue
index 9b9774c95..9db0b1357 100644
--- a/src/frontend/src/views/Settings.vue
+++ b/src/frontend/src/views/Settings.vue
@@ -568,6 +568,22 @@
v-else
class="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>not set
+
+ {{ sttCapability.label }}
Clear
+
{{ sttCapability.hint }}
@@ -2127,6 +2151,7 @@ import { useSettingsStore } from '../stores/settings'
import { useSessionsStore } from '../stores/sessions'
import { apiErrorMessage } from '../utils/apiError'
import { readOpsBool, opsBoolValue } from '../utils/opsSettings'
+import { describeSttCapability } from '../utils/sttCapability'
import { useEnterpriseStore } from '../stores/enterprise'
import NavBar from '../components/NavBar.vue'
import McpKeysTab from '../components/settings/McpKeysTab.vue'
@@ -2550,7 +2575,16 @@ const elevenLabs = reactive({
keySource: 'none', // override | env | none
apiKeyInput: '',
defaultVoiceId: '',
+ // #2695: what the provider said when asked whether this key may transcribe —
+ // capable | refused | unknown | unconfigured, plus its own status word.
+ sttCapability: 'unconfigured',
+ sttDetail: null,
})
+const sttCapability = computed(() => describeSttCapability({
+ key_configured: elevenLabs.keyConfigured,
+ stt_capability: elevenLabs.sttCapability,
+ stt_detail: elevenLabs.sttDetail,
+}))
const savingElevenLabs = ref(false)
const elevenLabsSaveSuccess = ref(false)
const elevenLabsError = ref('')
@@ -3053,6 +3087,10 @@ function applyElevenLabsState(state) {
elevenLabs.keyConfigured = !!state.key_configured
elevenLabs.keySource = state.key_source || 'none'
elevenLabs.defaultVoiceId = state.default_voice_id || ''
+ // An older backend sends no capability field: `undefined` reads as
+ // "unverified", never as "capable" — see describeSttCapability.
+ elevenLabs.sttCapability = state.stt_capability
+ elevenLabs.sttDetail = state.stt_detail ?? null
}
async function loadElevenLabsSettings() {
diff --git a/src/frontend/tests/unit/sttCapability.spec.js b/src/frontend/tests/unit/sttCapability.spec.js
new file mode 100644
index 000000000..499e7840e
--- /dev/null
+++ b/src/frontend/tests/unit/sttCapability.spec.js
@@ -0,0 +1,42 @@
+import { describe, it, expect } from 'vitest'
+import { describeSttCapability, STT_TONE } from '../../src/utils/sttCapability.js'
+
+// #2695 — "key configured" and "key can transcribe" are different facts, and the
+// panel must say which one it is asserting.
+describe('describeSttCapability', () => {
+ it('says nothing when there is no key — presence is the other badge', () => {
+ expect(describeSttCapability({ key_configured: false }).tone).toBe(STT_TONE.none)
+ expect(describeSttCapability({ key_configured: true, stt_capability: 'unconfigured' }).tone).toBe(STT_TONE.none)
+ expect(describeSttCapability(null).tone).toBe(STT_TONE.none)
+ })
+
+ it('a capable key reads as such', () => {
+ const d = describeSttCapability({ key_configured: true, stt_capability: 'capable' })
+ expect(d.tone).toBe(STT_TONE.ok)
+ expect(d.label).toBe('can transcribe')
+ })
+
+ it("a refused key names the provider's reason and the way out", () => {
+ const d = describeSttCapability({ key_configured: true, stt_capability: 'refused', stt_detail: 'missing_permissions' })
+ expect(d.tone).toBe(STT_TONE.bad)
+ expect(d.label).toBe('cannot transcribe — missing_permissions')
+ expect(d.hint).toMatch(/Speech to Text permission/)
+ })
+
+ it('a refusal with no detail is still a refusal', () => {
+ const d = describeSttCapability({ key_configured: true, stt_capability: 'refused', stt_detail: null })
+ expect(d.tone).toBe(STT_TONE.bad)
+ expect(d.label).toBe('cannot transcribe')
+ })
+
+ it('unknown is unverified — never "configured", never "cannot"', () => {
+ const d = describeSttCapability({ key_configured: true, stt_capability: 'unknown' })
+ expect(d.tone).toBe(STT_TONE.unverified)
+ expect(d.label).toBe('transcription not verified')
+ expect(d.hint).toMatch(/stays available/)
+ })
+
+ it('an older backend with no capability field reads as unverified, not capable', () => {
+ expect(describeSttCapability({ key_configured: true }).tone).toBe(STT_TONE.unverified)
+ })
+})
diff --git a/tests/unit/test_2695_stt_capability_probe.py b/tests/unit/test_2695_stt_capability_probe.py
new file mode 100644
index 000000000..6905d2b48
--- /dev/null
+++ b/tests/unit/test_2695_stt_capability_probe.py
@@ -0,0 +1,355 @@
+"""#2695 — the Workspace mic renders on speech-to-text CAPABILITY, not key presence.
+
+ElevenLabs keys carry per-endpoint permissions. A key granted Text-to-Speech but
+not Speech-to-Text passed `bool(tts_service.is_available())` and rendered a mic
+that failed on every press with `401 missing_permissions`. These tests pin the
+replacement gate end to end — the pure classifier, the cache, the fail-soft
+direction, and every consumer of the verdict (roster card, agent page, the `/stt`
+endpoint, the admin panel), by EXECUTING each one rather than reading its source.
+
+No real Redis, no real HTTP: the provider is a stubbed `httpx.AsyncClient`, the
+cache is the module's own per-process fallback (Redis stubbed to None).
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+from unittest.mock import patch
+
+import pytest
+
+import services.stt_capability_service as stt
+import services.tts_service as tts_service
+from client_portal import service as portal_service
+from client_portal.service import ClientPortalError
+
+KEY = "sk_example_key_with_tts_only"
+ROW = {"agent_name": "acme-bot", "owner": "owner@example.com"}
+
+
+@pytest.fixture(autouse=True)
+def _no_redis_fresh_cache(monkeypatch):
+ """Redis absent → the per-process fallback is the whole cache; cleared per test."""
+ monkeypatch.setattr(stt, "_redis", lambda: None)
+ stt._local.clear()
+ stt._inflight.clear()
+ yield
+ stt._local.clear()
+ stt._inflight.clear()
+
+
+def _stub_provider(monkeypatch, *, status: int, body: str = "", raises: Exception | None = None):
+ """Make `httpx.AsyncClient().post` answer one fixed provider response."""
+ calls: list[dict] = []
+
+ class _Resp:
+ def __init__(self):
+ self.status_code = status
+ self.text = body
+
+ class _Client:
+ def __init__(self, *a, **kw):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *a):
+ return False
+
+ async def post(self, url, **kw):
+ calls.append({"url": url, **kw})
+ if raises is not None:
+ raise raises
+ return _Resp()
+
+ monkeypatch.setattr(stt.httpx, "AsyncClient", _Client)
+ return calls
+
+
+MISSING = json.dumps({"detail": {"status": "missing_permissions",
+ "message": "The API key you used is missing the permission speech_to_text"}})
+
+
+# ---- the classifier is the partition --------------------------------------
+
+@pytest.mark.parametrize("status,body,verdict,detail", [
+ (401, MISSING, stt.VERDICT_REFUSED, "missing_permissions"),
+ (403, json.dumps({"detail": "forbidden"}), stt.VERDICT_REFUSED, "forbidden"),
+ (401, "not json", stt.VERDICT_REFUSED, "http_401"),
+ (422, json.dumps({"detail": [{"loc": ["file"], "msg": "invalid"}]}), stt.VERDICT_CAPABLE, None),
+ (400, "", stt.VERDICT_CAPABLE, None),
+ (429, "", stt.VERDICT_CAPABLE, None),
+ (200, json.dumps({"text": ""}), stt.VERDICT_CAPABLE, None),
+ (500, "", stt.VERDICT_UNKNOWN, "http_500"),
+ (503, "", stt.VERDICT_UNKNOWN, "http_503"),
+])
+def test_provider_answers_partition_into_verdicts(status, body, verdict, detail):
+ cap = stt.classify_response(status, body)
+ assert cap.verdict == verdict
+ assert cap.detail == detail
+ assert cap.checked_at is not None
+
+
+def test_only_a_refusal_hides_the_mic():
+ """The fail-SOFT direction: everything but a definitive refusal renders."""
+ assert stt.SttCapability(stt.VERDICT_REFUSED).allowed is False
+ assert stt.SttCapability(stt.VERDICT_CAPABLE).allowed is True
+ assert stt.SttCapability(stt.VERDICT_UNKNOWN).allowed is True
+ assert stt.UNCONFIGURED.allowed is True # presence is the other gate's job
+
+
+# ---- the probe -------------------------------------------------------------
+
+def test_probe_sends_no_audio_and_a_refusal_is_recorded(monkeypatch):
+ calls = _stub_provider(monkeypatch, status=401, body=MISSING)
+ cap = asyncio.run(stt.probe(KEY))
+ assert cap.verdict == stt.VERDICT_REFUSED and cap.detail == "missing_permissions"
+ assert len(calls) == 1
+ assert calls[0]["url"] == stt.STT_URL
+ assert calls[0]["headers"] == {"xi-api-key": KEY}
+ # One byte, never audio: the probe must cost no transcription minutes.
+ _name, payload, _ctype = calls[0]["files"]["file"]
+ assert payload == b"\0"
+
+
+def test_probe_that_cannot_complete_is_unknown_never_refused(monkeypatch):
+ _stub_provider(monkeypatch, status=0, raises=ConnectionError("provider down"))
+ cap = asyncio.run(stt.probe(KEY))
+ assert cap.verdict == stt.VERDICT_UNKNOWN
+ assert cap.allowed is True
+
+
+# ---- the cache -------------------------------------------------------------
+
+def test_verdict_is_cached_so_the_roster_does_not_probe_per_request(monkeypatch):
+ calls = _stub_provider(monkeypatch, status=401, body=MISSING)
+
+ async def _twice():
+ a = await stt.ensure_capability(KEY)
+ b = await stt.ensure_capability(KEY)
+ return a, b
+
+ a, b = asyncio.run(_twice())
+ assert a.verdict == b.verdict == stt.VERDICT_REFUSED
+ assert len(calls) == 1
+
+
+def test_cache_is_keyed_on_the_key_so_a_changed_key_is_a_miss(monkeypatch):
+ calls = _stub_provider(monkeypatch, status=401, body=MISSING)
+ asyncio.run(stt.ensure_capability(KEY))
+ assert len(calls) == 1
+ # Never the key itself in the row name — it lands in Redis listings.
+ assert KEY not in stt.cache_key(KEY)
+ assert stt.cache_key(KEY) != stt.cache_key(KEY + "-rotated")
+
+ _stub_provider(monkeypatch, status=422)
+ cap = asyncio.run(stt.ensure_capability(KEY + "-rotated"))
+ assert cap.verdict == stt.VERDICT_CAPABLE # the new key was asked, not the old row
+
+
+def test_invalidate_forgets_this_key_only(monkeypatch):
+ _stub_provider(monkeypatch, status=401, body=MISSING)
+ asyncio.run(stt.ensure_capability(KEY))
+ assert stt.read_cached(KEY).verdict == stt.VERDICT_REFUSED
+ stt.invalidate(KEY)
+ assert stt.read_cached(KEY) is None
+
+
+def test_unknown_has_a_short_ttl_and_a_decided_verdict_a_long_one():
+ assert stt._ttl_for(stt.SttCapability(stt.VERDICT_UNKNOWN)) == stt.TTL_UNKNOWN_SECONDS
+ assert stt._ttl_for(stt.SttCapability(stt.VERDICT_REFUSED)) == stt.TTL_DECIDED_SECONDS
+ assert stt._ttl_for(stt.SttCapability(stt.VERDICT_CAPABLE)) == stt.TTL_DECIDED_SECONDS
+ assert stt.TTL_UNKNOWN_SECONDS < stt.TTL_DECIDED_SECONDS
+
+
+def test_a_slow_probe_answers_unknown_now_and_fills_the_cache_later(monkeypatch):
+ """The roster must never wait on the provider past its budget — but the
+ probe it started still lands, so the NEXT reader gets the real verdict."""
+ class _Resp:
+ status_code = 401
+ text = MISSING
+
+ class _Slow:
+ def __init__(self, *a, **kw):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *a):
+ return False
+
+ async def post(self, *a, **kw):
+ await asyncio.sleep(0.2)
+ return _Resp()
+
+ monkeypatch.setattr(stt.httpx, "AsyncClient", _Slow)
+
+ async def _run():
+ first = await stt.ensure_capability(KEY, wait_seconds=0.01)
+ task = stt._inflight[stt.cache_key(KEY)]
+ await task
+ second = await stt.ensure_capability(KEY)
+ return first, second
+
+ first, second = asyncio.run(_run())
+ assert first.verdict == stt.VERDICT_UNKNOWN and first.allowed is True
+ assert second.verdict == stt.VERDICT_REFUSED
+
+
+def test_no_key_is_unconfigured_without_a_probe(monkeypatch):
+ calls = _stub_provider(monkeypatch, status=422)
+ cap = asyncio.run(stt.ensure_capability(""))
+ assert cap is stt.UNCONFIGURED
+ assert calls == []
+
+
+# ---- the consumers ---------------------------------------------------------
+
+def _card(*, tts_ready, stt_ready):
+ with patch.object(tts_service, "resolve_voice_from_config", return_value=None):
+ return portal_service._row_to_card(
+ dict(ROW), tts_ready, "platform-default",
+ is_platform=False, runtime="claude-code",
+ model_context=portal_service._model_context(),
+ stt_ready=stt_ready,
+ )
+
+
+def test_card_hides_the_mic_when_the_key_cannot_transcribe():
+ assert _card(tts_ready=True, stt_ready=False).stt_available is False
+
+
+def test_card_renders_the_mic_when_the_key_can_transcribe():
+ assert _card(tts_ready=True, stt_ready=True).stt_available is True
+
+
+def test_card_without_a_key_is_closed_regardless_of_capability():
+ assert _card(tts_ready=False, stt_ready=True).stt_available is False
+
+
+def test_stt_ready_is_the_capability_verdict_and_fails_soft(monkeypatch):
+ with patch("services.settings_service.settings_service.get_elevenlabs_api_key",
+ return_value=KEY):
+ _stub_provider(monkeypatch, status=401, body=MISSING)
+ assert asyncio.run(portal_service._stt_ready(True)) is False
+ stt._local.clear()
+ _stub_provider(monkeypatch, status=0, raises=TimeoutError("slow"))
+ assert asyncio.run(portal_service._stt_ready(True)) is True # unknown → mic stays
+ assert asyncio.run(portal_service._stt_ready(False)) is False # no key → closed
+
+
+def test_roster_threads_the_capability_into_every_card(monkeypatch):
+ """The roster READS the verdict once and every card carries it — executed,
+ not grepped: the SQL, Docker and model reads are stubbed, the gate is not."""
+ monkeypatch.setattr(portal_service, "_roster_rows",
+ lambda email, include_owned: [dict(ROW), {**ROW, "agent_name": "beta"}])
+
+ async def _avail(names):
+ return {n: "ready" for n in names}
+
+ async def _runtimes(names):
+ return {n: "claude-code" for n in names}
+
+ monkeypatch.setattr(portal_service, "_availability_map", _avail)
+ monkeypatch.setattr(portal_service, "_runtime_map", _runtimes)
+ monkeypatch.setattr(portal_service, "_default_voice_id", lambda: None)
+ monkeypatch.setattr(portal_service, "_multi_agent_chat_available", lambda: False)
+ seen: list[bool] = []
+
+ async def _gate(tts_ready):
+ seen.append(tts_ready)
+ return False
+
+ monkeypatch.setattr(portal_service, "_stt_ready", _gate)
+ with patch.object(tts_service, "is_available", return_value=True), \
+ patch.object(tts_service, "resolve_voice_from_config", return_value=None):
+ roster = asyncio.run(portal_service.get_roster("client@example.com"))
+
+ assert seen == [True] # once per load, not per card
+ assert [c.stt_available for c in roster.agents] == [False, False]
+
+
+def test_endpoint_refuses_when_the_key_cannot_transcribe(monkeypatch):
+ """The card bit and the `/stt` gate are one condition (#2212's rule, kept)."""
+ async def _call():
+ return await portal_service.transcribe_portal_audio(
+ "acme-bot", "client@example.com", "voice.webm", "audio/webm", b"x" * 4000
+ )
+
+ with patch.object(portal_service, "agent_on_roster", return_value=True), \
+ patch.object(tts_service, "is_available", return_value=True), \
+ patch("services.settings_service.settings_service.get_elevenlabs_api_key",
+ return_value=KEY):
+ _stub_provider(monkeypatch, status=401, body=MISSING)
+ with pytest.raises(ClientPortalError) as exc:
+ asyncio.run(_call())
+ assert exc.value.status_code == 404
+ assert "not available" in exc.value.detail
+
+
+def test_a_live_refusal_teaches_the_cache(monkeypatch):
+ """The symptom in the issue — every real transcription answering 401 — must
+ hide the mic on the next load even if the probe never ran."""
+ monkeypatch.setattr(stt, "ensure_capability",
+ lambda *a, **kw: _ready(stt.SttCapability(stt.VERDICT_UNKNOWN)))
+ _stub_provider(monkeypatch, status=401, body=MISSING) # the REAL /stt call
+
+ async def _call():
+ return await portal_service.transcribe_portal_audio(
+ "acme-bot", "client@example.com", "voice.webm", "audio/webm", b"x" * 4000
+ )
+
+ with patch.object(portal_service, "agent_on_roster", return_value=True), \
+ patch.object(tts_service, "is_available", return_value=True), \
+ patch("services.settings_service.settings_service.get_elevenlabs_api_key",
+ return_value=KEY):
+ with pytest.raises(ClientPortalError) as exc:
+ asyncio.run(_call())
+ assert exc.value.status_code == 422
+ assert stt.read_cached(KEY).verdict == stt.VERDICT_REFUSED
+
+
+def test_a_live_non_auth_failure_teaches_nothing(monkeypatch):
+ stt.record_live_refusal(KEY, 422, "bad audio")
+ assert stt.read_cached(KEY) is None
+
+
+async def _ready(value):
+ return value
+
+
+def test_settings_state_distinguishes_configured_from_can_transcribe(monkeypatch):
+ from routers import settings as settings_router
+
+ with patch("services.settings_service.settings_service.get_elevenlabs_api_key",
+ return_value=KEY), \
+ patch("services.settings_service.settings_service.elevenlabs_key_source",
+ return_value="override"), \
+ patch("services.settings_service.settings_service.get_default_voice_id",
+ return_value=None):
+ _stub_provider(monkeypatch, status=401, body=MISSING)
+ state = asyncio.run(settings_router._elevenlabs_settings_state_with_capability())
+
+ assert state["key_configured"] is True
+ assert state["stt_capability"] == stt.VERDICT_REFUSED
+ assert state["stt_detail"] == "missing_permissions"
+ assert state["stt_checked_at"] is not None
+ assert KEY not in json.dumps(state)
+
+
+def test_settings_state_without_a_key_is_unconfigured(monkeypatch):
+ from routers import settings as settings_router
+
+ calls = _stub_provider(monkeypatch, status=422)
+ with patch("services.settings_service.settings_service.get_elevenlabs_api_key",
+ return_value=""), \
+ patch("services.settings_service.settings_service.elevenlabs_key_source",
+ return_value="none"), \
+ patch("services.settings_service.settings_service.get_default_voice_id",
+ return_value=None):
+ state = asyncio.run(settings_router._elevenlabs_settings_state_with_capability())
+ assert state["key_configured"] is False
+ assert state["stt_capability"] == stt.VERDICT_UNCONFIGURED
+ assert calls == []
From 9778c7224743d997c371243d90181174ec23a849 Mon Sep 17 00:00:00 2001
From: Oleksii Dolhov
Date: Fri, 11 Sep 2026 12:38:49 +0300
Subject: [PATCH 02/28] fix(workspace): the /stt provider error says why,
instead of one opaque 422 (#2696)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`transcribe_portal_audio` mapped every non-200 from ElevenLabs onto
`422 "Could not transcribe the audio"`. A missing endpoint permission, a
rejected key, exhausted credits, a provider rate limit and a rejected audio
container all read identically, while the actionable status word sat in a
backend WARNING one line above — a live instance cost an operator with
container access a full round-trip to answer a question the system already
knew.
`stt_capability_service.classify_stt_failure()` (pure) maps a provider answer
onto a named category with its own client status and sentence:
permission / auth / quota 401·402·403 by status word → 503, operator-actionable
rate_limit 429 → 429, the existing retry wording
audio 400·413·415·422 → 422, "the recording could not be read"
provider 5xx → 502, the existing transport wording
unknown anything else → 502, still says who failed
No arm returns the old string; a test sweeps every status 300-599 to pin that,
so an unrecognised provider answer cannot regress to it. The client sentence
never carries the provider body. `record_live_failure` keeps the status word +
category for THIS key (`stt:last_failure:`, 24h; per-process fallback)
and still feeds #2695's capability cache on a 401/403, so the mic hides on the
next load. `GET /api/settings/elevenlabs` (admin-only) carries it as
`stt_last_failure`, and Settings -> Voice renders "Last voice-input failure:
(HTTP ) —
+
+
{{ sttLastFailure.text }} — {{ new Date(sttLastFailure.at * 1000).toLocaleString() }}
@@ -2151,7 +2159,7 @@ import { useSettingsStore } from '../stores/settings'
import { useSessionsStore } from '../stores/sessions'
import { apiErrorMessage } from '../utils/apiError'
import { readOpsBool, opsBoolValue } from '../utils/opsSettings'
-import { describeSttCapability } from '../utils/sttCapability'
+import { describeSttCapability, describeSttLastFailure } from '../utils/sttCapability'
import { useEnterpriseStore } from '../stores/enterprise'
import NavBar from '../components/NavBar.vue'
import McpKeysTab from '../components/settings/McpKeysTab.vue'
@@ -2579,7 +2587,9 @@ const elevenLabs = reactive({
// capable | refused | unknown | unconfigured, plus its own status word.
sttCapability: 'unconfigured',
sttDetail: null,
+ sttLastFailure: null, // #2696: {category, provider_status, detail, at} | null
})
+const sttLastFailure = computed(() => describeSttLastFailure(elevenLabs.sttLastFailure))
const sttCapability = computed(() => describeSttCapability({
key_configured: elevenLabs.keyConfigured,
stt_capability: elevenLabs.sttCapability,
@@ -3091,6 +3101,7 @@ function applyElevenLabsState(state) {
// "unverified", never as "capable" — see describeSttCapability.
elevenLabs.sttCapability = state.stt_capability
elevenLabs.sttDetail = state.stt_detail ?? null
+ elevenLabs.sttLastFailure = state.stt_last_failure ?? null
}
async function loadElevenLabsSettings() {
diff --git a/src/frontend/tests/unit/sttCapability.spec.js b/src/frontend/tests/unit/sttCapability.spec.js
index 499e7840e..be8df3787 100644
--- a/src/frontend/tests/unit/sttCapability.spec.js
+++ b/src/frontend/tests/unit/sttCapability.spec.js
@@ -40,3 +40,31 @@ describe('describeSttCapability', () => {
expect(describeSttCapability({ key_configured: true }).tone).toBe(STT_TONE.unverified)
})
})
+
+import { describeSttLastFailure } from '../../src/utils/sttCapability.js'
+
+// #2696 — the operator half of a client's "voice input failed".
+describe('describeSttLastFailure', () => {
+ it('is silent with nothing to report', () => {
+ expect(describeSttLastFailure(null)).toBeNull()
+ expect(describeSttLastFailure({})).toBeNull()
+ })
+
+ it("names the category and the provider's own status word", () => {
+ const d = describeSttLastFailure({ category: 'permission', provider_status: 401, detail: 'missing_permissions', at: 1700000000 })
+ expect(d.text).toBe('Last voice-input failure: the key is missing the speech-to-text permission (HTTP 401 missing_permissions).')
+ expect(d.at).toBe(1700000000)
+ })
+
+ it('distinguishes quota from auth from audio', () => {
+ expect(describeSttLastFailure({ category: 'quota', provider_status: 401, detail: 'quota_exceeded' }).text).toMatch(/out of credits/)
+ expect(describeSttLastFailure({ category: 'auth', provider_status: 401, detail: 'invalid_api_key' }).text).toMatch(/rejected/)
+ expect(describeSttLastFailure({ category: 'audio', provider_status: 400, detail: 'invalid_audio' }).text).toMatch(/recording was rejected/)
+ })
+
+ it('an unknown category still says the provider failed, never nothing', () => {
+ const d = describeSttLastFailure({ category: 'something_new', provider_status: 418 })
+ expect(d.text).toMatch(/unrecognised error \(HTTP 418\)/)
+ expect(d.at).toBeNull()
+ })
+})
diff --git a/tests/unit/test_2695_stt_capability_probe.py b/tests/unit/test_2695_stt_capability_probe.py
index 6905d2b48..a4077738a 100644
--- a/tests/unit/test_2695_stt_capability_probe.py
+++ b/tests/unit/test_2695_stt_capability_probe.py
@@ -307,7 +307,7 @@ async def _call():
return_value=KEY):
with pytest.raises(ClientPortalError) as exc:
asyncio.run(_call())
- assert exc.value.status_code == 422
+ assert exc.value.status_code == 503 # #2696: a named refusal, not the opaque 422
assert stt.read_cached(KEY).verdict == stt.VERDICT_REFUSED
diff --git a/tests/unit/test_2696_stt_provider_errors.py b/tests/unit/test_2696_stt_provider_errors.py
new file mode 100644
index 000000000..cff72a0e2
--- /dev/null
+++ b/tests/unit/test_2696_stt_provider_errors.py
@@ -0,0 +1,222 @@
+"""#2696 — the `/stt` provider-error branch says WHY, instead of one opaque 422.
+
+`transcribe_portal_audio` mapped every non-200 from ElevenLabs onto
+`422 "Could not transcribe the audio"`. A missing endpoint permission, a rejected
+key, exhausted credits, a provider rate limit and a bad audio container all read
+identically, while the actionable status word sat in a backend WARNING. These
+tests pin the replacement mapping — by EXECUTING `transcribe_portal_audio` with
+the provider stubbed at `httpx.AsyncClient`, and by driving the pure classifier
+over every documented status so an unrecognised one cannot regress to the old
+string.
+
+No real Redis, no real HTTP.
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+from unittest.mock import patch
+
+import httpx
+import pytest
+
+import services.stt_capability_service as stt
+import services.tts_service as tts_service
+from client_portal import service as portal_service
+from client_portal.service import ClientPortalError
+
+KEY = "sk_example_key_for_2696"
+OPAQUE = "Could not transcribe the audio"
+
+
+@pytest.fixture(autouse=True)
+def _no_redis_fresh_cache(monkeypatch):
+ monkeypatch.setattr(stt, "_redis", lambda: None)
+ stt._local.clear(); stt._local_failures.clear(); stt._inflight.clear()
+ yield
+ stt._local.clear(); stt._local_failures.clear(); stt._inflight.clear()
+
+
+def _body(status_word: str, message: str = "provider message") -> str:
+ return json.dumps({"detail": {"status": status_word, "message": message}})
+
+
+# ---- the mapping is total and named ----------------------------------------
+
+@pytest.mark.parametrize("status,body,category,http_status", [
+ (401, _body("missing_permissions"), stt.CATEGORY_PERMISSION, 503),
+ (403, _body("missing_permissions"), stt.CATEGORY_PERMISSION, 503),
+ (401, _body("invalid_api_key"), stt.CATEGORY_AUTH, 503),
+ (401, "not json at all", stt.CATEGORY_AUTH, 503),
+ (401, _body("quota_exceeded"), stt.CATEGORY_QUOTA, 503),
+ (401, _body("free_users_not_allowed"), stt.CATEGORY_QUOTA, 503),
+ (402, "", stt.CATEGORY_QUOTA, 503),
+ (429, _body("too_many_concurrent_requests"), stt.CATEGORY_RATE_LIMIT, 429),
+ (400, _body("invalid_audio", "File is corrupted"), stt.CATEGORY_AUDIO, 422),
+ (415, "", stt.CATEGORY_AUDIO, 422),
+ (422, json.dumps({"detail": [{"loc": ["file"], "msg": "x"}]}), stt.CATEGORY_AUDIO, 422),
+ (500, "", stt.CATEGORY_PROVIDER, 502),
+ (503, "", stt.CATEGORY_PROVIDER, 502),
+ (418, "", stt.CATEGORY_UNKNOWN, 502),
+ (301, "", stt.CATEGORY_UNKNOWN, 502),
+])
+def test_every_provider_status_lands_in_a_named_category(status, body, category, http_status):
+ f = stt.classify_stt_failure(status, body)
+ assert f.category == category
+ assert f.http_status == http_status
+ assert f.provider_status == status
+ assert f.client_message and f.client_message != OPAQUE
+
+
+def test_categories_are_distinguishable_by_their_client_sentence():
+ """AC 1 + 2: permission ≠ audio, quota ≠ auth — in the words the user sees."""
+ perm = stt.classify_stt_failure(401, _body("missing_permissions")).client_message
+ auth = stt.classify_stt_failure(401, _body("invalid_api_key")).client_message
+ quota = stt.classify_stt_failure(401, _body("quota_exceeded")).client_message
+ audio = stt.classify_stt_failure(400, _body("invalid_audio")).client_message
+ assert len({perm, auth, quota, audio}) == 4
+ assert "permission" in perm
+ assert "rejected" in auth
+ assert "credits" in quota
+ assert "recording" in audio
+
+
+def test_rate_limit_maps_to_429_with_the_existing_retry_wording():
+ f = stt.classify_stt_failure(429, "")
+ assert f.http_status == 429
+ assert f.client_message == "Too many voice messages just now — wait a moment and try again."
+
+
+def test_client_message_never_carries_the_provider_body():
+ secret_ish = _body("missing_permissions", "The API key you used (sk_live_abc) is missing speech_to_text")
+ f = stt.classify_stt_failure(401, secret_ish)
+ assert "sk_live_abc" not in f.client_message
+ assert "speech_to_text" not in f.client_message
+ assert f.detail == "missing_permissions" # the operator half keeps the status word
+
+
+def test_there_is_no_arm_that_returns_the_opaque_string():
+ """AC 6, the regression pin: sweep every status the provider could answer
+ and assert none reaches the old text. A new arm that defaulted to it would
+ fail here before it failed in a user's hands."""
+ for status in range(300, 600):
+ for body in ("", "{}", _body("something_new")):
+ assert stt.classify_stt_failure(status, body).client_message != OPAQUE
+
+
+# ---- the endpoint executes the mapping -------------------------------------
+
+def _stub_provider(monkeypatch, *, status: int, body: str):
+ class _Resp:
+ status_code = status
+ text = body
+
+ def json(self):
+ return json.loads(body or "{}")
+
+ class _Client:
+ def __init__(self, *a, **kw):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *a):
+ return False
+
+ async def post(self, *a, **kw):
+ return _Resp()
+
+ # `transcribe_portal_audio` does `import httpx` inside the function, so the
+ # module attribute is what it reads.
+ monkeypatch.setattr(httpx, "AsyncClient", _Client)
+
+
+def _call():
+ return asyncio.run(portal_service.transcribe_portal_audio(
+ "acme-bot", "client@example.com", "voice.webm", "audio/webm", b"x" * 4000))
+
+
+@pytest.fixture
+def past_the_gate(monkeypatch):
+ """Roster hit, key present, capability already verified — the real call runs.
+ (Seeding `capable` keeps the gate from probing the same stub the real call is
+ about to hit; the probe's own behaviour is #2695's suite.)"""
+ stt.store(KEY, stt.SttCapability(stt.VERDICT_CAPABLE))
+ with patch.object(portal_service, "agent_on_roster", return_value=True), \
+ patch.object(tts_service, "is_available", return_value=True), \
+ patch("services.settings_service.settings_service.get_elevenlabs_api_key",
+ return_value=KEY):
+ yield
+
+
+@pytest.mark.parametrize("status,body,expect_http,expect_fragment", [
+ (401, _body("missing_permissions"), 503, "speech-to-text permission"),
+ (401, _body("quota_exceeded"), 503, "credits"),
+ (401, _body("invalid_api_key"), 503, "rejected"),
+ (429, "", 429, "wait a moment"),
+ (400, _body("invalid_audio"), 422, "recording could not be read"),
+ (500, "", 502, "please type instead"),
+])
+def test_endpoint_answers_with_the_category_message(monkeypatch, past_the_gate,
+ status, body, expect_http, expect_fragment):
+ _stub_provider(monkeypatch, status=status, body=body)
+ with pytest.raises(ClientPortalError) as exc:
+ _call()
+ assert exc.value.status_code == expect_http
+ assert expect_fragment in exc.value.detail
+ assert exc.value.detail != OPAQUE
+ assert exc.value.status_code != 500 # AC 5: fail-soft, never a 500
+
+
+def test_endpoint_remembers_the_failure_for_the_operator(monkeypatch, past_the_gate):
+ _stub_provider(monkeypatch, status=401, body=_body("missing_permissions"))
+ with pytest.raises(ClientPortalError):
+ _call()
+ last = stt.read_last_failure(KEY)
+ assert last["category"] == stt.CATEGORY_PERMISSION
+ assert last["provider_status"] == 401
+ assert last["detail"] == "missing_permissions"
+ assert last["at"] is not None
+ # #2695's half still fires: a 401 teaches the capability cache.
+ assert stt.read_cached(KEY).verdict == stt.VERDICT_REFUSED
+
+
+def test_a_bad_recording_teaches_the_operator_but_not_the_capability_cache(monkeypatch, past_the_gate):
+ _stub_provider(monkeypatch, status=400, body=_body("invalid_audio"))
+ with pytest.raises(ClientPortalError) as exc:
+ _call()
+ assert exc.value.status_code == 422
+ assert stt.read_last_failure(KEY)["category"] == stt.CATEGORY_AUDIO
+ assert stt.read_cached(KEY).verdict == stt.VERDICT_CAPABLE # the KEY is fine; the mic stays
+
+
+def test_a_success_still_returns_the_transcript(monkeypatch, past_the_gate):
+ _stub_provider(monkeypatch, status=200, body=json.dumps({"text": " hello "}))
+ assert _call() == "hello"
+ assert stt.read_last_failure(KEY) is None
+
+
+# ---- the operator surface ----------------------------------------------------
+
+def test_settings_state_carries_the_last_failure_for_admins_only_by_route(monkeypatch):
+ from routers import settings as settings_router
+
+ stt.record_live_failure(KEY, 401, _body("missing_permissions"))
+ with patch("services.settings_service.settings_service.get_elevenlabs_api_key",
+ return_value=KEY), \
+ patch("services.settings_service.settings_service.elevenlabs_key_source",
+ return_value="override"), \
+ patch("services.settings_service.settings_service.get_default_voice_id",
+ return_value=None):
+ state = asyncio.run(settings_router._elevenlabs_settings_state_with_capability())
+
+ assert state["stt_last_failure"]["category"] == stt.CATEGORY_PERMISSION
+ assert state["stt_last_failure"]["detail"] == "missing_permissions"
+ assert KEY not in json.dumps(state)
+
+
+def test_the_portal_card_carries_no_operator_detail():
+ """AC 4: the client payload never grows the provider's status word."""
+ from client_portal.models import PortalAgentCard
+ assert not any("stt_detail" in f or "last_failure" in f for f in PortalAgentCard.model_fields)
From 06b86be99657d5f169f07457d3e18f2539ae08d8 Mon Sep 17 00:00:00 2001
From: Oleksii Dolhov
Date: Fri, 11 Sep 2026 15:03:50 +0300
Subject: [PATCH 03/28] test(e2e): measure the composer shell from one settled
render (workspace-model-choice)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The spec's own docstring says every box is taken from ONE render, but the
icon-button loop re-measured each button after `picker`/`send` were captured,
and skipped Send by coordinate (`box.x >= send.x`). The picker is a native