From 49ff4b5f8dd03809815ed6f9649f6c8e400830b3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:25:29 +0900 Subject: [PATCH] fix(compaction): ignore compaction agent updates Fixes #3819 --- src/hooks/context-window-monitor.test.ts | 39 ++++++++++++ src/hooks/context-window-monitor.ts | 3 + src/hooks/preemptive-compaction.test.ts | 76 +++++++++++++++++++++++- src/hooks/preemptive-compaction.ts | 3 + 4 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/hooks/context-window-monitor.test.ts b/src/hooks/context-window-monitor.test.ts index 75453b9c5..b664717a6 100644 --- a/src/hooks/context-window-monitor.test.ts +++ b/src/hooks/context-window-monitor.test.ts @@ -235,6 +235,45 @@ describe("context-window-monitor", () => { expect(output.output).toContain("context remaining") }) + // #given only a compaction agent summary message update is seen + // #when tool.execute.after checks context usage + // #then stale pre-compaction tokens should not create a context reminder + it("should ignore compaction-agent message updates when caching context usage", async () => { + const hook = createContextWindowMonitorHook(ctx as never) + const sessionID = "ses_compaction_agent_context" + + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + agent: "compaction", + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + finish: true, + tokens: { + input: 150000, + output: 1000, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + const output = { title: "", output: "original", metadata: null } + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_1" }, + output + ) + + expect(output.output).toBe("original") + expect(ctx.client.session.messages).not.toHaveBeenCalled() + }) + // #given session is deleted // #when session.deleted event fires // #then cached data should be cleaned up diff --git a/src/hooks/context-window-monitor.ts b/src/hooks/context-window-monitor.ts index 0f60be926..acdeee1c4 100644 --- a/src/hooks/context-window-monitor.ts +++ b/src/hooks/context-window-monitor.ts @@ -3,6 +3,7 @@ import { resolveActualContextLimit, type ContextLimitModelCacheState, } from "../shared/context-limit-resolver" +import { isCompactionAgent } from "../shared/compaction-marker" import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive" const CONTEXT_WARNING_THRESHOLD = 0.70 @@ -94,6 +95,7 @@ export function createContextWindowMonitorHook( if (event.type === "message.updated") { const info = props?.info as { + agent?: unknown role?: string sessionID?: string providerID?: string @@ -103,6 +105,7 @@ export function createContextWindowMonitorHook( } | undefined if (!info || info.role !== "assistant" || !info.finish) return + if (isCompactionAgent(info.agent)) return if (!info.sessionID || !info.providerID || !info.tokens) return tokenCache.set(info.sessionID, { diff --git a/src/hooks/preemptive-compaction.test.ts b/src/hooks/preemptive-compaction.test.ts index 09cbf83dc..ebf90c208 100644 --- a/src/hooks/preemptive-compaction.test.ts +++ b/src/hooks/preemptive-compaction.test.ts @@ -55,7 +55,9 @@ function setupImmediateTimeouts(): () => void { globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => { callback(...args) - return 1 as unknown as ReturnType + const timeoutID = originalSetTimeout(() => undefined, 0) + originalClearTimeout(timeoutID) + return timeoutID }) as typeof setTimeout globalThis.clearTimeout = (() => {}) as typeof clearTimeout @@ -637,6 +639,78 @@ describe("preemptive-compaction", () => { Date.now = originalNow }) + // #given compaction already succeeded for a session + // #when the compaction agent emits its summary message update + // #then it should not clear the compaction guard or trigger a duplicate summary + it("should ignore compaction-agent message updates after successful compaction", async () => { + const hook = createPreemptiveCompactionHook(ctx as never, {} as never) + const sessionID = "ses_compaction_agent_update" + + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_1" }, + { title: "", output: "test", metadata: null } + ) + + expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1) + + const originalNow = Date.now + try { + Date.now = () => originalNow() + 61_000 + + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + agent: "compaction", + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_2" }, + { title: "", output: "test", metadata: null } + ) + + expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1) + } finally { + Date.now = originalNow + } + }) + // #given modelContextLimitsCache has model-specific limit (256k) // #when tokens are above default 78% of 200k but below 78% of 256k // #then should NOT trigger compaction diff --git a/src/hooks/preemptive-compaction.ts b/src/hooks/preemptive-compaction.ts index a8da4b91e..b1e46b689 100644 --- a/src/hooks/preemptive-compaction.ts +++ b/src/hooks/preemptive-compaction.ts @@ -1,4 +1,5 @@ import type { OhMyOpenCodeConfig } from "../config" +import { isCompactionAgent } from "../shared/compaction-marker" import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver" import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor" @@ -70,6 +71,7 @@ export function createPreemptiveCompactionHook( if (event.type === "message.updated") { const info = props?.info as { id?: string + agent?: unknown role?: string sessionID?: string providerID?: string @@ -80,6 +82,7 @@ export function createPreemptiveCompactionHook( } | undefined if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return + if (isCompactionAgent(info.agent)) return if (info.providerID && info.tokens) { tokenCache.set(info.sessionID, {