fix(compaction): restore context and todos before continue
This commit is contained in:
@@ -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<void>
|
||||
type ToolExecuteBeforeInput = { tool: string; sessionID: string; callID: string }
|
||||
type ToolExecuteBeforeOutput = { args: Record<string, unknown> }
|
||||
|
||||
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<TodoWriter | null> {
|
||||
try {
|
||||
const loader = "opencode/session/todo"
|
||||
@@ -46,23 +103,35 @@ function resolveSessionID(props?: Record<string, unknown>): string | undefined {
|
||||
|
||||
export interface CompactionTodoPreserver {
|
||||
capture: (sessionID: string) => Promise<void>
|
||||
restore: (sessionID: string) => Promise<void>
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
"tool.execute.before": (input: ToolExecuteBeforeInput, output: ToolExecuteBeforeOutput) => Promise<void>
|
||||
}
|
||||
|
||||
export function createCompactionTodoPreserverHook(
|
||||
ctx: PluginInput,
|
||||
): CompactionTodoPreserver {
|
||||
const snapshots = new Map<string, TodoSnapshot[]>()
|
||||
const protectedSnapshots = new Map<string, TodoSnapshot[]>()
|
||||
|
||||
const capture = async (sessionID: string): Promise<void> => {
|
||||
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<void> => {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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<Todo>[]): 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<Todo>[]): 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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user