Skip to content

Commit c727d12

Browse files
t3dotggclaude
andauthored
fix(web): Compact chip follows Claude's real prompt cache TTL (pingdotgg#17945)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1cfd1c1 commit c727d12

9 files changed

Lines changed: 119 additions & 17 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

905930
describe("ClaudeAdapterV2 session permissions", () => {

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,29 @@ function claudeContextWindow(modelSelection: ModelSelection): number | null {
156156
);
157157
}
158158

159+
interface 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.
167+
function 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+
159176
export 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,13 +189,15 @@ 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),
177196
inputTokens,
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‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,10 @@ import {
498498
hasDismissedResumeCompaction,
499499
shouldOfferResumeCompaction,
500500
} from "./chat/ContextWindowMeter.logic";
501-
import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow";
501+
import {
502+
deriveLatestContextWindowSnapshot,
503+
latestProviderTurnTokenUsage,
504+
} from "../lib/contextWindow";
502505
import {
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‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
} from "../../providerInstances";
1010
import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils";
1111

12-
const CLAUDE_RESUME_COMPACTION_MINUTES = 70;
12+
// Used when the provider did not report its cache TTL: Claude's 1h cache plus slack.
13+
const CLAUDE_RESUME_COMPACTION_FALLBACK_MS = 70 * 60_000;
1314
const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000;
1415

1516
export 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. */
6366
export 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‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it } from "vite-plus/test";
22
import * as DateTime from "effect/DateTime";
3-
import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow";
3+
import {
4+
deriveLatestContextWindowSnapshot,
5+
formatContextWindowTokens,
6+
latestProviderTurnTokenUsage,
7+
} from "./contextWindow";
48

59
describe("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+
89+
describe("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‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 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. */
29+
export 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. */
2744
export function deriveLatestContextWindowSnapshot(
2845
entries: ReadonlyArray<{

‎docs/user/providers-claude.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,11 @@ window. Leave it empty for Claude Code's default.
5050

5151
You 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
53-
after more than an hour, a **Compact** chip with the thread's token count shows
53+
after Claude's prompt cache expires, a **Compact** chip with the thread's token count shows
5454
next to the send button. While it is on, Enter summarizes the history first, then
5555
sends your message. Click the chip to switch it to **Full** and keep the full
56-
history for that message. See [commands and skills](./composer.md#commands-and-skills) for using
56+
history for that message. The cache lasts about an hour on a Claude
57+
subscription and 5 minutes with an API key, Bedrock, or Vertex. See [commands and skills](./composer.md#commands-and-skills) for using
5758
composer commands.
5859

5960
## Usage limits

‎packages/contracts/src/orchestrationV2.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
});

0 commit comments

Comments
 (0)