Merge pull request #1611 from code-yeongyu/fix/1481-1483-compaction
fix: prevent compaction from inserting arbitrary constraints and preserve todo state (#1481, #1483)
This commit is contained in:
commit
5e5c091356
@ -87,6 +87,7 @@ export const HookNameSchema = z.enum([
|
||||
"category-skill-reminder",
|
||||
|
||||
"compaction-context-injector",
|
||||
"compaction-todo-preserver",
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command",
|
||||
"edit-error-recovery",
|
||||
|
||||
@ -56,4 +56,17 @@ describe("createCompactionContextInjector", () => {
|
||||
expect(prompt).toContain("Files already verified")
|
||||
})
|
||||
})
|
||||
|
||||
it("restricts constraints to explicit verbatim statements", async () => {
|
||||
//#given
|
||||
const injector = createCompactionContextInjector()
|
||||
|
||||
//#when
|
||||
const prompt = injector()
|
||||
|
||||
//#then
|
||||
expect(prompt).toContain("Explicit Constraints (Verbatim Only)")
|
||||
expect(prompt).toContain("Do NOT invent")
|
||||
expect(prompt).toContain("Quote constraints verbatim")
|
||||
})
|
||||
})
|
||||
|
||||
@ -29,11 +29,11 @@ When summarizing this session, you MUST include the following sections in your s
|
||||
- **External References**: Documentation URLs, library APIs, or external resources being consulted
|
||||
- **State & Variables**: Important variable names, configuration values, or runtime state relevant to ongoing work
|
||||
|
||||
## 6. MUST NOT Do (Critical Constraints)
|
||||
- Things that were explicitly forbidden
|
||||
- Approaches that failed and should not be retried
|
||||
- User's explicit restrictions or preferences
|
||||
- Anti-patterns identified during the session
|
||||
## 6. Explicit Constraints (Verbatim Only)
|
||||
- Include ONLY constraints explicitly stated by the user or in existing AGENTS.md context
|
||||
- Quote constraints verbatim (do not paraphrase)
|
||||
- Do NOT invent, add, or modify constraints
|
||||
- If no explicit constraints exist, write "None"
|
||||
|
||||
## 7. Agent Verification State (Critical for Reviewers)
|
||||
- **Current Agent**: What agent is running (momus, oracle, etc.)
|
||||
|
||||
74
src/hooks/compaction-todo-preserver/index.test.ts
Normal file
74
src/hooks/compaction-todo-preserver/index.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
127
src/hooks/compaction-todo-preserver/index.ts
Normal file
127
src/hooks/compaction-todo-preserver/index.ts
Normal 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 }
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -35,6 +35,7 @@ import {
|
||||
createSubagentQuestionBlockerHook,
|
||||
createStopContinuationGuardHook,
|
||||
createCompactionContextInjector,
|
||||
createCompactionTodoPreserverHook,
|
||||
createUnstableAgentBabysitterHook,
|
||||
createPreemptiveCompactionHook,
|
||||
createTasksTodowriteDisablerHook,
|
||||
@ -356,6 +357,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
? safeCreateHook("compaction-context-injector", () => createCompactionContextInjector(), { enabled: safeHookEnabled })
|
||||
: null;
|
||||
|
||||
const compactionTodoPreserver = isHookEnabled("compaction-todo-preserver")
|
||||
? safeCreateHook("compaction-todo-preserver", () => createCompactionTodoPreserverHook(ctx), { enabled: safeHookEnabled })
|
||||
: null;
|
||||
|
||||
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
|
||||
? safeCreateHook("todo-continuation-enforcer", () => createTodoContinuationEnforcer(ctx, {
|
||||
backgroundManager,
|
||||
@ -730,6 +735,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
await interactiveBashSession?.event(input);
|
||||
await ralphLoop?.event(input);
|
||||
await stopContinuationGuard?.event(input);
|
||||
await compactionTodoPreserver?.event(input);
|
||||
await atlasHook?.handler(input);
|
||||
|
||||
const { event } = input;
|
||||
@ -945,6 +951,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
_input: { sessionID: string },
|
||||
output: { context: string[] },
|
||||
): Promise<void> => {
|
||||
await compactionTodoPreserver?.capture(_input.sessionID);
|
||||
if (!compactionContextInjector) {
|
||||
return;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user