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", () => {...@@ -900,6 +900,31 @@ describe("ClaudeAdapterV2 context usage", () => {
900 updatedAt: "2026-08-29T00:00:00.000Z",900 updatedAt: "2026-08-29T00:00:00.000Z",
901 });901 });
902 });902 });
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 });
903});928});
904929
905describe("ClaudeAdapterV2 session permissions", () => {930describe("ClaudeAdapterV2 session permissions", () => {
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+20
...@@ -156,11 +156,29 @@ function claudeContextWindow(modelSelection: ModelSelection): number | null {...@@ -156,11 +156,29 @@ function claudeContextWindow(modelSelection: ModelSelection): number | null {
156 );156 );
157}157}
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
159export function claudeProviderTurnTokenUsage(176export function claudeProviderTurnTokenUsage(
160 usage: {177 usage: {
161 readonly input_tokens: number;178 readonly input_tokens: number;
162 readonly cache_creation_input_tokens?: number | null;179 readonly cache_creation_input_tokens?: number | null;
163 readonly cache_read_input_tokens?: number | null;180 readonly cache_read_input_tokens?: number | null;
181 readonly cache_creation?: ClaudeCacheCreation | null;
164 readonly output_tokens: number;182 readonly output_tokens: number;
165 },183 },
166 modelSelection: ModelSelection,184 modelSelection: ModelSelection,
...@@ -171,6 +189,7 @@ export function claudeProviderTurnTokenUsage(...@@ -171,6 +189,7 @@ export function claudeProviderTurnTokenUsage(
171 (usage.cache_creation_input_tokens ?? 0) +189 (usage.cache_creation_input_tokens ?? 0) +
172 (usage.cache_read_input_tokens ?? 0);190 (usage.cache_read_input_tokens ?? 0);
173 const outputTokens = usage.output_tokens;191 const outputTokens = usage.output_tokens;
192 const promptCacheTtlMs = claudePromptCacheTtlMs(usage.cache_creation);
174 return {193 return {
175 usedTokens: inputTokens + outputTokens,194 usedTokens: inputTokens + outputTokens,
176 maxTokens: claudeContextWindow(modelSelection),195 maxTokens: claudeContextWindow(modelSelection),
...@@ -178,6 +197,7 @@ export function claudeProviderTurnTokenUsage(...@@ -178,6 +197,7 @@ export function claudeProviderTurnTokenUsage(
178 cachedInputTokens: usage.cache_read_input_tokens ?? 0,197 cachedInputTokens: usage.cache_read_input_tokens ?? 0,
179 outputTokens,198 outputTokens,
180 reasoningOutputTokens: 0,199 reasoningOutputTokens: 0,
200 ...(promptCacheTtlMs === undefined ? {} : { promptCacheTtlMs }),
181 updatedAt,201 updatedAt,
182 };202 };
183}203}
apps/web/src/components/ChatView.tsx+12-12
...@@ -498,7 +498,10 @@ import {...@@ -498,7 +498,10 @@ import {
498 hasDismissedResumeCompaction,498 hasDismissedResumeCompaction,
499 shouldOfferResumeCompaction,499 shouldOfferResumeCompaction,
500} from "./chat/ContextWindowMeter.logic";500} from "./chat/ContextWindowMeter.logic";
501import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow";501import {
502 deriveLatestContextWindowSnapshot,
503 latestProviderTurnTokenUsage,
504} from "../lib/contextWindow";
502import {505import {
503 DRAFT_HERO_TRANSITION_ANIMATION_ID,506 DRAFT_HERO_TRANSITION_ANIMATION_ID,
504 DRAFT_HERO_TRANSITION_EASING,507 DRAFT_HERO_TRANSITION_EASING,
...@@ -1719,17 +1722,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -1719,17 +1722,12 @@ export default function ChatView(props: ChatViewProps) {
1719 status: threadStatus,1722 status: threadStatus,
1720 });1723 });
1721 const threadDetailLoading = threadSyncPhase === "loading";1724 const threadDetailLoading = threadSyncPhase === "loading";
1722 // Latest provider-reported context usage (#8144): the newest turn that has1725 // Latest provider-reported context usage (#8144): the newest report wins;
1723 // a report wins; stale turns keep the meter alive between turns.1726 // stale turns keep the meter alive between turns.
1724 const activeThreadLiveTokenUsage = useMemo(() => {1727 const activeThreadLiveTokenUsage = useMemo(
1725 const turns = serverProjection?.providerTurns;1728 () => latestProviderTurnTokenUsage(serverProjection?.providerTurns ?? []),
1726 if (!turns || turns.length === 0) return null;1729 [serverProjection?.providerTurns],
1727 for (let index = turns.length - 1; index >= 0; index -= 1) {1730 );
1728 const usage = turns[index]?.tokenUsage;
1729 if (usage !== undefined) return usage;
1730 }
1731 return null;
1732 }, [serverProjection?.providerTurns]);
1733 const serverVisibleTurnItems = useThreadVisibleTurnItems(routeThreadDetailRef);1731 const serverVisibleTurnItems = useThreadVisibleTurnItems(routeThreadDetailRef);
1734 const serverThreadHistory = useThreadHistory(routeThreadDetailRef);1732 const serverThreadHistory = useThreadHistory(routeThreadDetailRef);
1735 const threadHistoryControls = useMemo<MessagesTimelineHistoryControls | undefined>(() => {1733 const threadHistoryControls = useMemo<MessagesTimelineHistoryControls | undefined>(() => {
...@@ -7753,6 +7751,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -7753,6 +7751,8 @@ export default function ChatView(props: ChatViewProps) {
7753 provider: selectedProvider,7751 provider: selectedProvider,
7754 usedTokens: activeContextWindow.usedTokens,7752 usedTokens: activeContextWindow.usedTokens,
7755 updatedAt: activeContextWindow.updatedAt,7753 updatedAt: activeContextWindow.updatedAt,
7754 // Only the live report carries a TTL, and only when it is the window's source.
7755 promptCacheTtlMs: activeThreadLiveTokenUsage?.promptCacheTtlMs,
7756 now: `${nowMinute}:00.000Z`,7756 now: `${nowMinute}:00.000Z`,
7757 })7757 })
7758 ? activeContextWindow.usedTokens7758 ? activeContextWindow.usedTokens
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+13
...@@ -173,6 +173,19 @@ describe("shouldOfferResumeCompaction", () => {...@@ -173,6 +173,19 @@ describe("shouldOfferResumeCompaction", () => {
173 ).toBe(false);173 ).toBe(false);
174 });174 });
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
176 it("does not show Claude's resume prompt for another provider", () => {189 it("does not show Claude's resume prompt for another provider", () => {
177 expect(190 expect(
178 shouldOfferResumeCompaction({191 shouldOfferResumeCompaction({
apps/web/src/components/chat/ContextWindowMeter.logic.ts+6-2
...@@ -9,7 +9,8 @@ import {...@@ -9,7 +9,8 @@ import {
9} from "../../providerInstances";9} from "../../providerInstances";
10import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils";10import { 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;
13const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000;14const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000;
1415
15export function providerSupportsManualCompaction(16export function providerSupportsManualCompaction(
...@@ -60,10 +61,13 @@ export function hasDismissedResumeCompaction(...@@ -60,10 +61,13 @@ export function hasDismissedResumeCompaction(
60 });61 });
61}62}
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. */
63export function shouldOfferResumeCompaction(input: {66export function shouldOfferResumeCompaction(input: {
64 readonly provider: string | null | undefined;67 readonly provider: string | null | undefined;
65 readonly usedTokens: number | null | undefined;68 readonly usedTokens: number | null | undefined;
66 readonly updatedAt: string | null | undefined;69 readonly updatedAt: string | null | undefined;
70 readonly promptCacheTtlMs?: number | undefined;
67 readonly now: string;71 readonly now: string;
68}): boolean {72}): boolean {
69 if (73 if (
...@@ -78,7 +82,7 @@ export function shouldOfferResumeCompaction(input: {...@@ -78,7 +82,7 @@ export function shouldOfferResumeCompaction(input: {
78 return (82 return (
79 Number.isFinite(updatedAt) &&83 Number.isFinite(updatedAt) &&
80 Number.isFinite(now) &&84 Number.isFinite(now) &&
81 now - updatedAt >= CLAUDE_RESUME_COMPACTION_MINUTES * 60_00085 now - updatedAt >= (input.promptCacheTtlMs ?? CLAUDE_RESUME_COMPACTION_FALLBACK_MS)
82 );86 );
83}87}
8488
apps/web/src/lib/contextWindow.test.ts+20-1
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import * as DateTime from "effect/DateTime";2import * as DateTime from "effect/DateTime";
3import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow";3import {
4 deriveLatestContextWindowSnapshot,
5 formatContextWindowTokens,
6 latestProviderTurnTokenUsage,
7} from "./contextWindow";
48
5describe("V2 context window presentation", () => {9describe("V2 context window presentation", () => {
6 it("uses retained compaction token data when available", () => {10 it("uses retained compaction token data when available", () => {
...@@ -81,3 +85,18 @@ describe("live provider-turn usage (#8144)", () => {...@@ -81,3 +85,18 @@ describe("live provider-turn usage (#8144)", () => {
81 expect(snapshot?.usedPercentage).toBeNull();85 expect(snapshot?.usedPercentage).toBeNull();
82 });86 });
83});87});
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 @@...@@ -1,4 +1,5 @@
1import type {1import type {
2 OrchestrationV2ProviderTurn,
2 OrchestrationV2ProviderTurnTokenUsage,3 OrchestrationV2ProviderTurnTokenUsage,
3 OrchestrationV2ProviderThread,4 OrchestrationV2ProviderThread,
4 OrchestrationV2TurnItem,5 OrchestrationV2TurnItem,
...@@ -23,6 +24,22 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & {...@@ -23,6 +24,22 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & {
23 readonly updatedAt: string;24 readonly updatedAt: string;
24};25};
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
26/** Prefers the provider's live usage report (#8144); falls back to the last compaction item. */43/** Prefers the provider's live usage report (#8144); falls back to the last compaction item. */
27export function deriveLatestContextWindowSnapshot(44export function deriveLatestContextWindowSnapshot(
28 entries: ReadonlyArray<{45 entries: ReadonlyArray<{
docs/user/providers-claude.md+3-2
...@@ -50,10 +50,11 @@ window. Leave it empty for Claude Code's default....@@ -50,10 +50,11 @@ window. Leave it empty for Claude Code's default.
5050
51You can also send `/compact` in an existing conversation. Web and desktop offer51You can also send `/compact` in an existing conversation. Web and desktop offer
52**Compact context** from the context meter. When you return to a large thread52**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 shows53after Claude's prompt cache expires, a **Compact** chip with the thread's token count shows
54next to the send button. While it is on, Enter summarizes the history first, then54next to the send button. While it is on, Enter summarizes the history first, then
55sends your message. Click the chip to switch it to **Full** and keep the full55sends 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 using56history 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
57composer commands.58composer commands.
5859
59## Usage limits60## Usage limits
packages/contracts/src/orchestrationV2.ts+3
...@@ -979,6 +979,9 @@ export const OrchestrationV2ProviderTurnTokenUsage = Schema.Struct({...@@ -979,6 +979,9 @@ export const OrchestrationV2ProviderTurnTokenUsage = Schema.Struct({
979 cachedInputTokens: Schema.optional(NonNegativeInt),979 cachedInputTokens: Schema.optional(NonNegativeInt),
980 outputTokens: Schema.optional(NonNegativeInt),980 outputTokens: Schema.optional(NonNegativeInt),
981 reasoningOutputTokens: Schema.optional(NonNegativeInt),981 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),
982 /** ISO timestamp of the provider's report; string so wire encoding is stable. */985 /** ISO timestamp of the provider's report; string so wire encoding is stable. */
983 updatedAt: Schema.String,986 updatedAt: Schema.String,
984});987});