From e7970c50b7f71a190b29029bd43e868a3c72980a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 15 Sep 2026 12:13:35 -0700 Subject: [PATCH] fix(voice): discard continuations from closed conversations --- CHANGELOG.md | 3 +- docs/CURRENT-STATE.md | 3 +- docs/VOICE-OWNERSHIP.md | 2 ++ src/voice/realtimeOwners.test.mjs | 50 +++++++++++++++++++++++++++++-- src/voice/realtimeTurns.js | 9 ++++++ 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f02c64b4..2139f9bf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ - Separate Realtime connection, response/tool, Radio, input/audio, cost, viewport and diagnostic ownership while preserving voice controls and protocol behavior. Revoke stale connection offers/capture callbacks and release failed or stopped audio - meters; add recorded push-to-talk acceptance alongside click-start voice. + meters. Discard late action continuations after Stop or restart; add recorded + push-to-talk acceptance alongside click-start voice. - Separate application-shell responsibilities and state ownership while preserving the layer, scene and voice API. Revoke pending globe-reset callbacks on disposal diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9b48a844d..1b524a961 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6,7 +6,8 @@ controller exports and session/backend contracts remain available. Tool protocol response wording, push-to-talk timing, model preferences and Radio confirmation order are preserved. Stopped connections, stale offers and delayed captures cannot affect a replacement session; audio meters release failed initialization and reject revoked -frames. See [voice ownership](VOICE-OWNERSHIP.md). +frames. Delayed action results and post-capture continuations cannot resume a +stopped conversation or send output into a replacement. See [voice ownership](VOICE-OWNERSHIP.md). The application shell composes focused state owners for navigation, destination lookup/orbit, Cockpit, visual settings, panel layout, aircraft display and layer diff --git a/docs/VOICE-OWNERSHIP.md b/docs/VOICE-OWNERSHIP.md index a9faf6d9f..d5553df00 100644 --- a/docs/VOICE-OWNERSHIP.md +++ b/docs/VOICE-OWNERSHIP.md @@ -25,6 +25,8 @@ stops audio analysis, releases media and clears conversation/input state. Full removal also detaches UI, keyboard, annotation and Radio observers. Radio takeover retains confirmed playback through that same shutdown path. A late offer, peer callback, viewport capture or cancelled meter frame cannot enter a replacement session. +Action and post-capture continuations retain their conversation identity before +publishing output, queueing a response or changing status. Behavioral invariants remain in force: a 500 ms Space hold claims push-to-talk; short control taps and text entry remain native; a click-started session stays diff --git a/src/voice/realtimeOwners.test.mjs b/src/voice/realtimeOwners.test.mjs index fa2a0c9c6..1470790c2 100644 --- a/src/voice/realtimeOwners.test.mjs +++ b/src/voice/realtimeOwners.test.mjs @@ -24,9 +24,9 @@ function browser(t) { addTrack() {} createDataChannel() { this.channel = { - readyState: 'open', handlers: new Map(), + readyState: 'open', handlers: new Map(), sent: [], addEventListener(type, handler) { this.handlers.set(type, handler); }, - send() {}, close() { this.readyState = 'closed'; }, + send(message) { this.sent.push(JSON.parse(message)); }, close() { this.readyState = 'closed'; }, }; return this.channel; } @@ -217,3 +217,49 @@ test('stopping one audio meter revokes retained frames without stopping another second.stopVoiceVisualizer(); assert.deepEqual(contexts.map(c => c.closes), [1, 1]); }); + +test('late action or viewport completion cannot resume a stopped or replacement conversation', async (t) => { + browser(t); + for (const phase of ['tool', 'tool-error', 'viewport']) { + for (const restart of [false, true]) { + let finish; + const controller = new GevRealtimeController({ + runner: phase === 'viewport' + ? async () => ({ ok: true, ...localContext }) + : () => new Promise((resolve, reject) => { + finish = phase === 'tool-error' + ? () => reject(new Error('superseded action failed')) + : () => resolve({ ok: true, action: 'get_entity_context' }); + }), + backend: { + async requestToken() { return { token: 'synthetic', model: resolveVoiceModel('mini').id }; }, + async negotiate() { return 'answer'; }, + }, + debugSink: null, + ui: { root: { dataset: {}, classList: { remove() {} }, querySelectorAll: () => [] }, status: {}, detail: {} }, + }); + if (phase === 'viewport') controller._viewport.capture = () => new Promise(resolve => { + finish = () => resolve('data:image/jpeg;base64,abc'); + }); + await controller.start(); + controller.dc.handlers.get('open')(); + const pending = controller.handleRealtimeEvent({ data: JSON.stringify({ + type: 'response.function_call_arguments.done', name: 'get_entity_context', + call_id: 'delayed', arguments: '{}', + }) }); + while (!finish) await new Promise(resolve => setImmediate(resolve)); + controller.stop(); + if (restart) { + await controller.start(); + controller.dc.handlers.get('open')(); + } + const status = controller.status; + finish(); + await pending; + assert.equal(controller.status, status, `${phase}: stopped status stays owned by the new lifetime`); + assert.deepEqual(controller.dc?.sent || [], [], `${phase}: no old result or response reaches the replacement`); + assert.equal(controller.pendingResponseInstructions, null); + controller.stop(); + } + } +}); diff --git a/src/voice/realtimeTurns.js b/src/voice/realtimeTurns.js index dfad19243..366a8e7d3 100644 --- a/src/voice/realtimeTurns.js +++ b/src/voice/realtimeTurns.js @@ -172,7 +172,12 @@ export class RealtimeTurns { if (!sent) this.responseCreatePending = false; } + ownsConversation(channel) { + return this.dc === channel && channel?.readyState === 'open'; + } + async handleRealtimeEvent(event) { + const eventChannel = this.dc; let payload = null; try { payload = JSON.parse(event.data); @@ -410,6 +415,7 @@ export class RealtimeTurns { radioHandoffEpochAtStart === this.radio.radioHandoffEpoch), }, ); + if (!this.ownsConversation(eventChannel)) return; if (result?.ok && result.radioPlaybackRequested) { const sessionIsCurrent = this.activeToolAbortControllers.has(toolController) && @@ -494,6 +500,8 @@ export class RealtimeTurns { this.radio.releaseTool(toolController); } } + // Rejections also arrive after cancellation; never publish into a new session. + if (!this.ownsConversation(eventChannel)) return; if (radioReservationToken && result?.ok) { // Successful authority commits before its output is serialized. The // sibling abort synchronously restores manager ownership, so report @@ -560,6 +568,7 @@ export class RealtimeTurns { error: error?.message || String(error), }); } + if (!this.ownsConversation(eventChannel)) return; // Keep the Radio handoff wording authoritative even when another tool // result follows Radio in the same multi-intent response. this.queueResponseCreate(