mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(voice): discard continuations from closed conversations
This commit is contained in:
1 parent
aa6804802c
commit
e7970c50b7
5 files changed
+63
-4
No files matched your search
+2
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user