diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 07f09326a..ecfc212c6 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -6499,6 +6499,19 @@ if (maybeCompleteSteer(msg)) break; console.error('[impeccable] Error:', msg.message); showToast('Error: ' + msg.message, 5000); + // An agent error reply is terminal for the session it names: tear + // it down exactly like 'discarded' (cleanup includes clearSession), + // or the durable localStorage checkpoint survives and every reload + // resurrects a GENERATING bar for a session the server no longer + // knows about (issue #362). + if (msg.id && msg.id === currentSessionId) { + markSessionHandled(); + cleanup(); + break; + } + // A stored-but-not-current checkpoint naming the errored session + // (the error raced a reload) must not resurrect either. + if (msg.id && loadSession()?.id === msg.id) clearSession(); hideBar(); renderEditBadge('hidden'); setLiveState('PICKING'); diff --git a/tests/live-browser-regression.test.mjs b/tests/live-browser-regression.test.mjs index 406e1e97e..c092cc7db 100644 --- a/tests/live-browser-regression.test.mjs +++ b/tests/live-browser-regression.test.mjs @@ -273,6 +273,31 @@ describe('live-browser.js regression guards', () => { ); }); + it('SSE error reply clears the durable session checkpoint like discarded', () => { + // Issue #362: `live-poll.mjs --reply error "..."` is the documented + // abort flow in reference/live.md, and an agent error reply is terminal + // for the session it names. The 'error' case used to reset only the UI + // (hideBar + PICKING) while the localStorage checkpoint written for the + // GENERATING phase survived — so every reload resurrected a dead session + // the server no longer knew about, until the user hand-cleared the + // impeccable-live* keys in the console. The error path must tear down the + // named session exactly like 'discarded' does (markSessionHandled + + // cleanup, which includes clearSession), and drop a stored-but-not- + // current checkpoint that matches the errored id. + const errorCase = SOURCE.match(/case 'error':[\s\S]{0,2500}?setLiveState\('PICKING'\);\s*break;/); + assert.ok(errorCase, 'expected an SSE case \'error\' handler in live-browser.js'); + assert.match( + errorCase[0], + /if \(msg\.id && msg\.id === currentSessionId\) \{[\s\S]{0,160}?markSessionHandled\(\);[\s\S]{0,80}?cleanup\(\);[\s\S]{0,80}?break;/, + 'an error reply naming the current session must run the same markSessionHandled + cleanup teardown as \'discarded\' so the durable checkpoint is cleared', + ); + assert.match( + errorCase[0], + /if \(msg\.id && loadSession\(\)\?\.id === msg\.id\) clearSession\(\);/, + 'an error reply naming a stored-but-not-current session must drop that checkpoint so a reload cannot resurrect it', + ); + }); + it('handleServerLost preserves the current recoverable phase', () => { assert.doesNotMatch( SOURCE,