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:
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user