feat(desktop): the call window runs the avatar's Keyframe session for as long as a grant stands, feeds it, and reports it (avatar 9b/12)
Merged
CH
5d ago+675+675+012345678901234567890123456789-31-31-01234567890123456789
Plan item 9b. call window: run the session. Stacks on #2476 (9a), #2471 (6) and the tile from #5, all merged into feature/avatar-video-keyframe.
What changes
- Session (
call/call-avatar.ts): a kept-aliverendererRuntime.atomturns main's grants (onAvatarSession, read through@sidecar/hosted/avatar-grant, not the barrel) into oneopenAvatarSession(grant, module)per grant, using aStream.switchMap. The next grant, or nothing, closes that scope, and closing the scope closes the session. The same grant handed twice opens one session. The SDK bundle loads through #6'savatarSessionModuleAtom.speakingis not read: 9b has no consumer for it, and it allows only one reader. - Feed:
onAvatarFeedmaps exactly as Keyframe's bridge does:audio→sendAudio(base64 decoded),turn_end→endTurn,interrupt→endTurntheninterrupt. A closed session ignores them (#6'swhileOpen). - Report over
ACT_KIND.AVATAR_REPORT, through a new non-hooktellActinact.ts, which stays the only act caller:STARTINGonce a grant stands.LIVEon the video's first frame: the remote track'sunmute, not just the track arriving.ENDEDwith a reason: USER, SERVER_CLOSED or ERROR from the session's close. ACONNECTfailure or a bundle that fails to load reports ERROR. A grant withdrawn under the session reports USER.
- Mount:
CallWindowCarddrawsCallVideoTilein place of the card while the status isSTARTINGorLIVE, the statuses 9a sizes the window for. The tile's<audio>is drawn in every state. The tile's localAVATAR_STATUSis gone and #1's is used instead. The tile gained anonAudioElementprop, so a scoped atom can retry a refusedplay()every second, the wayuse-voice-session.tsdoes. - Subscribed at the root:
index.tsxmounts the avatar atom before the first state read. 9a's relay sends the standing grant onready-to-show, which can come before React mounts, so a subscription made from a component could miss it. Panels and the voice window subscribe too, but no grant is ever sent to them. - CSP (
call.html):connect-src wss://*.livekit.cloud https://*.livekit.cloud, with a comment.worker-src 'none'is kept, andmedia-srcis unchanged. - Sentry scrub (
sentry-breadcrumbs.ts): every renderer'sSentry.initnow has abeforeBreadcrumbthat drops (does not redact) any breadcrumb whosedata.urlormessagecontainsaccess_token. That covers fetch, xhr and console. This fixes the leak the spike found: livekit's/rtc/v1/validate?access_token=…fetch on a failed join. It keeps the AGENTS.md carve-out true. knip.json: removed theavatar-session-bundle.tsentry.AVATAR_SESSION_LOAD_FAILEDis no longer exported.AvatarEndReasonis now exported.
Tests
call-avatar.test.ts, withit.effectand a fakePersonaSession(#6's seam):- the feed maps to the right SDK calls, in order;
- reports go STARTING → LIVE, and LIVE waits for the first frame, not the track;
- each Keyframe close reason maps to its end reason;
- a refused connect and an unloadable bundle each report ERROR;
- a grant of nothing closes the session, reports ENDED USER once, and draws no tracks;
- the same grant handed twice opens one session.
sentry-breadcrumbs.test.ts: the validate fetch, xhr and console breadcrumbs with a token are dropped, and a clean one is kept../scripts/check.shexits 0.
Untested against a live session
No live Keyframe session was ever opened. These are unverified:
- the real
server_urlhost behind theconnect-src(#11's first real call confirms it); - that a remote video track's
unmutelines up with the first drawn frame; - that the SDK's close reasons come in as #6 read them from its code;
- that autoplay of the avatar's
<audio>works in the call window without the retry.
Known gaps / for #11
- Video off and Restart video on the tile do nothing yet: no act carries
voice.setVideountil #11. - 9a shrinks the window back to the card on
ENDED, so the tile's "Restart video" state is never drawn. #11 decides whetherENDEDkeeps the tile up. - The call window reports
ENDED USERwhen main withdraws the grant. If main wantsCALL_ENDEDto stand when the call ends, #11 decides which write wins.
CI could not verify this; Mac evidence pending. A pull request builds nothing for the Mac, and this Linux VM cannot run ./scripts/verify.sh.
🤖 Generated with Claude Code
Summary by cubic
Runs the avatar's Keyframe session in the call window for as long as main hands it a grant, feeds it Luke's voice, and reports it as starting, live, and ended.
- Each grant opens one session that the next grant or nothing closes; the same grant handed twice opens one.
- The feed maps as Keyframe's bridge does: audio → sendAudio, turn_end → endTurn, interrupt → endTurn then interrupt.
LIVEwaits for the video's first frame, not the track's arrival, andENDEDcarries the close reason, with refused connects and unloadable bundles reportingERROR.- The call window draws
CallVideoTilewhile starting or live and retries a refused play of the avatar's audio every second. call.htmlnow allowswss://*.livekit.cloudandhttps://*.livekit.cloud, and the avatar is subscribed at the renderer root so the standing grant is not missed.- No live session was ever opened, so the real
server_urlhost, theunmutetiming, and the SDK's close reasons are unverified.
Bug Fixes
- Both the page's and the preload's Sentry now drop any breadcrumb that carries
access_token, which LiveKit's failed-join validate fetch put in its URL.