fix(voice): preserve idle evidence through recoverable response errors

This commit is contained in:
Sameh Khamis committed 2026-10-26 08:47:54 -07:00
1 parent 074e272356
commit ddbcd2053b
2 files changed
+111 -3

No files matched your search

+13 -3
View File
@@ -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();
+98
View File
@@ -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();
}
}
});