2026-04-07 22:49:37 +09:00
|
|
|
import { describe, it, expect, afterEach, mock, spyOn } from "bun:test"
|
2026-02-10 19:09:22 +09:00
|
|
|
|
|
|
|
|
import { createEventHandler } from "./event"
|
2026-03-17 15:17:34 +09:00
|
|
|
import { createChatMessageHandler } from "./chat-message"
|
2026-04-07 22:49:37 +09:00
|
|
|
import * as openclawRuntimeDispatch from "../openclaw/runtime-dispatch"
|
2026-03-17 15:17:34 +09:00
|
|
|
import { _resetForTesting, setMainSession } from "../features/claude-code-session-state"
|
|
|
|
|
import { clearPendingModelFallback, createModelFallbackHook } from "../hooks/model-fallback/hook"
|
2026-03-18 14:21:27 +01:00
|
|
|
import { getSessionPromptParams, setSessionPromptParams } from "../shared/session-prompt-params-state"
|
2026-02-10 19:09:22 +09:00
|
|
|
|
2026-03-17 15:17:34 +09:00
|
|
|
type EventInput = { event: { type: string; properties?: unknown } }
|
2026-04-03 17:37:17 +09:00
|
|
|
type EventHandlerArgs = Parameters<typeof createEventHandler>[0]
|
|
|
|
|
type EventHandlerInput = Parameters<ReturnType<typeof createEventHandler>>[0]
|
|
|
|
|
type ChatMessageHandlerArgs = Parameters<typeof createChatMessageHandler>[0]
|
|
|
|
|
|
|
|
|
|
function asEventHandlerInput(input: EventInput): EventHandlerInput {
|
|
|
|
|
return input as unknown as EventHandlerInput
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asEventHandlerContext(ctx: unknown): EventHandlerArgs["ctx"] {
|
|
|
|
|
return ctx as unknown as EventHandlerArgs["ctx"]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asChatMessageHandlerContext(ctx: unknown): ChatMessageHandlerArgs["ctx"] {
|
|
|
|
|
return ctx as unknown as ChatMessageHandlerArgs["ctx"]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asPluginConfig(config: unknown): EventHandlerArgs["pluginConfig"] {
|
|
|
|
|
return config as unknown as EventHandlerArgs["pluginConfig"]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asChatPluginConfig(config: unknown): ChatMessageHandlerArgs["pluginConfig"] {
|
|
|
|
|
return config as unknown as ChatMessageHandlerArgs["pluginConfig"]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createEventHandlerManagers(
|
|
|
|
|
overrides: Record<string, unknown> = {},
|
|
|
|
|
): EventHandlerArgs["managers"] {
|
|
|
|
|
return {
|
|
|
|
|
...({} as EventHandlerArgs["managers"]),
|
|
|
|
|
tmuxSessionManager: {
|
|
|
|
|
onSessionCreated: async () => {},
|
|
|
|
|
onSessionDeleted: async () => {},
|
|
|
|
|
},
|
|
|
|
|
...overrides,
|
|
|
|
|
} as unknown as EventHandlerArgs["managers"]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createEventHandlerHooks(
|
|
|
|
|
overrides: Record<string, unknown>,
|
|
|
|
|
): EventHandlerArgs["hooks"] {
|
|
|
|
|
return {
|
|
|
|
|
...({} as EventHandlerArgs["hooks"]),
|
|
|
|
|
...overrides,
|
|
|
|
|
} as unknown as EventHandlerArgs["hooks"]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createChatMessageHandlerHooks(
|
|
|
|
|
overrides: Record<string, unknown>,
|
|
|
|
|
): ChatMessageHandlerArgs["hooks"] {
|
|
|
|
|
return {
|
|
|
|
|
...({} as ChatMessageHandlerArgs["hooks"]),
|
|
|
|
|
...overrides,
|
|
|
|
|
} as unknown as ChatMessageHandlerArgs["hooks"]
|
|
|
|
|
}
|
2026-03-17 15:17:34 +09:00
|
|
|
|
2026-04-12 02:30:11 +09:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 15:17:34 +09:00
|
|
|
afterEach(() => {
|
2026-04-07 22:49:37 +09:00
|
|
|
mock.restore()
|
2026-03-17 15:17:34 +09:00
|
|
|
_resetForTesting()
|
|
|
|
|
})
|
2026-02-10 19:09:22 +09:00
|
|
|
|
|
|
|
|
describe("createEventHandler - idle deduplication", () => {
|
2026-04-12 02:30:11 +09:00
|
|
|
it("#given synthetic idle fires first #when real idle arrives within 500ms #then real idle dispatched", async () => {
|
2026-02-10 19:09:22 +09:00
|
|
|
//#given
|
|
|
|
|
const dispatchCalls: EventInput[] = []
|
2026-04-12 02:30:11 +09:00
|
|
|
const eventHandler = createIdleTrackingEventHandler(dispatchCalls)
|
2026-02-10 19:09:22 +09:00
|
|
|
const sessionId = "ses_test123"
|
|
|
|
|
|
2026-04-12 02:30:11 +09:00
|
|
|
//#when
|
|
|
|
|
await eventHandler(asEventHandlerInput({
|
2026-02-10 19:09:22 +09:00
|
|
|
event: {
|
|
|
|
|
type: "session.status",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID: sessionId,
|
|
|
|
|
status: { type: "idle" },
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-12 02:30:11 +09:00
|
|
|
}))
|
|
|
|
|
await eventHandler(asEventHandlerInput({
|
2026-02-10 19:09:22 +09:00
|
|
|
event: {
|
|
|
|
|
type: "session.idle",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID: sessionId,
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-12 02:30:11 +09:00
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
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)
|
2026-02-10 19:09:22 +09:00
|
|
|
})
|
|
|
|
|
|
2026-04-12 02:30:11 +09:00
|
|
|
it("#given real idle fires first #when synthetic arrives within 500ms #then synthetic dropped", async () => {
|
2026-02-10 19:09:22 +09:00
|
|
|
//#given
|
|
|
|
|
const dispatchCalls: EventInput[] = []
|
2026-04-12 02:30:11 +09:00
|
|
|
const eventHandler = createIdleTrackingEventHandler(dispatchCalls)
|
2026-02-10 19:09:22 +09:00
|
|
|
const sessionId = "ses_test456"
|
|
|
|
|
|
2026-04-12 02:30:11 +09:00
|
|
|
//#when
|
|
|
|
|
await eventHandler(asEventHandlerInput({
|
2026-02-10 19:09:22 +09:00
|
|
|
event: {
|
|
|
|
|
type: "session.idle",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID: sessionId,
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-12 02:30:11 +09:00
|
|
|
}))
|
|
|
|
|
await eventHandler(asEventHandlerInput({
|
2026-02-10 19:09:22 +09:00
|
|
|
event: {
|
|
|
|
|
type: "session.status",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID: sessionId,
|
|
|
|
|
status: { type: "idle" },
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-12 02:30:11 +09:00
|
|
|
}))
|
2026-02-10 19:09:22 +09:00
|
|
|
|
2026-04-12 02:30:11 +09:00
|
|
|
//#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)
|
2026-02-10 19:09:22 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("both maps pruned on every event", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const eventHandler = createEventHandler({
|
|
|
|
|
ctx: {} as any,
|
|
|
|
|
pluginConfig: {} as any,
|
|
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
|
|
|
|
managers: {
|
|
|
|
|
tmuxSessionManager: {
|
|
|
|
|
onSessionCreated: async () => {},
|
|
|
|
|
onSessionDeleted: async () => {},
|
|
|
|
|
},
|
|
|
|
|
} as any,
|
|
|
|
|
hooks: {
|
|
|
|
|
autoUpdateChecker: { event: async () => {} },
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Trigger some synthetic idles
|
|
|
|
|
await eventHandler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.status",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID: "ses_stale_1",
|
|
|
|
|
status: { type: "idle" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await eventHandler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.status",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID: "ses_stale_2",
|
|
|
|
|
status: { type: "idle" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Trigger some real idles
|
|
|
|
|
await eventHandler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.idle",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID: "ses_stale_3",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await eventHandler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.idle",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID: "ses_stale_4",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#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",
|
|
|
|
|
},
|
2026-03-17 15:17:34 +09:00
|
|
|
} as any)
|
2026-02-10 19:09:22 +09:00
|
|
|
|
|
|
|
|
//#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,
|
|
|
|
|
pluginConfig: {} as any,
|
|
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
|
|
|
|
managers: {
|
|
|
|
|
tmuxSessionManager: {
|
|
|
|
|
onSessionCreated: async () => {},
|
|
|
|
|
onSessionDeleted: async () => {},
|
|
|
|
|
},
|
|
|
|
|
} as any,
|
|
|
|
|
hooks: {
|
|
|
|
|
autoUpdateChecker: {
|
|
|
|
|
event: async (input: EventInput) => {
|
|
|
|
|
dispatchCalls.push(input)
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
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 () => {} },
|
2026-03-17 15:17:34 +09:00
|
|
|
} as any,
|
2026-02-10 19:09:22 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await eventHandlerWithMock({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.idle",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID: "ses_stale_1",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(dispatchCalls.length).toBe(1)
|
|
|
|
|
expect(dispatchCalls[0].event.type).toBe("session.idle")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("dedup only applies within window - outside window both dispatch", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const dispatchCalls: EventInput[] = []
|
|
|
|
|
const eventHandler = createEventHandler({
|
|
|
|
|
ctx: {} as any,
|
|
|
|
|
pluginConfig: {} as any,
|
|
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
|
|
|
|
managers: {
|
|
|
|
|
tmuxSessionManager: {
|
|
|
|
|
onSessionCreated: async () => {},
|
|
|
|
|
onSessionDeleted: async () => {},
|
|
|
|
|
},
|
|
|
|
|
} as any,
|
|
|
|
|
hooks: {
|
|
|
|
|
autoUpdateChecker: {
|
|
|
|
|
event: async (input: EventInput) => {
|
|
|
|
|
if (input.event.type === "session.idle") {
|
|
|
|
|
dispatchCalls.push(input)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
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_outside_window"
|
|
|
|
|
|
|
|
|
|
//#when - synthetic idle first
|
|
|
|
|
await eventHandler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.status",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID: sessionId,
|
|
|
|
|
status: { type: "idle" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#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 eventHandler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.idle",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID: sessionId,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#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")
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-02-18 16:48:48 -03:00
|
|
|
|
|
|
|
|
describe("createEventHandler - event forwarding", () => {
|
2026-04-04 18:48:03 +09:00
|
|
|
it("forwards message activity events to tmux session manager", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const forwardedEvents: EventInput[] = []
|
|
|
|
|
const eventHandler = createEventHandler({
|
|
|
|
|
ctx: asEventHandlerContext({}),
|
2026-04-05 13:27:50 +09:00
|
|
|
pluginConfig: asPluginConfig({
|
|
|
|
|
tmux: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
layout: "main-vertical",
|
|
|
|
|
main_pane_size: 60,
|
|
|
|
|
main_pane_min_width: 120,
|
|
|
|
|
agent_pane_min_width: 40,
|
|
|
|
|
isolation: "inline",
|
|
|
|
|
},
|
|
|
|
|
}),
|
2026-04-04 18:48:03 +09:00
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
|
|
|
|
managers: createEventHandlerManagers({
|
|
|
|
|
skillMcpManager: {
|
|
|
|
|
disconnectSession: async () => {},
|
|
|
|
|
},
|
|
|
|
|
tmuxSessionManager: {
|
|
|
|
|
onEvent: (event: EventInput["event"]) => {
|
|
|
|
|
forwardedEvents.push({ event })
|
|
|
|
|
},
|
|
|
|
|
onSessionCreated: async () => {},
|
|
|
|
|
onSessionDeleted: async () => {},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
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")
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-05 13:27:50 +09:00
|
|
|
it("does not forward tmux activity events when tmux integration is disabled", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const forwardedEvents: EventInput[] = []
|
|
|
|
|
const eventHandler = createEventHandler({
|
|
|
|
|
ctx: asEventHandlerContext({}),
|
|
|
|
|
pluginConfig: asPluginConfig({
|
|
|
|
|
tmux: {
|
|
|
|
|
enabled: false,
|
|
|
|
|
layout: "main-vertical",
|
|
|
|
|
main_pane_size: 60,
|
|
|
|
|
main_pane_min_width: 120,
|
|
|
|
|
agent_pane_min_width: 40,
|
|
|
|
|
isolation: "inline",
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
|
|
|
|
managers: createEventHandlerManagers({
|
|
|
|
|
skillMcpManager: {
|
|
|
|
|
disconnectSession: async () => {},
|
|
|
|
|
},
|
|
|
|
|
tmuxSessionManager: {
|
|
|
|
|
onEvent: (event: EventInput["event"]) => {
|
|
|
|
|
forwardedEvents.push({ event })
|
|
|
|
|
},
|
|
|
|
|
onSessionCreated: async () => {},
|
|
|
|
|
onSessionDeleted: async () => {},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
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({}),
|
|
|
|
|
pluginConfig: asPluginConfig({
|
|
|
|
|
tmux: {
|
|
|
|
|
enabled: false,
|
|
|
|
|
layout: "main-vertical",
|
|
|
|
|
main_pane_size: 60,
|
|
|
|
|
main_pane_min_width: 120,
|
|
|
|
|
agent_pane_min_width: 40,
|
|
|
|
|
isolation: "inline",
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
|
|
|
|
managers: createEventHandlerManagers({
|
|
|
|
|
skillMcpManager: {
|
|
|
|
|
disconnectSession: async () => {},
|
|
|
|
|
},
|
|
|
|
|
tmuxSessionManager: {
|
|
|
|
|
onSessionCreated: async (event: { properties?: { info?: { id?: string } } }) => {
|
|
|
|
|
const sessionId = event.properties?.info?.id
|
|
|
|
|
if (sessionId) {
|
|
|
|
|
createdSessions.push(sessionId)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onSessionDeleted: async () => {},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
hooks: createEventHandlerHooks({}),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await eventHandler(asEventHandlerInput({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.created",
|
|
|
|
|
properties: { info: { id: "ses_tmux_disabled", parentID: "ses_parent" } },
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(createdSessions).toHaveLength(0)
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-10 18:47:18 +09:00
|
|
|
it("dispatches OpenClaw after session.created for main sessions (no parentID)", async () => {
|
|
|
|
|
//#given
|
2026-04-07 22:49:37 +09:00
|
|
|
const openClawSpy = spyOn(openclawRuntimeDispatch, "dispatchOpenClawEvent").mockResolvedValue(null)
|
|
|
|
|
const eventHandler = createEventHandler({
|
|
|
|
|
ctx: asEventHandlerContext({ directory: "/tmp/project-created" }),
|
|
|
|
|
pluginConfig: asPluginConfig({
|
|
|
|
|
openclaw: { enabled: true, gateways: {}, hooks: {} },
|
|
|
|
|
tmux: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
layout: "main-vertical",
|
|
|
|
|
main_pane_size: 60,
|
|
|
|
|
main_pane_min_width: 120,
|
|
|
|
|
agent_pane_min_width: 40,
|
|
|
|
|
isolation: "inline",
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
|
|
|
|
managers: createEventHandlerManagers({
|
|
|
|
|
skillMcpManager: { disconnectSession: async () => {} },
|
|
|
|
|
tmuxSessionManager: {
|
|
|
|
|
onSessionCreated: async () => {},
|
|
|
|
|
onSessionDeleted: async () => {},
|
|
|
|
|
getTrackedPaneId: (sessionID: string) => (sessionID === "ses_openclaw_created" ? "%9" : undefined),
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
hooks: createEventHandlerHooks({}),
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-10 18:47:18 +09:00
|
|
|
//#when - main session created (no parentID)
|
2026-04-07 22:49:37 +09:00
|
|
|
await eventHandler(asEventHandlerInput({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.created",
|
2026-04-10 18:47:18 +09:00
|
|
|
properties: { info: { id: "ses_openclaw_created" } },
|
2026-04-07 22:49:37 +09:00
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
|
2026-04-10 18:47:18 +09:00
|
|
|
//#then - OpenClaw dispatch called for main session
|
2026-04-07 22:49:37 +09:00
|
|
|
const [call] = openClawSpy.mock.calls[0] ?? []
|
|
|
|
|
expect(call).toMatchObject({
|
|
|
|
|
rawEvent: "session.created",
|
|
|
|
|
context: {
|
|
|
|
|
sessionId: "ses_openclaw_created",
|
|
|
|
|
projectPath: "/tmp/project-created",
|
|
|
|
|
tmuxPaneId: "%9",
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-10 18:47:18 +09:00
|
|
|
it("does NOT dispatch OpenClaw for subagent sessions (with parentID)", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const openClawSpy = spyOn(openclawRuntimeDispatch, "dispatchOpenClawEvent").mockResolvedValue(null)
|
|
|
|
|
const eventHandler = createEventHandler({
|
|
|
|
|
ctx: asEventHandlerContext({ directory: "/tmp/project-created" }),
|
|
|
|
|
pluginConfig: asPluginConfig({
|
|
|
|
|
openclaw: { enabled: true, gateways: {}, hooks: {} },
|
|
|
|
|
tmux: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
layout: "main-vertical",
|
|
|
|
|
main_pane_size: 60,
|
|
|
|
|
main_pane_min_width: 120,
|
|
|
|
|
agent_pane_min_width: 40,
|
|
|
|
|
isolation: "inline",
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
|
|
|
|
managers: createEventHandlerManagers({
|
|
|
|
|
skillMcpManager: { disconnectSession: async () => {} },
|
|
|
|
|
tmuxSessionManager: {
|
|
|
|
|
onSessionCreated: async () => {},
|
|
|
|
|
onSessionDeleted: async () => {},
|
|
|
|
|
getTrackedPaneId: (sessionID: string) => (sessionID === "ses_subagent" ? "%10" : undefined),
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-18 16:48:48 -03:00
|
|
|
it("forwards session.deleted to write-existing-file-guard hook", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const forwardedEvents: EventInput[] = []
|
|
|
|
|
const disconnectedSessions: string[] = []
|
|
|
|
|
const deletedSessions: string[] = []
|
|
|
|
|
const eventHandler = createEventHandler({
|
|
|
|
|
ctx: {} as never,
|
2026-04-05 13:27:50 +09:00
|
|
|
pluginConfig: asPluginConfig({
|
|
|
|
|
tmux: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
layout: "main-vertical",
|
|
|
|
|
main_pane_size: 60,
|
|
|
|
|
main_pane_min_width: 120,
|
|
|
|
|
agent_pane_min_width: 40,
|
|
|
|
|
isolation: "inline",
|
|
|
|
|
},
|
|
|
|
|
}),
|
2026-02-18 16:48:48 -03:00
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
|
|
|
|
managers: {
|
|
|
|
|
skillMcpManager: {
|
|
|
|
|
disconnectSession: async (sessionID: string) => {
|
|
|
|
|
disconnectedSessions.push(sessionID)
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
tmuxSessionManager: {
|
|
|
|
|
onSessionCreated: async () => {},
|
|
|
|
|
onSessionDeleted: async ({ sessionID }: { sessionID: string }) => {
|
|
|
|
|
deletedSessions.push(sessionID)
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
} as never,
|
|
|
|
|
hooks: {
|
|
|
|
|
writeExistingFileGuard: {
|
|
|
|
|
event: async (input: EventInput) => {
|
|
|
|
|
forwardedEvents.push(input)
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
} as never,
|
|
|
|
|
})
|
|
|
|
|
const sessionID = "ses_forward_delete_event"
|
|
|
|
|
|
|
|
|
|
//#when
|
2026-04-03 17:37:17 +09:00
|
|
|
await eventHandler(asEventHandlerInput({
|
2026-02-18 16:48:48 -03:00
|
|
|
event: {
|
|
|
|
|
type: "session.deleted",
|
|
|
|
|
properties: { info: { id: sessionID } },
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
}))
|
2026-02-18 16:48:48 -03:00
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(forwardedEvents.length).toBe(1)
|
|
|
|
|
expect(forwardedEvents[0]?.event.type).toBe("session.deleted")
|
|
|
|
|
expect(disconnectedSessions).toEqual([sessionID])
|
|
|
|
|
expect(deletedSessions).toEqual([sessionID])
|
|
|
|
|
})
|
2026-03-18 14:21:27 +01:00
|
|
|
|
2026-04-07 22:49:37 +09:00
|
|
|
it("dispatches OpenClaw for synthetic session.idle events", async () => {
|
|
|
|
|
const openClawSpy = spyOn(openclawRuntimeDispatch, "dispatchOpenClawEvent").mockResolvedValue(null)
|
|
|
|
|
const eventHandler = createEventHandler({
|
|
|
|
|
ctx: asEventHandlerContext({ directory: "/tmp/project-idle" }),
|
|
|
|
|
pluginConfig: asPluginConfig({ openclaw: { enabled: true, gateways: {}, hooks: {} } }),
|
|
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
|
|
|
|
managers: createEventHandlerManagers({
|
|
|
|
|
skillMcpManager: { disconnectSession: async () => {} },
|
|
|
|
|
tmuxSessionManager: {
|
|
|
|
|
onSessionCreated: async () => {},
|
|
|
|
|
onSessionDeleted: async () => {},
|
|
|
|
|
getTrackedPaneId: (sessionID: string) => (sessionID === "ses_openclaw_idle" ? "%3" : undefined),
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
hooks: createEventHandlerHooks({}),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await eventHandler(asEventHandlerInput({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.status",
|
|
|
|
|
properties: { sessionID: "ses_openclaw_idle", status: { type: "idle" } },
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const [call] = openClawSpy.mock.calls[0] ?? []
|
|
|
|
|
expect(call).toMatchObject({
|
|
|
|
|
rawEvent: "session.idle",
|
|
|
|
|
context: {
|
|
|
|
|
sessionId: "ses_openclaw_idle",
|
|
|
|
|
projectPath: "/tmp/project-idle",
|
|
|
|
|
tmuxPaneId: "%3",
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-18 14:21:27 +01:00
|
|
|
it("clears stored prompt params on session.deleted", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const eventHandler = createEventHandler({
|
|
|
|
|
ctx: {} as never,
|
|
|
|
|
pluginConfig: {} as never,
|
|
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
|
|
|
|
managers: {
|
|
|
|
|
skillMcpManager: {
|
|
|
|
|
disconnectSession: async () => {},
|
|
|
|
|
},
|
|
|
|
|
tmuxSessionManager: {
|
|
|
|
|
onSessionCreated: async () => {},
|
|
|
|
|
onSessionDeleted: async () => {},
|
|
|
|
|
},
|
|
|
|
|
} as never,
|
|
|
|
|
hooks: {} as never,
|
|
|
|
|
})
|
|
|
|
|
const sessionID = "ses_prompt_params_deleted"
|
|
|
|
|
setSessionPromptParams(sessionID, {
|
|
|
|
|
temperature: 0.4,
|
|
|
|
|
topP: 0.7,
|
|
|
|
|
options: { reasoningEffort: "high" },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when
|
2026-04-03 17:37:17 +09:00
|
|
|
await eventHandler(asEventHandlerInput({
|
2026-03-18 14:21:27 +01:00
|
|
|
event: {
|
|
|
|
|
type: "session.deleted",
|
|
|
|
|
properties: { info: { id: sessionID } },
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
}))
|
2026-03-18 14:21:27 +01:00
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(getSessionPromptParams(sessionID)).toBeUndefined()
|
|
|
|
|
})
|
2026-02-18 16:48:48 -03:00
|
|
|
})
|
2026-03-17 15:17:34 +09:00
|
|
|
|
|
|
|
|
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[] = []
|
|
|
|
|
const promptCalls: string[] = []
|
|
|
|
|
const modelFallback = createModelFallbackHook()
|
2026-04-18 02:35:46 +09:00
|
|
|
clearPendingModelFallback(modelFallback, sessionID)
|
2026-03-17 15:17:34 +09:00
|
|
|
|
|
|
|
|
const eventHandler = createEventHandler({
|
2026-04-03 17:37:17 +09:00
|
|
|
ctx: asEventHandlerContext({
|
2026-03-17 15:17:34 +09:00
|
|
|
directory: "/tmp",
|
|
|
|
|
client: {
|
|
|
|
|
session: {
|
|
|
|
|
abort: async ({ path }: { path: { id: string } }) => {
|
|
|
|
|
abortCalls.push(path.id)
|
|
|
|
|
return {}
|
|
|
|
|
},
|
|
|
|
|
prompt: async ({ path }: { path: { id: string } }) => {
|
|
|
|
|
promptCalls.push(path.id)
|
|
|
|
|
return {}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
}),
|
|
|
|
|
pluginConfig: asPluginConfig({}),
|
2026-03-17 15:17:34 +09:00
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
managers: createEventHandlerManagers({
|
2026-03-17 15:17:34 +09:00
|
|
|
skillMcpManager: {
|
|
|
|
|
disconnectSession: async () => {},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
}),
|
|
|
|
|
hooks: createEventHandlerHooks({
|
2026-03-17 15:17:34 +09:00
|
|
|
modelFallback,
|
|
|
|
|
stopContinuationGuard: { isStopped: () => false },
|
2026-04-03 17:37:17 +09:00
|
|
|
}),
|
2026-03-17 15:17:34 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const chatMessageHandler = createChatMessageHandler({
|
2026-04-03 17:37:17 +09:00
|
|
|
ctx: asChatMessageHandlerContext({
|
2026-03-17 15:17:34 +09:00
|
|
|
client: {
|
|
|
|
|
tui: {
|
|
|
|
|
showToast: async () => ({}),
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
}),
|
|
|
|
|
pluginConfig: asChatPluginConfig({}),
|
2026-03-17 15:17:34 +09:00
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
shouldOverride: () => false,
|
|
|
|
|
markApplied: () => {},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
hooks: createChatMessageHandlerHooks({
|
2026-03-17 15:17:34 +09:00
|
|
|
modelFallback,
|
|
|
|
|
stopContinuationGuard: null,
|
|
|
|
|
keywordDetector: null,
|
|
|
|
|
claudeCodeHooks: null,
|
|
|
|
|
autoSlashCommand: null,
|
|
|
|
|
startWork: null,
|
|
|
|
|
ralphLoop: null,
|
2026-04-03 17:37:17 +09:00
|
|
|
}),
|
2026-03-17 15:17:34 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const retryStatus = {
|
|
|
|
|
type: "retry",
|
|
|
|
|
attempt: 1,
|
2026-04-17 14:51:52 +09:00
|
|
|
message: "All credentials for model claude-opus-4-7-thinking are cooling down [retrying in 7m 56s attempt #1]",
|
2026-03-17 15:17:34 +09:00
|
|
|
next: 476,
|
|
|
|
|
} as const
|
|
|
|
|
|
2026-04-03 17:37:17 +09:00
|
|
|
await eventHandler(asEventHandlerInput({
|
2026-03-17 15:17:34 +09:00
|
|
|
event: {
|
|
|
|
|
type: "message.updated",
|
|
|
|
|
properties: {
|
|
|
|
|
info: {
|
|
|
|
|
id: "msg_user_retry_rearm",
|
|
|
|
|
sessionID,
|
|
|
|
|
role: "user",
|
2026-04-17 14:51:52 +09:00
|
|
|
modelID: "claude-opus-4-7-thinking",
|
2026-03-17 15:17:34 +09:00
|
|
|
providerID: "anthropic",
|
2026-04-07 10:08:04 +09:00
|
|
|
agent: "Sisyphus - Ultraworker",
|
2026-03-17 15:17:34 +09:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
}))
|
2026-03-17 15:17:34 +09:00
|
|
|
|
|
|
|
|
//#when - first retry key is handled
|
2026-04-03 17:37:17 +09:00
|
|
|
await eventHandler(asEventHandlerInput({
|
2026-03-17 15:17:34 +09:00
|
|
|
event: {
|
|
|
|
|
type: "session.status",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID,
|
|
|
|
|
status: retryStatus,
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
}))
|
2026-03-17 15:17:34 +09:00
|
|
|
|
|
|
|
|
const firstOutput = { message: {}, parts: [] as Array<{ type: string; text?: string }> }
|
|
|
|
|
await chatMessageHandler(
|
|
|
|
|
{
|
|
|
|
|
sessionID,
|
|
|
|
|
agent: "sisyphus",
|
2026-04-17 14:51:52 +09:00
|
|
|
model: { providerID: "anthropic", modelID: "claude-opus-4-7-thinking" },
|
2026-03-17 15:17:34 +09:00
|
|
|
},
|
|
|
|
|
firstOutput,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
//#when - session recovers to non-retry idle state
|
2026-04-03 17:37:17 +09:00
|
|
|
await eventHandler(asEventHandlerInput({
|
2026-03-17 15:17:34 +09:00
|
|
|
event: {
|
|
|
|
|
type: "session.status",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID,
|
|
|
|
|
status: { type: "idle" },
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
}))
|
2026-03-17 15:17:34 +09:00
|
|
|
|
|
|
|
|
//#when - same retry key appears again after recovery
|
2026-04-03 17:37:17 +09:00
|
|
|
await eventHandler(asEventHandlerInput({
|
2026-03-17 15:17:34 +09:00
|
|
|
event: {
|
|
|
|
|
type: "session.status",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID,
|
|
|
|
|
status: retryStatus,
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
}))
|
2026-03-17 15:17:34 +09:00
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(abortCalls).toEqual([sessionID, sessionID])
|
|
|
|
|
expect(promptCalls).toEqual([sessionID, sessionID])
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-03-23 18:12:51 +09:00
|
|
|
|
|
|
|
|
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[] = []
|
|
|
|
|
|
|
|
|
|
const eventHandler = createEventHandler({
|
2026-04-03 17:37:17 +09:00
|
|
|
ctx: asEventHandlerContext({
|
2026-03-23 18:12:51 +09:00
|
|
|
directory: "/tmp",
|
|
|
|
|
client: {
|
|
|
|
|
session: {
|
|
|
|
|
abort: async () => ({}),
|
|
|
|
|
summarize: async () => {
|
|
|
|
|
callOrder.push("summarize")
|
|
|
|
|
return {}
|
|
|
|
|
},
|
|
|
|
|
prompt: async () => {
|
|
|
|
|
callOrder.push("prompt")
|
|
|
|
|
return {}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
}),
|
|
|
|
|
pluginConfig: asPluginConfig({}),
|
2026-03-23 18:12:51 +09:00
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
managers: createEventHandlerManagers(),
|
|
|
|
|
hooks: createEventHandlerHooks({
|
2026-03-23 18:12:51 +09:00
|
|
|
sessionRecovery: {
|
|
|
|
|
isRecoverableError: () => true,
|
|
|
|
|
handleSessionRecovery: async () => true,
|
|
|
|
|
},
|
|
|
|
|
stopContinuationGuard: { isStopped: () => false },
|
2026-04-03 17:37:17 +09:00
|
|
|
}),
|
2026-03-23 18:12:51 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when
|
2026-04-03 17:37:17 +09:00
|
|
|
await eventHandler(asEventHandlerInput({
|
2026-03-23 18:12:51 +09:00
|
|
|
event: {
|
|
|
|
|
type: "session.error",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID,
|
|
|
|
|
messageID: "msg_123",
|
|
|
|
|
error: { name: "Error", message: "tool_result block(s) that are not immediately" },
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
}))
|
2026-03-23 18:12:51 +09:00
|
|
|
|
|
|
|
|
//#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[] = []
|
|
|
|
|
|
|
|
|
|
const eventHandler = createEventHandler({
|
2026-04-03 17:37:17 +09:00
|
|
|
ctx: asEventHandlerContext({
|
2026-03-23 18:12:51 +09:00
|
|
|
directory: "/tmp",
|
|
|
|
|
client: {
|
|
|
|
|
session: {
|
|
|
|
|
abort: async () => ({}),
|
|
|
|
|
summarize: async () => {
|
|
|
|
|
callOrder.push("summarize")
|
|
|
|
|
throw new Error("compaction failed")
|
|
|
|
|
},
|
|
|
|
|
prompt: async () => {
|
|
|
|
|
callOrder.push("prompt")
|
|
|
|
|
return {}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
}),
|
|
|
|
|
pluginConfig: asPluginConfig({}),
|
2026-03-23 18:12:51 +09:00
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
managers: createEventHandlerManagers(),
|
|
|
|
|
hooks: createEventHandlerHooks({
|
2026-03-23 18:12:51 +09:00
|
|
|
sessionRecovery: {
|
|
|
|
|
isRecoverableError: () => true,
|
|
|
|
|
handleSessionRecovery: async () => true,
|
|
|
|
|
},
|
|
|
|
|
stopContinuationGuard: { isStopped: () => false },
|
2026-04-03 17:37:17 +09:00
|
|
|
}),
|
2026-03-23 18:12:51 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when
|
2026-04-03 17:37:17 +09:00
|
|
|
await eventHandler(asEventHandlerInput({
|
2026-03-23 18:12:51 +09:00
|
|
|
event: {
|
|
|
|
|
type: "session.error",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID,
|
|
|
|
|
messageID: "msg_456",
|
|
|
|
|
error: { name: "Error", message: "tool_result block(s) that are not immediately" },
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
}))
|
2026-03-23 18:12:51 +09:00
|
|
|
|
|
|
|
|
//#then - continue is still sent even when compaction fails
|
|
|
|
|
expect(callOrder).toEqual(["summarize", "prompt"])
|
|
|
|
|
})
|
2026-03-27 09:30:43 +01:00
|
|
|
|
|
|
|
|
it("continues dispatching later event hooks when an earlier hook throws", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const runtimeFallbackCalls: EventInput[] = []
|
|
|
|
|
|
|
|
|
|
const eventHandler = createEventHandler({
|
2026-04-03 17:37:17 +09:00
|
|
|
ctx: asEventHandlerContext({
|
2026-03-27 09:30:43 +01:00
|
|
|
directory: "/tmp",
|
|
|
|
|
client: {
|
|
|
|
|
session: {
|
|
|
|
|
abort: async () => ({}),
|
|
|
|
|
prompt: async () => ({}),
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
}),
|
|
|
|
|
pluginConfig: asPluginConfig({}),
|
2026-03-27 09:30:43 +01:00
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
managers: createEventHandlerManagers(),
|
|
|
|
|
hooks: createEventHandlerHooks({
|
2026-03-27 09:30:43 +01:00
|
|
|
autoUpdateChecker: {
|
|
|
|
|
event: async () => {
|
|
|
|
|
throw new Error("upstream hook failed")
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
runtimeFallback: {
|
|
|
|
|
event: async (input: EventInput) => {
|
|
|
|
|
runtimeFallbackCalls.push(input)
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
stopContinuationGuard: { isStopped: () => false },
|
2026-04-03 17:37:17 +09:00
|
|
|
}),
|
2026-03-27 09:30:43 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
let thrownError: unknown
|
|
|
|
|
try {
|
2026-04-03 17:37:17 +09:00
|
|
|
await eventHandler(asEventHandlerInput({
|
2026-03-27 09:30:43 +01:00
|
|
|
event: {
|
|
|
|
|
type: "session.error",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID: "ses_hook_isolation",
|
|
|
|
|
error: { name: "Error", message: "retry me" },
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-03 17:37:17 +09:00
|
|
|
}))
|
2026-03-27 09:30:43 +01:00
|
|
|
} catch (error) {
|
|
|
|
|
thrownError = error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(thrownError).toBeUndefined()
|
|
|
|
|
expect(runtimeFallbackCalls).toHaveLength(1)
|
|
|
|
|
expect(runtimeFallbackCalls[0]?.event.type).toBe("session.error")
|
|
|
|
|
})
|
2026-03-23 18:12:51 +09:00
|
|
|
})
|