test(plugin): clean up event handler test helpers

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Choi Kijin / 최 기진 / チョイ キジン
2026-04-28 19:09:02 +09:00
parent 078e49629e
commit 27fb2d5e79
+27 -120
View File
@@ -12,55 +12,56 @@ type EventHandlerArgs = Parameters<typeof createEventHandler>[0]
type EventHandlerInput = Parameters<ReturnType<typeof createEventHandler>>[0]
type ChatMessageHandlerArgs = Parameters<typeof createChatMessageHandler>[0]
function cast<T>(value: unknown): T {
return value as T
}
function asEventHandlerInput(input: EventInput): EventHandlerInput {
return input as unknown as EventHandlerInput
return cast<EventHandlerInput>(input)
}
function asEventHandlerContext(ctx: unknown): EventHandlerArgs["ctx"] {
return ctx as unknown as EventHandlerArgs["ctx"]
return cast<EventHandlerArgs["ctx"]>(ctx)
}
function asChatMessageHandlerContext(ctx: unknown): ChatMessageHandlerArgs["ctx"] {
return ctx as unknown as ChatMessageHandlerArgs["ctx"]
return cast<ChatMessageHandlerArgs["ctx"]>(ctx)
}
function asPluginConfig(config: unknown): EventHandlerArgs["pluginConfig"] {
return config as unknown as EventHandlerArgs["pluginConfig"]
return cast<EventHandlerArgs["pluginConfig"]>(config)
}
function asChatPluginConfig(config: unknown): ChatMessageHandlerArgs["pluginConfig"] {
return config as unknown as ChatMessageHandlerArgs["pluginConfig"]
return cast<ChatMessageHandlerArgs["pluginConfig"]>(config)
}
function createEventHandlerManagers(
overrides: Record<string, unknown> = {},
): EventHandlerArgs["managers"] {
return {
...({} as EventHandlerArgs["managers"]),
return cast<EventHandlerArgs["managers"]>({
tmuxSessionManager: {
onSessionCreated: async () => {},
onSessionDeleted: async () => {},
},
...overrides,
} as unknown as EventHandlerArgs["managers"]
})
}
function createEventHandlerHooks(
overrides: Record<string, unknown>,
overrides: Record<string, unknown> = {},
): EventHandlerArgs["hooks"] {
return {
...({} as EventHandlerArgs["hooks"]),
...overrides,
} as unknown as EventHandlerArgs["hooks"]
return cast<EventHandlerArgs["hooks"]>(overrides)
}
function createChatMessageHandlerHooks(
overrides: Record<string, unknown>,
overrides: Record<string, unknown> = {},
): ChatMessageHandlerArgs["hooks"] {
return {
...({} as ChatMessageHandlerArgs["hooks"]),
...overrides,
} as unknown as ChatMessageHandlerArgs["hooks"]
return cast<ChatMessageHandlerArgs["hooks"]>(overrides)
}
async function wait(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms))
}
function createIdleTrackingEventHandler(dispatchCalls: EventInput[]): ReturnType<typeof createEventHandler> {
@@ -95,29 +96,21 @@ afterEach(() => {
describe("event error extraction", () => {
it("prefers nested APIError message over generic top-level message", async () => {
//#given
const error = {
name: "APIError",
message: "Error",
data: { message: "Forbidden: Selected provider is forbidden" },
}
//#when
const result = extractErrorMessage(error)
//#then
expect(result).toBe("Forbidden: Selected provider is forbidden")
})
})
describe("createEventHandler - idle deduplication", () => {
it("#given synthetic idle fires first #when real idle arrives within 500ms #then real idle dispatched", async () => {
//#given
describe("createEventHandler - idle deduplication", () => {
it("dispatches both idle events when the real idle arrives within 500ms", async () => {
const dispatchCalls: EventInput[] = []
const eventHandler = createIdleTrackingEventHandler(dispatchCalls)
const sessionId = "ses_test123"
//#when
await eventHandler(asEventHandlerInput({
event: {
type: "session.status",
@@ -135,8 +128,6 @@ describe("event error extraction", () => {
},
},
}))
//#then
expect(dispatchCalls).toHaveLength(2)
expect(dispatchCalls[0]?.event.type).toBe("session.idle")
expect(dispatchCalls[1]?.event.type).toBe("session.idle")
@@ -144,13 +135,10 @@ describe("event error extraction", () => {
expect((dispatchCalls[1]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId)
})
it("#given real idle fires first #when synthetic arrives within 500ms #then synthetic dropped", async () => {
//#given
it("drops the synthetic idle when a real idle already arrived within 500ms", async () => {
const dispatchCalls: EventInput[] = []
const eventHandler = createIdleTrackingEventHandler(dispatchCalls)
const sessionId = "ses_test456"
//#when
await eventHandler(asEventHandlerInput({
event: {
type: "session.idle",
@@ -168,15 +156,12 @@ describe("event error extraction", () => {
},
},
}))
//#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 () => {
//#given
it("prunes both maps on every event", async () => {
const eventHandler = createEventHandler({
ctx: {} as any,
pluginConfig: {} as any,
@@ -213,7 +198,6 @@ describe("event error extraction", () => {
} as any,
})
// Trigger some synthetic idles
await eventHandler({
event: {
type: "session.status",
@@ -234,7 +218,6 @@ describe("event error extraction", () => {
},
})
// Trigger some real idles
await eventHandler({
event: {
type: "session.idle",
@@ -252,19 +235,13 @@ describe("event error extraction", () => {
},
},
})
await wait(600)
//#when - wait for dedup window to expire (600ms > 500ms)
await new Promise((resolve) => setTimeout(resolve, 600))
// Trigger any event to trigger pruning
await eventHandler({
event: {
type: "message.updated",
},
} as any)
//#then - both maps should be pruned (no dedup should occur for new events)
// We verify by checking that a new idle event for same session is dispatched
const dispatchCalls: EventInput[] = []
const eventHandlerWithMock = createEventHandler({
ctx: {} as any,
@@ -319,8 +296,7 @@ describe("event error extraction", () => {
expect(dispatchCalls[0].event.type).toBe("session.idle")
})
it("dedup only applies within window - outside window both dispatch", async () => {
//#given
it("dispatches both idle events once the dedup window expires", async () => {
const dispatchCalls: EventInput[] = []
const eventHandler = createEventHandler({
ctx: {} as any,
@@ -365,8 +341,6 @@ describe("event error extraction", () => {
})
const sessionId = "ses_outside_window"
//#when - synthetic idle first
await eventHandler({
event: {
type: "session.status",
@@ -376,14 +350,8 @@ describe("event error extraction", () => {
},
},
})
//#then - synthetic dispatched
expect(dispatchCalls.length).toBe(1)
//#when - wait for dedup window to expire (600ms > 500ms)
await new Promise((resolve) => setTimeout(resolve, 600))
//#when - real idle arrives outside window
await wait(600)
await eventHandler({
event: {
type: "session.idle",
@@ -392,8 +360,6 @@ describe("event error extraction", () => {
},
},
})
//#then - real idle dispatched (outside dedup window)
expect(dispatchCalls.length).toBe(2)
expect(dispatchCalls[0].event.type).toBe("session.idle")
expect(dispatchCalls[1].event.type).toBe("session.idle")
@@ -402,7 +368,6 @@ describe("event error extraction", () => {
describe("createEventHandler - event forwarding", () => {
it("forwards message activity events to tmux session manager", async () => {
//#given
const forwardedEvents: EventInput[] = []
const eventHandler = createEventHandler({
ctx: asEventHandlerContext({}),
@@ -434,22 +399,17 @@ describe("createEventHandler - event forwarding", () => {
}),
hooks: createEventHandlerHooks({}),
})
//#when
await eventHandler(asEventHandlerInput({
event: {
type: "message.part.delta",
properties: { sessionID: "ses_tmux_activity", field: "text", delta: "x" },
},
}))
//#then
expect(forwardedEvents.length).toBe(1)
expect(forwardedEvents[0]?.event.type).toBe("message.part.delta")
})
it("does not forward tmux activity events when tmux integration is disabled", async () => {
//#given
const forwardedEvents: EventInput[] = []
const eventHandler = createEventHandler({
ctx: asEventHandlerContext({}),
@@ -481,21 +441,16 @@ describe("createEventHandler - event forwarding", () => {
}),
hooks: createEventHandlerHooks({}),
})
//#when
await eventHandler(asEventHandlerInput({
event: {
type: "message.part.delta",
properties: { sessionID: "ses_tmux_disabled", field: "text", delta: "x" },
},
}))
//#then
expect(forwardedEvents).toHaveLength(0)
})
it("does not forward session.created to tmux session manager when tmux integration is disabled", async () => {
//#given
const createdSessions: string[] = []
const eventHandler = createEventHandler({
ctx: asEventHandlerContext({}),
@@ -529,21 +484,16 @@ describe("createEventHandler - event forwarding", () => {
}),
hooks: createEventHandlerHooks({}),
})
//#when
await eventHandler(asEventHandlerInput({
event: {
type: "session.created",
properties: { info: { id: "ses_tmux_disabled", parentID: "ses_parent" } },
},
}))
//#then
expect(createdSessions).toHaveLength(0)
})
it("dispatches OpenClaw after session.created for main sessions (no parentID)", async () => {
//#given
const openClawSpy = spyOn(openclawRuntimeDispatch, "dispatchOpenClawEvent").mockResolvedValue(null)
const eventHandler = createEventHandler({
ctx: asEventHandlerContext({ directory: "/tmp/project-created" }),
@@ -572,16 +522,12 @@ describe("createEventHandler - event forwarding", () => {
}),
hooks: createEventHandlerHooks({}),
})
//#when - main session created (no parentID)
await eventHandler(asEventHandlerInput({
event: {
type: "session.created",
properties: { info: { id: "ses_openclaw_created" } },
},
}))
//#then - OpenClaw dispatch called for main session
const [call] = openClawSpy.mock.calls[0] ?? []
expect(call).toMatchObject({
rawEvent: "session.created",
@@ -593,8 +539,7 @@ describe("createEventHandler - event forwarding", () => {
})
})
it("does NOT dispatch OpenClaw for subagent sessions (with parentID)", async () => {
//#given
it("does not dispatch OpenClaw for subagent sessions with a parentID", async () => {
const openClawSpy = spyOn(openclawRuntimeDispatch, "dispatchOpenClawEvent").mockResolvedValue(null)
const eventHandler = createEventHandler({
ctx: asEventHandlerContext({ directory: "/tmp/project-created" }),
@@ -623,21 +568,16 @@ describe("createEventHandler - event forwarding", () => {
}),
hooks: createEventHandlerHooks({}),
})
//#when - subagent session created (with parentID)
await eventHandler(asEventHandlerInput({
event: {
type: "session.created",
properties: { info: { id: "ses_subagent", parentID: "ses_parent" } },
},
}))
//#then - OpenClaw dispatch NOT called for subagent session (handled by specialized callbacks)
expect(openClawSpy.mock.calls.length).toBe(0)
})
it("forwards session.deleted to write-existing-file-guard hook", async () => {
//#given
const forwardedEvents: EventInput[] = []
const disconnectedSessions: string[] = []
const deletedSessions: string[] = []
@@ -679,16 +619,12 @@ describe("createEventHandler - event forwarding", () => {
} as never,
})
const sessionID = "ses_forward_delete_event"
//#when
await eventHandler(asEventHandlerInput({
event: {
type: "session.deleted",
properties: { info: { id: sessionID } },
},
}))
//#then
expect(forwardedEvents.length).toBe(1)
expect(forwardedEvents[0]?.event.type).toBe("session.deleted")
expect(disconnectedSessions).toEqual([sessionID])
@@ -734,7 +670,6 @@ describe("createEventHandler - event forwarding", () => {
})
it("clears stored prompt params on session.deleted", async () => {
//#given
const eventHandler = createEventHandler({
ctx: {} as never,
pluginConfig: {} as never,
@@ -759,23 +694,18 @@ describe("createEventHandler - event forwarding", () => {
topP: 0.7,
options: { reasoningEffort: "high" },
})
//#when
await eventHandler(asEventHandlerInput({
event: {
type: "session.deleted",
properties: { info: { id: sessionID } },
},
}))
//#then
expect(getSessionPromptParams(sessionID)).toBeUndefined()
})
})
describe("createEventHandler - retry dedupe lifecycle", () => {
it("re-handles same retry key after session recovers to idle status", async () => {
//#given
const sessionID = "ses_retry_recovery_rearm"
setMainSession(sessionID)
const abortCalls: string[] = []
@@ -861,8 +791,6 @@ describe("createEventHandler - retry dedupe lifecycle", () => {
},
},
}))
//#when - first retry key is handled
await eventHandler(asEventHandlerInput({
event: {
type: "session.status",
@@ -882,8 +810,6 @@ describe("createEventHandler - retry dedupe lifecycle", () => {
},
firstOutput,
)
//#when - session recovers to non-retry idle state
await eventHandler(asEventHandlerInput({
event: {
type: "session.status",
@@ -893,8 +819,6 @@ describe("createEventHandler - retry dedupe lifecycle", () => {
},
},
}))
//#when - same retry key appears again after recovery
await eventHandler(asEventHandlerInput({
event: {
type: "session.status",
@@ -904,8 +828,6 @@ describe("createEventHandler - retry dedupe lifecycle", () => {
},
},
}))
//#then
expect(abortCalls).toEqual([sessionID, sessionID])
expect(promptCalls).toEqual([sessionID, sessionID])
})
@@ -913,7 +835,6 @@ describe("createEventHandler - retry dedupe lifecycle", () => {
describe("createEventHandler - session recovery compaction", () => {
it("triggers compaction before sending continue after session error recovery", async () => {
//#given
const sessionID = "ses_recovery_compaction"
setMainSession(sessionID)
const callOrder: string[] = []
@@ -949,8 +870,6 @@ describe("createEventHandler - session recovery compaction", () => {
stopContinuationGuard: { isStopped: () => false },
}),
})
//#when
await eventHandler(asEventHandlerInput({
event: {
type: "session.error",
@@ -961,13 +880,10 @@ describe("createEventHandler - session recovery compaction", () => {
},
},
}))
//#then - summarize (compaction) must be called before prompt (continue)
expect(callOrder).toEqual(["summarize", "prompt"])
})
it("sends continue even if compaction fails", async () => {
//#given
const sessionID = "ses_recovery_compaction_fail"
setMainSession(sessionID)
const callOrder: string[] = []
@@ -1003,8 +919,6 @@ describe("createEventHandler - session recovery compaction", () => {
stopContinuationGuard: { isStopped: () => false },
}),
})
//#when
await eventHandler(asEventHandlerInput({
event: {
type: "session.error",
@@ -1015,13 +929,10 @@ describe("createEventHandler - session recovery compaction", () => {
},
},
}))
//#then - continue is still sent even when compaction fails
expect(callOrder).toEqual(["summarize", "prompt"])
})
it("continues dispatching later event hooks when an earlier hook throws", async () => {
//#given
const runtimeFallbackCalls: EventInput[] = []
const eventHandler = createEventHandler({
@@ -1054,8 +965,6 @@ describe("createEventHandler - session recovery compaction", () => {
stopContinuationGuard: { isStopped: () => false },
}),
})
//#when
let thrownError: unknown
try {
await eventHandler(asEventHandlerInput({
@@ -1070,8 +979,6 @@ describe("createEventHandler - session recovery compaction", () => {
} catch (error) {
thrownError = error
}
//#then
expect(thrownError).toBeUndefined()
expect(runtimeFallbackCalls).toHaveLength(1)
expect(runtimeFallbackCalls[0]?.event.type).toBe("session.error")