diff --git a/src/chrome/src/run-reconnect.js b/src/chrome/src/run-reconnect.js index a7bd5f11e..e13931f0b 100644 --- a/src/chrome/src/run-reconnect.js +++ b/src/chrome/src/run-reconnect.js @@ -13,6 +13,13 @@ function requestMatches(value, requestId) { return value != null && String(value) === String(requestId); } +function hasPlannerRequestFailure(updates) { + return updates.some(update => ( + update?.type === 'warning' + && update?.data?.code === 'planner_request_failed' + )); +} + function runResponseFromSnapshot(snapshot, { reconnected = false, resumed = false, @@ -21,7 +28,12 @@ function runResponseFromSnapshot(snapshot, { const updates = (Array.isArray(snapshot?.events) ? snapshot.events : []) .filter(event => event?.type && event.type !== 'run_complete') .map(event => ({ type: event.type, data: event.data })); - if (snapshot?.status === 'failed' && !updates.some(update => update.type === 'error')) { + // Planner request failures already render an actionable Retry/Providers card. + // Treat that warning as the terminal presentation so recovery cannot append + // a second generic error card for the same failed snapshot. + if (snapshot?.status === 'failed' + && !updates.some(update => update.type === 'error') + && !hasPlannerRequestFailure(updates)) { updates.push({ type: 'error', data: { diff --git a/src/firefox/src/run-reconnect.js b/src/firefox/src/run-reconnect.js index a7bd5f11e..e13931f0b 100644 --- a/src/firefox/src/run-reconnect.js +++ b/src/firefox/src/run-reconnect.js @@ -13,6 +13,13 @@ function requestMatches(value, requestId) { return value != null && String(value) === String(requestId); } +function hasPlannerRequestFailure(updates) { + return updates.some(update => ( + update?.type === 'warning' + && update?.data?.code === 'planner_request_failed' + )); +} + function runResponseFromSnapshot(snapshot, { reconnected = false, resumed = false, @@ -21,7 +28,12 @@ function runResponseFromSnapshot(snapshot, { const updates = (Array.isArray(snapshot?.events) ? snapshot.events : []) .filter(event => event?.type && event.type !== 'run_complete') .map(event => ({ type: event.type, data: event.data })); - if (snapshot?.status === 'failed' && !updates.some(update => update.type === 'error')) { + // Planner request failures already render an actionable Retry/Providers card. + // Treat that warning as the terminal presentation so recovery cannot append + // a second generic error card for the same failed snapshot. + if (snapshot?.status === 'failed' + && !updates.some(update => update.type === 'error') + && !hasPlannerRequestFailure(updates)) { updates.push({ type: 'error', data: { diff --git a/test/run.js b/test/run.js index bf517fac4..88447e062 100644 --- a/test/run.js +++ b/test/run.js @@ -377,6 +377,7 @@ const { const { isBackgroundConnectionError: isBackgroundConnectionErrorCh, runDetachedWithReconnect: runDetachedWithReconnectCh, + runResponseFromSnapshot: runResponseFromSnapshotCh, sendPlanResponseWithReconnect: sendPlanResponseWithReconnectCh, } = await import( 'file://' + path.join(ROOT, 'src/chrome/src/run-reconnect.js').replace(/\\/g, '/') @@ -384,6 +385,7 @@ const { const { isBackgroundConnectionError: isBackgroundConnectionErrorFx, runDetachedWithReconnect: runDetachedWithReconnectFx, + runResponseFromSnapshot: runResponseFromSnapshotFx, sendPlanResponseWithReconnect: sendPlanResponseWithReconnectFx, } = await import( 'file://' + path.join(ROOT, 'src/firefox/src/run-reconnect.js').replace(/\\/g, '/') @@ -55933,6 +55935,52 @@ test('sidepanel run errors dedupe streamed, returned, and restored copies by tab } }); +test('failed planner snapshots keep the actionable warning as the only error presentation', () => { + for (const [label, runResponseFromSnapshot] of [ + ['chrome', runResponseFromSnapshotCh], + ['firefox', runResponseFromSnapshotFx], + ]) { + const plannerMessage = 'Planner request failed before a valid response was available. No tools ran.'; + const plannerResponse = runResponseFromSnapshot({ + status: 'failed', + requestId: `${label}-planner-failure`, + finalContent: plannerMessage, + events: [{ + type: 'warning', + data: { + code: 'planner_request_failed', + message: plannerMessage, + failureKind: 'request', + }, + }], + }); + assert.deepEqual( + plannerResponse.updates.map(update => update.type), + ['warning'], + `${label}: planner failure card should not be followed by a synthesized generic error`, + ); + + const ordinaryResponse = runResponseFromSnapshot({ + status: 'failed', + requestId: `${label}-ordinary-failure`, + finalContent: 'Error: Ordinary provider failure.', + events: [], + }); + assert.equal(ordinaryResponse.updates.length, 1, `${label}: ordinary failed snapshot should synthesize one error`); + assert.equal(ordinaryResponse.updates[0]?.type, 'error', `${label}: ordinary failure should remain an error update`); + assert.equal(ordinaryResponse.updates[0]?.data?.message, 'Ordinary provider failure.', `${label}: synthesized error copy`); + + const recordedResponse = runResponseFromSnapshot({ + status: 'failed', + requestId: `${label}-recorded-failure`, + finalContent: 'Error: Recorded provider failure.', + events: [{ type: 'error', data: { message: 'Recorded provider failure.' } }], + }); + assert.equal(recordedResponse.updates.length, 1, `${label}: recorded error should not be duplicated`); + assert.equal(recordedResponse.updates[0]?.type, 'error', `${label}: recorded error should be preserved`); + } +}); + test('sidepanel routes every run-error path through request-scoped deduplication', () => { for (const [label, panelRel] of [ ['chrome', 'src/chrome/src/ui/sidepanel.js'],