From bcebc4508f5068425b3f62dc024cbef8adc780ad Mon Sep 17 00:00:00 2001 From: "fabro-releases[bot]" Date: Sat, 23 May 2026 10:17:18 +0000 Subject: [PATCH 1/7] Bump version to 0.242.0-nightly.1 --- Cargo.lock | 94 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ee3219ec..789a8f4ce 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", @@ -1639,7 +1639,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "chrono", "fabro-config", @@ -1660,7 +1660,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -1684,11 +1684,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 +1704,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "assert_cmd", @@ -1805,7 +1805,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "bytes", @@ -1834,7 +1834,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "chrono", @@ -1862,7 +1862,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 +1877,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "assert_cmd", @@ -1896,7 +1896,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 +1913,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "bytes", @@ -1927,7 +1927,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "base64", @@ -1949,7 +1949,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "fabro-types", @@ -1963,7 +1963,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 +1987,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "fabro-static", "http", @@ -1997,7 +1997,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "base64", @@ -2013,7 +2013,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "async-trait", "dialoguer", @@ -2028,7 +2028,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -2062,7 +2062,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 +2073,7 @@ dependencies = [ [[package]] name = "fabro-manifest" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "fabro-api", @@ -2091,7 +2091,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "fabro-config", @@ -2107,7 +2107,7 @@ dependencies = [ [[package]] name = "fabro-mcp-server" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "chrono", @@ -2134,7 +2134,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "fabro-static", "http", @@ -2150,7 +2150,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "axum", @@ -2172,7 +2172,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 +2180,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "cc", "libc", @@ -2189,7 +2189,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 +2205,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -2248,7 +2248,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -2333,7 +2333,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 +2354,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 +2392,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "base64", @@ -2418,7 +2418,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "fabro-types", @@ -2432,7 +2432,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "assert_cmd", "axum", @@ -2455,7 +2455,7 @@ dependencies = [ [[package]] name = "fabro-tool" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -2476,7 +2476,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "async-trait", @@ -2490,7 +2490,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "chrono", "clap", @@ -2511,7 +2511,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 +2531,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 +2544,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "chrono", "fabro-types", @@ -2556,7 +2556,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "anyhow", "assert_cmd", @@ -7336,7 +7336,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.241.0-nightly.1" +version = "0.242.0-nightly.1" dependencies = [ "axum", "base64", @@ -7355,7 +7355,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] From 5e3a30c1c32a60f2a30cd542705eb9d5b9030a2d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 23 May 2026 06:03:20 -0400 Subject: [PATCH 2/7] feat(web): show duration breakdown in run header popover Hovering the clock chip on a run header now reveals wall-clock-since-created alongside active (inference + tools) time. Also extends formatDurationMs to render hours and days for long-running runs instead of inflating minutes. --- apps/fabro-web/app/lib/format.ts | 16 +++++-- apps/fabro-web/app/routes/run-detail.tsx | 54 +++++++++++++++++++++--- 2 files changed, 61 insertions(+), 9 deletions(-) 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/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 91e912ea9..4ba358ee1 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -49,6 +49,7 @@ import type { PullRequestDetails, RepositoryRef, RunLifecycle, + RunTiming, WorkflowRef, } from "@qltysh/fabro-api-client"; import { useAskFabroLayout } from "../lib/ask-fabro-layout"; @@ -63,7 +64,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"; @@ -303,6 +304,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 +611,22 @@ export default function RunDetail({ params }: { params: { id: string } }) { ) : ( workflowChip )} - {run.elapsed && ( - - + {run.elapsed && summary.timing && ( + + } + > + + + )} {run.lastEventAt && ( From ed5c1dc4f476a4d3e1701656c26a4c7c4f4c1474 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 23 May 2026 06:16:27 -0400 Subject: [PATCH 3/7] feat(web): list Teams, Discord, and Jira as coming soon integrations Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/routes/settings-integrations.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 + ); } From 14e2283fcd1c684d4860f5dd275a7c66fa5670a6 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 23 May 2026 06:29:24 -0400 Subject: [PATCH 4/7] feat(web): add Waterfall view to run events page Adds a Waterfall mode to /runs/:id/events alongside the existing event log, selectable via a segmented toggle (URL-synced as ?view=, default waterfall). The waterfall renders run phases (Submitted, Queued, Initializing) derived from run.* events plus a row per stage with status-colored bars, and ticks the active stage's duration locally each second so wall_time_ms staleness doesn't freeze the display. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/components/run-waterfall.tsx | 275 ++++++++++++++++++ apps/fabro-web/app/lib/run-phases.test.ts | 129 ++++++++ apps/fabro-web/app/lib/run-phases.ts | 70 +++++ apps/fabro-web/app/routes/run-events.tsx | 159 +++++++++- 4 files changed, 623 insertions(+), 10 deletions(-) create mode 100644 apps/fabro-web/app/components/run-waterfall.tsx create mode 100644 apps/fabro-web/app/lib/run-phases.test.ts create mode 100644 apps/fabro-web/app/lib/run-phases.ts 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..9bf76c148 --- /dev/null +++ b/apps/fabro-web/app/components/run-waterfall.tsx @@ -0,0 +1,275 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router"; +import { StageState, type RunStage } from "@qltysh/fabro-api-client"; + +import { isVisibleStage } from "../data/runs"; +import { formatAbsoluteTs, formatDurationMs } from "../lib/format"; +import { formatStageLabel } 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; + tooltip: string; +} + +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 phaseRow(phase: RunPhase, nowMs: number): Row { + const endMs = phase.endMs; + const closedEnd = endMs ?? nowMs; + const durationMs = closedEnd - phase.startMs; + return { + key: `phase:${phase.kind}`, + kind: "phase", + label: phase.label, + startMs: phase.startMs, + endMs, + durationMs: durationMs >= 0 ? durationMs : null, + barClass: endMs == null ? "bg-fg-3/40 animate-pulse" : "bg-fg-3/40", + href: null, + tooltip: `${phase.label} • started ${formatAbsoluteTs(new Date(phase.startMs).toISOString())}`, + }; +} + +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)}`, + tooltip: `${formatStageLabel(stage)} • ${stage.status} • started ${formatAbsoluteTs(stage.started_at)}`, + }; +} + +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 content = ( +
+
+ {row.label} +
+
+
+
+
+ {durationLabel} +
+
+ ); + if (row.href) { + return ( + + {content} + + ); + } + return content; +} 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-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} From 6cb3d03ade338afb2139523d5f5aab7fb578af3d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 23 May 2026 06:36:51 -0400 Subject: [PATCH 5/7] feat(web): show rich hover popovers on waterfall rows Replaces the native title tooltips on waterfall phase and stage rows with HoverCard popovers that surface the status pill, started timestamp, and live elapsed/duration. Promotes PopoverHeader, Rows, and Row from run-detail.tsx into components/ui.tsx so the waterfall and run header share one set of primitives, and lets HoverCard accept a className so a full-row block trigger can host the popover. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/components/run-waterfall.tsx | 103 ++++++++++++++---- apps/fabro-web/app/components/ui.tsx | 25 ++++- apps/fabro-web/app/routes/run-detail.tsx | 31 ++---- 3 files changed, 112 insertions(+), 47 deletions(-) diff --git a/apps/fabro-web/app/components/run-waterfall.tsx b/apps/fabro-web/app/components/run-waterfall.tsx index 9bf76c148..b9b06fb08 100644 --- a/apps/fabro-web/app/components/run-waterfall.tsx +++ b/apps/fabro-web/app/components/run-waterfall.tsx @@ -1,10 +1,15 @@ -import { useEffect, useMemo, useState } from "react"; +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 } from "../lib/stage-sidebar"; +import { + formatStageLabel, + stageStatusLabel, + stageStatusTone, +} from "../lib/stage-sidebar"; import { deriveRunPhases, type RunPhase } from "../lib/run-phases"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; @@ -25,7 +30,7 @@ interface Row { durationMs: number | null; barClass: string; href: string | null; - tooltip: string; + popover: ReactNode; } const MIN_BAR_WIDTH_PCT = 0.4; @@ -71,23 +76,72 @@ function chooseTickIntervalMs(rangeMs: number): number { 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 durationMs = closedEnd - phase.startMs; + 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: durationMs >= 0 ? durationMs : null, - barClass: endMs == null ? "bg-fg-3/40 animate-pulse" : "bg-fg-3/40", + durationMs, + barClass: inFlight ? "bg-fg-3/40 animate-pulse" : "bg-fg-3/40", href: null, - tooltip: `${phase.label} • started ${formatAbsoluteTs(new Date(phase.startMs).toISOString())}`, + 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); @@ -105,7 +159,7 @@ function stageRow(runId: string, stage: RunStage, nowMs: number): Row | null { durationMs, barClass: stageBarClass(stage.status), href: `/runs/${runId}/stages/${encodeURIComponent(stage.id)}`, - tooltip: `${formatStageLabel(stage)} • ${stage.status} • started ${formatAbsoluteTs(stage.started_at)}`, + popover: stagePopover(stage, durationMs, inFlight), }; } @@ -242,11 +296,8 @@ function WaterfallRow({ row.kind === "phase" ? "text-fg-muted" : "text-fg-2"; - const content = ( -
+ const inner = ( +
{row.label}
@@ -261,15 +312,19 @@ function WaterfallRow({
); - if (row.href) { - return ( - - {content} - - ); - } - return content; + 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/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 4ba358ee1..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, @@ -193,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); From a64a58d567e3de775aff86503703934c64fe2d38 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 23 May 2026 10:23:56 -0400 Subject: [PATCH 6/7] fix: gate implement-plan PRs on CI checks --- .fabro/workflows/implement-plan/workflow.fabro | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 } From c987766641f7478b45ecc0fa17c6bd9f1f506b7f Mon Sep 17 00:00:00 2001 From: "fabro-sh-0530[bot]" <281434857+fabro-sh-0530[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 12:55:23 -0400 Subject: [PATCH 7/7] agent: use API usage baseline for compaction context estimate (#366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replaces the whole-history `chars / 4` compaction trigger with a Claude Code-style hot-path estimator: find the latest assistant turn with real provider-reported `usage.total_tokens()`, use that as the baseline, and add local char estimates only for turns appended after it. This makes compaction sensitive to actual provider-reported context usage (including cache read/write and reasoning tokens) without adding any provider token-count API calls. ### Plan Summary - **New estimator** (`estimate_active_context_usage`): returns a `ContextEstimate` with both a token count and an `ContextEstimateMethod` enum tag (`ApiUsagePlusLocalDelta` or `LocalEstimate`). - **`check_context_usage`** now returns `Option` instead of `bool`, and includes `estimate_method` in warning `details`. The caller passes the estimate directly into `compact_context`, avoiding a double-compute. - **`compact_context`** signature drops `system_prompt` (no longer needed) and takes the pre-computed `ContextEstimate`. The `CompactionStarted` event is now emitted *after* the `turns.len() <= preserve_count` no-op guard, so a no-op can never emit `Started` without `Completed`. - **`History::compact`** invalidates preserved assistant `usage` (resets to `TokenCounts::default()`) so a preserved turn's pre-compaction provider baseline never becomes the next estimate's anchor. Content, tool calls, provider parts, and response IDs are untouched. - **`session.compact_if_needed`** restructured to early-return on `None` from `check_context_usage` or on compaction disabled, simplifying the nesting. ## Key design decisions **Why invalidate preserved assistant usage?** After compaction the prior turns are gone, so a stored `total_tokens` from before compaction would overstate the new context. The authoritative billing record is in emitted run events, not in mutable runtime history. **Why return `Option` from `check_context_usage`?** Avoids recomputing the estimate in `compact_context`. It also makes the call-site idiom (`let Some(estimate) = ... else { return; }`) an explicit gate, which is cleaner than a separate bool-then-compact pattern. **Why `strum::IntoStaticStr` on the method enum?** Lets the variant serialize to a `&'static str` for the JSON `details` field without a manual `match` or adding `serde` derives. ### Fabro Details
Ran 9 stages in 30m 54s for $7.93 | Stage | Duration | Cost | Retries | |---|---|---|---| | start | 0s | – | 0 | | toolchain | 2s | – | 0 | | preflight_compile | 1m 52s | – | 0 | | preflight_lint | 2m 6s | – | 0 | | implement | 8m 11s | $3.47 | 0 | | simplify_opus | 11m 7s | $3.53 | 0 | | simplify_gpt | 2m 39s | $0.93 | 0 | | verify | 4m 4s | – | 0 | | fmt | 3s | – | 0 | | **Total** | **30m 54s** | **$7.93** | **0** |
Ran ImplementPlan.fabro (12 nodes and 15 edges) ```dot digraph ImplementPlan { graph [ goal="Implement and simplify", model_stylesheet=" * { model: claude-opus-4-7; } " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0] preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0] preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0] fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] 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] start -> toolchain toolchain -> preflight_compile [condition="outcome=succeeded"] toolchain -> exit preflight_compile -> preflight_lint [condition="outcome=succeeded"] preflight_compile -> exit preflight_lint -> implement [condition="outcome=succeeded"] preflight_lint -> fix_lints fix_lints -> preflight_lint implement -> simplify_opus -> simplify_gpt -> verify verify -> fmt [condition="outcome=succeeded"] verify -> fixup fixup -> verify fmt -> exit } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro --- Cargo.lock | 1 + lib/crates/fabro-agent/Cargo.toml | 1 + lib/crates/fabro-agent/src/compaction.rs | 343 ++++++++++++++++++----- lib/crates/fabro-agent/src/history.rs | 72 ++++- lib/crates/fabro-agent/src/session.rs | 189 +++++++++++-- 5 files changed, 518 insertions(+), 88 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 789a8f4ce..7ec0867dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1629,6 +1629,7 @@ dependencies = [ "serde_json", "sha2", "shell-escape", + "strum", "tempfile", "thiserror 2.0.18", "tokio", 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,