From eb495793dbd03c8eaedff1f0a29f5cc0fa4a8929 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 12 Apr 2026 02:30:11 +0900 Subject: [PATCH] fix(event): prefer real session.idle over recent synthetic dedup (#2667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedup logic dropped real session.idle events when a synthetic idle had fired within the dedup window, losing data that downstream hooks needed. Now real idle events always reach dispatchToHooks while synthetic duplicates are still dropped when a real event came first. 🤖 Generated with OhMyOpenCode assistance https://github.com/code-yeongyu/oh-my-opencode --- src/plugin/event.test.ts | 163 ++++++++++++--------------------------- src/plugin/event.ts | 1 - 2 files changed, 49 insertions(+), 115 deletions(-) diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index f9a82da75..85223806c 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -63,61 +63,45 @@ function createChatMessageHandlerHooks( } as unknown as ChatMessageHandlerArgs["hooks"] } +function createIdleTrackingEventHandler(dispatchCalls: EventInput[]): ReturnType { + return createEventHandler({ + ctx: asEventHandlerContext({}), + pluginConfig: asPluginConfig({}), + firstMessageVariantGate: { + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: createEventHandlerManagers({ + skillMcpManager: { + disconnectSession: async () => {}, + }, + }), + hooks: createEventHandlerHooks({ + autoUpdateChecker: { + event: async (input: EventInput) => { + if (input.event.type === "session.idle") { + dispatchCalls.push(input) + } + }, + }, + }), + }) +} + afterEach(() => { mock.restore() _resetForTesting() }) describe("createEventHandler - idle deduplication", () => { - it("Order A (status→idle): synthetic idle deduped - real idle not dispatched again", async () => { + it("#given synthetic idle fires first #when real idle arrives within 500ms #then real idle 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 eventHandler = createIdleTrackingEventHandler(dispatchCalls) const sessionId = "ses_test123" - //#when - session.status with idle (generates synthetic idle first) - await eventHandler({ + //#when + await eventHandler(asEventHandlerInput({ event: { type: "session.status", properties: { @@ -125,91 +109,40 @@ afterEach(() => { 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({ + })) + await eventHandler(asEventHandlerInput({ event: { type: "session.idle", properties: { sessionID: sessionId, }, }, - }) + })) - //#then - real idle deduped, no additional dispatch - expect(dispatchCalls.length).toBe(1) + //#then + expect(dispatchCalls).toHaveLength(2) + expect(dispatchCalls[0]?.event.type).toBe("session.idle") + expect(dispatchCalls[1]?.event.type).toBe("session.idle") + expect((dispatchCalls[0]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) + expect((dispatchCalls[1]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) }) - it("Order B (idle→status): real idle deduped - synthetic idle not dispatched", async () => { + it("#given real idle fires first #when synthetic arrives within 500ms #then synthetic dropped", 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 eventHandler = createIdleTrackingEventHandler(dispatchCalls) const sessionId = "ses_test456" - //#when - real session.idle arrives first - await eventHandler({ + //#when + await eventHandler(asEventHandlerInput({ 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({ + })) + await eventHandler(asEventHandlerInput({ event: { type: "session.status", properties: { @@ -217,10 +150,12 @@ afterEach(() => { status: { type: "idle" }, }, }, - }) + })) - //#then - synthetic idle deduped, no additional dispatch - expect(dispatchCalls.length).toBe(1) + //#then + expect(dispatchCalls).toHaveLength(1) + expect(dispatchCalls[0]?.event.type).toBe("session.idle") + expect((dispatchCalls[0]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) }) it("both maps pruned on every event", async () => { diff --git a/src/plugin/event.ts b/src/plugin/event.ts index ec85ad4fd..34bc730e1 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -323,7 +323,6 @@ export function createEventHandler(args: { const emittedAt = recentSyntheticIdles.get(sessionID); if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) { recentSyntheticIdles.delete(sessionID); - return; } recentRealIdles.set(sessionID, Date.now()); }