mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(voice): preserve idle evidence through recoverable response errors
This commit is contained in:
1 parent
074e272356
commit
ddbcd2053b
2 files changed
+111
-3
No files matched your search
@@ -16,7 +16,8 @@ export function observeNativeActivity({
|
||||
: null;
|
||||
let dead = false,
|
||||
connected = false,
|
||||
inputBusy = false;
|
||||
inputBusy = false,
|
||||
recoverableError = false;
|
||||
function cleanNativeOutput() {
|
||||
if (nativeProvider === 'gemini') return true;
|
||||
if (nativeProvider !== 'openai') return false;
|
||||
@@ -122,13 +123,22 @@ export function observeNativeActivity({
|
||||
if (event?.type === 'state') {
|
||||
const state = event.state;
|
||||
const wasConnected = connected;
|
||||
const liveError =
|
||||
state === 'error' &&
|
||||
nativeProvider === 'openai' &&
|
||||
controller.dc?.readyState === 'open';
|
||||
const restartingAfterError = state === 'connecting' && recoverableError;
|
||||
recoverableError = liveError;
|
||||
connected = !['idle', 'connecting', 'error'].includes(state);
|
||||
// Connecting has multiple native phases around audio allocation. Retire
|
||||
// a previous connected generation once, not each phase of this start.
|
||||
// A failed reply can briefly report error on a live OpenAI transport.
|
||||
// Preserve its output evidence through recovery to listening; a later
|
||||
// reconnect still retires the failed connection's generation.
|
||||
if (
|
||||
state === 'idle' ||
|
||||
state === 'error' ||
|
||||
(state === 'connecting' && wasConnected)
|
||||
(state === 'error' && !liveError) ||
|
||||
(state === 'connecting' && (wasConnected || restartingAfterError))
|
||||
) {
|
||||
version++;
|
||||
retireAudio();
|
||||
|
||||
@@ -670,3 +670,101 @@ test('Gemini restores each retired audio immediately across repeated same-mount
|
||||
assert.equal(current.close, originals.get(current).close);
|
||||
observer.destroy();
|
||||
});
|
||||
|
||||
test('actual OpenAI failed-response error/listening recovery preserves only proven no-audio knowledge', async (t) => {
|
||||
const { GevRealtimeController } = await import('../realtimeController.js');
|
||||
t.mock.method(console, 'error', () => {});
|
||||
for (const [output, known] of [
|
||||
[[], true],
|
||||
[[{ type: 'message', content: [{ type: 'audio' }] }], false],
|
||||
[undefined, false],
|
||||
]) {
|
||||
let observer, idle, timer;
|
||||
const states = [];
|
||||
let expired = 0;
|
||||
const controller = new GevRealtimeController({
|
||||
runner: async () => ({ ok: true }),
|
||||
backend: {},
|
||||
debugSink: null,
|
||||
ui: {
|
||||
root: {
|
||||
dataset: {},
|
||||
classList: { remove() {} },
|
||||
querySelectorAll: () => [],
|
||||
},
|
||||
status: {},
|
||||
detail: {},
|
||||
},
|
||||
onSessionEvent(event) {
|
||||
if (event.type === 'state') states.push(event.state);
|
||||
observer?.handle(event);
|
||||
},
|
||||
});
|
||||
observer = observeNativeActivity({
|
||||
adapter: { controller },
|
||||
nativeProvider: 'openai',
|
||||
onChange: () => idle?.update(),
|
||||
});
|
||||
idle = createVoiceInactivity({
|
||||
readActivity: observer.snapshot,
|
||||
readPending: () => false,
|
||||
setTimer(callback) {
|
||||
timer = callback;
|
||||
return 1;
|
||||
},
|
||||
clearTimer() {
|
||||
timer = null;
|
||||
},
|
||||
onExpire() {
|
||||
expired++;
|
||||
},
|
||||
});
|
||||
try {
|
||||
controller.dc = {
|
||||
readyState: 'open',
|
||||
send() {},
|
||||
close() {
|
||||
this.readyState = 'closed';
|
||||
},
|
||||
};
|
||||
controller.setStatus('listening', 'Ready');
|
||||
const event = (payload) =>
|
||||
controller.handleRealtimeEvent({ data: JSON.stringify(payload) });
|
||||
await event({
|
||||
type: 'response.created',
|
||||
response: { id: 'failed-reply' },
|
||||
});
|
||||
await event({
|
||||
type: 'response.done',
|
||||
response: {
|
||||
id: 'failed-reply',
|
||||
status: 'failed',
|
||||
output,
|
||||
status_details: { error: { message: 'Synthetic failed reply' } },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(states.slice(-2), ['error', 'listening']);
|
||||
assert.equal(controller.dc.readyState, 'open');
|
||||
assert.equal(observer.snapshot().known, known);
|
||||
assert.equal(idle.snapshot().eligible, known);
|
||||
if (known) {
|
||||
timer();
|
||||
assert.equal(expired, 1);
|
||||
} else assert.equal(timer, null);
|
||||
// Fatal teardown can preserve the error status; the next connection must
|
||||
// still retire the old generation rather than inherit unresolved output.
|
||||
controller.setStatus('error', 'Transport failed');
|
||||
controller.stop({ preserveStatus: true });
|
||||
controller.setStatus('connecting', 'Reconnecting');
|
||||
assert.equal(observer.snapshot().connected, false);
|
||||
assert.equal(observer.snapshot().known, true);
|
||||
controller.stop();
|
||||
assert.equal(observer.snapshot().connected, false);
|
||||
assert.equal(observer.snapshot().known, true);
|
||||
} finally {
|
||||
idle.destroy();
|
||||
observer.destroy();
|
||||
controller.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user