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"]
|
} 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(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
_resetForTesting()
|
_resetForTesting()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("createEventHandler - idle deduplication", () => {
|
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
|
//#given
|
||||||
const dispatchCalls: EventInput[] = []
|
const dispatchCalls: EventInput[] = []
|
||||||
const mockDispatchToHooks = async (input: EventInput) => {
|
const eventHandler = createIdleTrackingEventHandler(dispatchCalls)
|
||||||
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"
|
const sessionId = "ses_test123"
|
||||||
|
|
||||||
//#when - session.status with idle (generates synthetic idle first)
|
//#when
|
||||||
await eventHandler({
|
await eventHandler(asEventHandlerInput({
|
||||||
event: {
|
event: {
|
||||||
type: "session.status",
|
type: "session.status",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -125,91 +109,40 @@ afterEach(() => {
|
|||||||
status: { type: "idle" },
|
status: { type: "idle" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}))
|
||||||
|
await eventHandler(asEventHandlerInput({
|
||||||
//#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: {
|
event: {
|
||||||
type: "session.idle",
|
type: "session.idle",
|
||||||
properties: {
|
properties: {
|
||||||
sessionID: sessionId,
|
sessionID: sessionId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}))
|
||||||
|
|
||||||
//#then - real idle deduped, no additional dispatch
|
//#then
|
||||||
expect(dispatchCalls.length).toBe(1)
|
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
|
//#given
|
||||||
const dispatchCalls: EventInput[] = []
|
const dispatchCalls: EventInput[] = []
|
||||||
const mockDispatchToHooks = async (input: EventInput) => {
|
const eventHandler = createIdleTrackingEventHandler(dispatchCalls)
|
||||||
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"
|
const sessionId = "ses_test456"
|
||||||
|
|
||||||
//#when - real session.idle arrives first
|
//#when
|
||||||
await eventHandler({
|
await eventHandler(asEventHandlerInput({
|
||||||
event: {
|
event: {
|
||||||
type: "session.idle",
|
type: "session.idle",
|
||||||
properties: {
|
properties: {
|
||||||
sessionID: sessionId,
|
sessionID: sessionId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}))
|
||||||
|
await eventHandler(asEventHandlerInput({
|
||||||
//#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: {
|
event: {
|
||||||
type: "session.status",
|
type: "session.status",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -217,10 +150,12 @@ afterEach(() => {
|
|||||||
status: { type: "idle" },
|
status: { type: "idle" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}))
|
||||||
|
|
||||||
//#then - synthetic idle deduped, no additional dispatch
|
//#then
|
||||||
expect(dispatchCalls.length).toBe(1)
|
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 () => {
|
it("both maps pruned on every event", async () => {
|
||||||
|
|||||||
@@ -323,7 +323,6 @@ export function createEventHandler(args: {
|
|||||||
const emittedAt = recentSyntheticIdles.get(sessionID);
|
const emittedAt = recentSyntheticIdles.get(sessionID);
|
||||||
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
|
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
|
||||||
recentSyntheticIdles.delete(sessionID);
|
recentSyntheticIdles.delete(sessionID);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
recentRealIdles.set(sessionID, Date.now());
|
recentRealIdles.set(sessionID, Date.now());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user