fix(todo-continuation): filter compaction agent to prevent infinite loop
- Add 'compaction' to DEFAULT_SKIP_AGENTS - Skip compaction agent messages when resolving agent info - Skip injection when compaction occurred but no real agent resolved - Replace cooldown-based approach with agent-based filtering
This commit is contained in:
parent
f1a279a10a
commit
58bb92134d
@ -873,4 +873,193 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls.length).toBe(1)
|
expect(promptCalls.length).toBe(1)
|
||||||
expect(promptCalls[0].model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-di
|
|||||||
|
|
||||||
const HOOK_NAME = "todo-continuation-enforcer"
|
const HOOK_NAME = "todo-continuation-enforcer"
|
||||||
|
|
||||||
const DEFAULT_SKIP_AGENTS = ["prometheus"]
|
const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"]
|
||||||
|
|
||||||
export interface TodoContinuationEnforcerOptions {
|
export interface TodoContinuationEnforcerOptions {
|
||||||
backgroundManager?: BackgroundManager
|
backgroundManager?: BackgroundManager
|
||||||
@ -373,6 +373,7 @@ export function createTodoContinuationEnforcer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let resolvedInfo: ResolvedMessageInfo | undefined
|
let resolvedInfo: ResolvedMessageInfo | undefined
|
||||||
|
let hasCompactionMessage = false
|
||||||
try {
|
try {
|
||||||
const messagesResp = await ctx.client.session.messages({
|
const messagesResp = await ctx.client.session.messages({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
@ -388,6 +389,10 @@ export function createTodoContinuationEnforcer(
|
|||||||
}>
|
}>
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
const info = messages[i].info
|
const info = messages[i].info
|
||||||
|
if (info?.agent === "compaction") {
|
||||||
|
hasCompactionMessage = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
|
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
|
||||||
resolvedInfo = {
|
resolvedInfo = {
|
||||||
agent: info.agent,
|
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}] 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)) {
|
if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) {
|
||||||
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent })
|
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (hasCompactionMessage && !resolvedInfo?.agent) {
|
||||||
|
log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
startCountdown(sessionID, incompleteCount, todos.length, resolvedInfo)
|
startCountdown(sessionID, incompleteCount, todos.length, resolvedInfo)
|
||||||
return
|
return
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user