fix(voice): discard continuations from closed conversations

This commit is contained in:
Sameh Khamis committed 2026-09-15 12:13:35 -07:00
1 parent aa6804802c
commit e7970c50b7
5 files changed
+63 -4

No files matched your search

+2 -1
View File
@@ -3,7 +3,8 @@
- Separate Realtime connection, response/tool, Radio, input/audio, cost, viewport - Separate Realtime connection, response/tool, Radio, input/audio, cost, viewport
and diagnostic ownership while preserving voice controls and protocol behavior. and diagnostic ownership while preserving voice controls and protocol behavior.
Revoke stale connection offers/capture callbacks and release failed or stopped audio 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 - Separate application-shell responsibilities and state ownership while preserving
the layer, scene and voice API. Revoke pending globe-reset callbacks on disposal the layer, scene and voice API. Revoke pending globe-reset callbacks on disposal
+2 -1
View File
@@ -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 response wording, push-to-talk timing, model preferences and Radio confirmation
order are preserved. Stopped connections, stale offers and delayed captures cannot affect a order are preserved. Stopped connections, stale offers and delayed captures cannot affect a
replacement session; audio meters release failed initialization and reject revoked 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 The application shell composes focused state owners for navigation, destination
lookup/orbit, Cockpit, visual settings, panel layout, aircraft display and layer lookup/orbit, Cockpit, visual settings, panel layout, aircraft display and layer
+2
View File
@@ -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 removal also detaches UI, keyboard, annotation and Radio observers. Radio takeover
retains confirmed playback through that same shutdown path. A late offer, peer callback, retains confirmed playback through that same shutdown path. A late offer, peer callback,
viewport capture or cancelled meter frame cannot enter a replacement session. 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; 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 short control taps and text entry remain native; a click-started session stays
+48 -2
View File
@@ -24,9 +24,9 @@ function browser(t) {
addTrack() {} addTrack() {}
createDataChannel() { createDataChannel() {
this.channel = { this.channel = {
readyState: 'open', handlers: new Map(), readyState: 'open', handlers: new Map(), sent: [],
addEventListener(type, handler) { this.handlers.set(type, handler); }, 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; return this.channel;
} }
@@ -217,3 +217,49 @@ test('stopping one audio meter revokes retained frames without stopping another
second.stopVoiceVisualizer(); second.stopVoiceVisualizer();
assert.deepEqual(contexts.map(c => c.closes), [1, 1]); 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();
}
}
});
+9
View File
@@ -172,7 +172,12 @@ export class RealtimeTurns {
if (!sent) this.responseCreatePending = false; if (!sent) this.responseCreatePending = false;
} }
ownsConversation(channel) {
return this.dc === channel && channel?.readyState === 'open';
}
async handleRealtimeEvent(event) { async handleRealtimeEvent(event) {
const eventChannel = this.dc;
let payload = null; let payload = null;
try { try {
payload = JSON.parse(event.data); payload = JSON.parse(event.data);
@@ -410,6 +415,7 @@ export class RealtimeTurns {
radioHandoffEpochAtStart === this.radio.radioHandoffEpoch), radioHandoffEpochAtStart === this.radio.radioHandoffEpoch),
}, },
); );
if (!this.ownsConversation(eventChannel)) return;
if (result?.ok && result.radioPlaybackRequested) { if (result?.ok && result.radioPlaybackRequested) {
const sessionIsCurrent = const sessionIsCurrent =
this.activeToolAbortControllers.has(toolController) && this.activeToolAbortControllers.has(toolController) &&
@@ -494,6 +500,8 @@ export class RealtimeTurns {
this.radio.releaseTool(toolController); this.radio.releaseTool(toolController);
} }
} }
// Rejections also arrive after cancellation; never publish into a new session.
if (!this.ownsConversation(eventChannel)) return;
if (radioReservationToken && result?.ok) { if (radioReservationToken && result?.ok) {
// Successful authority commits before its output is serialized. The // Successful authority commits before its output is serialized. The
// sibling abort synchronously restores manager ownership, so report // sibling abort synchronously restores manager ownership, so report
@@ -560,6 +568,7 @@ export class RealtimeTurns {
error: error?.message || String(error), error: error?.message || String(error),
}); });
} }
if (!this.ownsConversation(eventChannel)) return;
// Keep the Radio handoff wording authoritative even when another tool // Keep the Radio handoff wording authoritative even when another tool
// result follows Radio in the same multi-intent response. // result follows Radio in the same multi-intent response.
this.queueResponseCreate( this.queueResponseCreate(