fix(compaction): isolate preservation hook failures

This commit is contained in:
YeonGyu-Kim
2026-05-11 12:54:32 +09:00
parent bd4db2acfd
commit 954aa1f121
4 changed files with 210 additions and 85 deletions
+81 -49
View File
@@ -1,46 +1,9 @@
import { describe, expect, it, mock } from "bun:test"
function createCompactingHandler(hooks: {
compactionContextInjector?: {
capture: (sessionID: string) => Promise<void>
inject: (sessionID: string) => string
}
compactionTodoPreserver?: { capture: (sessionID: string) => Promise<void> }
claudeCodeHooks?: {
"experimental.session.compacting"?: (
input: { sessionID: string },
output: { context: string[] },
) => Promise<void>
}
}) {
return async (
input: { sessionID: string },
output: { context: string[] },
): Promise<void> => {
await hooks.compactionContextInjector?.capture(input.sessionID)
await hooks.compactionTodoPreserver?.capture(input.sessionID)
await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.(
input,
output,
)
if (hooks.compactionContextInjector) {
output.context.push(hooks.compactionContextInjector.inject(input.sessionID))
}
}
}
function createCompactionAutocontinueHandler(hooks: {
compactionContextInjector?: { restore: (sessionID: string) => Promise<boolean> }
compactionTodoPreserver?: { restore: (sessionID: string) => Promise<void> }
}) {
return async (
input: { sessionID: string },
_output: { enabled: boolean },
): Promise<void> => {
await hooks.compactionContextInjector?.restore(input.sessionID)
await hooks.compactionTodoPreserver?.restore(input.sessionID)
}
}
import {
createCompactionAutocontinueHandler,
createSessionCompactingHandler,
} from "./plugin/session-compacting"
describe("experimental.session.compacting handler", () => {
//#given all three hooks are present
@@ -49,7 +12,7 @@ describe("experimental.session.compacting handler", () => {
it("calls claudeCodeHooks PreCompact alongside other hooks", async () => {
const callOrder: string[] = []
const handler = createCompactingHandler({
const handler = createSessionCompactingHandler({
compactionContextInjector: {
capture: mock(async () => {
callOrder.push("checkpointCapture")
@@ -71,7 +34,7 @@ describe("experimental.session.compacting handler", () => {
},
})
const output = { context: [] as string[] }
const output = { context: [] as string[], prompt: undefined as string | undefined }
await handler({ sessionID: "ses_test" }, output)
expect(callOrder).toEqual([
@@ -87,7 +50,7 @@ describe("experimental.session.compacting handler", () => {
//#when compacting handler is invoked
//#then injected context from PreCompact is preserved in output
it("preserves context injected by PreCompact hooks", async () => {
const handler = createCompactingHandler({
const handler = createSessionCompactingHandler({
claudeCodeHooks: {
"experimental.session.compacting": async (_input, output) => {
output.context.push("precompact-injected-context")
@@ -95,7 +58,7 @@ describe("experimental.session.compacting handler", () => {
},
})
const output = { context: [] as string[] }
const output = { context: [] as string[], prompt: undefined as string | undefined }
await handler({ sessionID: "ses_test" }, output)
expect(output.context).toContain("precompact-injected-context")
@@ -109,7 +72,7 @@ describe("experimental.session.compacting handler", () => {
const checkpointCaptureMock = mock(async () => {})
const contextMock = mock(() => "injected-context")
const handler = createCompactingHandler({
const handler = createSessionCompactingHandler({
compactionContextInjector: {
capture: checkpointCaptureMock,
inject: contextMock,
@@ -118,7 +81,7 @@ describe("experimental.session.compacting handler", () => {
claudeCodeHooks: undefined,
})
const output = { context: [] as string[] }
const output = { context: [] as string[], prompt: undefined as string | undefined }
await handler({ sessionID: "ses_test" }, output)
expect(checkpointCaptureMock).toHaveBeenCalledWith("ses_test")
@@ -133,19 +96,67 @@ describe("experimental.session.compacting handler", () => {
it("does not early-return when compactionContextInjector is null", async () => {
const preCompactMock = mock(async () => {})
const handler = createCompactingHandler({
const handler = createSessionCompactingHandler({
claudeCodeHooks: {
"experimental.session.compacting": preCompactMock,
},
compactionContextInjector: undefined,
})
const output = { context: [] as string[] }
const output = { context: [] as string[], prompt: undefined as string | undefined }
await handler({ sessionID: "ses_test" }, output)
expect(preCompactMock).toHaveBeenCalled()
expect(output.context).toEqual([])
})
//#given a preservation hook throws while OpenCode is compacting
//#when compacting handler is invoked
//#then compaction still continues so the user does not see a failed compact
it("continues compaction when an internal preservation hook throws", async () => {
const preCompactMock = mock(async (_input, output: { context: string[] }) => {
output.context.push("precompact-context")
})
const handler = createSessionCompactingHandler({
compactionContextInjector: {
capture: mock(async () => {
throw new Error("checkpoint api down")
}),
inject: mock(() => "injected-context"),
},
compactionTodoPreserver: {
capture: mock(async () => {}),
},
claudeCodeHooks: {
"experimental.session.compacting": preCompactMock,
},
})
const output = { context: [] as string[], prompt: undefined as string | undefined }
await expect(handler({ sessionID: "ses_test" }, output)).resolves.toBeUndefined()
expect(preCompactMock).toHaveBeenCalled()
expect(output.context).toContain("precompact-context")
})
//#given a PreCompact hook replaces the OpenCode compaction prompt
//#when compacting handler is invoked
//#then the prompt replacement is preserved for OpenCode
it("preserves prompt replacement from PreCompact hooks", async () => {
const handler = createSessionCompactingHandler({
claudeCodeHooks: {
"experimental.session.compacting": mock(async (_input, output) => {
output.prompt = "custom compaction prompt"
}),
},
})
const output = { context: [] as string[], prompt: undefined as string | undefined }
await handler({ sessionID: "ses_prompt" }, output)
expect(output.prompt).toBe("custom compaction prompt")
})
})
describe("experimental.compaction.autocontinue handler", () => {
@@ -177,4 +188,25 @@ describe("experimental.compaction.autocontinue handler", () => {
expect(callOrder).toEqual(["context", "todos:ses_autocontinue"])
expect(output.enabled).toBe(true)
})
it("continues autocontinue restore when one restore hook throws", async () => {
//#given
const restoreMock = mock(async () => {})
const handler = createCompactionAutocontinueHandler({
compactionContextInjector: {
restore: mock(async () => {
throw new Error("checkpoint restore failed")
}),
},
compactionTodoPreserver: { restore: restoreMock },
})
const output = { enabled: true }
//#when
await expect(handler({ sessionID: "ses_autocontinue" }, output)).resolves.toBeUndefined()
//#then
expect(restoreMock).toHaveBeenCalledWith("ses_autocontinue")
expect(output.enabled).toBe(true)
})
})