From dd29e9b96a939f6f3cb60e26959279fe1e099235 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 13:02:00 +0900 Subject: [PATCH] fix(compaction): restore context and todos before continue --- src/hooks/compaction-context-injector/hook.ts | 10 +- .../compaction-context-injector/index.test.ts | 113 +++++++- .../compaction-context-injector/recovery.ts | 4 +- .../compaction-context-injector/types.ts | 1 + src/hooks/compaction-todo-preserver/hook.ts | 125 ++++++++- .../compaction-todo-preserver/index.test.ts | 247 +++++++++++++++++- src/index.compacting.test.ts | 44 ++++ ...x.compaction-model-agnostic.static.test.ts | 15 ++ src/index.ts | 21 +- src/plugin/tool-execute-before.test.ts | 36 +++ src/plugin/tool-execute-before.ts | 1 + 11 files changed, 601 insertions(+), 16 deletions(-) diff --git a/src/hooks/compaction-context-injector/hook.ts b/src/hooks/compaction-context-injector/hook.ts index 462dc18a6..29e253518 100644 --- a/src/hooks/compaction-context-injector/hook.ts +++ b/src/hooks/compaction-context-injector/hook.ts @@ -35,7 +35,15 @@ export function createCompactionContextInjector(options?: { const { recoverCheckpointedAgentConfig, maybeWarnAboutNoTextTail } = createRecoveryLogic(ctx, getTailState) + const restore = async (sessionID: string): Promise => { + return recoverCheckpointedAgentConfig(sessionID, "compaction.autocontinue") + } + const capture = async (sessionID: string): Promise => { + if (sessionID) { + clearCompactionAgentConfigCheckpoint(sessionID) + } + if (!ctx || !sessionID) { return } @@ -160,5 +168,5 @@ export function createCompactionContextInjector(options?: { } } - return { capture, inject, event } + return { capture, restore, inject, event } } diff --git a/src/hooks/compaction-context-injector/index.test.ts b/src/hooks/compaction-context-injector/index.test.ts index 69cb082a9..ad4972f06 100644 --- a/src/hooks/compaction-context-injector/index.test.ts +++ b/src/hooks/compaction-context-injector/index.test.ts @@ -19,7 +19,9 @@ afterAll(() => { }) import { createCompactionContextInjector } from "./index" +import type { BackgroundManager } from "../../features/background-agent" import { TaskHistory } from "../../features/background-agent/task-history" +import { setCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint" function createMockContext( messageResponses: Array }>>, @@ -42,6 +44,10 @@ function createMockContext( } } +function createMockBackgroundManager(): BackgroundManager { + return { taskHistory: new TaskHistory() } as BackgroundManager +} + describe("createCompactionContextInjector", () => { describe("Agent Verification State preservation", () => { it("includes Agent Verification State section in compaction prompt", async () => { @@ -112,7 +118,7 @@ describe("createCompactionContextInjector", () => { it("injects actual task history when backgroundManager and sessionID provided", async () => { //#given - const mockManager = { taskHistory: new TaskHistory() } as any + const mockManager = createMockBackgroundManager() mockManager.taskHistory.record("ses_parent", { id: "t1", sessionID: "ses_child", agent: "explore", description: "Find patterns", status: "completed", category: "quick" }) const injector = createCompactionContextInjector({ backgroundManager: mockManager }) @@ -128,7 +134,7 @@ describe("createCompactionContextInjector", () => { it("does not inject task history section when no entries exist", async () => { //#given - const mockManager = { taskHistory: new TaskHistory() } as any + const mockManager = createMockBackgroundManager() const injector = createCompactionContextInjector({ backgroundManager: mockManager }) //#when @@ -164,12 +170,22 @@ describe("createCompactionContextInjector", () => { }, }, ], + [ + { + info: { + role: "user", + agent: "compaction", + model: { providerID: "anthropic", modelID: "claude-opus-4-1" }, + }, + }, + ], [ { info: { role: "user", agent: "atlas", model: { providerID: "openai", modelID: "gpt-5" }, + tools: { bash: true }, }, }, ], @@ -203,6 +219,99 @@ describe("createCompactionContextInjector", () => { }) }) + it("re-injects checkpointed agent config during autocontinue before synthetic continue", async () => { + //#given + const promptAsyncMock = mock(async () => ({})) + const ctx = createMockContext( + [ + [ + { + info: { + role: "user", + agent: "atlas", + model: { providerID: "openai", modelID: "gpt-5" }, + tools: { bash: "allow" }, + }, + }, + ], + [ + { + info: { + role: "user", + agent: "compaction", + model: { providerID: "anthropic", modelID: "claude-opus-4-1" }, + }, + }, + ], + [ + { + info: { + role: "user", + agent: "compaction", + model: { providerID: "anthropic", modelID: "claude-opus-4-1" }, + }, + }, + ], + [ + { + info: { + role: "user", + agent: "atlas", + model: { providerID: "openai", modelID: "gpt-5" }, + tools: { bash: true }, + }, + }, + ], + ], + promptAsyncMock, + ) + const injector = createCompactionContextInjector({ ctx }) + + //#when + await injector.capture("ses_autocontinue_checkpoint") + const restored = await injector.restore("ses_autocontinue_checkpoint") + + //#then + expect(restored).toBe(true) + expect(promptAsyncMock).toHaveBeenCalledWith({ + path: { id: "ses_autocontinue_checkpoint" }, + body: { + noReply: true, + agent: "atlas", + model: { providerID: "openai", modelID: "gpt-5" }, + tools: { bash: true }, + parts: [ + { + type: "text", + text: expect.stringContaining("restore checkpointed session agent configuration"), + }, + ], + }, + query: { directory: "/tmp/test" }, + }) + }) + + it("clears stale checkpoint when the next compaction capture has no prompt config", async () => { + //#given + const promptAsyncMock = mock(async () => ({})) + const sessionID = "ses_empty_checkpoint_capture" + setCompactionAgentConfigCheckpoint(sessionID, { + agent: "atlas", + model: { providerID: "openai", modelID: "gpt-5" }, + tools: { bash: true }, + }) + const ctx = createMockContext([[], [], []], promptAsyncMock) + const injector = createCompactionContextInjector({ ctx }) + + //#when + await injector.capture(sessionID) + const restored = await injector.restore(sessionID) + + //#then + expect(restored).toBe(false) + expect(promptAsyncMock).not.toHaveBeenCalled() + }) + it("recovers after five consecutive assistant messages with no text", async () => { //#given const promptAsyncMock = mock(async () => ({})) diff --git a/src/hooks/compaction-context-injector/recovery.ts b/src/hooks/compaction-context-injector/recovery.ts index 31040d35f..ab8331e44 100644 --- a/src/hooks/compaction-context-injector/recovery.ts +++ b/src/hooks/compaction-context-injector/recovery.ts @@ -28,7 +28,7 @@ export function createRecoveryLogic( ) { const recoverCheckpointedAgentConfig = async ( sessionID: string, - reason: "session.compacted" | "no-text-tail", + reason: "compaction.autocontinue" | "session.compacted" | "no-text-tail", ): Promise => { if (!ctx) { return false @@ -73,7 +73,7 @@ export function createRecoveryLogic( const model = expectedPromptConfig.model const tools = expectedPromptConfig.tools - if (reason === "session.compacted") { + if (reason === "compaction.autocontinue" || reason === "session.compacted") { const latestPromptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID) if (isPromptConfigRecovered(latestPromptConfig, expectedPromptConfig)) { return false diff --git a/src/hooks/compaction-context-injector/types.ts b/src/hooks/compaction-context-injector/types.ts index b97c2e6f6..b560e21b4 100644 --- a/src/hooks/compaction-context-injector/types.ts +++ b/src/hooks/compaction-context-injector/types.ts @@ -1,5 +1,6 @@ export interface CompactionContextInjector { capture: (sessionID: string) => Promise + restore: (sessionID: string) => Promise inject: (sessionID?: string) => string event: (input: { event: { type: string; properties?: unknown } }) => Promise } diff --git a/src/hooks/compaction-todo-preserver/hook.ts b/src/hooks/compaction-todo-preserver/hook.ts index dc1a87211..2bfe20cac 100644 --- a/src/hooks/compaction-todo-preserver/hook.ts +++ b/src/hooks/compaction-todo-preserver/hook.ts @@ -2,15 +2,27 @@ import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../shared/logger" interface TodoSnapshot { - id: string + id?: string content: string status: "pending" | "in_progress" | "completed" | "cancelled" priority?: "low" | "medium" | "high" } type TodoWriter = (input: { sessionID: string; todos: TodoSnapshot[] }) => Promise +type ToolExecuteBeforeInput = { tool: string; sessionID: string; callID: string } +type ToolExecuteBeforeOutput = { args: Record } const HOOK_NAME = "compaction-todo-preserver" +const ATLAS_BOOTSTRAP_TODOS = [ + { + id: "orchestrate-plan", + content: "Complete ALL implementation tasks", + }, + { + id: "pass-final-wave", + content: "Pass Final Verification Wave - ALL reviewers APPROVE", + }, +] as const function extractTodos(response: unknown): TodoSnapshot[] { const payload = response as { data?: unknown } @@ -23,6 +35,51 @@ function extractTodos(response: unknown): TodoSnapshot[] { return [] } +function isAtlasBootstrapTodo(todo: TodoSnapshot): boolean { + return ATLAS_BOOTSTRAP_TODOS.some((bootstrapTodo) => + todo.id === bootstrapTodo.id || todo.content === bootstrapTodo.content + ) +} + +function hasDetailedTodos(todos: TodoSnapshot[]): boolean { + return todos.some((todo) => !isAtlasBootstrapTodo(todo)) +} + +function isAtlasBootstrapTodoList(todos: TodoSnapshot[]): boolean { + return todos.length > 0 && todos.every(isAtlasBootstrapTodo) +} + +function shouldRestoreOverCurrentTodos(input: { + snapshot: TodoSnapshot[] + currentTodos: TodoSnapshot[] +}): boolean { + if (input.currentTodos.length === 0) return true + if (!isAtlasBootstrapTodoList(input.currentTodos)) return false + return hasDetailedTodos(input.snapshot) +} + +function extractTodoArgument(value: unknown): TodoSnapshot[] { + if (Array.isArray(value)) { + return value as TodoSnapshot[] + } + + if (typeof value !== "string") { + return [] + } + + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed as TodoSnapshot[] : [] + } catch (err) { + log(`[${HOOK_NAME}] Failed to parse todowrite todos`, { error: String(err) }) + return [] + } +} + +function isTodoWriteTool(toolName: string): boolean { + return toolName.trim().toLowerCase() === "todowrite" +} + async function resolveTodoWriter(): Promise { try { const loader = "opencode/session/todo" @@ -46,23 +103,35 @@ function resolveSessionID(props?: Record): string | undefined { export interface CompactionTodoPreserver { capture: (sessionID: string) => Promise + restore: (sessionID: string) => Promise event: (input: { event: { type: string; properties?: unknown } }) => Promise + "tool.execute.before": (input: ToolExecuteBeforeInput, output: ToolExecuteBeforeOutput) => Promise } export function createCompactionTodoPreserverHook( ctx: PluginInput, ): CompactionTodoPreserver { const snapshots = new Map() + const protectedSnapshots = new Map() const capture = async (sessionID: string): Promise => { if (!sessionID) return + protectedSnapshots.delete(sessionID) try { const response = await ctx.client.session.todo({ path: { id: sessionID } }) const todos = extractTodos(response) - if (todos.length === 0) return + if (todos.length === 0) { + snapshots.delete(sessionID) + return + } + if (!hasDetailedTodos(todos)) { + snapshots.delete(sessionID) + return + } snapshots.set(sessionID, todos) log(`[${HOOK_NAME}] Captured todo snapshot`, { sessionID, count: todos.length }) } catch (err) { + snapshots.delete(sessionID) log(`[${HOOK_NAME}] Failed to capture todos`, { sessionID, error: String(err) }) } } @@ -81,14 +150,22 @@ export function createCompactionTodoPreserverHook( log(`[${HOOK_NAME}] Failed to fetch todos post-compaction`, { sessionID, error: String(err) }) } - if (hasCurrent && currentTodos.length > 0) { + if (hasCurrent && !shouldRestoreOverCurrentTodos({ snapshot, currentTodos })) { snapshots.delete(sessionID) + if (hasDetailedTodos(currentTodos)) { + protectedSnapshots.set(sessionID, currentTodos) + } else { + protectedSnapshots.delete(sessionID) + } log(`[${HOOK_NAME}] Skipped restore (todos already present)`, { sessionID, count: currentTodos.length }) return } + protectedSnapshots.set(sessionID, snapshot) + const writer = await resolveTodoWriter() if (!writer) { + snapshots.delete(sessionID) log(`[${HOOK_NAME}] Skipped restore (Todo.update unavailable)`, { sessionID }) return } @@ -110,6 +187,16 @@ export function createCompactionTodoPreserverHook( const sessionID = resolveSessionID(props) if (sessionID) { snapshots.delete(sessionID) + protectedSnapshots.delete(sessionID) + } + return + } + + if (event.type === "session.idle") { + const sessionID = resolveSessionID(props) + if (sessionID) { + snapshots.delete(sessionID) + protectedSnapshots.delete(sessionID) } return } @@ -123,5 +210,35 @@ export function createCompactionTodoPreserverHook( } } - return { capture, event } + const beforeToolExecute = async ( + input: ToolExecuteBeforeInput, + output: ToolExecuteBeforeOutput, + ): Promise => { + if (!isTodoWriteTool(input.tool)) { + return + } + + const snapshot = protectedSnapshots.get(input.sessionID) + if (!snapshot || !hasDetailedTodos(snapshot)) { + return + } + + const requestedTodos = extractTodoArgument(output.args.todos) + if (requestedTodos.length === 0) { + return + } + + if (!isAtlasBootstrapTodoList(requestedTodos)) { + protectedSnapshots.delete(input.sessionID) + return + } + + output.args.todos = snapshot + log(`[${HOOK_NAME}] Replaced late Atlas bootstrap todowrite with restored snapshot`, { + sessionID: input.sessionID, + count: snapshot.length, + }) + } + + return { capture, restore, event, "tool.execute.before": beforeToolExecute } } diff --git a/src/hooks/compaction-todo-preserver/index.test.ts b/src/hooks/compaction-todo-preserver/index.test.ts index 06bb2ab4f..786e8ec05 100644 --- a/src/hooks/compaction-todo-preserver/index.test.ts +++ b/src/hooks/compaction-todo-preserver/index.test.ts @@ -1,17 +1,24 @@ -import { describe, expect, it, afterAll, mock } from "bun:test" +import { describe, expect, it, afterAll, beforeEach, mock } from "bun:test" import type { PluginInput } from "@opencode-ai/plugin" import { createOpencodeClient } from "@opencode-ai/sdk" import type { Todo } from "@opencode-ai/sdk" import { createCompactionTodoPreserverHook } from "./index" const updateMock = mock(async () => {}) +let todoWriter: typeof updateMock | undefined = updateMock mock.module("opencode/session/todo", () => ({ Todo: { - update: updateMock, + get update() { + return todoWriter + }, }, })) +beforeEach(() => { + todoWriter = updateMock +}) + afterAll(() => { mock.module("opencode/session/todo", () => ({ Todo: { @@ -21,7 +28,9 @@ afterAll(() => { mock.restore() }) -function createMockContext(todoResponses: Array[]): PluginInput { +type TodoResponse = Todo[] | Error + +function createMockContext(todoResponses: TodoResponse[]): PluginInput { let callIndex = 0 const client = createOpencodeClient({ directory: "/tmp/test" }) @@ -33,6 +42,9 @@ function createMockContext(todoResponses: Array[]): PluginInput { client.session.todo = mock((_: SessionTodoOptions): SessionTodoResult => { const current = todoResponses[Math.min(callIndex, todoResponses.length - 1)] ?? [] callIndex += 1 + if (current instanceof Error) { + return Promise.reject(current) + } return Promise.resolve({ data: current, error: undefined, request, response }) }) @@ -52,8 +64,8 @@ describe("compaction-todo-preserver", () => { updateMock.mockClear() const sessionID = "session-compaction-missing" const todos: Todo[] = [ - { id: "1", content: "Task 1", status: "pending", priority: "high" }, - { id: "2", content: "Task 2", status: "in_progress", priority: "medium" }, + { content: "Task 1", status: "pending", priority: "high" }, + { content: "Task 2", status: "in_progress", priority: "medium" }, ] const ctx = createMockContext([todos, []]) const hook = createCompactionTodoPreserverHook(ctx) @@ -72,7 +84,7 @@ describe("compaction-todo-preserver", () => { updateMock.mockClear() const sessionID = "session-compaction-present" const todos: Todo[] = [ - { id: "1", content: "Task 1", status: "pending", priority: "high" }, + { content: "Task 1", status: "pending", priority: "high" }, ] const ctx = createMockContext([todos, todos]) const hook = createCompactionTodoPreserverHook(ctx) @@ -84,4 +96,227 @@ describe("compaction-todo-preserver", () => { //#then expect(updateMock).not.toHaveBeenCalled() }) + + it("restores detailed todos when only Atlas bootstrap todos are present after compaction", async () => { + //#given + updateMock.mockClear() + const sessionID = "session-compaction-atlas-bootstrap" + const detailedTodos: Todo[] = [ + { content: "Inspect runtime compaction state", status: "completed", priority: "high" }, + { content: "Add regression coverage for todo preservation", status: "in_progress", priority: "high" }, + { content: "Run focused tests and open PR", status: "pending", priority: "medium" }, + ] + const atlasBootstrapTodos: Todo[] = [ + { content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" }, + { content: "Pass Final Verification Wave - ALL reviewers APPROVE", status: "pending", priority: "high" }, + ] + const ctx = createMockContext([detailedTodos, atlasBootstrapTodos]) + const hook = createCompactionTodoPreserverHook(ctx) + + //#when + await hook.capture(sessionID) + await hook.event({ event: { type: "session.compacted", properties: { sessionID } } }) + + //#then + expect(updateMock).toHaveBeenCalledTimes(1) + expect(updateMock).toHaveBeenCalledWith({ sessionID, todos: detailedTodos }) + }) + + it("skips restore when current todos include meaningful post-compaction work", async () => { + //#given + updateMock.mockClear() + const sessionID = "session-compaction-meaningful-current" + const detailedTodos: Todo[] = [ + { content: "Inspect runtime compaction state", status: "completed", priority: "high" }, + { content: "Add regression coverage for todo preservation", status: "in_progress", priority: "high" }, + ] + const currentTodos: Todo[] = [ + { content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" }, + { content: "Review post-compaction findings", status: "pending", priority: "medium" }, + ] + const ctx = createMockContext([detailedTodos, currentTodos]) + const hook = createCompactionTodoPreserverHook(ctx) + + //#when + await hook.capture(sessionID) + await hook.event({ event: { type: "session.compacted", properties: { sessionID } } }) + + //#then + expect(updateMock).not.toHaveBeenCalled() + }) + + it("does not restore a stale snapshot after a later empty capture", async () => { + //#given + updateMock.mockClear() + const sessionID = "session-compaction-empty-later" + const oldTodos: Todo[] = [ + { content: "Old task that no longer exists", status: "pending", priority: "high" }, + ] + const ctx = createMockContext([oldTodos, []]) + const hook = createCompactionTodoPreserverHook(ctx) + + //#when + await hook.capture(sessionID) + await hook.capture(sessionID) + await hook.event({ event: { type: "session.compacted", properties: { sessionID } } }) + + //#then + expect(updateMock).not.toHaveBeenCalled() + }) + + it("does not restore a stale snapshot after a later failed capture", async () => { + //#given + updateMock.mockClear() + const sessionID = "session-compaction-failed-later" + const oldTodos: Todo[] = [ + { content: "Old task that should not come back", status: "pending", priority: "high" }, + ] + const ctx = createMockContext([oldTodos, new Error("todo api unavailable")]) + const hook = createCompactionTodoPreserverHook(ctx) + + //#when + await hook.capture(sessionID) + await hook.capture(sessionID) + await hook.event({ event: { type: "session.compacted", properties: { sessionID } } }) + + //#then + expect(updateMock).not.toHaveBeenCalled() + }) + + it("does not retain a stale snapshot when Todo.update is unavailable", async () => { + //#given + updateMock.mockClear() + const sessionID = "session-compaction-writer-unavailable" + const detailedTodos: Todo[] = [ + { content: "Detailed task before missing writer", status: "in_progress", priority: "high" }, + ] + const ctx = createMockContext([detailedTodos, [], []]) + const hook = createCompactionTodoPreserverHook(ctx) + + //#when + await hook.capture(sessionID) + todoWriter = undefined + await hook.restore(sessionID) + todoWriter = updateMock + await hook.event({ event: { type: "session.compacted", properties: { sessionID } } }) + + //#then + expect(updateMock).not.toHaveBeenCalled() + }) + + it("does not preserve Atlas bootstrap todos when they are the only pre-compaction snapshot", async () => { + //#given + updateMock.mockClear() + const sessionID = "session-compaction-bootstrap-only-snapshot" + const atlasBootstrapTodos: Todo[] = [ + { content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" }, + { content: "Pass Final Verification Wave - ALL reviewers APPROVE", status: "pending", priority: "high" }, + ] + const ctx = createMockContext([atlasBootstrapTodos, []]) + const hook = createCompactionTodoPreserverHook(ctx) + + //#when + await hook.capture(sessionID) + await hook.event({ event: { type: "session.compacted", properties: { sessionID } } }) + + //#then + expect(updateMock).not.toHaveBeenCalled() + }) + + it("preserves restored detailed todos when Atlas writes bootstrap todos after compaction", async () => { + //#given + updateMock.mockClear() + const sessionID = "session-compaction-late-atlas-bootstrap" + const detailedTodos: Todo[] = [ + { content: "Inspect runtime compaction state", status: "completed", priority: "high" }, + { content: "Add regression coverage for todo preservation", status: "in_progress", priority: "high" }, + { content: "Run focused tests and open PR", status: "pending", priority: "medium" }, + ] + const atlasBootstrapTodos: Todo[] = [ + { content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" }, + { content: "Pass Final Verification Wave - ALL reviewers APPROVE", status: "pending", priority: "high" }, + ] + const ctx = createMockContext([detailedTodos, []]) + const hook = createCompactionTodoPreserverHook(ctx) + const output = { args: { todos: atlasBootstrapTodos } } + + //#when + await hook.capture(sessionID) + await hook.event({ event: { type: "session.compacted", properties: { sessionID } } }) + await hook["tool.execute.before"]({ tool: "todowrite", sessionID, callID: "call-bootstrap" }, output) + + //#then + expect(updateMock).toHaveBeenCalledWith({ sessionID, todos: detailedTodos }) + expect(output.args.todos).toEqual(detailedTodos) + }) + + it("protects detailed current todos from a later Atlas bootstrap write after compaction", async () => { + //#given + updateMock.mockClear() + const sessionID = "session-compaction-detailed-current-late-bootstrap" + const detailedTodos: Todo[] = [ + { content: "Keep detailed task one", status: "in_progress", priority: "high" }, + { content: "Keep detailed task two", status: "pending", priority: "medium" }, + ] + const atlasBootstrapTodos: Todo[] = [ + { content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" }, + { content: "Pass Final Verification Wave - ALL reviewers APPROVE", status: "pending", priority: "high" }, + ] + const ctx = createMockContext([detailedTodos, detailedTodos]) + const hook = createCompactionTodoPreserverHook(ctx) + const output = { args: { todos: atlasBootstrapTodos } } + + //#when + await hook.capture(sessionID) + await hook.restore(sessionID) + await hook["tool.execute.before"]({ tool: "todowrite", sessionID, callID: "call-bootstrap" }, output) + + //#then + expect(updateMock).not.toHaveBeenCalled() + expect(output.args.todos).toEqual(detailedTodos) + }) + + it("clears late bootstrap protection when the session idles", async () => { + //#given + updateMock.mockClear() + const sessionID = "session-compaction-protection-idle" + const detailedTodos: Todo[] = [ + { content: "Detailed task before idle", status: "in_progress", priority: "high" }, + ] + const atlasBootstrapTodos: Todo[] = [ + { content: "Complete ALL implementation tasks", status: "in_progress", priority: "high" }, + { content: "Pass Final Verification Wave - ALL reviewers APPROVE", status: "pending", priority: "high" }, + ] + const ctx = createMockContext([detailedTodos, detailedTodos]) + const hook = createCompactionTodoPreserverHook(ctx) + const output = { args: { todos: atlasBootstrapTodos } } + + //#when + await hook.capture(sessionID) + await hook.restore(sessionID) + await hook.event({ event: { type: "session.idle", properties: { sessionID } } }) + await hook["tool.execute.before"]({ tool: "todowrite", sessionID, callID: "call-bootstrap" }, output) + + //#then + expect(output.args.todos).toEqual(atlasBootstrapTodos) + }) + + it("clears a pending snapshot when the session idles before restore", async () => { + //#given + updateMock.mockClear() + const sessionID = "session-compaction-idle-before-restore" + const detailedTodos: Todo[] = [ + { content: "Detailed task before interrupted compaction", status: "in_progress", priority: "high" }, + ] + const ctx = createMockContext([detailedTodos, []]) + const hook = createCompactionTodoPreserverHook(ctx) + + //#when + await hook.capture(sessionID) + await hook.event({ event: { type: "session.idle", properties: { sessionID } } }) + await hook.event({ event: { type: "session.compacted", properties: { sessionID } } }) + + //#then + expect(updateMock).not.toHaveBeenCalled() + }) }) diff --git a/src/index.compacting.test.ts b/src/index.compacting.test.ts index 46434d8cb..2a83e4cfe 100644 --- a/src/index.compacting.test.ts +++ b/src/index.compacting.test.ts @@ -29,6 +29,19 @@ function createCompactingHandler(hooks: { } } +function createCompactionAutocontinueHandler(hooks: { + compactionContextInjector?: { restore: (sessionID: string) => Promise } + compactionTodoPreserver?: { restore: (sessionID: string) => Promise } +}) { + return async ( + input: { sessionID: string }, + _output: { enabled: boolean }, + ): Promise => { + await hooks.compactionContextInjector?.restore(input.sessionID) + await hooks.compactionTodoPreserver?.restore(input.sessionID) + } +} + describe("experimental.session.compacting handler", () => { //#given all three hooks are present //#when compacting handler is invoked @@ -134,3 +147,34 @@ describe("experimental.session.compacting handler", () => { expect(output.context).toEqual([]) }) }) + +describe("experimental.compaction.autocontinue handler", () => { + it("restores checkpointed context and todos before OpenCode adds the synthetic continue turn", async () => { + //#given + const callOrder: string[] = [] + const restoreContextMock = mock(async () => { + callOrder.push("context") + return true + }) + const restoreMock = mock(async () => {}) + const handler = createCompactionAutocontinueHandler({ + compactionContextInjector: { restore: restoreContextMock }, + compactionTodoPreserver: { + restore: mock(async (sessionID: string) => { + callOrder.push(`todos:${sessionID}`) + await restoreMock(sessionID) + }), + }, + }) + const output = { enabled: true } + + //#when + await handler({ sessionID: "ses_autocontinue" }, output) + + //#then + expect(restoreContextMock).toHaveBeenCalledWith("ses_autocontinue") + expect(restoreMock).toHaveBeenCalledWith("ses_autocontinue") + expect(callOrder).toEqual(["context", "todos:ses_autocontinue"]) + expect(output.enabled).toBe(true) + }) +}) diff --git a/src/index.compaction-model-agnostic.static.test.ts b/src/index.compaction-model-agnostic.static.test.ts index 6dfacb6f3..91326dd28 100644 --- a/src/index.compaction-model-agnostic.static.test.ts +++ b/src/index.compaction-model-agnostic.static.test.ts @@ -18,4 +18,19 @@ describe("experimental.session.compacting", () => { expect(hookSlice.includes("providerID:")).toBe(false) expect(hookSlice.includes("modelID:")).toBe(false) }) + + test("registers autocontinue restores before OpenCode synthetic continue", () => { + //#given + const indexUrl = new URL("./index.ts", import.meta.url) + const content = readFileSync(indexUrl, "utf-8") + const hookIndex = content.lastIndexOf('"experimental.compaction.autocontinue"') + + //#when + const hookSlice = hookIndex >= 0 ? content.slice(hookIndex, hookIndex + 500) : "" + + //#then + expect(hookIndex).toBeGreaterThanOrEqual(0) + expect(hookSlice.includes("compactionContextInjector?.restore")).toBe(true) + expect(hookSlice.includes("compactionTodoPreserver?.restore")).toBe(true) + }) }) diff --git a/src/index.ts b/src/index.ts index 372143cd9..88e6150a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,15 @@ import { installAgentSortShim, setAgentSortOrder } from "./shared/agent-sort-shi import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector" import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash" +type CompactionAutocontinueHook = ( + input: { sessionID: string }, + output: { enabled: boolean }, +) => Promise + +type HooksWithCompactionAutocontinue = Hooks & { + "experimental.compaction.autocontinue"?: CompactionAutocontinueHook +} + const serverPlugin: Plugin = async (input, _options): Promise => { installAgentSortShim() initConfigContext("opencode", null) @@ -105,7 +114,7 @@ const serverPlugin: Plugin = async (input, _options): Promise => { tools: toolsResult.filteredTools, }) - return { + const pluginHooks: HooksWithCompactionAutocontinue = { ...pluginInterface, "experimental.session.compacting": async ( @@ -122,7 +131,17 @@ const serverPlugin: Plugin = async (input, _options): Promise => { output.context.push(hooks.compactionContextInjector.inject(compactingInput.sessionID)) } }, + + "experimental.compaction.autocontinue": async ( + autocontinueInput: { sessionID: string }, + _output: { enabled: boolean }, + ): Promise => { + await hooks.compactionContextInjector?.restore(autocontinueInput.sessionID) + await hooks.compactionTodoPreserver?.restore(autocontinueInput.sessionID) + }, } + + return pluginHooks } const pluginModule: PluginModule = { diff --git a/src/plugin/tool-execute-before.test.ts b/src/plugin/tool-execute-before.test.ts index 76d11a33b..516c97d48 100644 --- a/src/plugin/tool-execute-before.test.ts +++ b/src/plugin/tool-execute-before.test.ts @@ -88,6 +88,42 @@ describe("createToolExecuteBeforeHandler", () => { expect(called).toBe(false) }) + test("runs compaction todo preserver before hook for todowrite", async () => { + //#given + let called = false + const ctx = { + client: { + session: { + messages: async () => ({ data: [] }), + }, + }, + } + const preservedTodos = [ + { content: "Preserved detailed task", status: "pending", priority: "high" }, + ] + const hooks = { + compactionTodoPreserver: { + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record }, + ) => { + called = true + expect(input.tool).toBe("todowrite") + output.args.todos = preservedTodos + }, + }, + } + const handler = createToolExecuteBeforeHandler({ ctx, hooks }) + const output = { args: { todos: [] } as Record } + + //#when + await handler({ tool: "todowrite", sessionID: "ses_compact", callID: "call_todo" }, output) + + //#then + expect(called).toBe(true) + expect(output.args.todos).toBe(preservedTodos) + }) + describe("task tool subagent_type normalization", () => { const emptyHooks = {} diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index 093c3b157..3b66aa2c9 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -77,6 +77,7 @@ export function createToolExecuteBeforeHandler(args: { await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output) await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output) await hooks.atlasHook?.["tool.execute.before"]?.(input, output) + await hooks.compactionTodoPreserver?.["tool.execute.before"]?.(input, output) await hooks.teamToolGating?.["tool.execute.before"]?.(input, output) const normalizedToolName = input.tool.toLowerCase()