diff --git a/.config/nextest.toml b/.config/nextest.toml index fe7f4f778..3af76c17c 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -11,10 +11,6 @@ leak-timeout = "500ms" filter = "package(fabro-server)" slow-timeout = { period = "5s", terminate-after = 4 } - [[profile.default.overrides]] - filter = "package(fabro-server) & test(all_spec_routes_are_routable)" - slow-timeout = { period = "15s", terminate-after = 4 } - [[profile.default.overrides]] filter = "package(fabro-workflow)" slow-timeout = { period = "2s", terminate-after = 3 } diff --git a/apps/fabro-web/app/components/runs-list/run-table-row.tsx b/apps/fabro-web/app/components/runs-list/run-table-row.tsx index 850c36467..5066bc9d8 100644 --- a/apps/fabro-web/app/components/runs-list/run-table-row.tsx +++ b/apps/fabro-web/app/components/runs-list/run-table-row.tsx @@ -115,8 +115,10 @@ export function RunTableRow({ )} {show("size") && ( - - {run.size != null && } + + {run.size != null && ( + + )} )} {show("changes") && ( diff --git a/apps/fabro-web/app/components/size-chip.test.tsx b/apps/fabro-web/app/components/size-chip.test.tsx new file mode 100644 index 000000000..498c97f5e --- /dev/null +++ b/apps/fabro-web/app/components/size-chip.test.tsx @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; + +import { setupReactTestEnv } from "../lib/test-utils"; +import { SizeChip } from "./size-chip"; +import { Tooltip } from "./ui"; + +let teardownReactTestEnv: (() => void) | undefined; +const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; + +function render(element: React.ReactElement): TestRenderer.ReactTestRenderer { + let renderer: TestRenderer.ReactTestRenderer | undefined; + act(() => { + renderer = TestRenderer.create(element); + }); + mountedRenderers.push(renderer!); + return renderer!; +} + +function tooltipLabel(element: React.ReactElement): string { + return render(element).root.findByType(Tooltip).props.label as string; +} + +describe("SizeChip", () => { + beforeEach(() => { + teardownReactTestEnv = setupReactTestEnv(); + }); + + afterEach(() => { + act(() => { + for (const renderer of mountedRenderers.splice(0)) { + renderer.unmount(); + } + }); + teardownReactTestEnv?.(); + teardownReactTestEnv = undefined; + }); + + test("renders the size letter", () => { + expect(JSON.stringify(render().toJSON())).toContain("M"); + }); + + test("appends the cost to the tooltip", () => { + expect(tooltipLabel()) + .toBe("Size M · $12.34"); + }); + + test("omits the cost when the run has no billing yet", () => { + expect(tooltipLabel()).toBe("Size M"); + expect(tooltipLabel()).toBe("Size M"); + }); + + test("calls out the tiers that warrant attention", () => { + expect(tooltipLabel()) + .toBe("Size L (risky) · $150.00"); + expect(tooltipLabel()).toBe("Size XL (unhealthy)"); + }); +}); diff --git a/apps/fabro-web/app/components/size-chip.tsx b/apps/fabro-web/app/components/size-chip.tsx index e3d07ed40..7563b4c87 100644 --- a/apps/fabro-web/app/components/size-chip.tsx +++ b/apps/fabro-web/app/components/size-chip.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import type { RunSize } from "@qltysh/fabro-api-client"; import { formatUsdMicros } from "../lib/format"; @@ -11,7 +12,7 @@ const SIZE_TONE: Record = { XL: { className: "bg-coral/15 text-coral", note: "unhealthy" }, }; -export function SizeChip({ +export const SizeChip = memo(function SizeChip({ size, totalUsdMicros, }: { @@ -19,10 +20,10 @@ export function SizeChip({ totalUsdMicros?: number | null; }) { const tone = SIZE_TONE[size]; - const billed = totalUsdMicros != null ? ` · ${formatUsdMicros(totalUsdMicros)} billed` : ""; + const amount = totalUsdMicros != null ? ` · ${formatUsdMicros(totalUsdMicros)}` : ""; const tooltip = tone.note != null - ? `Size ${size} (${tone.note})${billed}` - : `Size ${size}${billed}`; + ? `Size ${size} (${tone.note})${amount}` + : `Size ${size}${amount}`; return ( @@ -30,4 +31,4 @@ export function SizeChip({ ); -} +}); diff --git a/apps/fabro-web/app/components/stage-popover.test.tsx b/apps/fabro-web/app/components/stage-popover.test.tsx index 4a8bbb12c..3f3c9fe92 100644 --- a/apps/fabro-web/app/components/stage-popover.test.tsx +++ b/apps/fabro-web/app/components/stage-popover.test.tsx @@ -9,6 +9,7 @@ import { StagePopover } from "./stage-popover"; import { deriveStageSummary } from "./stage-popover-summary"; import type { Stage } from "../lib/stage-sidebar"; import { generatedAxios } from "../lib/api-client"; +import { makeBilledTokenCounts } from "../lib/test-fixtures"; function makeEvent(overrides: Partial): EventEnvelope { return { @@ -34,6 +35,7 @@ function makeStage(overrides: Partial = {}): Stage { duration: "1m 30s", startedAt: "2026-05-24T11:58:30Z", providerUsed: { mode: "policy", model: "claude-opus-4-7", reasoning_effort: "high" }, + billing: makeBilledTokenCounts(), ...overrides, }; } diff --git a/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx b/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx index 26115eaee..9c48b1960 100644 --- a/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx +++ b/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx @@ -3,6 +3,7 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client"; import TestRenderer, { act } from "react-test-renderer"; import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils"; +import { makeBilledTokenCounts } from "../../lib/test-fixtures"; import type { Stage } from "../stage-sidebar"; import { FanInResults } from "./fan-in-results"; @@ -22,6 +23,7 @@ const fanInStage: Stage = { visit: 1, startedAt: "2026-04-09T12:00:00Z", providerUsed: null, + billing: makeBilledTokenCounts(), }; function event(seq: number, partial: Partial): EventEnvelope { diff --git a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx index f1313b780..e44953db5 100644 --- a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx +++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx @@ -4,6 +4,7 @@ import TestRenderer, { act } from "react-test-renderer"; import { MemoryRouter } from "react-router"; import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils"; +import { makeBilledTokenCounts } from "../../lib/test-fixtures"; import type { Stage } from "../stage-sidebar"; import { ParallelChildren } from "./parallel-children"; @@ -23,6 +24,7 @@ const parallelStage: Stage = { visit: 1, startedAt: "2026-04-09T12:00:00Z", providerUsed: null, + billing: makeBilledTokenCounts(), }; function event(partial: Partial): EventEnvelope { diff --git a/apps/fabro-web/app/components/stage-sidebar.test.tsx b/apps/fabro-web/app/components/stage-sidebar.test.tsx index 69cf0c097..663734196 100644 --- a/apps/fabro-web/app/components/stage-sidebar.test.tsx +++ b/apps/fabro-web/app/components/stage-sidebar.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import TestRenderer, { act } from "react-test-renderer"; import { MemoryRouter } from "react-router"; +import { makeBilledTokenCounts } from "../lib/test-fixtures"; import { StageSidebar, type Stage } from "./stage-sidebar"; function makeStage(overrides: Partial = {}): Stage { @@ -17,6 +18,7 @@ function makeStage(overrides: Partial = {}): Stage { duration: "--", startedAt: null, providerUsed: null, + billing: makeBilledTokenCounts(), ...overrides, }; } diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index 0ad593526..77fdc91e2 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -103,6 +103,17 @@ describe("mapRunListItem", () => { expect(mapRunListItem(summary).title).toBe("Untitled run"); }); + + test("carries the billed total so the size chip can show it on hover", () => { + expect(mapRunListItem(makeRun()).totalUsdMicros).toBe(500000); + }); + + test("leaves the billed total undefined for runs without terminal billing", () => { + expect(mapRunListItem(makeRun({ billing: null })).totalUsdMicros).toBeUndefined(); + expect( + mapRunListItem(makeRun({ billing: { total_usd_micros: null } })).totalUsdMicros, + ).toBeUndefined(); + }); }); describe("mapRunToRunItem", () => { diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index 4fb704133..c5f29f6ae 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -46,6 +46,7 @@ export interface RunItem { createdBy: Principal; lastEventAt?: string; size?: RunSize; + totalUsdMicros?: number; } export const columnStatuses = [ @@ -119,6 +120,7 @@ export function mapRunListItem(item: Run): RunItem { additions: item.diff?.additions, deletions: item.diff?.deletions, size: item.size, + totalUsdMicros: item.billing?.total_usd_micros ?? undefined, }; } diff --git a/apps/fabro-web/app/lib/billing.ts b/apps/fabro-web/app/lib/billing.ts new file mode 100644 index 000000000..8d7e2cbf7 --- /dev/null +++ b/apps/fabro-web/app/lib/billing.ts @@ -0,0 +1,28 @@ +import type { BilledTokenCounts } from "@qltysh/fabro-api-client"; + +export interface BillingTokenBucket { + label: string; + value: number; +} + +export function billableOutputTokens(billing: BilledTokenCounts): number { + return billing.output_tokens + billing.reasoning_tokens; +} + +/** The disjoint token buckets shown in every billing breakdown. */ +export function billingTokenBuckets(billing: BilledTokenCounts): BillingTokenBucket[] { + return [ + { label: "Cache read", value: billing.cache_read_tokens }, + { label: "Cache creation", value: billing.cache_write_tokens }, + { label: "Uncached", value: billing.input_tokens }, + { label: "Output", value: billableOutputTokens(billing) }, + ]; +} + +export function hasBillingUsage(billing: BilledTokenCounts): boolean { + return ( + billing.total_tokens !== 0 || + (billing.total_usd_micros ?? 0) !== 0 || + billingTokenBuckets(billing).some((bucket) => bucket.value !== 0) + ); +} diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index 3c8eb8a1d..9900a585e 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -1,3 +1,4 @@ +import { useCallback } from "react"; import useSWR, { type SWRConfiguration } from "swr"; import type { ApiQuestion, @@ -73,6 +74,7 @@ import { type RunFileSelection, type RunGraphDirection, } from "./query-keys"; +import { isTerminalRunStatus } from "./run-actions"; const immutableOptions: SWRConfiguration = { revalidateIfStale: false, @@ -179,10 +181,21 @@ export function useRunsPage(opts: RunsPageOptions = {}, enabled = true) { ); } -export function useRun(id: string | undefined) { +export function useRun(id: string | undefined, refreshInterval?: number) { + const pollingInterval = useCallback( + (run: Run | null | undefined) => + refreshInterval && + run?.timestamps.started_at && + !isTerminalRunStatus(run.lifecycle.status.kind) + ? refreshInterval + : 0, + [refreshInterval], + ); + return useSWR( id ? queryKeys.runs.detail(id) : null, () => apiNullableData(() => runsApi.retrieveRun(id!)), + refreshInterval ? { refreshInterval: pollingInterval } : undefined, ); } diff --git a/apps/fabro-web/app/lib/query-keys.test.ts b/apps/fabro-web/app/lib/query-keys.test.ts index ac441b998..31346b721 100644 --- a/apps/fabro-web/app/lib/query-keys.test.ts +++ b/apps/fabro-web/app/lib/query-keys.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; import { queryKeys } from "./query-keys"; -import { queryKeysForRunEvent } from "./run-events"; describe("queryKeys", () => { test("uses semantic tuples as stable SWR keys and keeps SSE URLs explicit", () => { @@ -61,52 +60,4 @@ describe("queryKeys", () => { expect(queryKeys.runs.attachUrl("run 1")).toBe("/api/v1/runs/run%201/attach"); }); - test("event-mapped keys match query hook resources", () => { - expect(queryKeysForRunEvent("run-1", "checkpoint.completed")).toEqual( - [ - ...queryKeys.runs.filesAllScopes("run-1"), - queryKeys.runs.commits("run-1"), - ], - ); - expect(queryKeysForRunEvent("run-1", "stage.completed", "stage-1")).toEqual([ - queryKeys.runs.stages("run-1"), - queryKeys.runs.billing("run-1"), - queryKeys.runs.events("run-1", 1000), - queryKeys.runs.graph("run-1", "LR"), - queryKeys.runs.graph("run-1", "TB"), - queryKeys.runs.detail("run-1"), - queryKeys.runs.state("run-1"), - queryKeys.runs.stageEvents("run-1", "stage-1"), - queryKeys.runs.stageContextWindow("run-1", "stage-1"), - ]); - expect(queryKeysForRunEvent("run-1", "run.title.updated")).toEqual([ - queryKeys.runs.detail("run-1"), - ]); - }); - - test("agent activity events invalidate per-stage resources", () => { - for (const event of [ - "stage.prompt", - "agent.tool.started", - "agent.tool.completed", - "command.started", - "command.completed", - ]) { - expect(queryKeysForRunEvent("run-1", event, "stage-1")).toEqual([ - queryKeys.runs.stageEvents("run-1", "stage-1"), - queryKeys.runs.stageContextWindow("run-1", "stage-1"), - ]); - } - expect(queryKeysForRunEvent("run-1", "agent.message", "stage-1")).toEqual([ - queryKeys.runs.state("run-1"), - queryKeys.runs.stageEvents("run-1", "stage-1"), - queryKeys.runs.stageContextWindow("run-1", "stage-1"), - ]); - }); - - test("agent message without a node_id still invalidates projected state", () => { - expect(queryKeysForRunEvent("run-1", "agent.message")).toEqual([ - queryKeys.runs.state("run-1"), - ]); - }); }); diff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts index a45034088..bf9f1590c 100644 --- a/apps/fabro-web/app/lib/run-actions.ts +++ b/apps/fabro-web/app/lib/run-actions.ts @@ -111,10 +111,7 @@ export async function deleteRuns( request?: Request, ): Promise { try { - // See `batchRunLifecycleAction` for the `as unknown as` rationale: - // openapi-generator types `uniqueItems` arrays as `Set` while the wire - // contract is a JSON array. - const body = { run_ids: runIds, force } as unknown as BatchDeleteRunsRequest; + const body: BatchDeleteRunsRequest = { run_ids: runIds, force }; return await apiData(() => runsApi.batchDeleteRuns(body, requestSignalOptions(request))); } catch (error) { throw lifecycleActionErrorFromError(error); @@ -284,10 +281,7 @@ async function batchRunLifecycleAction( request?: Request, ): Promise { try { - // openapi-generator's TypeScript client represents `uniqueItems` arrays as - // Set, but the HTTP wire contract is still a JSON array. Keep an array - // here so Axios serializes the request body correctly. - const body = { run_ids: runIds } as unknown as BatchRunLifecycleRequest; + const body: BatchRunLifecycleRequest = { run_ids: runIds }; switch (action) { case "archive": return await apiData(() => runsApi.batchArchiveRuns(body, requestSignalOptions(request))); diff --git a/apps/fabro-web/app/lib/run-events.test.tsx b/apps/fabro-web/app/lib/run-events.test.tsx index e984be74d..f4b345f28 100644 --- a/apps/fabro-web/app/lib/run-events.test.tsx +++ b/apps/fabro-web/app/lib/run-events.test.tsx @@ -85,6 +85,8 @@ describe("queryKeysForRunEvent", () => { test("interrupt settlement invalidates projected control state and stage activity", () => { expect(queryKeysForRunEvent("run-1", "agent.round.interrupted", "nap@1")).toEqual([ + queryKeys.runs.detail("run-1"), + queryKeys.runs.billing("run-1"), queryKeys.runs.state("run-1"), queryKeys.runs.events("run-1", 1000), queryKeys.runs.stageEvents("run-1", "nap@1"), @@ -129,10 +131,16 @@ describe("queryKeysForRunEvent", () => { test("every inference projection transition invalidates live run state", () => { for (const event of [ "agent.llm.started", - "agent.llm.first_output", - "agent.llm.retry", "agent.error", ]) { + expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([ + queryKeys.runs.detail("run-1"), + queryKeys.runs.state("run-1"), + queryKeys.runs.billing("run-1"), + queryKeys.runs.stageEvents("run-1", "code@1"), + ]); + } + for (const event of ["agent.llm.first_output", "agent.llm.retry"]) { expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([ queryKeys.runs.state("run-1"), queryKeys.runs.stageEvents("run-1", "code@1"), @@ -141,15 +149,47 @@ describe("queryKeysForRunEvent", () => { expect( queryKeysForRunEvent("run-1", "agent.message", "code@1"), ).toEqual([ + queryKeys.runs.detail("run-1"), queryKeys.runs.state("run-1"), + queryKeys.runs.billing("run-1"), queryKeys.runs.stageEvents("run-1", "code@1"), queryKeys.runs.stageContextWindow("run-1", "code@1"), ]); expect(queryKeysForRunEvent("run-1", "agent.session.ended")).toEqual([ + queryKeys.runs.detail("run-1"), queryKeys.runs.state("run-1"), + queryKeys.runs.billing("run-1"), ]); }); + test("ACP timing events invalidate live summaries and stage events", () => { + for (const event of [ + "agent.acp.started", + "agent.acp.completed", + "agent.acp.cancelled", + "agent.acp.timed_out", + ]) { + expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([ + queryKeys.runs.detail("run-1"), + queryKeys.runs.state("run-1"), + queryKeys.runs.billing("run-1"), + queryKeys.runs.stageEvents("run-1", "code@1"), + ]); + } + }); + + test("tool timing events invalidate live summaries and stage resources", () => { + for (const event of ["agent.tool.started", "agent.tool.completed"]) { + expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([ + queryKeys.runs.detail("run-1"), + queryKeys.runs.state("run-1"), + queryKeys.runs.billing("run-1"), + queryKeys.runs.stageEvents("run-1", "code@1"), + queryKeys.runs.stageContextWindow("run-1", "code@1"), + ]); + } + }); + test("watchdog timeout refreshes the stage events for that stage", () => { expect( queryKeysForRunEvent("run-1", "watchdog.timeout", "code@1"), diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts index d0d4f4b4d..ad5354a7b 100644 --- a/apps/fabro-web/app/lib/run-events.ts +++ b/apps/fabro-web/app/lib/run-events.ts @@ -118,6 +118,22 @@ const INFERENCE_EVENTS = new Set([ "agent.error", "agent.session.ended", ]); +const INFERENCE_TIMING_EVENTS = new Set([ + "agent.llm.started", + "agent.message", + "agent.error", + "agent.session.ended", +]); +const TOOL_TIMING_EVENTS = new Set([ + "agent.tool.started", + "agent.tool.completed", +]); +const ACP_TIMING_EVENTS = new Set([ + "agent.acp.started", + "agent.acp.completed", + "agent.acp.cancelled", + "agent.acp.timed_out", +]); // Todo / task mutation events refresh `getRunState` consumers (so per-stage // todo projections update live) and the run events list. const TODO_EVENTS = new Set([ @@ -126,6 +142,14 @@ const TODO_EVENTS = new Set([ "todo.deleted", ]); +function liveTimingKeys(runId: string): Key[] { + return [ + queryKeys.runs.detail(runId), + queryKeys.runs.state(runId), + queryKeys.runs.billing(runId), + ]; +} + export function queryKeysForRunEvent( runId: string, event: string, @@ -184,6 +208,12 @@ export function queryKeysForRunEvent( if (AGENT_CONTROL_STATE_EVENTS.has(event)) { keys.unshift(queryKeys.runs.state(runId)); } + if (event === "agent.round.interrupted") { + keys.unshift( + queryKeys.runs.detail(runId), + queryKeys.runs.billing(runId), + ); + } if (stageId) { keys.push(queryKeys.runs.stageEvents(runId, stageId)); keys.push(queryKeys.runs.stageContextWindow(runId, stageId)); @@ -192,7 +222,9 @@ export function queryKeysForRunEvent( } if (INFERENCE_EVENTS.has(event)) { - const keys: Key[] = [queryKeys.runs.state(runId)]; + const keys = INFERENCE_TIMING_EVENTS.has(event) + ? liveTimingKeys(runId) + : [queryKeys.runs.state(runId)]; if (stageId) { keys.push(queryKeys.runs.stageEvents(runId, stageId)); if (event === "agent.message") { @@ -202,6 +234,23 @@ export function queryKeysForRunEvent( return keys; } + if (TOOL_TIMING_EVENTS.has(event)) { + const keys = liveTimingKeys(runId); + if (stageId) { + keys.push(queryKeys.runs.stageEvents(runId, stageId)); + keys.push(queryKeys.runs.stageContextWindow(runId, stageId)); + } + return keys; + } + + if (ACP_TIMING_EVENTS.has(event)) { + const keys = liveTimingKeys(runId); + if (stageId) { + keys.push(queryKeys.runs.stageEvents(runId, stageId)); + } + return keys; + } + if (event === "watchdog.timeout") { return stageId ? [queryKeys.runs.stageEvents(runId, stageId)] : []; } diff --git a/apps/fabro-web/app/lib/stage-sidebar.test.ts b/apps/fabro-web/app/lib/stage-sidebar.test.ts index a71323654..53cfa1b2a 100644 --- a/apps/fabro-web/app/lib/stage-sidebar.test.ts +++ b/apps/fabro-web/app/lib/stage-sidebar.test.ts @@ -3,6 +3,7 @@ import type { PaginatedRunStageList, StageHandler, StageState } from "@qltysh/fa import type { Stage } from "../components/stage-sidebar"; import { aggregateGraphNodeStatus, formatStageLabel, mapRunStagesToSidebarStages } from "./stage-sidebar"; +import { makeBilledTokenCounts } from "./test-fixtures"; function makeStage(nodeId: string, visit: number, status: StageState): Stage { return { @@ -17,6 +18,7 @@ function makeStage(nodeId: string, visit: number, status: StageState): Stage { duration: "--", startedAt: null, providerUsed: null, + billing: makeBilledTokenCounts(), }; } @@ -38,6 +40,15 @@ describe("mapRunStagesToSidebarStages", () => { model: "gpt-5.5", reasoning_effort: "high", }, + billing: makeBilledTokenCounts({ + input_tokens: 28_640, + output_tokens: 7_550, + total_tokens: 43_690, + reasoning_tokens: 1_200, + cache_read_tokens: 4_800, + cache_write_tokens: 1_500, + total_usd_micros: 720_000, + }), }, { id: "apply-changes@2", @@ -46,6 +57,7 @@ describe("mapRunStagesToSidebarStages", () => { status: "running", node_id: "apply", visit: 2, + billing: makeBilledTokenCounts(), }, ], meta: { has_more: false }, @@ -64,6 +76,10 @@ describe("mapRunStagesToSidebarStages", () => { model: "gpt-5.5", reasoning_effort: "high", }); + // Each visit keeps its own tokens and cost, so the stage popover never + // shows a sibling visit's usage. + expect(result[0].billing.total_usd_micros).toBe(720_000); + expect(result[1].billing.total_usd_micros).toBeUndefined(); expect(formatStageLabel(result[0])).toBe("Apply Changes"); expect(result[1].id).toBe("apply-changes@2"); @@ -83,6 +99,7 @@ describe("mapRunStagesToSidebarStages", () => { status: "succeeded", node_id: "start", visit: 1, + billing: makeBilledTokenCounts(), }, { id: "verify@1", @@ -91,6 +108,7 @@ describe("mapRunStagesToSidebarStages", () => { status: "succeeded", node_id: "verify", visit: 1, + billing: makeBilledTokenCounts(), }, { id: "exit@1", @@ -99,6 +117,7 @@ describe("mapRunStagesToSidebarStages", () => { status: "succeeded", node_id: "exit", visit: 1, + billing: makeBilledTokenCounts(), }, ], meta: { has_more: false }, @@ -118,6 +137,7 @@ describe("mapRunStagesToSidebarStages", () => { status: "running", node_id: "verify", visit: 1, + billing: makeBilledTokenCounts(), }, ], meta: { has_more: false }, @@ -137,6 +157,7 @@ describe("mapRunStagesToSidebarStages", () => { node_id: "work", visit: 1, graph_visit: 1, + billing: makeBilledTokenCounts(), }, { id: "work@2", @@ -147,6 +168,7 @@ describe("mapRunStagesToSidebarStages", () => { visit: 2, graph_visit: 1, resumed_from_stage_id: "work@1", + billing: makeBilledTokenCounts(), }, ], meta: { has_more: false }, @@ -172,6 +194,7 @@ describe("mapRunStagesToSidebarStages", () => { status: "succeeded", node_id: "verify", visit: 1, + billing: makeBilledTokenCounts(), }, ], meta: { has_more: false }, @@ -192,6 +215,7 @@ describe("mapRunStagesToSidebarStages", () => { status: "pending", node_id: "approval", visit: 1, + billing: makeBilledTokenCounts(), }, ], meta: { has_more: false }, diff --git a/apps/fabro-web/app/lib/stage-sidebar.ts b/apps/fabro-web/app/lib/stage-sidebar.ts index c165adee0..5aefb1166 100644 --- a/apps/fabro-web/app/lib/stage-sidebar.ts +++ b/apps/fabro-web/app/lib/stage-sidebar.ts @@ -1,5 +1,6 @@ import { StageState } from "@qltysh/fabro-api-client"; import type { + BilledTokenCounts, PaginatedRunStageList, StageHandler, StageModelUsage, @@ -27,6 +28,11 @@ export interface Stage { resumedFromStageId: string | null; startedAt: string | null; providerUsed: StageModelUsage | null; + /** + * Tokens and cost for this visit alone, priced the same way the Billing tab + * prices its per-node rows. All-zero counts mean the stage called no model. + */ + billing: BilledTokenCounts; } export const ACTIVE_STAGE_STATES: ReadonlySet = new Set([ @@ -102,6 +108,7 @@ export function mapRunStagesToSidebarStages( : "--", startedAt: stage.started_at ?? null, providerUsed: stage.provider_used ?? null, + billing: stage.billing, }); } return stages; diff --git a/apps/fabro-web/app/lib/test-fixtures.ts b/apps/fabro-web/app/lib/test-fixtures.ts index c625ebe7d..521e46e14 100644 --- a/apps/fabro-web/app/lib/test-fixtures.ts +++ b/apps/fabro-web/app/lib/test-fixtures.ts @@ -1,4 +1,4 @@ -import type { Principal } from "@qltysh/fabro-api-client"; +import type { BilledTokenCounts, Principal } from "@qltysh/fabro-api-client"; export const TEST_PRINCIPAL: Principal = { kind: "user", @@ -6,3 +6,17 @@ export const TEST_PRINCIPAL: Principal = { login: "test", auth_method: "dev_token", }; + +export function makeBilledTokenCounts( + overrides: Partial = {}, +): BilledTokenCounts { + return { + cache_read_tokens: 0, + cache_write_tokens: 0, + input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + total_tokens: 0, + ...overrides, + }; +} diff --git a/apps/fabro-web/app/routes/run-artifacts.tsx b/apps/fabro-web/app/routes/run-artifacts.tsx index c44b6c633..a314973c9 100644 --- a/apps/fabro-web/app/routes/run-artifacts.tsx +++ b/apps/fabro-web/app/routes/run-artifacts.tsx @@ -1,14 +1,18 @@ import { useMemo } from "react"; import { useParams } from "react-router"; -import { ArrowDownTrayIcon, PaperClipIcon } from "@heroicons/react/24/outline"; +import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/react"; +import { ArrowDownTrayIcon, ChevronRightIcon, PaperClipIcon } from "@heroicons/react/24/outline"; import type { RunArtifactEntry } from "@qltysh/fabro-api-client"; import { EmptyState, ErrorState, LoadingState } from "../components/state"; import { StageSidebar } from "../components/stage-sidebar"; import { stageArtifactDownloadUrl } from "../lib/api-client"; import { formatBytes } from "../lib/format"; +import { plural } from "../lib/plural"; import { useRunArtifacts, useRunStages } from "../lib/queries"; -import { formatStageLabel, mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; +import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; +import type { ArtifactFile, ArtifactVersion } from "./run-artifacts/group"; +import { groupArtifactsByFile } from "./run-artifacts/group"; export const handle = { wide: true }; @@ -25,7 +29,12 @@ export default function RunArtifacts() {
- +
); @@ -34,86 +43,40 @@ export default function RunArtifacts() { function RunArtifactsBody({ runId, artifactsQuery, + stagesQuery, stages, }: { runId: string; artifactsQuery: ReturnType; + stagesQuery: ReturnType; stages: ReturnType; }) { - if (artifactsQuery.error) { + const error = artifactsQuery.error ?? stagesQuery.error; + if (error) { return ( void artifactsQuery.mutate()} + description={errorMessage(error)} + onRetry={() => { + if (artifactsQuery.error) void artifactsQuery.mutate(); + if (stagesQuery.error) void stagesQuery.mutate(); + }} /> ); } - if (artifactsQuery.data === undefined) { + if (artifactsQuery.data === undefined || stagesQuery.data === undefined) { return ; } - const entries = artifactsQuery.data?.data ?? []; - if (entries.length === 0) { - return ( - - ); - } - return ; + return ( + + ); } -interface StageGroup { - key: string; - stageId: string; - retry: number; - label: string; - entries: RunArtifactEntry[]; - totalBytes: number; -} - -function groupArtifacts( - entries: readonly RunArtifactEntry[], - stages: ReturnType, -): StageGroup[] { - const stageLabels = new Map(); - for (const stage of stages) { - stageLabels.set(stage.id, formatStageLabel(stage)); - } - - const groups = new Map(); - for (const entry of entries) { - const key = `${entry.stage_id}#${entry.retry}`; - const existing = groups.get(key); - if (existing) { - existing.entries.push(entry); - existing.totalBytes += entry.size; - } else { - groups.set(key, { - key, - stageId: entry.stage_id, - retry: entry.retry, - label: stageLabels.get(entry.stage_id) ?? entry.node_slug, - entries: [entry], - totalBytes: entry.size, - }); - } - } - - for (const group of groups.values()) { - group.entries.sort((a, b) => a.relative_path.localeCompare(b.relative_path)); - } - const sortedGroups = Array.from(groups.values()); - sortedGroups.sort((a, b) => { - const labelCmp = a.label.localeCompare(b.label); - return labelCmp !== 0 ? labelCmp : a.retry - b.retry; - }); - return sortedGroups; -} - -function ArtifactList({ +function ArtifactFiles({ runId, entries, stages, @@ -122,97 +85,209 @@ function ArtifactList({ entries: readonly RunArtifactEntry[]; stages: ReturnType; }) { - const groups = useMemo(() => groupArtifacts(entries, stages), [entries, stages]); - const totalBytes = useMemo( - () => entries.reduce((sum, entry) => sum + entry.size, 0), - [entries], - ); + const files = useMemo(() => groupArtifactsByFile(entries, stages), [entries, stages]); + + if (files.length === 0) { + return ( + + ); + } + return ; +} + +function ArtifactList({ runId, files }: { runId: string; files: readonly ArtifactFile[] }) { + const { captures, latestBytes, storedBytes } = useMemo(() => { + let captures = 0; + let latestBytes = 0; + let storedBytes = 0; + for (const file of files) { + captures += file.versions.length; + latestBytes += file.versions[0].size; + for (const version of file.versions) storedBytes += version.size; + } + return { captures, latestBytes, storedBytes }; + }, [files]); + + // Only mention versions once some file actually has more than one. + const versioned = captures > files.length; return (
-
+

- {entries.length} {entries.length === 1 ? "artifact" : "artifacts"} + {files.length} {plural(files.length, "file", "files")} + {versioned && ( + + {" "} + · {captures} {plural(captures, "version", "versions")} + + )}

- - {formatBytes(totalBytes)} total + + {versioned + ? `${formatBytes(latestBytes)} latest · ${formatBytes(storedBytes)} stored` + : `${formatBytes(latestBytes)} total`}
- {groups.map((group) => ( - - ))} +
+ {files.map((file) => ( + + ))} +
); } -function StageGroupCard({ runId, group }: { runId: string; group: StageGroup }) { +function ArtifactFileRow({ runId, file }: { runId: string; file: ArtifactFile }) { + const hasEarlier = file.versions.length > 1; + const latest = file.versions[0]; + return ( -
-
-
-

{group.label}

- {group.retry > 0 && ( - - retry {group.retry} + + {({ open }) => ( + <> +
+ {hasEarlier ? ( + + + {open ? "Hide" : "Show"} earlier versions of {file.name} + + + ) : ( +
+ + {hasEarlier && ( + + + )} -
- - {group.entries.length} {group.entries.length === 1 ? "file" : "files"} - {" · "} - {formatBytes(group.totalBytes)} - -
-
    - {group.entries.map((entry) => ( - - ))} -
-
+ + )} + ); } -function ArtifactRow({ runId, entry }: { runId: string; entry: RunArtifactEntry }) { +function EarlierVersions({ runId, file }: { runId: string; file: ArtifactFile }) { + return ( + <> + {file.versions.map((version, index) => + index === 0 ? null : ( +
  • + + + + + {formatBytes(version.size)} + + + +
  • + ), + )} + + ); +} + +function VersionLabel({ version }: { version: ArtifactVersion }) { + const attempt = attemptLabel(version); + return ( + <> + {version.stageLabel} + {attempt && {attempt}} + + ); +} + +function attemptLabel(version: ArtifactVersion): string | null { + return version.retry > 1 ? `attempt ${version.retry}` : null; +} + +function SizeDelta({ delta }: { delta: number | null }) { + if (delta === null) { + return first; + } + const tone = delta < 0 ? "text-amber" : "text-mint"; + const sign = delta < 0 ? "−" : "+"; + return ( + + {sign} + {formatBytes(Math.abs(delta))} + + ); +} + +function DownloadLink({ + runId, + file, + version, +}: { + runId: string; + file: ArtifactFile; + version: ArtifactVersion; +}) { const href = stageArtifactDownloadUrl( runId, - entry.stage_id, - entry.relative_path, - entry.retry, + version.stageId, + file.path, + version.retry, ); - + const attempt = attemptLabel(version); + const source = attempt ? `${version.stageLabel}, ${attempt}` : version.stageLabel; return ( -
  • - - {entry.relative_path} - - - {formatBytes(entry.size)} - - - -
  • + + ); } -function basename(path: string): string { - const idx = path.lastIndexOf("/"); - return idx >= 0 ? path.slice(idx + 1) : path; -} - function errorMessage(error: unknown): string | undefined { return error instanceof Error ? error.message : undefined; } diff --git a/apps/fabro-web/app/routes/run-artifacts/group.test.ts b/apps/fabro-web/app/routes/run-artifacts/group.test.ts new file mode 100644 index 000000000..72762c13e --- /dev/null +++ b/apps/fabro-web/app/routes/run-artifacts/group.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test"; +import { StageHandler, StageState } from "@qltysh/fabro-api-client"; +import type { RunArtifactEntry } from "@qltysh/fabro-api-client"; + +import type { Stage } from "../../lib/stage-sidebar"; +import { groupArtifactsByFile, splitArtifactPath } from "./group"; + +function stage(nodeId: string, startedAt: string | null, visit = 1): Stage { + return { + id: `${nodeId}@${visit}`, + name: nodeId, + handler: StageHandler.AGENT, + nodeId, + visit, + graphVisit: null, + resumedFromStageId: null, + status: StageState.SUCCEEDED, + duration: "1s", + startedAt, + providerUsed: null, + }; +} + +function artifact( + nodeSlug: string, + path: string, + size: number, + retry = 1, + visit = 1, +): RunArtifactEntry { + return { + stage_id: `${nodeSlug}@${visit}`, + node_slug: nodeSlug, + retry, + relative_path: path, + size, + }; +} + +/** Mirrors run 01KYJ8ZR0N: one report rewritten by four stages. */ +const REPORT = ".ai/reports/2026-07-27-wrk-002-instance-lifecycle.md"; + +const STAGES: Stage[] = [ + stage("start", "2026-07-27T17:12:08Z"), + stage("plan", "2026-07-27T17:21:44Z"), + stage("implement_plan", "2026-07-27T17:44:18Z"), + stage("simplify", "2026-07-27T18:43:11Z"), + stage("consolidate_reviews", "2026-07-27T19:44:26Z"), + stage("fix_review_findings", "2026-07-27T19:49:22Z"), +]; + +describe("splitArtifactPath", () => { + test("splits a nested path into directory prefix and filename", () => { + expect(splitArtifactPath(".ai/reports/run.md")).toEqual({ + dir: ".ai/reports/", + name: "run.md", + }); + }); + + test("leaves a root-level path without a directory", () => { + expect(splitArtifactPath("README.md")).toEqual({ dir: "", name: "README.md" }); + }); +}); + +describe("groupArtifactsByFile", () => { + test("collapses repeated captures of one path into a single file", () => { + const files = groupArtifactsByFile( + [ + artifact("consolidate_reviews", REPORT, 14323), + artifact("fix_review_findings", REPORT, 17483), + artifact("implement_plan", REPORT, 8422), + artifact("simplify", REPORT, 13162), + ], + STAGES, + ); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe(REPORT); + expect(files[0].dir).toBe(".ai/reports/"); + expect(files[0].name).toBe("2026-07-27-wrk-002-instance-lifecycle.md"); + expect(files[0].versions).toHaveLength(4); + }); + + test("orders versions newest first using the API stage order", () => { + const stages = [ + stage("implement_plan", "2026-07-27T20:00:00Z"), + stage("simplify", "2026-07-27T18:43:11Z"), + ]; + const files = groupArtifactsByFile( + [ + artifact("implement_plan", REPORT, 8422), + artifact("simplify", REPORT, 13162), + ], + stages, + ); + + expect(files[0].versions.map((v) => v.stageLabel)).toEqual([ + "simplify", + "implement_plan", + ]); + expect(files[0].versions[0].size).toBe(13162); + }); + + test.each([ + ["equal", "2026-07-27T18:43:11Z", "2026-07-27T18:43:11Z"], + ["missing", null, null], + ])("preserves API order when stage timestamps are %s", (_case, firstAt, secondAt) => { + const stages = [ + stage("implement_plan", firstAt), + stage("simplify", secondAt), + ]; + const files = groupArtifactsByFile( + [ + artifact("simplify", REPORT, 13162), + artifact("implement_plan", REPORT, 8422), + ], + stages, + ); + + expect(files[0].versions.map((v) => v.stageLabel)).toEqual([ + "simplify", + "implement_plan", + ]); + }); + + test("reports the byte change each capture introduced, oldest capture first", () => { + const files = groupArtifactsByFile( + [ + artifact("implement_plan", REPORT, 8422), + artifact("simplify", REPORT, 13162), + artifact("consolidate_reviews", REPORT, 14323), + artifact("fix_review_findings", REPORT, 17483), + ], + STAGES, + ); + + // versions are newest-first, so deltas read 17483-14323, 14323-13162, ... + expect(files[0].versions.map((v) => v.delta)).toEqual([3160, 1161, 4740, null]); + }); + + test("drops captures from graph control nodes", () => { + const files = groupArtifactsByFile( + [ + artifact("start", ".ai/reports/pre-existing.md", 12402), + artifact("plan", ".ai/plans/plan.md", 21749), + ], + STAGES, + ); + + expect(files.map((file) => file.path)).toEqual([".ai/plans/plan.md"]); + }); + + test("sorts files by their most recent capture", () => { + const files = groupArtifactsByFile( + [ + artifact("plan", ".ai/plans/plan.md", 21749), + artifact("fix_review_findings", REPORT, 17483), + artifact("simplify", ".ai/reviews/bugs.xml", 5231), + ], + STAGES, + ); + + expect(files.map((file) => file.path)).toEqual([ + REPORT, + ".ai/reviews/bugs.xml", + ".ai/plans/plan.md", + ]); + }); + + test("keeps retries of one stage as separate ordered versions", () => { + const files = groupArtifactsByFile( + [ + artifact("simplify", REPORT, 13162, 2), + artifact("simplify", REPORT, 9000, 1), + ], + STAGES, + ); + + expect(files[0].versions.map((v) => v.retry)).toEqual([2, 1]); + expect(files[0].versions[0].size).toBe(13162); + expect(files[0].versions.map((v) => v.delta)).toEqual([4162, null]); + }); + + test("returns no files when every capture came from a control node", () => { + const files = groupArtifactsByFile( + [artifact("start", ".ai/reports/pre-existing.md", 12402)], + STAGES, + ); + + expect(files).toEqual([]); + }); +}); diff --git a/apps/fabro-web/app/routes/run-artifacts/group.ts b/apps/fabro-web/app/routes/run-artifacts/group.ts new file mode 100644 index 000000000..b3577e3c6 --- /dev/null +++ b/apps/fabro-web/app/routes/run-artifacts/group.ts @@ -0,0 +1,104 @@ +import type { RunArtifactEntry } from "@qltysh/fabro-api-client"; + +import { isVisibleStage } from "../../data/runs"; +import type { Stage } from "../../lib/stage-sidebar"; +import { formatStageLabel } from "../../lib/stage-sidebar"; + +/** One capture of a file, written by a single stage attempt. */ +export interface ArtifactVersion { + stageId: string; + stageLabel: string; + retry: number; + size: number; + /** Byte change this capture introduced; null for the first capture. */ + delta: number | null; +} + +/** One artifact path together with its capture history, newest first. */ +export interface ArtifactFile { + path: string; + /** Directory prefix including the trailing slash, or "" at the root. */ + dir: string; + name: string; + versions: readonly [ArtifactVersion, ...ArtifactVersion[]]; +} + +export function splitArtifactPath(path: string): { dir: string; name: string } { + const idx = path.lastIndexOf("/"); + return idx >= 0 + ? { dir: path.slice(0, idx + 1), name: path.slice(idx + 1) } + : { dir: "", name: path }; +} + +interface StageInfo { + label: string; + order: number; +} + +/** Stage display data keyed by ID, preserving the API's event order. */ +function stageInfoById(stages: readonly Stage[]): Map { + const info = new Map(); + stages.forEach((stage, order) => { + info.set(stage.id, { label: formatStageLabel(stage), order }); + }); + return info; +} + +/** + * Collapse raw `(stage, retry, path)` capture keys into one entry per file, + * carrying the ordered history of every capture of that path. + * + * Captures from graph control nodes (`start`, `exit`) are dropped: those nodes + * run no work, so anything they match is a pre-existing workspace file rather + * than something the run produced. + */ +export function groupArtifactsByFile( + entries: readonly RunArtifactEntry[], + stages: readonly Stage[], +): ArtifactFile[] { + const stageInfo = stageInfoById(stages); + const byPath = new Map(); + + for (const entry of entries) { + if (!isVisibleStage(entry.node_slug)) continue; + + const info = stageInfo.get(entry.stage_id); + const version: ArtifactVersion = { + stageId: entry.stage_id, + stageLabel: info?.label ?? entry.node_slug, + retry: entry.retry, + size: entry.size, + delta: null, + }; + const bucket = byPath.get(entry.relative_path); + if (bucket) bucket.push(version); + else byPath.set(entry.relative_path, [version]); + } + + const files: Array<{ file: ArtifactFile; order: number }> = []; + for (const [path, versions] of byPath) { + // Oldest first, so each version's delta is the change that capture introduced. + versions.sort( + (a, b) => + (stageInfo.get(a.stageId)?.order ?? -1) - + (stageInfo.get(b.stageId)?.order ?? -1) || + a.retry - b.retry || + a.stageId.localeCompare(b.stageId), + ); + versions.forEach((version, index) => { + version.delta = index === 0 ? null : version.size - versions[index - 1].size; + }); + + versions.reverse(); + const latest = versions[0]; + const { dir, name } = splitArtifactPath(path); + files.push({ + file: { path, dir, name, versions }, + order: stageInfo.get(latest.stageId)?.order ?? -1, + }); + } + + // Most recently written file first — the page answers "what just happened?". + files.sort((a, b) => b.order - a.order || a.file.path.localeCompare(b.file.path)); + return files.map((entry) => entry.file); +} diff --git a/apps/fabro-web/app/routes/run-billing.test.tsx b/apps/fabro-web/app/routes/run-billing.test.tsx index 99f6ece5d..efb86e73d 100644 --- a/apps/fabro-web/app/routes/run-billing.test.tsx +++ b/apps/fabro-web/app/routes/run-billing.test.tsx @@ -2,11 +2,12 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; import TestRenderer from "react-test-renderer"; import type { - BilledTokenCounts, RunBilling, StageTiming, } from "@qltysh/fabro-api-client"; +import { makeBilledTokenCounts } from "../lib/test-fixtures"; + function stageTiming(wall_time_ms = 0, inference_time_ms = 0, tool_time_ms = 0): StageTiming { return { wall_time_ms, @@ -24,25 +25,12 @@ mock.module("../lib/queries", () => ({ const { default: RunBillingRoute } = await import("./run-billing"); -function zeroBilling(overrides: Partial = {}): BilledTokenCounts { - return { - cache_read_tokens: 0, - cache_write_tokens: 0, - input_tokens: 0, - output_tokens: 0, - reasoning_tokens: 0, - total_tokens: 0, - total_usd_micros: null, - ...overrides, - }; -} - function billing(overrides: Partial = {}): RunBilling { return { stages: [], totals: { timing: stageTiming(), - ...zeroBilling(), + ...makeBilledTokenCounts(), }, by_model: [], ...overrides, @@ -86,21 +74,21 @@ describe("RunBilling", () => { { stage: { id: "start", name: "start" }, model: null, - billing: zeroBilling(), + billing: makeBilledTokenCounts(), timing: stageTiming(), state: "succeeded", }, { stage: { id: "command", name: "command" }, model: null, - billing: zeroBilling(), + billing: makeBilledTokenCounts(), timing: stageTiming(61000), state: "succeeded", }, ], totals: { timing: stageTiming(61000), - ...zeroBilling(), + ...makeBilledTokenCounts(), }, }), ); @@ -121,7 +109,7 @@ describe("RunBilling", () => { { stage: { id: "start", name: "start" }, model: null, - billing: zeroBilling(), + billing: makeBilledTokenCounts(), timing: stageTiming(), state: "succeeded", }, @@ -131,7 +119,7 @@ describe("RunBilling", () => { provider: "anthropic", model_id: "claude-sonnet-4-5", }, - billing: zeroBilling({ + billing: makeBilledTokenCounts({ input_tokens: 1200, output_tokens: 300, total_tokens: 1500, @@ -143,7 +131,7 @@ describe("RunBilling", () => { ], totals: { timing: stageTiming(42000), - ...zeroBilling({ + ...makeBilledTokenCounts({ input_tokens: 1200, output_tokens: 300, total_tokens: 1500, @@ -157,7 +145,7 @@ describe("RunBilling", () => { model_id: "claude-sonnet-4-5", }, stages: 1, - billing: zeroBilling({ + billing: makeBilledTokenCounts({ input_tokens: 1200, output_tokens: 300, total_tokens: 1500, @@ -204,7 +192,7 @@ describe("RunBilling", () => { model_id: "claude-opus-4-6", speed: "fast", }, - billing: zeroBilling({ + billing: makeBilledTokenCounts({ input_tokens: 1200, output_tokens: 300, total_tokens: 1500, @@ -217,7 +205,7 @@ describe("RunBilling", () => { ], totals: { timing: stageTiming(), - ...zeroBilling({ + ...makeBilledTokenCounts({ input_tokens: 1200, output_tokens: 300, total_tokens: 1500, @@ -232,7 +220,7 @@ describe("RunBilling", () => { speed: "fast", }, stages: 1, - billing: zeroBilling({ + billing: makeBilledTokenCounts({ input_tokens: 1200, output_tokens: 300, total_tokens: 1500, @@ -268,4 +256,4 @@ describe("RunBilling", () => { Date.now = originalNow; } }); -}); \ No newline at end of file +}); diff --git a/apps/fabro-web/app/routes/run-billing.tsx b/apps/fabro-web/app/routes/run-billing.tsx index 20b60e775..1973ffb10 100644 --- a/apps/fabro-web/app/routes/run-billing.tsx +++ b/apps/fabro-web/app/routes/run-billing.tsx @@ -2,6 +2,11 @@ import { Fragment, useMemo } from "react"; import { EmptyState } from "../components/state"; import { Tooltip } from "../components/ui"; +import { + billableOutputTokens, + billingTokenBuckets, + hasBillingUsage, +} from "../lib/billing"; import { formatDurationMs, formatTokenCount, @@ -11,6 +16,7 @@ import { useRunBilling } from "../lib/queries"; import { IN_FLIGHT_STAGE_STATES } from "../lib/stage-sidebar"; import { useTickingNow } from "../lib/time"; import type { + BilledTokenCounts, BillingModelRef, RunBilling, RunBillingStage, @@ -39,23 +45,15 @@ function isInFlight(stage: RunBillingStage): boolean { function isVisibleRow(row: MappedStageRow): boolean { if (row.inFlight) return true; - return ( - (row.inputTokens ?? 0) > 0 || - (row.outputTokens ?? 0) > 0 || - (row.totalUsdMicros ?? 0) > 0 - ); + return row.billing != null && hasBillingUsage(row.billing); } interface MappedStageRow { - stage: string; - model: string | null; - inputTokens: number | null; - outputTokens: number | null; - cacheReadTokens: number | null; - cacheWriteTokens: number | null; - wallTimeMs: number; - totalUsdMicros: number | null | undefined; - inFlight: boolean; + stage: string; + model: string | null; + billing: BilledTokenCounts | null; + wallTimeMs: number; + inFlight: boolean; } function liveWallTimeMs(stage: RunBillingStage, now: number): number { @@ -73,49 +71,28 @@ export const handle = { wide: true }; function mapStageRow(stage: RunBillingStage, wallTimeMs: number): MappedStageRow { const hasModel = stage.model != null; return { - stage: stage.stage.name, - model: formatModelRef(stage.model), - inputTokens: hasModel ? stage.billing.input_tokens : null, - outputTokens: hasModel - ? stage.billing.output_tokens + stage.billing.reasoning_tokens - : null, - cacheReadTokens: hasModel ? stage.billing.cache_read_tokens : null, - cacheWriteTokens: hasModel ? stage.billing.cache_write_tokens : null, + stage: stage.stage.name, + model: formatModelRef(stage.model), + billing: hasModel ? stage.billing : null, wallTimeMs, - totalUsdMicros: stage.billing.total_usd_micros, - inFlight: isInFlight(stage), + inFlight: isInFlight(stage), }; } /** Hover breakdown of the disjoint token buckets behind an `in / out` count. */ -function TokenBreakdown({ - cacheReadTokens, - cacheWriteTokens, - inputTokens, - outputTokens, -}: { - cacheReadTokens: number; - cacheWriteTokens: number; - inputTokens: number; - outputTokens: number; -}) { - const rows = [ - { label: "Cache read", value: cacheReadTokens }, - { label: "Cache creation", value: cacheWriteTokens }, - { label: "Uncached", value: inputTokens }, - { label: "Output", value: outputTokens }, - ]; +function TokenBreakdown({ billing }: { billing: BilledTokenCounts }) { + const buckets = billingTokenBuckets(billing); return (
    -
    +
    Tokens in / out
    - {rows.map((row) => ( - -
    {row.label}
    -
    - {formatTokens(row.value)} + {buckets.map((bucket) => ( + +
    {bucket.label}
    +
    + {formatTokens(bucket.value)}
    ))} @@ -128,42 +105,16 @@ function TokenBreakdown({ * Renders an `input / output` token count. When the row has model usage, * hovering the count reveals the cache breakdown. */ -function TokensCell({ - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, -}: { - inputTokens: number | null; - outputTokens: number | null; - cacheReadTokens: number | null; - cacheWriteTokens: number | null; -}) { +function TokensCell({ billing }: { billing: BilledTokenCounts | null }) { const display = ( <> - {formatTokens(inputTokens)} /{" "} - {formatTokens(outputTokens)} + {formatTokens(billing?.input_tokens)} /{" "} + {formatTokens(billing ? billableOutputTokens(billing) : null)} ); - if ( - inputTokens == null || - outputTokens == null || - cacheReadTokens == null || - cacheWriteTokens == null - ) { - return display; - } + if (!billing) return display; return ( - - } - > + }> {display} ); @@ -189,15 +140,14 @@ export default function RunBilling({ params }: { params: { id: string } }) { if (!billing) return []; return billing.by_model .map((entry) => ({ - model: formatModelRef(entry.model) ?? EMPTY_VALUE, - stages: entry.stages, - inputTokens: entry.billing.input_tokens, - outputTokens: entry.billing.output_tokens + entry.billing.reasoning_tokens, - cacheReadTokens: entry.billing.cache_read_tokens, - cacheWriteTokens: entry.billing.cache_write_tokens, - totalUsdMicros: entry.billing.total_usd_micros, + model: formatModelRef(entry.model) ?? EMPTY_VALUE, + stages: entry.stages, + billing: entry.billing, })) - .sort((a, b) => (b.totalUsdMicros ?? -1) - (a.totalUsdMicros ?? -1)); + .sort( + (a, b) => + (b.billing.total_usd_micros ?? -1) - (a.billing.total_usd_micros ?? -1), + ); }, [billing]); // Re-derive only the in-flight rows on each tick; everything else stays put. @@ -218,16 +168,7 @@ export default function RunBilling({ params }: { params: { id: string } }) { : (billing?.totals.timing.wall_time_ms ?? 0); const hasLlmStages = (billing?.by_model.length ?? 0) > 0; - const totalInput = hasLlmStages ? (billing?.totals.input_tokens ?? null) : null; - const totalOutput = hasLlmStages && billing - ? billing.totals.output_tokens + billing.totals.reasoning_tokens - : null; - const totalCacheRead = hasLlmStages - ? (billing?.totals.cache_read_tokens ?? null) - : null; - const totalCacheWrite = hasLlmStages - ? (billing?.totals.cache_write_tokens ?? null) - : null; + const totalBilling = hasLlmStages && billing ? billing.totals : null; const totalUsdMicros = billing?.totals.total_usd_micros; const modelStageCount = modelBreakdown.reduce((sum, row) => sum + row.stages, 0); const visibleRows = rows.filter(isVisibleRow); @@ -268,18 +209,13 @@ export default function RunBilling({ params }: { params: { id: string } }) { {row.model ?? EMPTY_VALUE} - + {formatDurationMs(row.wallTimeMs)} - {formatUsdMicrosOrDash(row.totalUsdMicros)} + {formatUsdMicrosOrDash(row.billing?.total_usd_micros)} ))} @@ -289,12 +225,7 @@ export default function RunBilling({ params }: { params: { id: string } }) { Total All models - + {formatDurationMs(totalWallTimeMs)} @@ -328,15 +259,10 @@ export default function RunBilling({ params }: { params: { id: string } }) { {row.stages} - + - {formatUsdMicrosOrDash(row.totalUsdMicros)} + {formatUsdMicrosOrDash(row.billing.total_usd_micros)} ))} @@ -348,12 +274,7 @@ export default function RunBilling({ params }: { params: { id: string } }) { {modelStageCount} - + {formatUsdMicrosOrDash(totalUsdMicros)} diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 2e8f1a44e..b960fe95e 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -19,6 +19,7 @@ import { } from "../components/ui"; import { mutateRunListCaches } from "../lib/board-cache"; import { useDemoMode } from "../lib/demo-mode"; +import { useTickingNow } from "../lib/time"; import { useSWRConfig } from "swr"; import { useArchiveRun, @@ -56,10 +57,7 @@ import { lifecycleActionVisibility, updateLifecycleToastState, } from "./run-detail/lifecycle-toasts"; -import { - buildRunDetailRun, - useTickingNow, -} from "./run-detail/model"; +import { buildRunDetailRun } from "./run-detail/model"; import { buildRunDetailTabs, childRouteLayoutFlags, @@ -69,6 +67,8 @@ import { export const handle = { hideHeader: true }; +const RUN_TIMING_REFRESH_INTERVAL_MS = 30_000; + type LifecycleTrigger = () => Promise; export interface DockMeasurement { @@ -95,7 +95,7 @@ export function meta({ data }: any) { export default function RunDetail({ params }: { params: { id: string } }) { const demoMode = useDemoMode(); - const runQuery = useRun(params.id); + const runQuery = useRun(params.id, RUN_TIMING_REFRESH_INTERVAL_MS); const runStateQuery = useRunState(params.id); const summary = runQuery.data; const run = summary ? buildRunDetailRun(summary) : null; @@ -135,7 +135,10 @@ export default function RunDetail({ params }: { params: { id: string } }) { childrenCount, }); const steerBarRef = useRef(null); - const now = useTickingNow(30_000); + const now = useTickingNow( + summary != null && summary.timestamps.completed_at == null, + RUN_TIMING_REFRESH_INTERVAL_MS, + ); const { fullHeight, hideSteerBar } = childRouteLayoutFlags(matches); useRunEvents(params.id); diff --git a/apps/fabro-web/app/routes/run-detail/header.tsx b/apps/fabro-web/app/routes/run-detail/header.tsx index 70b323156..28135ec68 100644 --- a/apps/fabro-web/app/routes/run-detail/header.tsx +++ b/apps/fabro-web/app/routes/run-detail/header.tsx @@ -327,6 +327,7 @@ function DurationPopover({ }) { const endMs = completedAt != null ? Date.parse(completedAt) : now; const sinceCreatedMs = Math.max(0, endMs - Date.parse(createdAt)); + const isRunning = completedAt == null; return ( <> Duration @@ -336,8 +337,14 @@ function DurationPopover({
    {formatDurationMs(sinceCreatedMs)}
    -
    Active (inference + tools)
    +
    + Active (inference + tools){isRunning ? " — estimated" : ""} +
    {formatDurationMs(timing.active_time_ms)}
    +
    + {formatDurationMs(timing.inference_time_ms)} inference ·{" "} + {formatDurationMs(timing.tool_time_ms)} tools +
    diff --git a/apps/fabro-web/app/routes/run-detail/model.ts b/apps/fabro-web/app/routes/run-detail/model.ts index e0b853ce9..4428810e0 100644 --- a/apps/fabro-web/app/routes/run-detail/model.ts +++ b/apps/fabro-web/app/routes/run-detail/model.ts @@ -1,6 +1,3 @@ -import { useState } from "react"; - -import { useInterval } from "../../hooks/effects"; import { isRunStatus, mapRunToRunItem, @@ -8,12 +5,6 @@ import { type Run, } from "../../data/runs"; -export function useTickingNow(intervalMs: number): number { - const [now, setNow] = useState(() => Date.now()); - useInterval(() => setNow(Date.now()), intervalMs); - return now; -} - export type RunDetailRun = ReturnType & { statusLabel: string; statusDot: string; diff --git a/apps/fabro-web/app/routes/run-stages-chat.test.tsx b/apps/fabro-web/app/routes/run-stages-chat.test.tsx index c4307ebbc..1142ae6b8 100644 --- a/apps/fabro-web/app/routes/run-stages-chat.test.tsx +++ b/apps/fabro-web/app/routes/run-stages-chat.test.tsx @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { StageState } from "@qltysh/fabro-api-client"; import type { Stage } from "../lib/stage-sidebar"; +import { makeBilledTokenCounts } from "../lib/test-fixtures"; import { StageChatView } from "./run-stages"; function stage(overrides: Partial = {}): Stage { @@ -18,6 +19,7 @@ function stage(overrides: Partial = {}): Stage { resumedFromStageId: null, startedAt: "2026-04-09T12:00:00Z", providerUsed: null, + billing: makeBilledTokenCounts(), ...overrides, }; } diff --git a/apps/fabro-web/app/routes/run-stages-details.test.tsx b/apps/fabro-web/app/routes/run-stages-details.test.tsx index da18f7e67..4483ec4e9 100644 --- a/apps/fabro-web/app/routes/run-stages-details.test.tsx +++ b/apps/fabro-web/app/routes/run-stages-details.test.tsx @@ -1,9 +1,14 @@ import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; -import type { ReasoningOutput } from "@qltysh/fabro-api-client"; +import type { + BilledTokenCounts, + ReasoningOutput, + StageModelUsage, +} from "@qltysh/fabro-api-client"; -import { EventDetails } from "./run-stages"; +import { makeBilledTokenCounts } from "../lib/test-fixtures"; +import { EventDetails, ModelUsagePopover } from "./run-stages"; const RUN_START = "2026-04-09T12:00:00Z"; @@ -72,3 +77,77 @@ describe("EventDetails reasoning", () => { expect(html).toContain(`${"x".repeat(280)}…`); }); }); + +const PROVIDER_USED: StageModelUsage = { + mode: "agent", + provider: "moonshot", + model: "kimi-k3", + reasoning_effort: "max", +}; + +function popoverMarkup(counts: BilledTokenCounts): string { + return renderToStaticMarkup( + , + ); +} + +describe("ModelUsagePopover billing", () => { + test("shows the visit's token buckets and cost next to the model", () => { + const html = popoverMarkup( + makeBilledTokenCounts({ + input_tokens: 28_640, + output_tokens: 7_550, + reasoning_tokens: 1_200, + cache_read_tokens: 4_800, + cache_write_tokens: 1_500, + total_tokens: 43_690, + total_usd_micros: 720_000, + }), + ); + + expect(html).toContain("kimi-k3"); + expect(html).toContain("Cache read"); + expect(html).toContain("4.8k"); + expect(html).toContain("Cache creation"); + expect(html).toContain("1.5k"); + expect(html).toContain("Uncached"); + expect(html).toContain("28.6k"); + // Output folds in reasoning tokens, matching the Billing tab. + expect(html).toContain("Output"); + expect(html).toContain("8.8k"); + expect(html).toContain("Cost"); + expect(html).toContain("$0.72"); + }); + + test("omits the token section for a stage that called no model", () => { + const html = popoverMarkup(makeBilledTokenCounts()); + + expect(html).toContain("kimi-k3"); + expect(html).not.toContain("Tokens"); + expect(html).not.toContain("Cost"); + }); + + test("still shows tokens when nothing priced the stage", () => { + const html = popoverMarkup( + makeBilledTokenCounts({ + input_tokens: 1_000, + output_tokens: 500, + total_tokens: 1_500, + }), + ); + + expect(html).toContain("Uncached"); + expect(html).toContain("1.0k"); + expect(html).not.toContain("Cost"); + }); + + test("shows a provider-reported cost when token counts are unavailable", () => { + const html = popoverMarkup( + makeBilledTokenCounts({ total_usd_micros: 720_000 }), + ); + + expect(html).toContain("kimi-k3"); + expect(html).toContain("Cost"); + expect(html).toContain("$0.72"); + }); +}); diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 43f4410f1..0f03c6a98 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -67,7 +67,9 @@ import { formatBytes, formatDurationMs, formatTokenCount, + formatUsdMicros, } from "../lib/format"; +import { billingTokenBuckets, hasBillingUsage } from "../lib/billing"; import { plural } from "../lib/plural"; import { useRun, @@ -93,6 +95,7 @@ import { type UnknownRecord, } from "../lib/unknown"; import type { + BilledTokenCounts, EventEnvelope, ReasoningOutput, StageHandler, @@ -866,10 +869,42 @@ export function formatStageModelUsageLabel( return effort ? `${model}[${effort}]` : model; } -function ModelUsagePopover({ +const POPOVER_NUMBER = "block text-right font-mono tabular-nums"; + +/** Tokens and cost for this stage visit alone. */ +function StageBillingRows({ billing }: { billing: BilledTokenCounts }) { + if (!hasBillingUsage(billing)) return null; + const buckets = billingTokenBuckets(billing); + const cost = formatUsdMicros(billing.total_usd_micros); + return ( +
    + Tokens + + {buckets.map((bucket) => ( + + + {bucket.value === 0 + ? "0" + : formatTokenCount(bucket.value, { compactDecimal: true })} + + + ))} + {cost && ( + + {cost} + + )} + +
    + ); +} + +export function ModelUsagePopover({ providerUsed, + billing, }: { providerUsed: StageModelUsage; + billing: BilledTokenCounts; }) { return ( <> @@ -892,6 +927,7 @@ function ModelUsagePopover({ {providerUsed.speed} )} + ); } @@ -1905,6 +1941,7 @@ function EventsToolbar({ filteredCount, totalCount, providerUsed, + billing, events, runId, stageId, @@ -1924,6 +1961,7 @@ function EventsToolbar({ filteredCount: number; totalCount: number; providerUsed: StageModelUsage | null; + billing: BilledTokenCounts; events: EventEnvelope[]; runId: string; stageId: string; @@ -2004,7 +2042,9 @@ function EventsToolbar({ className={`inline-flex items-center gap-1.5 text-xs text-fg-muted ${ showFilters ? "" : "ml-auto" }`} - content={} + content={ + + } >