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:
+49
-114
@@ -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 () => {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user