From 8acfe2c15e5d3d7006439378e0fce3e0a59666b9 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 2 Sep 2026 01:41:31 -0700 Subject: [PATCH] [eric] frontend: the self-heal pill's flag lives beside the sessions, so a server refresh cannot wipe it before it shows Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012G8kyALnPjsA7aJFmMBq3R --- backend/apps/agents/core/fault_injection.py | 5 +-- .../pages/AgentChat/shell/SelfHealPill.tsx | 2 +- frontend/src/shared/state/agentsSlice.ts | 28 +++++++++------- .../state/selfHealSurvivesRefresh.test.ts | 32 +++++++++++++++++++ 4 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 frontend/src/shared/state/selfHealSurvivesRefresh.test.ts diff --git a/backend/apps/agents/core/fault_injection.py b/backend/apps/agents/core/fault_injection.py index 11f4fe26..f605512b 100644 --- a/backend/apps/agents/core/fault_injection.py +++ b/backend/apps/agents/core/fault_injection.py @@ -83,8 +83,9 @@ def squeezed_context_window() -> int: if n < VALVE_ELIGIBLE_WINDOW: logger.warning( "cli_context_squeeze window %d puts the compaction trigger under a turn's ~%d-token " - "baseline, so OUR mid-turn breaker is INELIGIBLE and cannot fire at any input. This " - "run can reproduce the CLI's autocompact thrash; it can say nothing about our valve.", + "baseline, so the breaker's CROSSING rule is INELIGIBLE (every reading already sits above " + "the trigger); only its 20,000-token growth rule can fire here, so a break in this run " + "proves that half alone. The run still reproduces the CLI's autocompact thrash.", n, TURN_BASELINE_TOKENS, ) return n diff --git a/frontend/src/app/pages/AgentChat/shell/SelfHealPill.tsx b/frontend/src/app/pages/AgentChat/shell/SelfHealPill.tsx index 0db26056..95c6b0f5 100644 --- a/frontend/src/app/pages/AgentChat/shell/SelfHealPill.tsx +++ b/frontend/src/app/pages/AgentChat/shell/SelfHealPill.tsx @@ -26,7 +26,7 @@ const COPY: Record = ({ sessionId }) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); - const heal = useAppSelector((s) => s.agents.sessions[sessionId]?.self_heal); + const heal = useAppSelector((s) => s.agents.selfHeals[sessionId]); useEffect(() => { if (!heal) return; diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 4908ec9f..c3e8b6e7 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -125,8 +125,6 @@ export interface AgentSession { // Parked waiting for the connection back; unlike the pills above this can last minutes, so the UI has to say so. reconnect_wait?: { retry_in_s: number | null; attempt: number | null; at: string } | null; provider_retrying?: { attempt: number | null; delay_ms: number | null; at: string } | null; - // One transient "OpenSwarm healed something mid-turn" pill; the kind picks the wording. - self_heal?: { kind: SelfHealKind; at: string; outstanding_s: number | null } | null; // Set when a view-builder turn installed/changed deps, so the app card does a HARD reload (Vite restart) at turn-finish instead of the soft one. Reset when the next turn starts. app_deps_changed?: boolean; mcp_suggestions?: Array<{ id: string; title: string; description: string; reason?: string }>; @@ -176,8 +174,17 @@ interface HistorySearchState { loading: boolean; } +// One transient "OpenSwarm healed something mid-turn" pill per session; the kind picks the wording. +export interface SelfHeal { + kind: SelfHealKind; + at: string; + outstanding_s: number | null; +} + interface AgentsState { sessions: Record; + // Kept OUTSIDE the session objects on purpose: every server refresh replaces a session wholesale, and a flag stored on it died before the pill could mount. + selfHeals: Record; history: Record; activeSessionId: string | null; expandedSessionIds: string[]; @@ -190,6 +197,7 @@ interface AgentsState { const initialState: AgentsState = { sessions: {}, + selfHeals: {}, history: {}, activeSessionId: null, expandedSessionIds: [], @@ -1102,19 +1110,15 @@ const agentsSlice = createSlice({ state, action: PayloadAction<{ sessionId: string; kind: SelfHealKind; outstandingS?: number | null }> ) { - const session = state.sessions[action.payload.sessionId]; - if (session) { - session.self_heal = { - kind: action.payload.kind, - at: new Date().toISOString(), - outstanding_s: action.payload.outstandingS ?? null, - }; - } + state.selfHeals[action.payload.sessionId] = { + kind: action.payload.kind, + at: new Date().toISOString(), + outstanding_s: action.payload.outstandingS ?? null, + }; }, clearSelfHeal(state, action: PayloadAction<{ sessionId: string }>) { - const session = state.sessions[action.payload.sessionId]; - if (session) session.self_heal = null; + delete state.selfHeals[action.payload.sessionId]; }, clearContextOverflow( diff --git a/frontend/src/shared/state/selfHealSurvivesRefresh.test.ts b/frontend/src/shared/state/selfHealSurvivesRefresh.test.ts new file mode 100644 index 00000000..69892fca --- /dev/null +++ b/frontend/src/shared/state/selfHealSurvivesRefresh.test.ts @@ -0,0 +1,32 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import reducer, { clearSelfHeal, fetchSession, resumeSession, setSelfHeal, updateSession } from './agentsSlice'; +import type { AgentSession } from './agentsSlice'; + +// The pill's flag used to live ON the session object, and every server refresh replaces that object +// wholesale (fetchSession on expand, updateSession on each status frame, resumeSession), so a heal that +// landed while the card was collapsed was wiped before the pill could mount: the wire carried +// agent:tool_recovered, the screen showed nothing. The flag now lives beside the sessions, not in them. +function session(id: string): AgentSession { + return { id, name: 'F2', status: 'running', messages: [], pending_approvals: [], tool_group_meta: {} } as unknown as AgentSession; +} +const seeded = reducer(undefined, updateSession(session('s1'))); + +test('a heal survives every reducer that replaces the session object', () => { + let state = reducer(seeded, setSelfHeal({ sessionId: 's1', kind: 'tool_restarted', outstandingS: 25 })); + assert.equal(state.selfHeals.s1?.kind, 'tool_restarted'); + state = reducer(state, fetchSession.fulfilled(session('s1'), 'req', { sessionId: 's1' } as never)); + assert.equal(state.selfHeals.s1?.kind, 'tool_restarted', 'fetchSession (card expanded) kept it'); + state = reducer(state, updateSession(session('s1'))); + assert.equal(state.selfHeals.s1?.kind, 'tool_restarted', 'a status frame kept it'); + state = reducer(state, resumeSession.fulfilled(session('s1'), 'req', { sessionId: 's1' } as never)); + assert.equal(state.selfHeals.s1?.outstanding_s, 25, 'resume kept it'); + assert.ok(!('self_heal' in state.sessions.s1), 'nothing on the session object carries it any more'); +}); + +test('the pill clears it, and a heal for a card that is not on the board is still recorded', () => { + let state = reducer(seeded, setSelfHeal({ sessionId: 'not-loaded', kind: 'cli_compacted' })); + assert.equal(state.selfHeals['not-loaded']?.kind, 'cli_compacted'); + state = reducer(state, clearSelfHeal({ sessionId: 'not-loaded' })); + assert.equal(state.selfHeals['not-loaded'], undefined); +});