diff --git a/src/hooks/context-window-monitor.test.ts b/src/hooks/context-window-monitor.test.ts index 1693e005b..75453b9c5 100644 --- a/src/hooks/context-window-monitor.test.ts +++ b/src/hooks/context-window-monitor.test.ts @@ -143,6 +143,62 @@ describe("context-window-monitor", () => { expect(ctx.client.session.messages).not.toHaveBeenCalled() }) + // #given total input tokens exceed the resolved actualLimit (e.g. 1M-context + // Anthropic model where resolveActualContextLimit falls back to the + // 200K default for the model family) + // #when tool.execute.after appends the context status block + // #then the displayed used% must be clamped to 100 and remaining% must not go + // negative. Safety-tuned models flag the >100% / negative-remaining + // block as prompt injection (issue #3655). + it("should clamp displayed percentages when input exceeds actualLimit (regression #3655)", async () => { + const hook = createContextWindowMonitorHook(ctx as never) + const sessionID = "ses_overflow" + + // 289,370 input + 0 cache against a 200K resolved limit -> 144.7% raw, + // -44.7% remaining if not clamped. + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "anthropic", + finish: true, + tokens: { + input: 289370, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + }, + }, + }) + + const output = { title: "", output: "original", metadata: null } + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_1" }, + output + ) + + // The block must still be emitted (we are above the 70% threshold). + expect(output.output).toContain("[Context Status:") + + // Extract the displayed percentages and assert clamping. + const match = output.output.match( + /\[Context Status: ([\d.-]+)% used \([\d,]+\/[\d,]+ tokens\), ([\d.-]+)% remaining\]/, + ) + expect(match).not.toBeNull() + const usedPct = Number(match![1]) + const remainingPct = Number(match![2]) + + expect(usedPct).toBeLessThanOrEqual(100) + expect(usedPct).toBeGreaterThanOrEqual(0) + expect(remainingPct).toBeGreaterThanOrEqual(0) + expect(remainingPct).toBeLessThanOrEqual(100) + }) + it("should append context reminder for google-vertex-anthropic provider", async () => { //#given cached usage for google-vertex-anthropic above threshold const hook = createContextWindowMonitorHook(ctx as never) diff --git a/src/hooks/context-window-monitor.ts b/src/hooks/context-window-monitor.ts index 3d137ae6d..0f60be926 100644 --- a/src/hooks/context-window-monitor.ts +++ b/src/hooks/context-window-monitor.ts @@ -65,8 +65,15 @@ export function createContextWindowMonitorHook( remindedSessions.add(sessionID) - const usedPct = (actualUsagePercentage * 100).toFixed(1) - const remainingPct = ((1 - actualUsagePercentage) * 100).toFixed(1) + // Clamp the displayed percentages so the block stays trustworthy when the + // resolved actualLimit underestimates the model's real context window + // (e.g. a 1M-context Anthropic model that falls back to the 200K default). + // Without clamping, the block would advertise >100% used and a negative + // "remaining" - safety-tuned models flag exactly that pattern as a prompt + // injection and refuse to follow the directive (issue #3655). + const clampedPercentage = Math.min(Math.max(actualUsagePercentage, 0), 1) + const usedPct = (clampedPercentage * 100).toFixed(1) + const remainingPct = ((1 - clampedPercentage) * 100).toFixed(1) const usedTokens = totalInputTokens.toLocaleString() const limitTokens = actualLimit.toLocaleString()