fix(event): prefer real session.idle over recent synthetic dedup (#2667)

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
This commit is contained in:
YeonGyu-Kim
2026-04-12 02:30:11 +09:00
parent 3bfa3bd608
commit eb495793db
2 changed files with 49 additions and 115 deletions
+49 -114
View File
@@ -63,61 +63,45 @@ function createChatMessageHandlerHooks(
} as unknown as ChatMessageHandlerArgs["hooks"]
}
function createIdleTrackingEventHandler(dispatchCalls: EventInput[]): ReturnType<typeof createEventHandler> {
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 () => {
-1
View File
@@ -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());
}