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" import { describe, expect, it, mock } from "bun:test"
function createCompactingHandler(hooks: { import {
compactionContextInjector?: { createCompactionAutocontinueHandler,
capture: (sessionID: string) => Promise<void> createSessionCompactingHandler,
inject: (sessionID: string) => string } from "./plugin/session-compacting"
}
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)
}
}
describe("experimental.session.compacting handler", () => { describe("experimental.session.compacting handler", () => {
//#given all three hooks are present //#given all three hooks are present
@@ -49,7 +12,7 @@ describe("experimental.session.compacting handler", () => {
it("calls claudeCodeHooks PreCompact alongside other hooks", async () => { it("calls claudeCodeHooks PreCompact alongside other hooks", async () => {
const callOrder: string[] = [] const callOrder: string[] = []
const handler = createCompactingHandler({ const handler = createSessionCompactingHandler({
compactionContextInjector: { compactionContextInjector: {
capture: mock(async () => { capture: mock(async () => {
callOrder.push("checkpointCapture") 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) await handler({ sessionID: "ses_test" }, output)
expect(callOrder).toEqual([ expect(callOrder).toEqual([
@@ -87,7 +50,7 @@ describe("experimental.session.compacting handler", () => {
//#when compacting handler is invoked //#when compacting handler is invoked
//#then injected context from PreCompact is preserved in output //#then injected context from PreCompact is preserved in output
it("preserves context injected by PreCompact hooks", async () => { it("preserves context injected by PreCompact hooks", async () => {
const handler = createCompactingHandler({ const handler = createSessionCompactingHandler({
claudeCodeHooks: { claudeCodeHooks: {
"experimental.session.compacting": async (_input, output) => { "experimental.session.compacting": async (_input, output) => {
output.context.push("precompact-injected-context") 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) await handler({ sessionID: "ses_test" }, output)
expect(output.context).toContain("precompact-injected-context") expect(output.context).toContain("precompact-injected-context")
@@ -109,7 +72,7 @@ describe("experimental.session.compacting handler", () => {
const checkpointCaptureMock = mock(async () => {}) const checkpointCaptureMock = mock(async () => {})
const contextMock = mock(() => "injected-context") const contextMock = mock(() => "injected-context")
const handler = createCompactingHandler({ const handler = createSessionCompactingHandler({
compactionContextInjector: { compactionContextInjector: {
capture: checkpointCaptureMock, capture: checkpointCaptureMock,
inject: contextMock, inject: contextMock,
@@ -118,7 +81,7 @@ describe("experimental.session.compacting handler", () => {
claudeCodeHooks: undefined, claudeCodeHooks: undefined,
}) })
const output = { context: [] as string[] } const output = { context: [] as string[], prompt: undefined as string | undefined }
await handler({ sessionID: "ses_test" }, output) await handler({ sessionID: "ses_test" }, output)
expect(checkpointCaptureMock).toHaveBeenCalledWith("ses_test") expect(checkpointCaptureMock).toHaveBeenCalledWith("ses_test")
@@ -133,19 +96,67 @@ describe("experimental.session.compacting handler", () => {
it("does not early-return when compactionContextInjector is null", async () => { it("does not early-return when compactionContextInjector is null", async () => {
const preCompactMock = mock(async () => {}) const preCompactMock = mock(async () => {})
const handler = createCompactingHandler({ const handler = createSessionCompactingHandler({
claudeCodeHooks: { claudeCodeHooks: {
"experimental.session.compacting": preCompactMock, "experimental.session.compacting": preCompactMock,
}, },
compactionContextInjector: undefined, compactionContextInjector: undefined,
}) })
const output = { context: [] as string[] } const output = { context: [] as string[], prompt: undefined as string | undefined }
await handler({ sessionID: "ses_test" }, output) await handler({ sessionID: "ses_test" }, output)
expect(preCompactMock).toHaveBeenCalled() expect(preCompactMock).toHaveBeenCalled()
expect(output.context).toEqual([]) 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", () => { describe("experimental.compaction.autocontinue handler", () => {
@@ -177,4 +188,25 @@ describe("experimental.compaction.autocontinue handler", () => {
expect(callOrder).toEqual(["context", "todos:ses_autocontinue"]) expect(callOrder).toEqual(["context", "todos:ses_autocontinue"])
expect(output.enabled).toBe(true) 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)
})
}) })
@@ -5,32 +5,34 @@ describe("experimental.session.compacting", () => {
test("does not hardcode a model and uses output.context", () => { test("does not hardcode a model and uses output.context", () => {
//#given //#given
const indexUrl = new URL("./index.ts", import.meta.url) const indexUrl = new URL("./index.ts", import.meta.url)
const compactionUrl = new URL("./plugin/session-compacting.ts", import.meta.url)
const content = readFileSync(indexUrl, "utf-8") const content = readFileSync(indexUrl, "utf-8")
const hookIndex = content.indexOf('"experimental.session.compacting"') const compactionContent = readFileSync(compactionUrl, "utf-8")
//#when //#when
const hookSlice = hookIndex >= 0 ? content.slice(hookIndex, hookIndex + 1200) : "" const hookIndex = content.indexOf("createSessionCompactingHandler")
//#then //#then
expect(hookIndex).toBeGreaterThanOrEqual(0) expect(hookIndex).toBeGreaterThanOrEqual(0)
expect(content.includes('modelID: "claude-opus-4-7"')).toBe(false) expect(`${content}\n${compactionContent}`.includes('modelID: "claude-opus-4-7"')).toBe(false)
expect(hookSlice.includes("output.context.push")).toBe(true) expect(compactionContent.includes("output.context.push")).toBe(true)
expect(hookSlice.includes("providerID:")).toBe(false) expect(compactionContent.includes("providerID:")).toBe(false)
expect(hookSlice.includes("modelID:")).toBe(false) expect(compactionContent.includes("modelID:")).toBe(false)
}) })
test("registers autocontinue restores before OpenCode synthetic continue", () => { test("registers autocontinue restores before OpenCode synthetic continue", () => {
//#given //#given
const indexUrl = new URL("./index.ts", import.meta.url) const indexUrl = new URL("./index.ts", import.meta.url)
const compactionUrl = new URL("./plugin/session-compacting.ts", import.meta.url)
const content = readFileSync(indexUrl, "utf-8") const content = readFileSync(indexUrl, "utf-8")
const hookIndex = content.lastIndexOf('"experimental.compaction.autocontinue"') const compactionContent = readFileSync(compactionUrl, "utf-8")
//#when //#when
const hookSlice = hookIndex >= 0 ? content.slice(hookIndex, hookIndex + 500) : "" const hookIndex = content.indexOf("createCompactionAutocontinueHandler")
//#then //#then
expect(hookIndex).toBeGreaterThanOrEqual(0) expect(hookIndex).toBeGreaterThanOrEqual(0)
expect(hookSlice.includes("compactionContextInjector?.restore")).toBe(true) expect(compactionContent.includes("compactionContextInjector?.restore")).toBe(true)
expect(hookSlice.includes("compactionTodoPreserver?.restore")).toBe(true) expect(compactionContent.includes("compactionTodoPreserver?.restore")).toBe(true)
}) })
}) })
+7 -26
View File
@@ -9,6 +9,11 @@ import { createRuntimeTmuxConfig, isTmuxIntegrationEnabled } from "./create-runt
import { createTools } from "./create-tools" import { createTools } from "./create-tools"
import { initializeOpenClaw } from "./openclaw" import { initializeOpenClaw } from "./openclaw"
import { createPluginInterface } from "./plugin-interface" import { createPluginInterface } from "./plugin-interface"
import {
createCompactionAutocontinueHandler,
createSessionCompactingHandler,
type CompactionAutocontinueHook,
} from "./plugin/session-compacting"
import { loadPluginConfig } from "./plugin-config" import { loadPluginConfig } from "./plugin-config"
import { createModelCacheState } from "./plugin-state" import { createModelCacheState } from "./plugin-state"
@@ -18,11 +23,6 @@ import { installAgentSortShim, setAgentSortOrder } from "./shared/agent-sort-shi
import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector" import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector"
import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash" import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash"
type CompactionAutocontinueHook = (
input: { sessionID: string },
output: { enabled: boolean },
) => Promise<void>
type HooksWithCompactionAutocontinue = Hooks & { type HooksWithCompactionAutocontinue = Hooks & {
"experimental.compaction.autocontinue"?: CompactionAutocontinueHook "experimental.compaction.autocontinue"?: CompactionAutocontinueHook
} }
@@ -117,28 +117,9 @@ const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
const pluginHooks: HooksWithCompactionAutocontinue = { const pluginHooks: HooksWithCompactionAutocontinue = {
...pluginInterface, ...pluginInterface,
"experimental.session.compacting": async ( "experimental.session.compacting": createSessionCompactingHandler(hooks),
compactingInput: { sessionID: string },
output: { context: string[] },
): Promise<void> => {
await hooks.compactionContextInjector?.capture(compactingInput.sessionID)
await hooks.compactionTodoPreserver?.capture(compactingInput.sessionID)
await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.(
compactingInput,
output,
)
if (hooks.compactionContextInjector) {
output.context.push(hooks.compactionContextInjector.inject(compactingInput.sessionID))
}
},
"experimental.compaction.autocontinue": async ( "experimental.compaction.autocontinue": createCompactionAutocontinueHandler(hooks),
autocontinueInput: { sessionID: string },
_output: { enabled: boolean },
): Promise<void> => {
await hooks.compactionContextInjector?.restore(autocontinueInput.sessionID)
await hooks.compactionTodoPreserver?.restore(autocontinueInput.sessionID)
},
} }
return pluginHooks return pluginHooks
+110
View File
@@ -0,0 +1,110 @@
import type { Hooks } from "@opencode-ai/plugin"
import { log } from "../shared/logger"
type SessionCompactingHook = NonNullable<Hooks["experimental.session.compacting"]>
type SessionCompactingInput = Parameters<SessionCompactingHook>[0]
type SessionCompactingOutput = Parameters<SessionCompactingHook>[1]
export type CompactionAutocontinueInput = {
sessionID: string
agent?: string
model?: unknown
provider?: unknown
message?: unknown
overflow?: boolean
}
export type CompactionAutocontinueOutput = {
enabled: boolean
}
export type CompactionAutocontinueHook = (
input: CompactionAutocontinueInput,
output: CompactionAutocontinueOutput,
) => Promise<void>
type CompactionHookDependencies = {
compactionContextInjector?: {
capture?: (sessionID: string) => Promise<void>
inject?: (sessionID: string) => string
restore?: (sessionID: string) => Promise<boolean>
} | null
compactionTodoPreserver?: {
capture?: (sessionID: string) => Promise<void>
restore?: (sessionID: string) => Promise<void>
} | null
claudeCodeHooks?: {
"experimental.session.compacting"?: SessionCompactingHook
} | null
}
async function runCompactionStep(
hook: string,
sessionID: string,
action: () => Promise<void> | void,
): Promise<void> {
try {
await action()
} catch (error) {
log("[session-compacting] hook execution failed", {
hook,
sessionID,
error: String(error),
})
}
}
export function createSessionCompactingHandler(
hooks: CompactionHookDependencies,
): SessionCompactingHook {
return async (
input: SessionCompactingInput,
output: SessionCompactingOutput,
): Promise<void> => {
await runCompactionStep("compactionContextInjector.capture", input.sessionID, async () => {
const capture = hooks.compactionContextInjector?.capture
if (capture) {
await capture(input.sessionID)
}
})
await runCompactionStep("compactionTodoPreserver.capture", input.sessionID, async () => {
const capture = hooks.compactionTodoPreserver?.capture
if (capture) {
await capture(input.sessionID)
}
})
await runCompactionStep("claudeCodeHooks.experimental.session.compacting", input.sessionID, async () => {
await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.(input, output)
})
await runCompactionStep("compactionContextInjector.inject", input.sessionID, () => {
const inject = hooks.compactionContextInjector?.inject
const context = inject ? inject(input.sessionID) : undefined
if (context) {
output.context.push(context)
}
})
}
}
export function createCompactionAutocontinueHandler(
hooks: CompactionHookDependencies,
): CompactionAutocontinueHook {
return async (
input: CompactionAutocontinueInput,
_output: CompactionAutocontinueOutput,
): Promise<void> => {
await runCompactionStep("compactionContextInjector.restore", input.sessionID, async () => {
const restore = hooks.compactionContextInjector?.restore
if (restore) {
await restore(input.sessionID)
}
})
await runCompactionStep("compactionTodoPreserver.restore", input.sessionID, async () => {
const restore = hooks.compactionTodoPreserver?.restore
if (restore) {
await restore(input.sessionID)
}
})
}
}