fix(compaction): isolate preservation hook failures
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user