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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user