Skip to content

Commit 00422b8

Browse files
committed
fix(fallback): suppress stale active toasts
1 parent 01be307 commit 00422b8

File tree

9 files changed

+107
-10
lines changed

9 files changed

+107
-10
lines changed

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ OpenCode plugin that adds ordered model fallback chains with a health state mach
99
## Commands
1010

1111
```bash
12-
bun test # Run all unit tests (163 tests across 16 files)
12+
bun test # Run all unit tests (166 tests across 16 files)
1313
bunx tsc --noEmit # Type check
1414
bun run build # Build to dist/
1515
```
@@ -133,7 +133,7 @@ Unit tests live in `test/`. Run with `bun test`.
133133

134134
Integration tests for the replay orchestrator and full fallback flow exist in `test/orchestrator.test.ts`, using the mock client helper in `test/helpers/mock-client.ts`. Preemptive redirect tests are in `test/preemptive.test.ts`.
135135

136-
Plugin event-handler hardening tests are in `test/plugin.test.ts`. `/fallback-status` tool output tests are in `test/fallback-status.test.ts`.
136+
Plugin event-handler hardening tests are in `test/plugin.test.ts`, including fallback-active and recovery notification guard behavior. `/fallback-status` tool output tests are in `test/fallback-status.test.ts`.
137137

138138
Additional coverage includes startup command bootstrap (`test/plugin-create.test.ts`), logger redaction/fault-tolerance (`test/logger.test.ts`), fallback usage aggregation (`test/usage.test.ts`), and health-store timer lifecycle (`test/model-health-lifecycle.test.ts`).
139139

Implementation.plan.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,15 @@ interface ModelHealth {
107107
interface SessionFallbackState {
108108
sessionId: string;
109109
agentName: string | null; // resolved lazily from messages API
110+
agentFile: string | null;
110111
originalModel: ModelKey | null;
111112
currentModel: ModelKey | null;
112113
fallbackDepth: number;
113114
isProcessing: boolean; // mutex for replay operations
114115
lastFallbackAt: number | null; // deduplication window
115116
fallbackHistory: FallbackEvent[]; // for /fallback-status reporting
117+
recoveryNotifiedForModel: ModelKey | null;
118+
fallbackActiveNotifiedKey: string | null; // dedupe repeated "Fallback Active" toasts
116119
}
117120
```
118121

@@ -127,8 +130,9 @@ chat.message hook fires (new user message)
127130
├─ Check model health: is target model rate_limited?
128131
│ └─ No → return (message proceeds normally)
129132
130-
├─ Resolve fallback chain → pick healthy model
133+
├─ Resolve fallback chain (exact or normalized agent name) → pick healthy model
131134
├─ Mutate output.message.model → redirect to fallback
135+
├─ Notify once per (original,current) fallback pair while fallback remains active
132136
└─ Message goes directly to fallback model (no 429 round-trip)
133137
```
134138

@@ -145,7 +149,7 @@ session.status event (type: "retry", message: "Rate limited...")
145149
├─ Check deduplication window (3s since lastFallbackAt)
146150
147151
├─ Resolve agent name (from cache or client.session.messages())
148-
├─ Look up fallback chain: config.agents[agentName] ?? config.agents["*"]
152+
├─ Look up fallback chain: exact agent match → normalized agent match → config.agents["*"]
149153
150154
├─ Fetch messages → sync currentModel (detect TUI revert → reset fallbackDepth)
151155
├─ Check maxFallbackDepth not exceeded (after sync so reset takes effect)
@@ -322,7 +326,7 @@ Addresses two problems: wasted 429 round-trips per message after a successful fa
322326

323327
## Verification Plan
324328

325-
1. **Unit tests** (per module): config validation, pattern matching, classification, health transitions, chain resolution, message conversion, agent loader, preemptive redirect, plugin events, plugin startup bootstrap, logger redaction/fault tolerance, usage aggregation, fallback-status tool, tick recovery transitions, health timer lifecycle, path traversal security, YAML schema enforcement — **163/163 passing**
329+
1. **Unit tests** (per module): config validation, pattern matching, classification, health transitions, chain resolution, message conversion, agent loader, preemptive redirect, plugin events, plugin startup bootstrap, logger redaction/fault tolerance, usage aggregation, fallback-status tool, tick recovery transitions, health timer lifecycle, path traversal security, YAML schema enforcement — **166/166 passing**
326330
2. **Integration tests** (mock client): full fallback flow, cascading, max depth, concurrent events, session deletion — **complete**
327331
3. **Manual E2E test**: Install as local plugin, configure fallback chains, trigger rate limit, verify:
328332
- Detection logged correctly

src/plugin.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,13 @@ export const createPlugin: Plugin = async ({ client, directory }) => {
109109
});
110110
}
111111

112-
// Remind user on each turn while running on a fallback model
113-
const current = sessionState.currentModel;
114-
const original = sessionState.originalModel;
115-
if (current && original && current !== original) {
116-
notifyFallbackActive(client, original, current).catch(() => {});
112+
const activeFallback = store.sessions.consumeFallbackActiveNotification(input.sessionID);
113+
if (activeFallback) {
114+
notifyFallbackActive(
115+
client,
116+
activeFallback.originalModel,
117+
activeFallback.currentModel
118+
).catch(() => {});
117119
}
118120
},
119121

@@ -287,6 +289,7 @@ export async function handleIdle(
287289
if (!state.originalModel) return;
288290
if (state.currentModel === state.originalModel) {
289291
state.recoveryNotifiedForModel = null;
292+
state.fallbackActiveNotifiedKey = null;
290293
return;
291294
}
292295

src/preemptive.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export function tryPreemptiveRedirect(
3535
sessionState.currentModel = modelKey;
3636
if (wasOnFallback && modelKey === sessionState.originalModel) {
3737
sessionState.fallbackDepth = 0;
38+
store.sessions.clearFallbackActiveNotification(sessionId);
3839
logger.debug("preemptive.depth.reset", { sessionId, modelKey });
3940
}
4041
}

src/resolution/agent-resolver.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import type { ModelKey, PluginConfig } from "../types.js";
33

44
type Client = PluginInput["client"];
55

6+
function normalizeAgentName(agentName: string): string {
7+
const compact = agentName.toLowerCase().replace(/[^a-z0-9]/g, "");
8+
return compact.endsWith("agent") ? compact.slice(0, -5) : compact;
9+
}
10+
611
/**
712
* Map a session to its agent name, then to the fallback config for that agent.
813
* Falls back to wildcard "*" agent config.
@@ -34,5 +39,16 @@ export function resolveFallbackModels(config: PluginConfig, agentName: string |
3439
if (agentName && config.agents[agentName]) {
3540
return config.agents[agentName].fallbackModels;
3641
}
42+
43+
if (agentName) {
44+
const normalized = normalizeAgentName(agentName);
45+
const matches = Object.entries(config.agents).filter(
46+
([name]) => name !== "*" && normalizeAgentName(name) === normalized
47+
);
48+
if (matches.length === 1) {
49+
return matches[0][1].fallbackModels;
50+
}
51+
}
52+
3753
return config.agents["*"]?.fallbackModels ?? [];
3854
}

src/state/session-state.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import type { ErrorCategory, FallbackEvent, ModelKey, SessionFallbackState } fro
33
export class SessionStateStore {
44
private store = new Map<string, SessionFallbackState>();
55

6+
private getFallbackActiveKey(originalModel: ModelKey, currentModel: ModelKey): string {
7+
return `${originalModel}->${currentModel}`;
8+
}
9+
610
get(sessionId: string): SessionFallbackState {
711
let state = this.store.get(sessionId);
812
if (!state) {
@@ -86,9 +90,31 @@ export class SessionStateStore {
8690
if (!state.originalModel) {
8791
state.originalModel = model;
8892
state.currentModel = model;
93+
state.fallbackActiveNotifiedKey = null;
8994
}
9095
}
9196

97+
consumeFallbackActiveNotification(
98+
sessionId: string
99+
): { originalModel: ModelKey; currentModel: ModelKey } | null {
100+
const state = this.get(sessionId);
101+
const { originalModel, currentModel } = state;
102+
103+
if (!originalModel || !currentModel || originalModel === currentModel) return null;
104+
105+
const key = this.getFallbackActiveKey(originalModel, currentModel);
106+
if (state.fallbackActiveNotifiedKey === key) return null;
107+
108+
state.fallbackActiveNotifiedKey = key;
109+
return { originalModel, currentModel };
110+
}
111+
112+
clearFallbackActiveNotification(sessionId: string): void {
113+
const state = this.store.get(sessionId);
114+
if (!state) return;
115+
state.fallbackActiveNotifiedKey = null;
116+
}
117+
92118
setAgentName(sessionId: string, agentName: string): void {
93119
const state = this.get(sessionId);
94120
state.agentName = agentName;
@@ -105,6 +131,7 @@ export class SessionStateStore {
105131
state.fallbackHistory = [];
106132
state.lastFallbackAt = null;
107133
state.isProcessing = false;
134+
state.fallbackActiveNotifiedKey = null;
108135
// Preserves: originalModel, currentModel, agentName, fallbackDepth
109136
}
110137

@@ -128,6 +155,7 @@ export class SessionStateStore {
128155
lastFallbackAt: null,
129156
fallbackHistory: [],
130157
recoveryNotifiedForModel: null,
158+
fallbackActiveNotifiedKey: null,
131159
};
132160
}
133161
}

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface SessionFallbackState {
4040
lastFallbackAt: number | null;
4141
fallbackHistory: FallbackEvent[];
4242
recoveryNotifiedForModel: ModelKey | null;
43+
fallbackActiveNotifiedKey: string | null;
4344
}
4445

4546
export interface AgentConfig {

test/fallback.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,21 @@ describe("resolveFallbackModels", () => {
112112
expect(result).toEqual(["a/1", "a/2"]);
113113
});
114114

115+
it("matches normalized agent names like Build -> BuildAgent", () => {
116+
const result = resolveFallbackModels(
117+
{
118+
...config,
119+
agents: {
120+
BuildAgent: { fallbackModels: ["c/1", "c/2"] },
121+
"*": { fallbackModels: ["b/1"] },
122+
},
123+
},
124+
"Build"
125+
);
126+
127+
expect(result).toEqual(["c/1", "c/2"]);
128+
});
129+
115130
it("falls back to wildcard when agent not configured", () => {
116131
const result = resolveFallbackModels(config, "coder");
117132
expect(result).toEqual(["b/1"]);
@@ -204,4 +219,32 @@ describe("SessionStateStore", () => {
204219
const state = store.get("s9");
205220
expect(state.fallbackDepth).toBe(1);
206221
});
222+
223+
it("consumes fallback-active notification only once per fallback pair", () => {
224+
const store = new SessionStateStore();
225+
store.setOriginalModel("s10", "a/orig");
226+
store.recordPreemptiveRedirect("s10", "a/orig", "a/fallback", "coder");
227+
228+
expect(store.consumeFallbackActiveNotification("s10")).toEqual({
229+
originalModel: "a/orig",
230+
currentModel: "a/fallback",
231+
});
232+
expect(store.consumeFallbackActiveNotification("s10")).toBeNull();
233+
});
234+
235+
it("clears fallback-active notification after returning to original model", () => {
236+
const store = new SessionStateStore();
237+
store.setOriginalModel("s11", "a/orig");
238+
store.recordPreemptiveRedirect("s11", "a/orig", "a/fallback", "coder");
239+
240+
expect(store.consumeFallbackActiveNotification("s11")).not.toBeNull();
241+
store.clearFallbackActiveNotification("s11");
242+
store.get("s11").currentModel = "a/orig";
243+
store.recordPreemptiveRedirect("s11", "a/orig", "a/fallback", "coder");
244+
245+
expect(store.consumeFallbackActiveNotification("s11")).toEqual({
246+
originalModel: "a/orig",
247+
currentModel: "a/fallback",
248+
});
249+
});
207250
});

test/usage.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ function baseState(): SessionFallbackState {
1515
lastFallbackAt: null,
1616
fallbackHistory: [],
1717
recoveryNotifiedForModel: null,
18+
fallbackActiveNotifiedKey: null,
1819
};
1920
}
2021

0 commit comments

Comments
 (0)