1631509989
When a user cancels a generation (ESC x2), all three idle hooks could enter a permanently broken state: 1. **todo-continuation-enforcer**: consecutiveFailures accumulated from abort-caused promptAsync failures, eventually hitting MAX_CONSECUTIVE_FAILURES and permanently stopping continuation injection. 2. **unstable-agent-babysitter**: no abort awareness at all — would keep firing reminders after user cancelled the session. 3. **runtime-fallback**: retry dedupe keys and pending fallback state persisted across cancellation, blocking legitimate error recovery. Fix: - Add shared `isAbortError()` utility for consistent abort detection - Reset consecutiveFailures and clear stale state on AbortError in all hooks - Track `lastCancelledAt` in todo-continuation-enforcer for abort window - Add abort-awareness to unstable-agent-babysitter (skip if recently cancelled) - Clear runtime-fallback retry state on abort errors Tests: 61 pass, 0 fail across all 3 affected hook test suites. Closes #2984
166 lines
6.3 KiB
TypeScript
166 lines
6.3 KiB
TypeScript
import { describe, expect, it } from "bun:test"
|
|
import type { HookDeps, RuntimeFallbackPluginInput } from "./types"
|
|
import type { AutoRetryHelpers } from "./auto-retry"
|
|
import { createFallbackState } from "./fallback-state"
|
|
import { createEventHandler } from "./event-handler"
|
|
|
|
function createContext(): RuntimeFallbackPluginInput {
|
|
return {
|
|
client: {
|
|
session: {
|
|
abort: async () => ({}),
|
|
messages: async () => ({ data: [] }),
|
|
promptAsync: async () => ({}),
|
|
},
|
|
tui: {
|
|
showToast: async () => ({}),
|
|
},
|
|
},
|
|
directory: "/test/dir",
|
|
}
|
|
}
|
|
|
|
function createDeps(): HookDeps {
|
|
return {
|
|
ctx: createContext(),
|
|
config: {
|
|
enabled: true,
|
|
retry_on_errors: [429, 503, 529],
|
|
max_fallback_attempts: 3,
|
|
cooldown_seconds: 60,
|
|
timeout_seconds: 30,
|
|
notify_on_fallback: false,
|
|
},
|
|
options: undefined,
|
|
pluginConfig: {},
|
|
sessionStates: new Map(),
|
|
sessionLastAccess: new Map(),
|
|
sessionRetryInFlight: new Set(),
|
|
sessionAwaitingFallbackResult: new Set(),
|
|
sessionFallbackTimeouts: new Map(),
|
|
sessionStatusRetryKeys: new Map(),
|
|
}
|
|
}
|
|
|
|
function createHelpers(deps: HookDeps, abortCalls: string[], clearCalls: string[]): AutoRetryHelpers {
|
|
return {
|
|
abortSessionRequest: async (sessionID: string) => {
|
|
abortCalls.push(sessionID)
|
|
},
|
|
clearSessionFallbackTimeout: (sessionID: string) => {
|
|
clearCalls.push(sessionID)
|
|
deps.sessionFallbackTimeouts.delete(sessionID)
|
|
},
|
|
scheduleSessionFallbackTimeout: () => {},
|
|
autoRetryWithFallback: async () => {},
|
|
resolveAgentForSessionFromContext: async () => undefined,
|
|
cleanupStaleSessions: () => {},
|
|
}
|
|
}
|
|
|
|
describe("createEventHandler", () => {
|
|
it("#given a session retry dedupe key #when session.stop fires #then the retry dedupe key is cleared", async () => {
|
|
// given
|
|
const sessionID = "session-stop"
|
|
const deps = createDeps()
|
|
const abortCalls: string[] = []
|
|
const clearCalls: string[] = []
|
|
const state = createFallbackState("google/gemini-2.5-pro")
|
|
state.pendingFallbackModel = "openai/gpt-5.4"
|
|
deps.sessionStates.set(sessionID, state)
|
|
deps.sessionRetryInFlight.add(sessionID)
|
|
deps.sessionStatusRetryKeys.set(sessionID, "retry:1")
|
|
const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls))
|
|
|
|
// when
|
|
await handler({ event: { type: "session.stop", properties: { sessionID } } })
|
|
|
|
// then
|
|
expect(deps.sessionStatusRetryKeys.has(sessionID)).toBe(false)
|
|
expect(clearCalls).toEqual([sessionID])
|
|
expect(abortCalls).toEqual([sessionID])
|
|
})
|
|
|
|
it("#given a session retry dedupe key without a pending fallback result #when session.idle fires #then the retry dedupe key is cleared", async () => {
|
|
// given
|
|
const sessionID = "session-idle"
|
|
const deps = createDeps()
|
|
const abortCalls: string[] = []
|
|
const clearCalls: string[] = []
|
|
const state = createFallbackState("google/gemini-2.5-pro")
|
|
state.pendingFallbackModel = "openai/gpt-5.4"
|
|
deps.sessionStates.set(sessionID, state)
|
|
deps.sessionRetryInFlight.add(sessionID)
|
|
deps.sessionFallbackTimeouts.set(sessionID, 1)
|
|
deps.sessionStatusRetryKeys.set(sessionID, "retry:1")
|
|
const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls))
|
|
|
|
// when
|
|
await handler({ event: { type: "session.idle", properties: { sessionID } } })
|
|
|
|
// then
|
|
expect(deps.sessionStatusRetryKeys.has(sessionID)).toBe(false)
|
|
expect(clearCalls).toEqual([sessionID])
|
|
expect(abortCalls).toEqual([])
|
|
expect(state.pendingFallbackModel).toBe(undefined)
|
|
})
|
|
|
|
it("#given a cancelled session #when session.error receives an abort error #then fallback retry state is reset", async () => {
|
|
const sessionID = "session-cancelled"
|
|
const deps = createDeps()
|
|
const abortCalls: string[] = []
|
|
const clearCalls: string[] = []
|
|
const state = createFallbackState("google/gemini-2.5-pro")
|
|
state.currentModel = "openai/gpt-5.4"
|
|
state.fallbackIndex = 1
|
|
state.attemptCount = 2
|
|
state.pendingFallbackModel = "openai/gpt-5.4"
|
|
state.failedModels.set("google/gemini-2.5-pro", Date.now())
|
|
deps.sessionStates.set(sessionID, state)
|
|
deps.sessionRetryInFlight.add(sessionID)
|
|
deps.sessionAwaitingFallbackResult.add(sessionID)
|
|
deps.sessionStatusRetryKeys.set(sessionID, "retry:2")
|
|
const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls))
|
|
|
|
await handler({ event: { type: "session.error", properties: { sessionID, error: { name: "AbortError" } } } })
|
|
|
|
const resetState = deps.sessionStates.get(sessionID)
|
|
expect(resetState?.originalModel).toBe("google/gemini-2.5-pro")
|
|
expect(resetState?.currentModel).toBe("google/gemini-2.5-pro")
|
|
expect(resetState?.fallbackIndex).toBe(-1)
|
|
expect(resetState?.attemptCount).toBe(0)
|
|
expect(resetState?.pendingFallbackModel).toBe(undefined)
|
|
expect(resetState?.failedModels.size).toBe(0)
|
|
expect(deps.sessionRetryInFlight.has(sessionID)).toBe(false)
|
|
expect(deps.sessionAwaitingFallbackResult.has(sessionID)).toBe(false)
|
|
expect(deps.sessionStatusRetryKeys.has(sessionID)).toBe(false)
|
|
expect(clearCalls).toEqual([sessionID])
|
|
expect(abortCalls).toEqual([])
|
|
})
|
|
|
|
it("#given a cancelled session #when session.idle fires #then fallback retry state stays cleared", async () => {
|
|
const sessionID = "session-cancelled-idle"
|
|
const deps = createDeps()
|
|
const abortCalls: string[] = []
|
|
const clearCalls: string[] = []
|
|
const state = createFallbackState("google/gemini-2.5-pro")
|
|
state.currentModel = "openai/gpt-5.4"
|
|
state.fallbackIndex = 1
|
|
state.attemptCount = 2
|
|
state.pendingFallbackModel = "openai/gpt-5.4"
|
|
deps.sessionStates.set(sessionID, state)
|
|
const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls))
|
|
|
|
await handler({ event: { type: "session.error", properties: { sessionID, error: { name: "MessageAbortedError" } } } })
|
|
clearCalls.length = 0
|
|
|
|
await handler({ event: { type: "session.idle", properties: { sessionID } } })
|
|
|
|
const resetState = deps.sessionStates.get(sessionID)
|
|
expect(resetState?.currentModel).toBe("google/gemini-2.5-pro")
|
|
expect(resetState?.attemptCount).toBe(0)
|
|
expect(clearCalls).toEqual([sessionID])
|
|
expect(abortCalls).toEqual([])
|
|
})
|
|
})
|