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
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
+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
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
+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
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
+48 -2
View File
@@ -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();
}
}
});
+9
View File
@@ -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(