Skip to content

feat(voice): audio-native voice — the model hears you and answers in the same breath - #2399

Merged
2witstudios merged 30 commits into
masterfrom
pu/gpt-realtime
Aug 11, 2026
Merged

feat(voice): audio-native voice — the model hears you and answers in the same breath#2399
2witstudios merged 30 commits into
masterfrom
pu/gpt-realtime

Conversation

@2witstudios

@2witstudios 2witstudios commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Replaces PageSpace's voice mode with an audio-native one on OpenAI's Realtime API (gpt-realtime-2.1), built as nine reviewed PRs into this branch.

Why

The old voice mode transcribed before inference: MediaRecorder → whisper → the text chat stream → tts → playback. The model never heard audio, so pace, hesitation, interruption and tone were destroyed before it saw your words, and you waited for a whole reply to be written before hearing any of it. That isn't a tuning problem — it's the wrong shape.

The thesis every decision was framed against

Voice is not a feature of a surface. It is a second transport onto conversations PageSpace already has.

You speak into a conversation that already existed and still exists after you hang up — as text, in the thread, where the sidebar and history already render it. One trigger on every route, binding to whatever assistant is already in view. Any page agent someone already built is talkable, with no new config.

How it works

The browser owns only the mic and the speaker. It POSTs an SDP offer to our route; the server mints the ephemeral secret, relays to OpenAI, reads the call_id, and hands off over an HMAC-signed internal call to apps/realtime — which attaches to that same session over WebSocket and holds it for the life of the call. Tools, permissions, persistence and metering all stay server-side; a tool schema never reaches the client.

  • One tool surface, not two. Reuses splitToolsForExposure, so voice exposes the identical core tools plus tool_search/execute_tool as text — filtered by the bound agent's own enabledTools, on both the advertised and the executable list. Permissions come for free because they're enforced inside the tools, against the agent the call is bound to.
  • Transcripts are the durable layer. Both sides land as ordinary messages rows via messageRepository, bumping conversations.rev in-transaction and emitting the events every chat surface already subscribes to — so the sidebar updates live with zero new client wiring.
  • Metering accumulates usage per response.done at real per-modality token rates, with hold/settle, idle timeout, duration cap and a concurrency cap against the account's 40k tok/min ceiling.

What a call is bound to

Voice binds to a conversation that already exists, so almost everything about the call is a fact about that conversation — resolved once, server-side, behind that conversation's own access check (loadVoiceBinding), and never read off the request body:

  • the history replayed into the session before the model speaks;
  • who is being talked to — a type: 'page' conversation's contextId is its agent page, asserted to be AI_CHAT;
  • what that assistant was told to be — its systemPrompt, verbatim, pushed on the same session.update as the tools (the mint carries neither);
  • which tools its owner allowed it — applied before the exposure split, so tool_search cannot describe and execute_tool cannot reach a tool that was switched off.

Tool execution therefore runs as the agent (chatSource.agentPageId), exactly as page-chat-turn.ts does for the same conversation — not as whoever pressed the mic.

Verified against the live API before it was built

A spike proved the whole architecture end to end — real Chromium, real WebRTC call, server attached to the same session, registered a tool, received the function_call, answered it, and the model spoke a value only our server could have supplied. It also found things the docs get wrong:

  • Mint success is not proof of model accessclient_secrets returns 200 for a model /v1/realtime/calls will reject.
  • The attach needs that call's own ephemeral secret, not the API key (docs say otherwise; API key gives 404 call_id_not_found).
  • An attached socket is silent on connect — no session.created ever arrives.
  • function_call.arguments is a newline-laden string; usage is per-response, not per-call.

Not verified

Nobody has spoken to this yet. Every test uses fakes — fake RTCPeerConnection, fake WebSocket, fake mic. Coverage was checked by mutation (breaking the source and watching tests go red) which caught four gaps that green checkmarks hid, but real acceptance is pressing the mic and talking.

One specific claim to confirm on a live call: the hangup credential is inferred, not measured — it uses the call's own ephemeral secret, which is what authenticates the attach, but that wasn't verified against a live hangup. It degrades safely (a refused hangup still tears our side down), and it now runs on two paths: every teardown reason, and an admission refusal abandoning a call OpenAI had already accepted.

Not retired

/api/voice/synthesize and Read Aloud stay — on-demand "read this to me" is a different feature that an audio-native conversation doesn't replace, and open PR #2173 builds on that route. Only the conversational loop (useVoiceMode.ts, /api/voice/transcribe) is deleted.

Migration

One nullable additive column: ALTER TABLE messages ADD COLUMN source text (0258), marking voice-authored rows so the UI can show a mic glyph. No backfill, no lock risk. Renumbered from 0256 after syncing master.

Gate

~1,800 web tests, 1,122 realtime tests, 826 lib tests, monorepo tsc and eslint clean, and apps/realtime back over its 98% branch-coverage threshold (97.01% → 98.08%) — that gate was what CI was failing on. The only failures are the known Postgres-backed integration suites that need a local test DB.

New tests were mutation-checked where they guard a security or correctness mechanism: the per-user admission race, the agent tool allowlist, the acting-agent context, the microphone release on a peer-setup throw, and the persisted-source broadcast were each verified to go red against the unfixed code.

Summary by CodeRabbit

  • New Features

    • Added real-time voice calls from the top bar that continue across navigation.
    • Voice calls share conversations with text, preserve context, support muting and interruption, and show live transcripts.
    • Voice-authored messages display a microphone indicator.
    • Voice calls can execute tools and save transcripts to conversations.
  • Bug Fixes

    • Improved microphone, connection, startup, and transcription error messages with retry guidance.
  • Changes

    • Removed hands-free chat microphone controls, voice settings, and standalone transcription.

2witstudios and others added 21 commits August 10, 2026 12:08
Ports the proven core from the pagespace-voice prototype into
apps/web/src/lib/ai/realtime/ as three pure modules — no I/O, no clock,
no randomness, no module-level mutable state, no React:

- events.ts: function calls out of `response.done`, transcripts under BOTH
  `response.output_audio_transcript.done` and the older
  `response.audio_transcript.done` (renamed across releases, deployments
  straddle it), the `function_call_output` client event, and a `parseEvent`
  that returns undefined on a malformed frame rather than throwing.
- session.ts: the `client_secrets` body shape, the two endpoint URLs, the
  `oai-events` channel name, and defensive `value` narrowing on the mint
  response. Default model is `gpt-realtime` — access is per-account,
  `client_secrets` accepts any string, and `/v1/realtime/calls` 403s
  `model_not_found` at connect time — with OPENAI_REALTIME_MODEL as the
  upgrade path, resolved purely from a passed-in env bag.
- session-state.ts: the session reducer. A failure keeps the transcript;
  a drop mid-conversation must not erase the record.

Tool definitions are deliberately not ported: PageSpace has its own tool
registry, so only the transport-shaped `RealtimeTool` type lives here.

68 tests, 100% statements/branches/functions/lines on all three files,
mutation-checked (34 mutations, each verified to turn the suite red).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuzQWypmJU8b8pKvqt3BgW
feat(voice): port the pure realtime core
Realtime carries tools as flat {type,name,description,parameters} with plain
JSON Schema; PageSpace holds them as AI SDK tool() objects. buildRealtimeTools
bridges the two by reusing the text stack's own machinery rather than curating
a voice-specific list: splitToolsForExposure picks the upfront half, and
tool_search/execute_tool come from the same factories the Global Assistant
uses, so voice and text cannot describe the same tool differently or drift as
tools are added.

Schema conversion is the same z.toJSONSchema call tool_search already makes.
$schema is dropped — it describes the document, not the parameter contract, and
strict schema validation rejects unsupported top-level keywords.

No always-upfront set: that rescue exists for composer-toggled tools facing
execute_tool's allowlist re-check on the text routes, and a voice call has no
composer toggles.

toRealtimeTool is exported so the conversion guard can run over every tool in
the real registry — buildRealtimeTools converts only the upfront half, so
testing through it alone would pass vacuously for the deferred majority
(mutation-checked: a z.date() on a deferred tool now goes red and names it).

Pure: the registry is a parameter, never a module-load import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011yZkd6fkVQ4bJVoUAqCHH4
feat(voice): adapt the tool registry to realtime function definitions (W1-A2)
…constants

Voice bills deterministically today by audio-second (whisper) and input-character
(tts). The audio-native realtime path bills TOKENS, per modality, in a shape
different enough to need its own table. Additive only: the whisper/tts rates and
behaviour are untouched, since on-demand TTS (Read Aloud) is not being retired.

voice-pricing.ts
- REALTIME_RATES: published gpt-realtime rates as USD per token, every rate
  env-overridable via the file's existing envFloat, defaults pinned by tests.
- calculateRealtimeCostDollars(model, usage): pre-markup provider cost for one
  response.done frame; the credit pipeline applies MARKUP_BPS as it does for
  every other call. A call's total is the sum across its responses, since usage
  is reported per RESPONSE, not per call.

Three wire asymmetries are modelled rather than smoothed over:
- Caching is input-only, so the output rate type has no cached member to fill in
  wrongly, and no image member either.
- cached_tokens is a SUBSET of input_tokens: the cached slice bills at the cache
  rate and only the REMAINDER at the full input rate. Each modality's cached
  count is clamped to its own gross count, so a malformed payload cannot drive
  the fresh remainder negative and subtract money from the bill.
- Modality is the cost driver (one short spoken sentence measured 30 text + 86
  audio output tokens, audio being ~2.7x the text rate), so each side is priced
  per modality rather than blended.

Unknown model, absent usage, or missing/negative/NaN quantities bill 0 — never a
negative or NaN charge. A usage object carrying only totals also bills 0: an
unattributed token cannot be priced (audio input is 8x text input) and guessing a
modality would over-charge, so the metering layer is left to notice that shape.

credit-pricing.ts — realtime session constants beside the existing voice ones:
- REALTIME_SESSION_HOLD_ESTIMATE_CENTS (10c). Realtime settles CONTINUOUSLY as
  usage arrives per response, so the hold need only cover the window between
  settles rather than the whole call.
- REALTIME_MAX_SESSION_SECONDS (600). Derived, not picked: it must stay inside
  CREDIT_HOLD_TTL_SECONDS (900) or the reconcile cron reclaims a live call's own
  reservation mid-session. A test asserts a >=300s margin.
- REALTIME_IDLE_TIMEOUT_SECONDS (120). Reaps the abandoned call, which otherwise
  holds a slot and streams ambient audio into a per-token-billed model.
- REALTIME_MAX_INFLIGHT (2 per user). Voice is physically exclusive, so 1 is the
  semantically correct cap; 2 keeps a not-yet-reaped zombie session from locking
  a user out of reconnecting.
- REALTIME_MAX_GLOBAL_SESSIONS (8). The per-user cap structurally cannot enforce
  the binding constraint: the OpenAI account is limited to 40,000 tokens/MINUTE
  across every session on the key, which a talking session approaches at roughly
  4k tokens/min — putting the ceiling near 10.

Pure: no route wiring, that is the metering leaf's job.

Verified: voice-pricing.ts at 100% statement/branch/function/line coverage, with
7 mutations (double-count guard, cached clamp, a published rate, the negative
quantity guard, the pro-rata share cap, and both session constants) each
confirmed to turn the suite red. Root typecheck 17/17 and lint 15/15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCyj6kA4A7bwmmpwDhHrer
feat(voice): price gpt-realtime token usage and add realtime session constants
The 2.1 default was reverted to gpt-realtime because /v1/realtime/calls returned
403 model_not_found for it. That was a genuine entitlement gap, not a bad model
id: 2.1 has since been added to the project allowlist, GET /v1/models now lists
gpt-realtime-2.1 and -mini, and the WebRTC calls endpoint returns 201 for it
(verified with a real browser SDP offer).

Two durable lessons kept in the comment, because both cost real debugging time:
mint returns 200 for a model the calls endpoint will reject, so model-config
errors must be read from the calls response; and a plain wss://...?model=X socket
opens even for an unentitled model, so it cannot be used as an entitlement probe.

OPENAI_REALTIME_MODEL stays the per-environment override — a deployment whose
project lacks 2.1 still needs gpt-realtime.
fix(voice): pin gpt-realtime-2.1 now that the project allowlists it
…d hold it

The server-side call plane, end to end: the browser POSTs an SDP offer to our
route and gets an answer back, while our server holds the call id and that
call's ephemeral secret and hands both to apps/realtime over an HMAC-signed
internal request. apps/realtime attaches a WebSocket to the same session,
lands session.update with the real tool set, and registers the socket for its
whole life.

PART 1 — apps/web
- POST /api/voice/realtime/call: auth + tier gate copied from the transcribe
  route, mint, SDP relay, Location parse, signed handoff, answer SDP back.
- The model is resolved with resolveRealtimeModel(process.env) and passed
  explicitly, never left to buildSessionConfig's default.
- The mint carries NO tools: tools arrive with the process that executes them,
  so a call with no server attached cannot advertise tools nobody will answer.
- Upstream failures surface as 502 carrying the upstream status and body —
  /v1/realtime/calls is the only place a bad OPENAI_REALTIME_MODEL is visible,
  since the mint 200s for a model the project cannot use.
- INTERNAL_REALTIME_URL unset degrades to a working audio call (attached:false)
  rather than failing; a missing/malformed Location fails loudly instead.
- The ephemeral secret is never returned, never logged, never persisted.

PART 2 — apps/realtime
- POST /api/realtime/attach, beside /api/broadcast, HMAC-verified before the
  body is parsed — it carries a live OpenAI credential.
- Readiness is socket OPEN then session.updated, never session.created, which
  an attached socket never sends.
- Named failures for every outcome, including the API-key-instead-of-ephemeral
  -secret case, refused before a socket is opened.
- Registry keyed by callId following socket-registry.ts, with a synchronously
  reserved slot so the concurrency cap holds across the attach await.
- Teardown on close, error, explicit end, and a hard duration cap.

Shared contract in packages/lib/src/realtime/voice-bridge-contract.ts, parsed
by both sides. Tools are converted web-side (the registry lives there and
apps/realtime has no dependency edge to apps/web) and travel in the signed
payload, so no tool schema reaches the browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012pYq7haTAw3E2zVKDp3t9W
feat(voice): get our server onto the realtime call, authenticated, and hold it
C-A got our server onto the realtime socket and holds it open. This is
everything that happens on it: the session is seeded from the bound
conversation, tool calls are dispatched against PageSpace's real registry with
real permissions, both sides of the exchange are written into that conversation
as ordinary messages, and every response's usage is metered.

One chunk, because it is one event loop. A `response.done` carries a tool call
AND the usage for the turn that produced it; the same silence that means "no
transcript to write" means "the idle timer should be running". Split apart,
each would need its own copy of the frame plumbing and its own theory of when
the call ended.

PART 1 — the seed (apps/web/src/lib/ai/realtime/seed.ts, pure)
- `buildRealtimeSeed(messages, opts?)` -> ordered `conversation.item.create`
  events. Hard-capped at 20 turns / ~4k estimated tokens, both configurable,
  most-recent kept, oldest dropped first.
- The cap IS the feature. Realtime cannot take assistant audio as history, so
  prior turns can only be injected as text, and injecting a large text history
  into a session with audio output is a documented way to get no audio at all.
- The content-part type is NOT symmetric and fails silently if you get it
  wrong: `input_text` for a user item, `text` for an assistant item. Verified
  against the conversations guide and openai/openai-realtime-api-beta#57.
- A single most-recent turn larger than the whole budget is TRUNCATED, not
  dropped: returning an empty seed for a thread that plainly has context is the
  one outcome nobody wants. The cap still holds absolutely.
- Token estimate is a documented chars/4 heuristic. No tokenizer dependency.
- Loaded web-side (`seed-loader.ts`) behind `canAccessConversation` and shipped
  in the attach payload, so it is in the realtime server's hand before the model
  speaks rather than a round trip later. Every unhappy path yields an empty
  seed: a seed is never a reason to fail a call.

PART 2 — tool dispatch (apps/web/src/lib/ai/realtime/tool-dispatch.ts)
- `function_call.arguments` is a newline-laden STRING and is not guaranteed to
  parse. Parsed defensively, with brace-counting recovery for the common
  streamed-delta corruption (trailing junk after a complete object) that ignores
  braces inside strings.
- Runs against `buildRealtimeToolSet` — extracted from `buildRealtimeTools` so
  what the session ADVERTISES and what the dispatcher RUNS are one expression
  evaluated twice, in two processes. `tool_search`/`execute_tool` work unchanged.
- Builds a real `ToolExecutionContext` carrying the acting `userId`,
  `conversationId`, `timezone` and `locationContext`. Permissions are NOT
  re-implemented: every PageSpace tool enforces access internally against that
  userId, and a second layer here would be a second thing to keep in step.
- Always returns a STRING, on every path including failure — the model is
  blocked until `function_call_output` arrives.
- Results are truncated generically (word boundary + how much was omitted +
  how to get the rest). The prototype's per-tool presenters do not scale to a
  registry of dozens, and a curated list would be a second tool surface.

PART 3 — transcript persistence
- Written through `messageRepository`, so the write, the in-transaction
  `conversations.rev` bump and the `conversation:*` emit are the SAME ones the
  typed path uses. The sidebar, GlobalAssistantView and page-agent chat update
  live with ZERO new client wiring — because this is not a second writer.
- Global -> `saveGlobalMessage`; page -> `savePageMessage` with the agent page,
  assistant rows attributed `userId: null` + `sourceAgentId` per the table's
  attribution rule; drive/client threads refused rather than guessed at.
- Lazy first-message creation via `resolveOrCreateConversation`, as typing does.
- New nullable `messages.source` column (migration 0256) marks voice-authored
  rows, so the UI can show a mic glyph and the seed can tell spoken turns from
  typed ones. Set on INSERT only — a later terminal write does not change how a
  message was made.

PART 4 — metering (apps/realtime, no HTTP hop)
- Usage is per RESPONSE, so `usage-accumulator.ts` folds it per modality;
  summing only the totals would produce a tidy number that prices at zero,
  because `calculateRealtimeCostDollars` prices from the detail blocks. Cached
  tokens stay a subset, never an addition.
- Continuous settlement: hold -> talk -> settle that window exactly -> re-hold.
  `trackUsage` takes ownership of the hold, so a long call that did not re-hold
  would run unreserved; a refused re-hold ends the call mid-conversation rather
  than at hangup.
- Idle timeout, duration cap (bounded by CREDIT_HOLD_TTL_SECONDS), per-user and
  per-deployment concurrency caps. `MAX_CALL_DURATION_MS` stays what its doc
  says: a socket-leak backstop an order of magnitude looser, not a duplicate.
- `gpt-realtime-2.1` had NO pricing row, so the model the app pins would have
  billed $0. Added (rates verified identical to `gpt-realtime`) and the stale
  "not served over WebRTC" comment corrected.

THE PROCESS BOUNDARY, decided deliberately
`apps/realtime` now calls BACK into web at `/api/internal/voice/bridge`, signed
with the same symmetric HMAC. Tools and transcripts cross because their owners
— the tool registry behind `@/` aliases, and `messageRepository` with its rev
invariant — genuinely cannot move. Metering deliberately does NOT cross: its
whole dependency set already lives in `@pagespace/lib`, which this app depends
on directly, so an HTTP hop would add a failure mode and buy nothing. Same rule
C-A applied to tools: the work runs where its owner lives.

This makes false a statement in docs/2.0-architecture/agent-sessions.md §3d
("realtime makes no outbound HTTP calls to web at all"), so that section is
amended in this change. The deploy ORDER is unchanged — every bridge hop is
best-effort, so new realtime against old web degrades to audio without tools or
transcripts rather than dropping calls.

`events.ts` moved to `packages/lib/src/realtime/voice-events.ts` and gained
`extractUsage`/`extractRateLimits`: the process that reads this event stream is
`apps/realtime`, which cannot import from the web app.

Gate: monorepo typecheck (16/16 non-web packages clean under --force; web clean
under direct `tsc --noEmit` — turbo's web#typecheck races its own build and
prints TS6053 for `.next/types`), lint 15/15, realtime 1093 tests, lib 9244,
web 16868. Remaining failures are DB-only suites ("DATABASE_URL must point at a
migrated Postgres") in a worktree with no test database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sdoa1UNkm5vqMowkytc39
feat(voice): what the server does while it is on the call
# Conflicts:
#	packages/db/drizzle/meta/0256_snapshot.json
#	packages/db/drizzle/meta/_journal.json
…yncing master

master added 0256_parched_bloodscream and 0257_absurd_grim_reaper while this
epic branch sat at 0255, so the generated 0256_violet_blur collided on both the
journal and the filename. Took master's journal, dropped the stale 0256, and
regenerated: the migration is byte-identical in effect (one nullable additive
column on messages) and now lands as 0258.
The server side of the audio-native call has been merged and working since
#2393/#2395 — mint, relay, HMAC handoff, seeding, tool dispatch, transcript
persistence, metering — and nothing had ever driven it from a browser. This is
that half: the page opens the peer connection, adds the mic track, opens the
`oai-events` data channel, POSTs its SDP offer to our relay route, and applies
the answer. It never contacts api.openai.com, never holds an ephemeral secret,
and is never sent a tool schema.

VoiceSessionProvider is mounted in Layout, ABOVE RightPanel. That placement is
load-bearing, not incidental: `rightPanelVisible && …` unmounts the right
sidebar outright when it closes, so a session owned by the panel would hang up
every time somebody collapsed it. The sidebar is voice's home, not its owner —
and the test starts a call from inside the panel and then unmounts the panel,
so the property is asserted rather than merely arranged.

Every decision is a pure module the provider only wires together: `voice-target`
(navigating is not rebinding — walking to another page moves locationContext,
choosing another agent moves the conversation), `chain-schedule` (when to hand
off), and the already-merged `sessionReducer` (what the UI shows).

CHAINING. A call has a server-enforced ceiling; a conversation does not. Before
the cap lands, the client mints a fresh call on the SAME conversationId — which
the server reseeds from that thread, because the transcript is the durable layer
— and swaps make-before-break, so there is no moment with no session. The
microphone is handed over as an independent track clone: no second permission
prompt, no blink in the recording indicator, and stopping the outgoing call
cannot take the incoming call's audio with it. The replacement negotiates
muted, so two live sessions cannot both hear the same sentence.

The ceiling had to come from the server. `REALTIME_MAX_SESSION_SECONDS` is
per-deployment env the browser cannot read, so the call route now reports
`maxDurationMs`. The alternative was a client-side copy of a server env var,
whose failure mode is a user cut off mid-sentence on the one deployment that
tuned it.

`getMicPermissionErrorMessage` moved out of useVoiceMode into
`lib/voice/mic-errors` rather than being ported: both paths ask the same API and
hit the same five failures, and its desktop-Electron branch (System Settings,
not "browser settings", which an Electron shell does not have) is not one to
keep two copies of. Denied and missing stay different outcomes with different
advice.

Gate: monorepo `bun run typecheck` 17/17, `bun run lint` 15/15, 345 voice tests
green. Teardown claims are mutation-checked, not asserted on faith.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhbBndG131JyacZCqXrF5w
…vives

Review caught a real hole. `VoiceSessionContext.test.tsx` mounts its own
provider, so it proves a session survives a child unmounting — but replace
`<VoiceSessionProvider>` in Layout with a passthrough and all 25 of those tests
still pass, while every call in production would hang up the moment the user
closed the sidebar. The one load-bearing fact of this chunk was unguarded.

This asserts it against the REAL Layout tree. Everything heavy is mocked EXCEPT
the thing under test: the provider is the real one, mounted by the real Layout,
and `RightPanel` is replaced by a probe that CONSUMES the session — so a
provider that is missing, passthrough, or moved inside the panel fails at render
rather than subtly. The call is then started from inside that probe and the
sidebar gate is closed underneath it.

Verified by breaking Layout three ways and watching it go red, then restoring:
  1. provider replaced with a passthrough (the exact review mutation) — 3 red
  2. provider moved inside the `rightPanelVisible &&` region — 3 red
  3. provider kept as an ancestor but re-keyed on `rightPanelVisible`, so it
     remounts on toggle — 2 red, on `stop` having been called and the reopened
     panel finding no call. That one isolates the SURVIVAL assertion, proving it
     is not decorative: the hooks never throw, only the call dies.

No existing test was weakened to make this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhbBndG131JyacZCqXrF5w
feat(voice): the browser holds the mic and the speaker, and nothing else
The first user-visible piece of the audio-native path. Everything beneath it
was merged and untested by a human; this is the part a person actually touches.

THE SHAPE, as settled:
- ONE trigger, in the nav bar, on every route. Nothing connects until it is
  pressed. It doubles as the live indicator and as the way back to a minimized
  call — and it NEVER hangs up, because a toggle there means pressing the live
  indicator hides the call you pressed it to see. Ending a call is the End
  button on the call.
- It binds to whatever assistant is in view: the sidebar's selected agent on a
  page route, the GlobalAssistantView conversation on the dashboard (where the
  sidebar has no chat tab at all). There is no second target picker anywhere.
- Voice is a MODE on the chat surface, not a fourth tab and not an overlay:
  the same message list, with a live call header above it. Spoken turns arrive
  in that list as ordinary messages — the realtime server writes them through
  messageRepository, so they ride the `conversation:*` events every surface
  already subscribes to. No new client wiring, and no second history.
- Closing the sidebar minimizes; navigating updates locationContext and does
  NOT rebind; the sidebar's agent switcher DOES rebind.

Every decision is a pure module beside the wiring — `resolveVoiceBinding`,
`rebindAction`, `decideReveal`, `describeCall`, `toVoiceLocationContext` — so
the rules are testable without a browser, a peer connection or a microphone.

`rebindAction` is the load-bearing one. Navigating and switching agent arrive
as the SAME change to the derived target, so a "watch the target and call
start" effect cannot tell them apart and would hang up the user's call every
time they opened a page. A rebind is therefore driven by the switch EVENT: the
switcher records an intent, and the intent is applied once the newly chosen
agent's conversation has resolved. Navigation records nothing.

TWO THINGS THE MERGED SEAM WAS MISSING, added at the provider rather than
worked around in the UI:
- `failure` — the classified reason beside `error`. A sentence cannot be
  branched on, and a denied prompt and an absent capture device need opposite
  affordances: one offers Try again, the other must not, because a retry that
  cannot conjure hardware teaches the user that voice is broken.
- `muted`/`setMuted` — mute has to live with the microphone. Held by the
  chrome it would return un-muted every time the sidebar was collapsed. It also
  fixes a latent bug: the swap hardcoded `setMicrophoneEnabled(true)`, so a
  chain at the duration ceiling un-muted a deliberately muted call.

`messages.source` now reaches the UI (conversion AND broadcast, so the glyph
does not appear only after a refresh) and both renderers mark a spoken turn.
The browser reads its own copy of the value with a drift guard, since client
components here cannot import @pagespace/db/schema.

Also: the right sidebar's page-context tab moved from panel-local state into
the layout store, because a button in the header cannot reach a `useState`
inside a panel that is unmounted while collapsed.

Unrelated to this chunk, but the branch's knip gate was already red for it:
two per-member request types in voice-bridge-contract.ts (from #2395) were
exported and never imported. Removed — consumers narrow on the union's `kind`.

Gate: `bun run typecheck` 17/17, `bun run lint` 15/15, `bun run knip:check`
within baseline, web tests 17085 passed with only the 17 known
Postgres-requiring integration files failing (no test DB in this worktree).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PyxC1FeYap8bU98cLzcXhH
feat(voice): the talk button, and voice as a mode on the chat surface (C-D)
The audio-native path is merged end to end — server call plane (#2393),
server behaviour (#2395), browser + lifecycle (#2396), UI (#2397) — so the
old pipeline is dead weight on the conversational path. This removes exactly
that much and nothing else.

WHAT WENT, and why each piece could not stay:

- `useVoiceMode` + `/api/voice/transcribe`: the loop itself. Whisper existed to
  turn audio into text before inference; the realtime session hears the audio
  directly, so there is nothing left for it to do. Verified with a repo-wide
  search that no other caller reaches the route.
- `useVoiceModeStore` and everything reading it — `VoiceCallPanel`,
  `VoiceModeSettings`, `VoiceModeBorder`, and the mic button in the chat box's
  footer. The store's only writer was that button. Left in place, the border
  would be UI that can never render and the button an affordance that toggles
  a mode nothing implements. The way into voice is the nav-bar trigger.
- `selectVoiceStreamText`, `selectVoiceActivationBaseline` and
  `selectPostBaselineAssistantMessage`: three pure selectors whose only job was
  deciding which written reply the old path should speak. Spoken turns now
  arrive as ordinary messages, so nothing derives a "what to say out loud"
  from the message list any more.
- The Whisper rate in `voice-pricing`, and `VOICE_HOLD_ESTIMATE_CENTS` — the
  flat hold that existed because STT could not know its own cost until the
  provider answered. Both had exactly one caller, the deleted route.

WHAT DELIBERATELY STAYED. `/api/voice/synthesize`, the tts-1/tts-1-hd rates,
`estimateVoiceHoldCents`, `VOICE_MAX_INFLIGHT` and `chunkForTts` all back
Read Aloud, which is an open PR (#2173) and a genuinely different feature: an
audio-native conversation does not replace "read this to me". `mic-errors`
stays because the realtime path is now its only consumer.

`chunkForTts` is kept despite having no in-tree caller on this branch — its last
one went with `VoiceCallPanel` — because `useReadAloud` imports `flushForTts`
from it on #2173. Deleting it would break work in flight. knip does not report
it, so it needed no ignore. The one knip.json line added is for
`@radix-ui/react-slider`: deleting VoiceModeSettings left `components/ui/slider.tsx`
as its only importer, and `src/components/ui/**` is already ignored.

TESTS DELETED WITH THEIR SUBJECTS, never to make the gate pass:
`useVoiceModeStore.test.ts`, `transcribe/route.test.ts`, and the three stream
selector tests. `voice-pricing.test.ts` loses its Whisper describe block; the
unknown-model and 1¢-floor assertions are kept, retargeted off `whisper-1`.
`whisper-1` survives as a fixture in the admin billing-coverage tests, where it
stands for historical usage rows that still exist in the database.

Gate: monorepo `bun run typecheck` 17/17, `bun run lint` 15/15, knip ratchet
green with an unchanged baseline. Unit suites pass (lib 9175, web 17057); the
only red files are the DB-backed integration tests, which need a Postgres this
worktree has no access to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYjZgYpebAVgBq9f5oVt81
feat(voice): retire the conversational STT->LLM->TTS loop
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6033676c-bec7-4f80-a83d-278f10cc5c24

📥 Commits

Reviewing files that changed from the base of the PR and between ba27f78 and e0c4029.

📒 Files selected for processing (14)
  • CHANGELOG.md
  • apps/realtime/src/voice/__tests__/call-hangup.test.ts
  • apps/realtime/src/voice/__tests__/voice-call-runtime.test.ts
  • apps/realtime/src/voice/voice-call-runtime.ts
  • apps/web/src/lib/ai/realtime/__tests__/active-conversation-guard.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/binding-loader.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/call-chrome.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/connect.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/instructions.test.ts
  • apps/web/src/lib/ai/realtime/binding-loader.ts
  • apps/web/src/lib/ai/realtime/call-chrome.ts
  • apps/web/src/lib/ai/realtime/connect.ts
  • apps/web/src/lib/ai/realtime/voice-runtime-deps.ts
  • scripts/lib/tenant-export-columns.ts
🚧 Files skipped from review as they are similar to previous changes (11)
  • apps/web/src/lib/ai/realtime/tests/connect.test.ts
  • apps/web/src/lib/ai/realtime/tests/call-chrome.test.ts
  • apps/web/src/lib/ai/realtime/binding-loader.ts
  • CHANGELOG.md
  • apps/realtime/src/voice/tests/voice-call-runtime.test.ts
  • apps/realtime/src/voice/tests/call-hangup.test.ts
  • apps/web/src/lib/ai/realtime/tests/binding-loader.test.ts
  • apps/web/src/lib/ai/realtime/connect.ts
  • apps/web/src/lib/ai/realtime/call-chrome.ts
  • apps/realtime/src/voice/voice-call-runtime.ts
  • apps/web/src/lib/ai/realtime/voice-runtime-deps.ts

📝 Walkthrough

Walkthrough

This PR adds audio-native realtime voice calls with WebRTC setup, server-side tool execution, transcript persistence, continuous metering, persistent browser controls, conversation rebinding, and spoken-turn metadata. It removes the previous transcription flow and voice-mode UI.

Changes

Realtime voice calling

Layer / File(s) Summary
Shared contracts and billing
packages/lib/src/realtime/*, packages/lib/src/monitoring/voice-pricing.ts, packages/lib/src/billing/credit-pricing.ts, packages/db/src/schema/conversations.ts
Adds voice bridge schemas, event utilities, token pricing, session holds, concurrency limits, and message-source storage.
Web handshake and bridge flow
apps/web/src/app/api/voice/realtime/call/route.ts, apps/web/src/lib/ai/realtime/*, apps/web/src/app/api/internal/voice/bridge/route.ts
Authenticates calls, loads bindings, performs SDP negotiation, signs attachment requests, dispatches tools, and persists transcripts.
Realtime attachment and runtime
apps/realtime/src/voice/*, apps/realtime/src/index.ts
Adds socket attachment, capacity reservations, metering, runtime event handling, bridge callbacks, hangup, and cleanup.
Persistent browser session and controls
apps/web/src/contexts/VoiceSessionContext.tsx, apps/web/src/components/ai/voice/realtime/*, apps/web/src/components/layout/*
Adds persistent call state, navigation updates, rebinding, chaining, mute controls, call bars, top-bar activation, and layout-aware reveal behavior.
Message metadata and legacy voice removal
apps/web/src/components/ai/shared/chat/*, apps/web/src/lib/repositories/*, apps/web/src/components/ai/voice/*, apps/web/src/hooks/useVoiceMode.ts
Propagates voice-source metadata, renders spoken-turn indicators, and removes the former transcription route, store, panel, settings, and chat-box controls.

Estimated code review effort: 5 (Critical) | ~120 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: replacing the legacy voice pipeline with audio-native realtime voice interaction.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/gpt-realtime

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91767b13e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

userId: input.userId,
status: response.status,
});
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject calls that fail realtime admission

When the attach endpoint returns 402 for insufficient credits or 429 for a concurrency limit, this path reduces the policy refusal to attached: false; the call route then still returns the already-created SDP answer, so the browser can continue using an unmetered OpenAI call and bypass both credit and concurrency gates. Distinguish admission refusals from an optional-service outage, terminate the just-created OpenAI call, and return a handshake failure.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in ba27f78.

handOff returned a bare boolean, so "the metering plane is unreachable" and "policy says no" both became attached: false and the route returned the answer SDP either way. It now returns a three-way HandoffOutcome:

  • 402 / 429 → refused: the handshake fails with a new admission_refused code, and abandonCall() POSTs /v1/realtime/calls/{id}/hangup with that call's own ephemeral secret before returning — refusing to attach does not end a call OpenAI already accepted. The route answers the upstream status (402/429) rather than 502, since this is a policy outcome, not a malfunction.
  • everything else → degraded: unchanged best-effort behaviour. A 400 from our own wire or a 502 from the attach declined nothing, so the caller keeps the degraded call.

Tests in call-handshake.test.ts (runCallHandshake — admission) cover both refusal statuses, that no answerSdp/callId comes back on a refusal, that a failing hangup still reports the refusal rather than throwing, and that 400/502 still degrade without a hangup.

request: RealtimeToolDispatchRequest,
model: string,
): ToolExecutionContext => ({
userId: request.userId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the page-agent identity in tool execution

For a voice call bound to a page agent whose ACL is narrower than the invoking user's, this context omits chatSource.agentPageId after the client has already discarded that target field. Consequently resolveActingAgentId() returns undefined and every centralized canActor* check falls back to the user's broader permissions, allowing the agent to read or modify resources its own memberships deny. Resolve the agent from the authorized conversation and populate the actor context before dispatching tools.

AGENTS.md reference: AGENTS.md:L119-L124

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in ba27f78 — this was the sharpest of the four, and they turned out to be one bug: nothing about the bound assistant was resolved server-side at all.

loadVoiceBinding (formerly seed-loader.ts, now binding-loader.ts) resolves the agent from the already-authorized conversation — a type: 'page' conversation's contextId IS its agent page — behind the same single access check the seed uses, and asserts the page is AI_CHAT so a document is never treated as an agent. It rides the attach payload as assistant, is echoed on each tool hop, and buildVoiceToolContext now sets chatSource: { type: 'page', agentPageId, agentTitle }, exactly as page-chat-turn.ts does for the same conversation.

Nothing the browser or the model says selects it — a request body naming its own agentPageId changes nothing, and there is a route test for that. Echoing it back on the bridge hop adds no trust assumption: that hop already carries userId under the same HMAC, so forging this would require the ability to already claim to be any user.

Mutation-checked: dropping the field turns the buildVoiceToolContext cases red.

// branches (the code-execution kill switch) that are the caller's
// decision. The realtime server cannot build these itself — the
// registry lives in this app — so they ride the signed internal hop.
tools: buildRealtimeTools(buildPageSpaceTools()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor the selected agent's tool allowlist

When voice is started on a page agent with a restricted enabledTools configuration, this advertises tools from the entire deployment registry instead of the agent-filtered set, while buildVoiceToolContext leaves enabledTools undefined and execute_tool interprets that as unrestricted. The model can therefore invoke write, delete, or other tools that the agent owner explicitly disabled; load the server-authoritative agent configuration and filter both advertised and executable tools with its allowlist.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in ba27f78, on both lists — filtering only the advertised one would not have been a filter.

buildRealtimeTools/buildRealtimeToolSet take the bound agent's allowlist and apply filterToolsForAgentAllowlist before splitToolsForExposure and before tool_search is handed its catalog — the same order page-chat-turn.ts uses. Filtering afterwards would leave a blocked tool discoverable through tool_search and callable through execute_tool, which is every tool the owner switched off.

The executable set is built in a different process on a different request (the bridge, per tool call), so voiceToolDispatchDeps(allowlist) takes it too — otherwise the model is offered the narrow list and permitted to run the wide one. enabledTools is also carried on the execution context, because execute_tool re-checks against it and reads undefined as unrestricted.

null (unconfigured) and [] (every tool off) are kept distinct end to end; there are tests for both, plus one asserting tool_search cannot describe a blocked tool. Mutation-checked.

Authorization: `Bearer ${deps.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(buildSessionConfig({ model: deps.model })),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Load the selected assistant's instructions

Every voice session is minted with only the model, so neither the selected page agent's systemPrompt nor the Global Assistant's PageSpace instructions ever reach OpenAI. The UI and changelog present the call as talking to the assistant currently in view, but the resulting call actually uses the realtime model's generic defaults regardless of which agent was selected; resolve and authorize the bound assistant server-side and include its instructions in the session config.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in ba27f78.

The mint carries no instructions by design (same argument as the tools: a call that never gets a server attached should not advertise capability to a model with nobody listening), so they now ride the same session.update the tools do, from apps/realtime.

buildVoiceInstructions (instructions.ts) composes two parts: spoken-turn guidance that applies to every call — a listener cannot skim, markdown read aloud is noise, expect to be interrupted — and then the bound assistant: a page agent's own systemPrompt verbatim, or its name plus the PageSpace persona when its owner configured none.

Deliberately not copied in: the text pipeline's page tree, agent memory, skill catalog and tool-discovery prompt. Most of it is turn-volatile and a realtime session's instructions are set once at attach, so a copy would be stale for the rest of the call; the tool guidance is written for a surface where a silent tool loop is acceptable. The module header states this. Where the caller is standing still reaches the tools live, via locationContext.

instructions is omitted rather than sent empty when there is none — an empty instructions would replace the session's. There is a test for that.

Comment on lines +234 to +236
if (reason !== 'call_ended') {
await hangUp(callId, secret);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Hang up after losing the attached socket

When the realtime server's attached WebSocket closes because of a network error, process shutdown, or its one-hour backstop, onClosed reports call_ended and this condition skips the hangup request. Closing that attached socket does not end the browser's independent WebRTC call, so the user can continue talking while metering and transcript supervision have already stopped. Only skip hangup for an event that proves the underlying call itself ended; unexpected attached-socket closure must explicitly hang up the browser call.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in e664a3b — you are right that call_ended does not prove the call ended.

I went further than "only skip for an event that proves it", because no close code actually proves it: onClosed reports call_ended for a network drop, a process shutdown and the one-hour socket backstop alike. So the hangup now runs on every reason.

The reason it was conditional was log noise on the common path — the user hangs up in the browser, OpenAI tears the call down, and our teardown asks anyway. That is handled where it belongs: hangUpCall now reads 404 as success ("already ended") and returns true, instead of warning. Asking twice is free; asking zero times bills nobody for a live call.

The old test asserting no hangup on call_ended is inverted, and call-hangup.ts — which had no test file at all — now has one covering the 404, genuine-refusal, transport-failure and non-Error paths.

* the only lever this process has: once a call is attached, its token spend is
* the model's to decide.
*/
export const DEFAULT_MAX_CONCURRENT_CALLS = 8;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply REALTIME_MAX_GLOBAL_SESSIONS

The new REALTIME_MAX_GLOBAL_SESSIONS setting is parsed and documented as the deployment-wide tuning knob, but the production registry still hardcodes its default to 8 and never imports that setting. Any operator changing the environment variable therefore sees no change to admission capacity, which can either continue overloading a lower-limit OpenAI account or unnecessarily cap a higher-limit one.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in e664a3b. DEFAULT_MAX_CONCURRENT_CALLS is now REALTIME_MAX_GLOBAL_SESSIONS rather than a literal 8, so the documented knob is the one that decides admission.

There is a test asserting the two are the same value, specifically so a future literal cannot quietly outrank the env var again.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (12)
apps/web/src/lib/ai/realtime/__tests__/session.test.ts-54-58 (1)

54-58: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The model-override cases cannot fail.

DEFAULT_REALTIME_MODEL is 'gpt-realtime-2.1' (Line 32). Every override case passes that same value:

  • Line 55: buildSessionConfig({ model: 'gpt-realtime-2.1' })
  • Line 97: resolveRealtimeModel({ [REALTIME_MODEL_ENV_VAR]: 'gpt-realtime-2.1' })
  • Line 103: the trimmed variant
  • Line 124: the round-trip variant

If buildSessionConfig dropped the model option, or resolveRealtimeModel ignored the environment variable, all four cases would still pass. The voice case at Line 61 uses 'cedar' against a default of 'marin' and does discriminate. Use a distinct sentinel model string here for the same reason.

💚 Proposed fix
   it('given a model override, should use it instead of the default', () => {
-    expect(buildSessionConfig({ model: 'gpt-realtime-2.1' }).session.model).toBe(
-      'gpt-realtime-2.1',
-    );
+    expect(buildSessionConfig({ model: 'gpt-realtime-next' }).session.model).toBe(
+      'gpt-realtime-next',
+    );
   });
   it('given an override, should use it — the env var is the path to a newer model', () => {
     expect(
-      resolveRealtimeModel({ [REALTIME_MODEL_ENV_VAR]: 'gpt-realtime-2.1' }),
-    ).toBe('gpt-realtime-2.1');
+      resolveRealtimeModel({ [REALTIME_MODEL_ENV_VAR]: 'gpt-realtime-next' }),
+    ).toBe('gpt-realtime-next');
   });

   it('given an override with surrounding whitespace, should trim it', () => {
     expect(
-      resolveRealtimeModel({ [REALTIME_MODEL_ENV_VAR]: '  gpt-realtime-2.1  ' }),
-    ).toBe('gpt-realtime-2.1');
+      resolveRealtimeModel({ [REALTIME_MODEL_ENV_VAR]: '  gpt-realtime-next  ' }),
+    ).toBe('gpt-realtime-next');
   });
   it('given a resolved model, should be usable as the session model', () => {
     const model = resolveRealtimeModel({
-      [REALTIME_MODEL_ENV_VAR]: 'gpt-realtime-2.1',
+      [REALTIME_MODEL_ENV_VAR]: 'gpt-realtime-next',
     });
-    expect(buildSessionConfig({ model }).session.model).toBe('gpt-realtime-2.1');
+    expect(buildSessionConfig({ model }).session.model).toBe('gpt-realtime-next');
   });

Also applies to: 95-127

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/__tests__/session.test.ts` around lines 54 - 58,
Update the model-override tests around buildSessionConfig and
resolveRealtimeModel to use a sentinel model value different from
DEFAULT_REALTIME_MODEL, including the trimmed and round-trip
environment-variable cases. Preserve the existing assertions while ensuring each
test would fail if the override were ignored.
apps/web/src/lib/voice/mic-errors.ts-72-75 (1)

72-75: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Scope the desktop message to macOS, or make it OS-neutral.

isDesktopShell() only reports that the app runs in the Electron shell. It does not report the operating system. On Windows and Linux the path "System Settings > Privacy & Security > Microphone" does not exist, so the user is sent to a control that is not there.

Either read the platform before you pick the sentence, or use wording that fits every desktop OS.

🔤 Proposed OS-neutral wording
     case 'denied':
       return isDesktop
-        ? 'Microphone access was blocked. Allow PageSpace in System Settings > Privacy & Security > Microphone, then try again.'
+        ? 'Microphone access was blocked. Allow PageSpace to use the microphone in your operating system privacy settings, then try again.'
         : 'Microphone access was blocked. Please allow microphone permissions in your browser settings and try again.';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/voice/mic-errors.ts` around lines 72 - 75, Update the denied
microphone message selection in the mic error mapping so Electron desktop users
are not directed to macOS-specific settings on Windows or Linux. Use an
OS-neutral desktop message, or additionally check the runtime platform before
selecting the macOS-specific wording; preserve the browser message for
non-desktop users.
apps/web/src/lib/ai/realtime/__tests__/seed.test.ts-265-285 (1)

265-285: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the truncation marker.

truncateToTokens retains the message head with text.slice(0, maxChars), so (earlier part of this message omitted) is incorrect. Change the marker to describe the omitted later part. Replace the exact-string assertion with a prefix assertion plus the marker.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/__tests__/seed.test.ts` around lines 265 - 285,
The truncation tests use an incorrect marker describing omitted earlier content.
Update the expected marker in the `truncateToTokens`-related assertions to
indicate that the later part of the message was omitted, and replace the
exact-string assertion in the word-boundary test with a prefix assertion plus a
check for the corrected marker.
apps/web/src/app/api/internal/voice/bridge/route.ts-42-46 (1)

42-46: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Check the declared length before buffering the body.

await request.text() buffers the whole body first. The cap runs after that, so an unauthenticated caller can still force the process to buffer an arbitrary payload. Also, rawBody.length counts UTF-16 code units, not bytes, so a multibyte payload passes the check above 256 KiB.

Read content-length first and reject early, then keep the parsed-size check as a backstop.

🛡️ Proposed fix
 export async function POST(request: Request) {
   try {
+    const declared = Number(request.headers.get('content-length') ?? '');
+    if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
+      return NextResponse.json({ ok: false, error: 'Payload too large' }, { status: 413 });
+    }
     const rawBody = await request.text();
 
-    if (rawBody.length > MAX_BODY_BYTES) {
+    if (Buffer.byteLength(rawBody, 'utf8') > MAX_BODY_BYTES) {
       return NextResponse.json({ ok: false, error: 'Payload too large' }, { status: 413 });
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/app/api/internal/voice/bridge/route.ts` around lines 42 - 46,
Update the request-body handling in the bridge route to inspect the
Content-Length header and return the existing 413 response before calling
request.text() when the declared byte length exceeds MAX_BODY_BYTES. Retain a
post-read size check as a backstop, measuring the buffered body in bytes rather
than UTF-16 code units.
apps/web/src/lib/ai/realtime/connect.ts-214-242 (1)

214-242: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

createMediaStream can throw on the reuse path.

Line 230 calls createMediaStream(clones) outside any try. If the constructor throws, the already-cloned tracks are never stopped and connectVoiceCall rejects instead of returning a VoiceConnectFailed. Every other failure in this module returns a structured result.

🛡️ Proposed fix
-    return { ok: true, stream: createMediaStream(clones) };
+    try {
+      return { ok: true, stream: createMediaStream(clones) };
+    } catch (error) {
+      for (const clone of clones) clone.stop();
+      return failure('mic-unknown', describeThrown(error), getMicPermissionErrorMessage(error));
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/connect.ts` around lines 214 - 242, Update
acquireMicrophone to cover the reused-microphone branch, including
createMediaStream(clones), with the same structured failure handling used for
getUserMedia. If stream creation throws, stop all cloned tracks and return a
classified mic failure via failure rather than allowing the exception to escape;
preserve the existing no-audio-track result.
apps/web/src/contexts/VoiceSessionContext.tsx-149-204 (1)

149-204: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Register the voice session with useEditingStore.

VoiceSessionProvider owns live transcript streaming, and the realtime runtime persists transcript rows. Use startStreaming during connecting and endStreaming on connection failure, connection loss, rebind, stop, and unmount. This defers auth refresh; ai-streaming does not pause SWR by design.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/contexts/VoiceSessionContext.tsx` around lines 149 - 204,
Register the session lifecycle with useEditingStore in VoiceSessionProvider:
obtain startStreaming and endStreaming, call startStreaming when a call enters
connecting, and call endStreaming on connection failure, connection loss,
rebind, stop, and provider unmount. Ensure each streaming session is ended
exactly once, including superseded or failed attempts, while preserving the
existing call lifecycle.

Source: Coding guidelines

apps/web/src/lib/ai/realtime/tools.ts-49-53 (1)

49-53: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Handle non-Zod AI SDK schemas in the realtime path. Tool['inputSchema'] also accepts jsonSchema() schemas, but these casts assume z.ZodType. A non-Zod tool can make session construction throw, or make dispatch call a missing safeParse before its try block. Narrow the realtime tool contract to Zod schemas or use the AI SDK validation contract. Apply the same change to createToolSearchTool and createExecuteTool.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/tools.ts` around lines 49 - 53, The realtime
tool contract currently assumes every Tool['inputSchema'] is a Zod schema,
causing invalid JSON-schema tools to fail during parameter conversion or
dispatch validation. Update toRealtimeParameters and the realtime tool factories
createToolSearchTool and createExecuteTool to require Zod-compatible schemas or
consistently use the AI SDK validation contract; also update the dispatch path
around tool-dispatch.ts lines 266-269 so validation does not call a missing
safeParse before error handling.
apps/web/src/components/layout/right-sidebar/index.tsx-67-68 (1)

67-68: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add setLocalActiveTab to both hook dependency arrays.

Both hooks capture setLocalActiveTab, but neither dependency array includes it. Add it to satisfy the globally enabled react-hooks/exhaustive-deps rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/layout/right-sidebar/index.tsx` around lines 67 - 68,
Add setLocalActiveTab to the dependency arrays of both hooks in the
right-sidebar component, alongside their existing dependencies, while leaving
the hook behavior unchanged.
apps/web/src/components/ai/shared/chat/MessageRenderer.tsx-275-275 (1)

275-275: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The microphone glyph repeats on multi-block messages. Both renderers gate createdAt and editedAt on isLastTextBlock, but pass spoken to every text block. A message that groups into more than one text block then renders SpokenTurnGlyph once per block, while the timestamp renders once.

  • apps/web/src/components/ai/shared/chat/MessageRenderer.tsx#L275-L275: change to spoken={isLastTextBlock && isSpokenTurn(message)}.
  • apps/web/src/components/ai/shared/chat/CompactMessageRenderer.tsx#L270-L270: apply the same gate on isLastTextBlock.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/ai/shared/chat/MessageRenderer.tsx` at line 275, The
spoken prop is passed to every text block, causing duplicate microphone glyphs.
In apps/web/src/components/ai/shared/chat/MessageRenderer.tsx:275 and
apps/web/src/components/ai/shared/chat/CompactMessageRenderer.tsx:270, gate
isSpokenTurn(message) with isLastTextBlock so the glyph renders only on the
final text block.
apps/web/src/lib/ai/realtime/seed.ts-127-133 (1)

127-133: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the truncation notice: the code drops the tail, not the head.

truncateToTokens keeps text.slice(0, maxChars), so the removed text is the LATER part of the message. The appended sentence states the earlier part was omitted. The model reads this notice as context, so an inverted statement misdescribes what is missing.

🐛 Proposed fix for the truncation notice
 const truncateToTokens = (text: string, maxTokens: number): string => {
   const maxChars = maxTokens * SEED_CHARS_PER_TOKEN;
   const window = text.slice(0, maxChars);
   const lastSpace = window.lastIndexOf(' ');
   const head = (lastSpace > 0 ? window.slice(0, lastSpace) : window).trimEnd();
-  return `${head}… (earlier part of this message omitted)`;
+  return `${head}… (rest of this message omitted)`;
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/seed.ts` around lines 127 - 133, Update the
truncation notice returned by truncateToTokens to state that the later part of
the message was omitted, matching the existing text.slice(0, maxChars) behavior;
leave the truncation logic unchanged.
apps/web/src/lib/ai/realtime/chain-schedule.ts-22-27 (1)

22-27: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

CHAIN_LEAD_MS is smaller than the worst-case handshake budget.

The comment states the lead covers a whole handshake whose upstream timeout is 15s. The handshake makes two sequential upstream fetches, each with OPENAI_TIMEOUT_MS = 15_000 (apps/web/src/lib/ai/realtime/call-handshake.ts, the mint at line 263 and the SDP relay at line 294), plus the handoff with HANDOFF_TIMEOUT_MS = 5_000 at line 223. The worst case is therefore about 35s, not 20s.

If both upstream hops stall near their ceilings, the replacement call is not ready before the server hangs up the current call, and the user is cut off mid-sentence — the exact outcome this module exists to prevent.

Raise the lead above the sum of the three timeouts, or start the chain from a budget derived from those constants.

🐛 Proposed fix for the chain lead time
 /**
- * Headroom before the cap. It covers a whole handshake — mint, relay to OpenAI,
- * the internal attach — whose own upstream timeout is 15s, plus the moment of
- * swapping which stream feeds the speaker.
+ * Headroom before the cap. It covers a whole handshake in the WORST case: two
+ * sequential upstream fetches at 15s each (mint, then the SDP relay) plus the
+ * 5s internal attach, and then the moment of swapping which stream feeds the
+ * speaker. A lead shorter than that sum lets a slow handshake land after the
+ * server has already hung the call up.
  */
-export const CHAIN_LEAD_MS = 20_000;
+export const CHAIN_LEAD_MS = 40_000;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/chain-schedule.ts` around lines 22 - 27, Update
CHAIN_LEAD_MS to exceed the combined worst-case budget of the two sequential
OPENAI_TIMEOUT_MS operations and HANDOFF_TIMEOUT_MS, totaling approximately 35
seconds. Prefer deriving the value from those timeout constants if they are
safely available; otherwise set a fixed lead with sufficient margin so
replacement setup completes before the current call is terminated.
apps/realtime/src/voice/call-metering.ts-196-207 (1)

196-207: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

duration reports total call time on every settle, not the window.

Line 202 computes nowFn() - startedAt for each track() call. A five-minute call at a one-minute settle interval emits five usage rows with durations of 60s, 120s, 180s, 240s and 300s. Any aggregation that sums duration over these rows reports 900s for a 300s call.

Every other field in the payload is per-window: inputTokens, outputTokens and providerCostDollars all come from usage, which is reset on Line 177.

Track the last settle time and report the window.

🐛 Proposed fix
   const startedAt = nowFn();
   let lastActivityAt = startedAt;
+  let windowStartedAt = startedAt;
+      const settledAt = nowFn();
       try {
         await track({
           userId,
           provider: 'openai_voice',
           model,
           source: 'voice',
           providerCostDollars: costDollars,
-          duration: nowFn() - startedAt,
+          duration: settledAt - windowStartedAt,
           success: true,

Set windowStartedAt = settledAt; after the try/catch completes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/realtime/src/voice/call-metering.ts` around lines 196 - 207, Update the
settle tracking around the usage reset and track call to maintain a window start
timestamp, and calculate duration from the current settle time minus that
timestamp instead of from startedAt. After each try/catch completes, assign
windowStartedAt to settledAt so subsequent rows report only the elapsed interval
while preserving the existing first-window and final-settlement behavior.
🧹 Nitpick comments (20)
apps/realtime/src/voice/__tests__/call-metering.test.ts (1)

235-245: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The interval test does not prove the interval settles.

The test fires the settle interval and then awaits started.meter.settle() explicitly. If the interval callback did nothing, the explicit settle() alone would still produce exactly one track call. The assertion passes in both cases.

Remove the explicit settle() and let the un-awaited interval work drain instead.

♻️ Proposed change to make the assertion meaningful
     started.meter.record(usage());
     h.fireSettle();
-    await started.meter.settle();
+    // Let the interval's un-awaited settle resolve.
+    await new Promise<void>((resolve) => setTimeout(resolve, 0));
 
     expect(h.track).toHaveBeenCalledTimes(1);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/realtime/src/voice/__tests__/call-metering.test.ts` around lines 235 -
245, Update the interval test around startCallMeter and h.fireSettle to remove
the explicit started.meter.settle() call; after firing the interval, await the
existing asynchronous drain mechanism so the interval callback performs
settlement before asserting h.track was called once.
apps/web/src/lib/ai/realtime/__tests__/tool-dispatch.test.ts (1)

97-100: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The title states one outcome, the body asserts two.

The title says the fragment should be refused. Line 98 asserts ok === true for 'oops {"a":1} but also {', which is acceptance, not refusal. Only Line 99 asserts refusal. Split the case in two, or rename it to cover both recovery and refusal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/__tests__/tool-dispatch.test.ts` around lines 97
- 100, Update the test description around parseToolArguments to accurately
reflect both asserted outcomes: recovery and acceptance for the embedded object
fragment, and refusal for the unrecoverable array fragment. Either split these
assertions into separate tests or rename the existing test to explicitly cover
both behaviors.
apps/web/src/lib/ai/realtime/__tests__/connect.test.ts (2)

63-65: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Restore the console.error spy after each test.

This file installs a console.error spy in beforeEach but never restores it. The sibling file apps/web/src/contexts/__tests__/VoiceSessionContext.test.tsx restores mocks in afterEach. Match that pattern so the spy cannot outlive the file.

♻️ Proposed change
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 beforeEach(() => {
   vi.spyOn(console, 'error').mockImplementation(() => {});
 });
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/__tests__/connect.test.ts` around lines 63 - 65,
Update the test setup around the existing beforeEach in connect.test.ts to add
an afterEach hook that restores the console.error spy, matching the cleanup
pattern used by VoiceSessionContext tests.

427-438: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The test title does not match the input.

The title states "given no cap reported". The body sends maxDurationMs: 'soon', which is a cap of the wrong type, not an absent cap. Rename the case to describe an invalid cap. Add a separate case that omits maxDurationMs entirely, because the chaining logic in VoiceSessionContext branches on that value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/__tests__/connect.test.ts` around lines 427 -
438, Rename the existing connectVoiceCall test to describe an invalid
maxDurationMs value, preserving its assertion that the result leaves the cap
undefined. Add a separate test with maxDurationMs omitted from the response
body, covering the no-cap path used by VoiceSessionContext and asserting its
expected undefined-cap behavior.
apps/web/src/lib/ai/realtime/__tests__/voice-bridge-contract-drift.test.ts (1)

28-40: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Build the registry with code execution enabled, so the guard covers every tool.

Lines 29 and 39 call buildPageSpaceTools() with no options. apps/web/src/lib/ai/realtime/__tests__/tools.test.ts calls buildPageSpaceTools({ codeExecutionEnabled: true }). If the flag adds tools to the registry, those tools are never checked against realtimeToolSchema here, and a drifting schema on a code-execution tool ships unnoticed.

The stated purpose of this file is to run against the real registry. Use the widest registry it can produce.

♻️ Proposed change
   it('given the real registry, every emitted tool should satisfy the shared schema', () => {
-    const tools = buildRealtimeTools(buildPageSpaceTools());
+    const tools = buildRealtimeTools(buildPageSpaceTools({ codeExecutionEnabled: true }));
     expect(tools.length).toBeGreaterThan(0);
   it('should carry the whole registry-built tool set through the attach payload intact', () => {
-    const tools = buildRealtimeTools(buildPageSpaceTools());
+    const tools = buildRealtimeTools(buildPageSpaceTools({ codeExecutionEnabled: true }));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/__tests__/voice-bridge-contract-drift.test.ts`
around lines 28 - 40, Update both buildPageSpaceTools calls in the registry
contract tests to enable code execution, matching the configuration used by
tools.test.ts. Keep the existing schema-validation and attach-payload assertions
unchanged so they cover the widest registry, including code-execution tools.
apps/web/src/contexts/__tests__/VoiceSessionContext.test.tsx (1)

210-236: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unmount the second render explicitly, or capture state before the next mount.

This test mounts two providers. probe is module state, so the last mounted StateProbe wins. The test relies on that ordering. The assertion at Line 232 reads the second provider only because the first one was unmounted at Line 224.

The behavior is correct today. It breaks silently if a future edit reorders the renders. Consider splitting the case into two tests, or capture probe.state.status next to each message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/contexts/__tests__/VoiceSessionContext.test.tsx` around lines
210 - 236, Make the test’s provider state assertions independent of render
ordering by capturing the first scenario’s status and error before unmounting,
then capturing the second scenario’s status and error after its call. Update the
assertions to use those captured values, or split the denied and missing
scenarios into separate tests; keep the distinct-message assertion intact.
apps/web/src/lib/ai/realtime/__tests__/tools.test.ts (2)

49-57: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This assertion pins a registry defect and fails when the defect is fixed.

CORE_TOOL_NAMES lists get_page_details, but no tool module defines it, so buildRealtimeTools can never emit it. The assertion requires the gap to stay exactly ['get_page_details']. If someone implements the tool, or removes the stale name from stub-tools.ts, this test goes red for a correct change.

Assert that the gap is a subset of a documented allowlist instead, so a fix passes and a new unimplemented name still fails.

Do you want me to open an issue to track the missing get_page_details implementation?

♻️ Proposed change
-    expect([...CORE_TOOL_NAMES].filter((n) => !registryCoreNames.includes(n))).toEqual([
-      'get_page_details',
-    ]);
+    // Names listed in CORE_TOOL_NAMES with no tool module behind them.
+    const KNOWN_UNIMPLEMENTED_CORE_NAMES = new Set(['get_page_details']);
+    for (const name of CORE_TOOL_NAMES) {
+      if (registryCoreNames.includes(name)) continue;
+      expect(
+        KNOWN_UNIMPLEMENTED_CORE_NAMES.has(name),
+        `"${name}" is a core tool name with no tool behind it`,
+      ).toBe(true);
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/__tests__/tools.test.ts` around lines 49 - 57,
Update the assertion in the realtime tools test around CORE_TOOL_NAMES and
registryCoreNames to verify that the missing-name gap is contained within a
documented allowlist, rather than equaling exactly ['get_page_details'].
Preserve the failure for any unimplemented name outside that allowlist, while
allowing get_page_details to pass whether it is implemented or removed from the
stub names.

93-99: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Do not match Zod’s internal error text.

Zod documents that unrepresentable schemas throw by default, but it does not document the exact Date cannot be represented in JSON Schema message. Use .toThrow() alone, or assert a repository-owned wrapper message that includes read_page.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/__tests__/tools.test.ts` around lines 93 - 99,
Update the toRealtimeTool test for the unrepresentable z.date() schema to avoid
matching Zod’s internal error text; assert only that the call throws, or assert
a repository-owned error message that includes the read_page tool name.
apps/web/src/lib/ai/realtime/__tests__/transcript-persistence.test.ts (1)

118-144: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the voice source on the page path too.

Line 72 asserts source: MESSAGE_SOURCE_VOICE for the global path. The page path has no equivalent assertion, so a regression that drops the marker on page conversations would pass.

♻️ Proposed addition
     expect(savePageMessage).toHaveBeenCalledWith(
-      expect.objectContaining({ pageId: 'page-1', role: 'user', userId: 'u1', sourceAgentId: null }),
+      expect.objectContaining({
+        pageId: 'page-1',
+        role: 'user',
+        userId: 'u1',
+        sourceAgentId: null,
+        source: MESSAGE_SOURCE_VOICE,
+      }),
     );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/__tests__/transcript-persistence.test.ts` around
lines 118 - 144, Extend the page-thread assertion in persistVoiceTranscript to
verify the saved message includes source: MESSAGE_SOURCE_VOICE, alongside the
existing pageId, role, userId, and sourceAgentId checks. Keep the assertion
focused on the savePageMessage payload.
apps/web/src/contexts/VoiceSessionContext.tsx (1)

198-203: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Ref writes during render are unsafe under concurrent rendering.

Lines 199 and 359 assign to refs in the render body. React can discard a render, and the mutation still happens. Move both assignments into a layout effect, or read deps and beginAttempt through a small useEffectEvent-style wrapper.

This is a hardening change, not a current defect: both values are overwritten on every render, so a discarded render is corrected by the committed one.

Also applies to: 359-359

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/contexts/VoiceSessionContext.tsx` around lines 198 - 203, Move
the render-time assignments to depsRef.current and beginAttemptRef.current in
VoiceSessionContext into a layout effect, or access them through a
useEffectEvent-style wrapper. Ensure both refs update only after the render
commits while preserving their latest committed deps and beginAttempt values.
apps/web/src/components/ai/voice/realtime/VoiceNavTrigger.tsx (1)

141-141: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the pressRef assignment into an effect.

pressRef.current is written during render. Assign it from useEffect instead.

♻️ Proposed refactor
-  pressRef.current = () => void handlePress();
+  useEffect(() => {
+    pressRef.current = () => void handlePress();
+  }, [handlePress]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/ai/voice/realtime/VoiceNavTrigger.tsx` at line 141,
Move the pressRef.current assignment out of render and into a useEffect in the
VoiceNavTrigger component, keeping it synchronized with handlePress and
preserving the existing invocation behavior.
apps/web/src/lib/ai/realtime/__tests__/bridge-handler.test.ts (1)

115-133: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the nested experimental_context values.

dispatchRealtimeToolCall passes the context in execute's second argument under experimental_context. Assert conversationId, timezone, and locationContext there instead of checking only that the tool ran.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/__tests__/bridge-handler.test.ts` around lines
115 - 133, Update the “should forward the call context to the dispatcher” test
around handleVoiceBridgeRequest and execute to inspect execute’s second argument
under experimental_context. Assert that conversationId, timezone, and
locationContext match the values supplied by toolBody, replacing the broad
toHaveBeenCalled assertion while preserving the successful response check.
apps/web/src/components/ai/shared/chat/message-types.ts (1)

26-38: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Narrow ConversationMessage.source and fix the symbol reference.

Current message writes use only 'voice' as a non-null source; other writes persist null. Define MessageSource = typeof VOICE_MESSAGE_SOURCE, use source?: MessageSource | null, and reference VOICE_MESSAGE_SOURCE from @/lib/ai/realtime/message-source. Update the voice-test fixture parameter to the narrowed type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/ai/shared/chat/message-types.ts` around lines 26 -
38, Update ConversationMessage.source to use a MessageSource alias defined as
typeof VOICE_MESSAGE_SOURCE, importing VOICE_MESSAGE_SOURCE from
`@/lib/ai/realtime/message-source`, and replace the outdated MESSAGE_SOURCE_VOICE
reference. Also narrow the voice-test fixture parameter to MessageSource while
preserving nullable and optional source behavior.
apps/realtime/src/voice/voice-bridge-client.ts (1)

96-97: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Validate the bridge response before returning it as VoiceBridgeResponse.

Line 96 casts the parsed JSON directly to VoiceBridgeResponse. packages/lib/src/realtime/voice-bridge-contract.ts declares VoiceBridgeResponse as a TypeScript type only, with no matching zod schema, so no runtime check happens.

A 200 response with an unexpected body then flows out typed as a success result. On the tool path, a caller that reads .output receives undefined and can hand it to function_call_output.output. The same contract file states that a tool result must always be a string.

The request direction is already validated by voiceBridgeRequestSchema. Consider adding the matching response schema in the contract file and parsing here, so both directions of the hop are checked at the boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/realtime/src/voice/voice-bridge-client.ts` around lines 96 - 97, Add a
runtime zod schema for VoiceBridgeResponse in voice-bridge-contract.ts,
including the required string output field, and use that schema to parse the
JSON response in the voice bridge client instead of directly casting it.
Preserve the existing successful return flow while rejecting malformed 200
responses before callers consume them.
packages/lib/src/monitoring/voice-pricing.ts (1)

303-328: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard the rate lookup against inherited Object.prototype keys.

Line 307 indexes REALTIME_RATES with an arbitrary model string. REALTIME_RATES is a plain object literal, so it inherits from Object.prototype. A model value of 'toString', 'valueOf', 'constructor', or 'hasOwnProperty' resolves to an inherited member instead of undefined. The !rates check at Line 308 then passes, and Line 323 dereferences rates.cachedInput.text on a value that has no cachedInput, which throws a TypeError.

This breaks the contract stated in the doc comment: "Returns 0 for an unknown model". It also matters downstream. In apps/realtime/src/voice/call-metering.ts, calculateRealtimeCostDollars(model, usage) runs inside flush() but outside the try block that wraps track(...). A throw there rejects the chained settle promise, and the interval calls it as void enqueueFlush(), so the rejection is unhandled.

model is env-derived today, so this is hardening rather than an active exploit. The fix is small.

🛡️ Proposed own-property lookup
-  const rates: RealtimeModelRates | undefined = REALTIME_RATES[model as RealtimeModel];
-  if (!rates || !usage) return 0;
+  const rates: RealtimeModelRates | undefined = Object.hasOwn(REALTIME_RATES, model)
+    ? REALTIME_RATES[model as RealtimeModel]
+    : undefined;
+  if (!rates || !usage) return 0;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/lib/src/monitoring/voice-pricing.ts` around lines 303 - 328, Guard
the REALTIME_RATES lookup in calculateRealtimeCostDollars against inherited
Object.prototype properties by accepting only own keys of REALTIME_RATES.
Preserve the existing 0 return for unknown models, including values such as
“toString”, before accessing rate fields.
packages/lib/src/billing/credit-pricing.ts (1)

121-133: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Enforce the CREDIT_HOLD_TTL_SECONDS invariant instead of documenting it.

The doc comment states that REALTIME_MAX_SESSION_SECONDS must stay below CREDIT_HOLD_TTL_SECONDS, and that raising one requires raising the other. Both values are independently overridable through env. If an operator sets REALTIME_MAX_SESSION_SECONDS=1200 and leaves the TTL at its 900s default, the reconcile cron can reclaim a live call's own hold mid-call. The test in packages/lib/src/billing/__tests__/credit-pricing.test.ts asserts this invariant only for the default values, so an override is not covered.

Consider clamping the value so the invariant holds for any env combination.

Note the ordering constraint: CREDIT_HOLD_TTL_SECONDS is declared at Line 316, after this constant. A clamp that reads it here would hit the temporal dead zone. Either move CREDIT_HOLD_TTL_SECONDS above this declaration, or derive the clamped value after Line 316.

♻️ Sketch of a clamped derivation
-export const REALTIME_MAX_SESSION_SECONDS = envInt('REALTIME_MAX_SESSION_SECONDS', 600);
+const REALTIME_MAX_SESSION_SECONDS_RAW = envInt('REALTIME_MAX_SESSION_SECONDS', 600);

Then, after CREDIT_HOLD_TTL_SECONDS is declared:

/** Clamped so a session can never outlive the hold that funds it. */
export const REALTIME_MAX_SESSION_SECONDS = Math.min(
  REALTIME_MAX_SESSION_SECONDS_RAW,
  Math.max(1, CREDIT_HOLD_TTL_SECONDS - 300),
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/lib/src/billing/credit-pricing.ts` around lines 121 - 133, Enforce
the hold-TTL invariant for all environment overrides by separating the raw value
from the exported value: rename the current envInt result near
REALTIME_MAX_SESSION_SECONDS to a raw constant, then derive the exported
REALTIME_MAX_SESSION_SECONDS after CREDIT_HOLD_TTL_SECONDS is declared, clamping
it to at most CREDIT_HOLD_TTL_SECONDS minus the 300-second settle margin and
ensuring the result is at least 1. Update the surrounding comment to describe
the enforced clamp.
apps/realtime/src/voice/usage-accumulator.ts (1)

82-89: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Compute addCachedDetails once.

The call is duplicated: once in the presence test and once for the value. Both calls allocate. A later edit that changes one argument list and not the other makes the condition disagree with the emitted value, and this is a billing path.

♻️ Proposed refactor to compute the cached details once
   const inputA = a.input_token_details;
   const inputB = b.input_token_details;
   const outputA = a.output_token_details;
   const outputB = b.output_token_details;
+  const cachedDetails = addCachedDetails(
+    inputA?.cached_tokens_details,
+    inputB?.cached_tokens_details,
+  );
             cached_tokens: count(inputA?.cached_tokens) + count(inputB?.cached_tokens),
-            ...(addCachedDetails(inputA?.cached_tokens_details, inputB?.cached_tokens_details)
-              ? {
-                  cached_tokens_details: addCachedDetails(
-                    inputA?.cached_tokens_details,
-                    inputB?.cached_tokens_details,
-                  ),
-                }
-              : {}),
+            ...(cachedDetails === undefined ? {} : { cached_tokens_details: cachedDetails }),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/realtime/src/voice/usage-accumulator.ts` around lines 82 - 89, In the
usage accumulation logic, update the cached token details handling around
addCachedDetails to compute its result once, store it in a local variable, and
reuse that variable for both the presence check and cached_tokens_details value.
Preserve the current omission behavior when the computed result is absent.
apps/realtime/src/voice/call-metering.ts (1)

246-262: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

onLimit is invoked inside flush, and the callback re-enters the settle chain.

Line 252 calls onLimit while the current flush is still executing. The registered callback is runtime.stop, which calls meter.stop, which awaits enqueueFlush() on Line 308. That new link chains onto settling — the promise that is running this flush.

This works today only because attach-handler.ts Line 168 uses void runtime?.stop(reason) and Line 253 returns immediately. If any caller awaits onLimit, or if a future flush does work after onLimit, the chain waits on itself and the call hangs with its timers still armed.

Defer the callback so the chain cannot be re-entered.

♻️ Proposed hardening
-      onLimit('credit_exhausted', 'This call ran out of credits.');
+      // Deferred: `onLimit` leads back into `meter.stop`, which enqueues onto
+      // the same settle chain this flush occupies.
+      queueMicrotask(() => onLimit('credit_exhausted', 'This call ran out of credits.'));
       return;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/realtime/src/voice/call-metering.ts` around lines 246 - 262, Defer the
onLimit callback in the flush logic after detecting an exhausted credit window,
rather than invoking it synchronously while flush is running. Preserve the
existing credit-exhaustion reason and message, return from flush immediately,
and ensure the deferred callback cannot enqueue work onto the currently
executing settling promise.
apps/realtime/src/__tests__/realtime-attach-handler.test.ts (1)

339-355: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a concurrent variant of the per-user cap test.

This test drives the two attaches sequentially, so the first call is registered before the second checks getForUser. The deployment cap has a concurrent counterpart at Lines 193-219; the per-user cap does not.

A concurrent test would fail against the current handler, because the per-user check reads only registered calls and in-flight attaches for the same user are invisible to it. See the related comment on apps/realtime/src/voice/attach-handler.ts Lines 137-153.

🧪 Proposed test
+  it('given SIMULTANEOUS attaches from ONE user against a per-user cap of one, should admit exactly one', async () => {
+    const registry = new RealtimeCallRegistry(8);
+    let release: (() => void) | undefined;
+    const inFlight = new Promise<void>((resolve) => {
+      release = resolve;
+    });
+    const attach = vi.fn(async (options: AttachOptions) => {
+      await inFlight;
+      return fakeSession(options);
+    });
+    const d = deps({ registry, attach, maxCallsPerUser: 1 });
+
+    const both = Promise.all([
+      handleRealtimeAttachRequest(d, JSON.stringify(VALID)),
+      handleRealtimeAttachRequest(d, JSON.stringify({ ...VALID, callId: 'rtc_second' })),
+    ]);
+    release?.();
+    const [a, b] = await both;
+
+    expect([a.status, b.status].sort()).toEqual([200, 429]);
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/realtime/src/__tests__/realtime-attach-handler.test.ts` around lines 339
- 355, Add a concurrent per-user-cap test alongside the existing sequential
test, starting two attach requests for the same user before awaiting either and
asserting one succeeds, the other returns 429, and attach is invoked only once.
Use the existing concurrency test pattern and symbols such as
handleRealtimeAttachRequest, maxCallsPerUser, and attach.
apps/realtime/src/voice/attach-handler.ts (1)

158-170: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Constrain subscriptionTier at the contract boundary.

realtimeAttachPayloadSchema accepts arbitrary strings, and startCallMeter passes the value to canConsumeAI. Use z.enum(TIERS).default('free'), remove the cast, and update the test that uses invalid tier plus.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/realtime/src/voice/attach-handler.ts` around lines 158 - 170, Update
realtimeAttachPayloadSchema to validate subscriptionTier with
z.enum(TIERS).default('free') before it reaches startCallMeter/canConsumeAI.
Then remove the SubscriptionTier cast from the deps.startMeter call and update
the affected test fixture to use a valid tier instead of invalid `plus`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 26e2a3cc-c395-42cf-83e0-8773ec4f5a2a

📥 Commits

Reviewing files that changed from the base of the PR and between 5b96655 and 91767b1.

📒 Files selected for processing (133)
  • .env.example
  • CHANGELOG.md
  • apps/realtime/src/__tests__/index.test.ts
  • apps/realtime/src/__tests__/realtime-attach-handler.test.ts
  • apps/realtime/src/__tests__/realtime-call-registry.test.ts
  • apps/realtime/src/__tests__/realtime-call-session.test.ts
  • apps/realtime/src/index.ts
  • apps/realtime/src/voice/__tests__/call-metering.test.ts
  • apps/realtime/src/voice/__tests__/usage-accumulator.test.ts
  • apps/realtime/src/voice/__tests__/voice-bridge-client.test.ts
  • apps/realtime/src/voice/__tests__/voice-call-runtime.test.ts
  • apps/realtime/src/voice/attach-handler.ts
  • apps/realtime/src/voice/call-hangup.ts
  • apps/realtime/src/voice/call-metering.ts
  • apps/realtime/src/voice/realtime-call-registry.ts
  • apps/realtime/src/voice/realtime-call-session.ts
  • apps/realtime/src/voice/usage-accumulator.ts
  • apps/realtime/src/voice/voice-bridge-client.ts
  • apps/realtime/src/voice/voice-call-runtime.ts
  • apps/web/src/app/api/ai/chat/messages/[messageId]/__tests__/route.test.ts
  • apps/web/src/app/api/ai/chat/messages/__tests__/route.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/[messageId]/__tests__/route.test.ts
  • apps/web/src/app/api/internal/voice/bridge/route.ts
  • apps/web/src/app/api/v1/chat/completions/__tests__/route-backfill.test.ts
  • apps/web/src/app/api/v1/chat/completions/__tests__/route.test.ts
  • apps/web/src/app/api/v1/conversations/__tests__/route.test.ts
  • apps/web/src/app/api/voice/realtime/call/__tests__/route.test.ts
  • apps/web/src/app/api/voice/realtime/call/route.ts
  • apps/web/src/app/api/voice/transcribe/__tests__/route.test.ts
  • apps/web/src/app/api/voice/transcribe/route.ts
  • apps/web/src/components/ai/chat/input/ChatInput.tsx
  • apps/web/src/components/ai/shared/chat/CompactMessageRenderer.tsx
  • apps/web/src/components/ai/shared/chat/MessageRenderer.tsx
  • apps/web/src/components/ai/shared/chat/SpokenTurnGlyph.tsx
  • apps/web/src/components/ai/shared/chat/__tests__/SpokenTurnGlyph.test.tsx
  • apps/web/src/components/ai/shared/chat/message-types.ts
  • apps/web/src/components/ai/voice/VoiceCallPanel.tsx
  • apps/web/src/components/ai/voice/VoiceModeBorder.tsx
  • apps/web/src/components/ai/voice/VoiceModeSettings.tsx
  • apps/web/src/components/ai/voice/index.ts
  • apps/web/src/components/ai/voice/realtime/VoiceCallBar.tsx
  • apps/web/src/components/ai/voice/realtime/VoiceCallBarForConversation.tsx
  • apps/web/src/components/ai/voice/realtime/VoiceNavTrigger.tsx
  • apps/web/src/components/ai/voice/realtime/VoiceSessionBridge.tsx
  • apps/web/src/components/ai/voice/realtime/__tests__/VoiceCallBar.test.tsx
  • apps/web/src/components/ai/voice/realtime/__tests__/VoiceCallBarForConversation.test.tsx
  • apps/web/src/components/ai/voice/realtime/__tests__/VoiceNavTrigger.test.tsx
  • apps/web/src/components/ai/voice/realtime/__tests__/VoiceSessionBridge.test.tsx
  • apps/web/src/components/ai/voice/realtime/index.ts
  • apps/web/src/components/layout/Layout.tsx
  • apps/web/src/components/layout/__tests__/Layout.voice-reveal.test.tsx
  • apps/web/src/components/layout/__tests__/Layout.voice-session.test.tsx
  • apps/web/src/components/layout/main-header/__tests__/TopBar.voice-trigger.test.tsx
  • apps/web/src/components/layout/main-header/index.tsx
  • apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx
  • apps/web/src/components/layout/right-sidebar/__tests__/RightPanel.page-tab.test.tsx
  • apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx
  • apps/web/src/components/layout/right-sidebar/index.tsx
  • apps/web/src/components/ui/floating-input/InputFooter.tsx
  • apps/web/src/contexts/VoiceSessionContext.tsx
  • apps/web/src/contexts/__tests__/VoiceSessionContext.test.tsx
  • apps/web/src/hooks/useVoiceMode.ts
  • apps/web/src/hooks/voice/useAudioLevel.ts
  • apps/web/src/hooks/voice/useVoiceBinding.ts
  • apps/web/src/lib/ai/core/__tests__/message-utils.test.ts
  • apps/web/src/lib/ai/core/message-utils.ts
  • apps/web/src/lib/ai/realtime/__tests__/bridge-handler.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/call-chrome.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/call-handshake.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/chain-schedule.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/connect.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/seed-loader.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/seed.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/session-state.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/session.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/tool-dispatch.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/tools.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/transcript-persistence.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/voice-binding.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/voice-bridge-contract-drift.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/voice-location.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/voice-rebind.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/voice-reveal.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/voice-target.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/webrtc-fakes.ts
  • apps/web/src/lib/ai/realtime/bridge-handler.ts
  • apps/web/src/lib/ai/realtime/call-chrome.ts
  • apps/web/src/lib/ai/realtime/call-handshake.ts
  • apps/web/src/lib/ai/realtime/chain-schedule.ts
  • apps/web/src/lib/ai/realtime/connect.ts
  • apps/web/src/lib/ai/realtime/message-source.ts
  • apps/web/src/lib/ai/realtime/seed-loader.ts
  • apps/web/src/lib/ai/realtime/seed.ts
  • apps/web/src/lib/ai/realtime/session-state.ts
  • apps/web/src/lib/ai/realtime/session.ts
  • apps/web/src/lib/ai/realtime/tool-dispatch.ts
  • apps/web/src/lib/ai/realtime/tools.ts
  • apps/web/src/lib/ai/realtime/transcript-persistence.ts
  • apps/web/src/lib/ai/realtime/voice-binding.ts
  • apps/web/src/lib/ai/realtime/voice-location.ts
  • apps/web/src/lib/ai/realtime/voice-rebind.ts
  • apps/web/src/lib/ai/realtime/voice-reveal.ts
  • apps/web/src/lib/ai/realtime/voice-runtime-deps.ts
  • apps/web/src/lib/ai/realtime/voice-target.ts
  • apps/web/src/lib/ai/streams/__tests__/selectPostBaselineAssistantMessage.test.ts
  • apps/web/src/lib/ai/streams/__tests__/selectVoiceActivationBaseline.test.ts
  • apps/web/src/lib/ai/streams/__tests__/selectVoiceStreamText.test.ts
  • apps/web/src/lib/ai/streams/selectPostBaselineAssistantMessage.ts
  • apps/web/src/lib/ai/streams/selectVoiceActivationBaseline.ts
  • apps/web/src/lib/ai/streams/selectVoiceStreamText.ts
  • apps/web/src/lib/repositories/message-repository.ts
  • apps/web/src/lib/repositories/unified-message-leg.ts
  • apps/web/src/lib/voice/__tests__/mic-errors.test.ts
  • apps/web/src/lib/voice/mic-errors.ts
  • apps/web/src/stores/__tests__/useVoiceModeStore.test.ts
  • apps/web/src/stores/useLayoutStore.ts
  • apps/web/src/stores/useVoiceModeStore.ts
  • apps/web/src/stores/useVoiceRebindStore.ts
  • docs/2.0-architecture/agent-sessions.md
  • knip.json
  • packages/db/drizzle/0258_thin_dormammu.sql
  • packages/db/drizzle/meta/0258_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/conversations.ts
  • packages/lib/package.json
  • packages/lib/src/billing/__tests__/credit-pricing.test.ts
  • packages/lib/src/billing/credit-pricing.ts
  • packages/lib/src/monitoring/__tests__/voice-pricing.test.ts
  • packages/lib/src/monitoring/voice-pricing.ts
  • packages/lib/src/realtime/__tests__/voice-events.test.ts
  • packages/lib/src/realtime/voice-bridge-contract.ts
  • packages/lib/src/realtime/voice-events.ts
  • packages/lib/src/services/sandbox/tool-runners.ts
💤 Files with no reviewable changes (16)
  • apps/web/src/lib/ai/streams/tests/selectVoiceActivationBaseline.test.ts
  • apps/web/src/components/ai/voice/index.ts
  • apps/web/src/lib/ai/streams/selectVoiceStreamText.ts
  • apps/web/src/app/api/voice/transcribe/route.ts
  • apps/web/src/components/ai/voice/VoiceModeSettings.tsx
  • apps/web/src/lib/ai/streams/tests/selectVoiceStreamText.test.ts
  • apps/web/src/lib/ai/streams/selectVoiceActivationBaseline.ts
  • apps/web/src/app/api/voice/transcribe/tests/route.test.ts
  • apps/web/src/components/ai/voice/VoiceModeBorder.tsx
  • apps/web/src/stores/useVoiceModeStore.ts
  • apps/web/src/hooks/useVoiceMode.ts
  • apps/web/src/stores/tests/useVoiceModeStore.test.ts
  • apps/web/src/components/ai/voice/VoiceCallPanel.tsx
  • apps/web/src/components/ai/chat/input/ChatInput.tsx
  • apps/web/src/lib/ai/streams/tests/selectPostBaselineAssistantMessage.test.ts
  • apps/web/src/lib/ai/streams/selectPostBaselineAssistantMessage.ts

Comment thread apps/realtime/src/voice/__tests__/voice-bridge-client.test.ts Outdated
Comment thread apps/realtime/src/voice/attach-handler.ts Outdated
Comment thread apps/realtime/src/voice/voice-bridge-client.ts Outdated
Comment thread apps/realtime/src/voice/voice-call-runtime.ts
Comment thread apps/realtime/src/voice/voice-call-runtime.ts
Comment thread apps/web/src/components/ai/voice/realtime/VoiceNavTrigger.tsx
Comment thread apps/web/src/hooks/voice/useAudioLevel.ts
Comment thread apps/web/src/lib/ai/realtime/connect.ts Outdated
Comment thread apps/web/src/lib/ai/realtime/transcript-persistence.ts
Comment thread apps/web/src/lib/repositories/message-repository.ts
@2witstudios

Copy link
Copy Markdown
Owner Author

Review findings

The patch permits unmetered realtime calls, can strand legacy personalization outside the migration path, and currently fails the tenant-export column coverage test. Additional concurrency and corroboration defects can violate documented limits and memory accuracy.

P1 — Reject calls when the metering attachment fails

apps/web/src/lib/ai/realtime/call-handshake.ts:226-234

When the realtime server returns 402/429, is unreachable, or is unconfigured, this returns false but the route still gives the browser the already-created OpenAI call. Those calls have no credit hold, usage metering, duration enforcement, or concurrency supervision, so a paid user with exhausted credits can continue generating provider costs indefinitely. The call should be terminated or the handshake rejected whenever the metering plane cannot attach.

P1 — Preserve legacy personalization before provisioning pointers

apps/web/src/app/api/settings/personalization/route.ts:74-79

For a legacy user with populated text columns and no page pointers, opening this GET provisions all pointers without copying the legacy content. getUserPersonalization then treats the new empty pages as authoritative, while backfill-memory-pages.ts only selects rows with a missing pointer. That user is permanently skipped by the backfill and their existing personalization immediately disappears from prompts. Copy the legacy values transactionally or leave the row eligible for backfill.

P2 — Export the new message source column

scripts/lib/tenant-export-columns.ts:258-263

The new messages.source value is omitted from tenant exports, so spoken-turn attribution is silently lost after a tenant migration and imported transcripts are treated as typed messages. This also breaks the repository guard: bun test scripts/__tests__/tenant-export-columns.test.ts reports messages: source as an unaccounted schema column.

P2 — Avoid recounting older evidence on every discovery run

apps/web/src/lib/memory/candidate-service.ts:174-186

When discovery re-cites evidence older than lastSeenAt, shouldIncrementOccurrences returns true because the UTC days differ, but lastSeenAt remains unchanged. The same older message can therefore increment the count again on every nightly reread, eventually promoting a one-off claim as corroborated. This is especially reachable through the documented out-of-range-index fallback to the oldest message. Only a genuinely newer evidence day should advance this single-watermark counter.

P2 — Honor the configured global realtime session limit

apps/realtime/src/voice/realtime-call-registry.ts:24

The newly introduced REALTIME_MAX_GLOBAL_SESSIONS environment setting is never consumed; the singleton registry always uses a separate hard-coded value of 8. Deployments lowering the setting for a smaller account rate limit will still admit eight attached calls and can trigger provider throttling, while increases have no effect.

P2 — Reserve per-user call capacity atomically

apps/realtime/src/voice/attach-handler.ts:137-139

When billing is disabled, two simultaneous attaches for the same user can both observe the same registered-call count before either reaches registration, because the existing pending reservation is global rather than keyed by user. Both requests then pass this check and exceed maxCallsPerUser, defeating the stated protection for tenant/on-prem deployments. Pending per-user attaches must be included in an atomic reservation.

…ching

Six review findings in apps/realtime, all of the same shape: a limit that
did not limit, or a failure that escaped the path meant to contain it.

- The per-user call cap counted only REGISTERED calls, and a call registers
  two awaits after admission — so simultaneous attaches from one person all
  saw zero and all passed. Claimed synchronously now, in the same
  reserveSlot() step as the deployment slot, with a race test that goes red
  against the old code.
- REALTIME_MAX_GLOBAL_SESSIONS was parsed and documented but never read;
  the registry hardcoded 8. An operator tuning it saw nothing change.
- 'call_ended' covers a network drop, a process shutdown and the one-hour
  socket backstop as well as a real hangup, so skipping the hangup for it
  left the browser talking to a model nobody was metering. We hang up on
  every reason now; a call that really ended answers 404, which is read as
  "already ended" rather than reported as a refusal.
- Promise.all over the tool answers meant one throwing session.send skipped
  the response.create for the whole turn — the model holding a turn it never
  speaks. allSettled, with each rejection logged.
- The fire-and-forget transcript write had no .catch, so a rejecting bridge
  raised an unhandled rejection inside a socket handler: process down, every
  other live call with it.
- The timeout comments claimed the transcript got the longer budget while
  the values said the opposite. The values are right — a tool dispatch runs
  a real page read behind it, a transcript is one insert — so the reasoning
  is corrected to match, and the test that said "TIGHTER" now says what it
  asserts.

Also restores the coverage gate CI was failing on (branches 97.01% -> 98.08%):
call-hangup.ts had no test file at all, and call-metering.test.ts repeated the
same unreachable `if (!started.ok) throw` narrowing twenty-two times, which is
twenty-two half-taken branches. That is now one shared helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016H8anXTeDyT7VZLVN3Vp5z

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
apps/realtime/src/voice/__tests__/voice-call-runtime.test.ts (1)

356-410: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move listener cleanup into finally.

flush already reaches the unhandledRejection event. Use finally to remove the listener if the test body fails.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/realtime/src/voice/__tests__/voice-call-runtime.test.ts` around lines
356 - 410, Update both transcript rejection tests around the unhandledRejection
listener so process.off is executed in a finally block. Keep the existing
assertions and test setup in the protected try section, ensuring the listener is
removed whether flush or an assertion fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/realtime/src/voice/__tests__/call-hangup.test.ts`:
- Around line 84-92: Update the test case around hangUpCall to guarantee
globalFetch cleanup even when the call or assertions fail. Place the await
expect and verification inside a try/finally block with
globalFetch.mockRestore() in the finally clause, or use the test suite’s
established afterEach mock cleanup if available.

In `@apps/realtime/src/voice/voice-call-runtime.ts`:
- Around line 258-269: Update the stop flow in the voice-call runtime so a
rejection from meter.stop(reason) is caught and does not abort execution or
propagate out of stop. Ensure hangUp(callId, secret) and session.end still run
after metering settlement fails, and preserve stop’s resolved completion for
callers such as attach-handler.

---

Nitpick comments:
In `@apps/realtime/src/voice/__tests__/voice-call-runtime.test.ts`:
- Around line 356-410: Update both transcript rejection tests around the
unhandledRejection listener so process.off is executed in a finally block. Keep
the existing assertions and test setup in the protected try section, ensuring
the listener is removed whether flush or an assertion fails.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d36af5e-d016-449e-b611-2a44d1c30f72

📥 Commits

Reviewing files that changed from the base of the PR and between 91767b1 and e664a3b.

📒 Files selected for processing (12)
  • apps/realtime/src/__tests__/realtime-attach-handler.test.ts
  • apps/realtime/src/__tests__/realtime-call-registry.test.ts
  • apps/realtime/src/__tests__/realtime-call-session.test.ts
  • apps/realtime/src/voice/__tests__/call-hangup.test.ts
  • apps/realtime/src/voice/__tests__/call-metering.test.ts
  • apps/realtime/src/voice/__tests__/voice-bridge-client.test.ts
  • apps/realtime/src/voice/__tests__/voice-call-runtime.test.ts
  • apps/realtime/src/voice/attach-handler.ts
  • apps/realtime/src/voice/call-hangup.ts
  • apps/realtime/src/voice/realtime-call-registry.ts
  • apps/realtime/src/voice/voice-bridge-client.ts
  • apps/realtime/src/voice/voice-call-runtime.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • apps/realtime/src/voice/tests/voice-bridge-client.test.ts
  • apps/realtime/src/tests/realtime-call-registry.test.ts
  • apps/realtime/src/voice/attach-handler.ts
  • apps/realtime/src/voice/voice-bridge-client.ts
  • apps/realtime/src/tests/realtime-attach-handler.test.ts
  • apps/realtime/src/tests/realtime-call-session.test.ts
  • apps/realtime/src/voice/tests/call-metering.test.ts

Comment thread apps/realtime/src/voice/__tests__/call-hangup.test.ts Outdated
Comment thread apps/realtime/src/voice/voice-call-runtime.ts
2witstudios and others added 2 commits August 11, 2026 07:05
The four P1 review findings were one bug wearing four hats: nothing about the
bound assistant was ever resolved server-side, so a call presented as talking
to a page agent got the realtime model's default persona, the whole deployment
tool registry, and the CALLER's permissions rather than the agent's.

Resolved once now, from the conversation the caller is already authorized for
(a page conversation's contextId IS its agent page), and carried for the life
of the call:

- TOOL EXECUTION runs as the agent. `resolveActingAgentId` reads
  chatSource.agentPageId and every canActor* check silently fell through to the
  invoking user without it, so an agent whose memberships are narrower than the
  caller's borrowed the caller's reach. The text surface authorizes as the
  agent for the same conversation; voice now does too.
- THE ALLOWLIST is honoured on BOTH lists. The advertised set and the
  executable set are built in different processes on different requests, and
  filtering only one of them is not filtering: `execute_tool` reaches whatever
  the exposure split deferred, so the allowlist is applied before the split and
  before tool_search is handed its catalog.
- INSTRUCTIONS reach OpenAI. The mint carries none by design, so they ride the
  same session.update as the tools — spoken-turn guidance plus the agent's own
  systemPrompt verbatim. Omitted rather than sent empty when there is none: an
  empty `instructions` REPLACES the session's.
- ADMISSION REFUSALS ARE REFUSALS. A 402 or 429 from the attach was reduced to
  `attached: false` and the browser kept a working, unmetered OpenAI call — a
  credit gate that was worse than absent. Those two statuses now fail the
  handshake and hang up the call OpenAI just made; 400/502 still degrade,
  because an outage declined nothing.

Also from review, in the same surfaces:

- The `upstream` passthrough is scoped to `realtime_call_rejected`. It exists
  to make a bad OPENAI_REALTIME_MODEL visible; a `mint_failed` body describes
  the managed API key request and can name the org, the quota or a masked key.
- Voice transcript writes re-check `isActive` under `FOR UPDATE` inside the
  write transaction. A call is held open for minutes, so "checked before the
  bridge hop" is not a check.
- The broadcast `source` is the PERSISTED one. It is insert-only, so a second
  write without it broadcast null over a row still saying 'voice' — the open
  thread dropped the mic glyph and grew it back on refresh.
- connect.ts wraps peer setup in the try that releases the microphone; a throw
  from createPeerConnection/addTrack/createDataChannel left the device held and
  the recording indicator on, one cloned track per attempt on the reuse path.
- useAudioLevel closes an AudioContext orphaned by a later setup failure — a
  stream with no live track is named as an expected cause, so it leaked a
  hardware audio thread in normal use.
- The two browser voice surfaces catch their own rejections; the trigger also
  toasts, because a failure before `start` never reaches chrome.state 'error'
  and the press looked like it did nothing.

seed-loader.ts is now binding-loader.ts: it answers one question — what is this
call bound to — behind one read and one access check, rather than deciding
access twice for the seed and the assistant separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016H8anXTeDyT7VZLVN3Vp5z
Self-review on the binding loader: `loadAgentPage` throwing escaped to the
outer handler, which degrades the WHOLE binding to unbound — throwing away a
seed that had already been fetched successfully alongside it, for a failure in
a different read.

Caught at the agent lookup instead. The call then runs as the Global Assistant
acting as the authenticated user, which is what every voice call did before the
binding existed and is bounded by that user's own ACL; the history survives,
and the failure is logged, because a page conversation whose agent cannot be
read is not the ordinary outcome a missing page row is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016H8anXTeDyT7VZLVN3Vp5z

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/lib/repositories/unified-message-leg.ts (1)

72-78: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Persist source in every writer that accepts UnifiedPageMessageRow.

source is now part of UnifiedPageMessageRow, but materializeUnifiedPageMessage and insertUnifiedPageMessage still omit it from their insert values at Lines 154-168 and Lines 194-206. A caller that supplies voice metadata through either path can silently store NULL. Add source: row.source ?? null to both insert paths, or use a narrower input type for functions that must not accept source.

Proposed fix
       sourceAgentId: row.sourceAgentId,
+      source: row.source ?? null,

Apply the same addition to both materializeUnifiedPageMessage and insertUnifiedPageMessage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/repositories/unified-message-leg.ts` around lines 72 - 78,
Update both materializeUnifiedPageMessage and insertUnifiedPageMessage to
include source in their insert values, using row.source ?? null so supplied
voice metadata is persisted while omitted values remain null.
🧹 Nitpick comments (2)
apps/web/src/lib/ai/realtime/binding-loader.ts (1)

68-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

maxTurns and maxTokens are declared but no caller supplies them.

apps/web/src/app/api/voice/realtime/call/route.ts calls loadVoiceBinding with userId and conversationId only. The two seed-budget fields are therefore dead surface today. Keep them only if a second caller is planned; otherwise remove them and let buildRealtimeSeed own its defaults.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/binding-loader.ts` around lines 68 - 73, Remove
the unused maxTurns and maxTokens fields from BindingLoaderRequest, since
loadVoiceBinding currently receives only userId and conversationId. Keep
seed-budget defaults owned by buildRealtimeSeed and update any related request
typing or destructuring to match.
apps/web/src/lib/ai/realtime/__tests__/connect.test.ts (1)

390-405: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The clone-stop claim is not asserted.

The test asserts only that the caller's own track stays live. It does not assert that the clone taken by this attempt was stopped. A regression that never clones, or that leaks the clone, still passes. Capture the cloned stream from the fake and assert its track is stopped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/realtime/__tests__/connect.test.ts` around lines 390 -
405, Update the reused-microphone test around fakeStream, asStream, and the
connectVoiceCall call to capture the cloned stream created during the attempt,
then assert that its track is stopped. Retain the existing assertion confirming
the caller’s original track remains live.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@apps/web/src/lib/repositories/unified-message-leg.ts`:
- Around line 72-78: Update both materializeUnifiedPageMessage and
insertUnifiedPageMessage to include source in their insert values, using
row.source ?? null so supplied voice metadata is persisted while omitted values
remain null.

---

Nitpick comments:
In `@apps/web/src/lib/ai/realtime/__tests__/connect.test.ts`:
- Around line 390-405: Update the reused-microphone test around fakeStream,
asStream, and the connectVoiceCall call to capture the cloned stream created
during the attempt, then assert that its track is stopped. Retain the existing
assertion confirming the caller’s original track remains live.

In `@apps/web/src/lib/ai/realtime/binding-loader.ts`:
- Around line 68-73: Remove the unused maxTurns and maxTokens fields from
BindingLoaderRequest, since loadVoiceBinding currently receives only userId and
conversationId. Keep seed-budget defaults owned by buildRealtimeSeed and update
any related request typing or destructuring to match.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c75c933f-2f8a-4610-9ba1-400860052cf0

📥 Commits

Reviewing files that changed from the base of the PR and between e664a3b and ba27f78.

📒 Files selected for processing (29)
  • apps/realtime/src/__tests__/realtime-attach-handler.test.ts
  • apps/realtime/src/__tests__/realtime-call-session.test.ts
  • apps/realtime/src/voice/__tests__/voice-call-runtime.test.ts
  • apps/realtime/src/voice/attach-handler.ts
  • apps/realtime/src/voice/realtime-call-session.ts
  • apps/realtime/src/voice/voice-call-runtime.ts
  • apps/web/src/app/api/voice/realtime/call/__tests__/route.test.ts
  • apps/web/src/app/api/voice/realtime/call/route.ts
  • apps/web/src/components/ai/voice/realtime/VoiceNavTrigger.tsx
  • apps/web/src/components/ai/voice/realtime/VoiceSessionBridge.tsx
  • apps/web/src/hooks/voice/useAudioLevel.ts
  • apps/web/src/lib/ai/realtime/__tests__/binding-loader.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/bridge-handler.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/call-handshake.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/connect.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/tool-dispatch.test.ts
  • apps/web/src/lib/ai/realtime/__tests__/tools.test.ts
  • apps/web/src/lib/ai/realtime/binding-loader.ts
  • apps/web/src/lib/ai/realtime/bridge-handler.ts
  • apps/web/src/lib/ai/realtime/call-handshake.ts
  • apps/web/src/lib/ai/realtime/connect.ts
  • apps/web/src/lib/ai/realtime/instructions.ts
  • apps/web/src/lib/ai/realtime/tool-dispatch.ts
  • apps/web/src/lib/ai/realtime/tools.ts
  • apps/web/src/lib/ai/realtime/voice-runtime-deps.ts
  • apps/web/src/lib/repositories/__tests__/message-repository-write-guards.test.ts
  • apps/web/src/lib/repositories/message-repository.ts
  • apps/web/src/lib/repositories/unified-message-leg.ts
  • packages/lib/src/realtime/voice-bridge-contract.ts
🚧 Files skipped from review as they are similar to previous changes (13)
  • apps/realtime/src/voice/attach-handler.ts
  • apps/web/src/hooks/voice/useAudioLevel.ts
  • apps/realtime/src/voice/voice-call-runtime.ts
  • apps/realtime/src/tests/realtime-call-session.test.ts
  • apps/web/src/components/ai/voice/realtime/VoiceNavTrigger.tsx
  • apps/realtime/src/tests/realtime-attach-handler.test.ts
  • apps/web/src/components/ai/voice/realtime/VoiceSessionBridge.tsx
  • apps/realtime/src/voice/realtime-call-session.ts
  • apps/web/src/lib/ai/realtime/tool-dispatch.ts
  • apps/web/src/lib/repositories/message-repository.ts
  • apps/web/src/lib/ai/realtime/connect.ts
  • apps/web/src/lib/ai/realtime/bridge-handler.ts
  • apps/web/src/lib/ai/realtime/call-handshake.ts

2witstudios and others added 6 commits August 11, 2026 07:16
…cedes

New review round, and it is the same failure class as the transcript write one
directory over. `stop()` awaited `meter.stop(reason)` first — correct, the final
window is usually the most expensive part of the call — but a rejection there
skipped BOTH the hangup and `session.end`, leaving precisely the state that
whole path exists to prevent: a live browser call with nobody metering it.

The rejection then escaped into `attach-handler.ts`'s `void runtime?.stop(…)`
calls, where an unhandled rejection takes the process down and every other live
call with it.

Caught and logged; teardown continues. An unbilled final window is a bad
outcome, an unbilled ONGOING call is a worse one. Two tests, mutation-checked:
one asserting hangup and session.end still run, one asserting nothing reaches
the unhandledRejection handler.

Also restores the global `fetch` spy in a `finally` in call-hangup.test.ts — a
spy that survives a failing assertion is a spy every later test runs under.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016H8anXTeDyT7VZLVN3Vp5z
…nd that limits refuse it

The unreleased voice entries described a call talking to 'whichever assistant
you are already looking at' — true of the binding, but until this round the
model never received that assistant's instructions or its tool allowlist. Both
now hold, so the claim is worth stating plainly rather than leaving implied.

The admission change is user-visible in its own right: out of credit, or at the
concurrency limit, used to connect anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016H8anXTeDyT7VZLVN3Vp5z
New module with only indirect coverage through the binding loader. The cases
that matter are an agent owner's own prompt arriving verbatim, the medium
coming before the persona so nothing bolted on argues with it, a cleared field
counting as no prompt rather than empty ones, and the builder never returning
'' — an empty `instructions` on a session.update REPLACES the session's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016H8anXTeDyT7VZLVN3Vp5z
Follow-through on my own admission change: making the route answer 402/429 left
the browser mapping both to `signaling-failed`, so "you are out of credit" and
"too many calls right now" were presented as "Could not reach the voice
service" — and both offered a retry.

Named separately, because they want opposite advice: `no-credit` joins the two
microphone cases in UNRETRYABLE (a retry cannot conjure credit), while
`call-limit` stays retryable, since a concurrency cap clears on its own and
trying again in a moment is exactly right.

The route's own sentence still wins over these fallbacks — it is the one that
knows the specifics — but the classification is what decides whether a Retry
button appears, and that was wrong on both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016H8anXTeDyT7VZLVN3Vp5z
The guard the review asked for landed in `voice-runtime-deps.ts` — the module
that exists precisely to be the untestable seam — so the mechanism itself had
no test. Exported and pinned directly instead: allows a live conversation,
refuses one deleted mid-call, refuses a missing row rather than assuming the
best, and takes the row lock rather than merely reading it.

That last case is the one that matters and is mutation-checked: without
FOR UPDATE this is a second guess rather than a decision, because it would not
serialise against the delete it is racing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016H8anXTeDyT7VZLVN3Vp5z
The migration this PR adds (`0258`, `messages.source`) never registered the
column in `scripts/lib/tenant-export-columns.ts`, so the drift guard failed:
an unlisted column is dropped silently by every tenant migration.

Carried rather than excluded. `source` is how a turn is known to have been
spoken rather than typed, and the thread renders a microphone from it — a
tenant that migrated without it would keep every word and lose the account of
how they were said, which is the same failure the column exists to prevent.

This was invisible until now: the realtime coverage gate aborted the turbo run
several tasks earlier, so the scripts suite never ran on this branch. Fixing
that gate is what let CI reach this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016H8anXTeDyT7VZLVN3Vp5z
@2witstudios
2witstudios merged commit 5141cc1 into master Aug 11, 2026
11 checks passed
@2witstudios
2witstudios deleted the pu/gpt-realtime branch August 12, 2026 20:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant