diff --git a/.fabro/workflows/implement-plan/workflow.fabro b/.fabro/workflows/implement-plan/workflow.fabro index e1cdc984a..bfa79d6c2 100644 --- a/.fabro/workflows/implement-plan/workflow.fabro +++ b/.fabro/workflows/implement-plan/workflow.fabro @@ -17,9 +17,8 @@ digraph ImplementPlan { implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"] simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"] simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"] - verify [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"] - fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3] - fmt [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0] + verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"] + fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3] start -> toolchain toolchain -> preflight_compile [condition="outcome=succeeded"] @@ -30,8 +29,7 @@ digraph ImplementPlan { preflight_lint -> fix_lints fix_lints -> preflight_lint implement -> simplify_opus -> simplify_gpt -> verify - verify -> fmt [condition="outcome=succeeded"] + verify -> exit [condition="outcome=succeeded"] verify -> fixup fixup -> verify - fmt -> exit } diff --git a/Cargo.lock b/Cargo.lock index 8ee3219ec..7ec0867dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1580,7 +1580,7 @@ dependencies = [ [[package]] name = "fabro-acp" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "agent-client-protocol", "agent-client-protocol-tokio", @@ -1599,7 +1599,7 @@ dependencies = [ [[package]] name = "fabro-agent" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -1629,6 +1629,7 @@ dependencies = [ "serde_json", "sha2", "shell-escape", + "strum", "tempfile", "thiserror 2.0.18", "tokio", @@ -1639,7 +1640,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "chrono", "fabro-config", @@ -1660,7 +1661,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -1684,11 +1685,11 @@ dependencies = [ [[package]] name = "fabro-build-support" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" [[package]] name = "fabro-checkpoint" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "chrono", "fabro-config", @@ -1704,7 +1705,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "assert_cmd", @@ -1805,7 +1806,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "bytes", @@ -1834,7 +1835,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "chrono", @@ -1862,7 +1863,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "async-trait", "fabro-types", @@ -1877,7 +1878,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "assert_cmd", @@ -1896,7 +1897,7 @@ dependencies = [ [[package]] name = "fabro-devcontainer" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "fabro-http", "fabro-static", @@ -1913,7 +1914,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "bytes", @@ -1927,7 +1928,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "base64", @@ -1949,7 +1950,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "fabro-types", @@ -1963,7 +1964,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "async-trait", "fabro-agent", @@ -1987,7 +1988,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "fabro-static", "http", @@ -1997,7 +1998,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "base64", @@ -2013,7 +2014,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "async-trait", "dialoguer", @@ -2028,7 +2029,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -2062,7 +2063,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "clap", "fabro-options-metadata", @@ -2073,7 +2074,7 @@ dependencies = [ [[package]] name = "fabro-manifest" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "fabro-api", @@ -2091,7 +2092,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "fabro-config", @@ -2107,7 +2108,7 @@ dependencies = [ [[package]] name = "fabro-mcp-server" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "chrono", @@ -2134,7 +2135,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "fabro-static", "http", @@ -2150,7 +2151,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "axum", @@ -2172,7 +2173,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "serde", "serde_json", @@ -2180,7 +2181,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "cc", "libc", @@ -2189,7 +2190,7 @@ dependencies = [ [[package]] name = "fabro-redact" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "aho-corasick", "ref-cast", @@ -2205,7 +2206,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -2248,7 +2249,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -2333,7 +2334,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "fabro-http", "fabro-interview", @@ -2354,18 +2355,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" [[package]] name = "fabro-store" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "async-trait", "bytes", @@ -2392,7 +2393,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "base64", @@ -2418,7 +2419,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "fabro-types", @@ -2432,7 +2433,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "assert_cmd", "axum", @@ -2455,7 +2456,7 @@ dependencies = [ [[package]] name = "fabro-tool" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -2476,7 +2477,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -2490,7 +2491,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "chrono", "clap", @@ -2511,7 +2512,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "console 0.15.11", @@ -2531,7 +2532,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "fabro-acp", "fabro-graphviz", @@ -2544,7 +2545,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "chrono", "fabro-types", @@ -2556,7 +2557,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "assert_cmd", @@ -7336,7 +7337,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "axum", "base64", @@ -7355,7 +7356,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 39008e385..2748f01a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" license = "MIT" [workspace.dependencies] diff --git a/apps/fabro-web/app/components/run-waterfall.tsx b/apps/fabro-web/app/components/run-waterfall.tsx new file mode 100644 index 000000000..b9b06fb08 --- /dev/null +++ b/apps/fabro-web/app/components/run-waterfall.tsx @@ -0,0 +1,330 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { Link } from "react-router"; +import { StageState, type RunStage } from "@qltysh/fabro-api-client"; + +import { HoverCard, PopoverHeader, PopoverRow, PopoverRows } from "./ui"; +import { isVisibleStage } from "../data/runs"; +import { formatAbsoluteTs, formatDurationMs } from "../lib/format"; +import { + formatStageLabel, + stageStatusLabel, + stageStatusTone, +} from "../lib/stage-sidebar"; +import { deriveRunPhases, type RunPhase } from "../lib/run-phases"; +import type { EventEnvelope } from "@qltysh/fabro-api-client"; + +interface WaterfallProps { + runId: string; + events: EventEnvelope[]; + stages: RunStage[]; + createdAtIso: string; + completedAtIso: string | null; +} + +interface Row { + key: string; + kind: "phase" | "stage"; + label: string; + startMs: number; + endMs: number | null; + durationMs: number | null; + barClass: string; + href: string | null; + popover: ReactNode; +} + +const MIN_BAR_WIDTH_PCT = 0.4; + +function useTickingNow(intervalMs: number): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); + return now; +} + +function stageBarClass(status: StageState): string { + switch (status) { + case StageState.RUNNING: + case StageState.RETRYING: + return "bg-teal-500 animate-pulse"; + case StageState.SUCCEEDED: + return "bg-mint"; + case StageState.PARTIALLY_SUCCEEDED: + return "bg-amber"; + case StageState.FAILED: + return "bg-coral"; + case StageState.PENDING: + return "bg-overlay-strong"; + case StageState.SKIPPED: + case StageState.CANCELLED: + return "bg-fg-muted/40"; + } +} + +function isStageInFlight(status: StageState): boolean { + return status === StageState.RUNNING || status === StageState.RETRYING; +} + +function chooseTickIntervalMs(rangeMs: number): number { + if (rangeMs < 30_000) return 5_000; + if (rangeMs < 120_000) return 15_000; + if (rangeMs < 600_000) return 60_000; + if (rangeMs < 3_600_000) return 5 * 60_000; + if (rangeMs < 6 * 3_600_000) return 30 * 60_000; + return 60 * 60_000; +} + +function phasePopover(phase: RunPhase, durationMs: number | null, inFlight: boolean): ReactNode { + return ( + <> + {phase.label} + + + {formatAbsoluteTs(new Date(phase.startMs).toISOString())} + + + + {durationMs != null ? formatDurationMs(durationMs) : "--"} + + + + + ); +} + +function phaseRow(phase: RunPhase, nowMs: number): Row { + const endMs = phase.endMs; + const inFlight = endMs == null; + const closedEnd = endMs ?? nowMs; + const rawDuration = closedEnd - phase.startMs; + const durationMs = rawDuration >= 0 ? rawDuration : null; + return { + key: `phase:${phase.kind}`, + kind: "phase", + label: phase.label, + startMs: phase.startMs, + endMs, + durationMs, + barClass: inFlight ? "bg-fg-3/40 animate-pulse" : "bg-fg-3/40", + href: null, + popover: phasePopover(phase, durationMs, inFlight), + }; +} + +function stagePopover( + stage: RunStage, + durationMs: number | null, + inFlight: boolean, +): ReactNode { + return ( + <> + {formatStageLabel(stage)} + + + + {stageStatusLabel(stage.status)} + + + {stage.started_at && ( + {formatAbsoluteTs(stage.started_at)} + )} + + + {durationMs != null ? formatDurationMs(durationMs) : "--"} + + + + + ); +} + +function stageRow(runId: string, stage: RunStage, nowMs: number): Row | null { + if (!stage.started_at) return null; + const startMs = Date.parse(stage.started_at); + if (Number.isNaN(startMs)) return null; + const inFlight = isStageInFlight(stage.status); + const wallMs = stage.wall_time_ms ?? null; + const endMs = inFlight ? null : wallMs != null ? startMs + wallMs : null; + const durationMs = inFlight ? nowMs - startMs : wallMs; + return { + key: `stage:${stage.id}`, + kind: "stage", + label: formatStageLabel(stage), + startMs, + endMs, + durationMs, + barClass: stageBarClass(stage.status), + href: `/runs/${runId}/stages/${encodeURIComponent(stage.id)}`, + popover: stagePopover(stage, durationMs, inFlight), + }; +} + +function buildRows({ + runId, + events, + stages, + createdAtIso, + nowMs, +}: { + runId: string; + events: EventEnvelope[]; + stages: RunStage[]; + createdAtIso: string; + nowMs: number; +}): Row[] { + const phases = deriveRunPhases(events, createdAtIso).map((p) => phaseRow(p, nowMs)); + const stageRows = stages + .filter((s) => isVisibleStage(s.node_id)) + .map((s) => stageRow(runId, s, nowMs)) + .filter((r): r is Row => r !== null) + .sort((a, b) => a.startMs - b.startMs); + return [...phases, ...stageRows]; +} + +export function RunWaterfall({ + runId, + events, + stages, + createdAtIso, + completedAtIso, +}: WaterfallProps) { + const nowMs = useTickingNow(1000); + const rows = useMemo( + () => buildRows({ runId, events, stages, createdAtIso, nowMs }), + [runId, events, stages, createdAtIso, nowMs], + ); + + const createdMs = Date.parse(createdAtIso); + const completedMs = completedAtIso ? Date.parse(completedAtIso) : null; + const hasInFlight = rows.some((r) => r.endMs == null); + const lastRowEnd = rows.reduce( + (max, r) => Math.max(max, r.endMs ?? r.startMs + (r.durationMs ?? 0)), + createdMs, + ); + const timelineStartMs = createdMs; + const timelineEndMs = Math.max( + timelineStartMs + 1_000, + completedMs ?? (hasInFlight ? nowMs : lastRowEnd), + ); + const rangeMs = timelineEndMs - timelineStartMs; + + const ticks = useMemo(() => { + const interval = chooseTickIntervalMs(rangeMs); + const out: { offsetMs: number; pct: number; label: string }[] = []; + for (let t = 0; t <= rangeMs + 1; t += interval) { + out.push({ + offsetMs: t, + pct: (t / rangeMs) * 100, + label: t === 0 ? "0s" : formatDurationMs(t), + }); + } + return out; + }, [rangeMs]); + + if (rows.length === 0) { + return ( +
+ Waterfall will populate as the run progresses. +
+ ); + } + + return ( +
+
+
+
+
+ {ticks.map((tick) => ( +
+
+ + {tick.label} + +
+ ))} +
+
+
+
+
+ +
+ {rows.map((row) => { + const startPct = + ((row.startMs - timelineStartMs) / rangeMs) * 100; + const closedEnd = row.endMs ?? timelineEndMs; + const rawWidthPct = ((closedEnd - row.startMs) / rangeMs) * 100; + const widthPct = Math.max(MIN_BAR_WIDTH_PCT, rawWidthPct); + const durationLabel = + row.durationMs != null ? formatDurationMs(row.durationMs) : ""; + return ( + + ); + })} +
+
+ ); +} + +function WaterfallRow({ + row, + startPct, + widthPct, + durationLabel, +}: { + row: Row; + startPct: number; + widthPct: number; + durationLabel: string; +}) { + const labelClass = + row.kind === "phase" + ? "text-fg-muted" + : "text-fg-2"; + const inner = ( +
+
+ {row.label} +
+
+
+
+
+ {durationLabel} +
+
+ ); + const trigger = row.href ? ( + + {inner} + + ) : ( + inner + ); + return ( + + {trigger} + + ); +} diff --git a/apps/fabro-web/app/components/ui.tsx b/apps/fabro-web/app/components/ui.tsx index 3b7aebe00..5f460d4e8 100644 --- a/apps/fabro-web/app/components/ui.tsx +++ b/apps/fabro-web/app/components/ui.tsx @@ -226,9 +226,11 @@ function hoverCardStyle(rect: DOMRect): CSSProperties { export function HoverCard({ content, children, + className = "inline-flex", }: { content: ReactNode; children: ReactNode; + className?: string; }) { const { open, rect, triggerProps } = useHoverAnchor(); const id = useId(); @@ -236,7 +238,7 @@ export function HoverCard({ return ( <> - + {children} {rect && portalTarget @@ -255,3 +257,24 @@ export function HoverCard({ ); } + +export function PopoverHeader({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function PopoverRows({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function PopoverRow({ label, children }: { label: string; children: ReactNode }) { + return ( + <> +
{label}
+
{children}
+ + ); +} diff --git a/apps/fabro-web/app/lib/format.ts b/apps/fabro-web/app/lib/format.ts index edc2e5154..06baf94a8 100644 --- a/apps/fabro-web/app/lib/format.ts +++ b/apps/fabro-web/app/lib/format.ts @@ -86,9 +86,19 @@ export function formatDurationMs(ms: number, fractionDigits = 1): string { const seconds = ms / 1000; return `${Number.isInteger(seconds) ? seconds : seconds.toFixed(fractionDigits)}s`; } - const minutes = Math.floor(ms / 60_000); - const seconds = Math.round((ms % 60_000) / 1000); - return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + if (ms < 3_600_000) { + const minutes = Math.floor(ms / 60_000); + const seconds = Math.round((ms % 60_000) / 1000); + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + } + if (ms < 86_400_000) { + const hours = Math.floor(ms / 3_600_000); + const minutes = Math.round((ms % 3_600_000) / 60_000); + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } + const days = Math.floor(ms / 86_400_000); + const hours = Math.round((ms % 86_400_000) / 3_600_000); + return hours > 0 ? `${days}d ${hours}h` : `${days}d`; } export function formatTokenCount( diff --git a/apps/fabro-web/app/lib/run-phases.test.ts b/apps/fabro-web/app/lib/run-phases.test.ts new file mode 100644 index 000000000..e138c70b6 --- /dev/null +++ b/apps/fabro-web/app/lib/run-phases.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; +import type { EventEnvelope } from "@qltysh/fabro-api-client"; + +import { deriveRunPhases } from "./run-phases"; + +const CREATED = "2026-05-23T12:00:00.000Z"; +const T_QUEUED = "2026-05-23T12:00:01.000Z"; +const T_STARTING = "2026-05-23T12:00:03.000Z"; +const T_RUNNING = "2026-05-23T12:00:10.000Z"; + +function makeEvent(name: string, ts: string, seq: number): EventEnvelope { + return { + id: `evt-${seq}`, + seq, + ts, + run_id: "run-1", + event: name, + } as EventEnvelope; +} + +describe("deriveRunPhases", () => { + test("returns empty for an unparseable created_at", () => { + expect(deriveRunPhases([], "not-a-date")).toEqual([]); + }); + + test("submitted phase is open-ended when no transitions have fired", () => { + const phases = deriveRunPhases([], CREATED); + expect(phases).toEqual([ + { + kind: "submitted", + label: "Submitted", + startMs: Date.parse(CREATED), + endMs: null, + }, + ]); + }); + + test("closes submitted at run.queued and opens an in-progress queued phase", () => { + const phases = deriveRunPhases( + [makeEvent("run.queued", T_QUEUED, 1)], + CREATED, + ); + expect(phases).toEqual([ + { + kind: "submitted", + label: "Submitted", + startMs: Date.parse(CREATED), + endMs: Date.parse(T_QUEUED), + }, + { + kind: "queued", + label: "Queued", + startMs: Date.parse(T_QUEUED), + endMs: null, + }, + ]); + }); + + test("emits submitted, queued, and initializing through run.running", () => { + const phases = deriveRunPhases( + [ + makeEvent("run.queued", T_QUEUED, 1), + makeEvent("run.starting", T_STARTING, 2), + makeEvent("run.running", T_RUNNING, 3), + ], + CREATED, + ); + expect(phases).toEqual([ + { + kind: "submitted", + label: "Submitted", + startMs: Date.parse(CREATED), + endMs: Date.parse(T_QUEUED), + }, + { + kind: "queued", + label: "Queued", + startMs: Date.parse(T_QUEUED), + endMs: Date.parse(T_STARTING), + }, + { + kind: "initializing", + label: "Initializing", + startMs: Date.parse(T_STARTING), + endMs: Date.parse(T_RUNNING), + }, + ]); + }); + + test("skips the queued phase when there was no run.queued event", () => { + const phases = deriveRunPhases( + [ + makeEvent("run.starting", T_STARTING, 1), + makeEvent("run.running", T_RUNNING, 2), + ], + CREATED, + ); + expect(phases.map((p) => p.kind)).toEqual(["submitted", "initializing"]); + expect(phases[0]!.endMs).toBe(Date.parse(T_STARTING)); + expect(phases[1]!.startMs).toBe(Date.parse(T_STARTING)); + expect(phases[1]!.endMs).toBe(Date.parse(T_RUNNING)); + }); + + test("uses run.starting as fallback end for submitted when queued is missing", () => { + const phases = deriveRunPhases( + [makeEvent("run.starting", T_STARTING, 1)], + CREATED, + ); + expect(phases[0]!.endMs).toBe(Date.parse(T_STARTING)); + }); + + test("ignores unrelated events", () => { + const phases = deriveRunPhases( + [ + makeEvent("agent.message", T_QUEUED, 1), + makeEvent("stage.started", T_STARTING, 2), + ], + CREATED, + ); + expect(phases).toEqual([ + { + kind: "submitted", + label: "Submitted", + startMs: Date.parse(CREATED), + endMs: null, + }, + ]); + }); +}); diff --git a/apps/fabro-web/app/lib/run-phases.ts b/apps/fabro-web/app/lib/run-phases.ts new file mode 100644 index 000000000..fb1769ae1 --- /dev/null +++ b/apps/fabro-web/app/lib/run-phases.ts @@ -0,0 +1,70 @@ +import type { EventEnvelope } from "@qltysh/fabro-api-client"; + +export type RunPhaseKind = "submitted" | "queued" | "initializing"; + +export interface RunPhase { + kind: RunPhaseKind; + label: string; + startMs: number; + endMs: number | null; +} + +const PHASE_LABEL: Record = { + submitted: "Submitted", + queued: "Queued", + initializing: "Initializing", +}; + +export function phaseLabel(kind: RunPhaseKind): string { + return PHASE_LABEL[kind]; +} + +// Stages own the timeline once `run.running` fires, so we stop slicing there. +export function deriveRunPhases( + events: ReadonlyArray | undefined, + createdAtIso: string, +): RunPhase[] { + const createdMs = Date.parse(createdAtIso); + if (Number.isNaN(createdMs)) return []; + + const firstTs = (name: string): number | null => { + if (!events) return null; + const event = events.find((e) => e.event === name); + if (!event) return null; + const ms = Date.parse(event.ts); + return Number.isNaN(ms) ? null : ms; + }; + + const queuedMs = firstTs("run.queued"); + const startingMs = firstTs("run.starting"); + const runningMs = firstTs("run.running"); + + const phases: RunPhase[] = []; + + phases.push({ + kind: "submitted", + label: PHASE_LABEL.submitted, + startMs: createdMs, + endMs: queuedMs ?? startingMs ?? runningMs, + }); + + if (queuedMs != null) { + phases.push({ + kind: "queued", + label: PHASE_LABEL.queued, + startMs: queuedMs, + endMs: startingMs ?? runningMs, + }); + } + + if (startingMs != null) { + phases.push({ + kind: "initializing", + label: PHASE_LABEL.initializing, + startMs: startingMs, + endMs: runningMs, + }); + } + + return phases; +} diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 91e912ea9..86cb6f7e4 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -38,7 +38,15 @@ import { InterviewDock } from "../components/interview-dock"; import { SteerBar, type SteerBarHandle } from "../components/steer-bar"; import { ErrorState } from "../components/state"; import { useToast } from "../components/toast"; -import { ConfirmDialog, HoverCard, SECONDARY_BUTTON_CLASS, Tooltip } from "../components/ui"; +import { + ConfirmDialog, + HoverCard, + PopoverHeader, + PopoverRow, + PopoverRows, + SECONDARY_BUTTON_CLASS, + Tooltip, +} from "../components/ui"; import { isRunStatus, mapRunToRunItem, @@ -49,6 +57,7 @@ import type { PullRequestDetails, RepositoryRef, RunLifecycle, + RunTiming, WorkflowRef, } from "@qltysh/fabro-api-client"; import { useAskFabroLayout } from "../lib/ask-fabro-layout"; @@ -63,7 +72,7 @@ import { type LifecycleMutationResult, type PreviewMutationResult, } from "../lib/mutations"; -import { formatAbsoluteTs, formatRelativeTime } from "../lib/format"; +import { formatAbsoluteTs, formatDurationMs, formatRelativeTime } from "../lib/format"; import { queryKeys } from "../lib/query-keys"; import { useRunEvents } from "../lib/run-events"; import { useRunToasts } from "../hooks/use-run-toasts"; @@ -192,27 +201,6 @@ export function meta({ data }: any) { // ---- Header hover-card popovers ---- -function PopoverHeader({ children }: { children: ReactNode }) { - return ( -
- {children} -
- ); -} - -function PopoverRows({ children }: { children: ReactNode }) { - return
{children}
; -} - -function PopoverRow({ label, children }: { label: string; children: ReactNode }) { - return ( - <> -
{label}
-
{children}
- - ); -} - function humanizeFailureReason(reason: string): string { const spaced = reason.replace(/_/g, " "); return spaced.charAt(0).toUpperCase() + spaced.slice(1); @@ -303,6 +291,36 @@ function WorkflowPopover({ ); } +function DurationPopover({ + timing, + createdAt, + completedAt, + now, +}: { + timing: RunTiming; + createdAt: string; + completedAt: string | null; + now: number; +}) { + const endMs = completedAt != null ? Date.parse(completedAt) : now; + const sinceCreatedMs = Math.max(0, endMs - Date.parse(createdAt)); + return ( + <> + Duration +
+
+
Wall-clock since created
+
{formatDurationMs(sinceCreatedMs)}
+
+
+
Active (inference + tools)
+
{formatDurationMs(timing.active_time_ms)}
+
+
+ + ); +} + function prStateBadge(details: PullRequestDetails): { label: string; className: string } { if (details.merged) return { label: "Merged", className: "bg-mint/15 text-mint" }; if (details.draft) return { label: "Draft", className: "bg-overlay-strong text-fg-3" }; @@ -580,11 +598,22 @@ export default function RunDetail({ params }: { params: { id: string } }) { ) : ( workflowChip )} - {run.elapsed && ( - - + {run.elapsed && summary.timing && ( + + } + > + + + )} {run.lastEventAt && ( diff --git a/apps/fabro-web/app/routes/run-events.tsx b/apps/fabro-web/app/routes/run-events.tsx index 2f855a39c..cfbe6a376 100644 --- a/apps/fabro-web/app/routes/run-events.tsx +++ b/apps/fabro-web/app/routes/run-events.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { useParams } from "react-router"; +import { useParams, useSearchParams } from "react-router"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; import { @@ -10,6 +10,7 @@ import { debugCategory, debugCategoryLabel, } from "../components/event-debug"; +import { RunWaterfall } from "../components/run-waterfall"; import { StageSidebar } from "../components/stage-sidebar"; import { EmptyState, ErrorState, LoadingState } from "../components/state"; import { useRun, useRunEventsList, useRunStages } from "../lib/queries"; @@ -17,11 +18,26 @@ import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; export const handle = { wide: true, fullHeight: true }; +type ViewMode = "waterfall" | "events"; + export default function RunEvents() { const { id } = useParams(); const runQuery = useRun(id); const stagesQuery = useRunStages(id); const eventsQuery = useRunEventsList(id); + const [searchParams, setSearchParams] = useSearchParams(); + const view: ViewMode = searchParams.get("view") === "events" ? "events" : "waterfall"; + const setView = (next: ViewMode) => { + setSearchParams( + (prev) => { + const params = new URLSearchParams(prev); + if (next === "waterfall") params.delete("view"); + else params.set("view", "events"); + return params; + }, + { replace: true }, + ); + }; const stages = useMemo( () => mapRunStagesToSidebarStages(stagesQuery.data), [stagesQuery.data], @@ -40,15 +56,133 @@ export default function RunEvents() { />
- void eventsQuery.mutate()} - runStart={ - runQuery.data?.timestamps.started_at ?? - runQuery.data?.timestamps.created_at - } - /> + {view === "waterfall" ? ( + { + void eventsQuery.mutate(); + void stagesQuery.mutate(); + }} + view={view} + onChangeView={setView} + /> + ) : ( + void eventsQuery.mutate()} + runStart={ + runQuery.data?.timestamps.started_at ?? + runQuery.data?.timestamps.created_at + } + view={view} + onChangeView={setView} + /> + )} +
+ ); +} + +function ViewToggle({ + value, + onChange, +}: { + value: ViewMode; + onChange: (v: ViewMode) => void; +}) { + const options: ReadonlyArray<{ value: ViewMode; label: string }> = [ + { value: "waterfall", label: "Waterfall" }, + { value: "events", label: "Events" }, + ]; + return ( +
+ {options.map((opt) => { + const active = value === opt.value; + return ( + + ); + })} +
+ ); +} + +function WaterfallPane({ + runId, + events, + eventsError, + stagesData, + stagesError, + createdAt, + completedAt, + onRetry, + view, + onChangeView, +}: { + runId: string; + events: EventEnvelope[] | undefined; + eventsError: unknown; + stagesData: ReturnType["data"]; + stagesError: unknown; + createdAt: string | undefined; + completedAt: string | null; + onRetry: () => void; + view: ViewMode; + onChangeView: (v: ViewMode) => void; +}) { + const error = eventsError ?? stagesError; + const ready = + events !== undefined && stagesData !== undefined && createdAt !== undefined; + + return ( +
+
+
+ +
+
+ {error ? ( +
+ +
+ ) : !ready ? ( +
+ +
+ ) : ( + + )}
); } @@ -58,11 +192,15 @@ function EventsView({ error, onRetry, runStart, + view, + onChangeView, }: { events: EventEnvelope[] | undefined; error: unknown; onRetry: () => void; runStart: string | undefined; + view: ViewMode; + onChangeView: (v: ViewMode) => void; }) { const [openSeq, setOpenSeq] = useState(null); const [selectedCategories, setSelectedCategories] = useState([]); @@ -134,6 +272,7 @@ function EventsView({
+ selected={selectedCategories} options={availableCategories} diff --git a/apps/fabro-web/app/routes/settings-integrations.tsx b/apps/fabro-web/app/routes/settings-integrations.tsx index cf17cfd9d..f9467cb60 100644 --- a/apps/fabro-web/app/routes/settings-integrations.tsx +++ b/apps/fabro-web/app/routes/settings-integrations.tsx @@ -49,6 +49,9 @@ function ProjectManagementPanel() { Coming Soon + + Coming Soon + ); } @@ -90,6 +93,18 @@ function SlackPanel({ settings }: { settings: ServerSettings }) { } /> + + Coming Soon + + + Coming Soon + ); } diff --git a/lib/crates/fabro-agent/Cargo.toml b/lib/crates/fabro-agent/Cargo.toml index be015c24d..e1fb7d0ab 100644 --- a/lib/crates/fabro-agent/Cargo.toml +++ b/lib/crates/fabro-agent/Cargo.toml @@ -38,6 +38,7 @@ fabro-http.workspace = true thiserror.workspace = true serde.workspace = true serde_json.workspace = true +strum.workspace = true tokio.workspace = true uuid.workspace = true futures.workspace = true diff --git a/lib/crates/fabro-agent/src/compaction.rs b/lib/crates/fabro-agent/src/compaction.rs index 01c756dc5..03d55638b 100644 --- a/lib/crates/fabro-agent/src/compaction.rs +++ b/lib/crates/fabro-agent/src/compaction.rs @@ -11,37 +11,53 @@ use crate::file_tracker::FileTracker; use crate::history::History; use crate::types::{AgentEvent, Message}; +const APPROX_CHARS_PER_TOKEN: usize = 4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub(crate) enum ContextEstimateMethod { + ApiUsagePlusLocalDelta, + LocalEstimate, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ContextEstimate { + pub tokens: usize, + pub method: ContextEstimateMethod, +} + /// Check whether the context window usage exceeds the configured threshold. /// Emits a `Warning` event with kind `"context_window"` when over the -/// threshold. Returns `true` if the threshold is exceeded. -pub fn check_context_usage( +/// threshold. Returns `Some(estimate)` if the threshold is exceeded so the +/// caller can pass it to `compact_context` without recomputing. +pub(crate) fn check_context_usage( system_prompt: &str, history: &History, provider_profile: &dyn AgentProfile, threshold_percent: usize, emitter: &Emitter, session_id: &str, -) -> bool { - let estimated_tokens = estimate_token_count(system_prompt, history); +) -> Option { + let estimate = estimate_active_context_usage(system_prompt, history); let context_window = provider_profile.context_window_size(); let threshold = context_window * threshold_percent / 100; - if estimated_tokens > threshold { + if estimate.tokens > threshold { + let usage_percent = estimate.tokens.saturating_mul(100) / context_window; + let method: &'static str = estimate.method.into(); emitter.emit(session_id.to_owned(), AgentEvent::Warning { kind: "context_window".into(), - message: format!( - "Context window usage: {}%", - estimated_tokens * 100 / context_window - ), + message: format!("Context window usage: {usage_percent}%"), details: serde_json::json!({ - "estimated_tokens": estimated_tokens, + "estimated_tokens": estimate.tokens, "context_window_size": context_window, - "usage_percent": estimated_tokens * 100 / context_window, + "usage_percent": usage_percent, + "estimate_method": method, }), }); - true + Some(estimate) } else { - false + None } } @@ -51,29 +67,29 @@ pub fn check_context_usage( clippy::too_many_arguments, reason = "Context compaction needs explicit history, model, tracking, and emission inputs." )] -pub async fn compact_context( +pub(crate) async fn compact_context( history: &mut History, llm_client: &Client, provider_profile: &dyn AgentProfile, - system_prompt: &str, file_tracker: &FileTracker, preserve_count: usize, + estimate: ContextEstimate, emitter: &Emitter, session_id: &str, ) -> Result<(), Error> { - let estimated_tokens = estimate_token_count(system_prompt, history); - let context_window = provider_profile.context_window_size(); let original_turn_count = history.turns().len(); - emitter.emit(session_id.to_owned(), AgentEvent::CompactionStarted { - estimated_tokens, - context_window_size: context_window, - }); - - // Determine turns to summarize + // Determine turns to summarize. If there are not enough turns to compact, + // do not emit a started event without a matching completion. if original_turn_count <= preserve_count { return Ok(()); } + + emitter.emit(session_id.to_owned(), AgentEvent::CompactionStarted { + estimated_tokens: estimate.tokens, + context_window_size: provider_profile.context_window_size(), + }); + let turns_to_summarize = &history.turns()[..original_turn_count - preserve_count]; let rendered = render_turns_for_summary(turns_to_summarize); @@ -139,7 +155,7 @@ function names, error messages, and exact values. Omit pleasantries and conversa "A different assistant began this task and produced the following summary. \ Build on their progress — do not repeat completed steps.\n\n{summary_text}" ); - let summary_token_estimate = summary_content.len() / 4; + let summary_token_estimate = estimate_chars_local_tokens(summary_content.len()); history.compact(preserve_count, summary_content); @@ -153,40 +169,76 @@ Build on their progress — do not repeat completed steps.\n\n{summary_text}" Ok(()) } -/// Estimate the total token count of the system prompt and conversation -/// history. Uses a rough heuristic of ~4 characters per token. -pub fn estimate_token_count(system_prompt: &str, history: &History) -> usize { - let mut total_chars = system_prompt.len(); - - for turn in history.turns() { - match turn { - Message::User { content, .. } => total_chars += content.len(), - Message::Assistant { - content, - tool_calls, - .. - } => { - total_chars += content.len(); - if let Some(r) = turn.reasoning_text() { - total_chars += r.len(); - } - for tc in tool_calls { - total_chars += tc.name.len(); - total_chars += tc.arguments.to_string().len(); - } - } - Message::ToolResults { results, .. } => { - for r in results { - total_chars += r.content.to_string().len(); - } - } - Message::System { content, .. } | Message::Steering { content, .. } => { - total_chars += content.len(); - } - } +pub(crate) fn estimate_active_context_usage( + system_prompt: &str, + history: &History, +) -> ContextEstimate { + let turns = history.turns(); + if let Some((baseline_index, baseline_tokens)) = latest_assistant_usage_baseline(turns) { + let local_delta = estimate_turns_local_tokens(&turns[baseline_index + 1..]); + return ContextEstimate { + tokens: baseline_tokens.saturating_add(local_delta), + method: ContextEstimateMethod::ApiUsagePlusLocalDelta, + }; } - total_chars / 4 // rough estimate: ~4 chars per token + ContextEstimate { + tokens: estimate_chars_local_tokens( + system_prompt + .len() + .saturating_add(estimate_turns_local_chars(turns)), + ), + method: ContextEstimateMethod::LocalEstimate, + } +} + +fn latest_assistant_usage_baseline(turns: &[Message]) -> Option<(usize, usize)> { + turns.iter().enumerate().rev().find_map(|(index, turn)| { + if let Message::Assistant { usage, .. } = turn { + let total_tokens = usage.total_tokens(); + if total_tokens > 0 { + return Some((index, usize::try_from(total_tokens).unwrap_or(usize::MAX))); + } + } + None + }) +} + +fn estimate_turns_local_tokens(turns: &[Message]) -> usize { + estimate_chars_local_tokens(estimate_turns_local_chars(turns)) +} + +fn estimate_turns_local_chars(turns: &[Message]) -> usize { + turns.iter().fold(0usize, |total, turn| { + total.saturating_add(estimate_turn_chars(turn)) + }) +} + +fn estimate_chars_local_tokens(chars: usize) -> usize { + chars / APPROX_CHARS_PER_TOKEN +} + +fn estimate_turn_chars(turn: &Message) -> usize { + match turn { + Message::User { content, .. } + | Message::System { content, .. } + | Message::Steering { content, .. } => content.len(), + Message::Assistant { + content, + tool_calls, + .. + } => { + let reasoning_chars = turn.reasoning_text().map_or(0, str::len); + let tool_call_chars: usize = tool_calls + .iter() + .map(|tc| tc.name.len() + tc.arguments.to_string().len()) + .sum(); + content.len() + reasoning_chars + tool_call_chars + } + Message::ToolResults { results, .. } => { + results.iter().map(|r| r.content.to_string().len()).sum() + } + } } /// Render conversation turns into a human-readable summary format for the @@ -313,14 +365,176 @@ mod tests { } #[test] - fn estimate_token_count_basic() { + fn estimate_local_token_count_basic() { let mut history = History::default(); history.push(Message::User { content: "Hello world".into(), // 11 chars timestamp: SystemTime::now(), }); - // system_prompt = "test" (4 chars) + 11 chars = 15 chars / 4 = 3 tokens - assert_eq!(estimate_token_count("test", &history), 3); + // system_prompt = "test" (4/4 = 1 token) + 11 chars / 4 = 2 tokens = 3 tokens + let estimate = estimate_active_context_usage("test", &history); + assert_eq!(estimate.tokens, 3); + assert_eq!(estimate.method, ContextEstimateMethod::LocalEstimate); + } + + #[test] + fn active_context_estimate_without_assistant_usage_uses_local_estimate() { + let mut history = History::default(); + history.push(Message::User { + content: "Hello world".into(), // 11 chars => 2 tokens + timestamp: SystemTime::now(), + }); + history.push(Message::Assistant { + // 18 chars content + tool call name (9) + args (16) = 43 chars => 10 tokens + content: "No usage available".into(), + tool_calls: vec![ToolCall::new( + "call_1", + "read_file", + serde_json::json!({"path": "foo.rs"}), + )], + provider_parts: vec![], + usage: Box::new(TokenCounts::default()), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), + }); + history.push(Message::ToolResults { + // 4 chars => 1 token + results: vec![ToolResult::success("call_1", serde_json::json!(1234))], + timestamp: SystemTime::now(), + }); + + let estimate = estimate_active_context_usage("test", &history); + + assert_eq!(estimate.method, ContextEstimateMethod::LocalEstimate); + // (system prompt 4 + turn chars 11 + 18 + 9 + 16 + 4) / 4 = 62/4 = 15 + assert_eq!(estimate.tokens, 15); + } + + #[test] + fn active_context_local_estimate_matches_whole_history_rounding() { + let mut history = History::default(); + history.push(Message::User { + content: "abc".into(), + timestamp: SystemTime::now(), + }); + + let estimate = estimate_active_context_usage("x", &history); + + assert_eq!(estimate.method, ContextEstimateMethod::LocalEstimate); + assert_eq!(estimate.tokens, 1); + } + + #[test] + fn active_context_estimate_uses_latest_assistant_usage_plus_later_turns() { + let mut history = History::default(); + history.push(Message::User { + content: "ignored before baseline".repeat(100), + timestamp: SystemTime::now(), + }); + history.push(Message::Assistant { + content: "baseline response".into(), + tool_calls: vec![], + provider_parts: vec![], + usage: Box::new(TokenCounts { + input_tokens: 50, + ..TokenCounts::default() + }), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), + }); + history.push(Message::ToolResults { + // JSON number renders as 4 chars => 1 local token. + results: vec![ToolResult::success("call_1", serde_json::json!(1234))], + timestamp: SystemTime::now(), + }); + history.push(Message::User { + // 16 chars => 4 local tokens. + content: "u".repeat(16), + timestamp: SystemTime::now(), + }); + history.push(Message::Steering { + // 8 chars => 2 local tokens. + content: "s".repeat(8), + timestamp: SystemTime::now(), + }); + + let estimate = estimate_active_context_usage("ignored system prompt", &history); + + assert_eq!(estimate.tokens, 57); + assert_eq!( + estimate.method, + ContextEstimateMethod::ApiUsagePlusLocalDelta + ); + } + + #[test] + fn active_context_estimate_uses_total_tokens_including_cache_and_reasoning() { + let mut history = History::default(); + history.push(Message::Assistant { + content: "short".into(), + tool_calls: vec![], + provider_parts: vec![], + usage: Box::new(TokenCounts { + input_tokens: 10, + output_tokens: 20, + reasoning_tokens: 30, + cache_read_tokens: 40, + cache_write_tokens: 50, + }), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), + }); + + let estimate = estimate_active_context_usage("", &history); + + assert_eq!(estimate.tokens, 150); + assert_eq!( + estimate.method, + ContextEstimateMethod::ApiUsagePlusLocalDelta + ); + } + + #[test] + fn active_context_estimate_ignores_earlier_usage_when_later_usage_exists() { + let mut history = History::default(); + history.push(Message::Assistant { + content: "older response".into(), + tool_calls: vec![], + provider_parts: vec![], + usage: Box::new(TokenCounts { + input_tokens: 1_000, + ..TokenCounts::default() + }), + response_id: "resp_old".into(), + timestamp: SystemTime::now(), + }); + history.push(Message::User { + content: "ignored before latest baseline".repeat(100), + timestamp: SystemTime::now(), + }); + history.push(Message::Assistant { + content: "latest response".into(), + tool_calls: vec![], + provider_parts: vec![], + usage: Box::new(TokenCounts { + input_tokens: 20, + ..TokenCounts::default() + }), + response_id: "resp_new".into(), + timestamp: SystemTime::now(), + }); + history.push(Message::User { + content: "u".repeat(8), + timestamp: SystemTime::now(), + }); + + let estimate = estimate_active_context_usage("", &history); + + assert_eq!(estimate.tokens, 22); + assert_eq!( + estimate.method, + ContextEstimateMethod::ApiUsagePlusLocalDelta + ); } #[test] @@ -329,8 +543,8 @@ mod tests { let emitter = Emitter::new(); let profile = TestProfile::new(); // Empty history, huge context window => well below threshold - let over = check_context_usage("short", &history, &profile, 80, &emitter, "sess"); - assert!(!over); + let result = check_context_usage("short", &history, &profile, 80, &emitter, "sess"); + assert!(result.is_none()); } #[test] @@ -345,11 +559,12 @@ mod tests { let mut rx = emitter.subscribe(); // TestProfile has context_window=200_000 by default; use a small one let profile = TestProfile::with_context_window(ToolRegistry::new(), 100); - let over = check_context_usage("prompt", &history, &profile, 80, &emitter, "sess"); - assert!(over); + let result = check_context_usage("prompt", &history, &profile, 80, &emitter, "sess"); + assert!(result.is_some()); // Should have emitted a Warning let event = rx.try_recv().unwrap(); - assert!(matches!(event.event, AgentEvent::Warning { .. })); + assert!(matches!(event.event, AgentEvent::Warning { details, .. } + if details["estimate_method"] == "local_estimate")); } } diff --git a/lib/crates/fabro-agent/src/history.rs b/lib/crates/fabro-agent/src/history.rs index 497900eae..e9dd10476 100644 --- a/lib/crates/fabro-agent/src/history.rs +++ b/lib/crates/fabro-agent/src/history.rs @@ -1,4 +1,4 @@ -use fabro_llm::types::{ContentPart, Message as LlmMessage, Role}; +use fabro_llm::types::{ContentPart, Message as LlmMessage, Role, TokenCounts}; use fabro_types::SessionMessage; use crate::types::Message; @@ -32,11 +32,17 @@ impl History { self.turns.iter().map(Message::to_session_message).collect() } + /// Compact the history by replacing all but the trailing `preserve_count` + /// turns with a summary `System` message. Preserved assistant turns have + /// their `usage` reset to default so a later context-window estimate does + /// not treat pre-compaction provider-reported usage as the new baseline; + /// authoritative billing is recorded via emitted run events. pub fn compact(&mut self, preserve_count: usize, summary: String) { if self.turns.len() <= preserve_count { return; } - let preserved = self.turns.split_off(self.turns.len() - preserve_count); + let mut preserved = self.turns.split_off(self.turns.len() - preserve_count); + Self::invalidate_preserved_usage(&mut preserved); let discarded = std::mem::take(&mut self.turns); let extracted_user_messages = extract_recent_user_messages(discarded, COMPACTION_USER_MESSAGE_TOKEN_BUDGET); @@ -49,6 +55,14 @@ impl History { self.strip_opaque_provider_items(); } + fn invalidate_preserved_usage(preserved: &mut [Message]) { + for turn in preserved { + if let Message::Assistant { usage, .. } = turn { + **usage = TokenCounts::default(); + } + } + } + /// Remove provider-specific opaque items that are no longer valid after /// compaction. OpenAI reasoning and message items are opaque round-trip /// data tied to specific API responses; after compaction replaces their @@ -599,6 +613,60 @@ mod tests { } } + #[test] + fn compact_preserves_assistant_data_but_resets_usage() { + let mut history = History::default(); + history.push(Message::User { + content: "old msg".into(), + timestamp: SystemTime::now(), + }); + let tool_call = ToolCall::new("call_1", "search", serde_json::json!({"query": "fabro"})); + let thinking = ContentPart::Thinking(ThinkingData { + text: "deep thought".into(), + signature: Some("sig_xyz".into()), + redacted: false, + }); + history.push(Message::Assistant { + content: "answer".into(), + tool_calls: vec![tool_call.clone()], + provider_parts: vec![thinking.clone()], + usage: Box::new(TokenCounts { + input_tokens: 10, + output_tokens: 20, + reasoning_tokens: 30, + cache_read_tokens: 40, + cache_write_tokens: 50, + }), + response_id: "resp_1".into(), + timestamp: SystemTime::now(), + }); + + history.compact(1, "Summary".into()); + + let assistant_turn = history + .turns() + .iter() + .find(|turn| matches!(turn, Message::Assistant { .. })) + .expect("preserved assistant turn"); + if let Message::Assistant { + content, + tool_calls, + provider_parts, + usage, + response_id, + .. + } = assistant_turn + { + assert_eq!(content, "answer"); + assert_eq!(tool_calls, &[tool_call]); + assert_eq!(provider_parts, &[thinking]); + assert_eq!(response_id, "resp_1"); + assert_eq!(**usage, TokenCounts::default()); + } else { + panic!("expected Assistant turn"); + } + } + #[test] fn compact_strips_reasoning_from_all_preserved_assistant_turns() { let mut history = History::default(); diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index 2d0d0b4e8..d999666e4 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -1670,31 +1670,34 @@ impl Session { } async fn compact_if_needed(&mut self) { - let over_threshold = check_context_usage( + let Some(estimate) = check_context_usage( &self.system_prompt, &self.history, self.provider_profile.as_ref(), self.config.compaction_threshold_percent, &self.event_emitter, &self.id, - ); - if over_threshold && self.config.enable_context_compaction { - if let Err(e) = compact_context( - &mut self.history, - &self.llm_client, - self.provider_profile.as_ref(), - &self.system_prompt, - &self.file_tracker, - self.config.compaction_preserve_turns, - &self.event_emitter, - &self.id, - ) - .await - { - self.event_emitter.emit(self.id.clone(), AgentEvent::Error { - error: Error::InvalidState(format!("Context compaction failed: {e}")), - }); - } + ) else { + return; + }; + if !self.config.enable_context_compaction { + return; + } + if let Err(e) = compact_context( + &mut self.history, + &self.llm_client, + self.provider_profile.as_ref(), + &self.file_tracker, + self.config.compaction_preserve_turns, + estimate, + &self.event_emitter, + &self.id, + ) + .await + { + self.event_emitter.emit(self.id.clone(), AgentEvent::Error { + error: Error::InvalidState(format!("Context compaction failed: {e}")), + }); } } @@ -1853,7 +1856,7 @@ mod tests { use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind}; use fabro_llm::provider::{ProviderAdapter, StreamEventStream}; use fabro_llm::types::{ - ContentPart, ReasoningEffort, Request, Response, Role, StreamEvent, ToolCall, + ContentPart, ReasoningEffort, Request, Response, Role, StreamEvent, TokenCounts, ToolCall, ToolDefinition, }; use futures::stream; @@ -3653,13 +3656,25 @@ mod tests { assert!(found_auth_error_event, "expected auth error event"); } + fn response_with_usage(mut response: Response, usage: TokenCounts) -> Response { + response.usage = usage; + response + } + + fn response_with_input_tokens(response: Response, input_tokens: i64) -> Response { + response_with_usage(response, TokenCounts { + input_tokens, + ..TokenCounts::default() + }) + } + #[tokio::test] async fn compaction_triggered_when_over_threshold() { // Tiny context window to trigger compaction // Responses: [0] conversation response (stream), [1] summarization (complete), // [2] unused fallback let responses = vec![ - text_response("OK"), + response_with_usage(text_response("OK"), TokenCounts::default()), text_response("Here is the summary of the conversation so far."), text_response("fallback"), ]; @@ -3704,6 +3719,90 @@ mod tests { ); } + #[tokio::test] + async fn compaction_uses_assistant_usage_baseline_for_short_response() { + let responses = vec![ + response_with_input_tokens(text_response("OK"), 90), + text_response("Here is the summary of the conversation so far."), + text_response("fallback"), + ]; + + let provider = Arc::new(MockLlmProvider::new(responses)); + let client = make_client(provider).await; + let registry = ToolRegistry::new(); + let profile = Arc::new(TestProfile::with_context_window(registry, 100)); + let env = Arc::new(MockSandbox::default()); + let config = SessionOptions { + enable_context_compaction: true, + compaction_preserve_turns: 1, + ..Default::default() + }; + let mut session = Session::new(client, profile, env, config, None); + let mut rx = session.subscribe(); + + session.process_input("hi").await.unwrap(); + + let mut started = None; + let mut found_completed = false; + while let Ok(event) = rx.try_recv() { + match event.event { + AgentEvent::CompactionStarted { + estimated_tokens, + context_window_size, + } => started = Some((estimated_tokens, context_window_size)), + AgentEvent::CompactionCompleted { .. } => found_completed = true, + _ => {} + } + } + + assert_eq!(started, Some((90, 100))); + assert!( + found_completed, + "CompactionCompleted event should be emitted" + ); + } + + #[tokio::test] + async fn compaction_noop_does_not_emit_started() { + let large_input = "x".repeat(400); + let responses = vec![text_response("OK")]; + + let provider = Arc::new(MockLlmProvider::new(responses)); + let client = make_client(provider).await; + let registry = ToolRegistry::new(); + let profile = Arc::new(TestProfile::with_context_window(registry, 100)); + let env = Arc::new(MockSandbox::default()); + let config = SessionOptions { + enable_context_compaction: true, + compaction_preserve_turns: 10, + ..Default::default() + }; + let mut session = Session::new(client, profile, env, config, None); + let mut rx = session.subscribe(); + + session.process_input(&large_input).await.unwrap(); + + let mut found_warning = false; + let mut found_compaction = false; + while let Ok(event) = rx.try_recv() { + match event.event { + AgentEvent::Warning { kind, .. } if kind == "context_window" => { + found_warning = true; + } + AgentEvent::CompactionStarted { .. } | AgentEvent::CompactionCompleted { .. } => { + found_compaction = true; + } + _ => {} + } + } + + assert!(found_warning, "threshold should have been exceeded"); + assert!( + !found_compaction, + "no-op compaction should not emit started or completed events" + ); + } + #[tokio::test] async fn compaction_not_triggered_when_disabled() { let large_input = "x".repeat(400); @@ -3735,6 +3834,49 @@ mod tests { assert!(!found_compaction, "No compaction events when disabled"); } + #[tokio::test] + async fn compaction_disabled_blocks_api_usage_baseline_compaction() { + let responses = vec![response_with_input_tokens(text_response("OK"), 90)]; + + let provider = Arc::new(MockLlmProvider::new(responses)); + let client = make_client(provider).await; + let registry = ToolRegistry::new(); + let profile = Arc::new(TestProfile::with_context_window(registry, 100)); + let env = Arc::new(MockSandbox::default()); + let config = SessionOptions { + enable_context_compaction: false, + compaction_preserve_turns: 1, + ..Default::default() + }; + let mut session = Session::new(client, profile, env, config, None); + let mut rx = session.subscribe(); + + session.process_input("hi").await.unwrap(); + + let mut found_api_usage_warning = false; + let mut found_compaction = false; + while let Ok(event) = rx.try_recv() { + match event.event { + AgentEvent::Warning { details, .. } + if details["estimated_tokens"] == 90 + && details["estimate_method"] == "api_usage_plus_local_delta" => + { + found_api_usage_warning = true; + } + AgentEvent::CompactionStarted { .. } | AgentEvent::CompactionCompleted { .. } => { + found_compaction = true; + } + _ => {} + } + } + + assert!( + found_api_usage_warning, + "API usage baseline should still drive context warning" + ); + assert!(!found_compaction, "compaction must remain disabled"); + } + #[tokio::test] async fn compaction_failure_is_non_fatal() { // Response [0] = conversation response (stream), [1] will be used for @@ -3789,7 +3931,10 @@ mod tests { } let large_input = "x".repeat(400); - let responses = vec![text_response("OK")]; + let responses = vec![response_with_usage( + text_response("OK"), + TokenCounts::default(), + )]; let provider = Arc::new(StreamOnlyProvider { responses,