fix(plugin): normalize event session ids

Handle OpenCode session events that carry the session ID under properties.info.id or properties.info.sessionID so background tasks and continuation hooks do not miss idle/error/delete events.

Add regression coverage for nested session.idle events completing background tasks and waking continuation hooks.
This commit is contained in:
YeonGyu-Kim
2026-05-12 11:48:18 +09:00
parent 67f90a819e
commit 4da48555ee
51 changed files with 740 additions and 254 deletions
@@ -5,6 +5,7 @@ import {
clearContinuationMarker,
} from "../../features/run-continuation-state"
import { log } from "../../shared/logger"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants"
import { armCompactionGuard } from "./compaction-guard"
@@ -71,7 +72,7 @@ export function createTodoContinuationHandler(args: {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
const sessionID = resolveSessionEventID(props)
if (!sessionID) return
const error = extractSessionErrorInfo(props?.error)
@@ -102,7 +103,7 @@ export function createTodoContinuationHandler(args: {
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
const sessionID = resolveSessionEventID(props)
if (!sessionID) return
sessionStateStore.startPruneInterval()
@@ -118,7 +119,7 @@ export function createTodoContinuationHandler(args: {
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined
const sessionID = resolveSessionEventID(props)
if (sessionID) {
const state = sessionStateStore.getState(sessionID)
const compactionEpoch = armCompactionGuard(state, Date.now())
@@ -129,9 +130,9 @@ export function createTodoContinuationHandler(args: {
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
clearContinuationMarker(ctx.directory, sessionInfo.id)
const sessionID = resolveSessionEventID(props)
if (sessionID) {
clearContinuationMarker(ctx.directory, sessionID)
}
}
@@ -1,4 +1,5 @@
import { log } from "../../shared/logger"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants"
import type { SessionStateStore } from "./session-state"
@@ -12,7 +13,7 @@ export function handleNonIdleEvent(args: {
if (eventType === "message.updated") {
const info = properties?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const sessionID = resolveMessageEventSessionID(properties)
const role = info?.role as string | undefined
if (!sessionID) return
@@ -50,12 +51,7 @@ export function handleNonIdleEvent(args: {
}
if (eventType === "message.part.updated") {
const sessionID = typeof properties?.sessionID === "string"
? properties.sessionID
: undefined
const legacyInfo = properties?.info as Record<string, unknown> | undefined
const legacySessionID = legacyInfo?.sessionID as string | undefined
const targetSessionID = sessionID ?? legacySessionID
const targetSessionID = resolveMessageEventSessionID(properties)
if (targetSessionID) {
const state = sessionStateStore.getExistingState(targetSessionID)
@@ -69,7 +65,7 @@ export function handleNonIdleEvent(args: {
}
if (eventType === "message.part.delta") {
const sessionID = properties?.sessionID as string | undefined
const sessionID = resolveMessageEventSessionID(properties)
if (sessionID) {
const state = sessionStateStore.getExistingState(sessionID)
if (state) {
@@ -83,7 +79,7 @@ export function handleNonIdleEvent(args: {
}
if (eventType === "tool.execute.before" || eventType === "tool.execute.after") {
const sessionID = properties?.sessionID as string | undefined
const sessionID = resolveMessageEventSessionID(properties)
if (sessionID) {
const state = sessionStateStore.getExistingState(sessionID)
if (state) {
@@ -97,10 +93,10 @@ export function handleNonIdleEvent(args: {
}
if (eventType === "session.deleted") {
const sessionInfo = properties?.info as { id?: string } | undefined
if (sessionInfo?.id) {
sessionStateStore.cleanup(sessionInfo.id)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
const sessionID = resolveSessionEventID(properties)
if (sessionID) {
sessionStateStore.cleanup(sessionID)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
}
return
}
@@ -12,6 +12,7 @@ import {
} from "./constants"
type TimerCallback = (...args: any[]) => void
type FakeTimerID = number & ReturnType<typeof setTimeout> & ReturnType<typeof setInterval>
interface FakeTimers {
advanceBy: (ms: number, advanceClock?: boolean) => Promise<void>
@@ -57,7 +58,7 @@ function createFakeTimers(): FakeTimers {
callback,
args,
})
return id
return id as FakeTimerID
}
const clear = (id: number | undefined) => {
@@ -74,7 +75,7 @@ function createFakeTimers(): FakeTimers {
if (normalized >= REAL_MAX_DELAY_MS) {
return original.setTimeout(callback, delay, ...args)
}
return schedule(callback, normalized, null, args) as unknown as ReturnType<typeof setTimeout>
return schedule(callback, normalized, null, args)
}) as typeof setTimeout
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
@@ -85,7 +86,7 @@ function createFakeTimers(): FakeTimers {
if (interval >= REAL_MAX_DELAY_MS) {
return original.setInterval(callback, delay, ...args)
}
return schedule(callback, interval, interval, args) as unknown as ReturnType<typeof setInterval>
return schedule(callback, interval, interval, args)
}) as typeof setInterval
globalThis.clearTimeout = ((id?: Parameters<typeof clearTimeout>[0]) => {
@@ -184,6 +185,8 @@ describe("todo-continuation-enforcer", () => {
}
}
type MockPluginInput = Parameters<typeof createTodoContinuationEnforcer>[0]
let mockMessages: MockMessage[] = []
function createMockPluginInput() {
@@ -225,7 +228,7 @@ describe("todo-continuation-enforcer", () => {
},
},
directory: "/tmp/test",
} as any
} as MockPluginInput
}
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
@@ -233,7 +236,7 @@ describe("todo-continuation-enforcer", () => {
getTasksByParentSession: () => runningTasks
? [{ status: "running" }]
: [],
} as any
} as BackgroundManager
}
beforeEach(() => {
@@ -302,6 +305,26 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
}, { timeout: 15000 })
test("should inject continuation when idle event carries session id in info", async () => {
fakeTimers.restore()
// given - OpenCode session events can nest the session id under info
const sessionID = "main-info-idle"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - session goes idle with the nested event shape
await hook.handler({
event: { type: "session.idle", properties: { info: { id: sessionID } } },
})
// then - continuation is still injected for that session
await wait(2500)
expect(promptCalls).toHaveLength(1)
expect(promptCalls[0].sessionID).toBe(sessionID)
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
}, { timeout: 15000 })
test("should not inject when all todos are complete", async () => {
// given - session with all todos complete
const sessionID = "main-456"
@@ -527,6 +550,42 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls).toHaveLength(0)
})
test("should cancel countdown on assistant activity when message.part.updated only has part session id", async () => {
// given - session starting countdown
const sessionID = "main-assistant-part-only"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// when - legacy part-only sync payload reports assistant output
await fakeTimers.advanceBy(500)
await hook.handler({
event: {
type: "message.part.updated",
properties: {
part: {
id: "part-1",
messageID: "msg-1",
sessionID,
type: "text",
text: "working",
},
time: Date.now(),
},
},
})
await fakeTimers.advanceBy(3000)
// then - no continuation injected (cancelled)
expect(promptCalls).toHaveLength(0)
})
test("should cancel countdown on assistant activity with message.part.delta payload", async () => {
// given - session starting countdown
const sessionID = "main-assistant-delta"
@@ -1599,7 +1658,7 @@ describe("todo-continuation-enforcer", () => {
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
} as MockPluginInput
const hook = createTodoContinuationEnforcer(mockInput, {
backgroundManager: createMockBackgroundManager(false),
@@ -1660,7 +1719,7 @@ describe("todo-continuation-enforcer", () => {
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
} as MockPluginInput
const hook = createTodoContinuationEnforcer(mockInput, {
backgroundManager: createMockBackgroundManager(false),
@@ -1712,7 +1771,7 @@ describe("todo-continuation-enforcer", () => {
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
} as MockPluginInput
const hook = createTodoContinuationEnforcer(mockInput, {})
@@ -1769,7 +1828,7 @@ describe("todo-continuation-enforcer", () => {
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
} as MockPluginInput
const hook = createTodoContinuationEnforcer(mockInput, {
backgroundManager: createMockBackgroundManager(false),
@@ -1823,7 +1882,7 @@ describe("todo-continuation-enforcer", () => {
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
} as MockPluginInput
const hook = createTodoContinuationEnforcer(mockInput, {})
@@ -1878,7 +1937,7 @@ describe("todo-continuation-enforcer", () => {
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
} as MockPluginInput
const hook = createTodoContinuationEnforcer(mockInput, {
skipAgents: [],
@@ -2122,7 +2181,7 @@ describe("todo-continuation-enforcer", () => {
const mockInput = createMockPluginInput()
mockInput.client.session.promptAsync = async () => {
const error = new Error("prompt is too long: 150000 tokens > 100000 maximum")
;(error as any).name = "ContextLengthError"
error.name = "ContextLengthError"
throw error
}