From e09fcdfb53e816f048e059596d888413c0329b56 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:17:30 +0000 Subject: [PATCH 1/4] feat(compaction): compact on return to a chat whose prompt cache expired When a desktop chat's prompt cache has lapsed and its next request would carry at least 100K tokens of history, a return to it (opening the chat, bringing the window back, or starting to type) compacts it: pi-vcc runs on its own because it makes no model call, and LLM compaction is offered as a "Compact first?" button in the composer instead. Each cache window is handled once and remembered across restarts. No idle compaction. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012N8F5U34gqvWS8cC5ruNSr --- README.md | 2 +- docs/chatgpt-desktop-ui-inspiration.md | 2 +- docs/plans/compaction-plan.md | 2 + package.json | 2 +- renderer/components/composer.tsx | 85 ++++++++++- .../components/settings/memory-settings.tsx | 5 + renderer/lib/context-pressure-feed.test.ts | 28 +++- renderer/lib/context-pressure-feed.ts | 15 +- renderer/lib/expired-cache-return.test.ts | 144 ++++++++++++++++++ renderer/lib/expired-cache-return.ts | 143 +++++++++++++++++ renderer/lib/use-expired-cache-return.ts | 69 +++++++++ renderer/main/chat-pane.tsx | 15 +- 12 files changed, 495 insertions(+), 17 deletions(-) create mode 100644 renderer/lib/expired-cache-return.test.ts create mode 100644 renderer/lib/expired-cache-return.ts create mode 100644 renderer/lib/use-expired-cache-return.ts diff --git a/README.md b/README.md index 15101751a..6bf96b22a 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The roadmap is maintained in [the plan index](docs/plans/README.md). These bulle - **Design Studio and Create Images** - early work exists behind the default-off `AIDEN_EXPERIMENTAL_DESIGN_STUDIO` and `AIDEN_EXPERIMENTAL_CREATE_IMAGES` flags. The former Designer Mode flow (select UI in a local Vite app, request a bounded change, approve, review the diff) is now phase DS-4 "Connected app", and its Phase 0 go/no-go gate is a DS-4 entry criterion. See the [Design Studio + Create Images plan](docs/plans/design-studio-create-images-rebuild-plan.md). - **Provider completion** - Pi built-in discovery, encrypted credentials, provider-owned authentication, native streaming, validated pi.dev catalog overlays with an offline cache, custom provider composition, canonical assistant provenance, and voice credential lookup already ship. Remaining work is scalable large-catalog UX and rollout cleanup. See the [Pi Provider Integration](docs/plans/pi-provider-integration-plan.md) plan. - **Truthful generation progress notes** - no progress-note runtime exists yet. The plan would show one temporary acknowledgement after an otherwise-silent start, using an explicitly selected on-device or verified hosted route without exposing hidden reasoning. See the [Generation Progress Notes plan](docs/plans/generation-progress-notes-plan.md). -- **Long-session context** - model-aware compaction with Pi-native checkpoints, crash recovery, visible compaction activity, and busy-composer Steer/Queue already ship. Remaining work includes durable-versus-working memory separation and provider-native compaction paths. See the [Compaction](docs/plans/compaction-plan.md) and [Pi Compaction and Durable Memory Upgrade](docs/plans/pi-compaction-memory-upgrade-plan.md) plans. +- **Long-session context** - model-aware compaction with Pi-native checkpoints, crash recovery, visible compaction activity, compaction when you return to a large chat whose prompt cache expired (automatic with pi-vcc, a "Compact first?" prompt with LLM compaction), and busy-composer Steer/Queue already ship. Remaining work includes durable-versus-working memory separation and provider-native compaction paths. See the [Compaction](docs/plans/compaction-plan.md) and [Pi Compaction and Durable Memory Upgrade](docs/plans/pi-compaction-memory-upgrade-plan.md) plans. - **Simulator devices** - on macOS, the Environment **Simulator** tab streams and controls iOS Simulators and Android Emulators on this Mac, a paired Mac, or an SSH host, with optional procedural 3D frames and consent-gated `device_*` agent tools. On by default since 0.55.0; `AIDEN_EXPERIMENTAL_DEVICES=0` is the kill switch. Remaining work is real-Mac acceptance for agent tools, paired Macs and 3D frames. See [Simulator devices](docs/devices.md). - **Whole-app performance, durability, and battery gates** - atomic/recoverable chat storage and several bounded lifecycles already ship. The broader program still needs production-equivalent baselines, recovery and hard bounds across the remaining stores and payloads, zero-idle Git/renderer scheduling, cancellable heavy helpers, and enforceable packaged release budgets. See the [Performance, Stability, Battery, and Efficiency plan](docs/plans/performance-stability-efficiency-plan.md). diff --git a/docs/chatgpt-desktop-ui-inspiration.md b/docs/chatgpt-desktop-ui-inspiration.md index e439501f8..033c634f6 100644 --- a/docs/chatgpt-desktop-ui-inspiration.md +++ b/docs/chatgpt-desktop-ui-inspiration.md @@ -174,7 +174,7 @@ labels recorded usage. Appearance can hide the meter without hiding the tool. | Card layout | Each section is its own borderless raised card with a title, a chevron after the label, a trailing summary while collapsed, and a header button left of a right-edge grip. There is no `+` menu: cards start collapsed and appear only when relevant. | The grip shows on hover and keyboard focus. Drag it, or press ArrowUp or ArrowDown on it, to reorder; a polite status announces the new position. Order and the cards the user opened persist on this device. A card that appears mid-session fades and scales in; Reduce Motion removes the motion. | | Folder | The first Environment row shows the workspace folder and its parent, with a preferred-editor action when one is installed. Its card header offers an open-external button that reveals the folder in Finder (Files off macOS). | The row opens Files on this workspace. Open failures surface as a toast. The row is disabled without file access and absent when the workspace has no folder. | | Pull request | A Changes row names the chat's pull request number and state (open, draft, merged, closed). It appears only once a pull request exists for this chat, and there is no Create PR button. | Activating it opens the pull request on GitHub. A merged pull request without a title is named by its branch. | -| Context | A card that appears after the first message. Collapsed, it shows the percentage beside a 10-pip meter, the same one the composer uses. Expanded, 100 tiles lay out the whole context window, each tile 1% of it: used tiles colored by category, then free space, then the hatched response reserve. Below, each category has its own row with tokens and a bar relative to the largest one, followed by quieter Reserved, Free and deferred rows. | Deferred MCP tools are marked "not counted", since they only count once used, and a fold lists them per server. A soft status pill covers compaction. For models whose provider documents a fixed prompt cache lifetime (Claude, OpenAI GPT-5.6 and later, Kimi K3, MiniMax M2, Copilot), a countdown shows how long the cache stays warm after the last request; providers that only give a range show none. Without a breakdown it falls back to Messages and System prompt & tools. The header button opens the Context tool in the sidebar, which shows the same tiles as a square beside the bars, next to Session and Recorded usage cards. | +| Context | A card that appears after the first message. Collapsed, it shows the percentage beside a 10-pip meter, the same one the composer uses. Expanded, 100 tiles lay out the whole context window, each tile 1% of it: used tiles colored by category, then free space, then the hatched response reserve. Below, each category has its own row with tokens and a bar relative to the largest one, followed by quieter Reserved, Free and deferred rows. | Deferred MCP tools are marked "not counted", since they only count once used, and a fold lists them per server. A soft status pill covers compaction. For models whose provider documents a fixed prompt cache lifetime (Claude, OpenAI GPT-5.6 and later, Kimi K3, MiniMax M2, Copilot), a countdown shows how long the cache stays warm after the last request; providers that only give a range show none. Once that cache has expired on a chat over 100K tokens, the composer's status row offers "Compact first?" with a dismiss button when LLM compaction is selected; pi-vcc compacts on return instead and shows its usual compacting status. Without a breakdown it falls back to Messages and System prompt & tools. The header button opens the Context tool in the sidebar, which shows the same tiles as a square beside the bars, next to Session and Recorded usage cards. | | Subagents | A card that appears once the chat has started a subagent and stays for the session. Collapsed, it summarizes active and finished runs. Expanded, runs are grouped Running, Needs attention, Failed, Stopped and Completed. | Completed starts folded behind one disclosure row. Active foreground runs reveal Stop on hover or focus. A row opens that run, and the header button opens the Subagents tool in the sidebar. | ### Review panel states diff --git a/docs/plans/compaction-plan.md b/docs/plans/compaction-plan.md index 5d1cbe71e..6c3a261df 100644 --- a/docs/plans/compaction-plan.md +++ b/docs/plans/compaction-plan.md @@ -8,6 +8,8 @@ The delivered boundary includes append-only private chat journals, current-turn- The broader plan intentionally remains Partial. The production-driven [Pi compaction compatibility follow-on](completed/compaction-reliability-plan.md) removed Aiden-only checkpoint acceptance rules so the adapter matches the audited Pi upstream 1:1, and added persisted safe provider-failure categories. Durable cross-task memory, semantic identifier/latest-request audits, provider-native compaction, replay benchmarks, rollout telemetry, and per-model policy tuning remain later work rather than being implied by the completed Pi parity delivery. +Update 2026-10-20: returning to a chat whose prompt cache has expired now compacts it when the next request would carry at least 100K tokens. Returns are opening the chat, bringing the window back, or starting to type, and they count only once the cache has lapsed; a draft does not count toward the 100K. The pi-vcc engine compacts on its own because it makes no model call; the LLM engine shows a "Compact first?" offer in the composer instead. Each cache window is handled at most once, remembered across restarts, and Aiden does not compact idle chats before expiry, since keeping a 5-minute cache warm costs more than it saves. Expiry comes from the same lifetime as the Context panel countdown (`main/services/prompt-cache-lifetimes.ts`). Desktop chats only; Bots, paired-Mac chats, phones and the CLI are unchanged. + I treated **“Py” as Pi**, and I also included **PydanticAI**, because its newer tiered-compaction design captures many of the best ideas in one framework. The strongest approach is **not** “summarize the entire conversation when the window is full.” The better architecture is a **layered, transactional context-management pipeline**: diff --git a/package.json b/package.json index dd88669ce..23cde3e2b 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "test:provider-failure": "tsx --test main/services/provider-failure.test.ts", "test:web-search": "tsx --test main/services/web-search.test.ts main/services/web-search-core.test.ts main/services/web-search-exa-core.test.ts main/services/web-search-provider-registry-core.test.ts main/services/web-search-provider-registry.test.ts main/services/web-search-credential-core.test.ts main/services/web-search-auth-reuse.test.ts main/services/web-search-rollout.test.ts main/services/web-search-wave1-adapters.test.ts main/services/web-search-wave1-ai.test.ts main/services/web-search-wave2-batch-a.test.ts main/services/web-search-wave2-batch-b.test.ts main/services/web-search-wave4-batch-a.test.ts main/services/web-search-wave4-batch-b.test.ts main/handlers/web-search-contract.test.ts renderer/components/settings/web-search-settings.test.tsx renderer/lib/settings-section.test.ts main/services/web-search-key-pool-core.test.ts main/handlers/web-search-key-pool.test.ts renderer/components/settings/web-search-key-pool.test.tsx", "test:concentrate": "tsx --test main/services/concentrate-provider.test.ts", - "test:compaction": "npm run test:vcc && tsx --test main/services/memory-shared.test.ts main/services/pi-runtime-events.test.ts main/services/pi-cache-warming.test.ts main/services/pi-agent-runtime-harness.test.ts main/services/pi-codemode.test.ts main/services/context-pressure-tools.test.ts main/services/pi-tool-discovery.test.ts main/services/bounded-json.test.ts main/services/pi-compaction-core.test.ts main/services/pi-legacy-session.test.ts main/services/pi-session-migration.test.ts main/services/pi-session-v4-upgrade.test.ts main/services/pi-session-port.test.ts main/services/context-lifecycle-service.test.ts main/services/context-lifecycle-surface-matrix.test.ts main/services/telegram/telegram-session.test.ts main/services/generation-emergency-outcome.test.ts main/services/pi-runtime-effect-store.test.ts main/services/generation-context.test.ts main/services/prompt-cache-lifetimes.test.ts main/services/memory-store.test.ts main/services/memory-context.test.ts main/services/memory-surface-matrix.test.ts main/services/pi-upgrade-evaluation.test.ts main/services/subagents/agent-compatibility.test.ts renderer/lib/ipc-stream.test.ts renderer/main/chat-transition.test.tsx main/services/pi-journal-fork.test.ts main/services/fork-summary-service.test.ts renderer/components/settings/compaction-budget-settings.test.tsx", + "test:compaction": "npm run test:vcc && tsx --test main/services/memory-shared.test.ts main/services/pi-runtime-events.test.ts main/services/pi-cache-warming.test.ts main/services/pi-agent-runtime-harness.test.ts main/services/pi-codemode.test.ts main/services/context-pressure-tools.test.ts main/services/pi-tool-discovery.test.ts main/services/bounded-json.test.ts main/services/pi-compaction-core.test.ts main/services/pi-legacy-session.test.ts main/services/pi-session-migration.test.ts main/services/pi-session-v4-upgrade.test.ts main/services/pi-session-port.test.ts main/services/context-lifecycle-service.test.ts main/services/context-lifecycle-surface-matrix.test.ts main/services/telegram/telegram-session.test.ts main/services/generation-emergency-outcome.test.ts main/services/pi-runtime-effect-store.test.ts main/services/generation-context.test.ts main/services/prompt-cache-lifetimes.test.ts main/services/memory-store.test.ts main/services/memory-context.test.ts main/services/memory-surface-matrix.test.ts main/services/pi-upgrade-evaluation.test.ts main/services/subagents/agent-compatibility.test.ts renderer/lib/ipc-stream.test.ts renderer/main/chat-transition.test.tsx main/services/pi-journal-fork.test.ts main/services/fork-summary-service.test.ts renderer/components/settings/compaction-budget-settings.test.tsx renderer/lib/expired-cache-return.test.ts", "test:compaction:packaged": "node --import tsx scripts/pi-session-packaged-restart-acceptance.mjs", "test:compaction:evaluate": "node --import tsx scripts/pi-upgrade-evaluation.mjs", "pi-upgrade:advance": "node --import tsx scripts/pi-upgrade-advance.mjs", diff --git a/renderer/components/composer.tsx b/renderer/components/composer.tsx index 8b2f7e1ae..aeddd929b 100644 --- a/renderer/components/composer.tsx +++ b/renderer/components/composer.tsx @@ -1,5 +1,6 @@ import { readCachedAppearance } from "../lib/appearance-runtime"; -import { compactionEngineLabel, type CompactionEngine } from "../shared/compaction"; +import { compactionEngineFrom, compactionEngineLabel, type CompactionEngine } from "../shared/compaction"; +import { formatContextTokenCount, type ChatContextPressureV1 } from "../shared/context-pressure"; // Message composer. On a new chat the top-row folder opens the workspace picker; // established chats reveal that folder in the system file manager. Git workspaces also show the // current branch. The input @@ -89,6 +90,7 @@ import { validateSlashCommandArgument, } from "../lib/slash-command-actions"; import { claimCompactionCommand } from "../lib/compaction-command-admission"; +import { useExpiredCacheReturn } from "../lib/use-expired-cache-return"; import { loadComposerDraft, markComposerSubmission, @@ -209,6 +211,11 @@ interface ComposerProps { thinkingControl?: React.ReactNode; /** Next-request context-pressure indicator, rendered next to the model picker. */ contextMeter?: React.ReactNode; + /** + * This chat's context reading without the draft, when the surface compacts + * on return to an expired prompt cache. Null while it loads. + */ + contextHistoryPressure?: ChatContextPressureV1 | null; /** Draft text/attachment changes so ambient projections (context pressure) can refresh. */ onDraftChange?: (text: string, attachments: Attachment[]) => void; currentChatTitle?: string; @@ -384,6 +391,7 @@ export function Composer({ modelPicker, thinkingControl, contextMeter, + contextHistoryPressure, onDraftChange, currentChatTitle, latestAssistantResponse, @@ -877,8 +885,9 @@ export function Composer({ } }, [inputRef, onExportChat, sessionCommandBusy]); + const settleExpiredCacheRef = React.useRef<() => void>(() => {}); const compactChat = React.useCallback( - async (engine?: CompactionEngine) => { + async (engine?: CompactionEngine, options?: { cacheExpired?: boolean }) => { if ( !onCompactChat || !claimCompactionCommand(sessionCommandBusyRef, sessionCommandBusy, compactionActive) @@ -886,11 +895,17 @@ export function Composer({ return; } setCompactionBusy(true); + let ran = false; setSessionCommandStatus( - engine ? `Compacting with ${compactionEngineLabel(engine)}…` : "Compacting chat…", + options?.cacheExpired && engine + ? `The prompt cache expired, so Aiden is compacting with ${compactionEngineLabel(engine)}…` + : engine + ? `Compacting with ${compactionEngineLabel(engine)}…` + : "Compacting chat…", ); try { const result = await onCompactChat(engine); + ran = result.compacted || result.reason !== "busy"; if (result.compacted) { const label = compactionEngineLabel(result.engine ?? engine ?? "llm"); const size = @@ -900,7 +915,8 @@ export function Composer({ const duration = result.durationMs !== undefined ? ` · ${(result.durationMs / 1000).toFixed(1)}s` : ""; toast.success(`${label} compaction complete${duration}${size}`); - } else { + } else if (!options?.cacheExpired || result.reason === "compaction_failed") { + // A compaction the user didn't ask for stays quiet unless it failed. const copy = { already_compact: "This chat is already compact enough.", busy: "Finish the current response or approval first.", @@ -914,15 +930,52 @@ export function Composer({ toast.info(copy[result.reason]); } } finally { + // Any compaction answers an expired cache window, so it is not offered again. + if (ran) settleExpiredCacheRef.current(); sessionCommandBusyRef.current = false; setCompactionBusy(false); setSessionCommandStatus(null); - requestAnimationFrame(() => inputRef?.current?.focus({ preventScroll: true })); + // An automatic compaction never pulls focus from wherever the user went. + if (!options?.cacheExpired) requestAnimationFrame(() => inputRef?.current?.focus({ preventScroll: true })); } }, [compactionActive, inputRef, onCompactChat, sessionCommandBusy], ); + // Returning to a large chat whose prompt cache expired: pi-vcc compacts on + // its own, LLM compaction is offered as "Compact first?". + const expiredCacheReturn = useExpiredCacheReturn({ + chatId, + pressure: contextHistoryPressure, + engine: + contextHistoryPressure !== undefined && settings.data ? compactionEngineFrom(settings.data.compactionEngine) : undefined, + idle: + Boolean(onCompactChat) && + ready && + !isGenerating && + !hasQueuedMessages && + !compactionActive && + !sessionCommandBusy && + !forkSummaryHold && + !sending && + !firstSendPending && + !attaching && + !voice.recording && + !voice.transcribing && + !configurationBusy && + !permissionSaving && + !gitOperationBusy, + compact: (engine, automatic) => void compactChat(engine, automatic ? { cacheExpired: true } : undefined), + }); + settleExpiredCacheRef.current = expiredCacheReturn.settle; + const typedRef = React.useRef(false); + React.useEffect(() => { + // Starting to type is a return too, unless it is a slash command. + const typed = text.trim().length > 0; + if (typed && !typedRef.current && !text.trimStart().startsWith("/")) expiredCacheReturn.markReturned(); + typedRef.current = typed; + }, [expiredCacheReturn.markReturned, text]); + const createWorktreeFromSlash = React.useCallback( async (branchName?: string) => { if (onDraftNewWorktree) { @@ -2210,6 +2263,28 @@ export function Composer({ : "The fork's summary didn't finish. Retry it or continue without it."} {queueForForkSummary ? " Messages you send now wait until it's settled." : null} + ) : expiredCacheReturn.offer ? ( +
+ + The prompt cache expired, so the next message resends ~ + {formatContextTokenCount(expiredCacheReturn.offer.contextTokens)} tokens. + +
+ + +
+
) : null} + + When you come back to a chat over 100K tokens after its prompt cache expired, pi-vcc + compacts it right away. With LLM Compaction, Aiden asks “Compact first?” instead, since + summarizing uses model tokens. + () { function harness() { const published: (number | null)[] = []; + const owners: { chatId: string | null; draft?: string }[] = []; const requests: { chatId: string; draft?: string; @@ -30,7 +31,10 @@ function harness() { requests.push({ chatId, draft, reply }); return reply.promise; }, - publish: (pressure) => published.push(pressure?.contextTokens ?? null), + publish: (pressure, chatId, draft) => { + published.push(pressure?.contextTokens ?? null); + owners.push(draft === undefined ? { chatId } : { chatId, draft }); + }, schedule: (run) => { const id = nextTimer++; timers.set(id, run); @@ -43,7 +47,7 @@ function harness() { timers.clear(); for (const run of pending) run(); }; - return { feed, published, requests, timers, fireTimers }; + return { feed, published, owners, requests, timers, fireTimers }; } const settle = () => new Promise((resolve) => setImmediate(resolve)); @@ -68,6 +72,26 @@ test("switching chats clears the old reading and drops its in-flight response", assert.deepEqual(h.published, [null, null, 100]); }); +test("each reading names its chat and the draft it priced", async () => { + const h = harness(); + h.feed.showChat("a"); + void h.feed.refresh(); + h.requests[0].reply.resolve(reading(100)); + await settle(); + h.feed.draftChanged("pasted notes"); + h.fireTimers(); + h.requests[1].reply.resolve(reading(180)); + await settle(); + h.feed.showChat("b"); + assert.deepEqual(h.published, [null, 100, 180, null]); + assert.deepEqual(h.owners, [ + { chatId: "a" }, + { chatId: "a" }, + { chatId: "a", draft: "pasted notes" }, + { chatId: "b" }, + ]); +}); + test("switching chats cancels a pending draft refresh from the previous chat", async () => { const h = harness(); h.feed.showChat("a"); diff --git a/renderer/lib/context-pressure-feed.ts b/renderer/lib/context-pressure-feed.ts index 1c6f208ad..d930e8ca1 100644 --- a/renderer/lib/context-pressure-feed.ts +++ b/renderer/lib/context-pressure-feed.ts @@ -6,8 +6,11 @@ export interface ContextPressureFeedOptions { chatId: string, draft: Draft | undefined, ): Promise; - /** Render a reading; null means the quiet "unknown" meter. */ - publish(pressure: ChatContextPressureV1 | null): void; + /** + * Render a reading for the shown chat; null means the quiet "unknown" meter. + * `draft` is the composer draft the reading prices, if any. + */ + publish(pressure: ChatContextPressureV1 | null, chatId: string | null, draft: Draft | undefined): void; draftDelayMs?: number; schedule?(run: () => void, delayMs: number): unknown; cancel?(handle: unknown): void; @@ -54,7 +57,7 @@ export class ContextPressureFeed { // changes, so it stays the baseline across chats in the same workspace. this.draft = undefined; this.invalidate(); - this.options.publish(null); + this.options.publish(null, this.chatId, undefined); } /** @@ -69,7 +72,7 @@ export class ContextPressureFeed { this.scope = scope; if (previous === undefined || previous === scope) return; this.invalidate(); - this.options.publish(null); + this.options.publish(null, this.chatId, undefined); } /** While live, only pushed readings reach the meter. */ @@ -92,7 +95,7 @@ export class ContextPressureFeed { return; } if (request === this.request && !this.live && chatId === this.chatId) { - this.options.publish(pressure); + this.options.publish(pressure, chatId, draft); } } @@ -114,7 +117,7 @@ export class ContextPressureFeed { */ pushed(chatId: string, pressure: ChatContextPressureV1 | null): void { if (chatId !== this.chatId) return; - if (this.live) this.options.publish(pressure); + if (this.live) this.options.publish(pressure, chatId, undefined); else void this.refresh(); } diff --git a/renderer/lib/expired-cache-return.test.ts b/renderer/lib/expired-cache-return.test.ts new file mode 100644 index 000000000..bf2a990a0 --- /dev/null +++ b/renderer/lib/expired-cache-return.test.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ChatContextPressureV1 } from "../shared/context-pressure"; +import { EXPIRED_CACHE_RETURN_GRACE_MS, ExpiredCacheReturns, promptCacheExpiresAt } from "./expired-cache-return"; + +const MINUTE = 60_000; +const LAST_REQUEST = 1_000_000; +const AFTER_EXPIRY = LAST_REQUEST + 6 * MINUTE; + +function pressure(overrides: Partial = {}): ChatContextPressureV1 { + return { + contextTokens: 140_000, + contextWindow: 1_000_000, + inputBudgetTokens: 900_000, + reservedTokens: 100_000, + percentOfWindow: 14, + percentOfUsableInput: 15, + shouldCompact: false, + messageTokens: 120_000, + staticTokens: 20_000, + compressibleHistoryMessages: 40, + source: "provider-anchored", + computedAt: AFTER_EXPIRY, + promptCache: { ttlMs: 5 * MINUTE, lastRequestAt: LAST_REQUEST }, + ...overrides, + }; +} + +const vcc = (reading: ChatContextPressureV1 | null, now = AFTER_EXPIRY, idle = true) => ({ + pressure: reading, + engine: "vcc" as const, + idle, + now, +}); +const llm = (reading: ChatContextPressureV1 | null, now = AFTER_EXPIRY, idle = true) => ({ + pressure: reading, + engine: "llm" as const, + idle, + now, +}); + +/** A fresh tracker over in-memory storage, so restarts can be simulated. */ +function tracker(saved: { value: Record } = { value: {} }) { + return new ExpiredCacheReturns({ load: () => ({ ...saved.value }), save: (handled) => { saved.value = handled; } }); +} + +test("returning to a large chat after its cache expired compacts with pi-vcc exactly once", () => { + const returns = tracker(); + returns.returned("chat", AFTER_EXPIRY); + assert.equal(returns.takeAutomatic("chat", vcc(pressure())), true); + // Later renders, focus changes and keystrokes in the same window do nothing. + assert.equal(returns.takeAutomatic("chat", vcc(pressure())), false); + returns.returned("chat", AFTER_EXPIRY + 1); + assert.equal(returns.takeAutomatic("chat", vcc(pressure({ contextTokens: 120_000 }), AFTER_EXPIRY + 1)), false); + // A new request opens a new window, which expires and compacts again on return. + const later = pressure({ promptCache: { ttlMs: 5 * MINUTE, lastRequestAt: LAST_REQUEST + 20 * MINUTE } }); + returns.returned("chat", LAST_REQUEST + 30 * MINUTE); + assert.equal(returns.takeAutomatic("chat", vcc(later, LAST_REQUEST + 30 * MINUTE)), true); +}); + +test("expiry while the user stays never compacts or offers; only a later return does", () => { + const returns = tracker(); + // Opened while the cache was still warm, then sat idle past expiry. + returns.returned("chat", LAST_REQUEST + MINUTE); + assert.equal(returns.takeAutomatic("chat", vcc(pressure(), LAST_REQUEST + 2 * MINUTE)), false); + assert.equal(returns.takeAutomatic("chat", vcc(pressure())), false); + assert.equal(returns.offer("chat", llm(pressure())), undefined); + assert.equal(tracker().takeAutomatic("chat", vcc(pressure())), false); + returns.returned("chat", AFTER_EXPIRY); + assert.deepEqual(returns.offer("chat", llm(pressure())), { contextTokens: 140_000 }); + assert.equal(returns.takeAutomatic("chat", vcc(pressure())), true); +}); + +test("a return waits briefly for the reading to load and the chat to settle", () => { + const returns = tracker(); + returns.returned("chat", AFTER_EXPIRY); + assert.equal(returns.takeAutomatic("chat", vcc(null)), false); + assert.equal(returns.takeAutomatic("chat", vcc(pressure(), AFTER_EXPIRY, false)), false); + assert.equal(returns.takeAutomatic("chat", vcc(pressure(), AFTER_EXPIRY + 1_000)), true); + + // A chat that only becomes ready long after the return waits for the next one. + const late = tracker(); + late.returned("chat", AFTER_EXPIRY); + assert.equal(late.takeAutomatic("chat", vcc(pressure(), AFTER_EXPIRY + EXPIRED_CACHE_RETURN_GRACE_MS + 1)), false); +}); + +test("small, already-compacting, uncompressible or untimed chats are left alone", () => { + const cases: ChatContextPressureV1[] = [ + pressure({ contextTokens: 99_999 }), + pressure({ shouldCompact: true }), + pressure({ compressibleHistoryMessages: 0 }), + pressure({ promptCache: undefined }), + ]; + for (const reading of cases) { + const returns = tracker(); + returns.returned("chat", AFTER_EXPIRY); + assert.equal(returns.offer("chat", llm(reading)), undefined); + assert.equal(returns.takeAutomatic("chat", vcc(reading)), false); + } +}); + +test("LLM compaction is only offered, and the offer ends when the user answers it", () => { + const returns = tracker(); + returns.returned("chat", AFTER_EXPIRY); + assert.equal(returns.takeAutomatic("chat", llm(pressure())), false); + assert.deepEqual(returns.offer("chat", llm(pressure())), { contextTokens: 140_000 }); + assert.equal(returns.offer("chat", llm(pressure(), AFTER_EXPIRY, false)), undefined); + returns.settle("chat", pressure()); + assert.equal(returns.offer("chat", llm(pressure())), undefined); + // Switching to pi-vcc afterwards does not compact the window the user answered. + returns.returned("chat", AFTER_EXPIRY + 1); + assert.equal(returns.takeAutomatic("chat", vcc(pressure(), AFTER_EXPIRY + 1)), false); +}); + +test("an answered window stays answered across a restart", () => { + const saved = { value: {} as Record }; + const before = tracker(saved); + before.returned("dismissed", AFTER_EXPIRY); + before.settle("dismissed", pressure()); + before.returned("compacted", AFTER_EXPIRY); + assert.equal(before.takeAutomatic("compacted", vcc(pressure())), true); + + const after = tracker(saved); + after.returned("dismissed", AFTER_EXPIRY + MINUTE); + after.returned("compacted", AFTER_EXPIRY + MINUTE); + assert.equal(after.offer("dismissed", llm(pressure(), AFTER_EXPIRY + MINUTE)), undefined); + assert.equal(after.takeAutomatic("compacted", vcc(pressure(), AFTER_EXPIRY + MINUTE)), false); +}); + +test("chats are tracked separately", () => { + const returns = tracker(); + returns.returned("a", AFTER_EXPIRY); + assert.equal(returns.takeAutomatic("b", vcc(pressure())), false); + assert.equal(returns.offer("b", llm(pressure())), undefined); + assert.equal(returns.takeAutomatic("a", vcc(pressure())), true); + returns.returned("b", AFTER_EXPIRY); + assert.deepEqual(returns.offer("b", llm(pressure())), { contextTokens: 140_000 }); +}); + +test("the expiry is the last cache request plus its lifetime", () => { + assert.equal(promptCacheExpiresAt(pressure()), LAST_REQUEST + 5 * MINUTE); + assert.equal(promptCacheExpiresAt(pressure({ promptCache: undefined })), undefined); + assert.equal(promptCacheExpiresAt(null), undefined); +}); diff --git a/renderer/lib/expired-cache-return.ts b/renderer/lib/expired-cache-return.ts new file mode 100644 index 000000000..213e96354 --- /dev/null +++ b/renderer/lib/expired-cache-return.ts @@ -0,0 +1,143 @@ +import type { CompactionEngine } from "../shared/compaction"; +import type { ChatContextPressureV1 } from "../shared/context-pressure"; + +/** + * Below this projected history size, resending an uncached chat costs little, + * so returning to it never compacts or asks to. + */ +export const EXPIRED_CACHE_COMPACTION_MIN_TOKENS = 100_000; + +/** A return starts an automatic compaction only if the chat settles this soon after it. */ +export const EXPIRED_CACHE_RETURN_GRACE_MS = 30_000; + +const HANDLED_STORAGE_KEY = "aiden.expired-cache-returns.v1"; +const HANDLED_LIMIT = 200; + +export interface ExpiredCacheInput { + /** The chat's reading without the composer draft, which compaction cannot shrink. */ + pressure: ChatContextPressureV1 | null | undefined; + engine: CompactionEngine; + /** Nothing is running, queued, held, recorded or being sent in this chat. */ + idle: boolean; + now: number; +} + +/** When the chat's prompt cache lapses, or undefined when no lifetime is known. */ +export function promptCacheExpiresAt(pressure: ChatContextPressureV1 | null | undefined): number | undefined { + const cache = pressure?.promptCache; + return cache ? cache.lastRequestAt + cache.ttlMs : undefined; +} + +/** + * The expired cache window a return at `returnedAt` would rebuild, keyed by + * the request that last used the cache: the cache had lapsed by then, the next + * request is large, and compacting could shrink it. A chat the runtime already + * compacts before its next request is left to that. + */ +function expiredWindow(pressure: ChatContextPressureV1 | null | undefined, returnedAt: number): number | undefined { + const expiresAt = promptCacheExpiresAt(pressure); + if (!pressure?.promptCache || expiresAt === undefined || returnedAt < expiresAt) return undefined; + if (pressure.shouldCompact || pressure.compressibleHistoryMessages === 0) return undefined; + if (pressure.contextTokens < EXPIRED_CACHE_COMPACTION_MIN_TOKENS) return undefined; + return pressure.promptCache.lastRequestAt; +} + +interface HandledStore { + load(): Record; + save(handled: Record): void; +} + +const localHandledStore: HandledStore = { + load() { + try { + const parsed: unknown = JSON.parse(globalThis.localStorage?.getItem(HANDLED_STORAGE_KEY) ?? "{}"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + return Object.fromEntries( + Object.entries(parsed).filter((entry): entry is [string, number] => Number.isSafeInteger(entry[1])), + ); + } catch { + return {}; + } + }, + save(handled) { + try { + globalThis.localStorage?.setItem(HANDLED_STORAGE_KEY, JSON.stringify(handled)); + } catch { + // Best-effort: without storage a restart may offer the same window again. + } + }, +}; + +/** + * Decides what returning to a chat with an expired prompt cache does. + * + * A return is opening the chat, bringing the window back, or starting to + * type, and it counts only once the cache has lapsed, so expiry while the user + * sits idle never compacts. pi-vcc compacts locally for free, so a return + * compacts on its own. LLM compaction spends model tokens, so a return only + * offers it. Either happens at most once per cache window: only a new request + * that uses the cache, or a compaction that changes the reading, brings the + * offer or the compaction back. + */ +export class ExpiredCacheReturns { + private readonly returnedAt = new Map(); + private handled: Map | undefined; + + constructor(private readonly store: HandledStore = localHandledStore) {} + + /** The user came back to this chat. */ + returned(chatId: string, now: number): void { + this.returnedAt.set(chatId, now); + } + + /** + * Whether to start a pi-vcc compaction now. Claims the window when it says + * yes, so callers start exactly one compaction for it. + */ + takeAutomatic(chatId: string, input: ExpiredCacheInput): boolean { + if (input.engine !== "vcc" || !input.idle) return false; + const returnedAt = this.returnedAt.get(chatId); + if (returnedAt === undefined || input.now - returnedAt > EXPIRED_CACHE_RETURN_GRACE_MS) return false; + const window = expiredWindow(input.pressure, returnedAt); + if (window === undefined || this.handledMap().get(chatId) === window) return false; + this.returnedAt.delete(chatId); + this.markHandled(chatId, window); + return true; + } + + /** The "Compact first?" offer for LLM compaction, if one stands. */ + offer(chatId: string, input: ExpiredCacheInput): { contextTokens: number } | undefined { + if (input.engine !== "llm" || !input.idle || !input.pressure) return undefined; + const returnedAt = this.returnedAt.get(chatId); + if (returnedAt === undefined) return undefined; + const window = expiredWindow(input.pressure, returnedAt); + if (window === undefined || this.handledMap().get(chatId) === window) return undefined; + return { contextTokens: input.pressure.contextTokens }; + } + + /** + * The window is answered: the user accepted or dismissed the offer, or any + * compaction ran in it. Its reading may not refresh before the next render. + */ + settle(chatId: string, pressure: ChatContextPressureV1 | null | undefined): void { + this.returnedAt.delete(chatId); + if (pressure?.promptCache) this.markHandled(chatId, pressure.promptCache.lastRequestAt); + } + + private handledMap(): Map { + this.handled ??= new Map(Object.entries(this.store.load())); + return this.handled; + } + + private markHandled(chatId: string, window: number): void { + const handled = this.handledMap(); + handled.delete(chatId); + handled.set(chatId, window); + // Keep the most recently handled chats. + while (handled.size > HANDLED_LIMIT) handled.delete(handled.keys().next().value as string); + this.store.save(Object.fromEntries(handled)); + } +} + +/** Shared across Composer remounts, which happen on every chat switch. */ +export const expiredCacheReturns = new ExpiredCacheReturns(); diff --git a/renderer/lib/use-expired-cache-return.ts b/renderer/lib/use-expired-cache-return.ts new file mode 100644 index 000000000..601ce9d94 --- /dev/null +++ b/renderer/lib/use-expired-cache-return.ts @@ -0,0 +1,69 @@ +import * as React from "react"; +import type { CompactionEngine } from "../shared/compaction"; +import type { ChatContextPressureV1 } from "../shared/context-pressure"; +import { expiredCacheReturns } from "./expired-cache-return"; + +interface ExpiredCacheReturnOptions { + chatId: string; + /** This chat's reading without the draft, or null while it loads. */ + pressure: ChatContextPressureV1 | null | undefined; + /** The automatic compaction engine, once settings have loaded. */ + engine: CompactionEngine | undefined; + idle: boolean; + /** Start a compaction; `automatic` is true when the user did not ask for it. */ + compact(engine: CompactionEngine, automatic: boolean): void; +} + +/** + * Wires return signals to {@link expiredCacheReturns}: mounting (Composer is + * keyed by chat, so this is opening the chat), the window coming back, and + * the start of typing. Returns the LLM offer and the actions it needs. + */ +export function useExpiredCacheReturn({ chatId, pressure, engine, idle, compact }: ExpiredCacheReturnOptions) { + const [returns, setReturns] = React.useState(0); + const markReturned = React.useCallback(() => { + expiredCacheReturns.returned(chatId, Date.now()); + setReturns((value) => value + 1); + }, [chatId]); + + React.useEffect(() => { + markReturned(); + const onVisibility = () => { + if (document.visibilityState === "visible") markReturned(); + }; + window.addEventListener("focus", markReturned); + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.removeEventListener("focus", markReturned); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [markReturned]); + + const compactRef = React.useRef(compact); + compactRef.current = compact; + React.useEffect(() => { + if (!engine) return; + if (expiredCacheReturns.takeAutomatic(chatId, { pressure, engine, idle, now: Date.now() })) { + compactRef.current("vcc", true); + } + }, [chatId, engine, idle, pressure, returns]); + + const offer = engine + ? expiredCacheReturns.offer(chatId, { pressure, engine, idle, now: Date.now() }) + : undefined; + const settle = React.useCallback(() => { + expiredCacheReturns.settle(chatId, pressure); + setReturns((value) => value + 1); + }, [chatId, pressure]); + return { + offer, + markReturned, + /** Any compaction in this window answers it, whoever started it. */ + settle, + accept: React.useCallback(() => { + settle(); + compactRef.current("llm", false); + }, [settle]), + dismiss: settle, + }; +} diff --git a/renderer/main/chat-pane.tsx b/renderer/main/chat-pane.tsx index 662d5e8ea..d1662cebe 100644 --- a/renderer/main/chat-pane.tsx +++ b/renderer/main/chat-pane.tsx @@ -429,6 +429,13 @@ export function ChatPane({ chatId }: { chatId: string }) { () => true, ); const [contextPressure, setContextPressure] = React.useState(null); + // The latest reading without a draft, and its chat, for compacting on + // return to an expired cache: a pasted draft is not history compaction can + // shrink, and a chat switch must never judge the new chat by the old one. + const [contextHistory, setContextHistory] = React.useState<{ + chatId: string | null; + pressure: ChatContextPressureV1 | null; + }>({ chatId: null, pressure: null }); const [contextCompactPending, setContextCompactPending] = React.useState(false); const [contextCompactedFlash, setContextCompactedFlash] = React.useState(false); const contextCompactedTimerRef = React.useRef(null); @@ -442,7 +449,10 @@ export function ChatPane({ chatId }: { chatId: string }) { () => new ContextPressureFeed({ fetch: (targetChatId, visibleDraft) => contextFetchRef.current(targetChatId, visibleDraft), - publish: setContextPressure, + publish: (pressure, owner, visibleDraft) => { + setContextPressure(pressure); + if (!visibleDraft || pressure === null) setContextHistory({ chatId: owner, pressure }); + }, }), ); // ChatPane stays mounted across chat navigation (see chat-transition tests), @@ -3431,6 +3441,9 @@ export function ChatPane({ chatId }: { chatId: string }) { ) } onDraftChange={onDraftContextChange} + contextHistoryPressure={ + draft ? undefined : contextHistory.chatId === chatId ? contextHistory.pressure : null + } modelPicker={ Date: Tue, 20 Oct 2026 20:19:14 +0000 Subject: [PATCH 2/4] ci: assign the expired-cache return test to a unit lane Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012N8F5U34gqvWS8cC5ruNSr --- scripts/ci-test-registry.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ci-test-registry.json b/scripts/ci-test-registry.json index 56877486a..43a8cc816 100644 --- a/scripts/ci-test-registry.json +++ b/scripts/ci-test-registry.json @@ -810,6 +810,7 @@ "renderer/lib/composer-type-focus.test.ts", "renderer/lib/composer-submission.test.ts", "renderer/lib/context-pressure-feed.test.ts", + "renderer/lib/expired-cache-return.test.ts", "renderer/lib/design-ipc.test.ts", "renderer/lib/device-ax.test.ts", "renderer/lib/device-clipboard.test.ts", From 4387fb9cdf1bfeb33607e702a37be38460bc7f52 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:28:00 +0000 Subject: [PATCH 3/4] fix(compaction): keep a draft-free reading and settle only expired windows Review follow-ups: the context feed now keeps its own draft-free history reading on every non-typing refresh, so a saved draft no longer blocks the expired-cache check or leaves it stale. An automatic compaction settles its window only once it has run, a compaction while the cache is warm settles nothing, and an IPC rejection no longer escapes unhandled. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012N8F5U34gqvWS8cC5ruNSr --- renderer/components/composer.tsx | 7 ++- renderer/lib/context-pressure-feed.test.ts | 53 ++++++++++++++-------- renderer/lib/context-pressure-feed.ts | 51 +++++++++++++++++---- renderer/lib/expired-cache-return.test.ts | 24 ++++++++-- renderer/lib/expired-cache-return.ts | 26 ++++++----- renderer/lib/use-expired-cache-return.ts | 6 +-- renderer/main/chat-pane.tsx | 8 ++-- 7 files changed, 122 insertions(+), 53 deletions(-) diff --git a/renderer/components/composer.tsx b/renderer/components/composer.tsx index aeddd929b..4ae788498 100644 --- a/renderer/components/composer.tsx +++ b/renderer/components/composer.tsx @@ -965,7 +965,12 @@ export function Composer({ !configurationBusy && !permissionSaving && !gitOperationBusy, - compact: (engine, automatic) => void compactChat(engine, automatic ? { cacheExpired: true } : undefined), + compact: (engine, automatic) => { + // The IPC failing outright leaves nothing to report for a compaction nobody asked for. + void compactChat(engine, automatic ? { cacheExpired: true } : undefined).catch(() => { + if (!automatic) toast.error("Compaction failed."); + }); + }, }); settleExpiredCacheRef.current = expiredCacheReturn.settle; const typedRef = React.useRef(false); diff --git a/renderer/lib/context-pressure-feed.test.ts b/renderer/lib/context-pressure-feed.test.ts index 48375a240..9f6a6b3ae 100644 --- a/renderer/lib/context-pressure-feed.test.ts +++ b/renderer/lib/context-pressure-feed.test.ts @@ -15,9 +15,9 @@ function deferred() { return { promise, resolve }; } -function harness() { +function harness(options: { history?: boolean } = {}) { const published: (number | null)[] = []; - const owners: { chatId: string | null; draft?: string }[] = []; + const histories: { chatId: string | null; tokens: number | null }[] = []; const requests: { chatId: string; draft?: string; @@ -31,10 +31,13 @@ function harness() { requests.push({ chatId, draft, reply }); return reply.promise; }, - publish: (pressure, chatId, draft) => { - published.push(pressure?.contextTokens ?? null); - owners.push(draft === undefined ? { chatId } : { chatId, draft }); - }, + publish: (pressure) => published.push(pressure?.contextTokens ?? null), + ...(options.history + ? { + publishHistory: (pressure: ChatContextPressureV1 | null, chatId: string | null) => + histories.push({ chatId, tokens: pressure?.contextTokens ?? null }), + } + : {}), schedule: (run) => { const id = nextTimer++; timers.set(id, run); @@ -47,7 +50,7 @@ function harness() { timers.clear(); for (const run of pending) run(); }; - return { feed, published, owners, requests, timers, fireTimers }; + return { feed, published, histories, requests, timers, fireTimers }; } const settle = () => new Promise((resolve) => setImmediate(resolve)); @@ -72,24 +75,36 @@ test("switching chats clears the old reading and drops its in-flight response", assert.deepEqual(h.published, [null, null, 100]); }); -test("each reading names its chat and the draft it priced", async () => { - const h = harness(); +test("the draft-free history follows every trigger except typing, even with a saved draft", async () => { + const h = harness({ history: true }); h.feed.showChat("a"); + // The reopened chat restores its draft before the first ambient read. + h.feed.draftChanged("saved draft"); void h.feed.refresh(); - h.requests[0].reply.resolve(reading(100)); + assert.deepEqual( + h.requests.map((request) => request.draft), + ["saved draft", undefined], + ); + h.requests[0].reply.resolve(reading(150)); + h.requests[1].reply.resolve(reading(120)); await settle(); - h.feed.draftChanged("pasted notes"); + assert.deepEqual(h.published, [null, 150]); + assert.deepEqual(h.histories, [{ chatId: "a", tokens: null }, { chatId: "a", tokens: 120 }]); + + // Typing re-prices only the meter. + h.fireTimers(); + assert.equal(h.requests.length, 3); + h.requests[2].reply.resolve(reading(160)); + await settle(); + assert.deepEqual(h.histories[h.histories.length - 1], { chatId: "a", tokens: 120 }); + + // A cleared draft's reading is the history, and a chat switch clears it. + h.feed.draftChanged(undefined); h.fireTimers(); - h.requests[1].reply.resolve(reading(180)); + h.requests[3].reply.resolve(reading(121)); await settle(); h.feed.showChat("b"); - assert.deepEqual(h.published, [null, 100, 180, null]); - assert.deepEqual(h.owners, [ - { chatId: "a" }, - { chatId: "a" }, - { chatId: "a", draft: "pasted notes" }, - { chatId: "b" }, - ]); + assert.deepEqual(h.histories.slice(-2), [{ chatId: "a", tokens: 121 }, { chatId: "b", tokens: null }]); }); test("switching chats cancels a pending draft refresh from the previous chat", async () => { diff --git a/renderer/lib/context-pressure-feed.ts b/renderer/lib/context-pressure-feed.ts index d930e8ca1..70bcafbba 100644 --- a/renderer/lib/context-pressure-feed.ts +++ b/renderer/lib/context-pressure-feed.ts @@ -6,11 +6,13 @@ export interface ContextPressureFeedOptions { chatId: string, draft: Draft | undefined, ): Promise; + /** Render a reading; null means the quiet "unknown" meter. */ + publish(pressure: ChatContextPressureV1 | null): void; /** - * Render a reading for the shown chat; null means the quiet "unknown" meter. - * `draft` is the composer draft the reading prices, if any. + * The shown chat's reading without the composer draft, refreshed on every + * trigger except draft typing. Null while it is unknown. */ - publish(pressure: ChatContextPressureV1 | null, chatId: string | null, draft: Draft | undefined): void; + publishHistory?(pressure: ChatContextPressureV1 | null, chatId: string | null): void; draftDelayMs?: number; schedule?(run: () => void, delayMs: number): unknown; cancel?(handle: unknown): void; @@ -31,6 +33,7 @@ export interface ContextPressureFeedOptions { export class ContextPressureFeed { private chatId: string | null = null; private request = 0; + private historyRequest = 0; private live = false; private draftTimer: unknown = null; private draft: Draft | undefined; @@ -57,7 +60,7 @@ export class ContextPressureFeed { // changes, so it stays the baseline across chats in the same workspace. this.draft = undefined; this.invalidate(); - this.options.publish(null, this.chatId, undefined); + this.publishBoth(null); } /** @@ -72,7 +75,7 @@ export class ContextPressureFeed { this.scope = scope; if (previous === undefined || previous === scope) return; this.invalidate(); - this.options.publish(null, this.chatId, undefined); + this.publishBoth(null); } /** While live, only pushed readings reach the meter. */ @@ -81,22 +84,49 @@ export class ContextPressureFeed { if (live) this.invalidate(); } - /** Ambient refresh priced with the composer's current draft. */ + /** Ambient refresh priced with the composer's current draft, plus the draft-free history. */ async refresh(): Promise { + await this.read(true); + } + + private async read(withHistory: boolean): Promise { const chatId = this.chatId; if (chatId === null || this.live) return; const draft = this.draft; const request = ++this.request; + const reading = this.options.fetch(chatId, draft); + const history = withHistory && draft !== undefined && this.options.publishHistory ? this.readHistory(chatId) : undefined; let pressure: ChatContextPressureV1 | null; try { - pressure = await this.options.fetch(chatId, draft); + pressure = await reading; } catch { // Best-effort: keep the last good reading. + await history; return; } if (request === this.request && !this.live && chatId === this.chatId) { - this.options.publish(pressure, chatId, draft); + this.options.publish(pressure); + if (draft === undefined) this.options.publishHistory?.(pressure, chatId); + } + await history; + } + + private async readHistory(chatId: string): Promise { + const request = ++this.historyRequest; + let pressure: ChatContextPressureV1 | null; + try { + pressure = await this.options.fetch(chatId, undefined); + } catch { + return; } + if (request === this.historyRequest && !this.live && chatId === this.chatId) { + this.options.publishHistory?.(pressure, chatId); + } + } + + private publishBoth(pressure: ChatContextPressureV1 | null): void { + this.options.publish(pressure); + this.options.publishHistory?.(pressure, this.chatId); } /** Debounced refresh for composer typing and attachment changes. */ @@ -105,7 +135,7 @@ export class ContextPressureFeed { this.cancelDraft(); this.draftTimer = this.schedule(() => { this.draftTimer = null; - void this.refresh(); + void this.read(false); }, this.draftDelayMs); } @@ -117,7 +147,7 @@ export class ContextPressureFeed { */ pushed(chatId: string, pressure: ChatContextPressureV1 | null): void { if (chatId !== this.chatId) return; - if (this.live) this.options.publish(pressure, chatId, undefined); + if (this.live) this.publishBoth(pressure); else void this.refresh(); } @@ -127,6 +157,7 @@ export class ContextPressureFeed { private invalidate(): void { this.request += 1; + this.historyRequest += 1; this.cancelDraft(); } diff --git a/renderer/lib/expired-cache-return.test.ts b/renderer/lib/expired-cache-return.test.ts index bf2a990a0..ab11eb36a 100644 --- a/renderer/lib/expired-cache-return.test.ts +++ b/renderer/lib/expired-cache-return.test.ts @@ -48,8 +48,10 @@ test("returning to a large chat after its cache expired compacts with pi-vcc exa const returns = tracker(); returns.returned("chat", AFTER_EXPIRY); assert.equal(returns.takeAutomatic("chat", vcc(pressure())), true); - // Later renders, focus changes and keystrokes in the same window do nothing. + // Later renders in the same return do nothing while the compaction runs. assert.equal(returns.takeAutomatic("chat", vcc(pressure())), false); + returns.settle("chat", pressure(), AFTER_EXPIRY + 2_000); + // Later focus changes and keystrokes in the same window do nothing either. returns.returned("chat", AFTER_EXPIRY + 1); assert.equal(returns.takeAutomatic("chat", vcc(pressure({ contextTokens: 120_000 }), AFTER_EXPIRY + 1)), false); // A new request opens a new window, which expires and compacts again on return. @@ -58,6 +60,21 @@ test("returning to a large chat after its cache expired compacts with pi-vcc exa assert.equal(returns.takeAutomatic("chat", vcc(later, LAST_REQUEST + 30 * MINUTE)), true); }); +test("a compaction that never ran is retried on the next return, and one while warm settles nothing", () => { + const returns = tracker(); + returns.returned("chat", AFTER_EXPIRY); + assert.equal(returns.takeAutomatic("chat", vcc(pressure())), true); + // The compaction could not start (another session command held it): no settle. + returns.returned("chat", AFTER_EXPIRY + MINUTE); + assert.equal(returns.takeAutomatic("chat", vcc(pressure(), AFTER_EXPIRY + MINUTE)), true); + + // A manual /compact before expiry leaves the coming expiry to its own return. + const warm = tracker(); + warm.settle("chat", pressure(), LAST_REQUEST + MINUTE); + warm.returned("chat", AFTER_EXPIRY); + assert.deepEqual(warm.offer("chat", llm(pressure())), { contextTokens: 140_000 }); +}); + test("expiry while the user stays never compacts or offers; only a later return does", () => { const returns = tracker(); // Opened while the cache was still warm, then sat idle past expiry. @@ -105,7 +122,7 @@ test("LLM compaction is only offered, and the offer ends when the user answers i assert.equal(returns.takeAutomatic("chat", llm(pressure())), false); assert.deepEqual(returns.offer("chat", llm(pressure())), { contextTokens: 140_000 }); assert.equal(returns.offer("chat", llm(pressure(), AFTER_EXPIRY, false)), undefined); - returns.settle("chat", pressure()); + returns.settle("chat", pressure(), AFTER_EXPIRY); assert.equal(returns.offer("chat", llm(pressure())), undefined); // Switching to pi-vcc afterwards does not compact the window the user answered. returns.returned("chat", AFTER_EXPIRY + 1); @@ -116,9 +133,10 @@ test("an answered window stays answered across a restart", () => { const saved = { value: {} as Record }; const before = tracker(saved); before.returned("dismissed", AFTER_EXPIRY); - before.settle("dismissed", pressure()); + before.settle("dismissed", pressure(), AFTER_EXPIRY); before.returned("compacted", AFTER_EXPIRY); assert.equal(before.takeAutomatic("compacted", vcc(pressure())), true); + before.settle("compacted", pressure(), AFTER_EXPIRY + 2_000); const after = tracker(saved); after.returned("dismissed", AFTER_EXPIRY + MINUTE); diff --git a/renderer/lib/expired-cache-return.ts b/renderer/lib/expired-cache-return.ts index 213e96354..3b7c433d4 100644 --- a/renderer/lib/expired-cache-return.ts +++ b/renderer/lib/expired-cache-return.ts @@ -75,9 +75,9 @@ const localHandledStore: HandledStore = { * type, and it counts only once the cache has lapsed, so expiry while the user * sits idle never compacts. pi-vcc compacts locally for free, so a return * compacts on its own. LLM compaction spends model tokens, so a return only - * offers it. Either happens at most once per cache window: only a new request - * that uses the cache, or a compaction that changes the reading, brings the - * offer or the compaction back. + * offers it. Either happens at most once per cache window, keyed by the + * request that last used the cache: compaction keeps that key, so only a new + * request brings the offer or the compaction back. */ export class ExpiredCacheReturns { private readonly returnedAt = new Map(); @@ -91,8 +91,10 @@ export class ExpiredCacheReturns { } /** - * Whether to start a pi-vcc compaction now. Claims the window when it says - * yes, so callers start exactly one compaction for it. + * Whether to start a pi-vcc compaction now. Uses up the return when it says + * yes, so callers start one compaction per return; the compaction itself + * settles the window once it has run, so one that never started is retried + * on the next return. */ takeAutomatic(chatId: string, input: ExpiredCacheInput): boolean { if (input.engine !== "vcc" || !input.idle) return false; @@ -101,12 +103,11 @@ export class ExpiredCacheReturns { const window = expiredWindow(input.pressure, returnedAt); if (window === undefined || this.handledMap().get(chatId) === window) return false; this.returnedAt.delete(chatId); - this.markHandled(chatId, window); return true; } /** The "Compact first?" offer for LLM compaction, if one stands. */ - offer(chatId: string, input: ExpiredCacheInput): { contextTokens: number } | undefined { + offer(chatId: string, input: Omit): { contextTokens: number } | undefined { if (input.engine !== "llm" || !input.idle || !input.pressure) return undefined; const returnedAt = this.returnedAt.get(chatId); if (returnedAt === undefined) return undefined; @@ -116,12 +117,15 @@ export class ExpiredCacheReturns { } /** - * The window is answered: the user accepted or dismissed the offer, or any - * compaction ran in it. Its reading may not refresh before the next render. + * The expired window is answered: the user accepted or dismissed the offer, + * or a compaction ran after the cache lapsed. A compaction while the cache + * is still warm leaves the coming expiry to its own return. */ - settle(chatId: string, pressure: ChatContextPressureV1 | null | undefined): void { + settle(chatId: string, pressure: ChatContextPressureV1 | null | undefined, now: number): void { + const expiresAt = promptCacheExpiresAt(pressure); + if (!pressure?.promptCache || expiresAt === undefined || now < expiresAt) return; this.returnedAt.delete(chatId); - if (pressure?.promptCache) this.markHandled(chatId, pressure.promptCache.lastRequestAt); + this.markHandled(chatId, pressure.promptCache.lastRequestAt); } private handledMap(): Map { diff --git a/renderer/lib/use-expired-cache-return.ts b/renderer/lib/use-expired-cache-return.ts index 601ce9d94..6c29113a1 100644 --- a/renderer/lib/use-expired-cache-return.ts +++ b/renderer/lib/use-expired-cache-return.ts @@ -48,11 +48,9 @@ export function useExpiredCacheReturn({ chatId, pressure, engine, idle, compact } }, [chatId, engine, idle, pressure, returns]); - const offer = engine - ? expiredCacheReturns.offer(chatId, { pressure, engine, idle, now: Date.now() }) - : undefined; + const offer = engine ? expiredCacheReturns.offer(chatId, { pressure, engine, idle }) : undefined; const settle = React.useCallback(() => { - expiredCacheReturns.settle(chatId, pressure); + expiredCacheReturns.settle(chatId, pressure, Date.now()); setReturns((value) => value + 1); }, [chatId, pressure]); return { diff --git a/renderer/main/chat-pane.tsx b/renderer/main/chat-pane.tsx index d1662cebe..460160e67 100644 --- a/renderer/main/chat-pane.tsx +++ b/renderer/main/chat-pane.tsx @@ -429,7 +429,7 @@ export function ChatPane({ chatId }: { chatId: string }) { () => true, ); const [contextPressure, setContextPressure] = React.useState(null); - // The latest reading without a draft, and its chat, for compacting on + // The latest reading without the draft, and its chat, for compacting on // return to an expired cache: a pasted draft is not history compaction can // shrink, and a chat switch must never judge the new chat by the old one. const [contextHistory, setContextHistory] = React.useState<{ @@ -449,10 +449,8 @@ export function ChatPane({ chatId }: { chatId: string }) { () => new ContextPressureFeed({ fetch: (targetChatId, visibleDraft) => contextFetchRef.current(targetChatId, visibleDraft), - publish: (pressure, owner, visibleDraft) => { - setContextPressure(pressure); - if (!visibleDraft || pressure === null) setContextHistory({ chatId: owner, pressure }); - }, + publish: setContextPressure, + publishHistory: (pressure, owner) => setContextHistory({ chatId: owner, pressure }), }), ); // ChatPane stays mounted across chat navigation (see chat-transition tests), From d5b58707581e51feb811fb298203759d2e2ea84d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:38:01 +0000 Subject: [PATCH 4/4] fix(context): fence every history publication with one freshness ticket An older independent draft-free read could overwrite a newer no-draft reading (for example after clearing the draft and switching models), reviving the previous model's cache window. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012N8F5U34gqvWS8cC5ruNSr --- renderer/lib/context-pressure-feed.test.ts | 21 +++++++++++++++++++++ renderer/lib/context-pressure-feed.ts | 5 ++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/renderer/lib/context-pressure-feed.test.ts b/renderer/lib/context-pressure-feed.test.ts index 9f6a6b3ae..d94558673 100644 --- a/renderer/lib/context-pressure-feed.test.ts +++ b/renderer/lib/context-pressure-feed.test.ts @@ -107,6 +107,27 @@ test("the draft-free history follows every trigger except typing, even with a sa assert.deepEqual(h.histories.slice(-2), [{ chatId: "a", tokens: 121 }, { chatId: "b", tokens: null }]); }); +test("an older draft-free history response never overwrites a newer one", async () => { + const h = harness({ history: true }); + h.feed.showChat("a"); + h.feed.draftChanged("saved draft"); + void h.feed.refresh(); + // The user clears the draft and switches models before the history read returns. + h.feed.draftChanged(undefined); + void h.feed.refresh(); + assert.deepEqual( + h.requests.map((request) => request.draft), + ["saved draft", undefined, undefined], + ); + h.requests[2].reply.resolve(reading(90)); + await settle(); + h.requests[1].reply.resolve(reading(140)); + h.requests[0].reply.resolve(reading(150)); + await settle(); + assert.deepEqual(h.histories, [{ chatId: "a", tokens: null }, { chatId: "a", tokens: 90 }]); + assert.deepEqual(h.published, [null, 90]); +}); + test("switching chats cancels a pending draft refresh from the previous chat", async () => { const h = harness(); h.feed.showChat("a"); diff --git a/renderer/lib/context-pressure-feed.ts b/renderer/lib/context-pressure-feed.ts index 70bcafbba..533889610 100644 --- a/renderer/lib/context-pressure-feed.ts +++ b/renderer/lib/context-pressure-feed.ts @@ -94,6 +94,8 @@ export class ContextPressureFeed { if (chatId === null || this.live) return; const draft = this.draft; const request = ++this.request; + // Every request that can publish history takes a ticket, so an older one never overwrites a newer reading. + const historyTicket = draft === undefined ? ++this.historyRequest : undefined; const reading = this.options.fetch(chatId, draft); const history = withHistory && draft !== undefined && this.options.publishHistory ? this.readHistory(chatId) : undefined; let pressure: ChatContextPressureV1 | null; @@ -106,7 +108,7 @@ export class ContextPressureFeed { } if (request === this.request && !this.live && chatId === this.chatId) { this.options.publish(pressure); - if (draft === undefined) this.options.publishHistory?.(pressure, chatId); + if (historyTicket === this.historyRequest) this.options.publishHistory?.(pressure, chatId); } await history; } @@ -125,6 +127,7 @@ export class ContextPressureFeed { } private publishBoth(pressure: ChatContextPressureV1 | null): void { + this.historyRequest += 1; this.options.publish(pressure); this.options.publishHistory?.(pressure, this.chatId); }