diff --git a/src/hooks/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer.test.ts index d7c19577b..0675ca437 100644 --- a/src/hooks/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer.test.ts @@ -873,4 +873,193 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls.length).toBe(1) expect(promptCalls[0].model).toEqual({ providerID: "openai", modelID: "gpt-5.2" }) }) + + // ============================================================ + // COMPACTION AGENT FILTERING TESTS + // These tests verify that compaction agent messages are filtered + // when resolving agent info, preventing infinite continuation loops + // ============================================================ + + test("should skip compaction agent messages when resolving agent info", async () => { + // #given - session where last message is from compaction agent but previous was Sisyphus + const sessionID = "main-compaction-filter" + setMainSession(sessionID) + + const mockMessagesWithCompaction = [ + { info: { id: "msg-1", role: "user", agent: "Sisyphus", model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" } } }, + { info: { id: "msg-2", role: "assistant", agent: "Sisyphus", modelID: "claude-sonnet-4-5", providerID: "anthropic" } }, + { info: { id: "msg-3", role: "assistant", agent: "compaction", modelID: "claude-sonnet-4-5", providerID: "anthropic" } }, + ] + + const mockInput = { + client: { + session: { + todo: async () => ({ + data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }], + }), + messages: async () => ({ data: mockMessagesWithCompaction }), + prompt: async (opts: any) => { + promptCalls.push({ + sessionID: opts.path.id, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + return {} + }, + }, + tui: { showToast: async () => ({}) }, + }, + directory: "/tmp/test", + } as any + + const hook = createTodoContinuationEnforcer(mockInput, { + backgroundManager: createMockBackgroundManager(false), + }) + + // #when - session goes idle + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await new Promise(r => setTimeout(r, 2500)) + + // #then - continuation uses Sisyphus (skipped compaction agent) + expect(promptCalls.length).toBe(1) + expect(promptCalls[0].agent).toBe("Sisyphus") + }) + + test("should skip injection when only compaction agent messages exist", async () => { + // #given - session with only compaction agent (post-compaction, no prior agent info) + const sessionID = "main-only-compaction" + setMainSession(sessionID) + + const mockMessagesOnlyCompaction = [ + { info: { id: "msg-1", role: "assistant", agent: "compaction" } }, + ] + + const mockInput = { + client: { + session: { + todo: async () => ({ + data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }], + }), + messages: async () => ({ data: mockMessagesOnlyCompaction }), + prompt: async (opts: any) => { + promptCalls.push({ + sessionID: opts.path.id, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + return {} + }, + }, + tui: { showToast: async () => ({}) }, + }, + directory: "/tmp/test", + } as any + + const hook = createTodoContinuationEnforcer(mockInput, {}) + + // #when - session goes idle + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + + await new Promise(r => setTimeout(r, 3000)) + + // #then - no continuation (compaction is in default skipAgents) + expect(promptCalls).toHaveLength(0) + }) + + test("should skip injection when prometheus agent is after compaction", async () => { + // #given - prometheus session that was compacted + const sessionID = "main-prometheus-compacted" + setMainSession(sessionID) + + const mockMessagesPrometheusCompacted = [ + { info: { id: "msg-1", role: "user", agent: "prometheus" } }, + { info: { id: "msg-2", role: "assistant", agent: "prometheus" } }, + { info: { id: "msg-3", role: "assistant", agent: "compaction" } }, + ] + + const mockInput = { + client: { + session: { + todo: async () => ({ + data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }], + }), + messages: async () => ({ data: mockMessagesPrometheusCompacted }), + prompt: async (opts: any) => { + promptCalls.push({ + sessionID: opts.path.id, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + return {} + }, + }, + tui: { showToast: async () => ({}) }, + }, + directory: "/tmp/test", + } as any + + const hook = createTodoContinuationEnforcer(mockInput, {}) + + // #when - session goes idle + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + + await new Promise(r => setTimeout(r, 3000)) + + // #then - no continuation (prometheus found after filtering compaction, prometheus is in skipAgents) + expect(promptCalls).toHaveLength(0) + }) + + test("should inject when agent info is undefined but skipAgents is empty", async () => { + // #given - session with no agent info but skipAgents is empty + const sessionID = "main-no-agent-no-skip" + setMainSession(sessionID) + + const mockMessagesNoAgent = [ + { info: { id: "msg-1", role: "user" } }, + { info: { id: "msg-2", role: "assistant" } }, + ] + + const mockInput = { + client: { + session: { + todo: async () => ({ + data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }], + }), + messages: async () => ({ data: mockMessagesNoAgent }), + prompt: async (opts: any) => { + promptCalls.push({ + sessionID: opts.path.id, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + return {} + }, + }, + tui: { showToast: async () => ({}) }, + }, + directory: "/tmp/test", + } as any + + const hook = createTodoContinuationEnforcer(mockInput, { + skipAgents: [], + }) + + // #when - session goes idle + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + + await new Promise(r => setTimeout(r, 3000)) + + // #then - continuation injected (no agents to skip) + expect(promptCalls.length).toBe(1) + }) }) diff --git a/src/hooks/todo-continuation-enforcer.ts b/src/hooks/todo-continuation-enforcer.ts index 41e8b2c54..a4e65ea6e 100644 --- a/src/hooks/todo-continuation-enforcer.ts +++ b/src/hooks/todo-continuation-enforcer.ts @@ -13,7 +13,7 @@ import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-di const HOOK_NAME = "todo-continuation-enforcer" -const DEFAULT_SKIP_AGENTS = ["prometheus"] +const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"] export interface TodoContinuationEnforcerOptions { backgroundManager?: BackgroundManager @@ -373,6 +373,7 @@ export function createTodoContinuationEnforcer( } let resolvedInfo: ResolvedMessageInfo | undefined + let hasCompactionMessage = false try { const messagesResp = await ctx.client.session.messages({ path: { id: sessionID }, @@ -388,6 +389,10 @@ export function createTodoContinuationEnforcer( }> for (let i = messages.length - 1; i >= 0; i--) { const info = messages[i].info + if (info?.agent === "compaction") { + hasCompactionMessage = true + continue + } if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { resolvedInfo = { agent: info.agent, @@ -401,11 +406,15 @@ export function createTodoContinuationEnforcer( log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(err) }) } - log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents }) + log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage }) if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) { log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent }) return } + if (hasCompactionMessage && !resolvedInfo?.agent) { + log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID }) + return + } startCountdown(sessionID, incompleteCount, todos.length, resolvedInfo) return