fix: add compaction todo preserver hook

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-02-07 19:15:46 +09:00
parent 67f701cd9e
commit 3947084cc5
3 changed files with 202 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, mock } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { createCompactionTodoPreserverHook } from "./index"
const updateMock = mock(async () => {})
mock.module("opencode/session/todo", () => ({
Todo: {
update: updateMock,
},
}))
type TodoSnapshot = {
id: string
content: string
status: "pending" | "in_progress" | "completed" | "cancelled"
priority?: "low" | "medium" | "high"
}
function createMockContext(todoResponses: TodoSnapshot[][]): PluginInput {
let callIndex = 0
return {
client: {
session: {
todo: async () => {
const current = todoResponses[Math.min(callIndex, todoResponses.length - 1)] ?? []
callIndex += 1
return { data: current }
},
},
},
directory: "/tmp/test",
} as PluginInput
}
describe("compaction-todo-preserver", () => {
it("restores todos after compaction when missing", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-missing"
const todos = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ id: "2", content: "Task 2", status: "in_progress", priority: "medium" },
]
const ctx = createMockContext([todos, []])
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 })
})
it("skips restore when todos already present", async () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-present"
const todos = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
]
const ctx = createMockContext([todos, todos])
const hook = createCompactionTodoPreserverHook(ctx)
//#when
await hook.capture(sessionID)
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
//#then
expect(updateMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,127 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger"
interface TodoSnapshot {
id: string
content: string
status: "pending" | "in_progress" | "completed" | "cancelled"
priority?: "low" | "medium" | "high"
}
type TodoWriter = (input: { sessionID: string; todos: TodoSnapshot[] }) => Promise<void>
const HOOK_NAME = "compaction-todo-preserver"
function extractTodos(response: unknown): TodoSnapshot[] {
const payload = response as { data?: unknown }
if (Array.isArray(payload?.data)) {
return payload.data as TodoSnapshot[]
}
if (Array.isArray(response)) {
return response as TodoSnapshot[]
}
return []
}
async function resolveTodoWriter(): Promise<TodoWriter | null> {
try {
const loader = "opencode/session/todo"
const mod = (await import(loader)) as {
Todo?: { update?: TodoWriter }
}
const update = mod.Todo?.update
if (typeof update === "function") {
return update
}
} catch (err) {
log(`[${HOOK_NAME}] Failed to resolve Todo.update`, { error: String(err) })
}
return null
}
function resolveSessionID(props?: Record<string, unknown>): string | undefined {
return (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined
}
export interface CompactionTodoPreserver {
capture: (sessionID: string) => Promise<void>
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
}
export function createCompactionTodoPreserverHook(
ctx: PluginInput,
): CompactionTodoPreserver {
const snapshots = new Map<string, TodoSnapshot[]>()
const capture = async (sessionID: string): Promise<void> => {
if (!sessionID) return
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
const todos = extractTodos(response)
if (todos.length === 0) return
snapshots.set(sessionID, todos)
log(`[${HOOK_NAME}] Captured todo snapshot`, { sessionID, count: todos.length })
} catch (err) {
log(`[${HOOK_NAME}] Failed to capture todos`, { sessionID, error: String(err) })
}
}
const restore = async (sessionID: string): Promise<void> => {
const snapshot = snapshots.get(sessionID)
if (!snapshot || snapshot.length === 0) return
let hasCurrent = false
let currentTodos: TodoSnapshot[] = []
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
currentTodos = extractTodos(response)
hasCurrent = true
} catch (err) {
log(`[${HOOK_NAME}] Failed to fetch todos post-compaction`, { sessionID, error: String(err) })
}
if (hasCurrent && currentTodos.length > 0) {
snapshots.delete(sessionID)
log(`[${HOOK_NAME}] Skipped restore (todos already present)`, { sessionID, count: currentTodos.length })
return
}
const writer = await resolveTodoWriter()
if (!writer) {
log(`[${HOOK_NAME}] Skipped restore (Todo.update unavailable)`, { sessionID })
return
}
try {
await writer({ sessionID, todos: snapshot })
log(`[${HOOK_NAME}] Restored todos after compaction`, { sessionID, count: snapshot.length })
} catch (err) {
log(`[${HOOK_NAME}] Failed to restore todos`, { sessionID, error: String(err) })
} finally {
snapshots.delete(sessionID)
}
}
const event = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") {
const sessionID = resolveSessionID(props)
if (sessionID) {
snapshots.delete(sessionID)
}
return
}
if (event.type === "session.compacted") {
const sessionID = resolveSessionID(props)
if (sessionID) {
await restore(sessionID)
}
return
}
}
return { capture, event }
}

View File

@@ -35,6 +35,7 @@ export { createQuestionLabelTruncatorHook } from "./question-label-truncator";
export { createSubagentQuestionBlockerHook } from "./subagent-question-blocker";
export { createStopContinuationGuardHook, type StopContinuationGuard } from "./stop-continuation-guard";
export { createCompactionContextInjector } from "./compaction-context-injector";
export { createCompactionTodoPreserverHook } from "./compaction-todo-preserver";
export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter";
export { createPreemptiveCompactionHook } from "./preemptive-compaction";
export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler";