fix(web): keep the chat cost stat visible across task boundaries and mid-run loads (#188)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-04 19:29:28 +08:00
committed by GitHub
parent 5b6b8905b1
commit 0f58744432
3 changed files with 412 additions and 53 deletions
+64 -53
View File
@@ -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: (
<SessionElapsed
stats={stats}
@@ -242,8 +215,14 @@ export function ChatPage() {
setTitle,
} = useSessions();
const [sessionCost, setSessionCost] = useState<number | null>(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<string | null>(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<string, true | Promise<boolean>>());
// 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.
@@ -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);
}
+139
View File
@@ -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> = {}): 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();
});
});