Merge pull request #3906 from code-yeongyu/codex/fix-compact-agent-mismatch

[codex] Restore detailed Atlas todos after compaction
This commit is contained in:
YeonGyu-Kim
2026-05-10 14:02:46 +09:00
committed by GitHub
11 changed files with 601 additions and 16 deletions
@@ -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>
}
+121 -4
View File
@@ -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()
})
})
+44
View File
@@ -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
View File
@@ -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 = {
+36
View File
@@ -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 = {}
+1
View File
@@ -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()