From 0f58744432540cd31221977d4c655cfd42df1764 Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Tue, 4 Aug 2026 19:29:28 +0800 Subject: [PATCH] fix(web): keep the chat cost stat visible across task boundaries and mid-run loads (#188) Co-authored-by: Claude Fable 5 --- packages/web/src/features/chat/chat-page.tsx | 117 +++++----- .../web/src/features/chat/header-stats.ts | 209 ++++++++++++++++++ packages/web/test/header-stats.test.ts | 139 ++++++++++++ 3 files changed, 412 insertions(+), 53 deletions(-) create mode 100644 packages/web/src/features/chat/header-stats.ts create mode 100644 packages/web/test/header-stats.test.ts diff --git a/packages/web/src/features/chat/chat-page.tsx b/packages/web/src/features/chat/chat-page.tsx index a26c2b4..7d1f400 100644 --- a/packages/web/src/features/chat/chat-page.tsx +++ b/packages/web/src/features/chat/chat-page.tsx @@ -33,7 +33,6 @@ import { apiErrorText } from "../../lib/api-error"; import { useDocumentTitle } from "../../lib/use-document-title"; import { formatDateTime, - formatMoney, humanizeDuration, humanizeDurationLive, humanizeTokens, @@ -42,7 +41,7 @@ import { latestConversation } from "../../lib/session-grouping"; import { approvalKey, isModelAuthDead } from "../../lib/omni/stream-model"; import type { StreamModel } from "../../lib/omni/stream-model"; import { bucketCostUsd, liveSessionElapsedMs } from "../../lib/omni/task-stats"; -import type { BucketPricing, TaskStatsTracker } from "../../lib/omni/task-stats"; +import type { TaskStatsTracker } from "../../lib/omni/task-stats"; import { useTheme } from "../../state/theme"; import { useProject } from "../../state/project"; import { useSessions } from "../../state/sessions"; @@ -59,6 +58,8 @@ import { latestTaskHasSubagent, taskStartCount } from "./agent-topology"; import { ChatInput } from "./chat-input"; import { ConversationOutline, OutlineMenuButton, useOutlineRailFit } from "./conversation-outline"; import { DraftView } from "./draft-view"; +import { advanceCostStat, applyUsageFetch, createCostStatHold } from "./header-stats"; +import type { CostStatDisplay } from "./header-stats"; import { buildInputHistory } from "./input-history"; import { buildOutline } from "./outline-model"; import { GoalStatusBanner } from "./goal-banner"; @@ -149,9 +150,9 @@ function SessionElapsed({ /** The header's three statistics — the chip row and the info dropdown render these verbatim. */ interface HeaderStats { tokensText: string; - /** Formatted session cost; null = nothing to show (no recorded figure and no live estimate). */ + /** Formatted session cost; null = nothing to show yet for this session (see header-stats.ts). */ costText: string | null; - /** Server-reported "some usage had no pricing" flag from the last idle refetch (the chip's `*`). */ + /** Server-reported "some usage had no pricing" flag riding the shown figure (the chip's `*`). */ costUncosted: boolean; elapsedNode: ReactNode; } @@ -159,47 +160,19 @@ interface HeaderStats { /** * Computes the header statistics, live while a Task runs: * - Tokens: session cumulative (main + subagents), already advancing per completed request; - * - Cost: the last idle-refetched session cost, plus — while a Task is open and the session - * Model has pricing — a live estimate converted from this Task's usage buckets. Subagents - * may run on different models, but the estimate applies the main Model's pricing to all - * live buckets: the estimate may be slightly off mid-task, and the idle getUsage refetch - * reconciles to the server-recorded value (kept deliberately simple). Without pricing the - * live addition is skipped (bucketCostUsd returns null, mirroring taskCost's uncosted - * signal) and the value stays exactly as when idle. costText stays null until there is an - * actual figure — no recorded cost plus a still-zero estimate renders nothing, not $0.00; + * - Cost: advanceCostStat's display value — the fetched session cost plus a client-settled + * live estimate that carries across Task boundaries, sticky once shown (semantics + * documented in header-stats.ts). The live estimate applies the main Model's pricing to + * all of the open Task's buckets (subagents may run on different models), so it can be + * slightly off mid-task; usage fetches reconcile it to the server-recorded value; * - Elapsed: ticking cumulative while running, settled cumulative when idle (SessionElapsed). */ -function headerStats( - model: StreamModel, - sessionCost: number | null, - costUncosted: boolean, - pricing: BucketPricing | undefined, - currency: "USD" | "CNY", -): HeaderStats { +function headerStats(model: StreamModel, cost: CostStatDisplay): HeaderStats { const stats = model.stats; - const liveCost = model.taskOpen - ? bucketCostUsd( - { - cacheRead: stats.taskCacheRead, - cacheWrite: stats.taskCacheWrite, - output: stats.taskOutput, - }, - pricing, - ) - : null; - // Only take the live path once it has something to say — a positive estimate, or a recorded - // session cost to keep showing from the Task's first instant. On a brand-new session, - // sessionCost is still null and liveCost is 0 until the first token_usage lands; blindly - // summing would flash a formatted $0.00 the moment the Task starts — exactly the "cost is - // zero or something's broken" reading the chip's render conditional exists to avoid. - const costUsd = - liveCost != null && (liveCost > 0 || sessionCost != null) - ? (sessionCost ?? 0) + liveCost - : sessionCost; return { tokensText: humanizeTokens(stats.sessionTotal + stats.subagentTotal), - costText: costUsd != null ? formatMoney(costUsd, currency) : null, - costUncosted, + costText: cost.costText, + costUncosted: cost.costUncosted, elapsedNode: ( (null); - const [costUncosted, setCostUncosted] = useState(false); + // Cost chip state lives in a mutable per-session hold (header-stats.ts, pure/unit-tested): + // the fetched session cost + a client-settled live base + the last shown value, advanced once + // per render by advanceCostStat (which also resets it on session switch). usageAppliedRef + // marks the session whose usage fetch has applied ("initial fetch done"); usageStamp only + // forces a repaint when a fetch resolves outside the stream's own version bumps. + const costHoldRef = useRef(createCostStatHold()); + const usageAppliedRef = useRef(null); + const [, bumpUsageStamp] = useState(0); const [credentialGuide, setCredentialGuide] = useState(false); const [infoOpen, setInfoOpen] = useState(false); const [modeSaving, setModeSaving] = useState(false); @@ -487,12 +466,12 @@ export function ChatPage() { // create the same path, so its summary must re-check instead of inheriting stale false state. const statCacheRef = useRef(new Map>()); - // Session switch: resets the cost, the file-card existence cache, and the per-turn thinking - // level (it's per-session UI state), avoiding stale data from the previous Session (Files - // panel state resets itself keyed on sessionId inside use-files-panel). + // Session switch: resets the usage-fetch marker, the file-card existence cache, and the + // per-turn thinking level (it's per-session UI state), avoiding stale data from the previous + // Session (Files panel state resets itself keyed on sessionId inside use-files-panel, and the + // cost hold re-keys itself on sessionId inside advanceCostStat). useEffect(() => { - setSessionCost(null); - setCostUncosted(false); + usageAppliedRef.current = null; setTurnThinkingLevel(""); statCacheRef.current = new Map(); }, [routeSessionId]); @@ -535,17 +514,27 @@ export function ChatPage() { [selected?.sessionId], ); - // Session's cumulative cost: refreshed on entry and every time it returns to idle (cost is computed by the server in real time based on current pricing). + // Session's cumulative cost (priced by the server in real time from its usage rows): fetched + // once when the session becomes selected — even mid-run, so a page load during an active run + // recovers the already-accrued total instead of waiting for idle — then refreshed on every + // return to idle (the authoritative reconcile, as before). usageAppliedRef marks the initial + // fetch done only when a response applies, so a cancelled/failed attempt retries on the next + // transition rather than polling. The cancelled flag doubles as a staleness guard: any + // task-state change re-runs the effect and discards an in-flight response fetched under the + // previous run state (whose total would misalign with the live buckets it is snapshotted + // against — see applyUsageFetch). useEffect(() => { - if (!projectId || !selected || stream.taskState !== "idle") return; + if (!projectId || !selected) return; + if (usageAppliedRef.current === selected.sessionId && stream.taskState !== "idle") return; let cancelled = false; api .getUsage(projectId, { groupBy: "session", agentId: selected.agentId }) .then((res) => { if (cancelled) return; + usageAppliedRef.current = selected.sessionId; const row = res.groups.find((g) => g.key === selected.sessionId); - setSessionCost(row?.cost ?? null); - setCostUncosted(row?.hasUncosted ?? false); + applyUsageFetch(costHoldRef.current, selected.sessionId, row ?? null); + bumpUsageStamp((n) => n + 1); }) .catch(() => undefined); return () => { @@ -882,8 +871,30 @@ export function ChatPage() { } // Header statistics (chip row + info dropdown), live while a Task runs; recomputed every - // stream version bump, so the in-place-mutated model stats always read fresh. - const hs = headerStats(stream.model, sessionCost, costUncosted, modelPricing, currency); + // stream version bump, so the in-place-mutated model stats always read fresh. The cost chip + // advances its per-session hold with this render's observation (idempotent per observation, + // so a replayed render converges — see header-stats.ts). + const liveTaskUsd = stream.model.taskOpen + ? bucketCostUsd( + { + cacheRead: stream.model.stats.taskCacheRead, + cacheWrite: stream.model.stats.taskCacheWrite, + output: stream.model.stats.taskOutput, + }, + modelPricing, + ) + : null; + const hs = headerStats( + stream.model, + advanceCostStat(costHoldRef.current, { + sessionId: selected?.sessionId ?? null, + taskCount, + taskOpen: stream.model.taskOpen, + loading: stream.loading, + liveUsd: liveTaskUsd, + currency, + }), + ); const modelInfo = models?.models.find((m) => sameModelRef(m, activeModelRef)); const contextWindow = modelInfo?.contextWindow; // Assumed supported by default: only models explicitly marked vision=false show a blocking hint when adding images. diff --git a/packages/web/src/features/chat/header-stats.ts b/packages/web/src/features/chat/header-stats.ts new file mode 100644 index 0000000..eb2ab09 --- /dev/null +++ b/packages/web/src/features/chat/header-stats.ts @@ -0,0 +1,209 @@ +/** + * Chat toolbar cost chip: a per-session tracker that keeps the figure visible and monotone + * while the session runs. Pure, no React — the chat page holds the mutable hold in a ref, + * advances it once per render (idempotent per observation), and applies usage fetches to it + * from the fetch effect; unit-tested in test/header-stats.test.ts. + * + * Why it exists: the naive `fetched sessionCost + open Task's live estimate` display blinked + * out whenever both halves went empty at once — every goal-round boundary zeroes the live + * buckets while the server keeps the session `running` (so an idle-gated refetch never ran), + * a reload mid-run started with no fetched cost at all, and a refetch resolving in the idle + * blip between queued follow-ups could return no row and clobber a known figure with null. + * + * Chosen semantics — the displayed figure is + * + * sessionCost (last applied fetch: absorbs everything recorded up to its resolve) + * + settledUsd (live cost of Tasks finished SINCE that fetch, folded at each Task + * boundary — goal rounds keep the running total instead of restarting) + * + max(0, open Task's live estimate − liveAtFetchUsd) + * + * where liveAtFetchUsd snapshots the open Task's live estimate at the moment a fetch applies: + * the server prices usage rows in real time, so the fetched total already covers the running + * Task's rows recorded so far, and only increments past the snapshot add on top — a mid-run + * fetch reconciles the base without double-counting the running Task. A small transient skew + * bounded by estimate-vs-server variance remains (the estimate applies the main Model's + * pricing to all buckets); the next idle refetch settles it, exactly as it always has. + * + * Display rules on top of the sum: + * - once a figure was shown for a session it never disappears: when the computation has + * nothing (pricing transiently missing, model mid-rebuild) the last shown value holds; + * - while a Task is open the figure never goes down — a fetch reconciling below the live + * estimate holds the shown value until the sum passes it again; when idle the computed + * figure applies verbatim (the authoritative reconcile may adjust either way, as the + * idle refetch always has); + * - a brand-new session with no recorded cost and a still-zero estimate shows nothing + * (not $0.00), and a recorded zero-cost session still shows $0 — both unchanged; + * - everything resets only on session switch (advanceCostStat re-keys on sessionId). + */ +import { formatMoney } from "../../lib/format"; + +/** The two fields the tracker reads off a getUsage session row (structural subset of UsageGroupRow). */ +export interface UsageCostRow { + cost: number | null; + hasUncosted: boolean; +} + +/** Mutable tracker state, held in a ref by the chat page. All money fields are USD. */ +export interface CostStatHold { + sessionId: string | null; + /** Task-start count last observed (taskStartCount over the stream items); an increase IS a Task boundary. */ + taskCount: number; + /** Cost from the last applied usage fetch; null until one applies. Never reset to null mid-session. */ + sessionCost: number | null; + /** Server-reported "some usage had no pricing" flag riding sessionCost (the chip's `*`). */ + costUncosted: boolean; + /** Live cost of Tasks finished since the last applied fetch (client-settled base on top of sessionCost). */ + settledUsd: number; + /** The open Task's latest observed live estimate — the fold source at the next boundary. */ + lastLiveUsd: number; + /** Portion of the open Task's live estimate already absorbed by the last applied fetch. */ + liveAtFetchUsd: number; + /** A fetch applied since the last observation: snapshot liveAtFetchUsd on the next one. */ + pendingFetchSnapshot: boolean; + /** Last displayed value — the sticky fallback and the monotone floor while running. */ + shownUsd: number | null; + /** The `*` flag shown alongside shownUsd (kept with it on the sticky path). */ + shownUncosted: boolean; +} + +export function createCostStatHold(): CostStatHold { + return { + sessionId: null, + taskCount: 0, + sessionCost: null, + costUncosted: false, + settledUsd: 0, + lastLiveUsd: 0, + liveAtFetchUsd: 0, + pendingFetchSnapshot: false, + shownUsd: null, + shownUncosted: false, + }; +} + +/** + * Applies one resolved getUsage row for `sessionId` (null = the response had no row for it). + * A missing row or a null cost never clobbers a known figure — the server simply has nothing + * (new) priced to report; only the `*` flag updates when a row exists. A priced cost replaces + * the base and absorbs the settled Tasks (and, via the snapshot taken on the next observation, + * the open Task's live-so-far). Ignores a resolve that raced a session switch. + */ +export function applyUsageFetch( + hold: CostStatHold, + sessionId: string, + row: UsageCostRow | null, +): void { + if (hold.sessionId !== sessionId) return; + if (row === null) return; + hold.costUncosted = row.hasUncosted; + if (row.cost == null) return; + hold.sessionCost = row.cost; + hold.settledUsd = 0; + hold.pendingFetchSnapshot = true; +} + +/** One per-render observation of the selected session's stream. */ +export interface CostStatObservation { + sessionId: string | null; + /** taskStartCount over the stream items (1:1 with the model's startTask calls). */ + taskCount: number; + taskOpen: boolean; + /** History (re)load in progress: the model emits placeholder values — display freezes, no folds. */ + loading: boolean; + /** bucketCostUsd of the open Task's live buckets; null with no open Task or no pricing. */ + liveUsd: number | null; + currency: "USD" | "CNY"; +} + +/** What the chip renders: null costText = nothing to show yet for this session. */ +export interface CostStatDisplay { + costText: string | null; + costUncosted: boolean; +} + +/** sessionCost + settled Tasks; null while neither has anything (chip hidden on a fresh session). */ +function baseUsd(hold: CostStatHold): number | null { + return hold.sessionCost != null || hold.settledUsd > 0 + ? (hold.sessionCost ?? 0) + hold.settledUsd + : null; +} + +/** Settles the display value into the hold and formats it. */ +function show(hold: CostStatHold, usd: number | null, uncosted: boolean, currency: "USD" | "CNY") { + if (usd != null) { + hold.shownUsd = usd; + hold.shownUncosted = uncosted; + } + return { + costText: usd != null ? formatMoney(usd, currency) : null, + costUncosted: uncosted, + }; +} + +/** + * Advances the hold with one observation and returns what the chip shows. Idempotent for a + * repeated observation (safe under StrictMode double renders); the hold self-resets when the + * observed sessionId changes, which is the ONLY reset. + */ +export function advanceCostStat(hold: CostStatHold, obs: CostStatObservation): CostStatDisplay { + if (obs.sessionId !== hold.sessionId) { + Object.assign(hold, createCostStatHold(), { sessionId: obs.sessionId }); + } + if (obs.loading) { + // A (re)loading model emits placeholder observations (empty items, closed task): fold and + // snapshot decisions wait for the rebuilt model, and the display freezes on the last shown + // value so a mid-run reload never blanks or dips the chip. Before anything was shown, a + // fetch that resolved faster than history may already have a base worth showing. + const usd = hold.shownUsd ?? baseUsd(hold); + const uncosted = hold.shownUsd != null ? hold.shownUncosted : hold.costUncosted; + return show(hold, usd, uncosted, obs.currency); + } + // Rebuild re-baseline: fewer Task starts than seen before means history was rewritten under + // the same session (compaction resync). Live tracking restarts from the rebuilt model — + // deliberately no fold: the replayed open Task's buckets already carry what lastLiveUsd held. + if (obs.taskCount < hold.taskCount) { + hold.taskCount = obs.taskCount; + hold.lastLiveUsd = 0; + hold.liveAtFetchUsd = 0; + } + // A fetch applied since the last observation: its total covers everything recorded up to its + // resolve, so the open Task's live-so-far is absorbed — snapshot it as the subtrahend and + // only count increments past this point. With pricing transiently missing (liveUsd null while + // open) the snapshot waits: taking 0 now would double-count the Task once pricing arrives. + if (hold.pendingFetchSnapshot && (!obs.taskOpen || obs.liveUsd != null)) { + hold.pendingFetchSnapshot = false; + const live = obs.taskOpen ? (obs.liveUsd ?? 0) : 0; + hold.liveAtFetchUsd = live; + hold.lastLiveUsd = live; + } + // Task boundary — a new Task started (goal rounds included) or the open one closed: fold the + // finished Task's un-absorbed live remainder into the settled base, so the total carries + // across the boundary instead of restarting from the zeroed buckets. + if ( + obs.taskCount > hold.taskCount || + (!obs.taskOpen && (hold.lastLiveUsd > 0 || hold.liveAtFetchUsd > 0)) + ) { + hold.settledUsd += Math.max(0, hold.lastLiveUsd - hold.liveAtFetchUsd); + hold.lastLiveUsd = 0; + hold.liveAtFetchUsd = 0; + } + hold.taskCount = obs.taskCount; + if (obs.taskOpen && obs.liveUsd != null) hold.lastLiveUsd = obs.liveUsd; + + const base = baseUsd(hold); + const liveAdd = + obs.taskOpen && obs.liveUsd != null ? Math.max(0, obs.liveUsd - hold.liveAtFetchUsd) : null; + // Same gate as the pre-tracker formula: a brand-new session (no base) with a still-zero live + // estimate shows nothing rather than flashing a formatted $0.00 the moment the Task starts. + const computed = liveAdd != null && (liveAdd > 0 || base != null) ? (base ?? 0) + liveAdd : base; + let usd: number | null; + if (computed == null) { + usd = hold.shownUsd; // sticky: nothing computable must not hide an already-shown figure + } else if (obs.taskOpen && hold.shownUsd != null && computed < hold.shownUsd) { + usd = hold.shownUsd; // monotone while running: hold until the sum passes the shown value + } else { + usd = computed; // idle applies verbatim — the authoritative reconcile may adjust either way + } + const uncosted = computed == null ? hold.shownUncosted : hold.costUncosted; + return show(hold, usd, uncosted, obs.currency); +} diff --git a/packages/web/test/header-stats.test.ts b/packages/web/test/header-stats.test.ts new file mode 100644 index 0000000..40b452c --- /dev/null +++ b/packages/web/test/header-stats.test.ts @@ -0,0 +1,139 @@ +/** + * advanceCostStat / applyUsageFetch unit tests: the chat toolbar's cost chip must never + * disappear (or visibly dip and recover) for a session once shown — across Task boundaries + * (goal rounds zero the live buckets while the server stays `running`), across refetches + * that return no row (the idle blip between queued follow-ups), and across transient + * pricing/model gaps — while a mid-run fetch reconciles the base without double-counting + * the running Task (live-at-fetch snapshot). Semantics documented in + * src/features/chat/header-stats.ts; the chat page feeds one observation per render. + */ +import { describe, expect, it } from "vitest"; +import { + advanceCostStat, + applyUsageFetch, + createCostStatHold, +} from "../src/features/chat/header-stats"; +import type { CostStatObservation } from "../src/features/chat/header-stats"; + +const obs = (over: Partial = {}): CostStatObservation => ({ + sessionId: "s1", + taskCount: 1, + taskOpen: false, + loading: false, + liveUsd: null, + currency: "USD", + ...over, +}); + +describe("advanceCostStat (chat header cost chip)", () => { + it("keeps the running total across a goal-round boundary (buckets zeroed, no session cost yet)", () => { + const hold = createCostStatHold(); + // Round 1 accrues live cost on a fresh session (nothing fetched — the goal keeps it running). + expect(advanceCostStat(hold, obs({ taskOpen: true, liveUsd: 0.3 })).costText).toBe("$0.3000"); + // Round boundary: the [goal round] user text starts a new Task in the same batch — the + // client sees taskCount+1 with the live buckets already reset to zero. + expect(advanceCostStat(hold, obs({ taskCount: 2, taskOpen: true, liveUsd: 0 })).costText).toBe( + "$0.3000", + ); + // The next round's usage adds on top of the settled rounds instead of restarting from zero. + expect( + advanceCostStat(hold, obs({ taskCount: 2, taskOpen: true, liveUsd: 0.05 })).costText, + ).toBe("$0.3500"); + // A plain close (taskOpen true→false, buckets zeroed by endTask) keeps it too. + expect(advanceCostStat(hold, obs({ taskCount: 2 })).costText).toBe("$0.3500"); + // And the Task after that starts from the settled total, not from its own zero. + expect(advanceCostStat(hold, obs({ taskCount: 3, taskOpen: true, liveUsd: 0 })).costText).toBe( + "$0.3500", + ); + }); + + it("a refetch returning no row (or a null cost) never clobbers a known figure", () => { + const hold = createCostStatHold(); + advanceCostStat(hold, obs()); + applyUsageFetch(hold, "s1", { cost: 0.5, hasUncosted: false }); + expect(advanceCostStat(hold, obs()).costText).toBe("$0.5000"); + // Idle blip between queued follow-ups: the refetch resolves with no row for the session. + applyUsageFetch(hold, "s1", null); + expect(advanceCostStat(hold, obs()).costText).toBe("$0.5000"); + // An all-uncosted row (cost null) keeps the figure as well; only the * flag updates. + applyUsageFetch(hold, "s1", { cost: null, hasUncosted: true }); + const shown = advanceCostStat(hold, obs()); + expect(shown.costText).toBe("$0.5000"); + expect(shown.costUncosted).toBe(true); + }); + + it("preserves the * uncosted marker across boundaries and sticky fallbacks", () => { + const hold = createCostStatHold(); + advanceCostStat(hold, obs()); + applyUsageFetch(hold, "s1", { cost: 0.5, hasUncosted: true }); + expect(advanceCostStat(hold, obs())).toEqual({ costText: "$0.5000", costUncosted: true }); + // A new Task with pricing missing (liveUsd null): the figure persists with its flag. + expect(advanceCostStat(hold, obs({ taskCount: 2, taskOpen: true }))).toEqual({ + costText: "$0.5000", + costUncosted: true, + }); + }); + + it("a recorded zero-cost session still shows $0; a fresh zero estimate still shows nothing", () => { + const hold = createCostStatHold(); + // Brand-new session, Task just started, no usage yet: no flashed $0.00 (unchanged behavior). + expect(advanceCostStat(hold, obs({ taskOpen: true, liveUsd: 0 })).costText).toBeNull(); + applyUsageFetch(hold, "s1", { cost: 0, hasUncosted: false }); + expect(advanceCostStat(hold, obs()).costText).toBe("$0"); + }); + + it("losing pricing mid-run falls back to the last shown value instead of hiding", () => { + const hold = createCostStatHold(); + expect(advanceCostStat(hold, obs({ taskOpen: true, liveUsd: 0.3 })).costText).toBe("$0.3000"); + // The models response is transiently gone (bucketCostUsd returns null without pricing). + expect(advanceCostStat(hold, obs({ taskOpen: true, liveUsd: null })).costText).toBe("$0.3000"); + // Pricing returns: the live estimate resumes from the real buckets. + expect(advanceCostStat(hold, obs({ taskOpen: true, liveUsd: 0.4 })).costText).toBe("$0.4000"); + }); + + it("a mid-run fetch absorbs the Task's live-so-far: reconciled base plus increments only", () => { + const hold = createCostStatHold(); + // Reload during an active run: history replays 0.4 of live cost, then the initial fetch + // resolves with the server total (which already includes those rows). + expect(advanceCostStat(hold, obs({ taskOpen: true, liveUsd: 0.4 })).costText).toBe("$0.4000"); + applyUsageFetch(hold, "s1", { cost: 1.0, hasUncosted: false }); + // Not $1.40 — the snapshot subtracts the absorbed live-so-far. + expect(advanceCostStat(hold, obs({ taskOpen: true, liveUsd: 0.4 })).costText).toBe("$1.00"); + expect(advanceCostStat(hold, obs({ taskOpen: true, liveUsd: 0.5 })).costText).toBe("$1.10"); + // At the boundary only the un-absorbed remainder folds into the settled base. + expect(advanceCostStat(hold, obs({ taskCount: 2, taskOpen: true, liveUsd: 0 })).costText).toBe( + "$1.10", + ); + }); + + it("never dips while running, even when a fetch reconciles below the live estimate", () => { + const hold = createCostStatHold(); + expect(advanceCostStat(hold, obs({ taskOpen: true, liveUsd: 0.4 })).costText).toBe("$0.4000"); + // Server-priced total is lower than the client estimate (subagents on cheaper models). + applyUsageFetch(hold, "s1", { cost: 0.3, hasUncosted: false }); + expect(advanceCostStat(hold, obs({ taskOpen: true, liveUsd: 0.4 })).costText).toBe("$0.4000"); + // Once idle, the authoritative figure applies verbatim (may adjust downward, as the idle + // refetch always has) and stays. + expect(advanceCostStat(hold, obs()).costText).toBe("$0.3000"); + }); + + it("freezes on the shown value during a history (re)load and resumes cleanly after", () => { + const hold = createCostStatHold(); + expect(advanceCostStat(hold, obs({ taskOpen: true, liveUsd: 0.4 })).costText).toBe("$0.4000"); + // Mid-run reconnect rebuild: the loading model reports an empty, closed placeholder. + expect(advanceCostStat(hold, obs({ taskCount: 0, loading: true })).costText).toBe("$0.4000"); + // The rebuilt model replays the same open Task; no fold happened, so nothing double-counts. + expect(advanceCostStat(hold, obs({ taskOpen: true, liveUsd: 0.45 })).costText).toBe("$0.4500"); + }); + + it("resets only on session switch", () => { + const hold = createCostStatHold(); + advanceCostStat(hold, obs()); + applyUsageFetch(hold, "s1", { cost: 0.5, hasUncosted: false }); + expect(advanceCostStat(hold, obs()).costText).toBe("$0.5000"); + expect(advanceCostStat(hold, obs({ sessionId: "s2" })).costText).toBeNull(); + // A stale resolve for the previous session is ignored after the switch. + applyUsageFetch(hold, "s1", { cost: 9, hasUncosted: false }); + expect(advanceCostStat(hold, obs({ sessionId: "s2" })).costText).toBeNull(); + }); +});