mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04: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
@@ -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