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?: unknown } } afterEach(() => { _resetForTesting() }) describe("createEventHandler - idle deduplication", () => { it("Order A (status→idle): synthetic idle deduped - real idle not dispatched again", async () => { //#given const dispatchCalls: EventInput[] = [] const mockDispatchToHooks = async (input: EventInput) => { if (input.event.type === "session.idle") { dispatchCalls.push(input) } } const eventHandler = createEventHandler({ ctx: {} as any, pluginConfig: {} as any, firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, }, managers: { tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, }, } as any, hooks: { autoUpdateChecker: { event: mockDispatchToHooks as any }, claudeCodeHooks: { event: async () => {} }, backgroundNotificationHook: { event: async () => {} }, sessionNotification: async () => {}, todoContinuationEnforcer: { handler: async () => {} }, unstableAgentBabysitter: { event: async () => {} }, contextWindowMonitor: { event: async () => {} }, directoryAgentsInjector: { event: async () => {} }, directoryReadmeInjector: { event: async () => {} }, rulesInjector: { event: async () => {} }, thinkMode: { event: async () => {} }, anthropicContextWindowLimitRecovery: { event: async () => {} }, agentUsageReminder: { event: async () => {} }, categorySkillReminder: { event: async () => {} }, interactiveBashSession: { event: async () => {} }, ralphLoop: { event: async () => {} }, stopContinuationGuard: { event: async () => {} }, compactionTodoPreserver: { event: async () => {} }, atlasHook: { handler: async () => {} }, } as any, }) const sessionId = "ses_test123" //#when - session.status with idle (generates synthetic idle first) await eventHandler({ event: { type: "session.status", properties: { sessionID: sessionId, status: { type: "idle" }, }, }, }) //#then - synthetic idle dispatched once expect(dispatchCalls.length).toBe(1) expect(dispatchCalls[0].event.type).toBe("session.idle") expect((dispatchCalls[0].event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) //#when - real session.idle arrives await eventHandler({ event: { type: "session.idle", properties: { sessionID: sessionId, }, }, }) //#then - real idle deduped, no additional dispatch expect(dispatchCalls.length).toBe(1) }) it("Order B (idle→status): real idle deduped - synthetic idle not dispatched", async () => { //#given const dispatchCalls: EventInput[] = [] const mockDispatchToHooks = async (input: EventInput) => { if (input.event.type === "session.idle") { dispatchCalls.push(input) } } const eventHandler = createEventHandler({ ctx: {} as any, pluginConfig: {} as any, firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, }, managers: { tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, }, } as any, hooks: { autoUpdateChecker: { event: mockDispatchToHooks as any }, claudeCodeHooks: { event: async () => {} }, backgroundNotificationHook: { event: async () => {} }, sessionNotification: async () => {}, todoContinuationEnforcer: { handler: async () => {} }, unstableAgentBabysitter: { event: async () => {} }, contextWindowMonitor: { event: async () => {} }, directoryAgentsInjector: { event: async () => {} }, directoryReadmeInjector: { event: async () => {} }, rulesInjector: { event: async () => {} }, thinkMode: { event: async () => {} }, anthropicContextWindowLimitRecovery: { event: async () => {} }, agentUsageReminder: { event: async () => {} }, categorySkillReminder: { event: async () => {} }, interactiveBashSession: { event: async () => {} }, ralphLoop: { event: async () => {} }, stopContinuationGuard: { event: async () => {} }, compactionTodoPreserver: { event: async () => {} }, atlasHook: { handler: async () => {} }, } as any, }) const sessionId = "ses_test456" //#when - real session.idle arrives first await eventHandler({ event: { type: "session.idle", properties: { sessionID: sessionId, }, }, }) //#then - real idle dispatched once expect(dispatchCalls.length).toBe(1) expect(dispatchCalls[0].event.type).toBe("session.idle") expect((dispatchCalls[0].event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) //#when - session.status with idle (generates synthetic idle) await eventHandler({ event: { type: "session.status", properties: { sessionID: sessionId, status: { type: "idle" }, }, }, }) //#then - synthetic idle deduped, no additional dispatch expect(dispatchCalls.length).toBe(1) }) it("both maps pruned on every event", async () => { //#given const eventHandler = createEventHandler({ ctx: {} as any, pluginConfig: {} as any, firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, }, managers: { tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, }, } as any, hooks: { autoUpdateChecker: { event: async () => {} }, claudeCodeHooks: { event: async () => {} }, backgroundNotificationHook: { event: async () => {} }, sessionNotification: async () => {}, todoContinuationEnforcer: { handler: async () => {} }, unstableAgentBabysitter: { event: async () => {} }, contextWindowMonitor: { event: async () => {} }, directoryAgentsInjector: { event: async () => {} }, directoryReadmeInjector: { event: async () => {} }, rulesInjector: { event: async () => {} }, thinkMode: { event: async () => {} }, anthropicContextWindowLimitRecovery: { event: async () => {} }, agentUsageReminder: { event: async () => {} }, categorySkillReminder: { event: async () => {} }, interactiveBashSession: { event: async () => {} }, ralphLoop: { event: async () => {} }, stopContinuationGuard: { event: async () => {} }, compactionTodoPreserver: { event: async () => {} }, atlasHook: { handler: async () => {} }, } as any, }) // Trigger some synthetic idles await eventHandler({ event: { type: "session.status", properties: { sessionID: "ses_stale_1", status: { type: "idle" }, }, }, }) await eventHandler({ event: { type: "session.status", properties: { sessionID: "ses_stale_2", status: { type: "idle" }, }, }, }) // Trigger some real idles await eventHandler({ event: { type: "session.idle", properties: { sessionID: "ses_stale_3", }, }, }) await eventHandler({ event: { type: "session.idle", properties: { sessionID: "ses_stale_4", }, }, }) //#when - wait for dedup window to expire (600ms > 500ms) await new Promise((resolve) => setTimeout(resolve, 600)) // Trigger any event to trigger pruning await eventHandler({ 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 const dispatchCalls: EventInput[] = [] const eventHandlerWithMock = createEventHandler({ ctx: {} as any, pluginConfig: {} as any, firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, }, managers: { tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, }, } as any, hooks: { autoUpdateChecker: { event: async (input: EventInput) => { dispatchCalls.push(input) }, }, claudeCodeHooks: { event: async () => {} }, backgroundNotificationHook: { event: async () => {} }, sessionNotification: async () => {}, todoContinuationEnforcer: { handler: async () => {} }, unstableAgentBabysitter: { event: async () => {} }, contextWindowMonitor: { event: async () => {} }, directoryAgentsInjector: { event: async () => {} }, directoryReadmeInjector: { event: async () => {} }, rulesInjector: { event: async () => {} }, thinkMode: { event: async () => {} }, anthropicContextWindowLimitRecovery: { event: async () => {} }, agentUsageReminder: { event: async () => {} }, categorySkillReminder: { event: async () => {} }, interactiveBashSession: { event: async () => {} }, ralphLoop: { event: async () => {} }, stopContinuationGuard: { event: async () => {} }, compactionTodoPreserver: { event: async () => {} }, atlasHook: { handler: async () => {} }, } as any, }) await eventHandlerWithMock({ event: { type: "session.idle", properties: { sessionID: "ses_stale_1", }, }, }) expect(dispatchCalls.length).toBe(1) expect(dispatchCalls[0].event.type).toBe("session.idle") }) it("dedup only applies within window - outside window both dispatch", async () => { //#given const dispatchCalls: EventInput[] = [] const eventHandler = createEventHandler({ ctx: {} as any, pluginConfig: {} as any, firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, }, managers: { tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async () => {}, }, } as any, hooks: { autoUpdateChecker: { event: async (input: EventInput) => { if (input.event.type === "session.idle") { dispatchCalls.push(input) } }, }, claudeCodeHooks: { event: async () => {} }, backgroundNotificationHook: { event: async () => {} }, sessionNotification: async () => {}, todoContinuationEnforcer: { handler: async () => {} }, unstableAgentBabysitter: { event: async () => {} }, contextWindowMonitor: { event: async () => {} }, directoryAgentsInjector: { event: async () => {} }, directoryReadmeInjector: { event: async () => {} }, rulesInjector: { event: async () => {} }, thinkMode: { event: async () => {} }, anthropicContextWindowLimitRecovery: { event: async () => {} }, agentUsageReminder: { event: async () => {} }, categorySkillReminder: { event: async () => {} }, interactiveBashSession: { event: async () => {} }, ralphLoop: { event: async () => {} }, stopContinuationGuard: { event: async () => {} }, compactionTodoPreserver: { event: async () => {} }, atlasHook: { handler: async () => {} }, } as any, }) const sessionId = "ses_outside_window" //#when - synthetic idle first await eventHandler({ event: { type: "session.status", properties: { sessionID: sessionId, status: { type: "idle" }, }, }, }) //#then - synthetic dispatched expect(dispatchCalls.length).toBe(1) //#when - wait for dedup window to expire (600ms > 500ms) await new Promise((resolve) => setTimeout(resolve, 600)) //#when - real idle arrives outside window await eventHandler({ event: { type: "session.idle", properties: { sessionID: sessionId, }, }, }) //#then - real idle dispatched (outside dedup window) expect(dispatchCalls.length).toBe(2) expect(dispatchCalls[0].event.type).toBe("session.idle") expect(dispatchCalls[1].event.type).toBe("session.idle") }) }) describe("createEventHandler - event forwarding", () => { it("forwards session.deleted to write-existing-file-guard hook", async () => { //#given const forwardedEvents: EventInput[] = [] const disconnectedSessions: string[] = [] const deletedSessions: string[] = [] const eventHandler = createEventHandler({ ctx: {} as never, pluginConfig: {} as never, firstMessageVariantGate: { markSessionCreated: () => {}, clear: () => {}, }, managers: { skillMcpManager: { disconnectSession: async (sessionID: string) => { disconnectedSessions.push(sessionID) }, }, tmuxSessionManager: { onSessionCreated: async () => {}, onSessionDeleted: async ({ sessionID }: { sessionID: string }) => { deletedSessions.push(sessionID) }, }, } as never, hooks: { writeExistingFileGuard: { event: async (input: EventInput) => { forwardedEvents.push(input) }, }, } as never, }) const sessionID = "ses_forward_delete_event" //#when await eventHandler({ event: { type: "session.deleted", properties: { info: { id: sessionID } }, }, } as any) //#then expect(forwardedEvents.length).toBe(1) expect(forwardedEvents[0]?.event.type).toBe("session.deleted") expect(disconnectedSessions).toEqual([sessionID]) 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]) }) })