diff --git a/docs/memory/architecture/backend.md b/docs/memory/architecture/backend.md index 706c249ec..5a7b08800 100644 --- a/docs/memory/architecture/backend.md +++ b/docs/memory/architecture/backend.md @@ -87,7 +87,7 @@ *Public Access & Monetization:* - `public_links.py` - Public agent link management -- `public.py` - Public chat endpoints; also Caddy's unauthenticated on-demand-TLS `ask` gate, `GET /api/public/tls-allowed` (#2380) +- `public.py` - Public chat endpoints - `paid.py` - x402 payment-gated chat (NVM-001) - `nevermined.py` - Nevermined payment config (NVM-001) - `slack.py` - Slack integration: OAuth, events, multi-agent channel routing, per-agent binding (SLACK-001/002) @@ -99,9 +99,9 @@ - `public_memory.py` - Per-user memory write endpoint for channel sessions (MEM-001, #888) *Subscriptions & Skills:* -- `subscriptions.py` - Subscription management (SUB-002). **#2572:** `POST` now also runs the credential-less adoption sweep (`subscription_service.adopt_for_credentialless_agents`) beside the #1089 rollover fan-out, in its own swallow-everything `try/except` — the sweep must never fail a registration whose credential is already stored, which is why the body param is `payload` and the injected one `http_request` (two things called `request` is how a log line inside an `except` raises `AttributeError` into a 500). The manual assign/clear routes now write the `subscription_assign` / `subscription_clear` audit rows this router had never carried (#2421 owns the remaining three actions); id and name only, never a token. #471 usage observability — extended `GET /{id}/usage` (failure counters + one-gate `rate_limited_now` + `headroom` block, `source: anthropic|observed`), `GET /{id}/usage/breakdown` (per-agent, cost-ranked), `POST /{id}/usage/refresh` (click probe), `GET/PUT /settings/headroom-auto-refresh` — all `assert_admin`; ent#433 adds `GET /{id}/headroom/history?window=24h|7d|30d` (bounded `last`-per-bucket series, `bucket_start` + real `fetched_at`, `coverage_pct`; 422 on an unknown window, id-OR-name + 404 parity with `/usage`; read-only, never probes); ent#434 adds `PUT /settings/headroom-alert-threshold` (`0` disables — the `operator_queue_retention_days` idiom — else 50–99 with a named 422; the escalation tier is DERIVED `max(threshold, 90)`, never a second knob, because two settable thresholds are an oscillator and `validate_ops_setting` is per-key so it cannot express the cross-field invariant) and extends the auto-refresh GET with a `weekly_alert` block carrying `active` + an `inactive_reason` of `no_subscriptions` / `threshold_disabled` / `auto_refresh_off` / `redis_unavailable` / `count_unavailable`, so "no alerts" is distinguishable from "not checking" (#2217) — an unreadable subscription list is its OWN reason rather than falling through to `active: true`, which it did while `None == 0` decided the first arm. Rendered by the threshold control in `SubscriptionsPanel.vue`, whose decidable rules live in `utils/headroomAlertSettings.js` (vitest runs `environment: 'node'` with no mount harness, so a rule inside the SFC is untestable — the ent#392 precedent). The key is 422-blocked on the generic `PUT /api/settings/{key}` -- `skills.py` - Skill CRUD and assignment. **#2703:** assignment DELIVERS — `POST`/`PUT` call `skill_service.deliver_assigned` (the start-path injection, all assigned + `force=False`; a subset would rewrite CLAUDE.md's section) and return a `delivery` block (`injected|partial|pending_start|in_progress|not_delivered+reason`) on a 200 the row never rolls back; bounded by `SKILL_DELIVERY_BUDGET_SECONDS` with the work kept alive past it. All four routes fire the thin `agent_skills_changed` WS trigger (`skill_service.broadcast_skills_changed`, identifiers only — #918 rule) — see [skill-injection.md](../feature-flows/skill-injection.md) -- `settings.py` - Platform admin settings (incl. Slack transport connect/disconnect/install). **#2572:** both Anthropic-key clear paths fire the credential-less adoption sweep, each gated on the route's existing `deleted` truthiness — the dedicated `DELETE /api-keys/anthropic` **and** the generic `DELETE /{key}` keyed on the two key aliases, because `db.delete_setting` has no delete-side twin of ent#435's `db.set_setting` sink guard and so reaches the key without ever touching `clear_secret_setting`. The hook sits on the ROUTES, not that leaf, which also serves `github_pat` and the Slack keys. Hosts the Tier-2 usage-sharing family (ent#12/ent#437): `GET /telemetry-sharing` (admin + human-only; `?preview=0` skips the aggregate build), `PUT /telemetry-sharing` (consent; mints/discards the share id), `POST /telemetry-sharing/ask/dismiss` (the once-per-install "don't ask again" marker) — see [Opt-in Instance Telemetry](#opt-in-instance-telemetry-ent437) +- `subscriptions.py` - Subscription management (SUB-002); #471 usage observability — extended `GET /{id}/usage` (failure counters + one-gate `rate_limited_now` + `headroom` block, `source: anthropic|observed`), `GET /{id}/usage/breakdown` (per-agent, cost-ranked), `POST /{id}/usage/refresh` (click probe), `GET/PUT /settings/headroom-auto-refresh` — all `assert_admin`; ent#433 adds `GET /{id}/headroom/history?window=24h|7d|30d` (bounded `last`-per-bucket series, `bucket_start` + real `fetched_at`, `coverage_pct`; 422 on an unknown window, id-OR-name + 404 parity with `/usage`; read-only, never probes); ent#434 adds `PUT /settings/headroom-alert-threshold` (`0` disables — the `operator_queue_retention_days` idiom — else 50–99 with a named 422; the escalation tier is DERIVED `max(threshold, 90)`, never a second knob, because two settable thresholds are an oscillator and `validate_ops_setting` is per-key so it cannot express the cross-field invariant) and extends the auto-refresh GET with a `weekly_alert` block carrying `active` + an `inactive_reason` of `no_subscriptions` / `threshold_disabled` / `auto_refresh_off` / `redis_unavailable` / `count_unavailable`, so "no alerts" is distinguishable from "not checking" (#2217) — an unreadable subscription list is its OWN reason rather than falling through to `active: true`, which it did while `None == 0` decided the first arm. Rendered by the threshold control in `SubscriptionsPanel.vue`, whose decidable rules live in `utils/headroomAlertSettings.js` (vitest runs `environment: 'node'` with no mount harness, so a rule inside the SFC is untestable — the ent#392 precedent). The key is 422-blocked on the generic `PUT /api/settings/{key}` +- `skills.py` - Skill CRUD and assignment +- `settings.py` - Platform admin settings (incl. Slack transport connect/disconnect/install). Hosts the Tier-2 usage-sharing family (ent#12/ent#437): `GET /telemetry-sharing` (admin + human-only; `?preview=0` skips the aggregate build), `PUT /telemetry-sharing` (consent; mints/discards the share id), `POST /telemetry-sharing/ask/dismiss` (the once-per-install "don't ask again" marker) — see [Opt-in Instance Telemetry](#opt-in-instance-telemetry-ent437) *Content & Files:* - `image_generation.py` - Image generation REST endpoints (IMG-001) @@ -122,7 +122,7 @@ - `template_registry_service.py` - Remote template registry (TMPL-002, ent#14): byte-capped **streaming** fetch (the ceiling counts WIRE bytes via `iter_raw()` — `Content-Length` is absent on chunked responses and trivially lied about, and `iter_bytes()` yields *decoded* chunks, so with httpx's default `Accept-Encoding: gzip, deflate` a legal-looking 199 KiB body inflated ~1030:1 to a 458 MB event-loop-thread allocation before the running total was consulted; any `Content-Encoding` is now refused before the body is read) + `follow_redirects=False` → `utils/safe_yaml.load_template_registry_yaml` (`AliasPolicy.REJECT`, the ent#314 rule pinned at the `utils/` layer) → allowlisted four-field parse → own TTL cache (3600 s + jitter, deliberately unaligned with the 600 s per-repo TTL to avoid a correlated herd; 7-day serve-stale cap; 60 s negative cache; cross-worker **generation counter** because a per-process invalidate half-applies under `--workers 2`; durable last-known-good as sanitized parsed JSON). Never raises — every failure returns `[]`. See [platform-settings.md](../feature-flows/platform-settings.md) - `template_schedules.py` - Tolerant `schedules:` reader (ent#89): `schedule_shape_errors` / `normalize_declared_schedules` over one private `_parse`, consumed by both `template_service` builders, the `crud` materializer, and compatibility check T-018. **Total by contract** — a raise would empty the catalog, enter the creation rollback fence, or fail-open T-018. Bounds (`MAX_DECLARED_SCHEDULES=20`, name/description/message limits), intra-block name dedupe, and strict cron/timezone via `schedule_validation.validate_cron_expression`. Stdlib + `schedule_validation` only — `template_service` imports it, so it must not import back. See [template-processing.md](../feature-flows/template-processing.md) - `agent_client.py` - HTTP client for agent container communication (chat, session, injection); hosts the transport circuit breaker — see [Circuit Breakers](execution.md#circuit-breakers-transport--dispatch-526) -- `settings_service.py` - Centralized settings retrieval (API keys, ops config, agent quotas). ent#582 adds the per-call resolvers for the first-run keys: `get_gemini_api_key` (encrypted `google_api_key` → `config.GEMINI_API_KEY`, i.e. `GEMINI_API_KEY` → `GOOGLE_API_KEY`), `get_resend_api_key`, `get_email_provider` (a Resend key saved in Settings selects Resend over `EMAIL_PROVIDER`) and `get/set/clear_email_from_address` (`email_from_address` → `SMTP_FROM`). Every platform Gemini consumer (voice, portal voice, VoIP, Brain Orb voice, Telegram transcription, image/avatar generation, feature flags, `/api/version`) calls the resolver instead of the import-frozen `config.GEMINI_API_KEY` +- `settings_service.py` - Centralized settings retrieval (API keys, ops config, agent quotas) - `a2a_gate.py` - Open-core seam for the A2A inbound allow-list: OSS registers no provider → any authenticated owner/shared caller is allowed; a private module can register one to further restrict caller identities. **Fails open** (a provider error never blocks an authenticated caller), so it is a restriction layered on auth, not a security boundary. A seam file — its comments describe the mechanism only and are grepped by `enterprise-docs-guard.yml` (#1461 class) (ent#157) - `a2a_outbound.py` - Open-core seam for the OUTBOUND A2A target registry, and the **deliberate inverse of `a2a_gate`: it FAILS CLOSED** — no provider, a provider that raises, or a provider returning a malformed object all refuse the call, because this seam decides *where a credential is sent* whereas `a2a_gate` only restricts an already-authenticated caller. The `isinstance(ResolvedEndpoint)` check on the return value is load-bearing rather than defensive: under a stubbed `sys.modules` a `MagicMock` module returns a truthy endpoint with a mock `.url`, silently inverting fail-closed *inside the suite that proves it closed*. Ships the **OSS provider** — admin-managed named endpoints in `system_settings` as one AES-256-GCM envelope (Invariant #12's `elevenlabs_api_key_encrypted` shape), so OSS is functional with **no new table, no migration, no Alembic revision**. Shipping a working source rather than only the seam is deliberate: a seam with no registered provider resolves nothing, so the tool would answer "no targets configured" on every install. A private per-agent provider may register and take precedence (#736) - `assignment_provider.py` - Open-core seam for **role assignments** — which human fills which business role for an agent, and which of them the agent primarily serves. OSS registers no provider → `resolve_assignment(agent_name, triggered_by)` returns `None` and the execution-context block renders byte-identically to a build without the seam; a registered module answers with a display name, a role id, the stakeholder list, and whether proactive contact is on file. **Deliberately SYNC**: `compose_system_prompt` is a plain `def` called from async handlers, so a provider that blocks on I/O here stalls the worker — providers answer from memory, never over HTTP. The seam owns the failure handling rather than delegating it (the `mfa_gate` position, not `a2a_outbound`'s): `compose_system_prompt` has NO exception handler, so an escape costs all three of its callers the execution-context block and costs one of them the platform prompt entirely. Shape validation sits beside the `try` because `try`/`except` cannot see the defect it catches — a `str` where a list was promised iterates into single characters and renders the WRONG prompt without raising, which is harder to notice than a missing line. `triggered_by` is passed through so a provider can suppress the answer for an audience that must not see staff identities; the seam carries the label and never decides that policy. A seam file — its comments describe the mechanism only and are grepped by `enterprise-docs-guard.yml` (#1461 class) (trinity-enterprise#500) @@ -171,13 +171,12 @@ - `setup_url_display.py` - UTS-46 nontransitional canonical host + eTLD+1 for an author-supplied `setup_url`, failing **closed** to inert text (ent#127). Leaf; zero deps on `template_service` - `credential_encryption.py` - AES-256-GCM encryption for `.credentials.enc` and DB-persisted tokens (CRED-002, Invariant #12). Supports **online key rotation** (#267): an optional decrypt-only `CREDENTIAL_ENCRYPTION_KEY_SECONDARY` (the previous key) keeps old-key ciphertext readable while new writes use the primary; `rewrap()` + `scripts/deploy/rotate-credential-key.py` re-encrypt persisted DB secrets onto the new key (runbook `docs/migrations/CREDENTIAL_KEY_ROTATION.md`) - `runtime_secret_scrub.py` - Runtime secret-scrub seam (ent#279): identity-based redaction of a producer-**STAGED** value out of persisted output, catching arbitrary values the pattern-based `utils/credential_sanitizer` (prefix regexes + `KEY=value`) cannot. A generic OSS mechanism — mechanism-only by design, names no consumer. Sync API: `stage_secret(agent_name, value)` (fail **CLOSED** — any failure, Redis down or hard cap, raises `StagingUnavailable` so a producer that cannot stage refuses delivery), `get_staged_values()`, and `scrub_text`/`scrub_obj` for the execution-terminal persistence chokepoints to redact staged values (marker `***REDACTED***`; raw / once-JSON-escaped / base64 renditions; longest-first; falsy-passthrough) **before** the write. Store = ONE global Redis HASH `secret_scrub:staged` (field `sha256(value)` → AES-256-GCM envelope via the credential-encryption singleton) + a `secret_scrub:staged_at` ZSET giving 24h **per-member** expiry (Redis holds only envelopes; values ≥8 chars only). The scrub side fails **OPEN** (a Redis/decrypt error → no identity scrub + one throttled ERROR; the pattern pass still runs and the terminal still persists — no NEW secret is delivered during the outage because the stage side is closed). Behaviour-neutral for OSS (no staged values ⇒ every scrub is a no-op) — see [runtime-secret-scrub.md](../feature-flows/runtime-secret-scrub.md) -- `subscription_service.py` - Subscription management (SUB-002); owns `derive_auth_mode` — the ONE auth-mode enum derivation shared by `AgentAuthStatus` and the #471 pressure batch endpoint. ent#582: `is_claude_auth_configured` (the `claude_auth_configured` flag's one definition) and `connect_agents_to_first_credential` — when a write gives the install its FIRST Claude credential, every agent created without one gets the new subscription assigned, and agents with a running container are recreated in the background via the SUB-003 `_restart_agent` under the #799 switch lock. Candidates come from the DB rows (`db.list_agents_awaiting_first_credential`: not ephemeral, no subscription, `use_platform_api_key` on, never a successful execution) filtered to Claude runtime, never from `list_all_agents_fast` (which reads `[]` on a Docker fault). The restart re-checks each agent under the lock and skips one with a running execution (`db.agent_has_running_execution`) or an already-current env, so the call is idempotent; returns an int count (`connected_agents` on both save responses). `system_seed_service._connect_seeded_agents` re-runs it after a seed pass that created agents (a create can straddle the save). Scope is deliberately "could not authenticate anyway": an established fleet, or an agent on its own `.env` key, is never touched. **#2572** adds the credential-less predicate and the adoption sweep: `instance_has_api_key` (which MUST stay `get_anthropic_api_key()` — env fallback included — never `has_secret_setting()`, a DB-only presence check that would adopt a fleet off a working env key), `credentialless_agent_names` (one `get_agent_subscription_map` read, `[]` whenever the instance has a key) and `adopt_for_credentialless_agents` (awaited decide+persist, backgrounded container apply). Its runtime gate is LABEL-STRICT via `docker_service.agent_container_runtime_labels` and fails CLOSED on an unreadable Docker — the opposite of ent#403's fail-open, because a UI affordance and a persisted credential assignment are not the same bet -- `subscription_headroom_service.py` - Live subscription headroom (#471): actual 5h/7d utilization % + reset times read from the `anthropic-ratelimit-unified-*` headers of a `max_tokens=1` micro-ping probe (the `/api/oauth/usage` endpoint is 403-scope-dead for stored `sk-ant-oat01-` setup tokens — the closed-PR #2170 mechanism, established 2026-08-19). Click-to-refresh (floored ≥60s/subscription) + default-ON ambient refresh (`subscription_headroom_auto_refresh` setting, 15-min floor, demand-driven). **Fail-CLOSED without Redis** — `_read_snapshot` is tri-state (`(redis_ok, snapshot)`; a client object existing ≠ a reachable server), so a Redis outage yields observed-only, never a probe storm; the dashboard batch path uses `wait=False` (stale-while-revalidate via strong-ref background task) so a hung provider can't wedge the 60s poll. Snapshot: Redis `subscription:headroom:{id}` (7d TTL); probes single-flighted (#1920); a probe 429 updates snapshot status only, never `subscription_rate_limit_events`. `services/subscription_headroom_service.resolve_rate_limited_now` is the ONE `rate_limited_now` derivation (#2157 one-gate rule), consumed by both `decorate_usage` and `pressure_states`. **It is three-state, not an OR (#447):** fresh provider verdict says limited → limited; fresh verdict says allowed → NOT limited; no usable verdict → the 2h event predicate. It shipped as `db_predicate OR fresh_verdict`, which made a fresh, authoritative *"allowed, 32% used, resets 19:10"* structurally powerless to clear the badge — because **nothing clears a failure row on success**: `clear_rate_limit_events` has had zero production callers since #444 removed the one call (clearing was destroying auto-switch's detection signal), so the event half only ever decays with the clock. Observed live: two subscriptions wearing `LIMIT` while every agent on them answered normally. The db predicate is an *inference from past failures*; a probe is *ground truth about now*, so it wins in both directions. `_headroom_indicates_healthy` is deliberately **not** the negation of `_headroom_indicates_limited` — "not limited" is also true for a stale snapshot, a rejected token and a transport error, none of which are evidence of headroom, so all three fall through to the predicate rather than clearing it (#2353's rule, preserved). **Both predicates judge a window against ONE named allowlist, `NON_BLOCKING_WINDOW_STATUSES` (#2396)** — an ALLOWLIST of statuses meaning "requests are being served", never a blocklist of blockers, so an unrecognised status still reads as limited (the inverse mistake is the #848 deny-check lesson). It shipped as the bare literal `("allowed",)` in one predicate and `(None, "allowed")` in the other, which made the provider's own near-the-limit tier `allowed_warning` read as a hard rate limit on every surface — a healthy subscription approaching its weekly window wore `LIMIT` while the provider was still serving it (observed live: a snapshot recording `seven_day: 90%, allowed_warning` beside 47 successful executions and zero `subscription_rate_limit_events`). `allowed_warning` now also counts as **positive proof of headroom** — the quota was reached and the answer was yes — so it clears a stale 2h db predicate; excluding it would be #447 returning in a narrower window. Note the ordering that bounds the blast radius: a real HTTP 429 sets the top-level snapshot `status` and is checked BEFORE any window, and the db predicate is a third independent detector, so the window arm is the weakest of the three. This is the DISPLAY predicate only: auto-switch candidate filtering reads the kind-blind `has_recent_subscription_failures`, so a just-recovered subscription is still skipped as a switch target and #444's ping-pong cannot return. **The event half of that predicate counts `failure_kind = 'rate_limit'` ONLY (#2352)** — it was kind-blind, so an auth failure (401/403: a dead, expired, or `.env`-shadowed token) set the flag and every surface reported a credential problem as quota exhaustion, sending the operator to wait out a window that was never full; the display layer had honoured the split since #471 (`rateLimitEventCount` reads the `rate_limit` kind alone) and the predicate was the layer that had not caught up. NULL `failure_kind` (pre-#471) is excluded — unknown is never promoted to "429". This is a **split, not a filter**: `db.has_recent_subscription_failures` preserves the kind-BLIND semantics for the two *candidate-selection* listings (`list_viable_alternative_subscriptions`, `list_assignable_subscriptions` — since #2409 the db only FILTERS; the services rank, see `subscription_auto_switch.py`), which must keep skipping a subscription that failed for ANY reason — narrowing them in place would have auto-switched agents onto subscriptions whose token had just been rejected (the #444 class). Two predicates, two meanings; re-merging them is how the bug happened. `pressure_states` therefore also emits `auth_failures_24h` beside the total, since after the split a dead-token subscription is no longer `rate_limited_now` and a bare total cannot tell the badge which word to use. The observed (DB-derived) arm is always populated; SUB-004's windows are **deduped since #471** (executions = sole cost/context/turn source; `chat_messages` contributes `output_tokens` only — a `/chat`/persisted-`/task` turn writes cost into BOTH tables). **ent#433 adds the durable half** — `_probe_and_store` now also writes each probe to `subscription_headroom_history`, so utilization TRENDS survive a snapshot that overwrites itself every probe. Three properties are load-bearing: the history write runs **after** `_store_snapshot` and **off the event loop** (`asyncio.to_thread`; a sync SQLAlchemy write on the loop stalls everything for up to the 30s busy timeout when it lands during the 03:30 backup or 04:30 VACUUM, and `try/except` handles errors but not blocking) — order pinned by test; it catches `Exception`, never `BaseException`, so shutdown's `CancelledError` still propagates; and it adds **no probe**, inheriting #471's entire rate-bounding envelope (60s floor, single-flight, fail-closed ambient) by sitting inside `_probe_and_store`. `get_history(subscription_id, window)` is the read — pure DB, never probes, so viewing a trend costs no quota. **ent#434 adds a THIRD predicate beside the two binary ones — `classify_headroom` → `saturated | has_headroom | unassessable`** — because the alert path needs a state the binary pair cannot express. `_headroom_indicates_healthy` returns `False` for a stale snapshot, a rejected token, a transport error AND a genuinely saturated subscription, so it cannot tell *no evidence* from *no headroom*; #2396's docstring named ent#434 as its consumer and was wrong, since the fleet escalation would then be blocked by exactly the condition it reports. The docstring is corrected and the BODY left untouched (`resolve_rate_limited_now` consumes it — a behavioural edit moves every `LIMIT` badge). The classifier keys on **utilization, not window status**: `allowed_warning` is deliberately non-blocking, so a status-driven classifier would file a live 90% warning-tier reading as `has_headroom` — the exact reading the alert exists for. Status remains the *stronger* signal (a blocking 7d status or a probe 429 is `saturated` with or without a number, counting toward the fleet claim but raising no percentage-crossing alert). Every could-not-tell path returns `unassessable`, including the easily-missed 5h-only snapshot (`parse_unified_headers`'s top guard passes on `5h-utilization` alone, so a gate reading "ok status + non-blocking windows" would claim WEEKLY headroom on zero weekly evidence). `ensure_reading(sid, max_age)` is the ONE probe decision per subscription per cycle — two consumers each deciding for themselves means the second is floored out by `MIN_PROBE_INTERVAL_SECONDS` and reads "no data" on precisely the subscriptions the first just refreshed. `SAMPLE_INTERVAL_SECONDS` (3600, floored at `REFRESH_SECONDS`) is a **constant, not a knob** (#1644) — and literally so: it reads no env var, because neither compose uses `env_file` and an unforwarded read would be inert while still reading as configurable. The one real lever is `SUBSCRIPTION_SWEEP_CONCURRENCY` (probes per sweep chunk), forwarded in both composes **#2409 adds the auto-switch ranker beside the classifier, over ONE gate.** `headroom_reading(headroom, max_age_seconds)` is the usability gate both consume — fresh? probe answered (`ok`/`rate_limited`/`invalid_token`)? window shape? — and `classify_headroom` became six lines of policy over it with byte-identical verdicts (pinned by a differential test against a frozen copy of the pre-#2409 function over the full age × status × window × threshold product; the ent#434 suite is unchanged). `cached_headroom_readings(ids)` is the selector's read: ONE `MGET` over `subscription:headroom:{id}`, tri-state on Redis (client unbuildable or a raise ⇒ every candidate unknown in one attempt; a malformed snapshot blinds only itself), and it never calls `get_headroom`/`_locked_probe`. `rank_subscriptions` / `selection_verdict` / `selection_sort_key` are **threshold-free** — the alert threshold is an operator knob that must not steer where agents land, and it is `0` when alerts are off: tier `measured` (fresh, serving, weekly figure) sorted by the FULLER window (`primary` = nearest wall) in `HEADROOM_BAND_PCT`=10 bands with `agent_count` as the in-band tiebreak, then the other window, then name; `unknown` (no/stale/error/5h-only/non-finite, or a STALE refusal) = exactly today's `agent_count ASC, name ASC`; `refused` (a FRESH probe 429 / blocking window / rejected token) is dropped. Two bounds: `MAX_READING_AGE_SECONDS` (2h — now OWNED here, `= max(2h, 2 × SAMPLE_INTERVAL_SECONDS)` so it always covers two sampler intervals, re-exported by `subscription_headroom_alerts`) bounds the weekly figure, which is a lower bound within the provider's fixed window; `FRESHNESS_SECONDS` (30 min, the LIMIT-badge bound) bounds the 5h figure (it can fully reset inside 2h) and any refusal (a point-in-time verdict). `describe_reading` is the notification/activity shape — tiers and figures, never a token. ent#582: `check_token(token)` sends that same one-message probe (`_post_probe`) for a raw token BEFORE it is registered — `ok`/`rate_limited`/`invalid_token`/`error`, backing `POST /api/subscriptions/test`. +- `subscription_service.py` - Subscription management (SUB-002); owns `derive_auth_mode` — the ONE auth-mode enum derivation shared by `AgentAuthStatus` and the #471 pressure batch endpoint +- `subscription_headroom_service.py` - Live subscription headroom (#471): actual 5h/7d utilization % + reset times read from the `anthropic-ratelimit-unified-*` headers of a `max_tokens=1` micro-ping probe (the `/api/oauth/usage` endpoint is 403-scope-dead for stored `sk-ant-oat01-` setup tokens — the closed-PR #2170 mechanism, established 2026-08-19). Click-to-refresh (floored ≥60s/subscription) + default-ON ambient refresh (`subscription_headroom_auto_refresh` setting, 15-min floor, demand-driven). **Fail-CLOSED without Redis** — `_read_snapshot` is tri-state (`(redis_ok, snapshot)`; a client object existing ≠ a reachable server), so a Redis outage yields observed-only, never a probe storm; the dashboard batch path uses `wait=False` (stale-while-revalidate via strong-ref background task) so a hung provider can't wedge the 60s poll. Snapshot: Redis `subscription:headroom:{id}` (7d TTL); probes single-flighted (#1920); a probe 429 updates snapshot status only, never `subscription_rate_limit_events`. `services/subscription_headroom_service.resolve_rate_limited_now` is the ONE `rate_limited_now` derivation (#2157 one-gate rule), consumed by both `decorate_usage` and `pressure_states`. **It is three-state, not an OR (#447):** fresh provider verdict says limited → limited; fresh verdict says allowed → NOT limited; no usable verdict → the 2h event predicate. It shipped as `db_predicate OR fresh_verdict`, which made a fresh, authoritative *"allowed, 32% used, resets 19:10"* structurally powerless to clear the badge — because **nothing clears a failure row on success**: `clear_rate_limit_events` has had zero production callers since #444 removed the one call (clearing was destroying auto-switch's detection signal), so the event half only ever decays with the clock. Observed live: two subscriptions wearing `LIMIT` while every agent on them answered normally. The db predicate is an *inference from past failures*; a probe is *ground truth about now*, so it wins in both directions. `_headroom_indicates_healthy` is deliberately **not** the negation of `_headroom_indicates_limited` — "not limited" is also true for a stale snapshot, a rejected token and a transport error, none of which are evidence of headroom, so all three fall through to the predicate rather than clearing it (#2353's rule, preserved). **Both predicates judge a window against ONE named allowlist, `NON_BLOCKING_WINDOW_STATUSES` (#2396)** — an ALLOWLIST of statuses meaning "requests are being served", never a blocklist of blockers, so an unrecognised status still reads as limited (the inverse mistake is the #848 deny-check lesson). It shipped as the bare literal `("allowed",)` in one predicate and `(None, "allowed")` in the other, which made the provider's own near-the-limit tier `allowed_warning` read as a hard rate limit on every surface — a healthy subscription approaching its weekly window wore `LIMIT` while the provider was still serving it (observed live: a snapshot recording `seven_day: 90%, allowed_warning` beside 47 successful executions and zero `subscription_rate_limit_events`). `allowed_warning` now also counts as **positive proof of headroom** — the quota was reached and the answer was yes — so it clears a stale 2h db predicate; excluding it would be #447 returning in a narrower window. Note the ordering that bounds the blast radius: a real HTTP 429 sets the top-level snapshot `status` and is checked BEFORE any window, and the db predicate is a third independent detector, so the window arm is the weakest of the three. This is the DISPLAY predicate only: auto-switch candidate filtering reads the kind-blind `has_recent_subscription_failures`, so a just-recovered subscription is still skipped as a switch target and #444's ping-pong cannot return. **The event half of that predicate counts `failure_kind = 'rate_limit'` ONLY (#2352)** — it was kind-blind, so an auth failure (401/403: a dead, expired, or `.env`-shadowed token) set the flag and every surface reported a credential problem as quota exhaustion, sending the operator to wait out a window that was never full; the display layer had honoured the split since #471 (`rateLimitEventCount` reads the `rate_limit` kind alone) and the predicate was the layer that had not caught up. NULL `failure_kind` (pre-#471) is excluded — unknown is never promoted to "429". This is a **split, not a filter**: `db.has_recent_subscription_failures` preserves the kind-BLIND semantics for the two *candidate-selection* listings (`list_viable_alternative_subscriptions`, `list_assignable_subscriptions` — since #2409 the db only FILTERS; the services rank, see `subscription_auto_switch.py`), which must keep skipping a subscription that failed for ANY reason — narrowing them in place would have auto-switched agents onto subscriptions whose token had just been rejected (the #444 class). Two predicates, two meanings; re-merging them is how the bug happened. `pressure_states` therefore also emits `auth_failures_24h` beside the total, since after the split a dead-token subscription is no longer `rate_limited_now` and a bare total cannot tell the badge which word to use. The observed (DB-derived) arm is always populated; SUB-004's windows are **deduped since #471** (executions = sole cost/context/turn source; `chat_messages` contributes `output_tokens` only — a `/chat`/persisted-`/task` turn writes cost into BOTH tables). **ent#433 adds the durable half** — `_probe_and_store` now also writes each probe to `subscription_headroom_history`, so utilization TRENDS survive a snapshot that overwrites itself every probe. Three properties are load-bearing: the history write runs **after** `_store_snapshot` and **off the event loop** (`asyncio.to_thread`; a sync SQLAlchemy write on the loop stalls everything for up to the 30s busy timeout when it lands during the 03:30 backup or 04:30 VACUUM, and `try/except` handles errors but not blocking) — order pinned by test; it catches `Exception`, never `BaseException`, so shutdown's `CancelledError` still propagates; and it adds **no probe**, inheriting #471's entire rate-bounding envelope (60s floor, single-flight, fail-closed ambient) by sitting inside `_probe_and_store`. `get_history(subscription_id, window)` is the read — pure DB, never probes, so viewing a trend costs no quota. **ent#434 adds a THIRD predicate beside the two binary ones — `classify_headroom` → `saturated | has_headroom | unassessable`** — because the alert path needs a state the binary pair cannot express. `_headroom_indicates_healthy` returns `False` for a stale snapshot, a rejected token, a transport error AND a genuinely saturated subscription, so it cannot tell *no evidence* from *no headroom*; #2396's docstring named ent#434 as its consumer and was wrong, since the fleet escalation would then be blocked by exactly the condition it reports. The docstring is corrected and the BODY left untouched (`resolve_rate_limited_now` consumes it — a behavioural edit moves every `LIMIT` badge). The classifier keys on **utilization, not window status**: `allowed_warning` is deliberately non-blocking, so a status-driven classifier would file a live 90% warning-tier reading as `has_headroom` — the exact reading the alert exists for. Status remains the *stronger* signal (a blocking 7d status or a probe 429 is `saturated` with or without a number, counting toward the fleet claim but raising no percentage-crossing alert). Every could-not-tell path returns `unassessable`, including the easily-missed 5h-only snapshot (`parse_unified_headers`'s top guard passes on `5h-utilization` alone, so a gate reading "ok status + non-blocking windows" would claim WEEKLY headroom on zero weekly evidence). `ensure_reading(sid, max_age)` is the ONE probe decision per subscription per cycle — two consumers each deciding for themselves means the second is floored out by `MIN_PROBE_INTERVAL_SECONDS` and reads "no data" on precisely the subscriptions the first just refreshed. `SAMPLE_INTERVAL_SECONDS` (3600, floored at `REFRESH_SECONDS`) is a **constant, not a knob** (#1644) — and literally so: it reads no env var, because neither compose uses `env_file` and an unforwarded read would be inert while still reading as configurable. The one real lever is `SUBSCRIPTION_SWEEP_CONCURRENCY` (probes per sweep chunk), forwarded in both composes **#2409 adds the auto-switch ranker beside the classifier, over ONE gate.** `headroom_reading(headroom, max_age_seconds)` is the usability gate both consume — fresh? probe answered (`ok`/`rate_limited`/`invalid_token`)? window shape? — and `classify_headroom` became six lines of policy over it with byte-identical verdicts (pinned by a differential test against a frozen copy of the pre-#2409 function over the full age × status × window × threshold product; the ent#434 suite is unchanged). `cached_headroom_readings(ids)` is the selector's read: ONE `MGET` over `subscription:headroom:{id}`, tri-state on Redis (client unbuildable or a raise ⇒ every candidate unknown in one attempt; a malformed snapshot blinds only itself), and it never calls `get_headroom`/`_locked_probe`. `rank_subscriptions` / `selection_verdict` / `selection_sort_key` are **threshold-free** — the alert threshold is an operator knob that must not steer where agents land, and it is `0` when alerts are off: tier `measured` (fresh, serving, weekly figure) sorted by the FULLER window (`primary` = nearest wall) in `HEADROOM_BAND_PCT`=10 bands with `agent_count` as the in-band tiebreak, then the other window, then name; `unknown` (no/stale/error/5h-only/non-finite, or a STALE refusal) = exactly today's `agent_count ASC, name ASC`; `refused` (a FRESH probe 429 / blocking window / rejected token) is dropped. Two bounds: `MAX_READING_AGE_SECONDS` (2h — now OWNED here, `= max(2h, 2 × SAMPLE_INTERVAL_SECONDS)` so it always covers two sampler intervals, re-exported by `subscription_headroom_alerts`) bounds the weekly figure, which is a lower bound within the provider's fixed window; `FRESHNESS_SECONDS` (30 min, the LIMIT-badge bound) bounds the 5h figure (it can fully reset inside 2h) and any refusal (a point-in-time verdict). `describe_reading` is the notification/activity shape — tiers and figures, never a token. - `subscription_headroom_alerts.py` - The weekly-window alert (ent#434): pure projection + tier decision + deterministic emitter, no durable state. **The alert id IS the state machine** — the 7d window was measured fixed-with-reset (live history: `seven_day_resets_at` constant across five days of probes, then a +7d step), so utilization is monotonic within a window, a hysteresis floor is dead code, and `sub-headroom-{sid}-{reset-day}-{tier}` gives edge-trigger, cross-worker dedup and re-arm for free via `create_item`'s `UNIQUE(agent_name, request_id)` ON CONFLICT DO NOTHING (the id is quantised to the DAY as a belt: if some plan did behave as rolling it degrades to one alert/day, not one/probe). **The threshold fires; the projection ranks** — `projected_end = utilization_pct / fraction_of_window_elapsed`, so urgency is the operator's own burn rate rather than a configured time gap that is wrong for everyone; the alert is never withheld at the threshold, `priority` is `low` under 100% projected and `high` over. Fleet escalation needs ≥2 subscriptions (with one, "this is full" and "every one is full" are the same fact) and every member assessable — one `unassessable` blocks the claim and is named (ent#100). **The denominator is built from the subscription roster, never from the sweep's results**: a member whose sampling raised carries `classification: None` by design (the swallow protects #447) and a mid-cycle lease yield `break`s with a short result list, so a truthiness filter dropped exactly the members that should have blocked the claim — a 3-subscription instance emitted "All 2 registered subscriptions are at or past 75%" with the per-subscription alerts suppressed by the early return. `fleet_verdict` was always correct; the caller had narrowed its input. It states what was MEASURED and deliberately does NOT say "auto-switch has nowhere to go": since #2409 the selector ranks over the SAME cached readings this sweep samples, but its candidate set is also narrowed by the 2h failure filter the sweep never sees, so the body describes the measurement and never promises what the selector will do. Platform-only emitter on sentinel `_sub-headroom` (`_PLATFORM_ALARM_SENTINELS` + `_RESERVED_ID_PREFIXES` + the `test_1677` allowlist; `expires_at` None). Residual: `create_item` has no UPDATE path, so a 75% row still reads 75% at 92% — the escalation is a separate, self-contained item -- `subscription_auto_switch.py` - SUB-003 auto-switch orchestration (`handle_subscription_failure` → `_perform_auto_switch`, hot-reload #1089). **Since #2409 it owns alternative SELECTION** — it used to be a first-match pick inside the db layer (`ORDER BY agent_count ASC`, first survivor of the 2h skip-list), which read no headroom, moved agents onto subscriptions at 99% of their weekly window, and sorted an *unused dead-token* subscription FIRST (no agents ⇒ no failure rows). `select_best_alternative_subscription(current)` = filter → rank → first: the db lists the survivors (`list_viable_alternative_subscriptions`, kind-blind #444/#2352, `agent_count ASC, name ASC` — filter only, the name tiebreak makes the fallback order deterministic), the headroom service ranks them (`rank_subscriptions` over `cached_headroom_readings`, one `MGET`, never a probe — a candidate the filter dropped is never even read), and the pick returns with a `why`. Runs under the per-agent switch lock via `asyncio.to_thread` (both reads are blocking). **Fail-open on the ranking half only, and LOUDLY**: Redis down, or a lazy import that resolved to the wrong module (`importlib.import_module` answers from `sys.modules`, not from a package attribute a previous test left behind — learnings 2026-08-12), degrades to the db's order with a WARNING; an all-unknown set with ambient refresh OFF logs that the ranker is inert. The only case that now yields no target where it previously did is *every survivor is currently refused by the provider* (an approved deviation from the issue's literal AC #3, recorded on it). `_perform_auto_switch(..., destination_headroom=why)` surfaces the pick on the activity `details`, the notification `metadata` and the result, plus one clause in the notification text (on a two-subscription install the ranking cannot change the pick, so the explanation IS the value). `subscription_service.select_subscription_for_new_agent()` (#74 new-agent auto-assign) rides the same ranker — `db.list_assignable_subscriptions()` → rank → first candidate whose token decrypts (#340), one decrypt in the common case; `database` resolved at call time for the creation harnesses. `get_least_used_subscription` / the db `select_best_alternative_subscription` are gone. See [subscription-auto-switch.md](../feature-flows/subscription-auto-switch.md) **#2638 adds three things the reactive switch could not do.** (1) The 2h skip-list is OVERRIDABLE per candidate: `_readmit_recovered` reads the filter's complement (`db.list_recently_failed_alternatives`) and readmits one only on positive evidence — `subscription_headroom_service.recovery_verdict` = a FRESH not-refusing reading (`serving_now`), or a blocked window's own reset instant ELAPSED **and predating** the failure (`window_reset`; without that ordering a subscription that 429'd after its rollover is readmitted on a reset it already consumed). Absence of evidence readmits nothing, so #444's ping-pong — caused by FORGETTING a failure — stays closed, and the fail-open ranking branch readmits nobody by construction because it is precisely where the evidence could not be read. A `window_reset` candidate is handed to the ranker as UNKNOWN: its `blocked` flag describes the window that just rolled over, and `rank_subscriptions` would drop it as `refused` — the readmission would be inert in exactly the case it exists for. (2) `ensure_serviceable_subscription` switches BEFORE the first dispatch when the assigned subscription is already known-refused (fresh provider refusal, or a 429 in the 2h window via the 429-only DISPLAY predicate — an auth failure is a credential problem another subscription may share). It never raises, records NO failure event (nothing failed; a synthetic one would poison the skip-list it feeds), dispatches anyway when there is no alternative, and performs the SAME `_perform_auto_switch` so one activity/notification/hot-reload happens whichever path fired — `pre_dispatch=True` changes only the wording. (3) `fallback_to_api_key` is the last resort when no subscription can serve: clear the assignment, set `use_platform_api_key`, RESTART (the reload endpoint pushes an OAuth token; this needs the opposite change, which `lifecycle`'s auth block already derives from DB state). Setting `subscription_api_key_fallback` (default ON, fail-OPEN on a read error) with `key_configured` on the GET, because a toggle reading only "on" with no key stored describes a remedy that cannot run. `earliest_known_reset` feeds the portal's honest "quota resets at …" refusal. **#2572 adds a FOURTH caller of `agent_switch_lock` / `_restart_agent`** — `subscription_service`'s credential-less adoption sweep — and changes nothing in this module: SUB-003 stays failure-driven and its precondition 2 (*the agent already has a subscription*) is deliberately NOT relaxed, because that precondition is where the #2572 gap was written down, not a defect. The sweep also deliberately does not become a fourth `_hot_reload_subscription_token` producer: credential-less → subscription is an auth-MODE change, and that helper's docstring invariant is that all its producers are sub→sub by construction. +- `subscription_auto_switch.py` - SUB-003 auto-switch orchestration (`handle_subscription_failure` → `_perform_auto_switch`, hot-reload #1089). **Since #2409 it owns alternative SELECTION** — it used to be a first-match pick inside the db layer (`ORDER BY agent_count ASC`, first survivor of the 2h skip-list), which read no headroom, moved agents onto subscriptions at 99% of their weekly window, and sorted an *unused dead-token* subscription FIRST (no agents ⇒ no failure rows). `select_best_alternative_subscription(current)` = filter → rank → first: the db lists the survivors (`list_viable_alternative_subscriptions`, kind-blind #444/#2352, `agent_count ASC, name ASC` — filter only, the name tiebreak makes the fallback order deterministic), the headroom service ranks them (`rank_subscriptions` over `cached_headroom_readings`, one `MGET`, never a probe — a candidate the filter dropped is never even read), and the pick returns with a `why`. Runs under the per-agent switch lock via `asyncio.to_thread` (both reads are blocking). **Fail-open on the ranking half only, and LOUDLY**: Redis down, or a lazy import that resolved to the wrong module (`importlib.import_module` answers from `sys.modules`, not from a package attribute a previous test left behind — learnings 2026-08-12), degrades to the db's order with a WARNING; an all-unknown set with ambient refresh OFF logs that the ranker is inert. The only case that now yields no target where it previously did is *every survivor is currently refused by the provider* (an approved deviation from the issue's literal AC #3, recorded on it). `_perform_auto_switch(..., destination_headroom=why)` surfaces the pick on the activity `details`, the notification `metadata` and the result, plus one clause in the notification text (on a two-subscription install the ranking cannot change the pick, so the explanation IS the value). `subscription_service.select_subscription_for_new_agent()` (#74 new-agent auto-assign) rides the same ranker — `db.list_assignable_subscriptions()` → rank → first candidate whose token decrypts (#340), one decrypt in the common case; `database` resolved at call time for the creation harnesses. `get_least_used_subscription` / the db `select_best_alternative_subscription` are gone. See [subscription-auto-switch.md](../feature-flows/subscription-auto-switch.md) - `ssh_service.py` - Ephemeral SSH credential generation -- `email_service.py` - Email sending for verification codes. Provider, Resend key and sender resolve per send through `settings_service` (ent#582), not the import-frozen config values -- `platform_keys_service.py` - Format rules + live checks for the keys the first-run flow configures (ent#582): the Anthropic `sk-ant-oat`-in-the-API-key-tab refusal, Resend (`GET /domains` — key valid AND sender domain verified; a sending-only key passes with a warning), Gemini (model list, key in a header). Messages are operator copy, twinned by `frontend/…/onboarding/steps/credentialSteps.js` +- `email_service.py` - Email sending for verification codes *Git & GitHub:* - `git_service.py` - Git sync operations for GitHub-native agents; persistent-state allowlist primitive (S4, #383); `rebind_origin_and_push` + `inspect_container_git` — the in-container half of the ent#109 repo binding (push committed history by explicit URL, THEN repoint `origin`, so a push failure leaves the agent untouched; reads `origin` back to prove the rewire took) @@ -193,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. **The provider-error branch says why (#2696):** `classify_stt_failure(status, body)` maps every non-200 onto a named category — `permission` / `auth` / `quota` (401/402/403 by the provider's status word) → 503 with an operator-actionable sentence, `rate_limit` (429) → 429 with the existing retry wording, `audio` (400/413/415/422) → 422 "the recording could not be read", `provider` (5xx) → 502, anything else `unknown` → 502 — no arm returns the old opaque "Could not transcribe the audio", and a test sweeps 300–599 to pin that. The client sentence never carries the provider body; `record_live_failure` keeps the status word + category under `stt:last_failure:` (24h) for the admin panel only. `GET`/`PUT /api/settings/elevenlabs` carry `stt_capability` + `stt_detail` + `stt_checked_at` + `stt_last_failure` 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) @@ -236,7 +235,7 @@ Channel DB modules: `db/slack_channels.py` (workspace connections, channel-agent ### Opt-in Instance Telemetry (ent#437) -The second cut of the Tier-2 sharing channel (ent#12 — `services/telemetry_sharing_service.py`, requirements §45.1/§45.2). Four things changed, and each closes a specific hole. **The ask is reachable and snooze-first**: the wizard ask only renders on a zero-agent install (seeding made that permanently false) and #2385 removed the welcome form on every pre-provisioned-admin install, so consent now lives in the first-run overlay's `sharing` step (`components/onboarding/FirstRunOverlay.vue` over the `firstRunSteps.js` registry, ent#581; admin + `profileVerified`-gated, with the #2381 sign-in-email nudge as the sibling `email` step). The overlay's Skip is a per-browser skip (a pre-ent#581 14-day snooze is honoured while it lasts); "Don't ask again" and consent write the server marker `telemetry_sharing_dismissed_at`; the **warm** copy is chosen once per browser after the install's first successful autonomous run, derived on read and memoised (`telemetry_sharing_first_value_at`) rather than hooked into the dispatch hot path (#2314 in progress). The step gates on four booleans from `feature-flags` and calls the admin status route only when it will render; the preview loads on expand (`?preview=0`). **The share identity is not the install identity**: `installation_id` rides with the operator's email and company in the ent#38 intake POST, so the aggregate carries a separate `sharing_id` — minted with `insert_setting_if_absent` (atomic across workers) on the off→on transition, deleted on revoke, re-minted on re-consent; the validator bans `installation_id` outright, the id is logged as an 8-char prefix, and `instance.trinity_version` is the release version (`utils/app_version.py`), never a commit SHA that adoption timing could re-join to other streams. **The schema is enforced** (`PAYLOAD_SCHEMA_V2`, telemetry-owned wire enums for trigger buckets and statuses, funnel steps derived from `_FUNNEL_STEPS`): `share_now` refuses to send on a violation — fail-closed egress — and the last 5 attempts, successes and failures, are kept in `telemetry_sharing_recent_sends` and rendered under Recent sends — each entry and the last-shared stamp recording the origin they went to, and the receiver sentence decided from that record rather than from the URL configured at read time (#2571) — with a 404 recorded against the default origin worded as a 404 at the default address (the ent#190 receiver has been live since 2026-09-04; the entitlement-gated benchmark view's read of it is the private module's one outbound call, under the same two gates as the heartbeat). **Delivery survives a missing receiver**: the consent-time backfill is retried at every due wake until the first 2xx (`telemetry_sharing_backfill_delivered_at`), windows are cumulative from `last_shared_at` in whole days, and a Redis tick marker (`telemetry_share:tick`, TTL half the interval — the send cadence, never the wake — a fresh lock per claim, released only when the receiver did not acknowledge) makes one worker send per interval. **The cadence is anchored on the persisted stamp, not on process age (#2618)**: the loop wakes every 10 minutes (+ ≤10 min jitter, sleep-first) and sends when `last_shared_at` is empty, unparseable, in the future, or older than the interval, so a restart never resets it — before, the loop slept the whole interval from process start and a daily-restarting install shared once and never again. `share_now` reports an acknowledged send as True even if the local stamp write fails (with the stamp unwritable, the marker is what stops a sibling re-sending an accepted snapshot); after five consecutive failures attempts fall to one per half-interval, measured from the persisted send log; the heartbeat is stopped in lifespan shutdown so a cancellation mid-send releases the marker. The payload adds an **outcome mix** (`by_trigger` projected from `db.get_fleet_execution_timeline` + `shape_execution_timeline`, cost dropped; `by_status` from the new `db.count_terminal_executions_by_status`; provider `rate_limit`/`auth` counts, whose reader has an unconditional cutoff and so gets an explicit wide window for an all-time backfill) and the #2380 install lane. Every reader is fenced and coerced (a stubbed `db` or `services.*` degrades a field, never the payload) and the builder runs off the event loop. Generic `DELETE /api/settings/{key}` stays open for the `telemetry_sharing_` prefix by design: it is the reset path and every deletion moves toward off / ask again / re-mint. OSS-core by decision, consistent with §45.1. +The second cut of the Tier-2 sharing channel (ent#12 — `services/telemetry_sharing_service.py`, requirements §45.1/§45.2). Four things changed, and each closes a specific hole. **The ask is reachable and snooze-first**: the wizard ask only renders on a zero-agent install (seeding made that permanently false) and #2385 removed the welcome form on every pre-provisioned-admin install, so consent now lives in the post-login **Finish setup** card (`components/onboarding/FinishSetupCard.vue`, admin + `profileVerified`-gated — one chassis with the #2381 sign-in-email nudge as section 1, usage sharing as section 2). "Not now" is a 14-day per-browser snooze; "Don't ask again" and consent write the server marker `telemetry_sharing_dismissed_at`; a **warm re-ask** returns once per browser after the install's first successful autonomous run, derived on read and memoised (`telemetry_sharing_first_value_at`) rather than hooked into the dispatch hot path (#2314 in progress). The card gates on four booleans from `feature-flags` and calls the admin status route only when it will render; the preview loads on expand (`?preview=0`). **The share identity is not the install identity**: `installation_id` rides with the operator's email and company in the ent#38 intake POST, so the aggregate carries a separate `sharing_id` — minted with `insert_setting_if_absent` (atomic across workers) on the off→on transition, deleted on revoke, re-minted on re-consent; the validator bans `installation_id` outright, the id is logged as an 8-char prefix, and `instance.trinity_version` is the release version (`utils/app_version.py`), never a commit SHA that adoption timing could re-join to other streams. **The schema is enforced** (`PAYLOAD_SCHEMA_V2`, telemetry-owned wire enums for trigger buckets and statuses, funnel steps derived from `_FUNNEL_STEPS`): `share_now` refuses to send on a violation — fail-closed egress — and the last 5 attempts, successes and failures, are kept in `telemetry_sharing_recent_sends` and rendered under Recent sends, with a 404 from the default URL worded as a 404 at the default address (the ent#190 receiver has been live since 2026-09-04; the entitlement-gated benchmark view's read of it is the private module's one outbound call, under the same two gates as the heartbeat). **Delivery survives a missing receiver**: the consent-time backfill is retried at every due wake until the first 2xx (`telemetry_sharing_backfill_delivered_at`), windows are cumulative from `last_shared_at` in whole days, and a Redis tick marker (`telemetry_share:tick`, TTL half the interval — the send cadence, never the wake — a fresh lock per claim, released only when the receiver did not acknowledge) makes one worker send per interval. **The cadence is anchored on the persisted stamp, not on process age (#2618)**: the loop wakes every 10 minutes (+ ≤10 min jitter, sleep-first) and sends when `last_shared_at` is empty, unparseable, in the future, or older than the interval, so a restart never resets it — before, the loop slept the whole interval from process start and a daily-restarting install shared once and never again. `share_now` reports an acknowledged send as True even if the local stamp write fails (with the stamp unwritable, the marker is what stops a sibling re-sending an accepted snapshot); after five consecutive failures attempts fall to one per half-interval, measured from the persisted send log; the heartbeat is stopped in lifespan shutdown so a cancellation mid-send releases the marker. The payload adds an **outcome mix** (`by_trigger` projected from `db.get_fleet_execution_timeline` + `shape_execution_timeline`, cost dropped; `by_status` from the new `db.count_terminal_executions_by_status`; provider `rate_limit`/`auth` counts, whose reader has an unconditional cutoff and so gets an explicit wide window for an all-time backfill) and the #2380 install lane. Every reader is fenced and coerced (a stubbed `db` or `services.*` degrades a field, never the payload) and the builder runs off the event loop. Generic `DELETE /api/settings/{key}` stays open for the `telemetry_sharing_` prefix by design: it is the reset path and every deletion moves toward off / ask again / re-mint. OSS-core by decision, consistent with §45.1. ### Vector Log Aggregator (`config/vector.yaml`) diff --git a/docs/memory/architecture/database.md b/docs/memory/architecture/database.md index d029c9ce8..248e2a616 100644 --- a/docs/memory/architecture/database.md +++ b/docs/memory/architecture/database.md @@ -688,7 +688,7 @@ CREATE TABLE agent_sync_state ( behind_main INTEGER DEFAULT 0, ahead_working INTEGER DEFAULT 0, -- #389 P6: working-branch divergence behind_working INTEGER DEFAULT 0, - git_dir_bytes INTEGER, -- #1596: agent .git on-disk size (bloat curve) + git_dir_bytes BIGINT, -- #1596: agent .git on-disk size (bloat curve); BIGINT since #2800 (int4 on PG overflowed at 2 GiB) pack_count INTEGER, -- #1595: packs from `git count-objects -v` loose_objects INTEGER, -- #1595: loose objects (gc-health signal) maintenance_failures INTEGER DEFAULT 0, -- #1595: consecutive failed maintenance attempts diff --git a/docs/memory/architecture/workspace.md b/docs/memory/architecture/workspace.md index 8179ff54e..9fe118921 100644 --- a/docs/memory/architecture/workspace.md +++ b/docs/memory/architecture/workspace.md @@ -39,6 +39,26 @@ session that *expires* on a browser which later gained a platform login, and the token's server-side validity post-sign-out (no self-service revoke; ent#281's primitive is per-email). +**One platform credential, one 401 verdict, one handler (#2791).** The paragraph above +describes *which session a tab is in*; this is the layer under it. The platform JWT used +to live in two places that could disagree — the in-memory +`axios.defaults.headers.common['Authorization']` copy and `localStorage['token']` re-read +per request — with **no** `storage` listener anywhere under `src/frontend/src` and three +separate 401 handlers. Because the Workspace opens in its own tab (ent#456) and polls +every 20s, a stale Workspace tab could log a freshly re-established platform session out +within seconds: its poll went out on the OLD token, 401'd, and the handler called +`authStore.logout()`, deleting the NEW session's token. `utils/platformSession.js` is now +the only reader (`readStoredToken`), the only verdict (`sessionLostVerdict` → +`ignore | stale | logout`, where **stale** means *the credential that failed has already +been replaced, so adopt the current session rather than destroy it*) and the only handler +registry; `main.js` installs a global axios **request** interceptor so every bare-`axios` +caller derives the header per request, and a `storage` listener so a login or logout in +one tab reaches every other. The `axios.defaults` copy is written nowhere (only cleared on +logout, for tabs still running a pre-fix build); the logout revoke carries its token +**explicitly**, because #2258's clear-before-revoke ordering means storage is already +empty by then. Full model and the verdict table: +[workspace-session-signout.md](../feature-flows/workspace-session-signout.md). + **Membership is a DB fact; container state is a projection onto the card (#2196).** The roster is built from `agent_ownership` / `agent_sharing` and is **never** filtered by whether a container exists. A live ownership row with no container is a routine state diff --git a/docs/memory/feature-flows/git-sync-health.md b/docs/memory/feature-flows/git-sync-health.md index 4905b95c8..836a19ade 100644 --- a/docs/memory/feature-flows/git-sync-health.md +++ b/docs/memory/feature-flows/git-sync-health.md @@ -69,7 +69,7 @@ ahead_main INTEGER DEFAULT 0 behind_main INTEGER DEFAULT 0 ahead_working INTEGER DEFAULT 0 behind_working INTEGER DEFAULT 0 -git_dir_bytes INTEGER -- #1596: .git on-disk size +git_dir_bytes BIGINT -- #1596: .git on-disk size (BIGINT since #2800: int4 on PG overflowed at 2 GiB) pack_count INTEGER -- #1595: packs (count-objects -v) loose_objects INTEGER -- #1595: loose objects maintenance_failures INTEGER DEFAULT 0 -- #1595: failed maintenance streak diff --git a/docs/memory/feature-flows/workspace-agents-at-the-centre.md b/docs/memory/feature-flows/workspace-agents-at-the-centre.md index a28736dff..6f32d2cf2 100644 --- a/docs/memory/feature-flows/workspace-agents-at-the-centre.md +++ b/docs/memory/feature-flows/workspace-agents-at-the-centre.md @@ -245,7 +245,8 @@ pending SET rather than a scalar. |---|---| | `PortalConversation.vue` | the agent's inbox | | `PortalRoom.vue` | **every participating agent's** inbox; the chip names the recipients (operator decision 13) | -| `PortalRailFiles.vue` | the target agent's inbox | +| `PortalRailFiles.vue` | the chosen target — in a room, **every participating agent** by default (#2794) | +| `Portal.vue::onEscalateToRoom` (#2794) | the **new** participants' inboxes — see below | - the whole conversation is the target, with an affordance naming what will happen; `isFileDrag` keeps a dragged link or text selection from lighting it @@ -265,6 +266,137 @@ pending SET rather than a scalar. ships against the existing upload path, and when they land the gesture does not change — only the caller's `upload`. +### The file follows an escalation (#2794) + +A 1:1 uploads each file to the CURRENT agent as it is attached, so @mentioning +a second agent used to move the conversation to a room and leave the file +behind — the person had watched a chip confirm the upload and believed both +agents had it. Only the original one ever did, and the room showed no trace of +a file at all. + +The rule is the issue's own: *whatever a user could do inside a room, +escalating into one must produce the same result.* So the escalation owes the +room's fan-out to exactly the participants that do not already hold the file. + +- the composable **keeps the `File` handle** on each entry (`markRaw`, so a + proxy can never reach `FormData.append`) — the same bytes reach a second + destination without asking the person to pick the file again; +- it exposes **`settled()`**, and `send()` awaits it before escalating: an + upload still in flight is waited for, never silently left behind. Two + overlapping drops now **chain** rather than run beside each other — that is + the same "sequential, or the per-email limiter trips" rule one level up, and + without it `settled()` could resolve while an earlier batch was still going; +- `send()` does **not** clear the chips. On success the component unmounts as + the room opens; on failure the shell hands the text back and the chips are + still standing beside it — the recovery path with no extra plumbing. It does + guard re-entry while it waits, because the composer is emptied *before* the + await and a second Enter in that window would emit a second escalation for + `Portal.vue`'s `escalating` flag to drop on the floor; +- the fan-out runs **before** the message is posted. The message is what wakes + the mentioned agent, and a turn that starts before the file is in its inbox + cannot see the thing it was asked about. The order is the feature; +- the origin agent is excluded **by name, not by position**: the shell builds + `agents` as `[origin, ...mentioned]`, and a plan trusting that order would + double-send the day it changes; +- the room then **says what happened** — what arrived and for whom, a file that + missed a participant named per file *and* per agent, and a file that never + finished uploading in the 1:1 named too. The notice is retired by the next + message in the room, because by then it describes history. + +Decidable rules live in `components/portal/portalAttachments.js`; the SFCs are +dispatchers over it (`vitest.config.js` pins `environment: 'node'` with no +mount harness, so a rule inside an SFC is one no test can reach). + +**Two adjacent defects fixed with it.** The room composer had shipped as +`
` chained to the "this conversation has ended" line (ent#358) — +the right rule — but `v-else` binds to the immediately preceding *element*, and +the batch notice, the chips (ent#524) and the budget banner (#2620) were each +inserted in between, so the chain ended on `attachments.length`: **attaching a +file to a room replaced the composer**, and a **closed room rendered a live +one**. `roomComposerChain.spec.js` had by then pinned the broken state as the +contract. The composer now states its own condition (`v-if="!isClosed"`), the +spec pins the outcome instead, and the room clears its chips after a successful +send the way the 1:1 always has. + +### A file reaches every agent in the room, and every agent is told (#2794) + +Testing the escalation above against a live instance turned up the rest of the +path, and it was worse than the original report. In a room holding +`analyst-demo` and `sidekick`, a client sent a screenshot and asked *"@sidekick +what is displayed on the pasted image?"*. sidekick answered *"I don't see any +image attached to your message."* — truthfully. Three independent gaps, each +invisible on its own, and each of which alone is enough to produce that reply. + +**1. The rail aimed at one agent.** `PortalRailFiles`' `Send to` select defaulted +to `participants[0]` while `PortalRoom`'s own drop zone fanned out to all of +them. Two surfaces in the same chat, two meanings for *send a file here*, and the +one with the visible control was the wrong one — so the file reached the agent +that was not being asked about it. A room now defaults to **everyone in it**, with +the individual agents still selectable underneath. + +The rules are in `portalFiles.js` (`uploadTargets`, `defaultUploadTarget`, +`resolveRecipients`, `uploadTargetLabel`, `uploadReceipt`), not in the SFC, for +the reason this document keeps giving: `environment: 'node'`, no mount harness, +so a rule in a `.vue` file is a rule no test can reach. Two of them encode a +direction rather than a value: + +- `resolveRecipients` fails **toward the fan-out** — a target that has left the + room resolves to everyone, because a file sent to one agent too many is + recoverable from the rail's own delete and a file sent to nobody is the silent + loss this issue is about; +- a file counts as sent only when it reached **every** recipient. A partial + delivery is a failure line naming the agents it missed — counting it as a + success would rebuild the reported bug inside its own fix, since *"Sent + shot.png to analyst-demo and sidekick"* while sidekick got nothing is exactly + the reassurance that made the gap invisible the first time. + +**2. Pasting did nothing.** There was no paste handler on either composer, so the +most common way anyone attaches a screenshot was inert *and silent*. The reported +session shows what that costs: the client's file was called +`Pasted image (3).png`, i.e. they had already been driven out to a file manager. +`usePortalFileDrop` now exposes `onPaste`, bound on both composers, feeding the +same `addFiles` batch as a drop — a second path in, never a second +implementation. It suppresses the default **only** when the clipboard carries no +`text/plain`, so pasting out of a rich editor still types the text it came with. + +**3. No agent was ever told — the core of it.** A room turn was built from +`_build_turn_prompt`, which is a header plus the transcript, and from nothing +else. The sentence that makes a file visible to an agent, and the vision blocks +that make *"what is in this picture"* answerable at all, were written **inline in +`portal_chat`** — so the 1:1 conversation was the only surface in the product +that had them. Delivery had never been the problem; the *telling* did not exist. + +That composition now lives in one place, +`client_portal/service.py::collect_inbox_context`, returning +`(manifest_prefix, images)`; `portal_chat` and +`shared_sessions/service.py::_wake_agent` both call it. The room prepends the +prefix to the turn prompt and passes `images=` to `execute_task`. Three +decisions, none obvious from the diff: + +- **the manifest is a PREFIX.** An agent that meets *"what is in the image?"* + before it has been told an image exists is the agent that answers "I don't see + any image attached"; +- **whose inbox** — the posting principal's, because a portal inbox is keyed by + the client's email and in a Workspace room that principal is the person who put + the file there. *Residual:* a room with two humans surfaces only the email of + whoever's message triggered this wake. Reading every human's inbox costs one + `docker exec` per human per wake, and the shape rooms actually have is one + person and N agents; +- **the image-intent test reads the whole delta, agent lines included.** + *"@sidekick can you look at the screenshot the client sent?"* is an ordinary + room move, and scoping the test to human text would make exactly that relay + arrive image-less — this bug, one hop along. + +Fail-safe throughout: no client email, an unreadable inbox or a raising collector +each yield `("", [])` and the turn runs unchanged. `images` is `None` rather than +`[]` when there is nothing, so a room without files is a byte-for-byte no-op. + +The one-composer property is the one worth guarding, because the failure being +fixed *is* a surface that quietly composes nothing: +`test_2794_room_file_awareness.py` counts the manifest sentence across the whole +backend and fails if it appears anywhere but `client_portal/service.py` — so a +third surface inventing its own is caught, not just a second one. + --- ## An answered ask says whether work started (ent#468) diff --git a/docs/memory/feature-flows/workspace-session-signout.md b/docs/memory/feature-flows/workspace-session-signout.md index bf2d2ec0a..ff5632ec8 100644 --- a/docs/memory/feature-flows/workspace-session-signout.md +++ b/docs/memory/feature-flows/workspace-session-signout.md @@ -163,6 +163,80 @@ form simply reappeared, indistinguishable from "you were never signed in". **Degradation:** if `sessionStorage` is unavailable (private mode), the marker reads as absent — pre-#2261 behaviour, rather than a workspace nobody can enter. +## One credential, one verdict, one handler (#2791) + +Everything above is about *which session a tab is in*. #2791 is the layer under +it: **where the credential lives, and who is allowed to end it.** + +One browser used to hold the platform JWT in two places that could disagree — +the in-memory `axios.defaults.headers.common['Authorization']` written once at +login by `auth.js::setupAxiosAuth`, and `localStorage['token']` re-read per +request by `api.js` — with no cross-tab listener anywhere under +`src/frontend/src`, and three separate 401 handlers. The Workspace made it bite +hardest because it opens in its own tab (ent#456) and polls every 20s. + +**The reported symptom.** Log out and log back in on the main app with a +Workspace tab open from the previous session. That tab still holds the OLD JWT; +its next poll 401s; the handler calls `authStore.logout()`, which removes +`localStorage['token']` — *the token the re-login had just written*. The main +tab's next request finds nothing and hard-redirects to `/login`. A stale tab +killed a fresh session, and the handler never asked whether the credential that +failed was still the current one. + +### The three things that are now singular + +**One source.** `utils/platformSession.js::readStoredToken()` is the only reader. +The `axios.defaults` copy is gone (`setupAxiosAuth` is a documented no-op), and +`main.js` installs a global axios **request** interceptor that rebuilds the +header from storage on every request — so all ~368 bare-`axios` call sites get +the current credential without being rewritten, and one added tomorrow cannot +forget to opt in. An **explicit** header on the config still wins, and exactly +one caller needs that: the logout revoke, which must carry a token storage has +already dropped (the #2258 ordering above is unchanged, so the token is captured +*before* the clear and passed *after* it — otherwise #187 silently stopped +revoking anything). + +**One verdict.** `sessionLostVerdict()` is a pure function returning +`ignore | stale | logout`, and it replaced a predicate that had been hand-copied +into `api.js`, `main.js` and `portalHttp` and drifted three ways: + +| situation | verdict | +|---|---| +| already on `/login`, `/setup`, `/m` | `ignore` | +| the failed token is **not** the stored one | `stale` — adopt the current session, never destroy it | +| no stored token, on the Workspace | `ignore` (an ordinary external client) | +| no stored token, anywhere else | `logout` | +| on the Workspace **and** a portal token is live | `ignore` — **AC #5**: a client whose browser holds a dead operator JWT is no longer thrown onto the operator login by `initializeAuth`'s `fetchUserProfile` | +| otherwise | `logout` | + +The `stale` arm is the fix for the reported symptom. The Workspace veto is scoped +by path *as well as* by portal token deliberately: off the Workspace the surface +is an operator one, so an expired operator JWT still bounces there even with a +stray portal token — this change does not widen that. + +**One handler.** `setPlatformUnauthorizedHandler` / `notifyPlatformUnauthorized` +in `utils/platformSession.js`. `main.js` registers the reaction (it is the only +module that already has both the router and the store); +`api.js`, the global interceptor and `portalHttp` all report to it. +`clientPortal.js` keeps `isPlatformSession` as its local gate — not redundant, +because it is the only thing that knows this tab's client session was +*suppressed* (#2261's `platformFallbackSuppressed`), which no amount of reading +localStorage reconstructs. + +### Cross-tab sync + +`main.js` listens for `storage` on the platform token key. A sibling tab logging +in → `adoptStoredSession()` (converge, re-fetch the profile, reset +`profileVerified` so role-gated UI stays closed until *this* token's profile +lands). A sibling logging out → `applySessionEndedElsewhere()`, which drops the +in-memory mirror only: it fires no second server revoke for an already-revoked +token, and writes nothing to storage, because N background tabs reacting to one +event would otherwise each clear it again. + +Neither branch navigates. A background tab pushing `/login` is the noise this +issue reports; the visible tab converges through the router guard and its next +request, both of which read the state these set. + ## Stated residuals (not hidden) - ~~**Client-session expiry with a later platform login** still falls back to the @@ -182,8 +256,12 @@ as absent — pre-#2261 behaviour, rather than a workspace nobody can enter. ## Files - `src/frontend/src/stores/clientPortal.js` — `signOutEverywhere()`, `PLATFORM_LOGIN_ROUTE` -- `src/frontend/src/stores/auth.js` — `logout()` local-clear-before-revoke ordering +- `src/frontend/src/utils/platformSession.js` — #2791: the one reader, the one verdict, the one handler registry +- `src/frontend/src/stores/auth.js` — `logout()` local-clear-before-revoke ordering; `adoptStoredSession` / `applySessionEndedElsewhere` +- `src/frontend/src/main.js` — global request interceptor, the registered reaction, the `storage` listener +- `src/frontend/src/api.js` — reports to the shared handler (no private predicate, no hard reload) - `src/frontend/src/views/Portal.vue` — `onSignOut`, `signingOut` frame - `src/frontend/src/components/portal/PortalSidebar.vue` — footer button + caption - `src/frontend/src/components/portal/portalUtils.js` — `signOutLabelFor` -- `src/frontend/tests/unit/workspaceSession.spec.js`, `workspaceSignOut.spec.js` +- `src/frontend/tests/unit/workspaceSession.spec.js`, `workspaceSignOut.spec.js`, + `platformSessionVerdict.spec.js`, `platformSessionSync.spec.js` diff --git a/docs/memory/learnings.md b/docs/memory/learnings.md index 3d359340c..cc7a1a5eb 100644 --- a/docs/memory/learnings.md +++ b/docs/memory/learnings.md @@ -815,6 +815,9 @@ plan or review. `/autoplan` reads this before planning; write for that reader. **Context**: trinity#2694 review (the 4.14 "defence on one of two branches" class, again). The plan added a 409 in `start_workspace_voice` so a voice call cannot start while a typed reply is in flight, and documented it as "no reply lands mid-call". The turn side had no counterpart: the owning tab's composer is inert during a call, but a second tab and the headless `/chat` surface are not, so a typed reply could still land between two spoken rows and sit after the cursor the next turn uses to find what the live session never heard. Caught by the independent structural review, not by tests — the tests pinned the gate that existed. **Lesson**: "A cannot happen while B" is two gates (A refuses over B, B refuses over A) plus a marker each side can read; when only one is built, say so in the doc ("A cannot *start* over B") rather than stating the invariant. A client-side lock (an inert composer) is never the second gate — it covers one tab. +## 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. ## 2026-09-11 — pitfall — A write path with a remove-on-unassign but no deliver-on-assign is asymmetric by construction; and "inject a subset" is unsafe whenever the injector renders a whole-set artifact **Context**: #2703. ent#236 made unassign REMOVE the package from the agent; assign still wrote a row and stopped, so a skill assigned from the Library (ent#386, no Sync button) reached the agent only on a manual Sync or the next start, and no open surface refreshed either way. The first plan fixed it with `inject_skills(agent, [name], force=True)` — and the strategy review found that `_inject_skills_locked` rewrites CLAUDE.md's `## Platform Skills` section to exactly the names of THIS run, so the subset call would have left a ten-skill agent advertising one. The prune was per-skill (verified); the section render was not (missed). **Lesson**: (1) when one side of a write path gains a side effect (removal), audit the other side for the mirror at the same time — an asymmetric pair reads as "works" on every test that drives one side. (2) Before calling an existing bulk operation with a subset, check EVERY artifact it renders, not just the one you verified: an operation that is idempotent per item can still be whole-set for a derived file. The contract-preserving fix was to run the same call the start path runs (all assigned, `force=False`) and project the report onto the requested names. (3) A request-path call into a bounded-only-by-restore-timeout operation needs a budget that KEEPS the work (`asyncio.wait` + a strong-ref task, answer `in_progress`), not `wait_for` (which cancels it) and not an unbounded await (the client's 30 s default turns a committed row into "save failed"). (4) A re-fetch of a hydrated card must be stale-while-revalidate — flipping its state to `pending` re-enters the loading skeleton on a zone that has data. @@ -867,3 +870,17 @@ plan or review. `/autoplan` reads this before planning; write for that reader. ## 2026-09-14 — pitfall — Three endpoints patched one at a time is the signal that the GATE is wrong; a header validated only for the principals who cannot forge it is validated nowhere **Context**: abilityai/trinity-enterprise#614. `X-Source-Agent` decides `triggered_by='agent'`, the `AGENT_COLLABORATION` activity and WebSocket edge, the execution row's `source_agent_name`, and the actor on three SEC-001 audit sites (`actor_user=current_user if not x_source_agent else None`). The only check on it — SELF-EXEC-001 in `derive_source_and_trigger` — was gated on `current_user.agent_name`, i.e. it fired **only for the agent-scoped keys that cannot lie anyway** and never for a human, and its single caller was `/task`, so `/chat` and `/fan-out` had no check at all. A `role=user` JWT with share access could therefore record its own action as performed by any agent it named, with the human's identity dropped and the hash chain certifying the row, and could forge collaboration activity on an agent it could not access. This is the **third** occurrence of the same class in this ledger: #1672 gated a resume IDOR on `not x_source_agent` (a regular user set the header and skipped it), ent#265 chose between two provenance arms on the header's presence (a human satisfied the agent arm by naming a value the row itself discloses). Both were closed at their own call site; the header stayed trusted everywhere else. Two more things only the sweep found: the MCP schedules tool sent the header for **system**-scoped keys too (whose backend principal is not agent-scoped), and the EVT-001 event loopback sent it under an admin JWT — and `emit_event` writes a JWT caller's *username* into `agent_events.source_agent`, so vouching that dispatch would have certified a username as an agent. **Lesson**: (1) **Count the occurrences before choosing the fix.** One is a bug at a call site; three is a wrong gate. The unit of repair for the third is the shared helper every reader must route through (here `dependencies.resolve_source_agent`, the #1310 family) plus a guard that fails a reader added tomorrow — the same escalation Invariant #8 made for admin gates after five agent-key incidents. (2) **A conditional validation classifies nothing.** `if header and current_user.agent_name` reads like a check but is inert for exactly the principal that can forge — state the rule as an ALLOWLIST over identity ("honoured only when the principal can prove it names itself") so a scope nobody has invented yet is refused by construction. (3) **Refuse, don't ignore.** Nulling an untrusted header is silent, and a warning nobody reads is how the next producer ships with attribution quietly dropped; a named 403 is what turns "my integration stopped working" into a one-line fix — it also flushed out a documented curl recipe in `AGENT_NETWORK_DEMO.md` teaching the exact forbidden shape. (4) **`validated or resolve(...)` is not a gate.** Preferring the validated key is right, but putting the resolve call behind an `or` short-circuits it away for the agent principal, silently re-ignoring a mismatched header on those routes; resolve unconditionally, then prefer. (5) **When a backend calls its own API, give it a principal, not a header.** The loopback now carries a `scope=event_loopback` claim in its SECRET_KEY-signed JWT — narrower than the `INTERNAL_API_SECRET` the scheduler and MCP server also hold — surfaced as `User.vouched_source_agent` and route-fenced to the one endpoint it exists for, so "the backend says so" is a property of the principal that every future guard can read, not a header the guard has to special-case. + +## 2026-09-15 — pitfall — `Integer` is int8 on SQLite and int4 on PostgreSQL; a column that exists to observe growth is the one that overflows, and the default backend cannot show it +**Context**: trinity#2800 (second occurrence after #2434's `duration_ms`). `agent_sync_state.git_dir_bytes` shipped as SQLAlchemy `Integer` / Alembic `INTEGER` — PostgreSQL int4, ceiling 2,147,483,647 — for a column whose whole purpose (#1596) is to record a `.git` that is getting *too big*. Any agent past 2 GiB made every `SyncHealthService` upsert raise `NumericValueOutOfRange`, so the bloat observability went dark at exactly the size it was built to report. It sat red for two months because SQLite's INTEGER is 64-bit (the unit test passed there), and the schema-parity PostgreSQL tier selects `-m requires_postgres` — the test had a `[postgres]` leg via `db_backend` but no marker, so CI never ran it. The service-layer guard (`_coerce_nonneg_int`, `< 2**63`) was already sized for int8; the column was the layer that had not caught up. +**Lesson**: (1) Any column storing **bytes, milliseconds, or a monotonic counter** is `BigInteger`/`BIGINT` from day one — on PostgreSQL `Integer` is a 2 GiB / 24.8-day ceiling, and the value a growth metric exists to record is by definition the one that crosses it. Audit the boundary guard beside the column: if the coercion allows `< 2**63` the column must too. (2) A `db_backend`-parametrized test is **not** a PostgreSQL gate until it carries `@pytest.mark.requires_postgres` — the `[postgres]` leg only runs where `TEST_POSTGRES_URL` is set, and CI sets it only for the marked selection. When the assertion can only fail on PostgreSQL, mark it, or it is a test nobody runs. (3) Widening a type on the dual track is **not** a SQLite no-op even though SQLite semantics are unchanged: `schema.py` is the single DDL source for both backends and `test_schema_parity` compares a fresh `init_schema` file against an upgraded one by *declared* column type, so a recorded no-op leaves upgraded files at `INTEGER` against a fresh `BIGINT` and turns that guard red forever. SQLite has no `ALTER COLUMN TYPE`; the honest SQLite half is a #1160 rename-swap rebuild — and a rebuild copies exactly the columns it names, so compare the live column set against the copy list and **raise** on an unknown column before touching anything, or the migration's failure mode is silent data loss. (4) Prove non-breaking on **both** tracks with real artifacts, not the harness: build a legacy SQLite file with the pre-fix code and boot the new `init_database()` over it twice (rows diffed byte-for-byte, index back, `integrity_check` ok); `pg_dump` a live instance into a disposable PostgreSQL and run the same boot path (`alembic_version` advances, `information_schema` reports `bigint`, sibling int4 columns untouched, a >2 GiB upsert lands). +## 2026-09-15 — pitfall — After a lost CAS the RETURNED status is not the one that stands, and a surface that reads only the return value reports a cancel as a fault +**Context**: trinity#2795. Wiring Stop onto a room's live tiles meant `shared_sessions.service._wake_agent` had to tell a user cancel from a failure. It read `result.status` from `execute_task` and branched — which is exact on a current agent image, because the agent relabels its own 504/502/500 to a `cancelled` 200 when its process registry says the turn was terminated (#679 F3). On an **older** image the agent re-raises instead: `execute_task` writes FAILED, that write **loses the CAS** to the CANCELLED the terminate route already wrote — and still returns FAILED to its caller. The room would then post "`` could not respond (no response)." for a stop the reader had just asked for, and drop the cached resume handle, making the next turn pay for a cold context rebuild on no evidence the handle was bad. The 1:1 surface is immune for a reason worth noticing: it never trusted the return value either, it remembers the cancel **client-side** (`cancelledExecutionIds`) and relabels. A room has no such memory, so the honest source is the row that actually stands. +**Lesson**: (1) **A CAS loser returns its own verdict, not the winner's.** Any caller that renders a terminal to a person must read the persisted row — or carry its own record of the action it took — whenever the returned status could have lost. Treat "the write lost the CAS" and "the returned status is authoritative" as mutually exclusive. (2) **Scope the re-read to the branch where it can change the answer.** The first draft fired on every terminal, adding a DB read to every successful room reply for a label that could not move; a test pinned the hot path at zero reads. (3) Make it **fail-open** — an unreadable row leaves the returned status in force, because a label lookup must never be able to break the turn it is labelling. (4) When one surface is immune to a class and its sibling is not, find out **why** before copying the sibling: here the difference was a client-side memory the new surface structurally cannot have, which is what said the fix belonged on the server rather than in the component. +## 2026-09-15 — pitfall — `v-else` is a promise about the neighbour above it, and a guard written after the neighbourhood already moved can pin the DEFECT as the contract +**Context**: trinity#2794. `PortalRoom.vue`'s composer shipped as `` chained to the "this conversation has ended" line (ent#358) — *render the composer unless the room is closed*, the right rule, and correct on the day it landed. `v-else` binds to the immediately preceding **element**, and three later changes each inserted a conditional in between: the batch notice and the attachment chips (ent#524), then the budget banner (#2620). By then the chain ended on `attachments.length`, which is two live defects in one expression — **attaching a file to a room replaced the composer** (and the room cleared no chips, so it never came back), and a **closed room rendered a live composer** directly under the line saying it had ended. Neither is visible in a diff: each insert is individually correct, the SFC compiles (a `v-else` after any `v-if` is valid), and the suite has no mount harness. What makes this worth a ledger entry is what happened next: #2620 hit the symptom (its own banner made the composer vanish), correctly diagnosed the *mechanism*, and then wrote `roomComposerChain.spec.js` to pin **the relationship it found** — `expect(prev).toContain('attachments.length')` — rather than the relationship the component needed. The guard was real, well-argued, AST-based rather than text-based, and it certified the bug for three months. The same class then recurred inside its own sibling fix: the first draft of #2795 inserted a stop-error line between two arms of the live-work `v-if`/`v-else-if` chain, silently repointing the "…is thinking…" fallback at `stopError`, and every test stayed green. +**Lesson**: (1) **Prefer a stated condition to an inherited one.** `v-if="!isClosed"` cannot be stolen by a neighbour; `v-else` can, and in a composer region that accumulates notices and banners it *will* be. Reach for `v-else` only where the two arms are genuinely one decision written once. (2) **A chain guard must pin the OUTCOME, not the adjacency it observes.** "The composer's own condition names the closed state" and "no composer form carries `v-else`" survive any future insert; "the element before the form is the chips block" hard-codes whatever was there the day it was written — and if that was already wrong, the guard makes it permanent. (3) When a spec is the thing failing after a fix, **ask which side the old assertion was on** before updating it (the #2733 rule, met here from the other direction: there the test stated the defect, here a *guard* did). (4) A `v-else-if` cascade is the same hazard with more arms — inserting anything into one repoints every arm below it, and the compiler is silent. Guard the cascade with an AST test that asserts each arm's predecessor, and **negative-test the guard** by reintroducing the defect, or it may be asserting something that is true for the wrong reason. + +## 2026-09-15 — pitfall — A capability composed inline in one caller is a capability every other surface silently lacks, and the missing one reports it as the agent's own ignorance +**Context**: trinity#2794, round two. A client in a Workspace room sent a screenshot and asked `@sidekick` what was on it; sidekick answered *"I don't see any image attached to your message."* Every part of the delivery worked — the file was in an inbox, the rail listed it, the transcript carried the question — because the thing that was missing was not delivery but **telling**. The sentence that makes a file visible to an agent, plus the vision blocks that make "what is in this picture" answerable at all, were ~25 lines written **inline inside `portal_chat`**, so the 1:1 conversation was the only surface in the product that had them; a room built its turn from `_build_turn_prompt` (a header plus the transcript) and nothing else. Isolating it is what made it undeniable: on the unfixed code, with the file placed in **sidekick's own inbox by hand**, it still said it could not see one — so the two visible gaps in the same path (a rail `Send to` that defaulted to `participants[0]` while the room's own drop zone fanned out, and a composer with no paste handler at all) were each *also* sufficient to produce the report, and fixing either alone would have left it standing. Three independent causes, one symptom, and the symptom names the agent rather than the platform — which is why it read as a model failure for weeks and was worked around by attaching CSVs. +**Lesson**: (1) **A capability that a second surface needs cannot live inline in the first one's caller.** Extract it the moment there is a second consumer, and guard it by COUNT (`the manifest sentence appears in exactly one file`) rather than by asserting about the two known callers — the failure being fixed is a surface that composes *nothing*, so the guard has to fail for a **third** one too, not just a second. (2) **When one symptom has several sufficient causes, prove each in isolation before claiming any of them is the fix.** Placing the artefact into the exact state the fix is supposed to produce, on the unfixed code, is the cheapest such proof and the only one that distinguishes "the plumbing was broken" from "nobody was told". (3) **Suspect a defect whose error message blames the model.** "I don't see any image" is a truthful report about an empty prompt, and it routes the operator to prompt-wrangling or to a workaround (here: "use CSVs") instead of to the missing prefix; treat a plausible in-character refusal as a platform bug report until the prompt has actually been read. (4) **Two surfaces that disagree about what one gesture means are one bug, not two features** — the room drop fanned out and the rail beside it did not, and the one with the visible control was the wrong one. (5) When a fan-out reports success, define success as reaching **every** recipient: counting a partial as sent recreates the original bug inside its own fix, because "Sent x to A and B" while B got nothing is exactly the reassurance that hid it. diff --git a/src/backend/client_portal/service.py b/src/backend/client_portal/service.py index b64e48460..6978ff268 100644 --- a/src/backend/client_portal/service.py +++ b/src/backend/client_portal/service.py @@ -687,6 +687,7 @@ 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, + stt_ready: bool | None = None, can_manage_canvases: bool = False) -> 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. @@ -702,6 +703,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"] @@ -743,10 +749,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#553 — threaded in like `availability`, never computed here: the # caller knows its own principal kind and this builder is shared with @@ -844,8 +853,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. @@ -913,6 +926,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() @@ -956,6 +974,7 @@ async def get_roster(email: str | None, include_owned: bool = False) -> PortalRo is_platform=include_owned, runtime=runtimes.get(r["agent_name"], _DEFAULT_RUNTIME), model_context=model_context, + stt_ready=stt_ready, # ent#553 — resolved through `may_manage_canvases`, the SAME # predicate the write routes enforce with, rather than a # faster per-row comparison against `r["owner"]`. That @@ -1515,14 +1534,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): @@ -1531,7 +1566,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", @@ -1555,7 +1590,17 @@ 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]) - raise ClientPortalError(422, "Could not transcribe the audio") + # #2696: say WHY. One category per provider condition — permission, + # rejected key, quota/plan, rate limit, bad audio, provider outage — + # each with its own client sentence and status, instead of one opaque + # 422 that made an operator read this log line to answer the question. + # The client sentence never carries the provider's body; the status + # word is remembered for the admin Settings panel. #2695: a 401/403 + # also teaches the capability cache, so the next roster load hides + # the mic instead of offering it again. + failure = stt_capability_service.record_live_failure( + elevenlabs_key, resp.status_code, resp.text) + raise ClientPortalError(failure.http_status, failure.client_message) text = ((resp.json() or {}).get("text") or "").strip() if not text: logger.warning("portal STT empty transcript — provider body: %s", resp.text[:500]) @@ -2844,35 +2889,12 @@ async def portal_chat(agent_name: str, message: str, email: str, _spawn_title_generation(agent_name, session_id, client_message, "", attempt=title_attempt) - # #78: make the agent aware of the client's uploaded files. Images are handed - # to the model as VISION blocks (so "what's in the picture" works) and MUST - # NOT be read as text — reading a binary floods the stream-json pipe and can - # trip the #728 subprocess-drain deadlock (a zombie claude pegging a core). - # Text files are listed by path so the agent can read them. Best-effort — a - # listing/read hiccup never blocks the chat. - # #78: make the agent aware of the client's files. Images are attached as - # vision blocks ONLY when this turn references them ("only when told"), never - # every turn; documents are listed for on-demand reading. The agent must NEVER - # read an image file as text — that floods the stream-json pipe (#728), which - # is exactly why we hand images over as vision INPUT instead. - images, image_names, doc_files = await _collect_inbox_for_turn(agent_name, email, message) - manifest_parts = [] - if images: - manifest_parts.append( - "The client's image(s) are shown to you directly below as images — " - "do NOT open/cat/read image files as text: " + ", ".join(image_names) - ) - elif image_names: - manifest_parts.append( - "The client has image(s) in your inbox (ask to see one and it'll be shown to you; " - "do NOT read image files as text): " + ", ".join(image_names) - ) - if doc_files: - listing = ", ".join(f"{d['filename']} ({_human_size(d['size_bytes'])})" for d in doc_files) - manifest_parts.append( - f"The client has uploaded these files to your inbox at `{_client_inbox(email)}/` — " - f"read any that are relevant: {listing}" - ) + # #78: make the agent aware of the client's files — see `collect_inbox_context`, + # which owns both halves (the sentence and the vision blocks). #2794 moved the + # composition there because a ROOM turn needs the identical thing, and two + # copies of "how an agent is told about a file" is how one surface silently + # stops telling it (the room was the surface that never told it at all). + manifest_prefix, images = await collect_inbox_context(agent_name, email, message) # Compose the execution message: prior conversation (context) → file manifest # → the client's actual message. Each section is optional. # @@ -2880,9 +2902,6 @@ async def portal_chat(agent_name: str, message: str, email: str, # when resuming (the session already remembers); `cold_message` always keeps # it, and is what the engine sends if the resume fails and it retries cold — # the retry has no session memory, so it needs the replay back. - manifest_prefix = "" - if manifest_parts: - manifest_prefix = "[Client Portal] " + " ".join(manifest_parts) + "\n\n" history_prefix = (convo_context + "\n\n") if convo_context else "" # #2694: the resumed turn carries the DELTA (what the session never heard), # never the whole-thread replay; the cold message carries the replay, which @@ -4731,6 +4750,56 @@ async def _collect_inbox_for_turn(agent_name: str, email: str, message: str): return images, image_names, doc_files +async def collect_inbox_context(agent_name: str, email: str, message: str) -> tuple[str, list[dict]]: + """How ONE agent is told about ONE client's files for ONE turn. + + Returns ``(manifest_prefix, images)``: + + * ``manifest_prefix`` — the ``"[Client Portal] …\n\n"`` sentence to put in + front of the turn's message, or ``""`` when the inbox is empty. It names + the images, names the documents with their sizes and the directory to read + them from, and in every branch tells the agent NOT to read an image as + text (#728: a binary through the stream-json pipe is the zombie-claude + deadlock, reproduced on an 83 KB JPEG). + * ``images`` — vision blocks for ``execute_task(images=…)``, attached only + when this turn actually references them ("only when told", #78). + + **This is the one place that composition lives (#2794).** It was inline in + `portal_chat`, which meant the 1:1 conversation was the only surface that + ever told an agent a file existed: a multi-agent ROOM built its turn prompt + from the transcript alone, so an agent @mentioned about a picture the client + had just sent it answered, correctly and uselessly, "I don't see any image + attached" — about a file sitting in its own inbox. Rooms now call this too. + Do not re-inline it: a third surface that composes its own sentence is the + same bug wearing a different name. + + Best-effort in both halves — a listing or read failure yields ``("", [])`` + rather than raising, because a file the agent cannot be told about must + still not cost the client their turn. + """ + images, image_names, doc_files = await _collect_inbox_for_turn(agent_name, email, message) + parts: list[str] = [] + if images: + parts.append( + "The client's image(s) are shown to you directly below as images — " + "do NOT open/cat/read image files as text: " + ", ".join(image_names) + ) + elif image_names: + parts.append( + "The client has image(s) in your inbox (ask to see one and it'll be shown to you; " + "do NOT read image files as text): " + ", ".join(image_names) + ) + if doc_files: + listing = ", ".join(f"{d['filename']} ({_human_size(d['size_bytes'])})" for d in doc_files) + parts.append( + f"The client has uploaded these files to your inbox at `{_client_inbox(email)}/` — " + f"read any that are relevant: {listing}" + ) + if not parts: + return "", images + return "[Client Portal] " + " ".join(parts) + "\n\n", images + + async def list_client_uploads(agent_name: str, email: str, include_owned: bool = False) -> dict: """Files the client has uploaded to this rostered agent (their inbox). Lets a client review what they've sent. Roster-scoped (miss → 404); empty when the diff --git a/src/backend/client_portal/work/service.py b/src/backend/client_portal/work/service.py index d4f085d19..522458d1e 100644 --- a/src/backend/client_portal/work/service.py +++ b/src/backend/client_portal/work/service.py @@ -158,6 +158,27 @@ def is_stale(elapsed: Optional[int], turn_timeout_seconds: int) -> bool: return elapsed is not None and elapsed > stale_bound_seconds(turn_timeout_seconds) +#: Kinds the terminate route will accept. An ALLOWLIST, never a blocklist of +#: kinds we happen to have thought of: an unrecognised trigger projects as +#: `other`, and offering Stop on a row whose cancel semantics nobody has read +#: is how the button becomes a lie. +#: +#: `room` (#2795) is here because the route genuinely accepts it, not because a +#: tile wanted a button. `shared_sessions.service._wake_agent` runs every room +#: turn through `execute_task(..., source_user_email=current_user.email)`, and +#: the agent is a room participant, which on the Workspace can only be an agent +#: already on the poster's roster — so both gates the route actually applies +#: (`_require_roster`, then `execution_belongs_to_caller`'s +#: `source_user_email` match) are satisfied by construction. Its absence was +#: the whole of the server-side half of #2795: the Work tab listed the run and +#: hid the only control that would have ended it. +#: +#: `loop` stays out deliberately — a loop is stopped from the Loops tab, where +#: stopping the LOOP is what the person means; cancelling one iteration leaves +#: the runner to start the next one. +STOPPABLE_KINDS = frozenset({"turn", "delegated", "room"}) + + def can_stop(item_kind: WorkKind, status: str, *, mine: bool, on_roster: bool, stale: bool) -> bool: """What `POST .../executions/{id}/terminate` will accept, decided once here so the button is never a lie: the route requires the agent on the roster @@ -165,7 +186,7 @@ def can_stop(item_kind: WorkKind, status: str, *, mine: bool, on_roster: bool, s a person can see.""" return (mine and on_roster and not stale and status in ("running", "queued") - and item_kind in ("turn", "delegated")) + and item_kind in STOPPABLE_KINDS) def mask(name: Optional[str], roster: Iterable[str]) -> Optional[str]: diff --git a/src/backend/db/migrations.py b/src/backend/db/migrations.py index c9eb47259..b1c07d94e 100644 --- a/src/backend/db/migrations.py +++ b/src/backend/db/migrations.py @@ -3181,6 +3181,106 @@ def _migrate_agent_sync_state_git_dir_bytes(cursor, conn): "ALTER TABLE agent_sync_state ADD COLUMN git_dir_bytes INTEGER", ) +# Every column the #2800 rebuild copies — the guard in the migration compares the +# live table against this set so a column it does not name can never be dropped. +_AGENT_SYNC_STATE_REBUILD_COLUMNS = frozenset({ + "agent_name", "last_sync_at", "last_sync_status", "consecutive_failures", + "last_error_summary", "last_remote_sha_main", "last_remote_sha_working", + "ahead_main", "behind_main", "ahead_working", "behind_working", + "git_dir_bytes", "pack_count", "loose_objects", "maintenance_failures", + "last_check_at", "updated_at", +}) + + +def _migrate_agent_sync_state_git_dir_bytes_bigint(cursor, conn): + """Re-declare agent_sync_state.git_dir_bytes as BIGINT (#2800). + + The defect is a PostgreSQL one — INTEGER there is int4, so any ``.git`` over + 2 GiB made the sync-state upsert raise ``NumericValueOutOfRange`` — and it is + fixed on that track by Alembic ``0062_agent_sync_state_git_dir_bytes_bigint``. + SQLite is unaffected: BIGINT and INTEGER are the same 64-bit INTEGER affinity, + so this migration changes no stored value and no runtime behaviour. + + It is NOT a bare no-op, though. ``schema.py`` is the single source of truth + for BOTH backends (the PG DDL is translated from the same strings), so the + canonical DDL now reads ``BIGINT`` — and the schema-parity test compares a + fresh ``init_schema`` database against an upgraded one by DECLARED column + type. A recorded no-op would leave every upgraded SQLite file declaring + ``INTEGER`` against a fresh file's ``BIGINT`` and turn that guard red + forever. SQLite has no ``ALTER COLUMN TYPE``, so the declared type is fixed + the only way it can be: the #1160 rename-swap rebuild, one row per agent, + every column copied verbatim, the one index re-created. Skipped when the + column already reads BIGINT (fresh installs, re-runs). + """ + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='agent_sync_state'") + if not cursor.fetchone(): + return # fresh install: init_schema creates it as BIGINT + cursor.execute("PRAGMA table_info(agent_sync_state)") + declared = {row[1]: (row[2] or "").upper() for row in cursor.fetchall()} + if declared.get("git_dir_bytes") == "BIGINT": + return + if "git_dir_bytes" not in declared: + return # #1596's add-column migration has not run yet; it runs first in MIGRATIONS order + # A rename-swap copies exactly the columns it names and DROPs the rest. The + # copy list below is the full agent_sync_state column set as of this + # migration; refuse — loudly, before touching anything — if the live table + # carries a column this list does not know, rather than silently dropping + # its data. A raise here surfaces as `first_pending` in the /health 503 + # (#1160); the table is left exactly as it was. + unexpected = set(declared) - _AGENT_SYNC_STATE_REBUILD_COLUMNS + if unexpected: + raise RuntimeError( + "agent_sync_state_git_dir_bytes_bigint: refusing to rebuild agent_sync_state — " + f"unknown column(s) {sorted(unexpected)} would be dropped by the rename-swap" + ) + print("Re-declaring agent_sync_state.git_dir_bytes as BIGINT (#2800)...") + _atomic_rebuild( + cursor, + conn, + "agent_sync_state", + """ + CREATE TABLE agent_sync_state_new ( + agent_name TEXT PRIMARY KEY, + last_sync_at TEXT, + last_sync_status TEXT, + consecutive_failures INTEGER DEFAULT 0, + last_error_summary TEXT, + last_remote_sha_main TEXT, + last_remote_sha_working TEXT, + ahead_main INTEGER DEFAULT 0, + behind_main INTEGER DEFAULT 0, + ahead_working INTEGER DEFAULT 0, + behind_working INTEGER DEFAULT 0, + git_dir_bytes BIGINT, + pack_count INTEGER, + loose_objects INTEGER, + maintenance_failures INTEGER DEFAULT 0, + last_check_at TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY (agent_name) REFERENCES agent_ownership(agent_name) + ) + """, + """ + INSERT INTO agent_sync_state_new + (agent_name, last_sync_at, last_sync_status, consecutive_failures, + last_error_summary, last_remote_sha_main, last_remote_sha_working, + ahead_main, behind_main, ahead_working, behind_working, + git_dir_bytes, pack_count, loose_objects, maintenance_failures, + last_check_at, updated_at) + SELECT agent_name, last_sync_at, last_sync_status, consecutive_failures, + last_error_summary, last_remote_sha_main, last_remote_sha_working, + ahead_main, behind_main, ahead_working, behind_working, + git_dir_bytes, pack_count, loose_objects, maintenance_failures, + last_check_at, updated_at + FROM agent_sync_state + """, + indexes=( + "CREATE INDEX IF NOT EXISTS idx_sync_state_status " + "ON agent_sync_state(last_sync_status, consecutive_failures)", + ), + ) + + def _migrate_agent_sync_state_gc_signals(cursor, conn): """Add pack_count / loose_objects / maintenance_failures to agent_sync_state (#1595). @@ -4370,4 +4470,5 @@ def _migrate_schedule_workspace_delivery(cursor, conn): ("schedule_workspace_delivery", _migrate_schedule_workspace_delivery), ("portal_messages_voice_source", _migrate_portal_messages_voice_source), ("portal_file_dismissals_table", _migrate_portal_file_dismissals_table), + ("agent_sync_state_git_dir_bytes_bigint", _migrate_agent_sync_state_git_dir_bytes_bigint), ] diff --git a/src/backend/db/schema.py b/src/backend/db/schema.py index 9a87f3a60..4073a7a58 100644 --- a/src/backend/db/schema.py +++ b/src/backend/db/schema.py @@ -1085,7 +1085,7 @@ behind_main INTEGER DEFAULT 0, ahead_working INTEGER DEFAULT 0, behind_working INTEGER DEFAULT 0, - git_dir_bytes INTEGER, + git_dir_bytes BIGINT, -- #2800: int8 on PG; INTEGER affinity on SQLite pack_count INTEGER, loose_objects INTEGER, maintenance_failures INTEGER DEFAULT 0, diff --git a/src/backend/db/tables.py b/src/backend/db/tables.py index d165af2e1..c712d833a 100644 --- a/src/backend/db/tables.py +++ b/src/backend/db/tables.py @@ -17,7 +17,7 @@ silently turn "one rating per person per thing" into "one row per click". """ -from sqlalchemy import Column, Float, ForeignKey, Index, MetaData, Table, Text, text +from sqlalchemy import BigInteger, Column, Float, ForeignKey, Index, MetaData, Table, Text, text from sqlalchemy import Integer as _Integer from sqlalchemy.types import TypeDecorator @@ -914,7 +914,13 @@ def process_bind_param(self, value, dialect): Column("behind_main", Integer), Column("ahead_working", Integer), Column("behind_working", Integer), - Column("git_dir_bytes", Integer), # #1596: agent .git on-disk size + # #1596: agent .git on-disk size. BigInteger, not Integer (#2800): SQLAlchemy + # Integer is int4 on PostgreSQL (ceiling 2 GiB), and a byte count whose whole + # job is to observe bloat is exactly the value that exceeds it — a 44 GiB + # repo made every sync-state upsert raise NumericValueOutOfRange. SQLite is + # unaffected (its INTEGER is already 64-bit), which is why the default + # backend never showed it. + Column("git_dir_bytes", BigInteger), Column("pack_count", Integer), # #1595: packs from `git count-objects -v` Column("loose_objects", Integer), # #1595: loose objects (gc-health signal) Column("maintenance_failures", Integer), # #1595: consecutive failed maintenance diff --git a/src/backend/migrations/versions/0062_agent_sync_state_git_dir_bytes_bigint.py b/src/backend/migrations/versions/0062_agent_sync_state_git_dir_bytes_bigint.py new file mode 100644 index 000000000..b5c2313f2 --- /dev/null +++ b/src/backend/migrations/versions/0062_agent_sync_state_git_dir_bytes_bigint.py @@ -0,0 +1,42 @@ +"""agent_sync_state.git_dir_bytes INTEGER -> BIGINT (#2800) + +``0019_agent_sync_state_git_dir_bytes`` added the column as ``INTEGER``, which +on PostgreSQL is int4 — ceiling 2,147,483,647 bytes (2 GiB). The column exists +to observe workspace-repo bloat (#1596), so the values it is there to record +are exactly the ones that overflow: an agent whose ``.git`` passes 2 GiB made +every ``SyncHealthService`` upsert raise ``NumericValueOutOfRange: integer out +of range``, and the agent's sync health went dark at the moment it mattered. +SQLite never showed it (its INTEGER is 64-bit), which is how it shipped. + +``ALTER COLUMN ... TYPE BIGINT`` is a metadata-plus-rewrite of one small table +(one row per agent); int4 -> int8 needs no ``USING`` and loses nothing. + +Mirrors the SQLite ``agent_sync_state_git_dir_bytes_bigint`` migration in +``db/migrations.py`` (a declared-type rebuild there, since SQLite has no ALTER +COLUMN TYPE) and the DDL in ``db/schema.py`` / MetaData in ``db/tables.py``. + +Fresh PG builds already get BIGINT via ``0001_baseline`` (it reuses the +``schema.py`` DDL); the ALTER is then a no-op. + +Revision ID: 0062_agent_sync_state_git_dir_bytes_bigint +Revises: 0061_execution_open_canvas +Create Date: 2026-09-15 +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0062_agent_sync_state_git_dir_bytes_bigint" +down_revision = "0061_execution_open_canvas" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("ALTER TABLE agent_sync_state ALTER COLUMN git_dir_bytes TYPE BIGINT") + + +def downgrade() -> None: + # Narrowing back to int4 fails on any row already holding a >2 GiB value — + # that is the honest inverse (the data would not fit), not something to + # paper over with a CAST that silently truncates. + op.execute("ALTER TABLE agent_sync_state ALTER COLUMN git_dir_bytes TYPE INTEGER") diff --git a/src/backend/routers/settings.py b/src/backend/routers/settings.py index 5c5a15c1c..cd31ee75e 100644 --- a/src/backend/routers/settings.py +++ b/src/backend/routers/settings.py @@ -3001,6 +3001,27 @@ 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() + # #2696: `stt_last_failure` — the last live `/stt` provider error for this + # key (category + the provider's status word), admin-only by virtue of the + # route. The client got a category sentence; this is the operator half. + state.update(stt_capability_service.describe( + cap, api_key=settings_service.get_elevenlabs_api_key())) + return state + + @router.get("/elevenlabs") async def get_elevenlabs_settings( request: Request, @@ -3012,7 +3033,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") @@ -3051,6 +3072,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() @@ -3084,7 +3110,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..d2a6fcdf0 --- /dev/null +++ b/src/backend/services/stt_capability_service.py @@ -0,0 +1,476 @@ +"""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 is the ONE authority; the per-process fallback is consulted only when + Redis cannot be asked (no client, or the read raised), so the two workers can + at worst each probe once during an outage. A Redis MISS is a miss — it never + falls through to a local copy, because that copy is exactly what `invalidate` + on the OTHER worker cannot reach (cross-worker staleness, the #2695 AC). + +* **Says WHY (#2696).** `classify_stt_failure()` maps a live `/stt` provider + error onto a category, an HTTP status and a client-facing sentence, so an + auth/permission failure, a quota/plan condition, a provider rate limit and a + rejected audio container stop collapsing into one opaque 422. The client + never sees the provider's body; the operator sees the status word and the + category on the admin Settings panel via `record_live_failure()`. + +* **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 — deliberately ABOVE the roster's patience +# (`WAIT_BUDGET_SECONDS`): the reader stops waiting at 4 s and answers +# `unknown`, while the probe runs on to fill the cache for the next reader. A +# probe bounded tighter than the reader would time out into a 2-minute +# `unknown` on every slow provider and the cache would never settle. +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 only: {cache_key: (expires_at, cap)}. +# Never read while Redis answers — see `read_cached`. +_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_local(k: str) -> Optional[SttCapability]: + hit = _local.get(k) + if hit and hit[0] > time.monotonic(): + return hit[1] + return None + + +def read_cached(api_key: str) -> Optional[SttCapability]: + """The cached verdict, or None on a miss. + + Redis is authoritative whenever it ANSWERS: a hit is returned, a miss is a + miss — and evicts this worker's local copy, since a row another worker + deleted (`invalidate`) or that expired is the one thing the local copy must + not resurrect. `_local` is read only when Redis cannot be asked at all. + """ + if not api_key: + return UNCONFIGURED + k = cache_key(api_key) + r = _redis() + if r is None: + return _read_local(k) + try: + raw = r.get(k) + except Exception as e: # noqa: BLE001 + logger.warning("stt capability cache read failed-open (%s)", e) + return _read_local(k) + if raw: + cap = SttCapability.from_json(raw) + if cap is not None: + return cap + # A corrupt row is a miss, not a fall-through to a local copy. + _local.pop(k, None) + 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 provider_status_parts(body: str) -> tuple[Optional[str], Optional[str]]: + """`(token, prose)` out of an ElevenLabs error body — either may be None. + + The two are kept APART because they answer different questions. A token + (`missing_permissions`, `quota_exceeded`) is a machine value the provider + documents and an operator can grep; prose is a sentence written for a human + and its words mean nothing in particular. + + Collapsing them is what made `classify_stt_failure` read a rejected key as a + billing problem: the quota matcher ran substring tests like "plan" and + "credit" over whatever this returned, so `{"detail": "Invalid API key for + your plan"}` — an ordinary auth failure — was reported to the operator as + "out of credits". Any matcher must consume the TOKEN; prose is for display + only. + """ + try: + d = json.loads(body or "{}") + except Exception: # noqa: BLE001 + return (None, None) + det = d.get("detail") if isinstance(d, dict) else None + token = prose = None + if isinstance(det, dict): + token = det.get("status") or det.get("code") + prose = det.get("message") + elif isinstance(det, str): + # A bare string detail is a sentence, not a documented token, even when + # it happens to be one word. + prose = det + trim = lambda v: str(v)[:120] if v else None # noqa: E731 + return (trim(token), trim(prose)) + + +def provider_status_word(body: str) -> Optional[str]: + """The best OPERATOR-FACING description of a provider error: the token when + there is one, else the prose. Display only — never a matcher's input, which + is what `provider_status_parts` exists to keep separate.""" + token, prose = provider_status_parts(body) + return token or prose + + +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 = provider_status_word(body) + 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) — Workspace server-side " + "dictation is disabled; the browser's own engine, if any, remains", + 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 withholds server-side dictation 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)) + + +# ---- live-failure mapping (#2696) --------------------------------------------- + +CATEGORY_PERMISSION = "permission" # the key may not call speech-to-text +CATEGORY_AUTH = "auth" # the key itself was rejected +CATEGORY_QUOTA = "quota" # credits / plan / entitlement +CATEGORY_RATE_LIMIT = "rate_limit" # transient; retry +CATEGORY_AUDIO = "audio" # the recording was rejected, not the key +CATEGORY_PROVIDER = "provider" # the provider itself failed +CATEGORY_UNKNOWN = "unknown" + +# Provider status words that mean "the account cannot pay for this", seen on +# 401/402 bodies. Matched as substrings of the status TOKEN only, lower-cased — +# never of the provider's prose (see `provider_status_parts`). +_QUOTA_WORDS = ("quota", "credit", "plan", "payment", "subscription", "billing", + "insufficient", "free_users", "entitlement", "trial") + + +@dataclass(frozen=True) +class SttFailure: + category: str + http_status: int # what the client receives + client_message: str # never carries the provider's body + provider_status: int # what the provider answered + detail: Optional[str] # the provider's status word, operator-facing only + + def to_json(self) -> str: + return json.dumps({**asdict(self), "at": time.time()}) + + +_LAST_FAILURE_PREFIX = "stt:last_failure:" +TTL_LAST_FAILURE_SECONDS = 24 * 3600 +# Per-process fallback, {row: (expires_at, json)} — the same shape as `_local`. +_local_failures: dict[str, tuple[float, str]] = {} + + +def _failure_row(api_key: str) -> str: + return _LAST_FAILURE_PREFIX + cache_key(api_key)[len(_CACHE_PREFIX):] + +_MSG_PERMISSION = ("Voice input is not enabled for this workspace: the speech " + "recognition key is missing the speech-to-text permission. " + "Ask your operator to update it — you can type instead.") +_MSG_AUTH = ("Voice input is not working: the speech recognition key was rejected. " + "Ask your operator to check it — you can type instead.") +_MSG_QUOTA = ("Voice input is unavailable: the speech recognition account is out of " + "credits or not on a plan that allows it. Ask your operator — " + "you can type instead.") +_MSG_RATE_LIMIT = "Too many voice messages just now — wait a moment and try again." +_MSG_AUDIO = ("That recording could not be read. Try again — or type your message instead.") +_MSG_PROVIDER = "Voice input failed — please type instead" +_MSG_UNKNOWN = ("Voice input failed (the speech recognition service answered with an " + "error). Try again, or type your message instead.") + + +def classify_stt_failure(status_code: int, body: str) -> SttFailure: + """One live `/stt` provider error → what the client is told and what the + operator is shown. Pure. Every status lands in a NAMED category; there is + no arm that hands back the old opaque string, so an unrecognised provider + answer is `unknown` — still specific about who failed — never a regression + to "Could not transcribe the audio".""" + token, prose = provider_status_parts(body) + word = token or prose # operator-facing detail: the best we have + lt = (token or "").lower() # matcher input: the TOKEN only, never prose + + if status_code in (401, 403): + # Matched on the token alone. A 401 whose body is prose is an auth + # failure by default — the honest reading of "the key was rejected and + # the provider did not say why" — because a sentence mentioning "plan" + # or "credit" is not evidence of a billing condition, and telling an + # operator to top up an account whose key simply needs replacing is + # worse than saying nothing specific. + if "permission" in lt: + return SttFailure(CATEGORY_PERMISSION, 503, _MSG_PERMISSION, status_code, word) + if any(q in lt for q in _QUOTA_WORDS): + return SttFailure(CATEGORY_QUOTA, 503, _MSG_QUOTA, status_code, word) + return SttFailure(CATEGORY_AUTH, 503, _MSG_AUTH, status_code, word) + if status_code == 402: + return SttFailure(CATEGORY_QUOTA, 503, _MSG_QUOTA, status_code, word) + if status_code == 429: + return SttFailure(CATEGORY_RATE_LIMIT, 429, _MSG_RATE_LIMIT, status_code, word) + if status_code in (400, 413, 415, 422): + return SttFailure(CATEGORY_AUDIO, 422, _MSG_AUDIO, status_code, word) + if status_code >= 500: + return SttFailure(CATEGORY_PROVIDER, 502, _MSG_PROVIDER, status_code, word) + return SttFailure(CATEGORY_UNKNOWN, 502, _MSG_UNKNOWN, status_code, word) + + +def record_live_failure(api_key: str, status_code: int, body: str) -> SttFailure: + """A genuine `/stt` call failed at the provider. Classify it, remember the + operator-facing half beside the key's capability row (so Settings → Voice + can name the cause without a container log), and — for a 401/403 — let + the capability verdict learn from it (#2695). Returns the classification + so the caller can raise the client-facing half.""" + failure = classify_stt_failure(status_code, body) + record_live_refusal(api_key, status_code, body) + if api_key: + k = _failure_row(api_key) + payload = failure.to_json() + _local_failures[k] = (time.monotonic() + TTL_LAST_FAILURE_SECONDS, payload) + r = _redis() + if r is not None: + try: + r.set(k, payload, ex=TTL_LAST_FAILURE_SECONDS) + except Exception as e: # noqa: BLE001 + logger.warning("stt last-failure write failed-open (%s)", e) + return failure + + +def read_last_failure(api_key: str) -> Optional[dict]: + """The most recent live failure for THIS key, as stored — operator-facing.""" + if not api_key: + return None + k = _failure_row(api_key) + raw = None + r = _redis() + if r is not None: + try: + raw = r.get(k) + except Exception as e: # noqa: BLE001 + logger.warning("stt last-failure read failed-open (%s)", e) + if not raw: + hit = _local_failures.get(k) + if hit and hit[0] > time.monotonic(): + raw = hit[1] + if not raw: + return None + try: + d = json.loads(raw) + except Exception: # noqa: BLE001 + return None + return { + "category": d.get("category"), + "provider_status": d.get("provider_status"), + "detail": d.get("detail"), + "at": d.get("at"), + } + + +def describe(cap: SttCapability, api_key: Optional[str] = None) -> dict: + """The admin-panel shape: verdict + detail + when, never the key. With the + key, also the last live failure (#2696) so an operator can read the cause + of a client's "voice input failed" without a container log.""" + out = { + "stt_capability": cap.verdict, + "stt_detail": cap.detail, + "stt_checked_at": cap.checked_at, + } + if api_key is not None: + out["stt_last_failure"] = read_last_failure(api_key) + return out diff --git a/src/backend/shared_sessions/service.py b/src/backend/shared_sessions/service.py index 8593c0e38..7e3a5c00d 100644 --- a/src/backend/shared_sessions/service.py +++ b/src/backend/shared_sessions/service.py @@ -828,6 +828,53 @@ def _build_turn_prompt(room: dict, agent_name: str, delta: list[dict], cold: boo return header + _format_delta(delta) +async def _room_inbox_context(agent_name: str, email: str | None, + delta: list[dict]) -> tuple[str, list[dict]]: + """What this agent should be told about the client's files, for this wake. + + #2794. A room turn used to be built from the transcript and nothing else, so + an agent @mentioned about a picture the client had just sent it replied — in + good faith — "I don't see any image attached", about a file sitting in its + own inbox. Every part of the delivery already worked: the drop fans out to + every participating agent, the bytes land in each agent's + ``~/inbox//``, the rail lists them. Only the *telling* was missing, + and it was missing because the sentence that does it was written inline in + the 1:1 chat path and never existed anywhere else. + + So this is a thin adapter onto the ONE composer + (``client_portal.service.collect_inbox_context``) — deliberately not a + second implementation of the manifest. The import is local for the same + reason ``agent_on_roster`` is: rooms lean on the portal at a handful of + points and neither module may import the other at module scope. + + Two decisions worth stating, because neither is obvious: + + * **Whose inbox.** The posting principal's. A portal inbox is keyed by the + client's email, and in a Workspace room that principal IS the person who + put the file there. *Residual:* a room with two humans surfaces only the + email of whoever's message triggered this wake — the other's files stay + unmentioned. Reading every human participant's inbox would cost one + ``docker exec`` per human per wake, and the shape rooms actually have is + one person and N agents. + + * **What counts as asking for an image.** The WHOLE delta, including agent + lines — not just the human's. "@sidekick can you look at the screenshot the + client sent?" is an ordinary room move, and scoping the intent test to + human text would make exactly that relay come through image-less: the bug + this fixes, one hop along. The size/count caps upstream bound the cost. + + Never raises. A room turn that cannot be told about a file still runs. + """ + if not email: + return "", [] + try: + from client_portal.service import collect_inbox_context + return await collect_inbox_context(agent_name, email, _format_delta(delta)) + except Exception as e: # noqa: BLE001 — a file we cannot mention never costs a turn + logger.warning("room: inbox context for %s/%s failed: %s", agent_name, email, e) + return "", [] + + async def post_message(current_user, room_id: str, content: str, _chain_depth: int = 0, _sender_override: Optional[tuple[str, str]] = None, @@ -1104,13 +1151,22 @@ async def _wake_agent(current_user, room_id: str, agent_name: str, chain_depth: room_prompt = build_user_facing_room_prompt() if user_facing else None + # #2794: the client's files, named to THIS agent. The prefix rides in front + # of the transcript for the same reason it rides in front of a 1:1 message — + # the agent has to know a file exists before the transcript referring to it + # means anything — and `images` is what makes "what is in this picture" + # answerable at all, since an agent must never read an image as text (#728). + client_email = getattr(current_user, "email", None) + manifest_prefix, images = await _room_inbox_context(agent_name, client_email, delta) + try: result = await get_task_execution_service().execute_task( agent_name=agent_name, - message=_build_turn_prompt(room, agent_name, delta, cold, user_facing), + message=manifest_prefix + _build_turn_prompt(room, agent_name, delta, cold, user_facing), triggered_by="room", system_prompt=room_prompt, - source_user_email=getattr(current_user, "email", None), + images=images or None, + source_user_email=client_email, timeout_seconds=ROOM_TURN_TIMEOUT_SECONDS, resume_session_id=cached, persist_session=True, @@ -1134,10 +1190,64 @@ async def _wake_agent(current_user, room_id: str, agent_name: str, chain_depth: _broadcast("room_participant_state", {"room_id": room_id, "identity": agent_name, "state": "idle"}) + # `TaskExecutionStatus` is a `str` Enum, so a plain string compare works for + # either — but normalise anyway rather than relying on that at a distance. status = getattr(result, "status", None) + status = str(getattr(status, "value", status) or "").strip().lower() reply = (getattr(result, "response", "") or "").strip() - if status in ("failed", "cancelled") or not reply: + # #2795: the RETURNED status is not always the one that stands. + # + # On a current agent image a cancelled turn comes back labelled: the agent + # relabels its own 504/502/500 to a `cancelled` 200 (#679 F3), so + # `execute_task` returns CANCELLED and the branch below is exact. An OLDER + # image re-raises instead, `execute_task` writes FAILED, that write LOSES + # the CAS to the CANCELLED the terminate route already wrote — and returns + # FAILED anyway. The room would then blame the agent for a stop the reader + # asked for, and drop a resume handle that was never bad. + # + # The 1:1 does not have this problem because it remembers the cancel + # client-side (`cancelledExecutionIds`); a room has no such memory, so it + # asks the row that actually stands. One indexed read, only on a path that + # has already lost an LLM turn, and fail-open — an unreadable row leaves the + # returned status in force. + # Only where it can change the outcome: the branch below fires on FAILED or + # on an empty reply, so anything else — a success with a reply — must pay + # nothing. (A test pinned this after the first draft re-read on every + # successful turn.) + if status != "cancelled" and (status == "failed" or not reply): + eid = getattr(result, "execution_id", None) + if eid: + try: + from database import db as core_db + persisted = core_db.get_execution(eid) + persisted_status = str( + getattr(getattr(persisted, "status", None), "value", + getattr(persisted, "status", None)) or "" + ).strip().lower() + if persisted_status == "cancelled": + status = "cancelled" + except Exception as e: # noqa: BLE001 — never let a label read break the turn + logger.warning("room %s: could not re-read execution %s for its " + "terminal label (%s)", room_id, eid, e) + + # #2795: a CANCEL IS NOT A FAILURE, and the room must not describe it as + # one. A person can now stop a room turn from the tile or the Work tab, and + # the line they got for doing it was " could not respond (no + # response)." — the surface reporting a fault for something the reader + # themselves just asked for, which is the AC's "no 'something went wrong' + # for a cancel the user asked for". + # + # It also must not clear the resume handle. That drop exists for a DEAD + # handle (the Session-tab idiom below), and a cancel is no evidence of one + # — the next turn would pay for a cold rebuild of a context that was fine. + # The read cursor is left alone either way, so the delta this turn never + # answered is re-delivered on the next wake. + if status == "cancelled": + _post_system(room_id, f"{agent_name}'s turn was stopped.") + return + + if status == "failed" or not reply: # A dead resume handle is the common cause — drop it so the next wake is # cold instead of failing the same way forever (Session-tab idiom). if cached: diff --git a/src/frontend/src/api.js b/src/frontend/src/api.js index fc8b88e63..81be113a4 100644 --- a/src/frontend/src/api.js +++ b/src/frontend/src/api.js @@ -6,6 +6,7 @@ */ import axios from 'axios' +import { notifyPlatformUnauthorized, readStoredToken } from '@/utils/platformSession' // PERF-269: In-flight request deduplication map // Key: "GET:/api/agents/context-stats" → Value: Promise @@ -20,7 +21,7 @@ const api = axios.create({ // Add auth token to requests api.interceptors.request.use( (config) => { - const token = localStorage.getItem('token') + const token = readStoredToken() if (token) { config.headers.Authorization = `Bearer ${token}` } @@ -32,32 +33,19 @@ api.interceptors.request.use( ) // Handle auth errors +// +// #2791: this used to be the THIRD logout implementation — it removed `token` +// (leaving `auth0_user` behind), hard-reloaded to `/login` with no server-side +// revoke, and carried its own copy of the bounce predicate. What "logged out" +// meant depended on which transport happened to 401 first. +// +// It now reports to the one handler (`utils/platformSession.js`), which owns the +// verdict AND the reaction — including the `stale` arm, without which this +// interceptor would still delete a freshly re-logged-in session's token. api.interceptors.response.use( (response) => response, (error) => { - if (error.response?.status === 401) { - // #138: an external client on the workspace manages its own - // (verified-email) session and must never be bounced to the operator - // /login by a stale operator JWT — let that code handle its own 401. - // ent#357: an internal user's workspace session IS the platform session, - // so they DO get bounced. Same path, two session kinds — discriminate on - // the portal token, not the URL. - const path = window.location.pathname - // Who gets bounced is decided by the PLATFORM token, not the portal one - // (/review I1). Reading the portal token here made the answer depend on - // timing: `fetchRoster`'s 401 handler calls `signOut()`, which removes it, - // so a second concurrent 401 saw no portal token and threw an external - // client onto the operator /login instead of the workspace sign-in form. - // "Does this browser hold a platform session that just expired?" is the - // actual question, and it has a stable answer. - const onWorkspace = path.startsWith('/workspace') || path.startsWith('/portal') - const internalSession = !!localStorage.getItem('token') - if (!onWorkspace || internalSession) { - // Token expired or invalid - redirect to login - localStorage.removeItem('token') - window.location.href = '/login' - } - } + if (error.response?.status === 401) notifyPlatformUnauthorized(error) return Promise.reject(error) } ) diff --git a/src/frontend/src/components/portal/PortalConversation.vue b/src/frontend/src/components/portal/PortalConversation.vue index 73f7d337e..1ce1d0cb3 100644 --- a/src/frontend/src/components/portal/PortalConversation.vue +++ b/src/frontend/src/components/portal/PortalConversation.vue @@ -621,6 +621,7 @@ @keydown="onComposerKeydown" @click="onComposerCaret" @select="onComposerCaret" + @paste="dropHandlers.onPaste" >
-
@@ -193,14 +224,7 @@ recipients named — a room's upload is a fan-out and the person should see who received it. -->

{{ batchNotice }}

- @@ -214,7 +238,11 @@ {{ notice.detail }} -
+ +
· to {{ recipientLabel }}
- + + +
+ + {{ carryNotice.text }} + Dismiss +
@@ -314,17 +383,20 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue' import { useClientPortalStore } from '@/stores/clientPortal' import { budgetNotice } from '@/utils/roomBudgets' +import InlineError from '@/components/InlineError.vue' import PortalAgentBubble from './PortalAgentBubble.vue' import PortalWorkCard from './PortalWorkCard.vue' import { usePortalWorkStore } from '@/stores/portalWork' -import { liveElapsedSeconds } from './portalWork' +import { liveElapsedSeconds, soleStoppableItem } from './portalWork' import PortalAvatar from './PortalAvatar.vue' import PortalStarButton from './PortalStarButton.vue' import PortalEditableTitle from './PortalEditableTitle.vue' import PortalTypeahead from './PortalTypeahead.vue' +import BaseButton from '@/components/base/BaseButton.vue' import PortalJumpToLatest from './PortalJumpToLatest.vue' import { workSignalFromRoom } from './portalRail' import { usePortalFileDrop, attachmentState } from '@/composables/usePortalFileDrop' +import { shouldCancelOnEscape, cancelOutcome } from '@/utils/turnCancel' import { useStickToBottom } from '@/composables/useStickToBottom' import { applyTypeaheadInsert, @@ -356,8 +428,13 @@ const props = defineProps({ // ent#475: text to seed the composer with — the rail's "Ask for a canvas" // pre-fills, never sends. Same contract as `PortalConversation`'s. prefill: { type: String, default: '' }, + // #2794: `{ roomId, text, problem }` — what the shell carried into this room + // when a 1:1 escalated into it, or null. Owned by the shell because the + // carry happens while this component is still mounting, and scoped to a + // room id there so it cannot follow the reader into another conversation. + carryNotice: { type: Object, default: null }, }) -const emit = defineEmits(['open-menu', 'rooms-changed', 'toggle-star', 'participants-changed', 'work-state', 'open-work']) +const emit = defineEmits(['open-menu', 'rooms-changed', 'toggle-star', 'participants-changed', 'work-state', 'open-work', 'dismiss-carry-notice']) const store = useClientPortalStore() @@ -431,6 +508,7 @@ const { dragging: fileDragging, entries: attachments, batchNotice, + clear: clearAttachments, handlers: dropHandlers, } = usePortalFileDrop( async (file) => { @@ -460,6 +538,32 @@ watch(() => roomLiveItems.value.length > 0, (on) => { onBeforeUnmount(() => { if (clockTimer) clearInterval(clockTimer) }) function elapsedOf(it) { return liveElapsedSeconds(it, { fetchedAtMs: workStore.fetchedAt, nowMs: clockMs.value }) } +// #2795 — stopping a room turn. +// +// The store action is the Work tab's, unchanged: it re-checks `can_stop`, calls +// the same portal terminate route, treats a 404 as the lost race rather than a +// refusal, and refetches so CANCELLED comes back from the server instead of +// being written optimistically here. Two surfaces, one cancel path. +// +// Only a FAILURE is reported. A successful stop already says so where the +// reader is looking — `_wake_agent` posts "'s turn was stopped." into +// the transcript — so a banner would be the same news twice. +const stopError = ref('') + +async function onStopWork(item) { + stopError.value = '' + const res = await workStore.stopItem(item) + if (!res.success) stopError.value = cancelOutcome({ ok: false }).message +} + +// Escape stops the turn ONLY when there is exactly one to stop (see +// `soleStoppableItem`). A room fans out to several agents, and a keystroke that +// picks one of them by position would destroy work somebody is still waiting +// for. In practice the fan-out is sequential, so a room normally has one live +// row and Escape behaves exactly as it does in a 1:1; when it does not, the +// tile's own Stop button is the unambiguous control. +const escapeStoppable = computed(() => soleStoppableItem(roomLiveItems.value, workStore.stoppingIds)) + // ent#474 — the shell scopes the rail to the room's participants and derives // its Work signal from the SERVER's `working` list (never a local flag), so // both survive a reload and follow the room's own poll — live push degrades @@ -640,6 +744,20 @@ function focusComposerFromShell(event) { } function onComposerKeydown(e) { + // Asked BEFORE `resolveComposerKey`, and gated on the typeahead/add-agent + // popups via `overlays`, so a press that belongs to something nearer the + // keystroke never reaches the turn (ent#155's rule, unchanged). + const target = escapeStoppable.value + if (target && shouldCancelOnEscape(e, { + inFlight: true, + cancelling: workStore.stoppingIds.includes(target.id), + overlays: [typeaheadOpen.value, addOpen.value], + })) { + e.preventDefault() + onStopWork(target) + return + } + const length = typeaheadBound.value.visible.length switch (resolveComposerKey({ key: e.key, @@ -735,6 +853,18 @@ async function send() { resetTypeahead() try { await store.postRoomMessage(props.roomId, text) + // #2794: the chips describe what is going out with THIS message, so they + // clear once it has gone — the 1:1's rule, which this composer never had. + // Without it a room accumulated every chip it had ever drawn, describing + // files that had been delivered several messages ago as though they were + // still pending. + clearAttachments() + // The carry notice describes the message that CREATED this room. Once a + // newer message exists it is describing history while sitting under the + // composer, so a send retires it — same reason as the chips above. The + // escalation's own first post is made by the shell, not here, so this + // cannot retire the notice before it has been read. + if (props.carryNotice) emit('dismiss-carry-notice') // The post returns once the mentioned agents have been woken; their replies // land as further messages, which the poll picks up. await load() diff --git a/src/frontend/src/components/portal/portalAttachments.js b/src/frontend/src/components/portal/portalAttachments.js new file mode 100644 index 000000000..9cb440481 --- /dev/null +++ b/src/frontend/src/components/portal/portalAttachments.js @@ -0,0 +1,188 @@ +/** + * Carrying a composer's attachments from a 1:1 into the room it escalates to + * (#2794). Pure. + * + * The gap this closes: `PortalConversation` uploads each dropped file straight + * into the CURRENT agent's inbox as it is attached, and the `escalate-to-room` + * event carried only `{ agents, message }` — so a message that @mentioned a + * second agent moved to a room and the file did not. The person had watched a + * chip say the upload succeeded, so they believed both agents had it; only the + * original one ever did, and the room showed no trace of a file at all. + * + * The rule the issue states, and the one everything here follows: *whatever a + * user could do inside a room, escalating into one from a 1:1 must produce the + * same result.* A room-native drop is one upload per participant + * (`PortalRoom.vue`), so an escalation owes the participants that have not + * already received the file exactly that — no more (the origin agent must not + * be sent the same file twice) and no less. + * + * Every rule is a pure function because `vitest.config.js` pins + * `environment: 'node'` with no component-mount harness: a decision made inside + * an SFC is a decision no test can reach. `PortalConversation.vue`, + * `Portal.vue` and `PortalRoom.vue` are dispatchers over this module. + */ + +import { attachmentState } from '@/composables/usePortalFileDrop' + +/** + * Split a composer's entries into what can travel and what cannot. + * + * `carried` is the files that actually reached the origin agent AND still hold + * a readable handle — both are required, and the second is not paranoia: an + * entry restored across a failed escalation, or one built by an older code + * path, has no `file`, and a plan built from it would fan out `undefined`. + * + * `dropped` is everything else, kept rather than discarded so the person can + * be TOLD. Silence is the one outcome the AC forbids. + * + * Anything still uploading counts as dropped — callers are expected to await + * `settled()` first, so an in-flight entry reaching here means the wait was + * skipped, and reporting it is strictly better than assuming it landed. + */ +export function partitionAttachments(entries) { + const carried = [] + const dropped = [] + for (const entry of Array.isArray(entries) ? entries : []) { + if (!entry || !entry.name) continue + if (attachmentState(entry) === 'sent' && entry.file) carried.push(entry) + else dropped.push(entry) + } + return { carried, dropped } +} + +/** + * The one carry set, from BOTH upload surfaces. + * + * The composer is not the only way to attach a file: the rail's Files panel + * (`PortalRailFiles.vue`) sends straight to its "Send to" target and keeps no + * pending state, so a person who attached there and then @mentioned a second + * agent had nothing carried — and, because the composer held no attachments, + * not even a notice saying so. Both surfaces funnel through + * `clientPortal.uploadDocument`, which logs them; this merges the two views. + * + * Composer entries WIN a tie: they carry the live per-file outcome the chip is + * rendering, and the carry-log entry is the same upload seen from the funnel. + * Identity is `name + size` rather than the `File` object, because the two + * surfaces hold different references to the same upload only when the composer + * was used — dedup by reference would double-carry every composer file. + * + * Rail entries are normalised into the entry shape the rest of this module + * speaks (`partitionAttachments` reads `attachmentState`), and they are + * `done: true` by construction: `uploadDocument` logs only after the server + * took the file. + */ +export function mergeCarrySources(composerEntries, railUploads) { + const merged = [] + const seen = new Set() + const key = (e) => `${e.name}\u0000${e.size ?? ''}` + + for (const e of Array.isArray(composerEntries) ? composerEntries : []) { + if (!e || !e.name) continue + seen.add(key(e)) + merged.push(e) + } + for (const u of Array.isArray(railUploads) ? railUploads : []) { + if (!u || !u.name || !u.file) continue + if (seen.has(key(u))) continue + seen.add(key(u)) + merged.push({ + name: u.name, + size: u.size, + file: u.file, + uploading: false, + error: '', + done: true, + }) + } + return merged +} + +/** + * Who still needs each carried file. + * + * `origin` already has it — that is what the 1:1 upload did — so it is excluded + * by name rather than by position: the shell builds `agents` as + * `[origin, ...mentioned]`, and a plan that trusted that order would re-send + * the file to the origin agent the day the order changes. Duplicate mentions + * collapse for the same reason a room's own fan-out iterates participants + * rather than mentions: the cost is one upload per RECIPIENT. + * + * A file nobody new needs yields no entry at all, so the caller's loop is + * empty rather than uploading to zero agents and reporting a success. + * + * @returns {Array<{entry: object, file: File, name: string, agents: string[]}>} + */ +export function fanOutPlan(carried, { origin, participants } = {}) { + const skip = new Set([origin].filter(Boolean)) + const targets = [] + const seen = new Set() + for (const name of Array.isArray(participants) ? participants : []) { + if (!name || skip.has(name) || seen.has(name)) continue + seen.add(name) + targets.push(name) + } + if (!targets.length) return [] + return (Array.isArray(carried) ? carried : []) + .filter((e) => e && e.file) + .map((entry) => ({ entry, file: entry.file, name: entry.name, agents: targets.slice() })) +} + +/** `a`, `a and b`, `a, b and c` — the recipient list, read aloud. */ +export function nameList(names) { + const rows = (Array.isArray(names) ? names : []).filter(Boolean) + if (!rows.length) return '' + if (rows.length === 1) return rows[0] + return `${rows.slice(0, -1).join(', ')} and ${rows[rows.length - 1]}` +} + +/** + * What the room says about the files that came with the escalated message. + * + * Three facts, and the order is the reader's priority: what arrived and for + * whom, what did not arrive, and what never left the 1:1 at all. `null` when + * there is nothing to say — an escalation with no attachments must not grow a + * line about attachments. + * + * A partial failure is named per FILE and per AGENT, because "some uploads + * failed" tells the person nothing they can act on, and the action here is + * concrete: drop that one file into the room again. + * + * @param {object[]} carried entries that travelled + * @param {object[]} dropped entries that could not travel + * @param {Array<{name: string, agents: string[]}>} failures per-file misses + * @param {string[]} recipients the agents the fan-out targeted + */ +export function carriedNotice({ carried = [], dropped = [], failures = [], recipients = [] } = {}) { + const parts = [] + const failedNames = new Set(failures.map((f) => f && f.name).filter(Boolean)) + const delivered = carried.filter((e) => e && !failedNames.has(e.name)) + + if (delivered.length && recipients.length) { + parts.push(`Sent with your message: ${nameList(delivered.map((e) => e.name))} ` + + `— also delivered to ${nameList(recipients)}.`) + } else if (delivered.length) { + parts.push(`Sent with your message: ${nameList(delivered.map((e) => e.name))}.`) + } + + for (const f of failures) { + if (!f || !f.name || !(f.agents || []).length) continue + parts.push(`${f.name} didn't reach ${nameList(f.agents)} — attach it again here to retry.`) + } + + if (dropped.length) { + parts.push(`${nameList(dropped.map((e) => e.name))} ` + + `${dropped.length === 1 ? 'was' : 'were'} not carried over — ` + + `${dropped.length === 1 ? 'it' : 'they'} never finished uploading.`) + } + + return parts.length ? parts.join(' ') : null +} + +/** + * Is this notice about a failure? Decides whether the room renders it as a + * warning or as an ordinary delivery line — the same verdict-not-a-pair-of- + * booleans shape `attachmentState` uses. + */ +export function noticeIsProblem({ dropped = [], failures = [] } = {}) { + return Boolean(dropped.length || failures.some((f) => f && (f.agents || []).length)) +} diff --git a/src/frontend/src/components/portal/portalFiles.js b/src/frontend/src/components/portal/portalFiles.js index 9708dcd18..0259024b9 100644 --- a/src/frontend/src/components/portal/portalFiles.js +++ b/src/frontend/src/components/portal/portalFiles.js @@ -285,3 +285,102 @@ export function sharePreviewPath(url, base) { if (at >= 0) return `${parsed.pathname.slice(at)}${parsed.search}` return sameOriginPath(parsed.href, base) } + +// --------------------------------------------------------------------------- +// Who a file goes to (#2794) +// --------------------------------------------------------------------------- +// +// The rail's send zone has always aimed at exactly ONE agent — a `Send to` +// select that quietly defaults to the first participant. In a 1:1 that is the +// only possible answer and nobody notices. In a ROOM it is a trap that produced +// the reported bug end to end: the client sends a screenshot from the rail while +// looking at a room with two agents in it, the file reaches the first name in +// the list, and the message they then write — "@sidekick what is in this +// image?" — is addressed to the agent that did not get it. +// +// The room's own drop zone already fans out (`PortalRoom.vue` uploads to every +// participant). So the two surfaces disagreed about what "send a file to this +// chat" means, and the one with the visible select was the one that was wrong. +// +// Fixed in the rules, not in the template: a room's default recipient is +// EVERYONE in it, with the individual agents still selectable underneath for the +// person who genuinely means one of them. + +/** The sentinel for "everyone in this chat". Not a legal agent name, so it can + * never collide with one. */ +export const ALL_PARTICIPANTS = '*' + +/** + * The `Send to` options, in order, for a chat with these participants. + * + * A 1:1 gets no fan-out entry: with one agent "everyone" and "that agent" are + * the same recipient, and offering both would be a choice with no difference. + */ +export function uploadTargets(participants = []) { + const names = (participants || []).filter(Boolean) + if (names.length < 2) return names.map((name) => ({ value: name, label: name })) + return [ + { value: ALL_PARTICIPANTS, label: `Everyone in this chat (${names.length} agents)` }, + ...names.map((name) => ({ value: name, label: name })), + ] +} + +/** + * The default recipient — everyone, wherever "everyone" is more than one. + * + * This is the line that fixes the reported bug. It is stated as its own function + * rather than an initial `ref()` value because a component's initial value is + * not reachable from a node-env test, and "a room sends to all of them" is + * precisely the claim that has to stay true. + */ +export function defaultUploadTarget(participants = []) { + const names = (participants || []).filter(Boolean) + if (names.length >= 2) return ALL_PARTICIPANTS + return names[0] || null +} + +/** + * The agents a chosen target actually resolves to. + * + * Fails toward the fan-out: a target that is no longer a participant (an agent + * left the room while the panel was open) resolves to everyone rather than to + * nobody. A file sent to one agent too many is recoverable — the rail has a + * delete — and a file sent to nobody is the silent loss this whole issue is about. + */ +export function resolveRecipients(target, participants = []) { + const names = (participants || []).filter(Boolean) + if (!names.length) return [] + if (target === ALL_PARTICIPANTS) return names + return names.includes(target) ? [target] : names +} + +/** + * What the send zone's button says it will do. Named, never "the agent" — the + * person is about to hand over a file and should be able to read where it goes + * before they let go of it. + */ +export function uploadTargetLabel(target, participants = []) { + const names = resolveRecipients(target, participants) + if (!names.length) return 'the agent' + if (names.length === 1) return names[0] + if (names.length === 2) return `${names[0]} and ${names[1]}` + return `all ${names.length} agents` +} + +/** + * The receipt. Both halves of a fan-out are stated — how many files, to how many + * agents — because "Sent file.png" over a two-agent fan-out is exactly the + * reassurance that was wrong before: it was true, and it was read as "both of + * them have it". + */ +export function uploadReceipt({ files = [], recipients = [] } = {}) { + const f = files.filter(Boolean) + if (!f.length || !recipients.length) return '' + const what = f.length === 1 ? `“${f[0]}”` : `${f.length} files` + const who = recipients.length === 1 + ? recipients[0] + : recipients.length === 2 + ? `${recipients[0]} and ${recipients[1]}` + : `all ${recipients.length} agents` + return `Sent ${what} to ${who}.` +} diff --git a/src/frontend/src/components/portal/portalWork.js b/src/frontend/src/components/portal/portalWork.js index 96911cd0c..c04db8d5e 100644 --- a/src/frontend/src/components/portal/portalWork.js +++ b/src/frontend/src/components/portal/portalWork.js @@ -63,6 +63,36 @@ export function liveItems(items) { return (Array.isArray(items) ? items : []).filter(isLive) } +/** + * The ONE live item Escape may stop, or null (#2795). + * + * A 1:1 has exactly one turn, so Escape there is unambiguous. A room fans a + * message out to several agents, and "stop the turn" stops *which*? Guessing + * destroys work somebody is still waiting for, which is the failure + * `shouldCancelOnEscape` is written to avoid ("when in doubt Escape does + * nothing") — so the rule is not "stop the first one" but "stop it only when + * there is nothing to be ambiguous about". + * + * That is not as narrow as it sounds: the room fan-out is SEQUENTIAL + * (`shared_sessions.service.post_message` awaits each `_wake_agent` in turn), + * so one room normally has exactly one execution in flight and Escape works + * the way it does everywhere else. Two live rows means the same agent is also + * busy in another room, or a chain overlapped — and then Escape is a no-op and + * the tile's own Stop button is the unambiguous control. + * + * An item already being stopped does not count: it is on its way out, and + * letting it hold the "sole" slot would make a second press act on it again. + * + * @param {Array} items the surface's live rows + * @param {Array} stoppingIds ids with a cancel already in flight + */ +export function soleStoppableItem(items, stoppingIds = []) { + const busy = new Set(stoppingIds || []) + const candidates = (Array.isArray(items) ? items : []) + .filter((it) => it && it.can_stop === true && isLive(it) && !busy.has(it.id)) + return candidates.length === 1 ? candidates[0] : null +} + /** * The status word a person reads. Honest about WHY it ended (the * `loopStatusLabel` rule, applied to executions): a timeout, a cancel and a diff --git a/src/frontend/src/composables/usePortalFileDrop.js b/src/frontend/src/composables/usePortalFileDrop.js index d527a8317..22600868b 100644 --- a/src/frontend/src/composables/usePortalFileDrop.js +++ b/src/frontend/src/composables/usePortalFileDrop.js @@ -16,7 +16,7 @@ * function, so the destination can move to the ent#484/#486 working folder * without the gesture changing. */ -import { ref } from 'vue' +import { markRaw, ref } from 'vue' // Mirrors the server's per-file ceiling (`MAX_UPLOAD_BYTES`, 25 MiB). Checked // client-side so a rejection names the file BEFORE 25 MiB crosses the wire; the @@ -45,6 +45,45 @@ export function isFileDrag(dataTransfer) { return Array.from(types).includes('Files') } +/** + * The files carried by a paste, or `[]` (#2794). + * + * Pasting a screenshot is how most people attach one — no file on disk, no + * Finder, just Cmd-Shift-4 and Cmd-V — and the Workspace composer simply ate it: + * there was no paste handler anywhere, on either chat surface, so the gesture + * did nothing and gave no reason. The reported session shows the cost: the + * client's file was named "Pasted image (3).png", i.e. they had already been + * driven out to a file manager to get it in at all. + * + * `clipboardData.files` is the answer where it exists; `items` is the fallback + * for the browsers that only populate that. Both are host objects, so both are + * normalised through `Array.from` rather than indexed. + */ +export function filesFromClipboard(clipboardData) { + if (!clipboardData) return [] + const direct = Array.from(clipboardData.files || []) + if (direct.length) return direct + const items = Array.from(clipboardData.items || []) + return items + .filter((it) => it && it.kind === 'file') + .map((it) => (typeof it.getAsFile === 'function' ? it.getAsFile() : null)) + .filter(Boolean) +} + +/** + * Does this paste also carry text that the person expects to be typed? + * + * Copying out of a rich editor puts BOTH an image and its text on the clipboard, + * and swallowing the paste there would silently delete what they meant to paste. + * So the file is attached either way and the default is only suppressed when + * there is no text to lose — which is exactly the screenshot case. + */ +export function clipboardHasText(clipboardData) { + const types = clipboardData?.types + if (!types) return false + return Array.from(types).includes('text/plain') +} + /** * Why this file cannot be sent, or null when it can. Returns the sentence the * chip shows — it names the file and the limit, never "upload failed". @@ -128,6 +167,9 @@ export function usePortalFileDrop(upload, { disabled = () => false } = {}) { const batchNotice = ref('') let dragDepth = 0 + // The in-flight batch, so a caller can ASK whether the gesture has landed + // (#2794). Without this the only way to know was to poll `entry.uploading`. + let inFlight = null // dragenter/dragleave fire for every child element the pointer crosses, so a // boolean toggled on leave flickers the affordance off while the pointer is @@ -159,6 +201,20 @@ export function usePortalFileDrop(upload, { disabled = () => false } = {}) { return addFiles(e.dataTransfer.files) } + /** + * Paste-to-attach (#2794). Same batch, same per-file outcome, same chips as a + * drop — a second path into `addFiles`, never a second implementation of it. + */ + function onPaste(e) { + if (disabled()) return + const files = filesFromClipboard(e.clipboardData) + if (!files.length) return + // Suppress the default ONLY when nothing else is on the clipboard; see + // `clipboardHasText`. A paste that carries both still types its text. + if (!clipboardHasText(e.clipboardData)) e.preventDefault() + return addFiles(files) + } + /** * The batch. Every file gets an entry before any upload starts, so the person * sees the whole gesture land at once rather than watching it appear one file @@ -184,6 +240,17 @@ export function usePortalFileDrop(upload, { disabled = () => false } = {}) { uploading: !rejection, error: rejection || '', done: false, + // #2794: the handle, kept so the SAME bytes can reach a SECOND + // destination without asking the person to pick the file again — + // which is what escalating a 1:1 into a room needs (the file has + // reached one agent's inbox; the room's other participants still need + // it, and a room-native drop is `one upload per participant`). + // + // `markRaw` is belt-and-braces: Vue's `reactive` already declines to + // proxy a `File` (it is not a plain object), but that is a fact about + // an internal type table rather than a promise, and a proxied `File` + // fails deep inside `FormData.append` where the cause is invisible. + file: markRaw(file), } entries.value.push(entry) return { file, entry, rejection } @@ -193,15 +260,52 @@ export function usePortalFileDrop(upload, { disabled = () => false } = {}) { // requests, and firing twenty at once is the surest way to trip it on a // gesture that would have succeeded spread over a second. A batch that does // trip it still reports per file, which is the AC. - for (const { file, entry, rejection } of mine) { - if (rejection) continue + // + // Chained onto whatever is already running rather than started beside it: + // two overlapping drops would otherwise interleave their requests, which is + // the burst the sequencing exists to avoid, and `settled()` could then + // resolve while the earlier batch was still going. + const run = Promise.resolve(inFlight).then(async () => { + for (const { file, entry, rejection } of mine) { + if (rejection) continue + try { + await upload(file) + entry.done = true + } catch (err) { + entry.error = uploadFailureReason(err) + } finally { + entry.uploading = false + } + } + }) + inFlight = run + await run + // Only the LAST batch clears the marker; an earlier one finishing must not + // report a later one as settled. + if (inFlight === run) inFlight = null + return entries.value + } + + /** + * Resolves once nothing is uploading (#2794). + * + * A send that happens while a chip is still spinning must not simply leave + * the file behind — "never silently dropped" is the rule. Waiting is the + * honest option and the cheap one: uploads are seconds, and the alternative + * (send now, tell them afterwards what did not make it) asks the person to + * fix something they cannot see the state of. + * + * Never rejects: a failed upload is recorded on its own entry, and a caller + * asking "has the gesture landed?" wants that answer, not an exception. + */ + async function settled() { + // A batch can chain another onto itself, so loop rather than await once. + while (inFlight) { try { - await upload(file) - entry.done = true - } catch (err) { - entry.error = uploadFailureReason(err) - } finally { - entry.uploading = false + await inFlight + } catch { + // Per-file failures already live on their entries. + break } } return entries.value @@ -223,8 +327,9 @@ export function usePortalFileDrop(upload, { disabled = () => false } = {}) { entries, batchNotice, addFiles, + settled, clear, removeAt, - handlers: { onDragEnter, onDragOver, onDragLeave, onDrop }, + handlers: { onDragEnter, onDragOver, onDragLeave, onDrop, onPaste }, } } diff --git a/src/frontend/src/main.js b/src/frontend/src/main.js index 80fc5f519..6c55b9ad6 100644 --- a/src/frontend/src/main.js +++ b/src/frontend/src/main.js @@ -5,7 +5,11 @@ import router from './router' import App from './App.vue' import './style.css' import { useAuthStore } from './stores/auth' -import { setPlatformSessionLostHandler } from './stores/clientPortal' +import { PORTAL_TOKEN_KEY } from './stores/clientPortal' +import { + notifyPlatformUnauthorized, readStoredToken, sessionLostVerdict, + setPlatformUnauthorizedHandler, TOKEN_KEY, tokenOfRequest, +} from './utils/platformSession' import { installConsoleBuffer } from './utils/consoleBuffer' // #1116: capture recent console errors/warnings from the very start so the @@ -29,52 +33,105 @@ authStore.initializeAuth() // and the auth store already live, and the instance's interceptor calls it ONLY // when the workspace session is the platform one — never for a client's 401 on a // browser that happens to hold an operator's JWT. -setPlatformSessionLostHandler(() => { - console.log('🔐 Workspace: platform session expired - redirecting to login') +// #2791 — ONE implementation of "a 401 came back; what does it mean?", shared by +// all three sites that used to answer it differently: this file's global axios +// interceptor, `api.js`'s instance interceptor, and `portalHttp`'s in +// `stores/clientPortal.js`. +// +// The verdict itself is a pure function (`utils/platformSession.js`) so it can be +// tested without a browser; this is the only part that needs the router and the +// store, which is why it lives here and reaches the other two by callback. +function handlePlatformUnauthorized(error) { + const path = router.currentRoute.value?.path || window.location.pathname + const verdict = sessionLostVerdict({ + failedToken: tokenOfRequest(error?.config), + storedToken: readStoredToken(), + portalTokenPresent: !!localStorage.getItem(PORTAL_TOKEN_KEY), + path, + }) + + if (verdict === 'ignore') return + + if (verdict === 'stale') { + // The credential that failed has already been replaced — by a re-login in + // this browser, in this tab or another. Destroying the session now would + // delete the NEW token, which is the reported bug: a Workspace tab left open + // across a logout/login killed the fresh session within one poll. + console.log('🔐 Session superseded — adopting the current one instead of logging out') + authStore.adoptStoredSession() + return + } + + console.log('🔐 Session expired - redirecting to login') + // NOT awaited, deliberately. `logout()` clears local state synchronously + // before its first `await` (#2258's ordering), so the `/login → /` router + // guard — which keys on `isAuthenticated` — is already satisfied when the push + // runs. Awaiting would hold the user on a dead page for the length of the + // server revoke, and a hung revoke would hold them there indefinitely. authStore.logout() router.push('/login') +} + +setPlatformUnauthorizedHandler(handlePlatformUnauthorized) + +// #2791 — every bare-`axios` caller gets the CURRENT credential, per request. +// +// There are ~368 `axios.get/post/...` call sites outside `api.js`, and they used +// to be served by `axios.defaults.headers.common['Authorization']` — an +// in-memory copy written once at login. That is the second credential source +// this issue is about: after another tab logged in or out, a tab was half on the +// old session (these callers) and half on the new one (`api.js`, which re-reads +// localStorage per request). +// +// Rebuilding here rather than rewriting 368 sites is what the AC's second half +// allows ("or is provably never read in preference to the store"), and it is the +// stronger of the two: a call site added tomorrow cannot forget to opt in. +// +// An EXPLICIT header on the config wins. Exactly one caller relies on that — the +// logout revoke, which must carry a token storage has already dropped (#2258's +// ordering) — and the other explicit sites pass `authStore.authHeader`, which +// derives from the same place, so "explicit" and "derived" cannot disagree. +axios.interceptors.request.use((config) => { + const headers = config.headers || {} + if (!headers.Authorization && !headers.authorization) { + const token = readStoredToken() + if (token) headers.Authorization = `Bearer ${token}` + } + config.headers = headers + return config }) -// Setup axios interceptor to handle token expiration +// #2791 — this interceptor no longer carries a predicate of its own. It used to +// duplicate `api.js`'s (`!onWorkspace || internalSession`), and the two drifted +// from `portalHttp`'s third one; the shared verdict now answers for all three. axios.interceptors.response.use( response => response, error => { - // If we get a 401 Unauthorized, token is expired or invalid - if (error.response?.status === 401) { - // Get the current route - const currentPath = router.currentRoute.value.path - - // Don't redirect if already on login or setup page, or when an EXTERNAL - // client holds a verified-email session on the workspace (#138): that - // surface owns its own session and handles its own 401, so a stale - // operator JWT must not bounce a signed-in client to /login. - // - // ent#357: an INTERNAL user on the workspace is the opposite case — their - // workspace session IS the platform session, so an expired JWT must - // bounce them like anywhere else. The discriminator is the portal token, - // not the path: same URL, two session kinds. - // Who gets bounced is decided by the PLATFORM token, not the portal one - // (/review I1). Reading the portal token here made the answer depend on - // timing: `fetchRoster`'s 401 handler calls `signOut()`, which removes it, - // so a second concurrent 401 saw no portal token and threw an external - // client onto the operator /login instead of the workspace sign-in form. - // "Does this browser hold a platform session that just expired?" is the - // actual question, and it has a stable answer. - const onWorkspace = currentPath.startsWith('/workspace') || currentPath.startsWith('/portal') - const internalSession = !!localStorage.getItem('token') - if (currentPath !== '/login' && currentPath !== '/setup' && currentPath !== '/m' - && (!onWorkspace || internalSession)) { - console.log('🔐 Session expired - redirecting to login') - - // Clear auth state - authStore.logout() - - // Redirect to login - router.push('/login') - } - } + if (error.response?.status === 401) notifyPlatformUnauthorized(error) return Promise.reject(error) } ) +// #2791 — cross-tab sync (AC #2). +// +// `localStorage` is the durable source of the platform credential and the only +// thing another tab can change, so the `storage` event is how a tab learns that +// a sibling logged in or out. Nothing in `src/frontend/src` listened for it +// before, which is why one browser could hold two live opinions about who was +// signed in. +// +// The event does NOT fire in the tab that made the change, so this is purely +// "somebody else did something". +// +// Neither branch navigates. A background tab pushing `/login` is the noise this +// issue reports; the visible tab converges through the router guard and its next +// request, both of which read the state set here. +window.addEventListener('storage', (event) => { + if (event.storageArea !== localStorage) return + if (event.key !== null && event.key !== TOKEN_KEY) return + // `key === null` is a whole-storage clear, which ends the session too. + if (readStoredToken()) authStore.adoptStoredSession() + else authStore.applySessionEndedElsewhere() +}) + app.mount('#app') diff --git a/src/frontend/src/stores/auth.js b/src/frontend/src/stores/auth.js index 28a96ff67..64f13a42b 100644 --- a/src/frontend/src/stores/auth.js +++ b/src/frontend/src/stores/auth.js @@ -1,5 +1,8 @@ import { defineStore } from 'pinia' import axios from 'axios' +import { + clearStoredSession, readStoredToken, readStoredUser, TOKEN_KEY, +} from '@/utils/platformSession' export const useAuthStore = defineStore('auth', { state: () => ({ @@ -140,8 +143,7 @@ export const useAuthStore = defineStore('auth', { } } catch (e) { console.warn('Failed to parse stored user, clearing credentials') - localStorage.removeItem('token') - localStorage.removeItem('auth0_user') + clearStoredSession() // #2791 — one implementation } } @@ -185,10 +187,71 @@ export const useAuthStore = defineStore('auth', { // exclusively. The clear-on-logout below stays so users carrying a // cookie from a pre-fix version get cleaned up on next logout (the // cookie's max-age=1800 also expires it within 30 minutes). + // #2791: this used to copy the token into + // `axios.defaults.headers.common['Authorization']`, which is the SECOND + // credential source the issue is about. A tab then had an in-memory copy + // that no other tab could correct, so after a re-login elsewhere it was half + // on the old session (bare-axios callers) and half on the new one + // (`api.js`, which re-reads localStorage per request). + // + // The copy is gone. `main.js` installs a global axios REQUEST interceptor + // that rebuilds the header from `readStoredToken()` on every request, so all + // ~368 bare-axios call sites get the current credential without being + // rewritten — and there is exactly one place a credential can come from. + // + // Kept as a named no-op rather than deleted at its three call sites: the + // sequencing those sites express ("the session is now established") is worth + // reading, and a future transport that genuinely needs a hook has somewhere + // to live. setupAxiosAuth() { - if (this.token) { - axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}` + /* no-op — see the note above (#2791) */ + }, + + // #2791 — adopt whatever platform session localStorage currently holds. + // + // Two callers, one rule: the `storage` listener (another tab logged in) and + // the `stale` arm of `sessionLostVerdict` (our in-memory token was + // superseded while a request was in flight). Both mean "the browser's + // session is not the one we were holding" and both want to CONVERGE on it + // rather than destroy it. + // + // Returns whether a session is now held, so a caller can branch without + // re-reading storage. + adoptStoredSession() { + const token = readStoredToken() + if (!token) { + this.applySessionEndedElsewhere() + return false } + if (this.token === token) return true + this.token = token + const stored = readStoredUser() + if (stored) this.user = stored + this.isAuthenticated = true + this.authError = null + // The profile belongs to whoever this token is; until /api/users/me + // answers, role-gated UI must stay closed (#2198's rule). + this.profileVerified = false + this.fetchUserProfile() + return true + }, + + // #2791 — another tab ended the session. Forget it HERE, locally. + // + // Deliberately not `logout()`: that would fire a second server revoke for a + // token already revoked, and — the reason this issue exists — it writes to + // localStorage, so N background tabs reacting to one `storage` event would + // each clear storage again. This only drops the in-memory mirror. + // + // It also does not navigate. A background tab pushing `/login` is the noise + // the issue reports; the router guard and the next 401 handle the visible + // tab, and both read the state this sets. + applySessionEndedElsewhere() { + this.token = null + this.user = null + this.isAuthenticated = false + this.profileVerified = false + this.mfaChallenge = null }, // Fetch the current user's profile from the backend and merge role/email @@ -443,6 +506,9 @@ export const useAuthStore = defineStore('auth', { // dashboard. // The revoke itself still carries the token: it rides the axios DEFAULT // header, which is deleted only after the call. + // #2791 — captured BEFORE the clear below, because it is what the + // server-side revoke is about (see the note beside the call). + const revoking = this.token this.token = null this.user = null this.isAuthenticated = false @@ -453,18 +519,39 @@ export const useAuthStore = defineStore('auth', { this.profileVerified = false this.authError = null this.mfaChallenge = null - localStorage.removeItem('token') - localStorage.removeItem('auth0_user') + // #2791: ONE implementation of "forget the session locally", shared with + // the 401 path in `api.js`, which used to remove `token` and leave + // `auth0_user` behind. + clearStoredSession() // #187: revoke the token server-side so an exfiltrated copy stops // working immediately. Best-effort — never block local logout if the // call fails. - try { - await axios.post('/api/auth/logout') - } catch (e) { - // ignore — local state is already cleared + // + // #2791: the token is passed EXPLICITLY here, and that is now + // load-bearing rather than tidy. The revoke used to ride + // `axios.defaults.headers.common['Authorization']`, which this method + // deleted afterwards; with the defaults copy gone (see `setupAxiosAuth`) + // the global request interceptor builds the header from storage — and + // storage was cleared three lines ago, by #2258's ordering, which must not + // change. So the revoke would have gone out unauthenticated and silently + // stopped revoking anything. Captured before the clear, sent after it: + // the interceptor leaves an explicit header alone. + if (revoking) { + try { + await axios.post('/api/auth/logout', null, { + headers: { Authorization: `Bearer ${revoking}` }, + }) + } catch (e) { + // ignore — local state is already cleared + } } + // #2791: nothing writes this any more (see `setupAxiosAuth`), but a tab + // that loaded the PREVIOUS build still carries the copy in memory, and a + // deploy does not reload open tabs. Cleared on the way out for exactly the + // reason the legacy cookie below is — leftovers from a pre-fix version get + // cleaned up on the next logout rather than outliving the session. delete axios.defaults.headers.common['Authorization'] // Clear the token cookie diff --git a/src/frontend/src/stores/clientPortal.js b/src/frontend/src/stores/clientPortal.js index ccebaf262..4c984e183 100644 --- a/src/frontend/src/stores/clientPortal.js +++ b/src/frontend/src/stores/clientPortal.js @@ -7,6 +7,7 @@ * endpoints — 404 in OSS/unentitled builds, but the route guard * ent#356 moved the module into OSS core, so it ships in every build. */ +import { markRaw } from 'vue' import { defineStore } from 'pinia' import { collaborationRecency, normalizeRoomRow, WORKSPACE_ROOT } from '@/components/portal/portalUtils' @@ -17,14 +18,43 @@ import { shouldRequestBriefing, } from '@/components/portal/portalBriefingState' import axios from 'axios' +import { notifyPlatformUnauthorized, setPlatformUnauthorizedHandler } from '@/utils/platformSession' import { useAuthStore } from './auth' + +// --- carry-log bounds (#2794 follow-up) -------------------------------------- +// +// Entries retain the `File` object, so the log is bounded three ways and the +// tightest one wins. Age is the honest bound (a carry is a seconds-to-minutes +// gesture); count and bytes exist so a pathological session cannot pin +// hundreds of megabytes in memory waiting for an age-out that may never come. +export const CARRY_MAX_AGE_MS = 15 * 60 * 1000 +export const CARRY_MAX_ENTRIES = 20 +export const CARRY_MAX_BYTES = 64 * 1024 * 1024 + +/** Newest-last, within every bound. Pure — exported for the unit suite. */ +export function pruneCarryLog(entries, now = Date.now()) { + let kept = (Array.isArray(entries) ? entries : []) + .filter((e) => e && e.file && now - e.at <= CARRY_MAX_AGE_MS) + if (kept.length > CARRY_MAX_ENTRIES) kept = kept.slice(kept.length - CARRY_MAX_ENTRIES) + // Drop oldest until the retained bytes fit. A single file over the cap is + // kept regardless: the alternative is silently refusing to carry the one + // file the person actually cares about. + let bytes = kept.reduce((n, e) => n + (e.size || 0), 0) + while (kept.length > 1 && bytes > CARRY_MAX_BYTES) { + bytes -= kept[0].size || 0 + kept = kept.slice(1) + } + return kept +} // #2162: the page size for a windowed report read. A dependency-free leaf // shared with the operator reports store — never re-typed here, since the // backend already owns REPORT_ROWS_PAGE_DEFAULT and a third hand-written copy // is the shape that drifts while each side's tests pin its own version. import { REPORT_ROWS_PAGE as ROWS_PAGE } from '@/utils/reportPaging' -const PORTAL_TOKEN_KEY = 'trinity.portalToken' +// #2791: exported so the cross-tab listener and the shared 401 verdict can ask +// whether a CLIENT session is live without re-deriving the key. +export const PORTAL_TOKEN_KEY = 'trinity.portalToken' // #2261 — per-TAB, so an operator working in another tab is untouched by a // client's idle timeout (that is the whole reason expiry may not end the // platform session). sessionStorage, not localStorage: it must survive a @@ -84,11 +114,12 @@ export const portalHttp = axios.create() // // A callback rather than a router import: the store is imported BY the views the // router loads, so importing the router here is a cycle. -let _onPlatformSessionLost = null - -export function setPlatformSessionLostHandler(fn) { - _onPlatformSessionLost = fn -} +// #2791: the per-module callback this file used to own is gone — the reaction is +// registered once, on `utils/platformSession.js`, and reached from all three +// transports. Kept as a thin re-export so an out-of-tree caller (or a test that +// has not been updated) still resolves to the one handler rather than silently +// registering a second. +export { setPlatformUnauthorizedHandler as setPlatformSessionLostHandler } portalHttp.interceptors.request.use((config) => { // The store is the ONLY source of a workspace credential. Whatever arrived on @@ -190,8 +221,17 @@ function installRotationInterceptor() { // token) must never reach it: their tab may well hold an operator's JWT, // and bouncing would destroy a session that did nothing wrong. if (error?.response?.status === 401) { + // #2791: the third 401 site now reports to the SAME handler as + // `api.js` and the global interceptor, which owns the verdict. + // + // `isPlatformSession` stays as the local gate, and it is not redundant + // with the shared verdict: it is the only thing that knows this tab's + // client session was SUPPRESSED (#2261's `platformFallbackSuppressed`), + // a state no amount of reading localStorage can reconstruct. The shared + // verdict then adds what this site could never see — whether the token + // that failed is still the stored one. try { - if (useClientPortalStore().isPlatformSession) _onPlatformSessionLost?.() + if (useClientPortalStore().isPlatformSession) notifyPlatformUnauthorized(error) } catch { // Pinia not active (module-scope request, or teardown): no session to // reason about, so there is nothing to bounce. @@ -349,6 +389,26 @@ export const useClientPortalStore = defineStore('clientPortal', { // // The rail owner drains it (`usePortalRailFeeds`); nothing else reads it. pendingUploadNotes: {}, + + // --- Carry log (#2794 follow-up) --- + // Files uploaded to an agent that have NOT yet gone out with a message, so + // an escalation into a room can take them along. + // + // It lives on the store rather than in the composer because there are TWO + // upload surfaces and only one of them is the composer: the rail's Files + // panel (`PortalRailFiles.vue::uploadBatch`) sends straight to its "Send + // to" target and keeps no pending state at all. A user who attaches there + // and then @mentions a second agent got nothing carried and — because the + // composer had no attachments — not even a notice saying so. `uploadDocument` + // is the ONE funnel all three surfaces already share (#2582), so recording + // here is what makes the carry surface-agnostic. + // + // Bounded three ways because these entries retain the `File` itself: + // by count, by age, and by total retained bytes (see `noteUploadForCarry`). + uploadCarryLog: [], + // agent -> ms timestamp. Everything logged at or before it has already gone + // out with a message (or belongs to a previous visit) and is not carried. + uploadsCarriedAt: {}, }), getters: { @@ -1355,9 +1415,51 @@ export const useClientPortalStore = defineStore('clientPortal', { { headers: this.authHeader } ) this.noteUploadPending(agentName) + this.noteUploadForCarry(agentName, file) return data }, + /** + * Remember a successful upload so an escalation can carry it (#2794). + * + * Only ever called from `uploadDocument`, i.e. after the server took the + * file — a refused upload is not carryable and must not be logged. + */ + noteUploadForCarry(agentName, file) { + if (!agentName || !file) return + const now = Date.now() + const entry = { + agent: agentName, + name: file.name, + size: Number(file.size) || 0, + // `markRaw` for the reason `usePortalFileDrop` gives: a proxied `File` + // fails deep inside `FormData.append`, where the cause is invisible. + file: markRaw(file), + at: now, + } + const next = this.uploadCarryLog.concat(entry) + this.uploadCarryLog = pruneCarryLog(next, now) + }, + + /** + * Everything logged for this agent up to now has been accounted for — it + * went out with a message, or the conversation was just opened. The + * composer's chips clear at exactly these moments; this is the same act for + * the surfaces that have no chips. + */ + markUploadsCarried(agentName) { + if (!agentName) return + this.uploadsCarriedAt = { ...this.uploadsCarriedAt, [agentName]: Date.now() } + }, + + /** Files sent to `agentName` that have not gone out with a message yet. */ + carryableUploadsFor(agentName) { + if (!agentName) return [] + const since = this.uploadsCarriedAt[agentName] || 0 + const fresh = pruneCarryLog(this.uploadCarryLog, Date.now()) + return fresh.filter((e) => e.agent === agentName && e.at > since) + }, + /** Mark one agent's inbox listing stale. Drained by the rail owner (#2582). */ noteUploadPending(agentName) { if (!agentName) return diff --git a/src/frontend/src/utils/boundedHttp.js b/src/frontend/src/utils/boundedHttp.js index 93b870725..be77e04ac 100644 --- a/src/frontend/src/utils/boundedHttp.js +++ b/src/frontend/src/utils/boundedHttp.js @@ -12,14 +12,22 @@ * `pruneQueueItemState`. One hung POST leaves that card's Send disabled and its * state unprunable for the life of the tab. * - * Why not `axios.create()`: `stores/auth.js` authenticates by mutating - * `axios.defaults.headers.common.Authorization` at login and deleting it at - * logout. `create()` snapshots defaults at construction, so a module-level - * instance would miss a later sign-in and keep a stale header after sign-out. + * Why not `axios.create()`: the credential is resolved PER REQUEST, by the + * global request interceptor `main.js` installs (#2791), and `create()` gives + * an instance its own interceptor chain that the global one never reaches. A + * module-level instance would therefore send no Authorization header at all. * These wrappers call the global per request, so the header, the base config * and the response interceptor all still resolve exactly as before — the only * thing added is a bound. * + * (Until #2791 the reason was different and is worth recording, because the + * conclusion survived the mechanism: `stores/auth.js` used to authenticate by + * mutating `axios.defaults.headers.common.Authorization` at login and deleting + * it at logout, and `create()` snapshots defaults at construction — so an + * instance would have missed a later sign-in and kept a stale header after + * sign-out. That mutation no longer exists; the global is still the only thing + * that carries a live credential.) + * * Why not `axios.defaults.timeout`: that is a process-wide mutation reaching * every other surface, including ones with legitimately long requests. * diff --git a/src/frontend/src/utils/platformSession.js b/src/frontend/src/utils/platformSession.js new file mode 100644 index 000000000..4f63974e5 --- /dev/null +++ b/src/frontend/src/utils/platformSession.js @@ -0,0 +1,189 @@ +/** + * The platform session, in one place (#2791). + * + * One browser used to hold the platform JWT in two places that could disagree — + * the in-memory `axios.defaults.headers.common['Authorization']` copy written by + * `auth.js::setupAxiosAuth`, and `localStorage['token']` re-read per request by + * `api.js` — with no cross-tab sync and three separate 401 handlers. The visible + * cost was a stale Workspace tab logging a freshly re-established session out: + * its poll went out on the OLD token, 401'd, and the handler called + * `authStore.logout()`, which removed the NEW session's token from localStorage. + * The handler never asked whether the token that failed was still the current one. + * + * This module owns three things, and nothing else in the app may re-derive them: + * + * 1. **where the credential lives** — `readStoredToken()` / `clearStoredSession()`; + * 2. **what a 401 means** — `sessionLostVerdict()`, the one predicate; + * 3. **how other tabs find out** — `installCrossTabSync()`. + * + * Everything decidable is a pure function of its arguments, because + * `vitest.config.js` pins `environment: 'node'` with no mount harness: a rule + * that lives inside an interceptor closure is a rule no unit test can reach, and + * this file exists precisely because three copies of one rule drifted. + */ + +export const TOKEN_KEY = 'token' +export const USER_KEY = 'auth0_user' + +/** Routes that are already the way out — bouncing from them is a loop. */ +const AUTH_ROUTES = ['/login', '/setup', '/m'] + +/** Surfaces whose session may be a CLIENT's rather than the operator's. */ +const WORKSPACE_PREFIXES = ['/workspace', '/portal'] + +export function isAuthRoute(path) { + return AUTH_ROUTES.includes(path || '') +} + +export function isWorkspacePath(path) { + const p = path || '' + return WORKSPACE_PREFIXES.some((prefix) => p.startsWith(prefix)) +} + +/** + * The stored platform credential, or null. + * + * `localStorage` is the durable source and the one other tabs mutate, so it is + * the source of truth; the Pinia store mirrors it. Reads are wrapped because a + * private window with site data blocked throws on access rather than returning + * null — and a throw here would take down an interceptor on every request. + */ +export function readStoredToken() { + try { + return localStorage.getItem(TOKEN_KEY) || null + } catch { + return null + } +} + +export function readStoredUser() { + try { + const raw = localStorage.getItem(USER_KEY) + return raw ? JSON.parse(raw) : null + } catch { + return null + } +} + +/** + * Forget the platform session locally. ONE implementation (AC #6). + * + * `api.js`'s 401 path used to remove only `token` and hard-reload, leaving + * `auth0_user` behind — so the next load restored a user object for a session + * that no longer existed, and `initializeAuth` skipped its own cleanup branch + * because the pair was no longer both-present. + */ +export function clearStoredSession() { + try { + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(USER_KEY) + } catch { + /* storage unavailable — there is nothing to clear */ + } +} + +/** + * The bearer token an axios request actually went out with, or null. + * + * Read from the request config rather than from anywhere current: the question + * `sessionLostVerdict` asks is whether the credential that FAILED is still the + * one we hold, and only the config knows what was sent. + */ +export function tokenOfRequest(config) { + const headers = config?.headers || {} + const raw = headers.Authorization || headers.authorization || '' + const value = typeof raw === 'string' ? raw : '' + return value.startsWith('Bearer ') ? value.slice(7) : null +} + +/** + * What to do about a 401. The ONE predicate, replacing three copies. + * + * @returns {'ignore'|'stale'|'logout'} + * + * * `ignore` — this 401 is not the platform session's to act on; + * * `stale` — the credential that failed has since been REPLACED, so the + * session it belonged to is already gone and the current one is + * innocent. Re-adopt what is stored; never destroy it; + * * `logout` — the stored platform credential is the one that failed. + * + * The `stale` arm is finding 1 of the issue, and it is the whole reason this + * function takes `failedToken`. A Workspace tab left open across a logout and a + * re-login holds the previous JWT in a closure; its next poll 401s; the old code + * ran `logout()` and deleted the NEW session's token from under the tab that had + * just created it. Comparing the two answers that in one line. + * + * Order is load-bearing: + * - auth routes first, so nothing can bounce off the page that fixes it; + * - `stale` before every session question, because a superseded credential + * says nothing about the session that replaced it; + * - the Workspace/portal veto (AC #5) before the final logout, so a CLIENT + * whose browser happens to hold a dead operator JWT is not thrown onto the + * operator login on page load. `initializeAuth` calls `fetchUserProfile` + * through bare axios on EVERY load, which is exactly how that fired. + * + * Note the veto is scoped by path as well as by portal token. Off the Workspace + * the surface itself is an operator one, so an expired operator JWT bounces + * there even if a portal token is lying around — that is today's behaviour and + * this change does not widen it. + */ +export function sessionLostVerdict({ + failedToken = null, + storedToken = null, + portalTokenPresent = false, + path = '', +} = {}) { + if (isAuthRoute(path)) return 'ignore' + + // Superseded: someone replaced the credential between the request and its + // answer. Whatever went wrong belonged to a session that is already over. + if (failedToken && storedToken && failedToken !== storedToken) return 'stale' + + const onWorkspace = isWorkspacePath(path) + + // No platform session to end. On the Workspace that is the ordinary state of + // an external client; anywhere else it still means "go and sign in". + if (!storedToken) return onWorkspace ? 'ignore' : 'logout' + + // AC #5 — a live client session owns this tab, and the platform credential + // beside it is not what the person is using. + if (onWorkspace && portalTokenPresent) return 'ignore' + + return 'logout' +} + + +// --------------------------------------------------------------------------- +// The one handler, reached from three transports (AC #3) +// --------------------------------------------------------------------------- +// +// Acting on the verdict needs the router and the auth store, and both of those +// import (transitively) the modules that need to CALL this — so the reaction is +// registered from `main.js`, where they already live, and the transports reach +// it through here. A direct import would be a cycle; this is the same shape +// `clientPortal.js::setPlatformSessionLostHandler` already uses, generalised so +// there is one of it instead of one per transport. + +let _onPlatformUnauthorized = null + +export function setPlatformUnauthorizedHandler(fn) { + _onPlatformUnauthorized = fn +} + +/** + * Report a 401 to the single handler. Never throws and never returns a promise + * the caller must await: an interceptor's job is to reject the original error, + * not to wait on the logout it may have triggered. + */ +export function notifyPlatformUnauthorized(error) { + try { + const result = _onPlatformUnauthorized?.(error) + // The reaction pushes a route, and Vue Router REJECTS a redundant or + // aborted navigation. A sync try/catch cannot see that, so a second 401 + // arriving while /login is already loading would surface as an unhandled + // rejection in every user's console — noise that looks like a real fault. + if (result && typeof result.catch === 'function') result.catch(() => {}) + } catch { + /* a failure to react must never replace the error being rejected */ + } +} diff --git a/src/frontend/src/utils/sttCapability.js b/src/frontend/src/utils/sttCapability.js new file mode 100644 index 000000000..0c4484d5b --- /dev/null +++ b/src/frontend/src/utils/sttCapability.js @@ -0,0 +1,71 @@ +// #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 Workspace server-side dictation ' + + 'is disabled (the browser\'s own dictation engine, where it has one, still works). ' + + '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.', + } + } +} + +const FAILURE_CATEGORY_TEXT = Object.freeze({ + permission: 'the key is missing the speech-to-text permission', + auth: 'the key was rejected', + quota: 'out of credits, or the plan does not allow speech-to-text', + rate_limit: 'the provider rate-limited the request', + audio: 'the recording was rejected by the provider', + provider: 'the provider failed', + unknown: 'the provider answered with an unrecognised error', +}) + +/** + * #2696 — the last live `/stt` failure, in operator words. `null` when there is + * none to report. The client got a category sentence at the time; this is the + * half with the provider's status word, which only the admin panel carries. + * @param {{ category?: string, provider_status?: number, detail?: string|null, at?: number|null }|null|undefined} failure + * @returns {{ text: string, at: number|null }|null} + */ +export function describeSttLastFailure(failure) { + if (!failure || !failure.category) return null + const why = FAILURE_CATEGORY_TEXT[failure.category] || FAILURE_CATEGORY_TEXT.unknown + const status = failure.provider_status ? `HTTP ${failure.provider_status}` : '' + const word = failure.detail ? `${failure.detail}` : '' + const provider = [status, word].filter(Boolean).join(' ') + return { + text: `Last voice-input failure: ${why}${provider ? ` (${provider})` : ''}.`, + at: typeof failure.at === 'number' ? failure.at : null, + } +} diff --git a/src/frontend/src/views/Portal.vue b/src/frontend/src/views/Portal.vue index dc36e7f73..1964eb172 100644 --- a/src/frontend/src/views/Portal.vue +++ b/src/frontend/src/views/Portal.vue @@ -260,7 +260,9 @@ :starred="isStarred('room', activeRoomIdFromRoute)" :prefill="prefill" :rename="renameRoom" + :carry-notice="activeRoomCarryNotice" @open-menu="mobileNav = true" + @dismiss-carry-notice="roomCarryNotice = null" @rooms-changed="refreshThreads" @toggle-star="toggleStar" @participants-changed="onRoomParticipants" @@ -693,6 +695,9 @@ import PortalRailFiles from '@/components/portal/PortalRailFiles.vue' import PortalCodeInput from '@/components/portal/PortalCodeInput.vue' import PortalAgentPicker from '@/components/portal/PortalAgentPicker.vue' import PortalRoom from '@/components/portal/PortalRoom.vue' +import { + partitionAttachments, fanOutPlan, carriedNotice, noticeIsProblem, mergeCarrySources, +} from '@/components/portal/portalAttachments' import PortalAgentBand from '@/components/portal/PortalAgentBand.vue' import PortalAgentDetails from '@/components/portal/PortalAgentDetails.vue' import ColumnResizeHandle from '@/components/ColumnResizeHandle.vue' @@ -1226,14 +1231,79 @@ const pickerError = ref(null) // created-but-unreachable room does not. const escalating = ref(false) -async function onEscalateToRoom({ agents, message } = {}) { +// #2794 — what the room says about the files that came with the escalated +// message, scoped to the room it belongs to so it cannot follow the reader +// into a different conversation. Held by the SHELL and not by the room: +// the carry happens while the room is still mounting, and a notice owned by a +// component that does not exist yet has nowhere to live. +const roomCarryNotice = ref(null) +const activeRoomCarryNotice = computed(() => ( + roomCarryNotice.value && roomCarryNotice.value.roomId === activeRoomIdFromRoute.value + ? roomCarryNotice.value + : null +)) + +async function onEscalateToRoom({ agents, message, attachments = [] } = {}) { if (escalating.value || !agents?.length) return escalating.value = true + roomCarryNotice.value = null try { const room = await store.createRoom(agents, `Chat with ${agents.join(', ')}`) const roomId = room.id || room.room_id await refreshThreads() openRoom(roomId) + + // #2794 — the attachments travel with the message. + // + // BEFORE the post, never after: the message is what wakes the mentioned + // agent, and a turn that starts before the file is in that agent's inbox + // cannot see the thing it was asked about. The order is the feature. + // + // The fan-out rule is the ROOM's own (`PortalRoom.vue`: one upload per + // participant), applied to the participants that do not already have the + // file — the origin agent received it when the chip was drawn, and sending + // it again would put two copies in one inbox. + // Both upload surfaces, not just the composer: the rail's Files panel sends + // straight to its target and holds no pending state, so a file attached + // there was invisible to the escalation — no carry and no notice. The + // carry log is the store's record of uploads that have not yet gone out + // with a message; the composer's own entries win a tie. + const { carried, dropped } = partitionAttachments( + mergeCarrySources(attachments, store.carryableUploadsFor(agents[0])), + ) + const plan = fanOutPlan(carried, { origin: agents[0], participants: agents }) + // Read OFF the plan rather than re-derived from `agents`: the plan already + // excludes the origin agent and collapses a duplicate mention, and two + // places deciding who the recipients are is how the notice ends up naming + // somebody the fan-out never wrote to. + const recipients = plan.length ? plan[0].agents : [] + const failures = [] + for (const item of plan) { + const missed = [] + for (const name of item.agents) { + // Sequential and per-agent: the per-email upload limiter counts + // requests (ent#287), and one refused participant is reported as + // itself rather than failing the whole carry — the same per-file, + // per-destination honesty a room-native drop already has. + try { + await store.uploadDocument(name, item.file) + } catch { + missed.push(name) + } + } + if (missed.length) failures.push({ name: item.name, agents: missed }) + } + + // Consumed: these have now gone out with a message, so a LATER escalation + // in this conversation must not carry them a second time. Same moment the + // composer clears its chips. + store.markUploadsCarried(agents[0]) + + const notice = carriedNotice({ carried, dropped, failures, recipients }) + if (notice) { + roomCarryNotice.value = { roomId, text: notice, problem: noticeIsProblem({ dropped, failures }) } + } + if (message) { try { await store.postRoomMessage(roomId, message) @@ -1241,7 +1311,9 @@ async function onEscalateToRoom({ agents, message } = {}) { } } catch (err) { // Escalation failed, so the user is still in the 1:1 with an emptied - // composer. Give the text back rather than losing what they typed. + // composer. Give the text back rather than losing what they typed — and + // the attachment chips are still standing beside it, because the + // conversation deliberately does not clear them on escalate (#2794). prefill.value = '' await nextTick() prefill.value = message || '' diff --git a/src/frontend/src/views/Settings.vue b/src/frontend/src/views/Settings.vue index e9397774f..6d76ecb95 100644 --- a/src/frontend/src/views/Settings.vue +++ b/src/frontend/src/views/Settings.vue @@ -574,6 +574,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 }}

+ +

{{ sttLastFailure.text }} — {{ new Date(sttLastFailure.at * 1000).toLocaleString() }}

@@ -2138,6 +2170,7 @@ import { useSettingsStore } from '../stores/settings' import { useSessionsStore } from '../stores/sessions' import { apiErrorMessage } from '../utils/apiError' import { readOpsBool, opsBoolValue } from '../utils/opsSettings' +import { describeSttCapability, describeSttLastFailure } from '../utils/sttCapability' import { useEnterpriseStore } from '../stores/enterprise' import NavBar from '../components/NavBar.vue' import McpKeysTab from '../components/settings/McpKeysTab.vue' @@ -2564,7 +2597,18 @@ 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, + 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, + stt_detail: elevenLabs.sttDetail, +})) const savingElevenLabs = ref(false) const elevenLabsSaveSuccess = ref(false) const elevenLabsError = ref('') @@ -3067,6 +3111,11 @@ 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 + elevenLabs.sttLastFailure = state.stt_last_failure ?? null } async function loadElevenLabsSettings() { diff --git a/src/frontend/tests/unit/platformSessionSync.spec.js b/src/frontend/tests/unit/platformSessionSync.spec.js new file mode 100644 index 000000000..0ed99da48 --- /dev/null +++ b/src/frontend/tests/unit/platformSessionSync.spec.js @@ -0,0 +1,217 @@ +/** + * #2791 — the store side: adopting a session, losing one to another tab, and + * the source guards for the wiring a node-env spec cannot execute. + * + * `vitest.config.js` pins `environment: 'node'`: there is no `window`, so the + * interceptors and the `storage` listener registered in `main.js` cannot be + * driven here. What CAN be driven is everything they delegate to, which is why + * #2791 put the verdict in a pure function and the reaction in two store actions + * instead of inside three closures. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { readFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { stripComments } from './helpers/stripComments' + +// node has no localStorage; the same hoisted shim `workspaceSignOut.spec.js` +// uses, because `auth.js` reads storage at import time. +vi.hoisted(() => { + const store = new Map() + globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), + clear: () => store.clear(), + } +}) + +vi.mock('axios', () => { + const mock = { + get: vi.fn().mockResolvedValue({ data: { email: 'op@example.com', role: 'admin' } }), + post: vi.fn().mockResolvedValue({ data: {} }), + put: vi.fn().mockResolvedValue({ data: {} }), + defaults: { headers: { common: {} } }, + interceptors: { request: { use: vi.fn() }, response: { use: vi.fn() } }, + create: vi.fn(() => ({ + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), + defaults: { headers: { common: {} } }, + interceptors: { request: { use: vi.fn() }, response: { use: vi.fn() } }, + })), + } + return { default: mock } +}) + +const read = (rel) => stripComments( + readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8'), +) +const MAIN = read('../../src/main.js') +const API = read('../../src/api.js') +const AUTH = read('../../src/stores/auth.js') +const PORTAL = read('../../src/stores/clientPortal.js') + +beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + vi.clearAllMocks() +}) + +describe('adopting the session the browser actually holds', () => { + it('a superseded tab converges on the CURRENT token instead of destroying it', async () => { + const { useAuthStore } = await import('@/stores/auth') + const auth = useAuthStore() + auth.token = 'old-jwt' + auth.isAuthenticated = true + localStorage.setItem('token', 'new-jwt') + localStorage.setItem('auth0_user', JSON.stringify({ email: 'op@example.com' })) + + expect(auth.adoptStoredSession()).toBe(true) + expect(auth.token).toBe('new-jwt') + expect(auth.isAuthenticated).toBe(true) + // The whole point: the new session's credential is still in storage. + expect(localStorage.getItem('token')).toBe('new-jwt') + }) + + it('role-gated UI stays closed until the new token’s own profile lands (#2198)', async () => { + const { useAuthStore } = await import('@/stores/auth') + const auth = useAuthStore() + auth.token = 'old-jwt' + auth.profileVerified = true // verified for the PREVIOUS principal + localStorage.setItem('token', 'new-jwt') + + auth.adoptStoredSession() + expect(auth.profileVerified).toBe(false) + }) + + it('adopting an identical token is a no-op, so a storm of 401s costs one fetch', async () => { + const axios = (await import('axios')).default + const { useAuthStore } = await import('@/stores/auth') + const auth = useAuthStore() + auth.token = 'jwt' + localStorage.setItem('token', 'jwt') + + expect(auth.adoptStoredSession()).toBe(true) + expect(axios.get).not.toHaveBeenCalled() + }) + + it('adopting when storage is empty ends the session locally', async () => { + const { useAuthStore } = await import('@/stores/auth') + const auth = useAuthStore() + auth.token = 'jwt' + auth.isAuthenticated = true + + expect(auth.adoptStoredSession()).toBe(false) + expect(auth.isAuthenticated).toBe(false) + expect(auth.token).toBeNull() + }) +}) + +describe('a sibling tab ending the session', () => { + it('drops the in-memory mirror without a second server revoke', async () => { + const axios = (await import('axios')).default + const { useAuthStore } = await import('@/stores/auth') + const auth = useAuthStore() + auth.token = 'jwt' + auth.isAuthenticated = true + auth.profileVerified = true + + auth.applySessionEndedElsewhere() + + expect(auth.isAuthenticated).toBe(false) + expect(auth.profileVerified).toBe(false) + // `logout()` would POST /api/auth/logout for a token another tab already + // revoked, and would write to localStorage — so N background tabs reacting + // to one `storage` event would each clear storage again. + expect(axios.post).not.toHaveBeenCalled() + }) + + it('does not itself write to storage — the tab that logged out already did', async () => { + const { useAuthStore } = await import('@/stores/auth') + localStorage.setItem('auth0_user', JSON.stringify({ email: 'someone@example.com' })) + const spy = vi.spyOn(localStorage, 'removeItem') + useAuthStore().applySessionEndedElsewhere() + expect(spy).not.toHaveBeenCalled() + // …and the sibling's own key is untouched, so a tab that ended a PORTAL + // session has not also wiped the platform user record. + expect(localStorage.getItem('auth0_user')).not.toBeNull() + spy.mockRestore() + }) +}) + +describe('one credential source, one handler (source guards)', () => { + it('nothing writes the axios defaults Authorization copy any more', () => { + // The second credential source. Only the logout cleanup may still DELETE it + // (a tab running the previous build still carries one). + const writes = AUTH.match(/axios\.defaults\.headers\.common\['Authorization'\]\s*=/g) || [] + expect(writes).toEqual([]) + expect(AUTH).toContain("delete axios.defaults.headers.common['Authorization']") + }) + + it('every transport derives the header from the one reader', () => { + expect(API).toContain('readStoredToken()') + expect(MAIN).toContain('readStoredToken()') + // `api.js` must not re-read storage directly any more. + expect(API).not.toMatch(/localStorage\.getItem\(['"]token['"]\)/) + }) + + it('all three 401 sites report to the single handler', () => { + expect(API).toContain('notifyPlatformUnauthorized(error)') + expect(MAIN).toContain('notifyPlatformUnauthorized(error)') + expect(PORTAL).toContain('notifyPlatformUnauthorized(error)') + // …and exactly one of them registers the reaction. + expect(MAIN).toContain('setPlatformUnauthorizedHandler(handlePlatformUnauthorized)') + }) + + it('none of them carries a private copy of the bounce predicate', () => { + // The duplicated expression that drifted three ways. + for (const src of [API, MAIN]) { + expect(src).not.toContain('const internalSession =') + expect(src).not.toMatch(/!onWorkspace \|\| internalSession/) + } + }) + + it('the api.js 401 path no longer hard-reloads or half-clears', () => { + expect(API).not.toContain("window.location.href = '/login'") + expect(API).not.toContain("localStorage.removeItem('token')") + }) + + it('a logout elsewhere is heard, and only for the platform token', () => { + expect(MAIN).toContain("window.addEventListener('storage'") + expect(MAIN).toContain('adoptStoredSession()') + expect(MAIN).toContain('applySessionEndedElsewhere()') + // A whole-storage clear (`key === null`) must count as the session ending. + expect(MAIN).toContain('event.key !== null && event.key !== TOKEN_KEY') + }) + + it('a rejected navigation from the reaction never escapes as an unhandled rejection', async () => { + const { setPlatformUnauthorizedHandler, notifyPlatformUnauthorized } = + await import('@/utils/platformSession') + // Vue Router rejects a redundant navigation, which is exactly what a second + // 401 arriving while /login is already loading produces. + setPlatformUnauthorizedHandler(() => Promise.reject(new Error('redundant navigation'))) + expect(() => notifyPlatformUnauthorized({})).not.toThrow() + await new Promise((r) => setTimeout(r, 0)) // let the rejection settle + setPlatformUnauthorizedHandler(null) + }) + + it('a throwing reaction never replaces the error being rejected', async () => { + const { setPlatformUnauthorizedHandler, notifyPlatformUnauthorized } = + await import('@/utils/platformSession') + setPlatformUnauthorizedHandler(() => { throw new Error('boom') }) + expect(() => notifyPlatformUnauthorized({})).not.toThrow() + setPlatformUnauthorizedHandler(null) + }) + + it('the reaction does not hold the user on a dead page for the revoke', () => { + // `logout()` clears local state synchronously before its first await, so + // the router guard is satisfied without waiting for the network call. + expect(MAIN).not.toContain('await authStore.logout()') + }) + + it('the global request interceptor leaves an explicit header alone', () => { + // The logout revoke depends on it: #2258 clears storage BEFORE the revoke, + // so the only credential that call can carry is the explicit one. + expect(MAIN).toContain('if (!headers.Authorization && !headers.authorization)') + expect(AUTH).toContain('headers: { Authorization: `Bearer ${revoking}` }') + }) +}) diff --git a/src/frontend/tests/unit/platformSessionVerdict.spec.js b/src/frontend/tests/unit/platformSessionVerdict.spec.js new file mode 100644 index 000000000..13e8aeb6a --- /dev/null +++ b/src/frontend/tests/unit/platformSessionVerdict.spec.js @@ -0,0 +1,133 @@ +/** + * #2791 — one platform credential, one 401 verdict. + * + * The reported symptom: log out and log back in on the main app with a + * Workspace tab still open from the previous session, and the NEW session dies + * within seconds. That tab holds the old JWT in a closure, its 20s asks poll + * 401s, and the handler calls `authStore.logout()` — which removes + * `localStorage['token']`, i.e. the token the re-login had just written. The + * handler never asked whether the credential that failed was still the current + * one. + * + * `sessionLostVerdict` is that question, asked once, in a pure function three + * interceptors share. This file is the table. + */ +import { describe, it, expect } from 'vitest' +import { + isAuthRoute, + isWorkspacePath, + sessionLostVerdict, + tokenOfRequest, +} from '@/utils/platformSession' + +const OLD = 'jwt-from-the-previous-session' +const NEW = 'jwt-from-the-re-login' + +describe('the superseded-token arm (finding 1 — the reported bug)', () => { + it('a stale tab whose token was replaced does NOT destroy the new session', () => { + expect(sessionLostVerdict({ + failedToken: OLD, + storedToken: NEW, + path: '/workspace', + })).toBe('stale') + }) + + it('is decided by the token, not by the surface — the main app is just as vulnerable', () => { + // A dashboard tab left open across a re-login holds the old JWT in + // `axios.defaults` exactly as the Workspace tab does. + expect(sessionLostVerdict({ + failedToken: OLD, storedToken: NEW, path: '/agents/scout', + })).toBe('stale') + }) + + it('still logs out when the credential that failed IS the stored one', () => { + // The ordinary expiry case must keep working — this is not a blanket + // "never log out", which would leave a dead session on screen forever. + expect(sessionLostVerdict({ + failedToken: OLD, storedToken: OLD, path: '/agents/scout', + })).toBe('logout') + }) + + it('logs out when the failed token is unknown, rather than assuming innocence', () => { + // A request that carried no Authorization header (or a caller we cannot + // read) must not buy immunity: absence is not evidence of supersession. + expect(sessionLostVerdict({ + failedToken: null, storedToken: OLD, path: '/agents/scout', + })).toBe('logout') + }) +}) + +describe('a client is never thrown onto the operator login (AC #5, #2261 preserved)', () => { + it('a Workspace client whose browser holds a DEAD operator JWT is not bounced', () => { + // `initializeAuth` calls `fetchUserProfile` through bare axios on every page + // load. Before this, its 401 bounced the client to the operator /login, and + // navigating back to /workspace signed them in again. + expect(sessionLostVerdict({ + failedToken: OLD, storedToken: OLD, + portalTokenPresent: true, path: '/workspace', + })).toBe('ignore') + }) + + it('an anonymous visitor on the Workspace is not bounced either', () => { + expect(sessionLostVerdict({ storedToken: null, path: '/workspace' })).toBe('ignore') + expect(sessionLostVerdict({ storedToken: null, path: '/workspace/c/abc' })).toBe('ignore') + expect(sessionLostVerdict({ storedToken: null, path: '/portal' })).toBe('ignore') + }) + + it('an OPERATOR on the Workspace whose session expired IS still bounced (ent#357)', () => { + // Their workspace session IS the platform session, so there is no second + // credential to fall back to. Dropping this would strand them. + expect(sessionLostVerdict({ + failedToken: OLD, storedToken: OLD, + portalTokenPresent: false, path: '/workspace', + })).toBe('logout') + }) + + it('off the Workspace a stray portal token does not veto the bounce', () => { + // The veto is about which session owns the SURFACE. `/agents/scout` is an + // operator surface whatever else the browser is holding, and widening the + // veto to every path would leave a dead operator session rendered. + expect(sessionLostVerdict({ + failedToken: OLD, storedToken: OLD, + portalTokenPresent: true, path: '/agents/scout', + })).toBe('logout') + }) + + it('a signed-out browser off the Workspace is still sent to sign in', () => { + expect(sessionLostVerdict({ storedToken: null, path: '/agents/scout' })).toBe('logout') + }) +}) + +describe('the pages that are already the way out', () => { + it('never bounce off /login, /setup or /m', () => { + for (const path of ['/login', '/setup', '/m']) { + expect(sessionLostVerdict({ failedToken: OLD, storedToken: OLD, path })).toBe('ignore') + } + }) + + it('and the auth-route test is exact, not a prefix', () => { + // `/login` must not shield `/loginsomething`, and the Workspace prefixes + // deliberately ARE prefixes because they carry sub-routes. + expect(isAuthRoute('/login')).toBe(true) + expect(isAuthRoute('/login/extra')).toBe(false) + expect(isWorkspacePath('/workspace/r/room_1')).toBe(true) + expect(isWorkspacePath('/worksp')).toBe(false) + }) +}) + +describe('reading the credential a request actually carried', () => { + it('pulls the bearer out of either header casing', () => { + expect(tokenOfRequest({ headers: { Authorization: `Bearer ${OLD}` } })).toBe(OLD) + expect(tokenOfRequest({ headers: { authorization: `Bearer ${OLD}` } })).toBe(OLD) + }) + + it('answers null rather than guessing', () => { + // Every one of these must read as "unknown", which the verdict table above + // treats as NOT superseded — the conservative direction. + expect(tokenOfRequest(undefined)).toBeNull() + expect(tokenOfRequest({})).toBeNull() + expect(tokenOfRequest({ headers: {} })).toBeNull() + expect(tokenOfRequest({ headers: { Authorization: 'Basic abc' } })).toBeNull() + expect(tokenOfRequest({ headers: { Authorization: 123 } })).toBeNull() + }) +}) diff --git a/src/frontend/tests/unit/roomComposerChain.spec.js b/src/frontend/tests/unit/roomComposerChain.spec.js index c9de6fc64..970bbb024 100644 --- a/src/frontend/tests/unit/roomComposerChain.spec.js +++ b/src/frontend/tests/unit/roomComposerChain.spec.js @@ -1,25 +1,30 @@ /** - * #2620 — the room composer's `v-else` must stay chained to the attachments - * block. + * The room composer renders on a condition it STATES (#2620, rewritten #2794). * - * `PortalRoom.vue` renders the attachment chips OR the composer: + * #2620 shipped this file to stop a conditional being inserted between the + * attachment chips and ``, because `v-else` binds to the + * immediately preceding element and the composer would then render on the + * wrong condition. The mechanism it describes is real and the hazard is real. * - *
…chips…
- * …composer… + * What it got wrong is WHICH relationship was correct. The composer was + * written as `v-else` to the "this conversation has ended" line (ent#358) — + * render the composer unless the room is closed. By the time #2620 looked, the + * batch notice, the attachment chips (ent#524) and its own budget banner had + * each been inserted in between, so the chain already ended on + * `attachments.length`. #2620 then pinned that as the contract, and with it two + * live defects: attaching a file to a room REPLACED the composer (and the room + * cleared no chips, so it never came back), and a closed room rendered a live + * composer directly under the line saying it had ended. * - * `v-else` binds to the **immediately preceding element**, so anything - * conditional inserted between them silently steals the chain and the composer - * then renders on the new condition instead. During this issue a banner was - * added exactly there, and the effect was that the composer DISAPPEARED at the - * moment the banner fired — the warning removed the ability to act on it. + * So the composer now carries `v-if="!isClosed"`. A `v-else` is a promise about + * whatever element happens to sit above it, and this neighbourhood has broken + * that promise three times in three changes. * - * Nothing else catches this: the SFC compiles (a `v-else` after any `v-if` is - * valid) and the suite has no mount harness, so nothing renders the template. - * - * This walks the parsed template AST rather than matching text. A first - * attempt did match text — "is the gap after the last `` empty" — and - * was VACUOUS, because the intruder's own closing tag becomes the last one and - * the gap reads clean. A structural question needs the structure. + * This file therefore pins the OUTCOME rather than the chain: the composer's + * condition is its own and names the closed state; the chips render beside the + * composer rather than instead of it; and the banner is still there. It walks + * the parsed template AST rather than matching text — a structural question + * needs the structure (#2620's own first attempt matched text and was vacuous). */ import { describe, it, expect } from 'vitest' import { readFileSync } from 'fs' @@ -59,33 +64,53 @@ function elementChildren(node) { return (node.children || []).filter((c) => c.type === ELEMENT) } -describe('#2620 room composer v-if/v-else chain', () => { +describe('#2620/#2794 the room composer renders on a stated condition', () => { const ast = templateAst() const all = elements(ast) - it('the composer form carries v-else', () => { - const form = all.find((n) => n.tag === 'form' && directive(n, 'else')) - expect(form, 'no
found — did the composer change shape?').toBeTruthy() + const composer = () => all.find((n) => n.tag === 'form' && directive(n, 'if')) + + it('the composer names its own condition, and it is the closed state', () => { + // THE property. Anything inserted above a `v-else` silently repoints it; + // a stated condition cannot be stolen by a neighbour. + const form = composer() + expect(form, 'no found — did the composer go back to v-else?').toBeTruthy() + expect(directive(form, 'if').exp.content).toContain('isClosed') }) - it('its preceding element sibling is the attachments v-if block', () => { - // THE property. `v-else` binds to the previous element sibling, so this is - // the one relationship that decides whether the composer renders. - const form = all.find((n) => n.tag === 'form' && directive(n, 'else')) - const parent = all.find((n) => elementChildren(n).includes(form)) - expect(parent, 'could not locate the form’s parent').toBeTruthy() + it('no composer form carries v-else or v-else-if', () => { + const chained = all.find((n) => n.tag === 'form' && (directive(n, 'else') || directive(n, 'else-if'))) + expect( + chained, + 'the composer is chained to whatever element precedes it again — that is ' + + 'how it came to render on `attachments.length` (#2794)', + ).toBeFalsy() + }) + it('the attachment chips render BESIDE the composer, not instead of it', () => { + // The defect in one assertion: with the chips and the composer on mutually + // exclusive conditions, attaching a file removes the box you type in. + const chips = all.find((n) => { + const vIf = directive(n, 'if') + return vIf && vIf.exp.content.includes('attachments.length') + }) + expect(chips, 'the attachment chip block is gone').toBeTruthy() + + const form = composer() + const parent = all.find((n) => elementChildren(n).includes(form)) const siblings = elementChildren(parent) - const prev = siblings[siblings.indexOf(form) - 1] - expect(prev, 'the form has no preceding element — its v-else binds to nothing').toBeTruthy() + // Both are children of the composer region, and both can be true at once. + expect(siblings).toContain(chips) + expect(directive(chips, 'else')).toBeFalsy() + expect(directive(chips, 'else-if')).toBeFalsy() + }) - const vIf = directive(prev, 'if') - expect( - vIf && vIf.exp && vIf.exp.content, - `the element before the composer is <${prev.tag}> with no v-if — ` + - 'something was inserted between the attachments block and the composer, ' + - 'and it has taken over the v-else', - ).toContain('attachments.length') + it('a closed room still says so', () => { + const line = all.find((n) => { + const vIf = directive(n, 'if') + return n.tag === 'p' && vIf && vIf.exp.content === 'isClosed' + }) + expect(line, 'the "this conversation has ended" line is gone').toBeTruthy() }) it('the budget banner is still rendered — moved, not dropped', () => { diff --git a/src/frontend/tests/unit/roomEscalationAttachments.spec.js b/src/frontend/tests/unit/roomEscalationAttachments.spec.js new file mode 100644 index 000000000..f427acf97 --- /dev/null +++ b/src/frontend/tests/unit/roomEscalationAttachments.spec.js @@ -0,0 +1,500 @@ +/** + * #2794 — attachments survive a 1:1 → room escalation. + * + * The bug: `PortalConversation` uploads a dropped file straight into the + * CURRENT agent's inbox as it is attached, and the `escalate-to-room` event + * carried only `{ agents, message }`. So @mentioning a second agent moved the + * conversation to a room and left the file behind — the person had watched a + * chip confirm the upload, so they believed both agents had it; only the + * original one ever did, and the room showed no trace of a file at all. + * + * Three halves: + * + * 1. the pure carry rules (`portalAttachments.js`) — who still needs the + * file, what travels, and what the room is told; + * 2. the composable's two new affordances — the retained `File` handle and + * an awaitable batch, which are what make a carry possible at all; + * 3. source guards for the wiring no unit test can reach, since + * `vitest.config.js` pins `environment: 'node'` with no mount harness. + */ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { stripComments } from './helpers/stripComments' +import { + partitionAttachments, fanOutPlan, nameList, carriedNotice, noticeIsProblem, mergeCarrySources, +} from '@/components/portal/portalAttachments' +import { + pruneCarryLog, CARRY_MAX_ENTRIES, CARRY_MAX_AGE_MS, CARRY_MAX_BYTES, +} from '@/stores/clientPortal' +import { usePortalFileDrop } from '@/composables/usePortalFileDrop' + +const read = (rel) => stripComments( + readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8'), +) +const CONVERSATION = read('../../src/components/portal/PortalConversation.vue') +const PORTAL = read('../../src/views/Portal.vue') +const ROOM = read('../../src/components/portal/PortalRoom.vue') + +const sent = (name, file = { name }) => ({ name, uploading: false, error: '', done: true, file }) +const failed = (name) => ({ name, uploading: false, error: 'Too large.', done: false, file: { name } }) +const uploading = (name) => ({ name, uploading: true, error: '', done: false, file: { name } }) + +// --------------------------------------------------------------------------- +// 1. the carry rules +// --------------------------------------------------------------------------- + +describe('#2794 partitionAttachments', () => { + it('carries only what actually uploaded', () => { + const ok = sent('a.pdf') + const { carried, dropped } = partitionAttachments([ok, failed('b.pdf')]) + expect(carried).toEqual([ok]) + expect(dropped.map((e) => e.name)).toEqual(['b.pdf']) + }) + + it('treats a still-uploading entry as dropped, never as landed', () => { + // Callers await `settled()` first, so an in-flight entry here means the + // wait was skipped — reporting it beats assuming it made it. + const { carried, dropped } = partitionAttachments([uploading('a.pdf')]) + expect(carried).toEqual([]) + expect(dropped.map((e) => e.name)).toEqual(['a.pdf']) + }) + + it('drops an entry with no readable handle rather than planning around it', () => { + // A plan built from a handle-less entry fans out `undefined`. + const { carried, dropped } = partitionAttachments([{ name: 'a.pdf', done: true, uploading: false, error: '' }]) + expect(carried).toEqual([]) + expect(dropped.map((e) => e.name)).toEqual(['a.pdf']) + }) + + it('survives junk', () => { + expect(partitionAttachments(null)).toEqual({ carried: [], dropped: [] }) + expect(partitionAttachments([null, undefined, {}])).toEqual({ carried: [], dropped: [] }) + }) +}) + +describe('#2794 fanOutPlan', () => { + it('sends each carried file to every participant the origin agent is not', () => { + // The room's own rule (one upload per participant), applied to the ones + // that do not already have it. + const plan = fanOutPlan([sent('a.pdf')], { origin: 'scout', participants: ['scout', 'sage', 'vault'] }) + expect(plan).toHaveLength(1) + expect(plan[0].agents).toEqual(['sage', 'vault']) + }) + + it('excludes the origin agent BY NAME, not by position', () => { + // The shell builds `agents` as [origin, ...mentioned]; a plan that trusted + // that order would double-send the day the order changes. + const plan = fanOutPlan([sent('a.pdf')], { origin: 'scout', participants: ['sage', 'scout'] }) + expect(plan[0].agents).toEqual(['sage']) + }) + + it('collapses a duplicate mention — the cost is per recipient', () => { + const plan = fanOutPlan([sent('a.pdf')], { origin: 'scout', participants: ['scout', 'sage', 'sage'] }) + expect(plan[0].agents).toEqual(['sage']) + }) + + it('plans nothing when nobody new needs it', () => { + // Better than an entry with an empty agent list, which reads as a success. + expect(fanOutPlan([sent('a.pdf')], { origin: 'scout', participants: ['scout'] })).toEqual([]) + expect(fanOutPlan([], { origin: 'scout', participants: ['scout', 'sage'] })).toEqual([]) + }) + + it('carries the handle through so the caller uploads the same bytes', () => { + const file = { name: 'a.pdf' } + const plan = fanOutPlan([sent('a.pdf', file)], { origin: 'scout', participants: ['scout', 'sage'] }) + expect(plan[0].file).toBe(file) + }) + + it('survives junk', () => { + expect(fanOutPlan(null, {})).toEqual([]) + expect(fanOutPlan([sent('a.pdf')], undefined)).toEqual([]) + }) +}) + +describe('#2794 nameList', () => { + it('reads aloud', () => { + expect(nameList(['a'])).toBe('a') + expect(nameList(['a', 'b'])).toBe('a and b') + expect(nameList(['a', 'b', 'c'])).toBe('a, b and c') + expect(nameList([])).toBe('') + expect(nameList(null)).toBe('') + }) +}) + +describe('#2794 carriedNotice', () => { + it('says nothing when there were no attachments', () => { + // An escalation without files must not grow a line about files. + expect(carriedNotice({})).toBeNull() + expect(carriedNotice({ carried: [], dropped: [], failures: [], recipients: ['sage'] })).toBeNull() + }) + + it('names the files and who else got them', () => { + const text = carriedNotice({ carried: [sent('a.pdf')], recipients: ['sage', 'vault'] }) + expect(text).toContain('a.pdf') + expect(text).toContain('sage and vault') + }) + + it('names a partial failure per file and per agent', () => { + // "Some uploads failed" is not actionable; this is. + const text = carriedNotice({ + carried: [sent('a.pdf'), sent('b.pdf')], + failures: [{ name: 'b.pdf', agents: ['vault'] }], + recipients: ['sage', 'vault'], + }) + expect(text).toContain("b.pdf didn't reach vault") + // The delivered line must not claim the file that did not arrive. Match the + // sentence itself rather than slicing the string: the failure sentence also + // starts with the file's name, so a naive prefix slice reads it back. + expect(text).toMatch(/Sent with your message: a\.pdf —/) + expect(text).not.toMatch(/Sent with your message: [^—]*b\.pdf/) + }) + + it('names what never left the 1:1 — never silently dropped', () => { + const text = carriedNotice({ carried: [], dropped: [failed('b.pdf')], recipients: ['sage'] }) + expect(text).toContain('b.pdf') + expect(text).toContain('not carried over') + }) + + it('classifies itself so the room knows how loudly to say it', () => { + expect(noticeIsProblem({ carried: [sent('a.pdf')] })).toBe(false) + expect(noticeIsProblem({ dropped: [failed('b.pdf')] })).toBe(true) + expect(noticeIsProblem({ failures: [{ name: 'b.pdf', agents: ['vault'] }] })).toBe(true) + // A failure naming no agent is not a failure anyone can act on. + expect(noticeIsProblem({ failures: [{ name: 'b.pdf', agents: [] }] })).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// 2. the composable +// --------------------------------------------------------------------------- + +const fileOf = (name, size = 10) => ({ name, size }) + +describe('#2794 usePortalFileDrop keeps the handle and can be awaited', () => { + it('keeps the File on the entry so a second destination is reachable', () => { + const drop = usePortalFileDrop(async () => {}) + const f = fileOf('a.pdf') + return drop.addFiles([f]).then(() => { + expect(drop.entries.value[0].file).toBe(f) + }) + }) + + it('a real File stays usable — markRaw does not break FormData', async () => { + // The claim in the comment, checked rather than asserted: `markRaw` defines + // a property on the object, and a File that refused it would fail deep + // inside `FormData.append` where the cause is invisible. + const drop = usePortalFileDrop(async () => {}) + const real = new File(['x'], 'a.txt', { type: 'text/plain' }) + await drop.addFiles([real]) + const kept = drop.entries.value[0].file + expect(kept).toBe(real) + expect(() => new FormData().append('file', kept)).not.toThrow() + }) + + it('settled() resolves only once the batch has landed', async () => { + let release + const gate = new Promise((r) => { release = r }) + const drop = usePortalFileDrop(() => gate) + + const batch = drop.addFiles([fileOf('a.pdf')]) + let done = false + const waiter = drop.settled().then(() => { done = true }) + await Promise.resolve() + expect(done, 'settled() resolved while the upload was still in flight').toBe(false) + + release() + await batch + await waiter + expect(done).toBe(true) + expect(drop.entries.value[0].uploading).toBe(false) + }) + + it('settled() is a no-op when nothing is in flight', async () => { + const drop = usePortalFileDrop(async () => {}) + await expect(drop.settled()).resolves.toEqual([]) + }) + + it('settled() never rejects — a failed upload is its own chip’s business', async () => { + const drop = usePortalFileDrop(async () => { throw new Error('nope') }) + const batch = drop.addFiles([fileOf('a.pdf')]) + await expect(drop.settled()).resolves.toBeDefined() + await batch + expect(drop.entries.value[0].error).toBeTruthy() + }) + + it('a second drop chains onto the first rather than racing it', async () => { + // Two overlapping batches firing together is the request burst the + // sequencing exists to avoid, and settled() would resolve early. + const order = [] + let release + const gate = new Promise((r) => { release = r }) + const drop = usePortalFileDrop(async (file) => { + order.push(`start:${file.name}`) + if (file.name === 'a.pdf') await gate + order.push(`end:${file.name}`) + }) + + const first = drop.addFiles([fileOf('a.pdf')]) + await Promise.resolve() + const second = drop.addFiles([fileOf('b.pdf')]) + await Promise.resolve() + expect(order).toEqual(['start:a.pdf']) + + release() + await Promise.all([first, second]) + expect(order).toEqual(['start:a.pdf', 'end:a.pdf', 'start:b.pdf', 'end:b.pdf']) + await expect(drop.settled()).resolves.toBeDefined() + }) +}) + +// --------------------------------------------------------------------------- +// 3. the wiring +// --------------------------------------------------------------------------- + +describe('#2794 the 1:1 hands the attachments over', () => { + it('waits for in-flight uploads before escalating', () => { + // "Never silently dropped" — and this is the last moment waiting is + // possible, because the composer is about to unmount. + expect(CONVERSATION).toMatch(/await attachmentsSettled\(\)/) + }) + + it('cannot re-enter while it waits — a second Enter must not eat the message', () => { + // The await is seconds long, and `input.value` is cleared BEFORE it. A + // second send in that window would clear the newly typed text and emit a + // second escalation, which `Portal.vue`'s own `escalating` flag drops on + // the floor: message gone, no error, no composer to recover it from. + const start = CONVERSATION.indexOf('async function send()') + const send = CONVERSATION.slice(start, CONVERSATION.indexOf('async function submitUserText', start)) + expect(send).toMatch(/if \(!text \|\| sending\.value \|\| escalatingNow\.value\) return/) + expect(send).toMatch(/escalatingNow\.value = true/) + // Released on BOTH paths: a flag left set would outlive a failed + // escalation and leave the composer the shell just restored dead. + expect(send).toMatch(/} finally \{[\s\S]*escalatingNow\.value = false/) + }) + + it('emits them with the message', () => { + expect(CONVERSATION).toMatch(/attachments: attachments\.value\.slice\(\)/) + }) + + it('does NOT clear them — that IS the failed-escalation recovery', () => { + // On success this component unmounts as the room opens; on failure the + // shell gives the text back and the chips are still standing beside it. + // The send() body ONLY: `clearAttachments()` legitimately lives in + // `deliver()`, which is where an ordinary turn clears its chips, and a + // whole-file scan would read that one. + const start = CONVERSATION.indexOf('async function send()') + const escalation = CONVERSATION.slice(start, CONVERSATION.indexOf('async function submitUserText', start)) + expect(escalation).toMatch(/emit\('escalate-to-room'/) + expect(escalation).not.toMatch(/clearAttachments\(\)/) + }) +}) + +describe('#2794 the shell fans them out', () => { + const body = PORTAL.slice( + PORTAL.indexOf('async function onEscalateToRoom'), + PORTAL.indexOf('async function onEscalateToRoom') + 3000, + ) + + it('uploads through the same store action a room-native drop uses', () => { + expect(body).toMatch(/store\.uploadDocument\(name, item\.file\)/) + }) + + it('uploads BEFORE posting the message', () => { + // The message is what wakes the mentioned agent; a turn that starts before + // the file is in its inbox cannot see the thing it was asked about. + const upload = body.indexOf('store.uploadDocument') + const post = body.indexOf('store.postRoomMessage') + expect(upload).toBeGreaterThan(-1) + expect(post).toBeGreaterThan(-1) + expect(upload).toBeLessThan(post) + }) + + it('excludes the origin agent from the fan-out', () => { + expect(body).toMatch(/fanOutPlan\(carried, \{ origin: agents\[0\], participants: agents \}\)/) + }) + + it('names the recipients off the plan, not off `agents` a second time', () => { + // Two places deciding who the recipients are is how the notice ends up + // naming somebody the fan-out never wrote to (and saying "sage and sage" + // for a duplicate mention). + expect(body).toMatch(/const recipients = plan\.length \? plan\[0\]\.agents : \[\]/) + }) + + it('reports a per-agent miss instead of failing the whole carry', () => { + expect(body).toMatch(/missed\.push\(name\)/) + expect(body).toMatch(/failures\.push\(\{ name: item\.name, agents: missed \}\)/) + }) + + it('hands the room a notice scoped to that room', () => { + expect(body).toMatch(/roomCarryNotice\.value = \{ roomId,/) + expect(PORTAL).toMatch(/roomCarryNotice\.value\.roomId === activeRoomIdFromRoute\.value/) + }) +}) + +describe('#2794 the room shows what came with the message', () => { + it('renders the carry notice', () => { + expect(ROOM).toMatch(/data-testid="portal-room-carry-notice"/) + expect(ROOM).toMatch(/carryNotice: \{ type: Object, default: null \}/) + }) + + it('retires the carry notice on the next message — it describes history by then', () => { + const send = ROOM.slice(ROOM.indexOf('async function send()'), ROOM.indexOf('async function addAgent')) + expect(send).toMatch(/if \(props\.carryNotice\) emit\('dismiss-carry-notice'\)/) + // The escalation's own first post is made by the shell, so this cannot + // retire the notice before it has been read. + expect(PORTAL).toMatch(/await store\.postRoomMessage\(roomId, message\)/) + }) + + it('clears its own chips once a message has gone', () => { + // The 1:1's rule, which the room never had: without it a room accumulated + // every chip it had ever drawn. + const send = ROOM.slice(ROOM.indexOf('async function send()'), ROOM.indexOf('async function addAgent')) + expect(send).toMatch(/clearAttachments\(\)/) + expect(send.indexOf('clearAttachments()')).toBeGreaterThan(send.indexOf('postRoomMessage')) + }) +}) + + +// --------------------------------------------------------------------------- +// 4. the OTHER upload surface (#2794 follow-up) +// --------------------------------------------------------------------------- + +const railEntry = (name, over = {}) => ({ + agent: 'scout', name, size: 10, file: { name }, at: Date.now(), ...over, +}) + +describe('#2794 mergeCarrySources — the composer is not the only way to attach', () => { + it('carries a file that only the rail knows about', () => { + // THE defect this closes: the rail's Files panel sends straight to its + // "Send to" target and keeps no pending state, so the escalation saw + // nothing — no carry, and not even a notice saying so. + const merged = mergeCarrySources([], [railEntry('deck.pdf')]) + expect(merged.map((e) => e.name)).toEqual(['deck.pdf']) + const { carried } = partitionAttachments(merged) + expect(carried).toHaveLength(1) + }) + + it('does not double-carry a file both surfaces saw', () => { + // A composer upload goes through the same funnel, so it is in BOTH views. + const chip = sent('a.pdf') + chip.size = 10 + const merged = mergeCarrySources([chip], [railEntry('a.pdf')]) + expect(merged).toHaveLength(1) + expect(merged[0]).toBe(chip) // the composer entry wins — it holds the live outcome + }) + + it('treats same-name-different-size as two files', () => { + const chip = sent('a.pdf'); chip.size = 10 + const merged = mergeCarrySources([chip], [railEntry('a.pdf', { size: 999 })]) + expect(merged).toHaveLength(2) + }) + + it('keeps a failed composer chip failed — the rail must not mask it', () => { + // Otherwise a file that failed in the composer would be reported as + // carried because a same-named rail entry sat behind it. + const bad = failed('a.pdf'); bad.size = 10 + const merged = mergeCarrySources([bad], [railEntry('a.pdf')]) + const { carried, dropped } = partitionAttachments(merged) + expect(carried).toEqual([]) + expect(dropped.map((e) => e.name)).toEqual(['a.pdf']) + }) + + it('normalises rail entries into the shape the rest of the module reads', () => { + const [e] = mergeCarrySources([], [railEntry('deck.pdf')]) + // `uploadDocument` logs only AFTER the server took the file, so these are + // landed by construction. + expect(e.done).toBe(true) + expect(e.uploading).toBe(false) + expect(e.error).toBe('') + expect(e.file).toBeTruthy() + }) + + it('survives junk from either side', () => { + expect(mergeCarrySources(null, null)).toEqual([]) + expect(mergeCarrySources([null], [null, {}, { name: 'x' }])).toEqual([]) // no file → not carryable + }) +}) + +describe('#2794 pruneCarryLog — the log retains File objects, so it is bounded', () => { + it('drops entries past the age window', () => { + const now = Date.now() + const kept = pruneCarryLog([ + railEntry('old.pdf', { at: now - CARRY_MAX_AGE_MS - 1 }), + railEntry('new.pdf', { at: now }), + ], now) + expect(kept.map((e) => e.name)).toEqual(['new.pdf']) + }) + + it('caps the entry count, keeping the newest', () => { + const now = Date.now() + const many = Array.from({ length: CARRY_MAX_ENTRIES + 5 }, (_, i) => + railEntry(`f${i}.pdf`, { at: now - (CARRY_MAX_ENTRIES + 5 - i) })) + const kept = pruneCarryLog(many, now) + expect(kept).toHaveLength(CARRY_MAX_ENTRIES) + expect(kept[kept.length - 1].name).toBe(`f${CARRY_MAX_ENTRIES + 4}.pdf`) + }) + + it('caps retained bytes, evicting oldest first', () => { + const now = Date.now() + const big = CARRY_MAX_BYTES / 2 + 1 + const kept = pruneCarryLog([ + railEntry('old.bin', { at: now - 3, size: big }), + railEntry('mid.bin', { at: now - 2, size: big }), + railEntry('new.bin', { at: now - 1, size: big }), + ], now) + expect(kept.map((e) => e.name)).toEqual(['new.bin']) + }) + + it('keeps a single over-cap file rather than refusing to carry it', () => { + // Evicting it would silently drop the one file the person cares about. + const now = Date.now() + const kept = pruneCarryLog([railEntry('huge.bin', { at: now, size: CARRY_MAX_BYTES * 4 })], now) + expect(kept).toHaveLength(1) + }) + + it('drops an entry whose File is gone', () => { + expect(pruneCarryLog([railEntry('x.pdf', { file: null })])).toEqual([]) + }) +}) + +describe('#2794 the carry boundary is drawn where the chips clear', () => { + const CONV = CONVERSATION + it('opening a conversation consumes NOTHING — mounting is not sending', () => { + // The regression this pins, found by the operator on the live instance: + // the rail is a SIBLING of the stage and survives navigation, so attaching + // from wherever you are and THEN opening the chat you want to escalate + // from is the ordinary gesture — and a mount boundary ate exactly that + // upload, carrying nothing and saying nothing. A thread switch and ⌘J + // remount this component too, so one boundary broke several gestures. + const start = CONV.indexOf('onMounted(async () => {') + const mounted = CONV.slice(start, start + 900) + expect(mounted).not.toMatch(/markUploadsCarried/) + }) + + it('the only consume points are a sent turn and an escalation', () => { + // Stated as a whole-file count so a third one cannot be added quietly. + const conv = (CONV.match(/store\.markUploadsCarried\(/g) || []).length + const portal = (PORTAL.match(/store\.markUploadsCarried\(/g) || []).length + expect(conv).toBe(1) // deliver(), beside clearAttachments() + expect(portal).toBe(1) // onEscalateToRoom() + }) + + it('a sent turn consumes them too', () => { + const i = CONV.indexOf('clearAttachments()') + expect(CONV.slice(i, i + 300)).toMatch(/store\.markUploadsCarried\(props\.agent\?\.name\)/) + }) + + it('the escalation consumes them, so a second one cannot re-carry', () => { + const body = PORTAL.slice(PORTAL.indexOf('async function onEscalateToRoom'), + PORTAL.indexOf('async function onEscalateToRoom') + 3000) + expect(body).toMatch(/mergeCarrySources\(attachments, store\.carryableUploadsFor\(agents\[0\]\)\)/) + expect(body).toMatch(/store\.markUploadsCarried\(agents\[0\]\)/) + }) + + it('the funnel every surface shares is what records them', () => { + const STORE = readFileSync(fileURLToPath(new URL('../../src/stores/clientPortal.js', import.meta.url)), 'utf8') + const fn = STORE.slice(STORE.indexOf('async uploadDocument'), STORE.indexOf('async uploadDocument') + 700) + expect(fn).toMatch(/this\.noteUploadForCarry\(agentName, file\)/) + }) +}) diff --git a/src/frontend/tests/unit/roomFileReach.spec.js b/src/frontend/tests/unit/roomFileReach.spec.js new file mode 100644 index 000000000..10f81f841 --- /dev/null +++ b/src/frontend/tests/unit/roomFileReach.spec.js @@ -0,0 +1,241 @@ +/** + * #2794 (second half) — a file put into a room reaches every agent in it, + * however it was put there. + * + * The first half of this issue made an attachment survive the escalation from a + * 1:1 into a room. Testing that live turned up the rest of the path, and it was + * worse than the original report: in a room with two agents, the client sent a + * screenshot from the rail, asked the SECOND agent about it, and got "I don't + * see any image attached". Three independent gaps, each individually invisible: + * + * 1. **The rail aimed at one agent.** Its `Send to` select defaulted to the + * first participant while the room's own drop zone fanned out — two + * surfaces, two meanings for "send a file to this chat", and the one with + * the visible control was the wrong one. Fixed in `portalFiles.js`, which + * is where a rule has to live to be testable at all (`environment: 'node'`, + * no mount harness). + * + * 2. **Pasting did nothing.** No paste handler existed on either composer, so + * the single most common way to attach a screenshot was inert and silent. + * + * 3. **No agent was ever told.** That half is backend and is pinned by + * `tests/unit/test_2794_room_file_awareness.py`. + * + * Source guards cover the wiring, per this file's sibling: a rule can be proven + * here, a `.vue` binding cannot. + */ +import { describe, it, expect, vi } from 'vitest' +import { readFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { stripComments } from './helpers/stripComments' +import { + ALL_PARTICIPANTS, + defaultUploadTarget, + resolveRecipients, + uploadReceipt, + uploadTargetLabel, + uploadTargets, +} from '@/components/portal/portalFiles' +import { + clipboardHasText, filesFromClipboard, usePortalFileDrop, +} from '@/composables/usePortalFileDrop' + +const read = (rel) => stripComments( + readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8'), +) +const RAIL = read('../../src/components/portal/PortalRailFiles.vue') +const ROOM = read('../../src/components/portal/PortalRoom.vue') +const CONVERSATION = read('../../src/components/portal/PortalConversation.vue') + +const TWO = ['analyst-demo', 'sidekick'] + +// --------------------------------------------------------------------------- +// 1. who a file goes to +// --------------------------------------------------------------------------- + +describe('the rail sends a room file to the whole room', () => { + it('defaults a multi-agent chat to everyone', () => { + // The line that fixes the report. The old default was `participants[0]`. + expect(defaultUploadTarget(TWO)).toBe(ALL_PARTICIPANTS) + expect(resolveRecipients(defaultUploadTarget(TWO), TWO)).toEqual(TWO) + }) + + it('offers no fan-out entry in a 1:1, because there is no choice to make', () => { + expect(uploadTargets(['solo'])).toEqual([{ value: 'solo', label: 'solo' }]) + expect(defaultUploadTarget(['solo'])).toBe('solo') + }) + + it('lists everyone first, then each agent, so one recipient stays reachable', () => { + const values = uploadTargets(TWO).map((t) => t.value) + expect(values).toEqual([ALL_PARTICIPANTS, 'analyst-demo', 'sidekick']) + }) + + it('honours an explicit single pick', () => { + expect(resolveRecipients('sidekick', TWO)).toEqual(['sidekick']) + }) + + it('falls back to the fan-out when the pick has left the room', () => { + // Recoverable (the rail has a delete) vs. silent loss. Only one of those is + // the failure this issue is about. + expect(resolveRecipients('departed', TWO)).toEqual(TWO) + }) + + it('resolves to nobody only when there IS nobody', () => { + expect(resolveRecipients(ALL_PARTICIPANTS, [])).toEqual([]) + }) + + it('names the recipients before the file is let go of', () => { + expect(uploadTargetLabel(ALL_PARTICIPANTS, TWO)).toBe('analyst-demo and sidekick') + expect(uploadTargetLabel('sidekick', TWO)).toBe('sidekick') + expect(uploadTargetLabel(ALL_PARTICIPANTS, ['a', 'b', 'c'])).toBe('all 3 agents') + }) + + it('states BOTH halves of a fan-out in the receipt', () => { + // "Sent shot.png" over a two-agent fan-out was true and was read as "both + // of them have it" — which, before this, was false. + expect(uploadReceipt({ files: ['shot.png'], recipients: TWO })) + .toBe('Sent “shot.png” to analyst-demo and sidekick.') + expect(uploadReceipt({ files: ['a', 'b'], recipients: ['solo'] })) + .toBe('Sent 2 files to solo.') + }) + + it('claims nothing when nothing was sent', () => { + expect(uploadReceipt({ files: [], recipients: TWO })).toBe('') + expect(uploadReceipt({ files: ['a'], recipients: [] })).toBe('') + }) +}) + +describe('the rail is wired to those rules', () => { + it('renders the option list rather than the bare participants', () => { + expect(RAIL).toMatch(/v-for="t in targets"/) + expect(RAIL).not.toMatch(/v-for="p in participants"[^>]*:value="p"/) + }) + + it('falls back to the DEFAULT, never to the first participant', () => { + expect(RAIL).toContain('defaultUploadTarget(participants.value)') + expect(RAIL).not.toContain('target.value = participants.value[0]') + }) + + it('uploads once per recipient', () => { + expect(RAIL).toMatch(/for \(const agent of to\)/) + expect(RAIL).toContain('feeds.upload(agent, file)') + }) + + it('reports the name the SERVER wrote, not the one that was picked', () => { + // `_safe_filename` sanitizes server-side and the response carries the name + // that actually landed; reporting `file.name` would name a file the inbox + // does not contain — the honesty class this whole PR is about. + expect(RAIL).toContain('if (res?.filename) landed = res.filename') + expect(RAIL).toContain('else sent.push(landed)') + }) + + it('names the agents a file MISSED, and counts a partial as a failure', () => { + // Counting a partial fan-out as a success would rebuild the reported bug + // inside its own fix: "Sent shot.png to analyst-demo and sidekick" while + // sidekick got nothing is exactly what made the gap invisible. + expect(RAIL).toContain('if (missed.length) failed.push') + expect(RAIL).toMatch(/\$\{file\.name\} → \$\{missed\.join\(', '\)\}/) + expect(RAIL).toContain('else sent.push(landed)') + }) +}) + +// --------------------------------------------------------------------------- +// 2. pasting a screenshot +// --------------------------------------------------------------------------- + +describe('paste attaches a file', () => { + const pngFile = { name: 'image.png', size: 12, type: 'image/png' } + + it('reads clipboard files', () => { + expect(filesFromClipboard({ files: [pngFile] })).toEqual([pngFile]) + }) + + it('falls back to items for the browsers that only fill those', () => { + const data = { files: [], items: [{ kind: 'file', getAsFile: () => pngFile }] } + expect(filesFromClipboard(data)).toEqual([pngFile]) + }) + + it('ignores a text paste entirely', () => { + const data = { files: [], items: [{ kind: 'string', getAsFile: () => null }] } + expect(filesFromClipboard(data)).toEqual([]) + expect(filesFromClipboard(null)).toEqual([]) + }) + + it('detects text riding along with the image', () => { + expect(clipboardHasText({ types: ['text/plain', 'Files'] })).toBe(true) + expect(clipboardHasText({ types: ['Files'] })).toBe(false) + expect(clipboardHasText(undefined)).toBe(false) + }) + + it('goes through the same batch as a drop', async () => { + const upload = vi.fn().mockResolvedValue({}) + const { handlers, entries } = usePortalFileDrop(upload) + const preventDefault = vi.fn() + await handlers.onPaste({ + preventDefault, + clipboardData: { files: [pngFile], items: [], types: ['Files'] }, + }) + expect(upload).toHaveBeenCalledOnce() + expect(entries.value.map((e) => e.name)).toEqual(['image.png']) + // Nothing else on the clipboard, so the default is suppressed and no stray + // text lands in the composer. + expect(preventDefault).toHaveBeenCalled() + }) + + it('does not swallow a paste that also carries text', async () => { + // Copying out of a rich editor puts both on the clipboard. Attaching the + // image must not delete the text they meant to paste. + const upload = vi.fn().mockResolvedValue({}) + const { handlers } = usePortalFileDrop(upload) + const preventDefault = vi.fn() + await handlers.onPaste({ + preventDefault, + clipboardData: { files: [pngFile], items: [], types: ['text/plain', 'Files'] }, + }) + expect(upload).toHaveBeenCalledOnce() + expect(preventDefault).not.toHaveBeenCalled() + }) + + it('is inert on a plain text paste', async () => { + const upload = vi.fn() + const { handlers, entries } = usePortalFileDrop(upload) + const preventDefault = vi.fn() + await handlers.onPaste({ + preventDefault, + clipboardData: { files: [], items: [], types: ['text/plain'] }, + }) + expect(upload).not.toHaveBeenCalled() + expect(entries.value).toEqual([]) + expect(preventDefault).not.toHaveBeenCalled() + }) + + it('respects the disabled gate, like every other handler', async () => { + const upload = vi.fn() + const { handlers } = usePortalFileDrop(upload, { disabled: () => true }) + await handlers.onPaste({ + preventDefault: () => {}, + clipboardData: { files: [pngFile], items: [], types: ['Files'] }, + }) + expect(upload).not.toHaveBeenCalled() + }) +}) + +describe('both composers accept a paste', () => { + // The handler is worth nothing unbound, and a binding is exactly what a + // node-env suite cannot exercise. + it('the 1:1 composer', () => { + expect(CONVERSATION).toContain('@paste="dropHandlers.onPaste"') + }) + it('the room composer', () => { + expect(ROOM).toContain('@paste="dropHandlers.onPaste"') + }) +}) + +describe('the room drop still fans out', () => { + it('uploads to every participating agent', () => { + // Unchanged by this PR, asserted because it is now HALF of a pair: if this + // regressed to a single target, the rail would be the only surface that + // reached the whole room and the bug would return by the other door. + expect(ROOM).toMatch(/for \(const name of names\) await store\.uploadDocument\(name, file\)/) + }) +}) diff --git a/src/frontend/tests/unit/roomStopWork.spec.js b/src/frontend/tests/unit/roomStopWork.spec.js new file mode 100644 index 000000000..6780f6eef --- /dev/null +++ b/src/frontend/tests/unit/roomStopWork.spec.js @@ -0,0 +1,154 @@ +/** + * #2795 — running executions in a room can be stopped. + * + * Two independent gaps stacked up, so the fix has two halves and this spec + * proves each of them separately: + * + * 1. the room's live tiles were never handed `can-stop` / `@stop`, so the + * Stop button `PortalWorkCard` already renders was simply never turned on + * there — a SOURCE guard, because there is no mount harness; + * 2. Escape gets a rule of its own (`soleStoppableItem`), because a room + * fans out to several agents and "stop the turn" has to name which one. + * + * The server half (`can_stop` admitting `kind: "room"`) lives in + * `tests/unit/test_2795_room_stop.py` — the button being wired is worth + * nothing while the server says the row is unstoppable. + */ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { parse } from '@vue/compiler-sfc' +import { baseParse } from '@vue/compiler-core' +import { stripComments } from './helpers/stripComments' +import { soleStoppableItem } from '@/components/portal/portalWork' + +const ROOM = stripComments( + readFileSync(fileURLToPath(new URL('../../src/components/portal/PortalRoom.vue', import.meta.url)), 'utf8'), +) + +const live = (over = {}) => ({ id: 'e1', agent_name: 'a', status: 'running', can_stop: true, ...over }) + +describe('#2795 soleStoppableItem', () => { + it('returns the one stoppable live row', () => { + const it0 = live() + expect(soleStoppableItem([it0])).toBe(it0) + }) + + it('is null when two rows are stoppable — Escape must not pick one by position', () => { + // THE property. Guessing destroys work somebody is still waiting for. + expect(soleStoppableItem([live({ id: 'e1' }), live({ id: 'e2' })])).toBeNull() + }) + + it('ignores rows the server says cannot be stopped', () => { + const mine = live({ id: 'e1' }) + expect(soleStoppableItem([mine, live({ id: 'e2', can_stop: false })])).toBe(mine) + expect(soleStoppableItem([live({ can_stop: false })])).toBeNull() + }) + + it('ignores rows that are not live — a stale row is not stoppable', () => { + expect(soleStoppableItem([live({ stale: true })])).toBeNull() + expect(soleStoppableItem([live({ status: 'success' })])).toBeNull() + }) + + it('ignores a row whose cancel is already in flight', () => { + // Otherwise the row being stopped keeps holding the "sole" slot and a + // second press acts on it again. + expect(soleStoppableItem([live({ id: 'e1' })], ['e1'])).toBeNull() + const other = live({ id: 'e2' }) + expect(soleStoppableItem([live({ id: 'e1' }), other], ['e1'])).toBe(other) + }) + + it('survives junk without throwing', () => { + expect(soleStoppableItem(null)).toBeNull() + expect(soleStoppableItem(undefined, undefined)).toBeNull() + expect(soleStoppableItem([null, undefined])).toBeNull() + }) +}) + +describe('#2795 the room wires the card it already renders', () => { + it('passes the server verdict to the tile, never a local guess', () => { + // `can_stop` mirrors what the terminate route will accept; a client-side + // re-derivation is how the button becomes a lie. + expect(ROOM).toMatch(/:can-stop="it\.can_stop"/) + }) + + it('shows the Stopping… state from the shared store', () => { + expect(ROOM).toMatch(/:stopping="workStore\.stoppingIds\.includes\(it\.id\)"/) + }) + + it('stops through the Work tab’s own store action — one cancel path', () => { + expect(ROOM).toMatch(/@stop="onStopWork"/) + expect(ROOM).toMatch(/workStore\.stopItem\(item\)/) + }) + + it('surfaces a refused cancel, which is the outcome a person must act on', () => { + expect(ROOM).toMatch(/data-testid="portal-room-stop-error"/) + expect(ROOM).toMatch(/cancelOutcome\(\{ ok: false \}\)/) + }) + + it('asks the shared Escape rule, with the popups as overlays', () => { + // ent#155's rule unchanged: anything nearer the keystroke wins. + expect(ROOM).toMatch(/shouldCancelOnEscape\(/) + expect(ROOM).toMatch(/overlays: \[typeaheadOpen\.value, addOpen\.value\]/) + expect(ROOM).toMatch(/soleStoppableItem\(roomLiveItems\.value, workStore\.stoppingIds\)/) + }) +}) + + +describe('#2795 the live-work chain survives the new error line', () => { + // `roomComposerChain.spec.js` pins the COMPOSER's chain. This is the same + // hazard one region up, and it is not hypothetical: the first draft of this + // change inserted the stop-error paragraph between `v-if="roomLiveItems"` + // and `v-else-if="workingAgents"`, which silently repointed the "…is + // thinking…" fallback at `stopError`. The SFC compiled and every other test + // passed. + const ELEMENT = 1 + const SRC = readFileSync( + fileURLToPath(new URL('../../src/components/portal/PortalRoom.vue', import.meta.url)), 'utf8', + ) + const { descriptor } = parse(SRC, { filename: 'PortalRoom.vue' }) + const ast = baseParse(descriptor.template.content) + + const all = [] + ;(function walk(node) { + for (const child of node.children || []) { + if (child.type === ELEMENT) { all.push(child); walk(child) } + } + })(ast) + + const dir = (node, name) => (node.props || []).find((pr) => pr.type === 7 && pr.name === name) + const elementChildren = (node) => (node.children || []).filter((c) => c.type === ELEMENT) + const expr = (node, name) => dir(node, name)?.exp?.content || '' + + function previousSibling(node) { + const parent = all.find((n) => elementChildren(n).includes(node)) + if (!parent) return null + const sibs = elementChildren(parent) + return sibs[sibs.indexOf(node) - 1] || null + } + + it('the "is thinking…" fallback still chains off the work tiles', () => { + const fallback = all.find((n) => expr(n, 'else-if').includes('workingAgents.length')) + expect(fallback, 'the server-derived thinking fallback is gone').toBeTruthy() + expect(expr(previousSibling(fallback), 'if')).toContain('roomLiveItems.length') + }) + + it('the "sending" fallback still chains off the "is thinking…" one', () => { + const sending = all.find((n) => expr(n, 'else-if') === 'sending') + expect(sending, 'the local sending fallback is gone').toBeTruthy() + expect(expr(previousSibling(sending), 'else-if')).toContain('workingAgents.length') + }) + + it('the stop-error line sits OUTSIDE the chain', () => { + const line = all.find((n) => (n.props || []).some( + (pr) => pr.type === 6 && pr.name === 'data-testid' + && pr.value?.content === 'portal-room-stop-error', + )) + expect(line, 'the stop-error line is gone').toBeTruthy() + // It must not be the element any chain arm binds to. + const after = all.find((n) => previousSibling(n) === line) + if (after) { + expect(dir(after, 'else-if') || dir(after, 'else')).toBeFalsy() + } + }) +}) diff --git a/src/frontend/tests/unit/sttCapability.spec.js b/src/frontend/tests/unit/sttCapability.spec.js new file mode 100644 index 000000000..be8df3787 --- /dev/null +++ b/src/frontend/tests/unit/sttCapability.spec.js @@ -0,0 +1,70 @@ +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) + }) +}) + +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/src/frontend/tests/unit/workspaceSession.spec.js b/src/frontend/tests/unit/workspaceSession.spec.js index b207a0ecd..1068503a9 100644 --- a/src/frontend/tests/unit/workspaceSession.spec.js +++ b/src/frontend/tests/unit/workspaceSession.spec.js @@ -17,6 +17,7 @@ * who lands on a dead link has no way to report it. */ import { describe, it, expect, beforeEach, vi } from 'vitest' +import { sessionLostVerdict } from '@/utils/platformSession' import { setActivePinia, createPinia } from 'pinia' // `vitest.config.js` runs unit tests in the NODE environment on purpose ("pure @@ -417,37 +418,53 @@ describe('workspace availability state is not sticky (/review C1)', () => { }) }) -describe('who gets bounced to /login on a 401 (/review I1)', () => { - // The guards live in api.js / main.js interceptors, which need `window`. The - // property under test is the PREDICATE, so assert it directly against the - // storage states it reads — the same expression both interceptors use. - const shouldBounce = (path, hasPlatformToken) => { - const onWorkspace = path.startsWith('/workspace') || path.startsWith('/portal') - return !onWorkspace || hasPlatformToken - } +describe('who gets bounced to /login on a 401 (/review I1, rewritten for #2791)', () => { + // This block used to define its OWN `shouldBounce` helper — a hand-copied + // duplicate of the expression in `api.js` and `main.js`. That is why it stayed + // green while the two interceptors and `portalHttp` drifted into three + // different answers, and it would have stayed green through #2791 too: nothing + // under test imported it. + // + // It now asserts the REAL predicate, which is a pure function precisely so a + // node-env spec can reach it. it('an internal user whose platform session expired IS bounced', () => { - expect(shouldBounce('/workspace', true)).toBe(true) + expect(sessionLostVerdict({ + failedToken: 'jwt', storedToken: 'jwt', path: '/workspace', + })).toBe('logout') }) it('an external client on the workspace is NOT bounced to the operator login', () => { - expect(shouldBounce('/workspace', false)).toBe(false) - expect(shouldBounce('/workspace/c/abc', false)).toBe(false) - expect(shouldBounce('/portal', false)).toBe(false) // legacy URL, mid-redirect + // #2791 widens this: it now holds for a client whose browser also carries a + // DEAD operator JWT, which is the case #2261 left open (AC #5). The old + // predicate keyed on the token merely EXISTING and bounced them. + for (const path of ['/workspace', '/workspace/c/abc', '/portal']) { + expect(sessionLostVerdict({ storedToken: null, path })).toBe('ignore') + expect(sessionLostVerdict({ + failedToken: 'dead-operator-jwt', storedToken: 'dead-operator-jwt', + portalTokenPresent: true, path, + })).toBe('ignore') + } }) - it('the verdict does not depend on the portal token, which signOut() races away', () => { + it('the verdict does not depend on the portal token racing away', () => { // The first 401 drops the portal token (fetchRoster -> signOut). A second, - // concurrent 401 must reach the same answer as the first — keying on the - // portal token made this flip and threw the client onto /login. - const before = shouldBounce('/workspace', false) // portal token present - const after = shouldBounce('/workspace', false) // portal token now gone + // concurrent 401 must reach the same answer as the first. For an OPERATOR + // (no portal token either way) that answer is stable by construction. + const before = sessionLostVerdict({ + failedToken: 'jwt', storedToken: 'jwt', portalTokenPresent: false, path: '/workspace', + }) + const after = sessionLostVerdict({ + failedToken: 'jwt', storedToken: 'jwt', portalTokenPresent: false, path: '/workspace', + }) expect(after).toBe(before) }) it('everywhere else keeps the normal bounce', () => { - expect(shouldBounce('/agents/scout', false)).toBe(true) - expect(shouldBounce('/', true)).toBe(true) + expect(sessionLostVerdict({ storedToken: null, path: '/agents/scout' })).toBe('logout') + expect(sessionLostVerdict({ + failedToken: 'jwt', storedToken: 'jwt', path: '/', + })).toBe('logout') }) }) diff --git a/src/frontend/tests/unit/workspaceSignOut.spec.js b/src/frontend/tests/unit/workspaceSignOut.spec.js index f9ffea1b9..fbea5cddd 100644 --- a/src/frontend/tests/unit/workspaceSignOut.spec.js +++ b/src/frontend/tests/unit/workspaceSignOut.spec.js @@ -135,9 +135,15 @@ describe('auth.logout() clears the local session before the network revoke', () seen = { stored: localStorage.getItem('token'), authed: auth.isAuthenticated, - // The revoke itself must still be able to carry the credential: it - // rides the axios DEFAULT header, deleted only after the call. - header: axios.defaults.headers.common['Authorization'], + // #2791: the revoke still carries the credential, by a new route. It + // used to ride the axios DEFAULT header (deleted after the call); that + // header is the second credential source this issue removed, so the + // token is now captured before the clear and passed EXPLICITLY. + // + // The property is unchanged and is the one worth pinning: #2258 clears + // local state BEFORE the revoke, so the revoke has to carry a + // credential storage no longer holds, or #187 silently stops revoking. + header: axios.post.mock.calls.at(-1)?.[2]?.headers?.Authorization, } const err = new Error('401'); err.response = { status: 401 }; throw err }) diff --git a/tests/unit/test_1596_git_sync_observability.py b/tests/unit/test_1596_git_sync_observability.py index 994235c3c..2f21e4054 100644 --- a/tests/unit/test_1596_git_sync_observability.py +++ b/tests/unit/test_1596_git_sync_observability.py @@ -8,6 +8,8 @@ """ from __future__ import annotations +import importlib.util +import sqlite3 import sys from pathlib import Path @@ -29,6 +31,12 @@ def _ops(): return SyncStateOperations() +# #2800: the 44 GiB round-trip below can only FAIL on PostgreSQL — SQLite's +# INTEGER is 64-bit, PostgreSQL's is int4 (ceiling 2 GiB). `schema-parity.yml` +# selects on this marker and runs it with TEST_POSTGRES_URL set, so the +# [postgres] leg is a required gate instead of a leg nobody ever ran (it shipped +# red for two months because the tier ran only `-m requires_postgres`). +@pytest.mark.requires_postgres class TestGitDirBytesRoundTrip: def test_upsert_and_read_git_dir_bytes(self, db_backend): ops = _ops() @@ -36,6 +44,25 @@ def test_upsert_and_read_git_dir_bytes(self, db_backend): row = ops.get("a1") assert row["git_dir_bytes"] == 47244640256 + def test_git_dir_bytes_is_64_bit_on_postgres(self, db_backend): + """#2800: the declared type, not just one value that happened to fit. + + The round-trip above proves a 44 GiB value persists; this proves WHY, so + a future `schema.py` edit that quietly reverts the column to INTEGER is + named by column type rather than by a NumericValueOutOfRange stack. + """ + if db_backend != "postgres": + pytest.skip("declared-type assertion is PostgreSQL-only (SQLite INTEGER is already 64-bit)") + from sqlalchemy import text + from db.engine import get_engine + + with get_engine().connect() as conn: + data_type = conn.execute(text( + "SELECT data_type FROM information_schema.columns " + "WHERE table_name = 'agent_sync_state' AND column_name = 'git_dir_bytes'" + )).scalar() + assert data_type == "bigint", f"git_dir_bytes is {data_type!r} on PostgreSQL — must be bigint (#2800)" + def test_partial_update_preserves_git_dir_bytes(self, db_backend): ops = _ops() ops.upsert("a1", last_sync_status="success", git_dir_bytes=1000) @@ -66,3 +93,106 @@ def test_still_ignores_credentials_and_content(self): from services.git_service import _GITIGNORE_PATTERNS for pat in (".env", ".mcp.json", "content/", "*.pem"): assert pat in _GITIGNORE_PATTERNS + + +class TestGitDirBytesSqliteDeclaredTypeMigration: + """#2800: the SQLite half is a declared-type rebuild, and it must keep the rows. + + SQLite has no ALTER COLUMN TYPE, so the migration re-creates + `agent_sync_state` via the #1160 rename-swap. Three things must hold on a + pre-#2800 file: the column now reads BIGINT (schema-parity compares declared + types), every row survives verbatim, and the one index is back. + """ + + @staticmethod + def _migrations(): + spec = importlib.util.spec_from_file_location( + "migrations_for_2800", _BACKEND / "db" / "migrations.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + def test_rebuild_redeclares_bigint_and_preserves_rows(self): + conn = sqlite3.connect(":memory:") + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE agent_sync_state ( + agent_name TEXT PRIMARY KEY, + last_sync_at TEXT, + last_sync_status TEXT, + consecutive_failures INTEGER DEFAULT 0, + last_error_summary TEXT, + last_remote_sha_main TEXT, + last_remote_sha_working TEXT, + ahead_main INTEGER DEFAULT 0, + behind_main INTEGER DEFAULT 0, + ahead_working INTEGER DEFAULT 0, + behind_working INTEGER DEFAULT 0, + git_dir_bytes INTEGER, + pack_count INTEGER, + loose_objects INTEGER, + maintenance_failures INTEGER DEFAULT 0, + last_check_at TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY (agent_name) REFERENCES agent_ownership(agent_name) + ) + """ + ) + cur.execute( + "CREATE INDEX IF NOT EXISTS idx_sync_state_status " + "ON agent_sync_state(last_sync_status, consecutive_failures)" + ) + cur.execute( + "INSERT INTO agent_sync_state (agent_name, last_sync_status, consecutive_failures, " + "git_dir_bytes, pack_count, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + ("a1", "failed", 3, 47244640256, 21, "2026-09-15T00:00:00Z"), + ) + conn.commit() + + mig = self._migrations() + mig._migrate_agent_sync_state_git_dir_bytes_bigint(cur, conn) + + declared = {row[1]: row[2].upper() for row in cur.execute("PRAGMA table_info(agent_sync_state)")} + assert declared["git_dir_bytes"] == "BIGINT" + assert declared["pack_count"] == "INTEGER" # only the byte column moved + row = cur.execute( + "SELECT agent_name, last_sync_status, consecutive_failures, git_dir_bytes, pack_count, updated_at " + "FROM agent_sync_state" + ).fetchall() + assert row == [("a1", "failed", 3, 47244640256, 21, "2026-09-15T00:00:00Z")] + indexes = {r[0] for r in cur.execute("SELECT name FROM sqlite_master WHERE type='index'")} + assert "idx_sync_state_status" in indexes + assert not cur.execute( + "SELECT name FROM sqlite_master WHERE name='agent_sync_state_new'" + ).fetchone() + + # Idempotent: a second run sees BIGINT and touches nothing. + mig._migrate_agent_sync_state_git_dir_bytes_bigint(cur, conn) + assert cur.execute("SELECT COUNT(*) FROM agent_sync_state").fetchone()[0] == 1 + + def test_refuses_to_drop_an_unknown_column(self): + """A rename-swap copies only the columns it names; an unknown one must stop it, not vanish.""" + conn = sqlite3.connect(":memory:") + cur = conn.cursor() + cur.execute( + "CREATE TABLE agent_sync_state (agent_name TEXT PRIMARY KEY, git_dir_bytes INTEGER, " + "updated_at TEXT NOT NULL, future_col TEXT)" + ) + cur.execute("INSERT INTO agent_sync_state VALUES ('a1', 1, 'now', 'keep me')") + conn.commit() + with pytest.raises(RuntimeError, match="future_col"): + self._migrations()._migrate_agent_sync_state_git_dir_bytes_bigint(cur, conn) + # Nothing touched: column and row both still there, no orphan _new table. + assert cur.execute("SELECT future_col FROM agent_sync_state").fetchone() == ("keep me",) + assert not cur.execute("SELECT 1 FROM sqlite_master WHERE name='agent_sync_state_new'").fetchone() + + def test_noop_before_the_column_exists(self): + """Pre-#1596 file: the add-column migration runs first; this one must not rebuild a table it cannot describe.""" + conn = sqlite3.connect(":memory:") + cur = conn.cursor() + cur.execute("CREATE TABLE agent_sync_state (agent_name TEXT PRIMARY KEY, updated_at TEXT NOT NULL)") + conn.commit() + self._migrations()._migrate_agent_sync_state_git_dir_bytes_bigint(cur, conn) + assert "git_dir_bytes" not in {row[1] for row in cur.execute("PRAGMA table_info(agent_sync_state)")} 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..848fcc781 --- /dev/null +++ b/tests/unit/test_2695_stt_capability_probe.py @@ -0,0 +1,482 @@ +"""#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) in the +single-worker tests and a dict-backed fake Redis shared by two module instances +in the two-worker block, which is where the cross-worker AC is proven. +""" +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 + + +# ---- the cache with Redis PRESENT: two workers, one authority --------------- +# +# Every other test stubs Redis to None, so without this block the Redis-present +# branch (`r.get` / `r.set(ex=)` / `r.delete`, `from_json` on a decoded row) had +# zero executing coverage — and the defect it hid was the #2695 AC itself: the +# per-process `_local` was consulted on a Redis MISS, so worker B kept serving a +# `refused` that worker A had already invalidated, for the rest of the 6 h. + + +class _FakeRedis: + """A dict-backed Redis shared by both simulated workers. Records every + `set`'s TTL so the test can pin that the decided/unknown split reaches + the wire, and can be made to raise so the outage branch is reachable too.""" + + def __init__(self): + self.rows: dict[str, str] = {} + self.ttls: dict[str, int] = {} + self.raise_on_read = False + + def get(self, k): + if self.raise_on_read: + raise ConnectionError("redis down") + return self.rows.get(k) + + def set(self, k, v, ex=None): + self.rows[k] = v + self.ttls[k] = ex + + def delete(self, k): + self.rows.pop(k, None) + self.ttls.pop(k, None) + + +def _load_worker(fake_redis, monkeypatch): + """A second instance of the module = a second uvicorn worker: its own + `_local` / `_inflight`, the same Redis. + + The synthetic name has to be in `sys.modules` for the duration of + `exec_module`, because `@dataclass` resolves the class's module BY NAME + while the module body runs. It is registered through `monkeypatch.setitem` + rather than assigned and popped by hand: pytest then removes it at teardown + even if `exec_module` raises, and the repo's `sys.modules` lint + (`tests/lint_sys_modules.py`) exists precisely to stop bare writes here — + one leaked entry is a module every later test in the session imports instead + of the real one. + + Leaving the entry up until teardown rather than popping it immediately is + harmless and deliberate: the name is unique per fixture instance, and + nothing after `exec_module` resolves it. + """ + import importlib.util + import inspect + import sys + name = f"stt_worker_{id(fake_redis)}" + spec = importlib.util.spec_from_file_location(name, inspect.getsourcefile(stt)) + mod = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, name, mod) + spec.loader.exec_module(mod) + mod._redis = lambda: fake_redis + return mod + + +@pytest.fixture +def two_workers(monkeypatch): + fake = _FakeRedis() + monkeypatch.setattr(stt, "_redis", lambda: fake) # worker A = the imported module + worker_b = _load_worker(fake, monkeypatch) + return fake, stt, worker_b + + +def test_redis_row_is_written_with_the_verdicts_ttl_and_read_back(two_workers): + fake, a, _ = two_workers + a.store(KEY, a.SttCapability(a.VERDICT_REFUSED, detail="missing_permissions", checked_at=1.0)) + k = a.cache_key(KEY) + assert k in fake.rows and fake.ttls[k] == a.TTL_DECIDED_SECONDS + assert a.read_cached(KEY).detail == "missing_permissions" # decoded via from_json + a.store(KEY, a.SttCapability(a.VERDICT_UNKNOWN)) + assert fake.ttls[k] == a.TTL_UNKNOWN_SECONDS + + +def test_invalidate_on_one_worker_is_honoured_by_the_other(two_workers): + """The #2695 AC: re-saving a key must not leave the OTHER worker serving the + old verdict. Worker A learns `refused`, worker B reads it (and caches it + locally as it would in production), the admin re-saves the key on A.""" + fake, a, b = two_workers + a.store(KEY, a.SttCapability(a.VERDICT_REFUSED, detail="missing_permissions")) + assert b.read_cached(KEY).verdict == b.VERDICT_REFUSED + b.store(KEY, b.read_cached(KEY)) # B's own write also lands in B._local + assert b.cache_key(KEY) in b._local + + a.invalidate(KEY) # admin re-saved the key on worker A + assert fake.rows == {} # the shared row is gone … + assert b.read_cached(KEY) is None # … and B does NOT resurrect it from _local + assert b.cache_key(KEY) not in b._local # the stale local copy was evicted, not just skipped + + +def test_a_redis_miss_is_a_miss_even_when_a_local_copy_exists(two_workers): + """The 6 h sequence from the review: A's re-probe lands `unknown` (2 min), + that row expires, B must now MISS — not fall back to its 6 h `refused`.""" + fake, a, b = two_workers + b.store(KEY, b.SttCapability(b.VERDICT_REFUSED)) + a.invalidate(KEY) + a.store(KEY, a.SttCapability(a.VERDICT_UNKNOWN)) # A's re-probe: unknown, 2-min row + fake.rows.clear(); fake.ttls.clear() # … which then expires + assert b.read_cached(KEY) is None + + +def test_local_copy_is_used_only_when_redis_cannot_be_asked(two_workers): + fake, a, b = two_workers + b.store(KEY, b.SttCapability(b.VERDICT_CAPABLE)) + fake.raise_on_read = True # outage: the read raises + assert b.read_cached(KEY).verdict == b.VERDICT_CAPABLE # local fallback, fail-open + fake.raise_on_read = False + fake.rows.clear() # Redis answers again: empty + assert b.read_cached(KEY) is None # authority restored → miss + + +def test_a_corrupt_redis_row_is_a_miss_not_a_local_fallthrough(two_workers): + fake, a, _ = two_workers + a.store(KEY, a.SttCapability(a.VERDICT_REFUSED)) + fake.rows[a.cache_key(KEY)] = "{not json" + assert a.read_cached(KEY) is None + assert a.cache_key(KEY) not in a._local + + +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 == 503 # #2696: a named refusal, not the opaque 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 == [] 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..05211a4ca --- /dev/null +++ b/tests/unit/test_2696_stt_provider_errors.py @@ -0,0 +1,273 @@ +"""#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), + # Review finding: a quota WORD inside the provider's PROSE is not a quota + # condition. Every row below is an ordinary auth failure whose sentence + # happens to contain "plan" / "subscription" / "credit"; classifying them as + # quota sent the operator to a billing page for a key that needed replacing. + (401, json.dumps({"detail": "Invalid API key for your plan"}), stt.CATEGORY_AUTH, 503), + (401, json.dumps({"detail": {"message": "not valid for this subscription"}}), + stt.CATEGORY_AUTH, 503), + (403, json.dumps({"detail": "Your credit card was fine; this key is not"}), + stt.CATEGORY_AUTH, 503), + # ...and a token still decides, whatever the prose says around it. + (401, json.dumps({"detail": {"status": "quota_exceeded", + "message": "the key looks fine"}}), stt.CATEGORY_QUOTA, 503), + (401, json.dumps({"detail": {"code": "missing_permissions", + "message": "plan and credit words here"}}), + stt.CATEGORY_PERMISSION, 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_a_quota_word_in_prose_is_not_a_quota_verdict(): + """Review finding. `provider_status_parts` keeps the provider's TOKEN apart + from its PROSE, and only the token may decide a category — a sentence + mentioning "plan" or "credit" is not evidence of a billing condition. + + The failure this pins is not cosmetic: it told an operator whose key had + been rejected that the account was out of credits, which is a worse answer + than the opaque 422 this issue replaces, because it is confidently wrong in + a direction they will act on.""" + prose = json.dumps({"detail": "Invalid API key for your plan"}) + f = stt.classify_stt_failure(401, prose) + assert f.category == stt.CATEGORY_AUTH + assert f.client_message == stt._MSG_AUTH + # The prose is still kept for the operator panel — withheld from the + # matcher, not thrown away. + assert f.detail == "Invalid API key for your plan" + + +def test_provider_status_parts_separates_token_from_prose(): + assert stt.provider_status_parts(_body("missing_permissions")) == ( + "missing_permissions", "provider message") + # A bare string detail is a sentence, even when it is one word. + assert stt.provider_status_parts(json.dumps({"detail": "nope"})) == (None, "nope") + assert stt.provider_status_parts("not json") == (None, None) + assert stt.provider_status_parts("") == (None, None) + # `code` is a token too — some provider errors carry it instead of `status`. + assert stt.provider_status_parts(json.dumps({"detail": {"code": "x"}}))[0] == "x" + + +def test_operator_detail_still_falls_back_to_prose(): + """The split must not cost the operator the only description there is.""" + f = stt.classify_stt_failure(401, json.dumps({"detail": "some sentence"})) + assert f.detail == "some sentence" + assert stt.provider_status_word(json.dumps({"detail": "some sentence"})) == "some sentence" + + +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) diff --git a/tests/unit/test_2794_room_file_awareness.py b/tests/unit/test_2794_room_file_awareness.py new file mode 100644 index 000000000..36e4abed7 --- /dev/null +++ b/tests/unit/test_2794_room_file_awareness.py @@ -0,0 +1,292 @@ +"""#2794 — an agent woken in a room is told about the client's files. + +The reported session, in full: a client opens a room with `analyst-demo` and +`sidekick`, sends a screenshot, and writes *"@sidekick What is displayed on the +pasted image?"*. sidekick replies *"I don't see any image attached to your +message."* — and it is telling the truth. + +Nothing was broken in delivery. The file reached an inbox, the rail listed it, +the transcript carried the question. What did not exist was the *telling*: a +room turn was built from `_build_turn_prompt`, which is a header plus the +transcript, and from nothing else. The sentence that makes a file visible to an +agent — and the vision blocks that make "what is in this picture" answerable at +all — were written inline in `portal_chat`, so the 1:1 conversation was the only +surface in the product that had them. + +Two claims are pinned here, and neither is checkable by reading the diff: + +1. **A room turn carries the manifest and the images.** Not "calls a function" — + the composed message that reaches `execute_task` starts with the manifest, and + `images=` is populated. A wiring that built the prefix and dropped it would + pass any test that only asserted the collector was called. + +2. **There is exactly ONE composer.** The failure mode being fixed is a second + surface that quietly does not tell the agent anything. A third one is only a + matter of time unless the sentence lives in one place, so its presence is + counted across the whole backend rather than trusted to a comment. + +Everything here fails CLOSED in the same direction the code does: a file that +cannot be mentioned never costs the client their turn. +""" +import asyncio +import os +import sys +import tempfile +from pathlib import Path +from types import SimpleNamespace + +os.environ.setdefault("REDIS_URL", "redis://test:test@redis:6379") +os.environ.setdefault("REDIS_PASSWORD", "test") +os.environ.setdefault("REDIS_BACKEND_PASSWORD", "test") +os.environ.setdefault("AGENT_AUTH_SECRET", "0" * 64) +os.environ.setdefault("SECRET_KEY", "x" * 32) +os.environ.setdefault("INTERNAL_API_SECRET", "y" * 32) +os.environ.setdefault("TRINITY_DB_PATH", str(Path(tempfile.gettempdir()) / "trinity-2794.db")) +os.environ.setdefault("LOG_ARCHIVE_PATH", str(Path(tempfile.gettempdir()) / "trinity-2794-logs")) + +_REPO = Path(__file__).resolve().parents[2] +_BACKEND = _REPO / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +import pytest # noqa: E402 + +pytestmark = pytest.mark.unit + +EMAIL = "bob@example.com" +AGENT = "sidekick" +OTHER = "analyst-demo" +ROOM = "room-1" + + +@pytest.fixture +def rooms(): + from shared_sessions import service as mod + return mod + + +@pytest.fixture +def portal(): + from client_portal import service as mod + return mod + + +# --------------------------------------------------------------------------- +# 1. the composer — one sentence, every branch +# --------------------------------------------------------------------------- + +def _collected(monkeypatch, portal, images=(), image_names=(), doc_files=()): + async def _fake(agent_name, email, message): + return list(images), list(image_names), list(doc_files) + monkeypatch.setattr(portal, "_collect_inbox_for_turn", _fake) + + +def test_an_empty_inbox_composes_nothing(monkeypatch, portal): + """No files, no sentence — and emphatically not an empty `[Client Portal] ` + banner in front of every turn the client ever sends.""" + _collected(monkeypatch, portal) + prefix, images = asyncio.run(portal.collect_inbox_context(AGENT, EMAIL, "hello")) + assert prefix == "" + assert images == [] + + +def test_an_attached_image_is_announced_as_shown(monkeypatch, portal): + _collected(monkeypatch, portal, + images=[{"media_type": "image/png", "data": "AAA"}], + image_names=["shot.png"]) + prefix, images = asyncio.run(portal.collect_inbox_context(AGENT, EMAIL, "what is in the image?")) + assert "shown to you directly below" in prefix + assert "shot.png" in prefix + assert images == [{"media_type": "image/png", "data": "AAA"}] + + +def test_an_unrequested_image_is_offered_rather_than_attached(monkeypatch, portal): + """#78's "only when told": an image the turn does not reference is NAMED so + the client can ask for it, not pushed into every unrelated turn.""" + _collected(monkeypatch, portal, images=[], image_names=["shot.png"]) + prefix, images = asyncio.run(portal.collect_inbox_context(AGENT, EMAIL, "unrelated")) + assert "in your inbox" in prefix + assert "shot.png" in prefix + assert images == [] + + +def test_every_branch_forbids_reading_an_image_as_text(): + """#728 — a binary through the stream-json pipe is the zombie-claude + deadlock. The prohibition is not decoration on one branch; an agent told + about an image it was NOT handed is the branch most likely to go and `cat` + it.""" + from client_portal import service as portal + + async def run(images, names): + async def _fake(*a, **k): + return images, names, [] + import unittest.mock as m + with m.patch.object(portal, "_collect_inbox_for_turn", _fake): + return await portal.collect_inbox_context(AGENT, EMAIL, "image") + + attached, _ = asyncio.run(run([{"media_type": "image/png", "data": "A"}], ["a.png"])) + offered, _ = asyncio.run(run([], ["a.png"])) + assert "do NOT" in attached and "as text" in attached + assert "do NOT" in offered and "as text" in offered + + +def test_documents_are_listed_with_the_directory_to_read_them_from(monkeypatch, portal): + _collected(monkeypatch, portal, + doc_files=[{"filename": "q3.csv", "size_bytes": 2048}]) + prefix, _ = asyncio.run(portal.collect_inbox_context(AGENT, EMAIL, "summarise it")) + assert "q3.csv" in prefix + assert portal._client_inbox(EMAIL) in prefix + + +# --------------------------------------------------------------------------- +# 2. the room turn actually carries it +# --------------------------------------------------------------------------- + +class _WakeHarness: + """`_wake_agent` driven to the point where `execute_task` has been called. + + `_build_turn_prompt` is deliberately NOT stubbed: the assertion is about the + composed message, and a stub would hide whether the manifest reached it. + """ + + def __init__(self, monkeypatch, rooms, *, email=EMAIL, context=("", []), raises=False): + self.kwargs = None + self.posted = [] + + monkeypatch.setattr(rooms.db, "get_participant", + lambda *a, **k: {"last_read_seq": 0, "cached_session_id": None}) + monkeypatch.setattr(rooms.db, "get_room", + lambda *a, **k: {"status": "open", "id": ROOM, "name": "Q3", "topic": None}) + monkeypatch.setattr(rooms.db, "get_messages", lambda *a, **k: self.delta) + monkeypatch.setattr(rooms.db, "get_recent_messages", lambda *a, **k: self.delta) + monkeypatch.setattr(rooms.db, "list_participants", lambda *a, **k: []) + monkeypatch.setattr(rooms.db, "advance_read_cursor", lambda *a, **k: None) + monkeypatch.setattr(rooms.db, "clear_cached_session", lambda *a, **k: None) + monkeypatch.setattr(rooms, "_post_system", lambda *a, **k: None) + monkeypatch.setattr(rooms, "_mark_agent_working", lambda *a, **k: None) + monkeypatch.setattr(rooms, "_clear_agent_working", lambda *a, **k: None) + monkeypatch.setattr(rooms, "_broadcast", lambda *a, **k: None) + monkeypatch.setattr(rooms, "room_is_user_facing", lambda *a, **k: False) + monkeypatch.setattr(rooms, "build_user_facing_room_prompt", lambda *a, **k: None) + + self.delta = [{"seq": 4, "content": "@sidekick what is in the image?", + "sender_kind": "user", "sender_identity": EMAIL}] + self.email = email + + async def _post_message(*a, **k): + self.posted.append(a) + monkeypatch.setattr(rooms, "post_message", _post_message) + + self.asked = [] + + async def _ctx(agent_name, email_, message): + self.asked.append((agent_name, email_, message)) + if raises: + raise RuntimeError("inbox unreadable") + return context + + # Patched where the room REACHES it — the import inside + # `_room_inbox_context` resolves against the portal module, so patching + # the room module would be a no-op that still passed. + from client_portal import service as portal + monkeypatch.setattr(portal, "collect_inbox_context", _ctx) + + async def _execute_task(**kwargs): + self.kwargs = kwargs + return SimpleNamespace(status="success", response="ok", + error="", execution_id="e1", session_id="s1") + + import services.task_execution_service as tes + monkeypatch.setattr(tes, "get_task_execution_service", + lambda: SimpleNamespace(execute_task=_execute_task)) + + def run(self, rooms): + asyncio.run(rooms._wake_agent(SimpleNamespace(email=self.email), ROOM, AGENT, 1)) + return self.kwargs + + +def test_the_room_turn_leads_with_the_manifest_and_carries_the_images(monkeypatch, rooms): + """The whole fix, in one assertion. + + Order matters and is asserted as order: the manifest is a *prefix*. An agent + that meets "@sidekick what is in the image?" before it has been told an image + exists is the agent that answers "I don't see any image attached". + """ + h = _WakeHarness(monkeypatch, rooms, + context=("[Client Portal] image here\n\n", + [{"media_type": "image/png", "data": "AAA"}])) + kw = h.run(rooms) + assert kw["message"].startswith("[Client Portal] image here") + assert "what is in the image?" in kw["message"] + assert kw["images"] == [{"media_type": "image/png", "data": "AAA"}] + + +def test_an_empty_inbox_leaves_the_room_prompt_exactly_as_it_was(monkeypatch, rooms): + """The no-files path must be a byte-for-byte no-op. Rooms without files are + the overwhelming majority of rooms, and `images=None` (never `[]`) is what + `execute_task` already expects for "no vision input".""" + h = _WakeHarness(monkeypatch, rooms, context=("", [])) + kw = h.run(rooms) + assert kw["message"].startswith("You are participating in the Trinity room") + assert kw["images"] is None + + +def test_the_intent_test_sees_the_whole_delta_including_an_agents_relay(monkeypatch, rooms): + """"@sidekick can you look at the screenshot the client sent?" is an ordinary + room move. Scoping the image-intent test to human lines would make exactly + that relay arrive image-less — this bug, one hop along.""" + h = _WakeHarness(monkeypatch, rooms, context=("", [])) + h.delta = [{"seq": 5, "content": "@sidekick look at the screenshot the client sent", + "sender_kind": "agent", "sender_identity": OTHER}] + h.run(rooms) + assert h.asked, "the room never asked about the inbox at all" + _, _, message = h.asked[0] + assert "screenshot" in message + + +def test_a_turn_with_no_client_email_still_runs(monkeypatch, rooms): + """An agent-only room has no client inbox to read. It must cost nothing — + not a docker exec, not an exception, not a lost turn.""" + h = _WakeHarness(monkeypatch, rooms, email=None, context=("", [])) + kw = h.run(rooms) + assert h.asked == [] + assert kw["images"] is None + assert kw["message"].startswith("You are participating in the Trinity room") + + +def test_an_unreadable_inbox_never_costs_the_turn(monkeypatch, rooms): + """Fail-safe, and in the direction that keeps the conversation working: the + agent is simply not told about the files, and still answers.""" + h = _WakeHarness(monkeypatch, rooms, raises=True) + kw = h.run(rooms) + assert kw is not None, "the turn was never dispatched" + assert kw["images"] is None + assert "[Client Portal]" not in kw["message"] + + +# --------------------------------------------------------------------------- +# 3. one composer, counted +# --------------------------------------------------------------------------- + +def test_the_manifest_sentence_exists_in_exactly_one_place(): + """The bug was a surface that composed nothing because the composition lived + somewhere else. A second copy re-opens it silently — both surfaces work on + the day it is written, and then one of them is edited. + + Counted across the backend rather than asserted about two known files, so a + THIRD surface inventing its own sentence fails here too. + """ + backend = _REPO / "src" / "backend" + needle = "shown to you directly below as images" + hits = [p for p in backend.rglob("*.py") + if "__pycache__" not in p.parts and needle in p.read_text(errors="ignore")] + assert [p.name for p in hits] == ["service.py"], hits + assert hits[0].parent.name == "client_portal", hits + + +def test_the_room_reaches_that_composer_rather_than_its_own(): + """Source-level, because the behavioural tests above stub the collector and + would pass against a room that had grown a private copy.""" + src = (_REPO / "src" / "backend" / "shared_sessions" / "service.py").read_text() + assert "from client_portal.service import collect_inbox_context" in src + assert "[Client Portal]" not in src, "the room is composing its own manifest" diff --git a/tests/unit/test_2795_room_stop.py b/tests/unit/test_2795_room_stop.py new file mode 100644 index 000000000..03f82339a --- /dev/null +++ b/tests/unit/test_2795_room_stop.py @@ -0,0 +1,389 @@ +"""#2795 — a running room turn can be stopped, and a stop is not a failure. + +Two halves, matching the two gaps in the issue: + +1. **`can_stop` admits `kind: "room"`.** It is not a cosmetic widening: the + terminate route's own gates are `_require_roster(agent)` and + `execution_belongs_to_caller` (agent match + `source_user_email` match), and + `shared_sessions.service._wake_agent` satisfies both by construction — it + runs every wake through `execute_task(..., source_user_email=)` + on an agent that is a room participant. So the route accepted these rows all + along and the projection hid the button. That asymmetry is what this file + pins, by asserting the ROUTE's predicate and the PROJECTION's verdict agree. + +2. **A cancelled turn reads as stopped, not as a fault.** `_wake_agent`'s + terminal branch treated CANCELLED exactly like FAILED: it posted + " could not respond (no response)." and dropped the cached resume + handle. The first is the surface blaming the agent for something the reader + asked for; the second makes the next turn pay for a cold context rebuild on + no evidence the handle was bad. + +The frontend half (the tile wiring and the Escape rule) is +`src/frontend/tests/unit/roomStopWork.spec.js`. +""" +from __future__ import annotations + +import asyncio +import os +import sys +import tempfile +from pathlib import Path +from types import SimpleNamespace + +os.environ.setdefault("REDIS_URL", "redis://test:test@redis:6379") +os.environ.setdefault("REDIS_PASSWORD", "test") +os.environ.setdefault("REDIS_BACKEND_PASSWORD", "test") +os.environ.setdefault("AGENT_AUTH_SECRET", "0" * 64) +os.environ.setdefault("SECRET_KEY", "x" * 32) +os.environ.setdefault("INTERNAL_API_SECRET", "y" * 32) +os.environ.setdefault("TRINITY_DB_PATH", str(Path(tempfile.gettempdir()) / "trinity-2795.db")) +os.environ.setdefault("LOG_ARCHIVE_PATH", str(Path(tempfile.gettempdir()) / "trinity-2795-logs")) + +_REPO = Path(__file__).resolve().parents[2] +_BACKEND = _REPO / "src" / "backend" +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +import pytest # noqa: E402 + +pytestmark = pytest.mark.unit + +EMAIL = "bob@example.com" +AGENT = "scout" +ROOM = "room-1" + + +@pytest.fixture +def svc(): + from client_portal.work import service as mod + return mod + + +@pytest.fixture +def rooms(): + from shared_sessions import service as mod + return mod + + +def _recent(seconds_ago: int = 30) -> str: + from datetime import datetime, timedelta, timezone + return (datetime.now(timezone.utc) - timedelta(seconds=seconds_ago)).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _room_row(**over): + """A row exactly as `_wake_agent` creates one: `triggered_by="room"`, the + poster's email, and NO channel stamp (a room is not a portal thread).""" + base = dict( + id="exec-room-1", agent_name=AGENT, status="running", started_at=_recent(), + completed_at=None, duration_ms=None, message="Summarise the thread", + triggered_by="room", source_user_email=EMAIL, source_agent_name=None, + source_channel=None, source_channel_chat_id=None, loop_id=None, + error_summary=None, + ) + base.update(over) + return base + + +# --------------------------------------------------------------------------- +# 1. can_stop +# --------------------------------------------------------------------------- + +def test_a_room_turn_is_still_kind_room(svc): + """The widening must not be smuggled in by relabelling the kind: the card + says "Room turn" and the Work tab filters on it.""" + assert svc.work_kind(_room_row()) == "room" + + +@pytest.mark.parametrize("status, expected", [("running", True), ("queued", True)]) +def test_a_room_turn_is_stoppable(svc, status, expected): + assert svc.can_stop("room", status, mine=True, on_roster=True, stale=False) is expected + + +@pytest.mark.parametrize("kw", [ + dict(mine=False, on_roster=True, stale=False), # someone else's run + dict(mine=True, on_roster=False, stale=False), # route would 404 + dict(mine=True, on_roster=True, stale=True), # lost: nothing to stop +]) +def test_a_room_turn_obeys_every_other_gate(svc, kw): + """`room` is added to the kind allowlist and NOTHING else moves — in + particular "only the person who started it may stop it" is untouched.""" + assert svc.can_stop("room", "running", **kw) is False + + +def test_loops_are_still_not_stoppable_here(svc): + """A loop is stopped from the Loops tab; cancelling one iteration just + leaves the runner to start the next.""" + assert svc.can_stop("loop", "running", mine=True, on_roster=True, stale=False) is False + + +def test_stoppable_kinds_is_an_allowlist(svc): + """A blocklist would offer Stop on any trigger nobody has thought about + yet. An unknown trigger projects as `other`, which must stay unstoppable.""" + assert svc.can_stop("other", "running", mine=True, on_roster=True, stale=False) is False + assert isinstance(svc.STOPPABLE_KINDS, frozenset) + + +def test_the_projection_offers_stop_on_a_room_row(svc): + """End to end through `_project`, which is what the client actually reads.""" + from datetime import datetime, timezone + item = svc._project(_room_row(), email=EMAIL, roster={AGENT}, + turn_timeout=3600, now=datetime.now(timezone.utc)) + assert item.kind == "room" + assert item.mine is True + assert item.can_stop is True + # A room is not a portal thread, so there is no chat id to open — the tile + # links via "Open in Work", not via a chat. + assert item.chat_id is None + + +def test_the_projection_and_the_terminate_route_agree(svc): + """The one property worth a test rather than a comment: `can_stop` claims to + mirror `POST .../executions/{id}/terminate`, so the two predicates are + evaluated against the SAME row and compared. + + `execution_belongs_to_caller` is the route's caller gate; the roster gate is + the route's `_require_roster`, modelled here by the roster set. + """ + from datetime import datetime, timezone + import client_portal.service as portal_service + + row = _room_row() + execution = SimpleNamespace(agent_name=row["agent_name"], status=row["status"], + source_user_email=row["source_user_email"]) + + class _FakeDB: + def get_execution(self, _id): + return execution + + import database + real = database.db + database.db = _FakeDB() + try: + route_would_accept = portal_service.execution_belongs_to_caller(row["id"], AGENT, EMAIL) + finally: + database.db = real + + item = svc._project(row, email=EMAIL, roster={AGENT}, + turn_timeout=3600, now=datetime.now(timezone.utc)) + assert route_would_accept is True + assert item.can_stop is route_would_accept + + +# --------------------------------------------------------------------------- +# 2. a cancel is not a failure +# --------------------------------------------------------------------------- + +class _WakeHarness: + """`_wake_agent` up to its terminal branch, with every collaborator faked. + + Only the branch under test is exercised: the wake is driven to the point + where `execute_task` has returned, and what the room DOES with that result + is the assertion. + """ + + def __init__(self, monkeypatch, rooms, result, persisted_status=None): + self.system_lines = [] + self.cleared_sessions = [] + self.advanced = [] + self.posted = [] + self.reread = [] + + monkeypatch.setattr(rooms.db, "get_participant", + lambda *a, **k: {"last_read_seq": 0, "cached_session_id": "sess-cached"}) + monkeypatch.setattr(rooms.db, "get_room", lambda *a, **k: {"status": "open", "id": ROOM}) + monkeypatch.setattr(rooms.db, "get_messages", + lambda *a, **k: [{"seq": 4, "body": "hi", "sender_kind": "user", + "sender_identity": EMAIL}]) + monkeypatch.setattr(rooms.db, "list_participants", lambda *a, **k: []) + monkeypatch.setattr(rooms.db, "clear_cached_session", + lambda room_id, agent: self.cleared_sessions.append((room_id, agent))) + monkeypatch.setattr(rooms.db, "advance_read_cursor", + lambda *a, **k: self.advanced.append(a)) + monkeypatch.setattr(rooms, "_post_system", + lambda room_id, text: self.system_lines.append(text)) + monkeypatch.setattr(rooms, "_mark_agent_working", lambda *a, **k: None) + monkeypatch.setattr(rooms, "_clear_agent_working", lambda *a, **k: None) + monkeypatch.setattr(rooms, "_broadcast", lambda *a, **k: None) + monkeypatch.setattr(rooms, "_build_turn_prompt", lambda *a, **k: "prompt") + monkeypatch.setattr(rooms, "room_is_user_facing", lambda *a, **k: True) + monkeypatch.setattr(rooms, "build_user_facing_room_prompt", lambda *a, **k: None) + + async def _post_message(*a, **k): + self.posted.append(a) + + monkeypatch.setattr(rooms, "post_message", _post_message) + + async def _execute_task(**kwargs): + self.kwargs = kwargs + return result + + import services.task_execution_service as tes + monkeypatch.setattr(tes, "get_task_execution_service", + lambda: SimpleNamespace(execute_task=_execute_task)) + + # The #2795 label re-read. `persisted_status=None` models a row that + # cannot be read at all, which must leave the returned status in force. + harness = self + + class _CoreDB: + def get_execution(self, eid): + harness.reread.append(eid) + if persisted_status is _UNREADABLE: + raise RuntimeError("db down") + if persisted_status is None: + return None + return SimpleNamespace(status=persisted_status) + + import database + monkeypatch.setattr(database, "db", _CoreDB()) + + +#: Distinguishes "the row read as nothing" from "the read raised". +_UNREADABLE = object() + + +def _result(status, response="", error=""): + return SimpleNamespace(status=status, response=response, error=error, + execution_id="exec-room-1", session_id="sess-new") + + +def test_a_cancelled_turn_says_it_was_stopped(monkeypatch, rooms): + h = _WakeHarness(monkeypatch, rooms, _result("cancelled", error="Execution cancelled by user")) + asyncio.run(rooms._wake_agent(SimpleNamespace(email=EMAIL), ROOM, AGENT, 1)) + + assert h.system_lines == [f"{AGENT}'s turn was stopped."] + # The words a person asked for must not come back as the agent's fault. + assert not any("could not respond" in line for line in h.system_lines) + + +def test_a_cancelled_turn_keeps_the_resume_handle(monkeypatch, rooms): + """The drop exists for a DEAD handle. A cancel is no evidence of one, and + dropping it makes the next turn pay for a cold context rebuild.""" + h = _WakeHarness(monkeypatch, rooms, _result("cancelled")) + asyncio.run(rooms._wake_agent(SimpleNamespace(email=EMAIL), ROOM, AGENT, 1)) + + assert h.cleared_sessions == [] + + +def test_a_cancelled_turn_does_not_advance_the_read_cursor(monkeypatch, rooms): + """Unchanged, and worth pinning: the delta this turn never answered must be + re-delivered on the next wake.""" + h = _WakeHarness(monkeypatch, rooms, _result("cancelled")) + asyncio.run(rooms._wake_agent(SimpleNamespace(email=EMAIL), ROOM, AGENT, 1)) + + assert h.advanced == [] + assert h.posted == [] + + +def test_a_failed_turn_is_unchanged(monkeypatch, rooms): + """The regression guard on the split: FAILED keeps both behaviours.""" + h = _WakeHarness(monkeypatch, rooms, _result("failed", error="boom")) + asyncio.run(rooms._wake_agent(SimpleNamespace(email=EMAIL), ROOM, AGENT, 1)) + + assert h.system_lines == [f"{AGENT} could not respond: boom"] + assert h.cleared_sessions == [(ROOM, AGENT)] + + +def test_a_success_with_no_reply_is_still_a_failure(monkeypatch, rooms): + h = _WakeHarness(monkeypatch, rooms, _result("success", response=" ")) + asyncio.run(rooms._wake_agent(SimpleNamespace(email=EMAIL), ROOM, AGENT, 1)) + + assert h.system_lines == [f"{AGENT} could not respond (no response)."] + assert h.cleared_sessions == [(ROOM, AGENT)] + + +def test_a_successful_turn_still_posts_and_advances(monkeypatch, rooms): + h = _WakeHarness(monkeypatch, rooms, _result("success", response="Here you go.")) + asyncio.run(rooms._wake_agent(SimpleNamespace(email=EMAIL), ROOM, AGENT, 1)) + + assert h.system_lines == [] + assert h.posted and h.advanced + + +def test_the_wake_stamps_the_poster_so_only_they_can_stop_it(monkeypatch, rooms): + """The fact `can_stop`'s `mine` gate rests on. If a wake ever stopped + carrying the poster's email, Stop would silently vanish again.""" + h = _WakeHarness(monkeypatch, rooms, _result("success", response="ok")) + asyncio.run(rooms._wake_agent(SimpleNamespace(email=EMAIL), ROOM, AGENT, 1)) + + assert h.kwargs["source_user_email"] == EMAIL + assert h.kwargs["triggered_by"] == "room" + + +# --------------------------------------------------------------------------- +# 3. the label that actually stands (old agent images) +# --------------------------------------------------------------------------- + +def test_a_failed_label_over_a_cancelled_row_reads_as_stopped(monkeypatch, rooms): + """The old-image path. The agent re-raises instead of relabelling, so + `execute_task` writes FAILED, that write LOSES the CAS to the CANCELLED the + terminate route already wrote — and returns FAILED anyway. Without the + re-read the room blames the agent for a stop the reader asked for.""" + h = _WakeHarness(monkeypatch, rooms, _result("failed", error="Timeout"), + persisted_status="cancelled") + asyncio.run(rooms._wake_agent(SimpleNamespace(email=EMAIL), ROOM, AGENT, 1)) + + assert h.reread == ["exec-room-1"] + assert h.system_lines == [f"{AGENT}'s turn was stopped."] + # And the resume handle survives: a cancel is no evidence of a dead one. + assert h.cleared_sessions == [] + + +def test_a_genuine_failure_is_still_a_failure(monkeypatch, rooms): + """The re-read must not turn every failure into a cancel.""" + h = _WakeHarness(monkeypatch, rooms, _result("failed", error="boom"), + persisted_status="failed") + asyncio.run(rooms._wake_agent(SimpleNamespace(email=EMAIL), ROOM, AGENT, 1)) + + assert h.system_lines == [f"{AGENT} could not respond: boom"] + assert h.cleared_sessions == [(ROOM, AGENT)] + + +def test_an_already_cancelled_label_is_not_re_read(monkeypatch, rooms): + """No read on the path that is already exact — the common case pays nothing.""" + h = _WakeHarness(monkeypatch, rooms, _result("cancelled"), persisted_status="cancelled") + asyncio.run(rooms._wake_agent(SimpleNamespace(email=EMAIL), ROOM, AGENT, 1)) + + assert h.reread == [] + assert h.system_lines == [f"{AGENT}'s turn was stopped."] + + +def test_a_success_with_a_reply_is_not_re_read(monkeypatch, rooms): + """The hot path pays nothing. The first draft re-read on EVERY turn — one + extra DB read per successful room reply, for a label that could not change.""" + h = _WakeHarness(monkeypatch, rooms, _result("success", response="ok"), + persisted_status="cancelled") + asyncio.run(rooms._wake_agent(SimpleNamespace(email=EMAIL), ROOM, AGENT, 1)) + + assert h.reread == [] + assert h.system_lines == [] + assert h.posted + + +def test_a_success_with_no_reply_IS_re_read(monkeypatch, rooms): + """An empty reply takes the failure branch, so it is a place the label can + still be wrong — a SIGKILL'd turn on an old image can land here.""" + h = _WakeHarness(monkeypatch, rooms, _result("success", response=" "), + persisted_status="cancelled") + asyncio.run(rooms._wake_agent(SimpleNamespace(email=EMAIL), ROOM, AGENT, 1)) + + assert h.reread == ["exec-room-1"] + assert h.system_lines == [f"{AGENT}'s turn was stopped."] + assert h.cleared_sessions == [] + + +def test_an_unreadable_row_leaves_the_returned_status_in_force(monkeypatch, rooms): + """Fail-OPEN: a label read must never be able to break the turn.""" + h = _WakeHarness(monkeypatch, rooms, _result("failed", error="boom"), + persisted_status=_UNREADABLE) + asyncio.run(rooms._wake_agent(SimpleNamespace(email=EMAIL), ROOM, AGENT, 1)) + + assert h.system_lines == [f"{AGENT} could not respond: boom"] + + +def test_a_missing_row_leaves_the_returned_status_in_force(monkeypatch, rooms): + h = _WakeHarness(monkeypatch, rooms, _result("failed", error="boom"), + persisted_status=None) + asyncio.run(rooms._wake_agent(SimpleNamespace(email=EMAIL), ROOM, AGENT, 1)) + + assert h.system_lines == [f"{AGENT} could not respond: boom"] diff --git a/tests/unit/test_ent473_chat_titles.py b/tests/unit/test_ent473_chat_titles.py index 602ab3fc5..64a64d860 100644 --- a/tests/unit/test_ent473_chat_titles.py +++ b/tests/unit/test_ent473_chat_titles.py @@ -462,8 +462,19 @@ def test_the_spawn_sits_between_the_persist_and_the_turn(): persist = src.index("_persist_user_turn(agent_name, email, session_id, client_message, voice_call_id=voice_call_id)") spawn = src.index('_spawn_title_generation(agent_name, session_id, client_message, "",') # The first thing the turn path does after the spawn. - turn = src.index("images, image_names, doc_files = await _collect_inbox_for_turn") + # + # #2794 moved the manifest COMPOSITION out to `collect_inbox_context` so a + # room could reuse it, which left the old anchor + # (`images, image_names, doc_files = await _collect_inbox_for_turn`) still + # present in the file — but inside that new function, several thousand lines + # BELOW the spawn. The assertion would have stayed green while pinning the + # order of two lines in different functions, i.e. proving nothing. The anchor + # has to be the call `portal_chat` itself makes. + turn = src.index("manifest_prefix, images = await collect_inbox_context(") assert persist < spawn < turn + # And that anchor must be unique, or the index() above can silently drift to + # a second occurrence the next time this is refactored. + assert src.count("manifest_prefix, images = await collect_inbox_context(") == 1 def test_there_is_exactly_one_spawn_site_and_it_carries_no_reply():