diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ade26d4..271830ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ See `docs/llm-wiki/release.md`. - **Account heatmap stats strip**: Codex-style totals — cumulative tokens, peak day, longest chat, current / longest streak — plus a **Cumulative** calendar view (running-total color). ### Fixed +- **Usage modal cache > input, then empty**: Session spend only sums `turn_completed` snapshots. Cache-heavy fragments (cache > input, no `modelCalls`) are dropped so cached cannot exceed input; a real turn without `modelCalls` still counts. Totals persist in sessionStorage, and an in-flight turn says usage updates when it finishes. - **Chat image cards no longer die after the turn ends**: Leftover remote https thumbs (web-fetch charts, etc.) first-paint from the in-memory thumb cache on journal remount. Swapping `src` mid-load used to abort the original `` and lock `broken_blob` (“preview failed”). Abort / stale-src errors are ignored; a working https original is not wiped when thumb resolve returns empty. ## [0.2.17] - 2026-08-14 diff --git a/src/app/AppWorkbench.tsx b/src/app/AppWorkbench.tsx index 21b8996ba..c34772883 100644 --- a/src/app/AppWorkbench.tsx +++ b/src/app/AppWorkbench.tsx @@ -21739,6 +21739,10 @@ export function AppWorkbench() { spend={sessionSpend} account={account} customRoute={customRouteActive} + turnActive={ + session.state === "streaming" || + session.state === "awaiting_permission" + } onClose={() => setShowUsageLimitModal(false)} /> {(agentDashboardOpen) ? ( diff --git a/src/components/UsageLimitModal.tsx b/src/components/UsageLimitModal.tsx index 89e20ceea..df65eda97 100644 --- a/src/components/UsageLimitModal.tsx +++ b/src/components/UsageLimitModal.tsx @@ -29,6 +29,8 @@ type Props = { spend: SessionSpend; account: AccountStatus | null; customRoute?: boolean; + /** True while a turn is in flight — usage lands on turn_completed. */ + turnActive?: boolean; onClose: () => void; }; @@ -46,6 +48,7 @@ export function UsageLimitModal({ spend, account, customRoute = false, + turnActive = false, onClose, }: Props) { const tr = useMemo(() => createT(locale), [locale]); @@ -160,7 +163,11 @@ export function UsageLimitModal({ {!sessionId ? (

{tr("usageModal.noSession")}

) : !showSpend ? ( -

{tr("usageModal.noCalls")}

+

+ {turnActive + ? tr("usageModal.pendingTurn") + : tr("usageModal.noCalls")} +

) : (
diff --git a/src/i18n/messages/en/account.ts b/src/i18n/messages/en/account.ts index 7c58dd706..26e8a5d11 100644 --- a/src/i18n/messages/en/account.ts +++ b/src/i18n/messages/en/account.ts @@ -52,6 +52,7 @@ export const enAccount = { "usageModal.cost": "Cost", "usageModal.noSession": "Session usage is unavailable until the session starts.", "usageModal.noCalls": "No model calls yet in this session.", + "usageModal.pendingTurn": "Usage updates when this turn finishes.", "usageModal.incomplete": "Usage is incomplete and may under-count.", "usageModal.costPartial": "Cost is incomplete and may under-count.", "usageModal.quotaUnknown": "Could not load the weekly limit.", diff --git a/src/i18n/messages/zh-TW/account.ts b/src/i18n/messages/zh-TW/account.ts index 935fa341b..e0badc88f 100644 --- a/src/i18n/messages/zh-TW/account.ts +++ b/src/i18n/messages/zh-TW/account.ts @@ -52,6 +52,7 @@ export const zhTWAccount = { "usageModal.cost": "費用", "usageModal.noSession": "對話尚未開始,暫無用量。", "usageModal.noCalls": "本對話還沒有模型呼叫。", + "usageModal.pendingTurn": "用量在本回合結束後更新。", "usageModal.incomplete": "用量追蹤不完整,數字可能偏低。", "usageModal.costPartial": "費用追蹤不完整,數字可能偏低。", "usageModal.quotaUnknown": "無法載入每週限額。", diff --git a/src/i18n/messages/zh/account.ts b/src/i18n/messages/zh/account.ts index 2dfecc3da..6b3e0378c 100644 --- a/src/i18n/messages/zh/account.ts +++ b/src/i18n/messages/zh/account.ts @@ -52,6 +52,7 @@ export const zhAccount = { "usageModal.cost": "费用", "usageModal.noSession": "会话尚未开始,暂无用量。", "usageModal.noCalls": "本会话还没有模型调用。", + "usageModal.pendingTurn": "用量在本回合结束后更新。", "usageModal.incomplete": "用量跟踪不完整,数字可能偏低。", "usageModal.costPartial": "费用跟踪不完整,数字可能偏低。", "usageModal.quotaUnknown": "未能加载每周限额。", diff --git a/src/lib/sessionSpend.test.ts b/src/lib/sessionSpend.test.ts index 9d1aa1ea8..7c6a337d2 100644 --- a/src/lib/sessionSpend.test.ts +++ b/src/lib/sessionSpend.test.ts @@ -34,7 +34,8 @@ afterEach(() => { describe("isSessionSpendBillingSource", () => { it("accepts turn_completed and rejects occupancy / prompt_result", () => { expect(isSessionSpendBillingSource("turn_completed")).toBe(true); - expect(isSessionSpendBillingSource("response_completed")).toBe(true); + expect(isSessionSpendBillingSource("response_completed")).toBe(false); + expect(isSessionSpendBillingSource("turn_usage")).toBe(false); expect(isSessionSpendBillingSource("prompt_result")).toBe(false); expect(isSessionSpendBillingSource("context_size")).toBe(false); expect(isSessionSpendBillingSource("compact")).toBe(false); @@ -102,6 +103,72 @@ describe("applySessionSpendTurn", () => { expect(again.modelCalls).toBe(6); }); + it("does not add cache-heavy fragments that lack modelCalls", () => { + const once = applySessionSpendTurn( + emptySessionSpend(), + { + source: "turn_completed", + inputTokens: 65_402, + outputTokens: 2_268, + totalTokens: 67_670, + cachedReadTokens: 46_848, + reasoningTokens: 762, + modelCalls: 3, + apiDurationMs: 44_692, + costUsdTicks: 741_400_000, + }, + 1_000, + ); + const again = applySessionSpendTurn( + once, + { + source: "turn_completed", + inputTokens: 22_887, + outputTokens: 3_288, + cachedReadTokens: 97_152, + reasoningTokens: 1_419, + }, + 1_100, + ); + expect(again.inputTokens).toBe(65_402); + expect(again.cachedReadTokens).toBe(46_848); + expect(again.modelCalls).toBe(3); + expect(sessionSpendCacheHitRate(again)).toBe(72); + }); + + it("accepts a turn_completed snapshot even when modelCalls is missing", () => { + const next = applySessionSpendTurn( + emptySessionSpend(), + { + source: "turn_completed", + inputTokens: 65_402, + outputTokens: 2_268, + totalTokens: 67_670, + cachedReadTokens: 46_848, + }, + 1_000, + ); + expect(next.inputTokens).toBe(65_402); + expect(next.cachedReadTokens).toBe(46_848); + expect(sessionSpendCacheHitRate(next)).toBe(72); + }); + + it("clamps per-turn cache to input", () => { + const next = applySessionSpendTurn( + emptySessionSpend(), + { + source: "turn_completed", + inputTokens: 10_000, + outputTokens: 1, + totalTokens: 10_001, + cachedReadTokens: 48_000, + modelCalls: 1, + }, + 1_000, + ); + expect(next.cachedReadTokens).toBe(10_000); + }); + it("marks incomplete / partial flags", () => { const next = applySessionSpendTurn( emptySessionSpend(), diff --git a/src/lib/sessionSpend.ts b/src/lib/sessionSpend.ts index abf19aa48..63ebaede7 100644 --- a/src/lib/sessionSpend.ts +++ b/src/lib/sessionSpend.ts @@ -63,19 +63,16 @@ export function emptySessionSpend(): SessionSpend { return { ...EMPTY_SESSION_SPEND }; } +/** + * Only the CLI **user-turn** aggregate. Per-call `response_completed` / + * `turn_usage` packets use a different cache accounting (often cache-only + * or uncached+cache split) and must not be summed with `turn_completed`. + */ export function isSessionSpendBillingSource( source: string | null | undefined, ): boolean { const s = (source ?? "").toLowerCase(); - if (!s) return false; - if (s === "prompt_result") return false; - return ( - s === "turn_completed" || - s === "response_completed" || - s.includes("turn_completed") || - s === "turn_usage" || - s === "turnusage" - ); + return s === "turn_completed"; } function finiteNonNeg(n: number | null | undefined): number | null { @@ -96,6 +93,19 @@ export function spendTurnFingerprint(turn: SessionSpendTurn): string { ].join(":"); } +/** + * Per-call / split-accounting fragments: cache with no input, or cache > input + * and no modelCalls. Those packets must not be summed into the turn snapshot. + */ +export function isSpendFragment(turn: SessionSpendTurn): boolean { + const input = finiteNonNeg(turn.inputTokens) ?? 0; + const cached = finiteNonNeg(turn.cachedReadTokens) ?? 0; + const calls = finiteNonNeg(turn.modelCalls) ?? 0; + if (calls > 0) return false; + if (cached > 0 && (input <= 0 || cached > input)) return true; + return false; +} + export function hasSpendSignal(turn: SessionSpendTurn): boolean { return ( finiteNonNeg(turn.inputTokens) != null || @@ -146,6 +156,7 @@ export function applySessionSpendTurn( ): SessionSpend { if (!isSessionSpendBillingSource(turn.source)) return state; if (!hasSpendSignal(turn)) return state; + if (isSpendFragment(turn)) return state; const fp = spendTurnFingerprint(turn); if ( @@ -158,7 +169,9 @@ export function applySessionSpendTurn( const input = finiteNonNeg(turn.inputTokens) ?? 0; const output = finiteNonNeg(turn.outputTokens) ?? 0; - const cached = finiteNonNeg(turn.cachedReadTokens) ?? 0; + let cached = finiteNonNeg(turn.cachedReadTokens) ?? 0; + // CLI `inputTokens` already includes cache reads; cache cannot exceed input. + if (input > 0 && cached > input) cached = input; const reasoning = finiteNonNeg(turn.reasoningTokens) ?? 0; const calls = finiteNonNeg(turn.modelCalls) ?? 0; const duration = finiteNonNeg(turn.apiDurationMs) ?? 0; @@ -268,8 +281,43 @@ export function formatUsageResetTime( // ── In-memory per-session store (App process lifetime) ──────────────── +const SPEND_STORE_KEY = "grok.sessionSpend.v1"; + const spendBySession = new Map(); const listeners = new Set<(sessionId: string) => void>(); +let storeHydrated = false; + +function canUseSessionStorage(): boolean { + return typeof sessionStorage !== "undefined"; +} + +function persistSpendStore(): void { + if (!canUseSessionStorage()) return; + try { + const obj: Record = {}; + for (const [id, spend] of spendBySession) obj[id] = spend; + sessionStorage.setItem(SPEND_STORE_KEY, JSON.stringify(obj)); + } catch { + /* quota / private mode */ + } +} + +function hydrateSpendStore(): void { + if (storeHydrated) return; + storeHydrated = true; + if (!canUseSessionStorage()) return; + try { + const raw = sessionStorage.getItem(SPEND_STORE_KEY); + if (!raw) return; + const obj = JSON.parse(raw) as Record; + for (const [id, spend] of Object.entries(obj ?? {})) { + if (!id || !spend || typeof spend !== "object") continue; + spendBySession.set(id, { ...emptySessionSpend(), ...spend }); + } + } catch { + /* ignore bad cache */ + } +} function notify(sessionId: string): void { for (const fn of listeners) { @@ -282,12 +330,15 @@ function notify(sessionId: string): void { } export function getSessionSpend(sessionId: string | null | undefined): SessionSpend { + hydrateSpendStore(); if (!sessionId) return emptySessionSpend(); return spendBySession.get(sessionId) ?? emptySessionSpend(); } export function resetSessionSpend(sessionId: string): void { + hydrateSpendStore(); spendBySession.delete(sessionId); + persistSpendStore(); notify(sessionId); } @@ -296,10 +347,12 @@ export function ingestSessionSpend( turn: SessionSpendTurn, now = Date.now(), ): SessionSpend { + hydrateSpendStore(); const prev = spendBySession.get(sessionId) ?? emptySessionSpend(); const next = applySessionSpendTurn(prev, turn, now); if (next === prev) return prev; spendBySession.set(sessionId, next); + persistSpendStore(); notify(sessionId); return next; } @@ -316,4 +369,12 @@ export function subscribeSessionSpend( /** Test-only: wipe the process map. */ export function clearSessionSpendStore(): void { spendBySession.clear(); + storeHydrated = true; + if (canUseSessionStorage()) { + try { + sessionStorage.removeItem(SPEND_STORE_KEY); + } catch { + /* ignore */ + } + } }