| author | |
| committer | |
| log | c727d1219f4ef10ad2a41f0e67df80ca51924673 |
| tree | f154615e14680d00941cbb065a3203351cd504a3 |
| parent | 1cfd1c1a4489b96defb1cea79fc8561cf5dc2a24 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | }); |
| 904 | 929 | ||
| 905 | describe("ClaudeAdapterV2 session permissions", () => { | 930 | describe("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 | } |
| 158 | 158 | ||
| 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 | |||
| 159 | export function claudeProviderTurnTokenUsage( | 176 | export 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"; |
| 501 | import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow"; | 501 | import { |
| 502 | deriveLatestContextWindowSnapshot, | ||
| 503 | latestProviderTurnTokenUsage, | ||
| 504 | } from "../lib/contextWindow"; | ||
| 502 | import { | 505 | import { |
| 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 has | 1725 | // 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.usedTokens | 7758 | ? 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 | }); |
| 175 | 175 | ||
| 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"; |
| 10 | import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; | 10 | import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; |
| 11 | 11 | ||
| 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; | ||
| 13 | const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; | 14 | const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; |
| 14 | 15 | ||
| 15 | export function providerSupportsManualCompaction( | 16 | export function providerSupportsManualCompaction( |
| ... | @@ -60,10 +61,13 @@ export function hasDismissedResumeCompaction( | ... | @@ -60,10 +61,13 @@ export function hasDismissedResumeCompaction( |
| 60 | }); | 61 | }); |
| 61 | } | 62 | } |
| 62 | 63 | ||
| 64 | /** True once a large Claude context's prompt cache has expired, so the next | ||
| 65 | turn would re-read all of it uncached. */ | ||
| 63 | export function shouldOfferResumeCompaction(input: { | 66 | export 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_000 | 85 | now - updatedAt >= (input.promptCacheTtlMs ?? CLAUDE_RESUME_COMPACTION_FALLBACK_MS) |
| 82 | ); | 86 | ); |
| 83 | } | 87 | } |
| 84 | 88 |
apps/web/src/lib/contextWindow.test.ts+20-1| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import * as DateTime from "effect/DateTime"; | 2 | import * as DateTime from "effect/DateTime"; |
| 3 | import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow"; | 3 | import { |
| 4 | deriveLatestContextWindowSnapshot, | ||
| 5 | formatContextWindowTokens, | ||
| 6 | latestProviderTurnTokenUsage, | ||
| 7 | } from "./contextWindow"; | ||
| 4 | 8 | ||
| 5 | describe("V2 context window presentation", () => { | 9 | describe("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 | |||
| 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+17| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { | 1 | import 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 | }; |
| 25 | 26 | ||
| 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 | |||
| 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. */ |
| 27 | export function deriveLatestContextWindowSnapshot( | 44 | export 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. |
| 50 | 50 | ||
| 51 | You can also send `/compact` in an existing conversation. Web and desktop offer | 51 | You 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 thread | 52 | **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 |
| 54 | next to the send button. While it is on, Enter summarizes the history first, then | 54 | next to the send button. While it is on, Enter summarizes the history first, then |
| 55 | sends your message. Click the chip to switch it to **Full** and keep the full | 55 | 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 | ||
| 57 | composer commands. | 58 | composer commands. |
| 58 | 59 | ||
| 59 | ## Usage limits | 60 | ## 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 | }); |