Skip to content
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- Fixed `/goal` threshold auto-compaction never firing whenever the per-turn supersede/drop-useless prune saved ≥`compaction.thresholdTokens - calculateContextTokens(usage)` tokens. The pre-fix code subtracted prune savings from the threshold input, so a long-running goal session whose visible context (anchored to the same provider billing) sat above `compaction.thresholdTokens` could keep growing past it indefinitely. Threshold maintenance now triggers from the actual last-turn billed context, with the post-prune local estimate kept as a payload-compression floor. ([#3174](https://github.com/can1357/oh-my-pi/issues/3174))

## [16.1.10] - 2026-06-21

### Added
Expand Down
31 changes: 16 additions & 15 deletions packages/coding-agent/src/session/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8323,28 +8323,29 @@ export class AgentSession {
// Stale-result pass runs every turn, before any threshold gating: it is
// cheap (bails when no candidate) and independent of the compaction
// setting.
const supersedeResult = await this.#pruneStaleToolResults();
await this.#pruneStaleToolResults();

const compactionSettings = this.settings.getGroup("compaction");
if (!compactionSettings.enabled || compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;

// Case 4: Threshold - turn succeeded but context is getting large
// Skip if this was an error (non-overflow errors don't have usage data)
if (assistantMessage.stopReason === "error") return COMPACTION_CHECK_NONE;
const pruneResult = await this.#pruneToolOutputs();
let contextTokens = calculateContextTokens(assistantMessage.usage);
if (supersedeResult) {
contextTokens = Math.max(0, contextTokens - supersedeResult.tokensSaved);
}
if (pruneResult) {
contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
}
// Floor by the real stored-conversation estimate so a payload-shrinking
// before_provider_request hook (e.g. a compression extension such as
// Headroom) can't deflate the provider-reported usage below the true
// history size and skip the threshold. The estimate runs after the prune
// passes above, so it reflects the post-prune message set.
contextTokens = compactionContextTokens(contextTokens, this.#estimateStoredContextTokens());
await this.#pruneToolOutputs();
// Pruning frees bytes for the NEXT prompt; it does not change the size of
// the prompt the LLM just billed for. Earlier revisions subtracted the
// per-turn supersede/prune `tokensSaved` from the threshold input, which
// let a long-running `/goal` session sit above `compaction.thresholdTokens`
// indefinitely whenever per-turn pruning saved enough to drop the
// post-prune estimate below the user-configured trigger — the visible
// context (anchored to the same provider billing) still showed >threshold,
// but `shouldCompact` no-op'd (#3174). Anchor on the last turn's billed
// context tokens, floored by the post-prune stored-conversation estimate
// so a payload-compression hook still can't deflate the trigger.
const contextTokens = compactionContextTokens(
calculateContextTokens(assistantMessage.usage),
this.#estimateStoredContextTokens(),
);
if (shouldCompact(contextTokens, contextWindow, compactionSettings)) {
// Try promotion first — if a larger model is available, switch instead of compacting
const promoted = await this.#tryContextPromotion(assistantMessage);
Expand Down
145 changes: 145 additions & 0 deletions packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,151 @@ describe("AgentSession auto-compaction queue resume", () => {
expect(runtimeSignals.some(signal => signal.startsWith("compaction:end:"))).toBe(true);
});

it("triggers threshold compaction in active goals even when per-turn pruning shaves the post-prune estimate below threshold", async () => {
// Regression for #3174. Goal mode is the most common scenario: the agent
// runs many tool-result-heavy turns and the per-turn "useless" /
// "supersede" passes shave tokens off every check. Pre-fix
// `#checkCompaction` subtracted those savings from the threshold input, so
// with the reporter's fixed `compaction.thresholdTokens: 76384`, the
// threshold input fell below the trigger even when the provider-billed
// prompt (and the visible context anchored to it) sat above 90k tokens —
// auto-compaction silently no-op'd indefinitely while the loop kept
// running.
//
// This seeds one large `useless` tool result whose suffix sits inside the
// 8k cache-warm window so `#pruneStaleToolResults` actually returns ≥20k
// savings (well above the buggy code's mis-subtraction needed to drop
// 91000 below 76384). Compaction MUST still fire because the last turn's
// billed context tokens (91k) are above the configured threshold.
const now = Date.now();

// Seed: small user, small toolCall, ONE big useless tool result, then a
// handful of small turns that keep the suffix after the big result under
// the 8000-token cache-warm cutoff. The big result is the only viable
// prune candidate, and it alone saves well over 20k tokens — enough to
// drag the pre-fix threshold input from 91k well below 76384.
sessionManager.appendMessage({
role: "user",
content: "Investigate every module of the project.",
timestamp: now - 200,
});
const bigCallId = "call-big-useless";
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "toolCall", id: bigCallId, name: "search", arguments: { pattern: "TODO" } }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "toolUse",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: now - 180,
});
sessionManager.appendMessage({
role: "toolResult",
toolCallId: bigCallId,
toolName: "search",
content: [{ type: "text", text: "match line\n".repeat(20000) }], // ~40k+ tokens
isError: false,
useless: true,
timestamp: now - 170,
});
// A few small follow-up turns so the big result's suffix stays inside the
// 8000-token cache-warm window. Each pair is well under a hundred tokens.
for (let i = 0; i < 4; i++) {
const smallId = `call-small-${i}`;
const ts = now - 160 + i * 2;
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "toolCall", id: smallId, name: "read", arguments: { path: `note-${i}.md` } }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "toolUse",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: ts,
});
sessionManager.appendMessage({
role: "toolResult",
toolCallId: smallId,
toolName: "read",
content: [{ type: "text", text: `tiny note ${i}` }],
isError: false,
timestamp: ts + 1,
});
}
session.agent.replaceMessages(session.buildDisplaySessionContext().messages);

session.setGoalModeState({
enabled: true,
mode: "active",
goal: {
id: "goal-threshold-pruneable",
objective: "continue until compacted",
status: "active",
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: now,
updatedAt: now,
},
});

vi.spyOn(session.agent, "continue").mockImplementation(async () => {
session.agent.clearAllQueues();
});

session.settings.set("compaction.thresholdTokens", 76384);
session.settings.set("compaction.thresholdPercent", -1);
session.settings.set("compaction.strategy", "context-full");
session.settings.set("compaction.dropUseless", true);
session.settings.set("compaction.supersedeReads", true);
session.settings.set("compaction.keepRecentTokens", 10000);
session.settings.set("compaction.reserveTokens", 16384);

// Final assistant turn: billed at ~91k context tokens, just over the
// reporter's threshold. The pre-fix code would have subtracted ≥20k of
// prune savings and dropped the threshold input below 76384, skipping
// compaction. Post-fix it must trigger.
const finalAssistant = {
role: "assistant" as const,
content: [{ type: "text" as const, text: "Investigated module-7; continuing." }],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
stopReason: "stop" as const,
usage: {
input: 5000,
output: 1000,
cacheRead: 85000,
cacheWrite: 0,
totalTokens: 91000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: now,
};

session.agent.emitExternalEvent({ type: "message_end", message: finalAssistant });
session.agent.emitExternalEvent({ type: "agent_end", messages: [finalAssistant] });

await session.waitForIdle();

const runtimeSignals = getRuntimeSignals();
expect(runtimeSignals).toContain("compaction:start:threshold");
expect(runtimeSignals.some(signal => signal.startsWith("compaction:end:"))).toBe(true);
});
it("has isCompacting true when the auto_compaction_start event fires", async () => {
// Defect 1: the compaction AbortController (which backs isCompacting) must be
// installed before auto_compaction_start is emitted. If it is installed after,
Expand Down
Loading