[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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012G8kyALnPjsA7aJFmMBq3R
This commit is contained in:
ciregenz
2026-09-02 01:41:31 -07:00
co-authored by Claude Fable 5.1
parent 5e4d7cd096
commit 8acfe2c15e
4 changed files with 52 additions and 15 deletions
+3 -2
View File
@@ -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
@@ -26,7 +26,7 @@ const COPY: Record<SelfHealKind, { text: string; why: (outstandingS: number | nu
export const SelfHealPill: React.FC<{ sessionId: string }> = ({ 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;
+16 -12
View File
@@ -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<string, AgentSession>;
// 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<string, SelfHeal>;
history: Record<string, HistorySession>;
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(
@@ -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);
});