feat(web): live header statistics while a task runs (#75)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a955046b73
commit
83e7b85eca
4 files changed
+235
-26
No files matched your search
@@ -59,6 +59,14 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page })
|
|||||||
await expect(page.getByText("exec_command").first()).toBeVisible();
|
await expect(page.getByText("exec_command").first()).toBeVisible();
|
||||||
// Thinking + tool calls are wrapped in a work group; header shows running/done status.
|
// Thinking + tool calls are wrapped in a work group; header shows running/done status.
|
||||||
await expect(page.getByText("运行中").first()).toBeVisible();
|
await expect(page.getByText("运行中").first()).toBeVisible();
|
||||||
|
|
||||||
|
// Live header statistics: the elapsed chip ticks once per second while the task runs (the
|
||||||
|
// pending approval below keeps it running), so its text must advance with no further server
|
||||||
|
// event. Scoped to the header stats container (div.hidden …): the per-reply footer reuses
|
||||||
|
// the same 用时 ("elapsed") label once the turn's stats line lands.
|
||||||
|
const headerElapsed = page.locator('div.hidden span[title="用时"]');
|
||||||
|
const elapsedBefore = await headerElapsed.textContent();
|
||||||
|
await expect(headerElapsed).not.toHaveText(elapsedBefore);
|
||||||
// The user takes control of the running work group (toggle = userToggled), keeps it open, and
|
// The user takes control of the running work group (toggle = userToggled), keeps it open, and
|
||||||
// opens the exec_command card to watch the arguments. Both must survive the end of the turn.
|
// opens the exec_command card to watch the arguments. Both must survive the end of the turn.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
* created. The Session list and the new-chat entry point live in the global sidebar.
|
* created. The Session list and the new-chat entry point live in the global sidebar.
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import type {
|
import type {
|
||||||
AgentSummary,
|
AgentSummary,
|
||||||
@@ -27,9 +28,18 @@ import { ApiError } from "../../api/client";
|
|||||||
import { S } from "../../lib/strings";
|
import { S } from "../../lib/strings";
|
||||||
import { apiErrorText } from "../../lib/api-error";
|
import { apiErrorText } from "../../lib/api-error";
|
||||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||||
import { formatDateTime, formatMoney, humanizeDuration, humanizeTokens } from "../../lib/format";
|
import {
|
||||||
|
formatDateTime,
|
||||||
|
formatMoney,
|
||||||
|
humanizeDuration,
|
||||||
|
humanizeDurationLive,
|
||||||
|
humanizeTokens,
|
||||||
|
} from "../../lib/format";
|
||||||
import { latestConversation } from "../../lib/session-grouping";
|
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 { bucketCostUsd, liveSessionElapsedMs } from "../../lib/omni/task-stats";
|
||||||
|
import type { BucketPricing, 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";
|
||||||
@@ -65,7 +75,7 @@ const STAT_ICONS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Iconized stat item: a symbol + a value, with the title giving the full meaning. */
|
/** Iconized stat item: a symbol + a value, with the title giving the full meaning. */
|
||||||
function StatChip({ icon, value, label }: { icon: string; value: string; label: string }) {
|
function StatChip({ icon, value, label }: { icon: string; value: ReactNode; label: string }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
title={label}
|
title={label}
|
||||||
@@ -89,6 +99,101 @@ function StatChip({ icon, value, label }: { icon: string; value: string; label:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elapsed value for the header statistics: while a Task runs it ticks once per second over the
|
||||||
|
* live cumulative (settled cross-Task total + the running Task's wall clock so far, see
|
||||||
|
* liveSessionElapsedMs); when idle no timer runs and it renders exactly the settled total.
|
||||||
|
* Whole seconds while ticking, decimals only on the settled value — same convention as
|
||||||
|
* LiveDuration on running tool/thinking cards.
|
||||||
|
*/
|
||||||
|
function SessionElapsed({
|
||||||
|
stats,
|
||||||
|
taskOpen,
|
||||||
|
taskStartLocalMs,
|
||||||
|
}: {
|
||||||
|
stats: TaskStatsTracker;
|
||||||
|
taskOpen: boolean;
|
||||||
|
taskStartLocalMs: number;
|
||||||
|
}) {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskOpen) return;
|
||||||
|
// Load-bearing, not redundant: `now` still holds whatever the state last saw (mount time,
|
||||||
|
// or the final tick of a previous Task), and the first interval callback is a full second
|
||||||
|
// away. The first live render after a Task starts must not compute from that stale clock,
|
||||||
|
// so re-anchor immediately on entering the running state.
|
||||||
|
setNow(Date.now());
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [taskOpen]);
|
||||||
|
if (!taskOpen) return <>{humanizeDuration(stats.sessionElapsedMs)}</>;
|
||||||
|
return <>{humanizeDurationLive(liveSessionElapsedMs(stats, taskOpen, taskStartLocalMs, now))}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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). */
|
||||||
|
costText: string | null;
|
||||||
|
/** Server-reported "some usage had no pricing" flag from the last idle refetch (the chip's `*`). */
|
||||||
|
costUncosted: boolean;
|
||||||
|
elapsedNode: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
* - 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 {
|
||||||
|
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,
|
||||||
|
elapsedNode: (
|
||||||
|
<SessionElapsed
|
||||||
|
stats={stats}
|
||||||
|
taskOpen={model.taskOpen}
|
||||||
|
taskStartLocalMs={model.taskStartLocalMs}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route id for a draft chat (`/chat/new`): the Session hasn't been persisted yet — the user may
|
* Route id for a draft chat (`/chat/new`): the Session hasn't been persisted yet — the user may
|
||||||
* still want to change the model or configure a key first. The actual Session is only created
|
* still want to change the model or configure a key first. The actual Session is only created
|
||||||
@@ -611,16 +716,7 @@ export function ChatPage() {
|
|||||||
// happen mid-turn, and if only running were checked, the trailing group would flash
|
// happen mid-turn, and if only running were checked, the trailing group would flash
|
||||||
// "finished running" during compaction before flipping back to "running".
|
// "finished running" during compaction before flipping back to "running".
|
||||||
taskRunning: stream.taskState !== "idle",
|
taskRunning: stream.taskState !== "idle",
|
||||||
taskCost: (stats) => {
|
taskCost: (stats) => bucketCostUsd(stats.tokensByBucket, modelPricing),
|
||||||
if (!modelPricing) return null;
|
|
||||||
const b = stats.tokensByBucket;
|
|
||||||
return (
|
|
||||||
(b.cacheRead * modelPricing.cacheRead +
|
|
||||||
b.cacheWrite * modelPricing.cacheWrite +
|
|
||||||
b.output * modelPricing.output) /
|
|
||||||
1e6
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// Reconnect countdown controls (live waiting state only): retry-now skips the
|
// Reconnect countdown controls (live waiting state only): retry-now skips the
|
||||||
// remaining backoff server-side (benign no-op on timing races), give-up is the
|
// remaining backoff server-side (benign no-op on timing races), give-up is the
|
||||||
// ordinary session abort — the engine's abort-during-backoff path ends the turn.
|
// ordinary session abort — the engine's abort-during-backoff path ends the turn.
|
||||||
@@ -649,7 +745,9 @@ export function ChatPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalTokens = stream.model.stats.sessionTotal + stream.model.stats.subagentTotal;
|
// 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);
|
||||||
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.
|
||||||
@@ -742,24 +840,20 @@ export function ChatPage() {
|
|||||||
<div className="hidden items-center gap-3 sm:flex">
|
<div className="hidden items-center gap-3 sm:flex">
|
||||||
<StatChip
|
<StatChip
|
||||||
icon={STAT_ICONS.tokens}
|
icon={STAT_ICONS.tokens}
|
||||||
value={humanizeTokens(totalTokens)}
|
value={hs.tokensText}
|
||||||
label={`${S.chat.statTokens}(Token)`}
|
label={`${S.chat.statTokens}(Token)`}
|
||||||
/>
|
/>
|
||||||
{/* When there's no cost (the Model has no pricing configured), don't render this stat
|
{/* When there's no cost (the Model has no pricing configured), don't render this stat
|
||||||
at all, rather than showing a "—" — that would take up space while saying
|
at all, rather than showing a "—" — that would take up space while saying
|
||||||
nothing, only making people think the cost is zero or something's broken. */}
|
nothing, only making people think the cost is zero or something's broken. */}
|
||||||
{sessionCost != null && (
|
{hs.costText != null && (
|
||||||
<StatChip
|
<StatChip
|
||||||
icon={STAT_ICONS.cost}
|
icon={STAT_ICONS.cost}
|
||||||
value={`${formatMoney(sessionCost, currency)}${costUncosted ? " *" : ""}`}
|
value={`${hs.costText}${hs.costUncosted ? " *" : ""}`}
|
||||||
label={`${S.common.cost}(${currency})${costUncosted ? ` · ${S.usage.uncostedNote}` : ""}`}
|
label={`${S.common.cost}(${currency})${hs.costUncosted ? ` · ${S.usage.uncostedNote}` : ""}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<StatChip
|
<StatChip icon={STAT_ICONS.elapsed} value={hs.elapsedNode} label={S.chat.statElapsed} />
|
||||||
icon={STAT_ICONS.elapsed}
|
|
||||||
value={humanizeDuration(stream.model.stats.sessionElapsedMs)}
|
|
||||||
label={S.chat.statElapsed}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Files panel toggle: docks on the right of the chat instead of replacing it full-screen (use-files-panel.ts). */}
|
{/* Files panel toggle: docks on the right of the chat instead of replacing it full-screen (use-files-panel.ts). */}
|
||||||
@@ -848,10 +942,9 @@ export function ChatPage() {
|
|||||||
</p>
|
</p>
|
||||||
{/* Same as above: if there's no cost, the whole item is omitted, not left as "Cost —". */}
|
{/* Same as above: if there's no cost, the whole item is omitted, not left as "Cost —". */}
|
||||||
<p className="font-mono text-xs">
|
<p className="font-mono text-xs">
|
||||||
{S.chat.statTokens} {humanizeTokens(totalTokens)}
|
{S.chat.statTokens} {hs.tokensText}
|
||||||
{sessionCost != null &&
|
{hs.costText != null && ` · ${S.common.cost} ${hs.costText}`} ·{" "}
|
||||||
` · ${S.common.cost} ${formatMoney(sessionCost, currency)}`}{" "}
|
{S.chat.statElapsed} {hs.elapsedNode}
|
||||||
· {S.chat.statElapsed} {humanizeDuration(stream.model.stats.sessionElapsedMs)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,32 @@ export interface TokenBucketCounts {
|
|||||||
output: number;
|
output: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Three pricing buckets in USD per million tokens (mirrors the server's ModelPricingDto shape). */
|
||||||
|
export interface BucketPricing {
|
||||||
|
cacheRead: number;
|
||||||
|
cacheWrite: number;
|
||||||
|
output: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a three-bucket Token count to USD at per-million-token pricing; null when no pricing
|
||||||
|
* is configured (uncosted — callers omit the figure rather than fabricating a $0). Shared by the
|
||||||
|
* per-reply stats row's turn cost and the header's live session cost, so the two conversions can
|
||||||
|
* never drift apart.
|
||||||
|
*/
|
||||||
|
export function bucketCostUsd(
|
||||||
|
buckets: TokenBucketCounts,
|
||||||
|
pricing: BucketPricing | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
if (!pricing) return null;
|
||||||
|
return (
|
||||||
|
(buckets.cacheRead * pricing.cacheRead +
|
||||||
|
buckets.cacheWrite * pricing.cacheWrite +
|
||||||
|
buckets.output * pricing.output) /
|
||||||
|
1e6
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Stats tracker: Session-level cumulative counts that persist across Tasks + this-Task delta counts. */
|
/** Stats tracker: Session-level cumulative counts that persist across Tasks + this-Task delta counts. */
|
||||||
export interface TaskStatsTracker {
|
export interface TaskStatsTracker {
|
||||||
/** Current context occupancy = total from the most recent main-session request (not updated by compaction requests). */
|
/** Current context occupancy = total from the most recent main-session request (not updated by compaction requests). */
|
||||||
@@ -287,6 +313,25 @@ export function endTask(t: TaskStatsTracker, elapsedMs: number): TaskStats | nul
|
|||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session elapsed time for live display (the header chip): the settled cross-Task cumulative
|
||||||
|
* plus the currently running Task's wall clock so far. While no Task is open this is exactly
|
||||||
|
* `sessionElapsedMs`, so the idle rendering equals the settled value; the running addition
|
||||||
|
* counts from the model's task-start local clock (`taskStartLocalMs`), clamped so a clock
|
||||||
|
* anomaly never makes the sum go backwards. At task end {@link endTask} folds the Task's
|
||||||
|
* elapsed into `sessionElapsedMs` in the same model update that flips `taskOpen` off, so the
|
||||||
|
* live addition never double-counts across the boundary.
|
||||||
|
*/
|
||||||
|
export function liveSessionElapsedMs(
|
||||||
|
t: TaskStatsTracker,
|
||||||
|
taskOpen: boolean,
|
||||||
|
taskStartLocalMs: number | null,
|
||||||
|
nowMs: number,
|
||||||
|
): number {
|
||||||
|
const running = taskOpen && taskStartLocalMs != null ? Math.max(0, nowMs - taskStartLocalMs) : 0;
|
||||||
|
return t.sessionElapsedMs + running;
|
||||||
|
}
|
||||||
|
|
||||||
/** Localized labels for {@link formatTaskStats} (supplied by the view layer's active dictionary). */
|
/** Localized labels for {@link formatTaskStats} (supplied by the view layer's active dictionary). */
|
||||||
export interface TaskStatsLabels {
|
export interface TaskStatsLabels {
|
||||||
/** Row prefix, e.g. "Stats" / "统计信息". */
|
/** Row prefix, e.g. "Stats" / "统计信息". */
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import type { TokenUsagePayload } from "@prismshadow/penguin-core/omnimessage";
|
|||||||
import {
|
import {
|
||||||
addLlmDuration,
|
addLlmDuration,
|
||||||
beginCompaction,
|
beginCompaction,
|
||||||
|
bucketCostUsd,
|
||||||
commitPendingCompaction,
|
commitPendingCompaction,
|
||||||
createTaskStatsTracker,
|
createTaskStatsTracker,
|
||||||
endCompaction,
|
endCompaction,
|
||||||
endTask,
|
endTask,
|
||||||
formatTaskStats,
|
formatTaskStats,
|
||||||
|
liveSessionElapsedMs,
|
||||||
resetTaskCounters,
|
resetTaskCounters,
|
||||||
trackMainUsage,
|
trackMainUsage,
|
||||||
trackSubagentUsage,
|
trackSubagentUsage,
|
||||||
@@ -194,6 +196,67 @@ describe("TaskStatsTracker", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("liveSessionElapsedMs", () => {
|
||||||
|
it("idle (no open Task): exactly the settled cumulative — the header renders the same value as before", () => {
|
||||||
|
const t = createTaskStatsTracker();
|
||||||
|
t.sessionElapsedMs = 2300;
|
||||||
|
expect(liveSessionElapsedMs(t, false, 1000, 99_999)).toBe(2300);
|
||||||
|
// Open but without a recorded start clock: nothing to add either.
|
||||||
|
expect(liveSessionElapsedMs(t, true, null, 99_999)).toBe(2300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("running: settled cumulative + wall clock since the Task started, never going backwards", () => {
|
||||||
|
const t = createTaskStatsTracker();
|
||||||
|
t.sessionElapsedMs = 2000;
|
||||||
|
expect(liveSessionElapsedMs(t, true, 5000, 8000)).toBe(5000); // 2000 + 3000
|
||||||
|
// A clock anomaly (now before the recorded start) adds nothing instead of subtracting.
|
||||||
|
expect(liveSessionElapsedMs(t, true, 5000, 4000)).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no double count across the Task boundary: endTask folds the Task in as taskOpen flips off", () => {
|
||||||
|
const t = createTaskStatsTracker();
|
||||||
|
// Task 1 settled earlier.
|
||||||
|
endTask(t, 1000);
|
||||||
|
// Task 2 runs: started at 10_000, now 14_000 -> 1000 settled + 4000 live.
|
||||||
|
expect(liveSessionElapsedMs(t, true, 10_000, 14_000)).toBe(5000);
|
||||||
|
// Task 2 ends: the same model update folds its elapsed into the cumulative AND flips
|
||||||
|
// taskOpen off — the live view continues from the settled value without re-adding.
|
||||||
|
endTask(t, 4000);
|
||||||
|
expect(liveSessionElapsedMs(t, false, 10_000, 15_000)).toBe(5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bucketCostUsd", () => {
|
||||||
|
it("converts the three buckets at per-million-token pricing", () => {
|
||||||
|
expect(
|
||||||
|
bucketCostUsd(
|
||||||
|
{ cacheRead: 2_000_000, cacheWrite: 1_000_000, output: 500_000 },
|
||||||
|
{ cacheRead: 0.5, cacheWrite: 2, output: 10 },
|
||||||
|
),
|
||||||
|
).toBe(8); // 2M×$0.5/M + 1M×$2/M + 0.5M×$10/M = 1 + 2 + 5
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no pricing -> null (uncosted; callers keep the value as-is instead of fabricating $0)", () => {
|
||||||
|
const buckets = { cacheRead: 100, cacheWrite: 100, output: 100 };
|
||||||
|
expect(bucketCostUsd(buckets, undefined)).toBeNull();
|
||||||
|
expect(bucketCostUsd(buckets, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the live Task buckets equal the settled stats row's buckets (the mid-task estimate lands on the final turn cost)", () => {
|
||||||
|
const t = createTaskStatsTracker();
|
||||||
|
trackMainUsage(t, req(300, 100, 200));
|
||||||
|
trackSubagentUsage(t, req(50, 0, 3));
|
||||||
|
const pricing = { cacheRead: 1, cacheWrite: 2, output: 3 };
|
||||||
|
const live = bucketCostUsd(
|
||||||
|
{ cacheRead: t.taskCacheRead, cacheWrite: t.taskCacheWrite, output: t.taskOutput },
|
||||||
|
pricing,
|
||||||
|
);
|
||||||
|
const s = endTask(t, 100);
|
||||||
|
expect(live).not.toBeNull();
|
||||||
|
expect(live).toBe(bucketCostUsd(s!.tokensByBucket, pricing));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("formatTaskStats", () => {
|
describe("formatTaskStats", () => {
|
||||||
const EN_LABELS = {
|
const EN_LABELS = {
|
||||||
stats: "Stats",
|
stats: "Stats",
|
||||||
|
|||||||
Reference in new issue
Block a user