authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-10 18:07:37-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 18:07:37-07:00
logc727d1219f4ef10ad2a41f0e67df80ca51924673
treef154615e14680d00941cbb065a3203351cd504a3
parent1cfd1c1a4489b96defb1cea79fc8561cf5dc2a24
signature Signed by PGP key B5690EEEBB952194

fix(web): Compact chip follows Claude's real prompt cache TTL (#17945)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

9 files changed, 119 insertions(+), 17 deletions(-)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+25
......@@ -900,6 +900,31 @@ describe("ClaudeAdapterV2 context usage", () => {
900900 updatedAt: "2026-08-29T00:00:00.000Z",
901901 });
902902 });
903
904 it("reports the prompt cache TTL the response wrote", () => {
905 const ttlFor = (cache_creation: {
906 ephemeral_1h_input_tokens: number;
907 ephemeral_5m_input_tokens: number;
908 }) =>
909 ClaudeAdapterV2.claudeProviderTurnTokenUsage(
910 { input_tokens: 1, output_tokens: 1, cache_creation },
911 CLAUDE_TEST_MODEL_SELECTION,
912 "2026-08-29T00:00:00.000Z",
913 ).promptCacheTtlMs;
914
915 assert.strictEqual(
916 ttlFor({ ephemeral_1h_input_tokens: 2_000, ephemeral_5m_input_tokens: 300 }),
917 60 * 60_000,
918 );
919 assert.strictEqual(
920 ttlFor({ ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 300 }),
921 5 * 60_000,
922 );
923 assert.strictEqual(
924 ttlFor({ ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0 }),
925 undefined,
926 );
927 });
903928});
904929
905930describe("ClaudeAdapterV2 session permissions", () => {
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+20
......@@ -156,11 +156,29 @@ function claudeContextWindow(modelSelection: ModelSelection): number | null {
156156 );
157157}
158158
159interface ClaudeCacheCreation {
160 readonly ephemeral_1h_input_tokens: number;
161 readonly ephemeral_5m_input_tokens: number;
162}
163
164// Claude Code writes 1h cache entries on a subscription within its limits and
165// 5m entries otherwise (API key, Bedrock, Vertex, extra usage). A response
166// that wrote nothing leaves the TTL unknown.
167function claudePromptCacheTtlMs(
168 cacheCreation: ClaudeCacheCreation | null | undefined,
169): number | undefined {
170 if (!cacheCreation) return undefined;
171 if (cacheCreation.ephemeral_1h_input_tokens > 0) return 60 * 60_000;
172 if (cacheCreation.ephemeral_5m_input_tokens > 0) return 5 * 60_000;
173 return undefined;
174}
175
159176export function claudeProviderTurnTokenUsage(
160177 usage: {
161178 readonly input_tokens: number;
162179 readonly cache_creation_input_tokens?: number | null;
163180 readonly cache_read_input_tokens?: number | null;
181 readonly cache_creation?: ClaudeCacheCreation | null;
164182 readonly output_tokens: number;
165183 },
166184 modelSelection: ModelSelection,
......@@ -171,6 +189,7 @@ export function claudeProviderTurnTokenUsage(
171189 (usage.cache_creation_input_tokens ?? 0) +
172190 (usage.cache_read_input_tokens ?? 0);
173191 const outputTokens = usage.output_tokens;
192 const promptCacheTtlMs = claudePromptCacheTtlMs(usage.cache_creation);
174193 return {
175194 usedTokens: inputTokens + outputTokens,
176195 maxTokens: claudeContextWindow(modelSelection),
......@@ -178,6 +197,7 @@ export function claudeProviderTurnTokenUsage(
178197 cachedInputTokens: usage.cache_read_input_tokens ?? 0,
179198 outputTokens,
180199 reasoningOutputTokens: 0,
200 ...(promptCacheTtlMs === undefined ? {} : { promptCacheTtlMs }),
181201 updatedAt,
182202 };
183203}
apps/web/src/components/ChatView.tsx+12-12
......@@ -498,7 +498,10 @@ import {
498498 hasDismissedResumeCompaction,
499499 shouldOfferResumeCompaction,
500500} from "./chat/ContextWindowMeter.logic";
501import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow";
501import {
502 deriveLatestContextWindowSnapshot,
503 latestProviderTurnTokenUsage,
504} from "../lib/contextWindow";
502505import {
503506 DRAFT_HERO_TRANSITION_ANIMATION_ID,
504507 DRAFT_HERO_TRANSITION_EASING,
......@@ -1719,17 +1722,12 @@ export default function ChatView(props: ChatViewProps) {
17191722 status: threadStatus,
17201723 });
17211724 const threadDetailLoading = threadSyncPhase === "loading";
1722 // Latest provider-reported context usage (#8144): the newest turn that has
1723 // a report wins; stale turns keep the meter alive between turns.
1724 const activeThreadLiveTokenUsage = useMemo(() => {
1725 const turns = serverProjection?.providerTurns;
1726 if (!turns || turns.length === 0) return null;
1727 for (let index = turns.length - 1; index >= 0; index -= 1) {
1728 const usage = turns[index]?.tokenUsage;
1729 if (usage !== undefined) return usage;
1730 }
1731 return null;
1732 }, [serverProjection?.providerTurns]);
1725 // Latest provider-reported context usage (#8144): the newest report wins;
1726 // stale turns keep the meter alive between turns.
1727 const activeThreadLiveTokenUsage = useMemo(
1728 () => latestProviderTurnTokenUsage(serverProjection?.providerTurns ?? []),
1729 [serverProjection?.providerTurns],
1730 );
17331731 const serverVisibleTurnItems = useThreadVisibleTurnItems(routeThreadDetailRef);
17341732 const serverThreadHistory = useThreadHistory(routeThreadDetailRef);
17351733 const threadHistoryControls = useMemo<MessagesTimelineHistoryControls | undefined>(() => {
......@@ -7753,6 +7751,8 @@ export default function ChatView(props: ChatViewProps) {
77537751 provider: selectedProvider,
77547752 usedTokens: activeContextWindow.usedTokens,
77557753 updatedAt: activeContextWindow.updatedAt,
7754 // Only the live report carries a TTL, and only when it is the window's source.
7755 promptCacheTtlMs: activeThreadLiveTokenUsage?.promptCacheTtlMs,
77567756 now: `${nowMinute}:00.000Z`,
77577757 })
77587758 ? activeContextWindow.usedTokens
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+13
......@@ -173,6 +173,19 @@ describe("shouldOfferResumeCompaction", () => {
173173 ).toBe(false);
174174 });
175175
176 it("uses the reported prompt cache TTL instead of the fallback", () => {
177 const offer = (updatedAt: string) =>
178 shouldOfferResumeCompaction({
179 provider: "claudeAgent",
180 usedTokens: 200_000,
181 updatedAt,
182 promptCacheTtlMs: 5 * 60_000,
183 now,
184 });
185 expect(offer("2026-08-24T11:55:00.000Z")).toBe(true);
186 expect(offer("2026-08-24T11:56:00.000Z")).toBe(false);
187 });
188
176189 it("does not show Claude's resume prompt for another provider", () => {
177190 expect(
178191 shouldOfferResumeCompaction({
apps/web/src/components/chat/ContextWindowMeter.logic.ts+6-2
......@@ -9,7 +9,8 @@ import {
99} from "../../providerInstances";
1010import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils";
1111
12const CLAUDE_RESUME_COMPACTION_MINUTES = 70;
12// Used when the provider did not report its cache TTL: Claude's 1h cache plus slack.
13const CLAUDE_RESUME_COMPACTION_FALLBACK_MS = 70 * 60_000;
1314const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000;
1415
1516export function providerSupportsManualCompaction(
......@@ -60,10 +61,13 @@ export function hasDismissedResumeCompaction(
6061 });
6162}
6263
64/** True once a large Claude context's prompt cache has expired, so the next
65 turn would re-read all of it uncached. */
6366export function shouldOfferResumeCompaction(input: {
6467 readonly provider: string | null | undefined;
6568 readonly usedTokens: number | null | undefined;
6669 readonly updatedAt: string | null | undefined;
70 readonly promptCacheTtlMs?: number | undefined;
6771 readonly now: string;
6872}): boolean {
6973 if (
......@@ -78,7 +82,7 @@ export function shouldOfferResumeCompaction(input: {
7882 return (
7983 Number.isFinite(updatedAt) &&
8084 Number.isFinite(now) &&
81 now - updatedAt >= CLAUDE_RESUME_COMPACTION_MINUTES * 60_000
85 now - updatedAt >= (input.promptCacheTtlMs ?? CLAUDE_RESUME_COMPACTION_FALLBACK_MS)
8286 );
8387}
8488
apps/web/src/lib/contextWindow.test.ts+20-1
......@@ -1,6 +1,10 @@
11import { describe, expect, it } from "vite-plus/test";
22import * as DateTime from "effect/DateTime";
3import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow";
3import {
4 deriveLatestContextWindowSnapshot,
5 formatContextWindowTokens,
6 latestProviderTurnTokenUsage,
7} from "./contextWindow";
48
59describe("V2 context window presentation", () => {
610 it("uses retained compaction token data when available", () => {
......@@ -81,3 +85,18 @@ describe("live provider-turn usage (#8144)", () => {
8185 expect(snapshot?.usedPercentage).toBeNull();
8286 });
8387});
88
89describe("latestProviderTurnTokenUsage", () => {
90 it("picks the newest report, not the last turn in snapshot order", () => {
91 const usage = (usedTokens: number, updatedAt: string) => ({
92 tokenUsage: { usedTokens, updatedAt },
93 });
94 // Snapshots sort by provider thread id, so an older thread can come last.
95 const latest = latestProviderTurnTokenUsage([
96 usage(300_000, "2026-06-20T12:00:00.000Z"),
97 {},
98 usage(90_000, "2026-06-20T09:00:00.000Z"),
99 ]);
100 expect(latest?.usedTokens).toBe(300_000);
101 });
102});
apps/web/src/lib/contextWindow.ts+17
......@@ -1,4 +1,5 @@
11import type {
2 OrchestrationV2ProviderTurn,
23 OrchestrationV2ProviderTurnTokenUsage,
34 OrchestrationV2ProviderThread,
45 OrchestrationV2TurnItem,
......@@ -23,6 +24,22 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & {
2324 readonly updatedAt: string;
2425};
2526
27/** Newest usage report across a thread's provider turns. Snapshots order turns
28 by provider thread, not time, so pick by timestamp. */
29export function latestProviderTurnTokenUsage(
30 turns: ReadonlyArray<Pick<OrchestrationV2ProviderTurn, "tokenUsage">>,
31): OrchestrationV2ProviderTurnTokenUsage | null {
32 let latest: OrchestrationV2ProviderTurnTokenUsage | null = null;
33 for (const { tokenUsage } of turns) {
34 if (tokenUsage === undefined) continue;
35 // Ties go to the later entry, since live upserts append.
36 if (latest === null || Date.parse(tokenUsage.updatedAt) >= Date.parse(latest.updatedAt)) {
37 latest = tokenUsage;
38 }
39 }
40 return latest;
41}
42
2643/** Prefers the provider's live usage report (#8144); falls back to the last compaction item. */
2744export function deriveLatestContextWindowSnapshot(
2845 entries: ReadonlyArray<{
docs/user/providers-claude.md+3-2
......@@ -50,10 +50,11 @@ window. Leave it empty for Claude Code's default.
5050
5151You can also send `/compact` in an existing conversation. Web and desktop offer
5252**Compact context** from the context meter. When you return to a large thread
53after more than an hour, a **Compact** chip with the thread's token count shows
53after Claude's prompt cache expires, a **Compact** chip with the thread's token count shows
5454next to the send button. While it is on, Enter summarizes the history first, then
5555sends your message. Click the chip to switch it to **Full** and keep the full
56history for that message. See [commands and skills](./composer.md#commands-and-skills) for using
56history for that message. The cache lasts about an hour on a Claude
57subscription and 5 minutes with an API key, Bedrock, or Vertex. See [commands and skills](./composer.md#commands-and-skills) for using
5758composer commands.
5859
5960## Usage limits
packages/contracts/src/orchestrationV2.ts+3
......@@ -979,6 +979,9 @@ export const OrchestrationV2ProviderTurnTokenUsage = Schema.Struct({
979979 cachedInputTokens: Schema.optional(NonNegativeInt),
980980 outputTokens: Schema.optional(NonNegativeInt),
981981 reasoningOutputTokens: Schema.optional(NonNegativeInt),
982 /** Prompt cache lifetime the provider wrote with this report. Clients treat
983 the cache as cold once `updatedAt` is this old. */
984 promptCacheTtlMs: Schema.optional(PositiveInt),
982985 /** ISO timestamp of the provider's report; string so wire encoding is stable. */
983986 updatedAt: Schema.String,
984987});