From c727d1219f4ef10ad2a41f0e67df80ca51924673 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 10 Oct 2026 18:07:37 -0700 Subject: [PATCH] fix(web): Compact chip follows Claude's real prompt cache TTL (#17945) Co-authored-by: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 25 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 20 +++++++++++++++ apps/web/src/components/ChatView.tsx | 24 +++++++++--------- .../chat/ContextWindowMeter.logic.test.ts | 13 ++++++++++ .../chat/ContextWindowMeter.logic.ts | 8 ++++-- apps/web/src/lib/contextWindow.test.ts | 21 +++++++++++++++- apps/web/src/lib/contextWindow.ts | 17 +++++++++++++ docs/user/providers-claude.md | 5 ++-- packages/contracts/src/orchestrationV2.ts | 3 +++ 9 files changed, 119 insertions(+), 17 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 9ecb9c63d7349c6d79b096cae53f581613b6c4a6..d80e76072388e5bf2659f8106bbbef728b8e68f5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -900,6 +900,31 @@ describe("ClaudeAdapterV2 context usage", () => { updatedAt: "2026-08-29T00:00:00.000Z", }); }); + + it("reports the prompt cache TTL the response wrote", () => { + const ttlFor = (cache_creation: { + ephemeral_1h_input_tokens: number; + ephemeral_5m_input_tokens: number; + }) => + ClaudeAdapterV2.claudeProviderTurnTokenUsage( + { input_tokens: 1, output_tokens: 1, cache_creation }, + CLAUDE_TEST_MODEL_SELECTION, + "2026-08-29T00:00:00.000Z", + ).promptCacheTtlMs; + + assert.strictEqual( + ttlFor({ ephemeral_1h_input_tokens: 2_000, ephemeral_5m_input_tokens: 300 }), + 60 * 60_000, + ); + assert.strictEqual( + ttlFor({ ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 300 }), + 5 * 60_000, + ); + assert.strictEqual( + ttlFor({ ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0 }), + undefined, + ); + }); }); describe("ClaudeAdapterV2 session permissions", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 3f8ecc1f004bd768903fa42e2a29d0e4d809ec68..da344c202ff0f3971e0df0a6a02b1bcf3a0b1c98 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -156,11 +156,29 @@ function claudeContextWindow(modelSelection: ModelSelection): number | null { ); } +interface ClaudeCacheCreation { + readonly ephemeral_1h_input_tokens: number; + readonly ephemeral_5m_input_tokens: number; +} + +// Claude Code writes 1h cache entries on a subscription within its limits and +// 5m entries otherwise (API key, Bedrock, Vertex, extra usage). A response +// that wrote nothing leaves the TTL unknown. +function claudePromptCacheTtlMs( + cacheCreation: ClaudeCacheCreation | null | undefined, +): number | undefined { + if (!cacheCreation) return undefined; + if (cacheCreation.ephemeral_1h_input_tokens > 0) return 60 * 60_000; + if (cacheCreation.ephemeral_5m_input_tokens > 0) return 5 * 60_000; + return undefined; +} + export function claudeProviderTurnTokenUsage( usage: { readonly input_tokens: number; readonly cache_creation_input_tokens?: number | null; readonly cache_read_input_tokens?: number | null; + readonly cache_creation?: ClaudeCacheCreation | null; readonly output_tokens: number; }, modelSelection: ModelSelection, @@ -171,6 +189,7 @@ export function claudeProviderTurnTokenUsage( (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0); const outputTokens = usage.output_tokens; + const promptCacheTtlMs = claudePromptCacheTtlMs(usage.cache_creation); return { usedTokens: inputTokens + outputTokens, maxTokens: claudeContextWindow(modelSelection), @@ -178,6 +197,7 @@ export function claudeProviderTurnTokenUsage( cachedInputTokens: usage.cache_read_input_tokens ?? 0, outputTokens, reasoningOutputTokens: 0, + ...(promptCacheTtlMs === undefined ? {} : { promptCacheTtlMs }), updatedAt, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 288769e79e5562f28a6b5d28f30bd55f21b2170c..87d1303e05e21be4e45e49ea9b779d4a1a54ffa7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -498,7 +498,10 @@ import { hasDismissedResumeCompaction, shouldOfferResumeCompaction, } from "./chat/ContextWindowMeter.logic"; -import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow"; +import { + deriveLatestContextWindowSnapshot, + latestProviderTurnTokenUsage, +} from "../lib/contextWindow"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, DRAFT_HERO_TRANSITION_EASING, @@ -1719,17 +1722,12 @@ export default function ChatView(props: ChatViewProps) { status: threadStatus, }); const threadDetailLoading = threadSyncPhase === "loading"; - // Latest provider-reported context usage (#8144): the newest turn that has - // a report wins; stale turns keep the meter alive between turns. - const activeThreadLiveTokenUsage = useMemo(() => { - const turns = serverProjection?.providerTurns; - if (!turns || turns.length === 0) return null; - for (let index = turns.length - 1; index >= 0; index -= 1) { - const usage = turns[index]?.tokenUsage; - if (usage !== undefined) return usage; - } - return null; - }, [serverProjection?.providerTurns]); + // Latest provider-reported context usage (#8144): the newest report wins; + // stale turns keep the meter alive between turns. + const activeThreadLiveTokenUsage = useMemo( + () => latestProviderTurnTokenUsage(serverProjection?.providerTurns ?? []), + [serverProjection?.providerTurns], + ); const serverVisibleTurnItems = useThreadVisibleTurnItems(routeThreadDetailRef); const serverThreadHistory = useThreadHistory(routeThreadDetailRef); const threadHistoryControls = useMemo(() => { @@ -7753,6 +7751,8 @@ export default function ChatView(props: ChatViewProps) { provider: selectedProvider, usedTokens: activeContextWindow.usedTokens, updatedAt: activeContextWindow.updatedAt, + // Only the live report carries a TTL, and only when it is the window's source. + promptCacheTtlMs: activeThreadLiveTokenUsage?.promptCacheTtlMs, now: `${nowMinute}:00.000Z`, }) ? activeContextWindow.usedTokens diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts index d97e9393486a7d3e5e06783124147a00b493688a..48b071ea4fbdcb3851bb2314c9acad134914848e 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts @@ -173,6 +173,19 @@ describe("shouldOfferResumeCompaction", () => { ).toBe(false); }); + it("uses the reported prompt cache TTL instead of the fallback", () => { + const offer = (updatedAt: string) => + shouldOfferResumeCompaction({ + provider: "claudeAgent", + usedTokens: 200_000, + updatedAt, + promptCacheTtlMs: 5 * 60_000, + now, + }); + expect(offer("2026-08-24T11:55:00.000Z")).toBe(true); + expect(offer("2026-08-24T11:56:00.000Z")).toBe(false); + }); + it("does not show Claude's resume prompt for another provider", () => { expect( shouldOfferResumeCompaction({ diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index 390caab18fbf67b863142d8b06bdaba07b7ec001..78ee72c9b8708825b217f406b13494f1a7c8170f 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -9,7 +9,8 @@ import { } from "../../providerInstances"; import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; -const CLAUDE_RESUME_COMPACTION_MINUTES = 70; +// Used when the provider did not report its cache TTL: Claude's 1h cache plus slack. +const CLAUDE_RESUME_COMPACTION_FALLBACK_MS = 70 * 60_000; const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; export function providerSupportsManualCompaction( @@ -60,10 +61,13 @@ export function hasDismissedResumeCompaction( }); } +/** True once a large Claude context's prompt cache has expired, so the next + turn would re-read all of it uncached. */ export function shouldOfferResumeCompaction(input: { readonly provider: string | null | undefined; readonly usedTokens: number | null | undefined; readonly updatedAt: string | null | undefined; + readonly promptCacheTtlMs?: number | undefined; readonly now: string; }): boolean { if ( @@ -78,7 +82,7 @@ export function shouldOfferResumeCompaction(input: { return ( Number.isFinite(updatedAt) && Number.isFinite(now) && - now - updatedAt >= CLAUDE_RESUME_COMPACTION_MINUTES * 60_000 + now - updatedAt >= (input.promptCacheTtlMs ?? CLAUDE_RESUME_COMPACTION_FALLBACK_MS) ); } diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts index 4894ce2d3a348a8c1312b1342fbfbdc0b6cc9293..24adda08ae1736b54c424d62f4e6f60b4db10d87 100644 --- a/apps/web/src/lib/contextWindow.test.ts +++ b/apps/web/src/lib/contextWindow.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; import * as DateTime from "effect/DateTime"; -import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow"; +import { + deriveLatestContextWindowSnapshot, + formatContextWindowTokens, + latestProviderTurnTokenUsage, +} from "./contextWindow"; describe("V2 context window presentation", () => { it("uses retained compaction token data when available", () => { @@ -81,3 +85,18 @@ describe("live provider-turn usage (#8144)", () => { expect(snapshot?.usedPercentage).toBeNull(); }); }); + +describe("latestProviderTurnTokenUsage", () => { + it("picks the newest report, not the last turn in snapshot order", () => { + const usage = (usedTokens: number, updatedAt: string) => ({ + tokenUsage: { usedTokens, updatedAt }, + }); + // Snapshots sort by provider thread id, so an older thread can come last. + const latest = latestProviderTurnTokenUsage([ + usage(300_000, "2026-06-20T12:00:00.000Z"), + {}, + usage(90_000, "2026-06-20T09:00:00.000Z"), + ]); + expect(latest?.usedTokens).toBe(300_000); + }); +}); diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 9045fe03ebcbbff7cf4e0abd9b351eb761633035..d06141b0e84ee558cd4f01c3535ab29aa1eab2b3 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -1,4 +1,5 @@ import type { + OrchestrationV2ProviderTurn, OrchestrationV2ProviderTurnTokenUsage, OrchestrationV2ProviderThread, OrchestrationV2TurnItem, @@ -23,6 +24,22 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & { readonly updatedAt: string; }; +/** Newest usage report across a thread's provider turns. Snapshots order turns + by provider thread, not time, so pick by timestamp. */ +export function latestProviderTurnTokenUsage( + turns: ReadonlyArray>, +): OrchestrationV2ProviderTurnTokenUsage | null { + let latest: OrchestrationV2ProviderTurnTokenUsage | null = null; + for (const { tokenUsage } of turns) { + if (tokenUsage === undefined) continue; + // Ties go to the later entry, since live upserts append. + if (latest === null || Date.parse(tokenUsage.updatedAt) >= Date.parse(latest.updatedAt)) { + latest = tokenUsage; + } + } + return latest; +} + /** Prefers the provider's live usage report (#8144); falls back to the last compaction item. */ export function deriveLatestContextWindowSnapshot( entries: ReadonlyArray<{ diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index b116143471df0e71ff43c9bfc2b6f66514b1acaf..514673beb268f8565feadb43061c16e83f5dc15f 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -50,10 +50,11 @@ window. Leave it empty for Claude Code's default. You can also send `/compact` in an existing conversation. Web and desktop offer **Compact context** from the context meter. When you return to a large thread -after more than an hour, a **Compact** chip with the thread's token count shows +after Claude's prompt cache expires, a **Compact** chip with the thread's token count shows next to the send button. While it is on, Enter summarizes the history first, then sends your message. Click the chip to switch it to **Full** and keep the full -history for that message. See [commands and skills](./composer.md#commands-and-skills) for using +history for that message. The cache lasts about an hour on a Claude +subscription and 5 minutes with an API key, Bedrock, or Vertex. See [commands and skills](./composer.md#commands-and-skills) for using composer commands. ## Usage limits diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 865077ef9d2bfb9b31bc7e8a999563bc68fe9c9f..842f0576f29020dd89167c90bfb411975dd98359 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -979,6 +979,9 @@ export const OrchestrationV2ProviderTurnTokenUsage = Schema.Struct({ cachedInputTokens: Schema.optional(NonNegativeInt), outputTokens: Schema.optional(NonNegativeInt), reasoningOutputTokens: Schema.optional(NonNegativeInt), + /** Prompt cache lifetime the provider wrote with this report. Clients treat + the cache as cold once `updatedAt` is this old. */ + promptCacheTtlMs: Schema.optional(PositiveInt), /** ISO timestamp of the provider's report; string so wire encoding is stable. */ updatedAt: Schema.String, }); -- 2.54.0