diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index 67089e2a5..dfd2aa9ee 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -585,3 +585,123 @@ describe("createEventHandler - retry dedupe lifecycle", () => { expect(promptCalls).toEqual([sessionID, sessionID]) }) }) + +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({ + ctx: { + directory: "/tmp", + client: { + session: { + abort: async () => ({}), + summarize: async () => { + callOrder.push("summarize") + return {} + }, + prompt: async () => { + callOrder.push("prompt") + return {} + }, + }, + }, + } as any, + pluginConfig: {} as any, + firstMessageVariantGate: { + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: { + tmuxSessionManager: { + onSessionCreated: async () => {}, + onSessionDeleted: async () => {}, + }, + } as any, + hooks: { + sessionRecovery: { + isRecoverableError: () => true, + handleSessionRecovery: async () => true, + }, + stopContinuationGuard: { isStopped: () => false }, + } as any, + }) + + //#when + await eventHandler({ + event: { + type: "session.error", + properties: { + sessionID, + messageID: "msg_123", + error: { name: "Error", message: "tool_result block(s) that are not immediately" }, + }, + }, + } as any) + + //#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({ + ctx: { + directory: "/tmp", + client: { + session: { + abort: async () => ({}), + summarize: async () => { + callOrder.push("summarize") + throw new Error("compaction failed") + }, + prompt: async () => { + callOrder.push("prompt") + return {} + }, + }, + }, + } as any, + pluginConfig: {} as any, + firstMessageVariantGate: { + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: { + tmuxSessionManager: { + onSessionCreated: async () => {}, + onSessionDeleted: async () => {}, + }, + } as any, + hooks: { + sessionRecovery: { + isRecoverableError: () => true, + handleSessionRecovery: async () => true, + }, + stopContinuationGuard: { isStopped: () => false }, + } as any, + }) + + //#when + await eventHandler({ + event: { + type: "session.error", + properties: { + sessionID, + messageID: "msg_456", + error: { name: "Error", message: "tool_result block(s) that are not immediately" }, + }, + }, + } as any) + + //#then - continue is still sent even when compaction fails + expect(callOrder).toEqual(["summarize", "prompt"]) + }) +}) diff --git a/src/plugin/event.ts b/src/plugin/event.ts index d55783477..5d8383df0 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -148,6 +148,8 @@ export function createEventHandler(args: { body: { parts: Array<{ type: "text"; text: string }> }; query: { directory: string }; }) => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + summarize: (...args: any[]) => Promise; }; }; }; @@ -502,6 +504,17 @@ export function createEventHandler(args: { sessionID === getMainSessionID() && !hooks.stopContinuationGuard?.isStopped(sessionID) ) { + // Trigger compaction before sending "continue" to avoid double-sending continuation + await pluginContext.client.session + .summarize({ + path: { id: sessionID }, + body: { auto: true }, + query: { directory: pluginContext.directory }, + }) + .catch((err: unknown) => { + log("[event] compaction before recovery continue failed:", { sessionID, error: err }); + }); + await pluginContext.client.session .prompt({ path: { id: sessionID },