mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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
|
- 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in new issue
Block a user