From a196d84c2ed2c0133ce475f06835a347a4f4c93b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:54:01 +0900 Subject: [PATCH 1/2] fix(plugin): allow real session.idle after synthetic idle within dedup window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When session.status(idle) is converted to synthetic session.idle and recorded in recentAnyIdles, a real session.idle arriving within 500ms was being dropped by the dedup logic. recentSyntheticIdles was cleared but recentAnyIdles persisted, causing TODO-DIAG to red-alert with 'no todossession.idle event'. Fix: when real session.idle arrives, also clear recentAnyIdles entry so dedup does not drop it. Test renamed and expected dispatchCalls updated 1 → 2. Fixes #2667 --- src/plugin/event.test.ts | 6 ++++-- src/plugin/event.ts | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index 49ed2c031..8b10a5950 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -335,7 +335,7 @@ describe("createEventHandler - idle deduplication", () => { expect(spawnTmuxPane).toHaveBeenCalledTimes(1) }) - it("dedups real-idle-after-synthetic-idle within 500ms", async () => { + it("does NOT dedup real-idle-after-synthetic-idle within 500ms", async () => { //#given const dispatchCalls: EventInput[] = [] const eventHandler = createIdleTrackingEventHandler(dispatchCalls) @@ -359,9 +359,11 @@ describe("createEventHandler - idle deduplication", () => { })) //#then - expect(dispatchCalls).toHaveLength(1) + expect(dispatchCalls).toHaveLength(2) expect(dispatchCalls[0]?.event.type).toBe("session.idle") expect((dispatchCalls[0]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) + expect(dispatchCalls[1]?.event.type).toBe("session.idle") + expect((dispatchCalls[1]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) }) it("dedups back-to-back real session.idle events for the same sessionID within 500ms", async () => { diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 265244f29..e8bb49a7d 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -415,6 +415,9 @@ export function createEventHandler(args: { const emittedAt = recentSyntheticIdles.get(sessionID); if (emittedAt !== undefined && now - emittedAt < DEDUP_WINDOW_MS) { recentSyntheticIdles.delete(sessionID); + // Let real idle events through even when a synthetic idle fired moments earlier. + // OpenCode diagnostics expect a concrete session.idle event signal. + recentAnyIdles.delete(sessionID); } recentRealIdles.set(sessionID, now); if (!shouldDispatchIdleEvent(sessionID, now)) { From 2168040ac68fe812fb784c90dd41f1a5c95ae3dd Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 08:45:34 +0900 Subject: [PATCH 2/2] fix(plugin): scope synthetic-idle dedup bypass to matching marker Only clear recentAnyIdles when the stored marker matches the synthetic idle timestamp for the same session, preventing accidental clobbering of newer idle markers. Add a regression test to verify other sessions keep their dedup state during this bypass path. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin/event.test.ts | 66 ++++++++++++++++++++++++++++++++++++++++ src/plugin/event.ts | 5 ++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index 8b10a5950..a400118e6 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -366,6 +366,72 @@ describe("createEventHandler - idle deduplication", () => { expect((dispatchCalls[1]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId) }) + it("keeps other session dedup state untouched when bypassing synthetic-idle for current session", async () => { + //#given + const originalDateNow = Date.now + let currentNow = 30_000 + Date.now = () => currentNow + const dispatchedSessionIds: string[] = [] + const eventHandler = createIdleDedupSpyEventHandler({ + onEvent: () => {}, + sessionNotification: async (input: EventInput) => { + if (input.event.type !== "session.idle") { + return + } + const props = input.event.properties as { sessionID?: string } | undefined + if (props?.sessionID) { + dispatchedSessionIds.push(props.sessionID) + } + }, + }) + + try { + //#when + await eventHandler(asEventHandlerInput({ + event: { + type: "session.status", + properties: { + sessionID: "ses_a", + status: { type: "idle" }, + }, + }, + })) + await eventHandler(asEventHandlerInput({ + event: { + type: "session.idle", + properties: { + sessionID: "ses_b", + }, + }, + })) + + currentNow += 100 + await eventHandler(asEventHandlerInput({ + event: { + type: "session.idle", + properties: { + sessionID: "ses_a", + }, + }, + })) + + currentNow += 100 + await eventHandler(asEventHandlerInput({ + event: { + type: "session.idle", + properties: { + sessionID: "ses_b", + }, + }, + })) + + //#then + expect(dispatchedSessionIds).toEqual(["ses_a", "ses_b", "ses_a"]) + } finally { + Date.now = originalDateNow + } + }) + it("dedups back-to-back real session.idle events for the same sessionID within 500ms", async () => { //#given const originalDateNow = Date.now diff --git a/src/plugin/event.ts b/src/plugin/event.ts index e8bb49a7d..2b94fcadf 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -417,7 +417,10 @@ export function createEventHandler(args: { recentSyntheticIdles.delete(sessionID); // Let real idle events through even when a synthetic idle fired moments earlier. // OpenCode diagnostics expect a concrete session.idle event signal. - recentAnyIdles.delete(sessionID); + const lastAnyIdleAt = recentAnyIdles.get(sessionID); + if (lastAnyIdleAt === emittedAt) { + recentAnyIdles.delete(sessionID); + } } recentRealIdles.set(sessionID, now); if (!shouldDispatchIdleEvent(sessionID, now)) {