diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index 8db998c43..67089e2a5 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -1,8 +1,15 @@ -import { describe, it, expect } from "bun:test" +import { describe, it, expect, afterEach } from "bun:test" import { createEventHandler } from "./event" +import { createChatMessageHandler } from "./chat-message" +import { _resetForTesting, setMainSession } from "../features/claude-code-session-state" +import { clearPendingModelFallback, createModelFallbackHook } from "../hooks/model-fallback/hook" -type EventInput = { event: { type: string; properties?: Record } } +type EventInput = { event: { type: string; properties?: unknown } } + +afterEach(() => { + _resetForTesting() +}) describe("createEventHandler - idle deduplication", () => { it("Order A (status→idle): synthetic idle deduped - real idle not dispatched again", async () => { @@ -66,7 +73,7 @@ type EventInput = { event: { type: string; properties?: Record //#then - synthetic idle dispatched once expect(dispatchCalls.length).toBe(1) expect(dispatchCalls[0].event.type).toBe("session.idle") - expect(dispatchCalls[0].event.properties?.sessionID).toBe(sessionId) + expect((dispatchCalls[0].event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) //#when - real session.idle arrives await eventHandler({ @@ -142,7 +149,7 @@ type EventInput = { event: { type: string; properties?: Record //#then - real idle dispatched once expect(dispatchCalls.length).toBe(1) expect(dispatchCalls[0].event.type).toBe("session.idle") - expect(dispatchCalls[0].event.properties?.sessionID).toBe(sessionId) + expect((dispatchCalls[0].event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) //#when - session.status with idle (generates synthetic idle) await eventHandler({ @@ -245,7 +252,7 @@ type EventInput = { event: { type: string; properties?: Record event: { type: "message.updated", }, - }) + } as any) //#then - both maps should be pruned (no dedup should occur for new events) // We verify by checking that a new idle event for same session is dispatched @@ -287,7 +294,7 @@ type EventInput = { event: { type: string; properties?: Record stopContinuationGuard: { event: async () => {} }, compactionTodoPreserver: { event: async () => {} }, atlasHook: { handler: async () => {} }, - }, + } as any, }) await eventHandlerWithMock({ @@ -426,7 +433,7 @@ describe("createEventHandler - event forwarding", () => { type: "session.deleted", properties: { info: { id: sessionID } }, }, - }) + } as any) //#then expect(forwardedEvents.length).toBe(1) @@ -435,3 +442,146 @@ describe("createEventHandler - event forwarding", () => { expect(deletedSessions).toEqual([sessionID]) }) }) + +describe("createEventHandler - retry dedupe lifecycle", () => { + it("re-handles same retry key after session recovers to idle status", async () => { + //#given + const sessionID = "ses_retry_recovery_rearm" + setMainSession(sessionID) + clearPendingModelFallback(sessionID) + + const abortCalls: string[] = [] + const promptCalls: string[] = [] + const modelFallback = createModelFallbackHook() + + const eventHandler = createEventHandler({ + ctx: { + directory: "/tmp", + client: { + session: { + abort: async ({ path }: { path: { id: string } }) => { + abortCalls.push(path.id) + return {} + }, + prompt: async ({ path }: { path: { id: string } }) => { + promptCalls.push(path.id) + return {} + }, + }, + }, + } as any, + pluginConfig: {} as any, + firstMessageVariantGate: { + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: { + tmuxSessionManager: { + onSessionCreated: async () => {}, + onSessionDeleted: async () => {}, + }, + skillMcpManager: { + disconnectSession: async () => {}, + }, + } as any, + hooks: { + modelFallback, + stopContinuationGuard: { isStopped: () => false }, + } as any, + }) + + const chatMessageHandler = createChatMessageHandler({ + ctx: { + client: { + tui: { + showToast: async () => ({}), + }, + }, + } as any, + pluginConfig: {} as any, + firstMessageVariantGate: { + shouldOverride: () => false, + markApplied: () => {}, + }, + hooks: { + modelFallback, + stopContinuationGuard: null, + keywordDetector: null, + claudeCodeHooks: null, + autoSlashCommand: null, + startWork: null, + ralphLoop: null, + } as any, + }) + + const retryStatus = { + type: "retry", + attempt: 1, + message: "All credentials for model claude-opus-4-6-thinking are cooling down [retrying in 7m 56s attempt #1]", + next: 476, + } as const + + await eventHandler({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg_user_retry_rearm", + sessionID, + role: "user", + modelID: "claude-opus-4-6-thinking", + providerID: "anthropic", + agent: "Sisyphus (Ultraworker)", + }, + }, + }, + } as any) + + //#when - first retry key is handled + await eventHandler({ + event: { + type: "session.status", + properties: { + sessionID, + status: retryStatus, + }, + }, + } as any) + + const firstOutput = { message: {}, parts: [] as Array<{ type: string; text?: string }> } + await chatMessageHandler( + { + sessionID, + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" }, + }, + firstOutput, + ) + + //#when - session recovers to non-retry idle state + await eventHandler({ + event: { + type: "session.status", + properties: { + sessionID, + status: { type: "idle" }, + }, + }, + } as any) + + //#when - same retry key appears again after recovery + await eventHandler({ + event: { + type: "session.status", + properties: { + sessionID, + status: retryStatus, + }, + }, + } as any) + + //#then + expect(abortCalls).toEqual([sessionID, sessionID]) + expect(promptCalls).toEqual([sessionID, sessionID]) + }) +}) diff --git a/src/plugin/event.ts b/src/plugin/event.ts index d18ec3691..45765c025 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -421,6 +421,12 @@ export function createEventHandler(args: { const sessionID = props?.sessionID as string | undefined; const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined; + // Retry dedupe lifecycle: set key when a retry status is handled, clear it after recovery + // (non-retry idle) so future failures with the same key can trigger fallback again. + if (sessionID && status?.type === "idle") { + lastHandledRetryStatusKey.delete(sessionID); + } + if (sessionID && status?.type === "retry" && isModelFallbackEnabled && !isRuntimeFallbackEnabled) { try { const retryMessage = typeof status.message === "string" ? status.message : "";