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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user