From f11d2061f6c7993e7fb5b0451c732500f117567c Mon Sep 17 00:00:00 2001 From: Code_G <288527233+codeg-dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:56:27 +0900 Subject: [PATCH 1/2] fix(compaction): stabilize request-local reduction cache --- packages/coding-agent/changes.md | 24 ++ .../builtin/compaction/context-reduction.ts | 24 +- .../extensions/builtin/compaction/index.ts | 19 +- ...-context-reduction-cache-stability.test.ts | 125 +++++++++++ .../900-context-reduction-lifecycle.test.ts | 210 ++++++++++++++++++ 5 files changed, 393 insertions(+), 9 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/900-context-reduction-cache-stability.test.ts create mode 100644 packages/coding-agent/test/suite/regressions/900-context-reduction-lifecycle.test.ts diff --git a/packages/coding-agent/changes.md b/packages/coding-agent/changes.md index 36a2a6882c..166fd747b0 100644 --- a/packages/coding-agent/changes.md +++ b/packages/coding-agent/changes.md @@ -1,5 +1,29 @@ # Local fork changes +## 2026-08-16 — Stabilize request-local context reduction + +### What changed + +- Context reduction now stays engaged after crossing the 50% usage gate until + an accepted persisted compaction changes the stored session history. +- Provider-native compaction lanes still bypass Senpi context reduction. +- Added a deterministic sanitized threshold-control regression covering 507 + request-local evaluations and 319 threshold crossings against a + one-million-token window. +- Added a separate payload-scale extension regression with 1,510 eligible tool + results and more than one megabyte of serialized history. It proves rejected + compaction preserves the latch and accepted compaction resets it. + +### Why + +- The builtin context hook rebuilds outgoing messages from unchanged stored + history on every request. A stateless gate alternated reduced and unreduced + payload shapes when reported usage moved across 50%, invalidating stable + prefix reuse. +- Numeric release hysteresis cannot guarantee stability because a successful + request-local reduction can move the next reported usage below a release + band without changing stored history. + ## 2026-08-14 — RPC stream regression suites for multi-session compaction ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/context-reduction.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/context-reduction.ts index 287f5f2096..121f0f5c18 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/context-reduction.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/context-reduction.ts @@ -151,12 +151,30 @@ export interface ShouldApplyContextReductionInput { isProviderNativeCompactionPath?: boolean; } -export function shouldApplyContextReduction(input: ShouldApplyContextReductionInput): boolean { - const gate = input.gateRatio ?? BUILTIN_CONTEXT_REDUCTION_GATE_RATIO; +export interface ContextReductionLatch { + engaged: boolean; +} + +export function createContextReductionLatch(): ContextReductionLatch { + return { engaged: false }; +} + +export function resetContextReductionLatch(latch: ContextReductionLatch): void { + latch.engaged = false; +} + +export function shouldApplyContextReduction( + input: ShouldApplyContextReductionInput, + latch?: ContextReductionLatch, +): boolean { if (input.isProviderNativeCompactionPath === true) return false; + if (latch?.engaged === true) return true; if (input.usageTokens === null) return false; if (input.contextWindow <= 0) return false; - return input.usageTokens >= input.contextWindow * gate; + const gate = input.gateRatio ?? BUILTIN_CONTEXT_REDUCTION_GATE_RATIO; + const shouldApply = input.usageTokens >= input.contextWindow * gate; + if (shouldApply && latch) latch.engaged = true; + return shouldApply; } function approxTextTokens(text: string): number { diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts index ee75f2a694..c68128aa73 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts @@ -15,7 +15,9 @@ import * as checkpointState from "./checkpoint-state.ts"; import * as breaker from "./circuit-breaker.ts"; import { BUILTIN_CONTEXT_REDUCTION_OPTIONS, + createContextReductionLatch, reduceContextMessages, + resetContextReductionLatch, shouldApplyContextReduction, } from "./context-reduction.ts"; import { @@ -193,6 +195,7 @@ export default function compactionExtension( const lanePolicy = createCompactionLanePolicy(); const restorationDirectiveState = checkpointState.createRestorationDirectiveState(); const emergencyPruneLatch = createEmergencyPruneLatch(); + const contextReductionLatch = createContextReductionLatch(); const degradationState = createDegradationMonitorState(); const restorationState = state.restoration ?? restoration.createRestorationTrackerState(); state = { ...state, restoration: restorationState }; @@ -745,6 +748,7 @@ export default function compactionExtension( const compactEvent = event; invalidateSpeculativeCompaction(ctx); if (compactEvent.accepted) { + resetContextReductionLatch(contextReductionLatch); persistAcceptedMetadata(compactEvent.requestId); const branchEntries = ctx.sessionManager.getBranch(); const firstKeptIndex = branchEntries.findIndex( @@ -864,12 +868,15 @@ export default function compactionExtension( const usage = ctx.getContextUsage(); const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? DEFAULT_CONTEXT_WINDOW; const promptContextWindow = getPromptContextWindow(contextWindow, ctx.model?.maxTokens); - const sourceMessages = shouldApplyContextReduction({ - usageTokens: usage?.tokens ?? null, - contextWindow, - isProviderNativeCompactionPath: - isOpenAiRemoteCompactionModel(ctx.model) || lanePolicy.disablesSenpiCompaction(ctx), - }) + const sourceMessages = shouldApplyContextReduction( + { + usageTokens: usage?.tokens ?? null, + contextWindow, + isProviderNativeCompactionPath: + isOpenAiRemoteCompactionModel(ctx.model) || lanePolicy.disablesSenpiCompaction(ctx), + }, + contextReductionLatch, + ) ? reduceContextMessages(event.messages, BUILTIN_CONTEXT_REDUCTION_OPTIONS).messages : event.messages; // The claude-sdk-oauth lane stands down from senpi compaction entirely: diff --git a/packages/coding-agent/test/suite/regressions/900-context-reduction-cache-stability.test.ts b/packages/coding-agent/test/suite/regressions/900-context-reduction-cache-stability.test.ts new file mode 100644 index 0000000000..e5c872a026 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/900-context-reduction-cache-stability.test.ts @@ -0,0 +1,125 @@ +import { createHash } from "node:crypto"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; +import { + BUILTIN_CONTEXT_REDUCTION_OPTIONS, + type ContextReductionLatch, + createContextReductionLatch, + reduceContextMessages, + resetContextReductionLatch, + shouldApplyContextReduction, +} from "../../../src/core/extensions/builtin/compaction/context-reduction.ts"; + +const CONTEXT_WINDOW = 1_000_000; +const EXPOSURE_REQUESTS = 507; +const OBSERVED_THRESHOLD_CROSSINGS = 319; + +function emptyUsage(): Usage { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function assistantToolCall(id: string, timestamp: number): AssistantMessage { + return { + role: "assistant", + content: [{ type: "toolCall", id, name: "bash", arguments: { command: `probe-${id}` } }], + api: "faux-completion", + provider: "faux", + model: "faux-model", + usage: emptyUsage(), + stopReason: "toolUse", + timestamp, + }; +} + +function toolResult(id: string, timestamp: number): ToolResultMessage { + return { + role: "toolResult", + toolCallId: id, + toolName: "bash", + content: [{ type: "text", text: `result-${id}-${"x".repeat(4_000)}` }], + isError: false, + timestamp, + }; +} + +function userMessage(text: string, timestamp: number): UserMessage { + return { role: "user", content: text, timestamp }; +} + +function thresholdControlHistory(): AgentMessage[] { + const messages: AgentMessage[] = [userMessage("sanitized threshold-control fixture", 1)]; + for (let index = 0; index < 12; index += 1) { + const id = `call-${index}`; + messages.push(assistantToolCall(id, index * 2 + 2), toolResult(id, index * 2 + 3)); + } + messages.push(userMessage("latest request", 100)); + return messages; +} + +function payloadHash(messages: AgentMessage[]): string { + return createHash("sha256").update(JSON.stringify(messages)).digest("hex"); +} + +describe("request-local context reduction cache stability", () => { + it("keeps one payload shape across the 507-request threshold-control fixture", () => { + // Given: the incident had a one-million-token window, 507 proxy-exposed + // requests, and 319 observed threshold crossings. This intentionally small + // control fixture isolates state-machine oscillation without claiming to + // reproduce payload scale or that the proxy cohort measures caused overhead. + // Payload-scale reducer behavior is pinned by the companion lifecycle test. + const messages = thresholdControlHistory(); + const latch = { engaged: false } satisfies ContextReductionLatch; + const usageSeries = Array.from({ length: EXPOSURE_REQUESTS }, (_, index) => { + if (index > OBSERVED_THRESHOLD_CROSSINGS) return 499_000; + return index % 2 === 0 ? 501_000 : 499_000; + }); + + // When: every provider request independently assembles context from the + // same stored history while computed usage crosses the 50% gate. + const hashes = usageSeries.map((usageTokens) => { + const shouldReduce = shouldApplyContextReduction({ usageTokens, contextWindow: CONTEXT_WINDOW }, latch); + const outgoing = shouldReduce + ? reduceContextMessages(messages, BUILTIN_CONTEXT_REDUCTION_OPTIONS).messages + : messages; + return payloadHash(outgoing); + }); + + // Then: once reduction engages, request-local payloads retain one stable + // cacheable shape until persisted compaction resets the latch. + expect(new Set(hashes).size).toBe(1); + expect(hashes.every((hash) => hash !== payloadHash(messages))).toBe(true); + }); + + it("resets the sticky reduction state after accepted persisted compaction", () => { + const latch = createContextReductionLatch(); + + expect(shouldApplyContextReduction({ usageTokens: 501_000, contextWindow: CONTEXT_WINDOW }, latch)).toBe(true); + expect(shouldApplyContextReduction({ usageTokens: 499_000, contextWindow: CONTEXT_WINDOW }, latch)).toBe(true); + + resetContextReductionLatch(latch); + + expect(shouldApplyContextReduction({ usageTokens: 499_000, contextWindow: CONTEXT_WINDOW }, latch)).toBe(false); + }); + + it("preserves the provider-native compaction bypass while sticky", () => { + const latch = { engaged: true } satisfies ContextReductionLatch; + const shouldReduce = shouldApplyContextReduction( + { + usageTokens: 900_000, + contextWindow: CONTEXT_WINDOW, + isProviderNativeCompactionPath: true, + }, + latch, + ); + expect(shouldReduce).toBe(false); + expect(latch.engaged).toBe(true); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/900-context-reduction-lifecycle.test.ts b/packages/coding-agent/test/suite/regressions/900-context-reduction-lifecycle.test.ts new file mode 100644 index 0000000000..ab9a3fcb70 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/900-context-reduction-lifecycle.test.ts @@ -0,0 +1,210 @@ +import { createHash } from "node:crypto"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; +import { AuthStorage } from "../../../src/core/auth-storage.ts"; +import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/core/compaction/index.ts"; +import { createEventBus } from "../../../src/core/event-bus.ts"; +import compactionExtension from "../../../src/core/extensions/builtin/compaction/index.ts"; +import { createExtensionRuntime, loadExtensionFromFactory } from "../../../src/core/extensions/loader.ts"; +import { ExtensionRunner } from "../../../src/core/extensions/runner.ts"; +import type { ExtensionActions, ExtensionContextActions } from "../../../src/core/extensions/types.ts"; +import type { CompactionEntry } from "../../../src/core/session-manager.ts"; +import { SessionManager } from "../../../src/core/session-manager.ts"; +import { createInMemoryExtensionSessionSettings } from "../../helpers/extension-session-settings.ts"; +import { createModelRegistry } from "../../model-runtime-test-utils.ts"; + +const CONTEXT_WINDOW = 1_000_000; +const INCIDENT_DERIVED_TOOL_RESULT_VOLUME = 1_510; +const RESULT_BODY_BYTES = 800; + +interface UsageState { + tokens: number; +} + +function emptyUsage(): Usage { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function assistantToolCall(id: string, timestamp: number): AssistantMessage { + return { + role: "assistant", + content: [{ type: "toolCall", id, name: "bash", arguments: { command: `probe-${id}` } }], + api: "faux-completion", + provider: "faux", + model: "faux-model", + usage: emptyUsage(), + stopReason: "toolUse", + timestamp, + }; +} + +function toolResult(id: string, timestamp: number): ToolResultMessage { + return { + role: "toolResult", + toolCallId: id, + toolName: "bash", + content: [{ type: "text", text: `result-${id}-${"x".repeat(RESULT_BODY_BYTES)}` }], + isError: false, + timestamp, + }; +} + +function userMessage(text: string, timestamp: number): UserMessage { + return { role: "user", content: text, timestamp }; +} + +function payloadScaleHistory(): AgentMessage[] { + const messages: AgentMessage[] = [userMessage("sanitized payload-scale fixture", 1)]; + for (let index = 0; index < INCIDENT_DERIVED_TOOL_RESULT_VOLUME; index += 1) { + const id = `call-${index}`; + messages.push(assistantToolCall(id, index * 2 + 2), toolResult(id, index * 2 + 3)); + } + return [...messages, userMessage("latest request", INCIDENT_DERIVED_TOOL_RESULT_VOLUME * 2 + 2)]; +} + +function payloadHash(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +async function createRunner(usageState: UsageState): Promise { + const extensionActions: ExtensionActions = { + registerLazyToolActivator: () => {}, + sendMessage: () => {}, + sendUserMessage: () => {}, + appendEntry: () => {}, + setSessionName: () => {}, + getSessionName: () => undefined, + setLabel: () => {}, + executeTool: async () => { + throw new Error("Tool execution is not available in this lifecycle harness"); + }, + getActiveTools: () => ["read", "write"], + getAllTools: () => [], + setActiveTools: () => {}, + refreshTools: () => {}, + registerRemovedToolHint: () => {}, + getCommands: () => [], + setModel: async () => false, + getThinkingLevel: () => "high", + setThinkingLevel: () => {}, + setSessionModel: async () => false, + setSessionThinkingLevel: () => {}, + setSessionFastMode: () => {}, + }; + const cwd = process.cwd(); + const contextActions: ExtensionContextActions = { + getModel: () => undefined, + getServiceTier: () => undefined, + getScopedModels: () => [], + isIdle: () => true, + isProjectTrusted: () => true, + getSignal: () => undefined, + abort: () => {}, + hasPendingMessages: () => false, + isCompacting: () => false, + shutdown: () => {}, + getContextUsage: () => ({ + tokens: usageState.tokens, + contextWindow: CONTEXT_WINDOW, + percent: usageState.tokens / CONTEXT_WINDOW, + }), + compact: () => {}, + getMessageRevision: () => 1, + applyCompaction: async () => ({ applied: false, reason: "rejected" }), + getCompactionSettings: () => DEFAULT_COMPACTION_SETTINGS, + getLookAtSettings: () => ({ enabled: true, models: undefined }), + getImageSettings: () => ({ autoResize: true, blockImages: false }), + sessionSettings: createInMemoryExtensionSessionSettings(), + getSystemPrompt: () => "", + getLoadedHookSources: () => ({ + agentDir: cwd, + cwd, + globalHookSourcePaths: [], + globalHooksPath: `${cwd}/hooks.json`, + preSessionHookSourcePaths: [], + projectHookSourcePaths: [], + projectHooksPath: `${cwd}/.senpi/hooks.json`, + runtimeHookSourcePaths: [], + }), + }; + const runtime = createExtensionRuntime(); + const extension = await loadExtensionFromFactory( + compactionExtension, + cwd, + createEventBus(), + runtime, + "", + ); + const runner = new ExtensionRunner( + [extension], + runtime, + cwd, + SessionManager.inMemory(cwd), + await createModelRegistry(AuthStorage.inMemory()), + ); + runner.bindCore(extensionActions, contextActions); + return runner; +} + +function acceptedCompactionEntry(): CompactionEntry { + return { + type: "compaction", + id: "accepted-compaction", + parentId: null, + timestamp: new Date(0).toISOString(), + summary: "sanitized compacted context", + firstKeptEntryId: "none", + tokensBefore: 501_000, + }; +} + +async function contextHash(runner: ExtensionRunner, messages: AgentMessage[]): Promise { + return payloadHash(await runner.emitContext(messages)); +} + +describe("request-local context reduction extension lifecycle", () => { + it("holds a payload-scale reduction through rejection and resets only after accepted compaction", async () => { + const messages = payloadScaleHistory(); + const serializedBytes = Buffer.byteLength(JSON.stringify(messages)); + const toolResultCount = messages.filter((message) => message.role === "toolResult").length; + expect(toolResultCount).toBe(INCIDENT_DERIVED_TOOL_RESULT_VOLUME); + expect(serializedBytes).toBeGreaterThan(1_000_000); + + const usageState = { tokens: 501_000 }; + const runner = await createRunner(usageState); + const reducedHash = await contextHash(runner, messages); + + usageState.tokens = 499_000; + expect(await contextHash(runner, messages)).toBe(reducedHash); + + await runner.emit({ + type: "session_compact", + reason: "manual", + requestId: "rejected-request", + accepted: false, + rejectionCause: "cancelled-by-extension", + fromExtension: false, + willRetry: false, + }); + expect(await contextHash(runner, messages)).toBe(reducedHash); + + await runner.emit({ + type: "session_compact", + reason: "manual", + requestId: "accepted-request", + accepted: true, + compactionEntry: acceptedCompactionEntry(), + fromExtension: true, + willRetry: false, + }); + expect(await contextHash(runner, messages)).not.toBe(reducedHash); + }); +}); From 231d4672c0589016409a97fe218a791c46c51058 Mon Sep 17 00:00:00 2001 From: Code_G <288527233+codeg-dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:13:52 +0900 Subject: [PATCH 2/2] fix(compaction): reset reduction cache on tree navigation --- packages/coding-agent/changes.md | 3 ++- .../extensions/builtin/compaction/index.ts | 4 ++++ .../900-context-reduction-lifecycle.test.ts | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/changes.md b/packages/coding-agent/changes.md index 166fd747b0..d4ba5fb81d 100644 --- a/packages/coding-agent/changes.md +++ b/packages/coding-agent/changes.md @@ -5,7 +5,8 @@ ### What changed - Context reduction now stays engaged after crossing the 50% usage gate until - an accepted persisted compaction changes the stored session history. + an accepted persisted compaction or session-tree navigation changes the active + stored history. - Provider-native compaction lanes still bypass Senpi context reduction. - Added a deterministic sanitized threshold-control regression covering 507 request-local evaluations and 319 threshold crossings against a diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts index c68128aa73..f2ca3884a3 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts @@ -744,6 +744,10 @@ export default function compactionExtension( } }); + pi.on("session_tree", () => { + resetContextReductionLatch(contextReductionLatch); + }); + pi.on("session_compact", async (event: SessionCompactEvent, ctx) => { const compactEvent = event; invalidateSpeculativeCompaction(ctx); diff --git a/packages/coding-agent/test/suite/regressions/900-context-reduction-lifecycle.test.ts b/packages/coding-agent/test/suite/regressions/900-context-reduction-lifecycle.test.ts index ab9a3fcb70..2b3b58e8bc 100644 --- a/packages/coding-agent/test/suite/regressions/900-context-reduction-lifecycle.test.ts +++ b/packages/coding-agent/test/suite/regressions/900-context-reduction-lifecycle.test.ts @@ -171,6 +171,24 @@ async function contextHash(runner: ExtensionRunner, messages: AgentMessage[]): P } describe("request-local context reduction extension lifecycle", () => { + it("resets reduction when navigation replaces the active branch", async () => { + const messages = payloadScaleHistory(); + const usageState = { tokens: 501_000 }; + const runner = await createRunner(usageState); + const reducedHash = await contextHash(runner, messages); + + usageState.tokens = 499_000; + expect(await contextHash(runner, messages)).toBe(reducedHash); + + await runner.emit({ + type: "session_tree", + oldLeafId: "long-branch-leaf", + newLeafId: "short-branch-leaf", + }); + + expect(await contextHash(runner, messages)).not.toBe(reducedHash); + }); + it("holds a payload-scale reduction through rejection and resets only after accepted compaction", async () => { const messages = payloadScaleHistory(); const serializedBytes = Buffer.byteLength(JSON.stringify(messages));