diff --git a/src/voice/panel/nativeActivity.js b/src/voice/panel/nativeActivity.js index c019f76c8..ce9426cdc 100644 --- a/src/voice/panel/nativeActivity.js +++ b/src/voice/panel/nativeActivity.js @@ -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(); diff --git a/src/voice/panel/nativeActivity.test.mjs b/src/voice/panel/nativeActivity.test.mjs index f1ac6cd5f..81709ce53 100644 --- a/src/voice/panel/nativeActivity.test.mjs +++ b/src/voice/panel/nativeActivity.test.mjs @@ -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(); + } + } +});