diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 48452b5c8..d9f0c3c23 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -886,6 +886,13 @@ export interface ContextSnapshot { nonMessageTokens: number; // estimated non-message total at send time /** Estimated prompt tokens removed by local history rewrites after this provider snapshot was recorded. */ historyRewriteTokensRemoved?: number; + /** + * Compaction epoch current when this snapshot's provider request was recorded. + * A later compaction bumps the session epoch, so an anchor whose epoch is + * older than the current in-flight snapshot describes pre-compaction history + * and must not override the rebased estimate. + */ + compactionEpoch?: number; lastMessageTimestamp?: number; } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fdd5ae758..044fc3680 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed - Fixed Claude Code marketplace plugins ignoring the `enabledPlugins` switch in `~/.claude/settings.json` and `.claude/settings(.local).json`: a plugin turned off for a project no longer loads there, and a local-scope install enabled for a project loads even when its recorded `projectPath` is a different directory +- Fixed a mid-run compaction being misread as a phantom overflow: after a compaction rebased the in-flight context snapshot, `getContextBreakdown` used message position (`anchorIndex >= cutoffCount`) as a freshness proxy, so an in-flight provider response whose request predated the compaction out-ranked the rebased estimate and reported the pre-compaction token count (~2.6x the real one). This tripped the "Compaction freed too little context to make progress" guard and drove the frame-rescue path on a byte-identical `tokensBefore`. Assistant context snapshots now carry a monotonic compaction epoch, and a post-cutoff anchor whose epoch predates the last compaction is no longer trusted over the rebased estimate ([#8887](https://github.com/can1357/oh-my-pi/issues/8887)). - Fixed task and eval subagents discovering newly added agent definitions while resolving their role aliases from stale startup settings. Subagent preflight now atomically reloads persisted settings before agent discovery while preserving live runtime overrides. - Fixed images returned by tools mounted under `xd://` rendering only as file links instead of inline terminal graphics. - Resume Cursor idle-stall turns after completed MCP/todo tool results. The watchdog already closes the Connect stream, so unmarked blocks no longer need the `exec-resolved` marker to continue. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index b93105728..d5a5570b0 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2369,6 +2369,7 @@ export class AgentSession { assistantMsg.contextSnapshot = { promptTokens: calculatePromptTokens(assistantMsg.usage), nonMessageTokens: this.#stats.pendingNonMessageTokens ?? computeNonMessageTokens(this), + compactionEpoch: this.#stats.compactionEpoch, }; } } diff --git a/packages/coding-agent/src/session/session-stats.ts b/packages/coding-agent/src/session/session-stats.ts index cd16cbcd6..d13cdaa02 100644 --- a/packages/coding-agent/src/session/session-stats.ts +++ b/packages/coding-agent/src/session/session-stats.ts @@ -22,6 +22,12 @@ interface PendingContextSnapshot { promptTokens: number; nonMessageTokens: number; cutoffCount: number; + /** + * Compaction epoch at rebase time. Distinguishes a genuinely fresh in-turn + * anchor (same epoch) from a post-cutoff anchor that predates a mid-run + * compaction (older epoch) so the latter never out-ranks this snapshot. + */ + epoch: number; } /** Capabilities the stats tracker borrows from its owning session. */ @@ -44,6 +50,7 @@ export class SessionStatsTracker { readonly #host: SessionStatsTrackerHost; #pendingContextSnapshot: PendingContextSnapshot | undefined; #contextUsageRevision = 0; + #compactionEpoch = 0; constructor(host: SessionStatsTrackerHost) { this.#host = host; @@ -162,8 +169,11 @@ export class SessionStatsTracker { } } + const anchorEpoch = anchorAssistant?.contextSnapshot?.compactionEpoch ?? 0; const useAnchor = - anchorAssistant !== undefined && anchorIndex !== -1 && (!pending || anchorIndex >= pending.cutoffCount); + anchorAssistant !== undefined && + anchorIndex !== -1 && + (!pending || (anchorIndex >= pending.cutoffCount && anchorEpoch >= pending.epoch)); if (useAnchor && anchorAssistant) { const promptTokens = correctedPromptTokens(anchorAssistant); const nonMessageTokens = @@ -255,6 +265,15 @@ export class SessionStatsTracker { return this.#contextUsageRevision; } + /** + * Monotonic compaction epoch, bumped whenever history is compacted. Stamped + * onto each assistant snapshot at record time so {@link getContextBreakdown} + * can reject a post-cutoff anchor whose usage predates the last compaction. + */ + get compactionEpoch(): number { + return this.#compactionEpoch; + } + /** Non-message token count captured for the active provider request. */ get pendingNonMessageTokens(): number | undefined { return this.#pendingContextSnapshot?.nonMessageTokens; @@ -292,6 +311,7 @@ export class SessionStatsTracker { assistant.contextSnapshot = { promptTokens: calculatePromptTokens(assistant.usage), nonMessageTokens: computeNonMessageTokens(this.#host.session), + compactionEpoch: this.#compactionEpoch, }; } const snapshot = assistant.contextSnapshot; @@ -302,13 +322,14 @@ export class SessionStatsTracker { } /** Sets or clears the in-flight context snapshot. */ - setPendingSnapshot(snapshot: PendingContextSnapshot | undefined): void { - this.#pendingContextSnapshot = snapshot; + setPendingSnapshot(snapshot: Omit | undefined): void { + this.#pendingContextSnapshot = snapshot ? { ...snapshot, epoch: this.#compactionEpoch } : undefined; this.#contextUsageRevision++; } /** Recomputes an in-flight snapshot after history is compacted or rewritten. */ rebaseAfterCompaction(): void { + this.#compactionEpoch++; if (!this.#pendingContextSnapshot) return; const nonMessageTokens = computeNonMessageTokens(this.#host.session); const messages = this.#host.agent.state.messages; diff --git a/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts index a843759ba..58ba8fc7d 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts @@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { Agent } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; import { type CompactionPreparation, resolveThresholdTokens, shouldCompact } from "@oh-my-pi/pi-agent-core/compaction"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -505,6 +506,75 @@ describe("AgentSession auto-compaction progress guard", () => { const noProgress = notices.filter(n => n.source === NOTICE_SOURCE && n.message.includes(NO_PROGRESS_FRAGMENT)); expect(noProgress.length).toBe(0); }); + + it("rejects a stale pre-compaction anchor that lands past the rebase cutoff", async () => { + // Regression (#8887): after a mid-run compaction rebases the in-flight + // snapshot, an in-flight provider response whose request was assembled + // BEFORE the compaction lands past the rebase cutoff carrying + // pre-compaction usage. getContextBreakdown used message position as a + // freshness proxy (anchorIndex >= cutoffCount), so that stale anchor + // out-ranked the rebased estimate and reported a ~2.6x phantom overflow — + // tripping the "freed too little context" guard / frame-rescue path. + seedPriorTurns(); + activateOngoingGoal("stale-anchor"); + const gate = Promise.withResolvers(); + const firstPromptCall = Promise.withResolvers(); + vi.spyOn(session.agent, "prompt").mockImplementation(() => { + firstPromptCall.resolve(); + return gate.promise as never; + }); + vi.spyOn(session.agent, "continue").mockResolvedValue(); + + const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers(); + session.subscribe(event => { + if (event.type === "auto_compaction_end") onCompactionDone(); + }); + + // Hold a request in flight so the pending snapshot survives the compaction. + const inFlight = session.prompt("x".repeat(600_000)); + await firstPromptCall.promise; + + // Mid-run compaction fires and rebases the pending snapshot to the summary. + const trigger = highUsageAssistant(); + session.agent.emitExternalEvent({ type: "message_end", message: trigger }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [trigger] }); + await compactionDone; + + const rebasedTokens = session.getContextBreakdown()?.usedTokens ?? 0; + expect(rebasedTokens).toBeLessThan(50_000); + // The trigger was persisted before the compaction, so its snapshot carries + // the pre-compaction epoch — the exact stamp a real in-flight response has. + const preCompactionEpoch = (trigger as AssistantMessage).contextSnapshot?.compactionEpoch ?? 0; + + const staleAnchor = { + role: "assistant", + content: [{ type: "text", text: "stale in-flight response" }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + stopReason: "stop", + usage: { + input: 360000, + output: 500, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 360500, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + contextSnapshot: { promptTokens: 360000, nonMessageTokens: 100, compactionEpoch: preCompactionEpoch }, + timestamp: Date.now() + 1, + } as AssistantMessage; + sessionManager.appendMessage(staleAnchor); + session.agent.replaceMessages([...session.agent.state.messages, staleAnchor]); + + // Freshness marker rejects the stale anchor: usage tracks the rebased + // estimate, not the ~360k pre-compaction figure. + expect(session.getContextBreakdown()?.usedTokens ?? 0).toBeLessThan(50_000); + + gate.resolve(); + await inFlight.catch(() => {}); + await session.waitForIdle(); + }); /** * Seed several large prior turns into the session branch so `prepareCompaction` * returns a real preparation after the overflow recovery drops the failed