Merge pull request #3906 from code-yeongyu/codex/fix-compact-agent-mismatch
[codex] Restore detailed Atlas todos after compaction
This commit is contained in:
@@ -35,7 +35,15 @@ export function createCompactionContextInjector(options?: {
|
||||
|
||||
const { recoverCheckpointedAgentConfig, maybeWarnAboutNoTextTail } = createRecoveryLogic(ctx, getTailState)
|
||||
|
||||
const restore = async (sessionID: string): Promise<boolean> => {
|
||||
return recoverCheckpointedAgentConfig(sessionID, "compaction.autocontinue")
|
||||
}
|
||||
|
||||
const capture = async (sessionID: string): Promise<void> => {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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<Array<{ info?: Record<string, unknown> }>>,
|
||||
@@ -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 () => ({}))
|
||||
|
||||
@@ -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<boolean> => {
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface CompactionContextInjector {
|
||||
capture: (sessionID: string) => Promise<void>
|
||||
restore: (sessionID: string) => Promise<boolean>
|
||||
inject: (sessionID?: string) => string
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,19 @@ function createCompactingHandler(hooks: {
|
||||
}
|
||||
}
|
||||
|
||||
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", () => {
|
||||
//#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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+20
-1
@@ -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<void>
|
||||
|
||||
type HooksWithCompactionAutocontinue = Hooks & {
|
||||
"experimental.compaction.autocontinue"?: CompactionAutocontinueHook
|
||||
}
|
||||
|
||||
const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
|
||||
installAgentSortShim()
|
||||
initConfigContext("opencode", null)
|
||||
@@ -105,7 +114,7 @@ const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
|
||||
tools: toolsResult.filteredTools,
|
||||
})
|
||||
|
||||
return {
|
||||
const pluginHooks: HooksWithCompactionAutocontinue = {
|
||||
...pluginInterface,
|
||||
|
||||
"experimental.session.compacting": async (
|
||||
@@ -122,7 +131,17 @@ const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
|
||||
output.context.push(hooks.compactionContextInjector.inject(compactingInput.sessionID))
|
||||
}
|
||||
},
|
||||
|
||||
"experimental.compaction.autocontinue": async (
|
||||
autocontinueInput: { sessionID: string },
|
||||
_output: { enabled: boolean },
|
||||
): Promise<void> => {
|
||||
await hooks.compactionContextInjector?.restore(autocontinueInput.sessionID)
|
||||
await hooks.compactionTodoPreserver?.restore(autocontinueInput.sessionID)
|
||||
},
|
||||
}
|
||||
|
||||
return pluginHooks
|
||||
}
|
||||
|
||||
const pluginModule: PluginModule = {
|
||||
|
||||
@@ -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<string, unknown> },
|
||||
) => {
|
||||
called = true
|
||||
expect(input.tool).toBe("todowrite")
|
||||
output.args.todos = preservedTodos
|
||||
},
|
||||
},
|
||||
}
|
||||
const handler = createToolExecuteBeforeHandler({ ctx, hooks })
|
||||
const output = { args: { todos: [] } as Record<string, unknown> }
|
||||
|
||||
//#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 = {}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user