[eric] health: a mid-refresh 401 that outlives the token rotation window is a dead login; recheck at 240s and push the pill

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ciregenz
2026-09-05 09:28:59 -07:00
co-authored by Claude Fable 5.1
parent 5ac58919e5
commit 05e8310691
5 changed files with 243 additions and 8 deletions
@@ -0,0 +1,29 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import reducer, { healthReported, hideProviderHealthToast } from './subscriptionsSlice';
// A ChatGPT login whose token expired on 08-30 answered every probe with the router's "(reset after Ns)" 401 for six
// days; the boot-time verdict excused it as mid-refresh and nothing ever looked again. The backend now re-probes after
// the rotation window and pushes the verdict; the slice has to open the same pill the boot fetch would have.
test('a pushed verdict opens the reconnect pill', () => {
const s = reducer(undefined, healthReported({ dead: [{ provider: 'codex', label: 'ChatGPT' }] }));
assert.equal(s.healthToastOpen, true);
assert.deepEqual(s.healthDead, [{ provider: 'codex', label: 'ChatGPT' }]);
});
test('an empty verdict closes nothing the user already dismissed and opens nothing', () => {
let s = reducer(undefined, healthReported({ dead: [{ provider: 'codex', label: 'ChatGPT' }] }));
s = reducer(s, hideProviderHealthToast());
s = reducer(s, healthReported({ dead: [] }));
assert.equal(s.healthToastOpen, false);
assert.deepEqual(s.healthDead, []);
});
test('the socket routes subscriptions:health into the slice', () => {
const src = fs.readFileSync(path.join(process.cwd(), 'src/shared/ws/WebSocketManager.ts'), 'utf8');
assert.ok(src.includes("case 'subscriptions:health':"));
assert.ok(src.includes('store.dispatch(healthReported({ dead: data.dead }))'));
});
@@ -72,6 +72,11 @@ const subscriptionsSlice = createSlice({
state.status = action.payload;
},
// Optimistic: 9Router /providers lags /exchange, so refetching right after would clobber the just-connected state with stale data. The 30s poller reconciles.
// The backend re-probes a "mid-refresh" 401 on its own after the rotation window and pushes the verdict here; the boot-time fetch is long gone by then.
healthReported(state, action: PayloadAction<{ dead: DeadProvider[] }>) {
state.healthDead = action.payload.dead ?? [];
state.healthToastOpen = state.healthDead.length > 0 || state.healthCliMissing;
},
markSubscriptionConnected(state, action: PayloadAction<{ provider: string }>) {
if (!state.status) return;
const { provider } = action.payload;
@@ -113,7 +118,7 @@ const subscriptionsSlice = createSlice({
},
});
export const { setSubscriptionStatus, markSubscriptionConnected, hideProviderHealthToast } = subscriptionsSlice.actions;
export const { setSubscriptionStatus, markSubscriptionConnected, hideProviderHealthToast, healthReported } = subscriptionsSlice.actions;
// Stable empty ref so the selector doesn't hand back a fresh [] each call (forces needless rerenders).
const EMPTY_CONNECTIONS: SubscriptionConnection[] = [];
@@ -34,6 +34,7 @@ import {
} from '../state/agentsSlice';
import { streamStart, streamSnapshot, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice';
import { fetchToolStatus } from '../state/toolsSlice';
import { healthReported } from '../state/subscriptionsSlice';
import { remountAppPreview } from '../state/outputsSlice';
import { BackgroundDeltaBuffer } from './BackgroundDeltaBuffer';
import { interactionActive, installInteractionListeners } from '../interactionPriority';
@@ -657,6 +658,11 @@ class WebSocketManager {
}
break;
case 'subscriptions:health':
if (Array.isArray(data.dead)) {
store.dispatch(healthReported({ dead: data.dead }));
}
break;
case 'tools:updated':
// A connector's auth state changed on the backend (an OAuth claim landed, a disconnect); refetch it and tell the Tools page.
if (data.tool_id) {