Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
Bryan Helmkamp 2026-05-23 12:55:53 -04:00
commit 93f87ce62d
No known key found for this signature in database
15 changed files with 1356 additions and 183 deletions

View file

@ -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
}

95
Cargo.lock generated
View file

@ -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",

View file

@ -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]

View file

@ -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 (
<>
<PopoverHeader>{phase.label}</PopoverHeader>
<PopoverRows>
<PopoverRow label="Started">
{formatAbsoluteTs(new Date(phase.startMs).toISOString())}
</PopoverRow>
<PopoverRow label={inFlight ? "Elapsed" : "Duration"}>
<span className="font-mono">
{durationMs != null ? formatDurationMs(durationMs) : "--"}
</span>
</PopoverRow>
</PopoverRows>
</>
);
}
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 (
<>
<PopoverHeader>{formatStageLabel(stage)}</PopoverHeader>
<PopoverRows>
<PopoverRow label="Status">
<span
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium ${stageStatusTone(stage.status)}`}
>
{stageStatusLabel(stage.status)}
</span>
</PopoverRow>
{stage.started_at && (
<PopoverRow label="Started">{formatAbsoluteTs(stage.started_at)}</PopoverRow>
)}
<PopoverRow label={inFlight ? "Elapsed" : "Duration"}>
<span className="font-mono">
{durationMs != null ? formatDurationMs(durationMs) : "--"}
</span>
</PopoverRow>
</PopoverRows>
</>
);
}
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 (
<div className="px-4 py-12 text-sm text-fg-muted">
Waterfall will populate as the run progresses.
</div>
);
}
return (
<div className="min-w-0 flex-1 overflow-y-auto pt-2 pb-[calc(1.5rem+var(--fabro-interview-dock-clearance,0px))]">
<div className="sticky top-0 z-10 bg-page">
<div className="flex items-end gap-3 px-3 pb-1">
<div className="w-48 shrink-0" />
<div className="relative h-5 flex-1">
{ticks.map((tick) => (
<div
key={tick.offsetMs}
className="absolute top-0 h-full"
style={{ left: `${tick.pct}%` }}
>
<div className="absolute top-0 h-2 w-px bg-line-strong" />
<span className="absolute top-2 -translate-x-1/2 whitespace-nowrap font-mono text-[10px] text-fg-muted">
{tick.label}
</span>
</div>
))}
</div>
<div className="w-16 shrink-0" />
</div>
<div className="border-b border-line" />
</div>
<div>
{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 (
<WaterfallRow
key={row.key}
row={row}
startPct={startPct}
widthPct={widthPct}
durationLabel={durationLabel}
/>
);
})}
</div>
</div>
);
}
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 = (
<div className="flex items-center gap-3 px-3 py-1.5 hover:bg-overlay">
<div className={`w-48 shrink-0 truncate font-mono text-xs ${labelClass}`}>
{row.label}
</div>
<div className="relative h-3 flex-1">
<div
className={`absolute top-0 h-full rounded-sm ${row.barClass}`}
style={{ left: `${startPct}%`, width: `${widthPct}%` }}
/>
</div>
<div className="w-16 shrink-0 text-right font-mono text-[11px] tabular-nums text-fg-muted">
{durationLabel}
</div>
</div>
);
const trigger = row.href ? (
<Link
to={row.href}
className="block focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
>
{inner}
</Link>
) : (
inner
);
return (
<HoverCard content={row.popover} className="block">
{trigger}
</HoverCard>
);
}

View file

@ -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 (
<>
<span {...triggerProps} aria-describedby={open ? id : undefined} className="inline-flex">
<span {...triggerProps} aria-describedby={open ? id : undefined} className={className}>
{children}
</span>
{rect && portalTarget
@ -255,3 +257,24 @@ export function HoverCard({
</>
);
}
export function PopoverHeader({ children }: { children: ReactNode }) {
return (
<div className="mb-1.5 border-b border-line pb-1 font-medium text-fg-2">
{children}
</div>
);
}
export function PopoverRows({ children }: { children: ReactNode }) {
return <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">{children}</dl>;
}
export function PopoverRow({ label, children }: { label: string; children: ReactNode }) {
return (
<>
<dt className="text-fg-3">{label}</dt>
<dd className="min-w-0 text-fg">{children}</dd>
</>
);
}

View file

@ -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(

View file

@ -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,
},
]);
});
});

View file

@ -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<RunPhaseKind, string> = {
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<EventEnvelope> | 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;
}

View file

@ -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 (
<div className="mb-1.5 border-b border-line pb-1 font-medium text-fg-2">
{children}
</div>
);
}
function PopoverRows({ children }: { children: ReactNode }) {
return <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">{children}</dl>;
}
function PopoverRow({ label, children }: { label: string; children: ReactNode }) {
return (
<>
<dt className="text-fg-3">{label}</dt>
<dd className="min-w-0 text-fg">{children}</dd>
</>
);
}
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 (
<>
<PopoverHeader>Duration</PopoverHeader>
<dl className="space-y-2">
<div>
<dt className="text-fg-3">Wall-clock since created</dt>
<dd className="mt-0.5 font-mono text-fg">{formatDurationMs(sinceCreatedMs)}</dd>
</div>
<div>
<dt className="text-fg-3">Active (inference + tools)</dt>
<dd className="mt-0.5 font-mono text-fg">{formatDurationMs(timing.active_time_ms)}</dd>
</div>
</dl>
</>
);
}
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 && (
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
<ClockIcon className="size-3.5" aria-hidden="true" />
{run.elapsed}
</span>
{run.elapsed && summary.timing && (
<HoverCard
content={
<DurationPopover
timing={summary.timing}
createdAt={summary.timestamps.created_at}
completedAt={summary.timestamps.completed_at}
now={now}
/>
}
>
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
<ClockIcon className="size-3.5" aria-hidden="true" />
{run.elapsed}
</span>
</HoverCard>
)}
{run.lastEventAt && (
<Tooltip label={`Last event ${formatAbsoluteTs(run.lastEventAt)}`}>

View file

@ -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() {
/>
</div>
<EventsView
events={eventsQuery.data}
error={eventsQuery.error}
onRetry={() => void eventsQuery.mutate()}
runStart={
runQuery.data?.timestamps.started_at ??
runQuery.data?.timestamps.created_at
}
/>
{view === "waterfall" ? (
<WaterfallPane
runId={id!}
events={eventsQuery.data}
eventsError={eventsQuery.error}
stagesData={stagesQuery.data}
stagesError={stagesQuery.error}
createdAt={runQuery.data?.timestamps.created_at}
completedAt={runQuery.data?.timestamps.completed_at ?? null}
onRetry={() => {
void eventsQuery.mutate();
void stagesQuery.mutate();
}}
view={view}
onChangeView={setView}
/>
) : (
<EventsView
events={eventsQuery.data}
error={eventsQuery.error}
onRetry={() => void eventsQuery.mutate()}
runStart={
runQuery.data?.timestamps.started_at ??
runQuery.data?.timestamps.created_at
}
view={view}
onChangeView={setView}
/>
)}
</div>
);
}
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 (
<div
role="group"
aria-label="View"
className="inline-flex rounded-md bg-panel p-0.5 outline-1 -outline-offset-1 outline-line-strong"
>
{options.map((opt) => {
const active = value === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
aria-pressed={active}
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500 ${
active
? "bg-overlay-strong text-fg"
: "text-fg-muted hover:text-fg-2"
}`}
>
{opt.label}
</button>
);
})}
</div>
);
}
function WaterfallPane({
runId,
events,
eventsError,
stagesData,
stagesError,
createdAt,
completedAt,
onRetry,
view,
onChangeView,
}: {
runId: string;
events: EventEnvelope[] | undefined;
eventsError: unknown;
stagesData: ReturnType<typeof useRunStages>["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 (
<div className="flex min-h-0 min-w-0 flex-1 flex-col pt-3">
<div className="shrink-0 border-b border-line">
<div className="flex items-center gap-3 pb-3 pl-3 pr-4 sm:pr-6 lg:pr-8">
<ViewToggle value={view} onChange={onChangeView} />
</div>
</div>
{error ? (
<div className="min-w-0 flex-1 pt-3">
<ErrorState
title="Couldn't load waterfall"
description={errorMessage(error)}
onRetry={onRetry}
/>
</div>
) : !ready ? (
<div className="min-w-0 flex-1 pt-3">
<LoadingState label="Loading waterfall…" />
</div>
) : (
<RunWaterfall
runId={runId}
events={events!}
stages={stagesData!.data ?? []}
createdAtIso={createdAt!}
completedAtIso={completedAt}
/>
)}
</div>
);
}
@ -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<number | null>(null);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
@ -134,6 +272,7 @@ function EventsView({
<div className="pl-3 pr-4 sm:pr-6 lg:pr-8">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 pb-3">
<div className="flex flex-1 flex-wrap items-center gap-2">
<ViewToggle value={view} onChange={onChangeView} />
<MultiSelectFilter<string>
selected={selectedCategories}
options={availableCategories}

View file

@ -49,6 +49,9 @@ function ProjectManagementPanel() {
<Row title="Linear" help="Sync runs with Linear issues and projects.">
<span className="text-sm text-fg-muted">Coming Soon</span>
</Row>
<Row title="Jira" help="Sync runs with Jira issues and projects.">
<span className="text-sm text-fg-muted">Coming Soon</span>
</Row>
</Panel>
);
}
@ -90,6 +93,18 @@ function SlackPanel({ settings }: { settings: ServerSettings }) {
}
/>
</Row>
<Row
title="Microsoft Teams"
help="Channel app for run notifications and approvals."
>
<span className="text-sm text-fg-muted">Coming Soon</span>
</Row>
<Row
title="Discord"
help="Server app for run notifications and approvals."
>
<span className="text-sm text-fg-muted">Coming Soon</span>
</Row>
</Panel>
);
}

View file

@ -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

View file

@ -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<ContextEstimate> {
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"));
}
}

View file

@ -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();

View file

@ -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,