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:
1 parent
5b6b8905b1
commit
0f58744432
3 files changed
+412
-53
No files matched your search
@@ -33,7 +33,6 @@ import { apiErrorText } from "../../lib/api-error";
|
|||||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||||
import {
|
import {
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
formatMoney,
|
|
||||||
humanizeDuration,
|
humanizeDuration,
|
||||||
humanizeDurationLive,
|
humanizeDurationLive,
|
||||||
humanizeTokens,
|
humanizeTokens,
|
||||||
@@ -42,7 +41,7 @@ import { latestConversation } from "../../lib/session-grouping";
|
|||||||
import { approvalKey, isModelAuthDead } from "../../lib/omni/stream-model";
|
import { approvalKey, isModelAuthDead } from "../../lib/omni/stream-model";
|
||||||
import type { StreamModel } from "../../lib/omni/stream-model";
|
import type { StreamModel } from "../../lib/omni/stream-model";
|
||||||
import { bucketCostUsd, liveSessionElapsedMs } from "../../lib/omni/task-stats";
|
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 { useTheme } from "../../state/theme";
|
||||||
import { useProject } from "../../state/project";
|
import { useProject } from "../../state/project";
|
||||||
import { useSessions } from "../../state/sessions";
|
import { useSessions } from "../../state/sessions";
|
||||||
@@ -59,6 +58,8 @@ import { latestTaskHasSubagent, taskStartCount } from "./agent-topology";
|
|||||||
import { ChatInput } from "./chat-input";
|
import { ChatInput } from "./chat-input";
|
||||||
import { ConversationOutline, OutlineMenuButton, useOutlineRailFit } from "./conversation-outline";
|
import { ConversationOutline, OutlineMenuButton, useOutlineRailFit } from "./conversation-outline";
|
||||||
import { DraftView } from "./draft-view";
|
import { DraftView } from "./draft-view";
|
||||||
|
import { advanceCostStat, applyUsageFetch, createCostStatHold } from "./header-stats";
|
||||||
|
import type { CostStatDisplay } from "./header-stats";
|
||||||
import { buildInputHistory } from "./input-history";
|
import { buildInputHistory } from "./input-history";
|
||||||
import { buildOutline } from "./outline-model";
|
import { buildOutline } from "./outline-model";
|
||||||
import { GoalStatusBanner } from "./goal-banner";
|
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. */
|
/** The header's three statistics — the chip row and the info dropdown render these verbatim. */
|
||||||
interface HeaderStats {
|
interface HeaderStats {
|
||||||
tokensText: string;
|
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;
|
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;
|
costUncosted: boolean;
|
||||||
elapsedNode: ReactNode;
|
elapsedNode: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -159,47 +160,19 @@ interface HeaderStats {
|
|||||||
/**
|
/**
|
||||||
* Computes the header statistics, live while a Task runs:
|
* Computes the header statistics, live while a Task runs:
|
||||||
* - Tokens: session cumulative (main + subagents), already advancing per completed request;
|
* - 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
|
* - Cost: advanceCostStat's display value — the fetched session cost plus a client-settled
|
||||||
* Model has pricing — a live estimate converted from this Task's usage buckets. Subagents
|
* live estimate that carries across Task boundaries, sticky once shown (semantics
|
||||||
* may run on different models, but the estimate applies the main Model's pricing to all
|
* documented in header-stats.ts). The live estimate applies the main Model's pricing to
|
||||||
* live buckets: the estimate may be slightly off mid-task, and the idle getUsage refetch
|
* all of the open Task's buckets (subagents may run on different models), so it can be
|
||||||
* reconciles to the server-recorded value (kept deliberately simple). Without pricing the
|
* slightly off mid-task; usage fetches reconcile it to the server-recorded value;
|
||||||
* 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;
|
|
||||||
* - Elapsed: ticking cumulative while running, settled cumulative when idle (SessionElapsed).
|
* - Elapsed: ticking cumulative while running, settled cumulative when idle (SessionElapsed).
|
||||||
*/
|
*/
|
||||||
function headerStats(
|
function headerStats(model: StreamModel, cost: CostStatDisplay): HeaderStats {
|
||||||
model: StreamModel,
|
|
||||||
sessionCost: number | null,
|
|
||||||
costUncosted: boolean,
|
|
||||||
pricing: BucketPricing | undefined,
|
|
||||||
currency: "USD" | "CNY",
|
|
||||||
): HeaderStats {
|
|
||||||
const stats = model.stats;
|
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 {
|
return {
|
||||||
tokensText: humanizeTokens(stats.sessionTotal + stats.subagentTotal),
|
tokensText: humanizeTokens(stats.sessionTotal + stats.subagentTotal),
|
||||||
costText: costUsd != null ? formatMoney(costUsd, currency) : null,
|
costText: cost.costText,
|
||||||
costUncosted,
|
costUncosted: cost.costUncosted,
|
||||||
elapsedNode: (
|
elapsedNode: (
|
||||||
<SessionElapsed
|
<SessionElapsed
|
||||||
stats={stats}
|
stats={stats}
|
||||||
@@ -242,8 +215,14 @@ export function ChatPage() {
|
|||||||
setTitle,
|
setTitle,
|
||||||
} = useSessions();
|
} = useSessions();
|
||||||
|
|
||||||
const [sessionCost, setSessionCost] = useState<number | null>(null);
|
// Cost chip state lives in a mutable per-session hold (header-stats.ts, pure/unit-tested):
|
||||||
const [costUncosted, setCostUncosted] = useState(false);
|
// 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 [credentialGuide, setCredentialGuide] = useState(false);
|
||||||
const [infoOpen, setInfoOpen] = useState(false);
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
const [modeSaving, setModeSaving] = 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.
|
// 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>>());
|
const statCacheRef = useRef(new Map<string, true | Promise<boolean>>());
|
||||||
|
|
||||||
// Session switch: resets the cost, the file-card existence cache, and the per-turn thinking
|
// Session switch: resets the usage-fetch marker, the file-card existence cache, and the
|
||||||
// level (it's per-session UI state), avoiding stale data from the previous Session (Files
|
// per-turn thinking level (it's per-session UI state), avoiding stale data from the previous
|
||||||
// panel state resets itself keyed on sessionId inside use-files-panel).
|
// 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(() => {
|
useEffect(() => {
|
||||||
setSessionCost(null);
|
usageAppliedRef.current = null;
|
||||||
setCostUncosted(false);
|
|
||||||
setTurnThinkingLevel("");
|
setTurnThinkingLevel("");
|
||||||
statCacheRef.current = new Map();
|
statCacheRef.current = new Map();
|
||||||
}, [routeSessionId]);
|
}, [routeSessionId]);
|
||||||
@@ -535,17 +514,27 @@ export function ChatPage() {
|
|||||||
[selected?.sessionId],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!projectId || !selected || stream.taskState !== "idle") return;
|
if (!projectId || !selected) return;
|
||||||
|
if (usageAppliedRef.current === selected.sessionId && stream.taskState !== "idle") return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
api
|
api
|
||||||
.getUsage(projectId, { groupBy: "session", agentId: selected.agentId })
|
.getUsage(projectId, { groupBy: "session", agentId: selected.agentId })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
usageAppliedRef.current = selected.sessionId;
|
||||||
const row = res.groups.find((g) => g.key === selected.sessionId);
|
const row = res.groups.find((g) => g.key === selected.sessionId);
|
||||||
setSessionCost(row?.cost ?? null);
|
applyUsageFetch(costHoldRef.current, selected.sessionId, row ?? null);
|
||||||
setCostUncosted(row?.hasUncosted ?? false);
|
bumpUsageStamp((n) => n + 1);
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -882,8 +871,30 @@ export function ChatPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Header statistics (chip row + info dropdown), live while a Task runs; recomputed every
|
// 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.
|
// stream version bump, so the in-place-mutated model stats always read fresh. The cost chip
|
||||||
const hs = headerStats(stream.model, sessionCost, costUncosted, modelPricing, currency);
|
// 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 modelInfo = models?.models.find((m) => sameModelRef(m, activeModelRef));
|
||||||
const contextWindow = modelInfo?.contextWindow;
|
const contextWindow = modelInfo?.contextWindow;
|
||||||
// Assumed supported by default: only models explicitly marked vision=false show a blocking hint when adding images.
|
// 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