diff --git a/desktop/src/features/agents/lib/agentFleetRunway.test.mjs b/desktop/src/features/agents/lib/agentFleetRunway.test.mjs new file mode 100644 index 00000000000..794945edd8d --- /dev/null +++ b/desktop/src/features/agents/lib/agentFleetRunway.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + agentFleetRunwaySortWeight, + countLowFundsAgents, + deriveAgentFleetRunwayBadge, + sortByFleetRunway, +} from "./agentFleetRunway.ts"; + +/** + * Covers buzz#76: runway badges on AgentIdentityCard + the sidebar + * low-funds alert. Reuses buzz#80's `NetworkSpendState`/`agentNetworkFlow` + * runway derivation — these tests exercise the fleet-glance layer built on + * top of it (badge thresholds, sort order, low-funds count), including the + * "demonstrated against a deliberately drained agent" acceptance criterion + * via a synthetic depleted/near-depleted `NetworkFlowRead`. + */ + +const READ = { + depositBaseUnits: 10_000_000n, + owedBaseUnits: 4_000_000n, + creditedBaseUnits: 0n, + burnRateBaseUnitsPerSec: 100, + incomeRateBaseUnitsPerSec: 0, + incomeSampleCount: 0, +}; + +function quoted(read) { + return { kind: "quoted", read, source: "local", hasBurnSample: true }; +} + +test("unavailable/relay/pending states never fabricate a badge", () => { + assert.equal(deriveAgentFleetRunwayBadge({ kind: "relay" }), null); + assert.equal(deriveAgentFleetRunwayBadge({ kind: "pending" }), null); + assert.equal(deriveAgentFleetRunwayBadge({ kind: "unavailable" }), null); +}); + +test("a deliberately drained agent (depleted balance) reads critical", () => { + const badge = deriveAgentFleetRunwayBadge( + quoted({ ...READ, depositBaseUnits: 4_000_000n }), + ); + assert.deepEqual(badge, { level: "critical", label: "Out of funds" }); +}); + +test("runway under the critical threshold (hours left) reads critical", () => { + // 6,000,000 remaining / 100 per sec = 60,000s = ~16.7 hours, under 1 day. + const badge = deriveAgentFleetRunwayBadge(quoted(READ)); + assert.equal(badge?.level, "critical"); + assert.match(badge.label, /hr/); +}); + +test("runway under the warning threshold but over critical reads warning", () => { + // Slower burn: 6,000,000 / 10 per sec = 600,000s = ~6.9 days... too long. + // Use a burn rate that lands runway at ~2 days (172,800s). + const read = { ...READ, burnRateBaseUnitsPerSec: 6_000_000 / 172_800 }; + const badge = deriveAgentFleetRunwayBadge(quoted(read)); + assert.equal(badge?.level, "warning"); + assert.match(badge.label, /day/); +}); + +test("healthy runway (well over the warning threshold) shows no badge", () => { + const read = { ...READ, burnRateBaseUnitsPerSec: 1 }; + const badge = deriveAgentFleetRunwayBadge(quoted(read)); + assert.equal(badge, null); +}); + +test("self-funding agents show no badge — not a low-funds concern", () => { + const read = { + ...READ, + incomeRateBaseUnitsPerSec: 150, + incomeSampleCount: 5, + }; + const badge = deriveAgentFleetRunwayBadge(quoted(read)); + assert.equal(badge, null); +}); + +test("sort weight ranks critical ahead of warning ahead of everything else", () => { + const critical = { level: "critical", label: "Out of funds" }; + const warning = { level: "warning", label: "2 days left" }; + assert.ok( + agentFleetRunwaySortWeight(critical) < agentFleetRunwaySortWeight(warning), + ); + assert.ok( + agentFleetRunwaySortWeight(warning) < agentFleetRunwaySortWeight(null), + ); +}); + +test("sortByFleetRunway surfaces a deliberately drained agent ahead of healthy ones", () => { + const agents = [ + { id: "healthy-a", badge: null }, + { id: "healthy-b", badge: null }, + { id: "warning", badge: { level: "warning", label: "2 days left" } }, + { id: "drained", badge: { level: "critical", label: "Out of funds" } }, + ]; + const sorted = sortByFleetRunway(agents, (agent) => agent.badge); + assert.deepEqual( + sorted.map((agent) => agent.id), + ["drained", "warning", "healthy-a", "healthy-b"], + ); +}); + +test("sortByFleetRunway is stable — ties keep their original relative order", () => { + const agents = [ + { id: "b", badge: null }, + { id: "a", badge: null }, + ]; + const sorted = sortByFleetRunway(agents, (agent) => agent.badge); + assert.deepEqual( + sorted.map((agent) => agent.id), + ["b", "a"], + ); +}); + +test("countLowFundsAgents counts critical and warning, ignores everything else", () => { + const count = countLowFundsAgents([ + { level: "critical", label: "Out of funds" }, + null, + { level: "warning", label: "2 days left" }, + null, + ]); + assert.equal(count, 2); +}); + +test("countLowFundsAgents is zero when nothing needs attention", () => { + assert.equal(countLowFundsAgents([null, null]), 0); +}); diff --git a/desktop/src/features/agents/lib/agentFleetRunway.ts b/desktop/src/features/agents/lib/agentFleetRunway.ts new file mode 100644 index 00000000000..f9f15e331bb --- /dev/null +++ b/desktop/src/features/agents/lib/agentFleetRunway.ts @@ -0,0 +1,114 @@ +import { + deriveNetworkRunway, + type NetworkFlowRead, +} from "@/features/profile/lib/agentNetworkFlow"; +import type { NetworkSpendState } from "@/features/profile/lib/networkSpendState"; + +/** + * Fleet-glance runway badge for `AgentIdentityCard` + the sidebar low-funds + * alert (buzz#76, part of the agent-fleet-money epic toon-meta#261). Reuses + * buzz#80's `NetworkSpendState`/`agentNetworkFlow.ts` runway derivation + * rather than re-deriving it, per this ticket's own instruction. + * + * Thresholds are days-of-runway (burn-rate-relative), never absolute USDC, + * so a warning stays meaningful across a pricing change. + */ + +export const AGENT_FLEET_RUNWAY_CRITICAL_DAYS = 1; +export const AGENT_FLEET_RUNWAY_WARNING_DAYS = 3; + +const SECONDS_PER_DAY = 86_400; + +export type AgentFleetRunwayLevel = "critical" | "warning"; + +export type AgentFleetRunwayBadge = { + level: AgentFleetRunwayLevel; + label: string; +} | null; + +/** + * Null covers every state that is not an actionable warning: healthy + * runway, self-funding, and — for every agent but the identity this desktop + * process itself pays as — `unavailable`. There is no per-agent channel + * read yet (buzz#79's ADR 0006 gap, confirmed still open on this ticket), + * so an absent read must never be dressed up as a healthy one; it shows + * nothing, same as the card looks today. + */ +export function deriveAgentFleetRunwayBadge( + state: NetworkSpendState, +): AgentFleetRunwayBadge { + if (state.kind !== "quoted") return null; + return runwayBadgeForRead(state.read); +} + +function runwayBadgeForRead(read: NetworkFlowRead): AgentFleetRunwayBadge { + const runway = deriveNetworkRunway(read); + if (runway.kind === "depleted") { + return { level: "critical", label: "Out of funds" }; + } + if (runway.kind === "self-funding") return null; + + const runwayDays = runway.runwaySeconds / SECONDS_PER_DAY; + if (runwayDays < AGENT_FLEET_RUNWAY_CRITICAL_DAYS) { + return { level: "critical", label: formatRunwayLabel(runwayDays) }; + } + if (runwayDays < AGENT_FLEET_RUNWAY_WARNING_DAYS) { + return { level: "warning", label: formatRunwayLabel(runwayDays) }; + } + return null; +} + +function formatRunwayLabel(runwayDays: number): string { + if (runwayDays < 1) { + const hours = Math.max(1, Math.round(runwayDays * 24)); + return `${hours} hr${hours === 1 ? "" : "s"} left`; + } + const days = Math.round(runwayDays); + return `${days} day${days === 1 ? "" : "s"} left`; +} + +const RUNWAY_SORT_WEIGHT: Record = { + critical: 0, + warning: 1, +}; + +/** + * Sort weight so starving agents rise to the top of the Agents grid — + * critical, then warning, then everything else at an equal, unranked tier. + */ +export function agentFleetRunwaySortWeight( + badge: AgentFleetRunwayBadge, +): number { + return badge ? RUNWAY_SORT_WEIGHT[badge.level] : 2; +} + +/** + * Sort `items` so the most-starving agents surface first. `badgeOf` is + * evaluated once per item up front (it can be as expensive as picking and + * sorting a group's agents) rather than on every comparison. Uses + * `Array.sort`, which is stable — items with the same (or no) badge keep + * their existing relative order rather than being shuffled. + */ +export function sortByFleetRunway( + items: readonly T[], + badgeOf: (item: T) => AgentFleetRunwayBadge, +): T[] { + return items + .map((item) => ({ + item, + weight: agentFleetRunwaySortWeight(badgeOf(item)), + })) + .sort((a, b) => a.weight - b.weight) + .map(({ item }) => item); +} + +/** The sidebar low-funds alert's count — how many fleet agents need attention right now. */ +export function countLowFundsAgents( + badges: Iterable, +): number { + let count = 0; + for (const badge of badges) { + if (badge) count += 1; + } + return count; +} diff --git a/desktop/src/features/agents/lib/useAgentFleetRunwayBadges.ts b/desktop/src/features/agents/lib/useAgentFleetRunwayBadges.ts new file mode 100644 index 00000000000..061773a8892 --- /dev/null +++ b/desktop/src/features/agents/lib/useAgentFleetRunwayBadges.ts @@ -0,0 +1,41 @@ +import * as React from "react"; + +import { + deriveAgentFleetRunwayBadge, + type AgentFleetRunwayBadge, +} from "@/features/agents/lib/agentFleetRunway"; +import { useNetworkSpend } from "@/features/profile/lib/useNetworkSpend"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { ManagedAgent } from "@/shared/api/types"; + +/** + * Per-agent runway badges for the Agents grid + sidebar low-funds alert + * (buzz#76). Only the identity this desktop process itself pays as + * (account index 0) has a live channel read today — see + * `networkSpendState.ts`'s module doc — so every other managed agent maps + * to `null` (no badge) rather than a fabricated or stale figure. That is a + * real, documented architectural gap (buzz#79's ADR 0006), not something + * this hook works around. + */ +export function useAgentFleetRunwayBadges( + agents: readonly ManagedAgent[], +): ReadonlyMap { + const identityQuery = useIdentityQuery(); + const currentPubkey = identityQuery.data?.pubkey; + const selfSpend = useNetworkSpend(true); + const selfBadge = React.useMemo( + () => deriveAgentFleetRunwayBadge(selfSpend.state), + [selfSpend.state], + ); + + return React.useMemo(() => { + const badgeByPubkey = new Map(); + for (const agent of agents) { + const isSelf = + currentPubkey !== undefined && + agent.pubkey.toLowerCase() === currentPubkey.toLowerCase(); + badgeByPubkey.set(agent.pubkey, isSelf ? selfBadge : null); + } + return badgeByPubkey; + }, [agents, currentPubkey, selfBadge]); +} diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 19a5ef1171f..f58caac452a 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -1,14 +1,20 @@ import * as React from "react"; import { AlertTriangle, + BatteryWarning, ChevronDown, ChevronRight, RefreshCw, } from "lucide-react"; import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModelLabel"; +import { + sortByFleetRunway, + type AgentFleetRunwayBadge, +} from "@/features/agents/lib/agentFleetRunway"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; +import { useAgentFleetRunwayBadges } from "@/features/agents/lib/useAgentFleetRunwayBadges"; import { useUserProfileQuery } from "@/features/profile/hooks"; import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; @@ -101,10 +107,36 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onImportSnapshotFile, } = props; - const { groups, ungrouped, unknown } = React.useMemo( + const { + groups: unsortedGroups, + ungrouped: unsortedUngrouped, + unknown: unsortedUnknown, + } = React.useMemo( () => buildUnifiedGroups(personas, agents), [personas, agents], ); + const runwayBadges = useAgentFleetRunwayBadges(agents); + const runwayBadgeForAgent = React.useCallback( + (agent: ManagedAgent | undefined): AgentFleetRunwayBadge => + agent ? (runwayBadges.get(agent.pubkey) ?? null) : null, + [runwayBadges], + ); + // Starving agents rise to the top of the grid (buzz#76's fleet glance). + const groups = React.useMemo( + () => + sortByFleetRunway(unsortedGroups, (group) => + runwayBadgeForAgent(pickProfileAgent(group.agents)), + ), + [unsortedGroups, runwayBadgeForAgent], + ); + const ungrouped = React.useMemo( + () => sortByFleetRunway(unsortedUngrouped, runwayBadgeForAgent), + [unsortedUngrouped, runwayBadgeForAgent], + ); + const unknown = React.useMemo( + () => sortByFleetRunway(unsortedUnknown, runwayBadgeForAgent), + [unsortedUnknown, runwayBadgeForAgent], + ); const [collapsed, setCollapsed] = React.useState>(new Set()); const { fileInputRef, @@ -179,6 +211,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { defaultModel={defaultModel} key={group.persona.id} persona={group.persona} + runwayBadge={runwayBadgeForAgent(profileAgent)} startingAgentPubkey={startingAgentPubkey} startingPersonaIds={startingPersonaIds} onOpenAgentProfile={onOpenAgentProfile} @@ -203,6 +236,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { defaultModel={defaultModel} groupKey="__unknown__" label="Unknown agents" + runwayBadgeForAgent={runwayBadgeForAgent} startingAgentPubkey={startingAgentPubkey} onToggle={toggle} onOpenAgentProfile={onOpenAgentProfile} @@ -216,6 +250,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { defaultModel={defaultModel} groupKey="__ungrouped__" label="Custom agents" + runwayBadgeForAgent={runwayBadgeForAgent} startingAgentPubkey={startingAgentPubkey} onToggle={toggle} onOpenAgentProfile={onOpenAgentProfile} @@ -243,11 +278,51 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { ); } +/** The `statusBadge` slot's single warning, in priority order — operational issues before a low-funds runway warning, never both. */ +function AgentStatusBadge({ + agent, + runwayBadge, +}: { + agent: ManagedAgent | undefined; + runwayBadge: AgentFleetRunwayBadge; +}) { + if (agent?.personaOrphaned) { + return ( + + + Configuration missing + + ); + } + if (agent?.needsRestart) { + return ( + + + Restart required + + ); + } + if (runwayBadge) { + return ( + + + {runwayBadge.label} + + ); + } + return null; +} + function AgentPersonaCard({ actions, agent, defaultModel, persona, + runwayBadge, startingAgentPubkey, startingPersonaIds, onOpenAgentProfile, @@ -262,6 +337,7 @@ function AgentPersonaCard({ agent: ManagedAgent | undefined; defaultModel: string; persona: AgentPersona; + runwayBadge: AgentFleetRunwayBadge; startingAgentPubkey: string | null; startingPersonaIds: ReadonlySet; onOpenAgentProfile: ( @@ -337,19 +413,7 @@ function AgentPersonaCard({ } onOpenPersonaProfile(persona); }} - statusBadge={ - agent?.personaOrphaned ? ( - - - Configuration missing - - ) : agent?.needsRestart ? ( - - - Restart required - - ) : null - } + statusBadge={} /> ); } @@ -357,12 +421,14 @@ function AgentPersonaCard({ function StandaloneAgentCard({ agent, defaultModel, + runwayBadge, startingAgentPubkey, onOpenAgentProfile, onStartAgent, }: { agent: ManagedAgent; defaultModel: string; + runwayBadge: AgentFleetRunwayBadge; startingAgentPubkey: string | null; onOpenAgentProfile: ( pubkey: string, @@ -412,19 +478,7 @@ function StandaloneAgentCard({ opensRuntimeTab ? { tab: "runtime" } : undefined, ); }} - statusBadge={ - agent.personaOrphaned ? ( - - - Configuration missing - - ) : agent.needsRestart ? ( - - - Restart required - - ) : null - } + statusBadge={} /> ); } @@ -502,6 +556,7 @@ function CollapsibleAgentGroup({ agents, collapsed, defaultModel, + runwayBadgeForAgent, startingAgentPubkey, onToggle, onOpenAgentProfile, @@ -512,6 +567,9 @@ function CollapsibleAgentGroup({ agents: ManagedAgent[]; collapsed: ReadonlySet; defaultModel: string; + runwayBadgeForAgent: ( + agent: ManagedAgent | undefined, + ) => AgentFleetRunwayBadge; startingAgentPubkey: string | null; onToggle: (key: string) => void; onOpenAgentProfile: ( @@ -543,6 +601,7 @@ function CollapsibleAgentGroup({ agent={agent} defaultModel={defaultModel} key={agent.pubkey} + runwayBadge={runwayBadgeForAgent(agent)} startingAgentPubkey={startingAgentPubkey} onOpenAgentProfile={onOpenAgentProfile} onStartAgent={onStartAgent} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index a3b1efef6b2..8c1c5db7086 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -44,6 +44,7 @@ import { SectionQuickAction, } from "@/features/sidebar/ui/CustomChannelSection"; import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; +import { SidebarLowFundsCard } from "@/features/sidebar/ui/SidebarLowFundsCard"; import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard"; import { SidebarRelayConnectionCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard"; import type { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; @@ -885,6 +886,7 @@ export function AppSidebar({ onReconnect={relayConnectionCard.onReconnectRelay} /> ) : null} + {showSidebarUpdateCard ? (
void; +}; + +/** + * The fleet low-funds alert (buzz#76) — same `SidebarCompactActionCard` + * idiom as `SidebarRelayConnectionCard`: icon, title, one action, + * dismissible, `role=alert`. Appears only once at least one agent's runway + * has crossed a warning threshold; see `agentFleetRunway.ts` for why that + * is `0` for almost every agent today (no per-agent channel read yet). + */ +export function SidebarLowFundsCard({ onOpenFleet }: SidebarLowFundsCardProps) { + const agentsQuery = useManagedAgentsQuery(); + const agents = agentsQuery.data ?? []; + const runwayBadges = useAgentFleetRunwayBadges(agents); + const count = React.useMemo( + () => countLowFundsAgents(runwayBadges.values()), + [runwayBadges], + ); + const [dismissedAtCount, setDismissedAtCount] = React.useState( + null, + ); + + if (!shouldShowSidebarLowFundsCard(count, dismissedAtCount)) { + return null; + } + + return ( +
+
+ ); +} diff --git a/desktop/src/features/sidebar/ui/sidebarLowFundsCardVisibility.test.mjs b/desktop/src/features/sidebar/ui/sidebarLowFundsCardVisibility.test.mjs new file mode 100644 index 00000000000..ce22ca52818 --- /dev/null +++ b/desktop/src/features/sidebar/ui/sidebarLowFundsCardVisibility.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldShowSidebarLowFundsCard } from "./sidebarLowFundsCardVisibility.ts"; + +test("hidden when no agent needs attention", () => { + assert.equal(shouldShowSidebarLowFundsCard(0, null), false); +}); + +test("shown the first time an agent needs attention", () => { + assert.equal(shouldShowSidebarLowFundsCard(1, null), true); +}); + +test("stays dismissed while the count does not exceed the dismissed watermark", () => { + assert.equal(shouldShowSidebarLowFundsCard(2, 2), false); + assert.equal(shouldShowSidebarLowFundsCard(1, 2), false); +}); + +test("reappears once a new agent pushes the count past the dismissed watermark", () => { + assert.equal(shouldShowSidebarLowFundsCard(3, 2), true); +}); diff --git a/desktop/src/features/sidebar/ui/sidebarLowFundsCardVisibility.ts b/desktop/src/features/sidebar/ui/sidebarLowFundsCardVisibility.ts new file mode 100644 index 00000000000..979a97d32fa --- /dev/null +++ b/desktop/src/features/sidebar/ui/sidebarLowFundsCardVisibility.ts @@ -0,0 +1,16 @@ +/** + * Whether the fleet low-funds `SidebarCompactActionCard` should show + * (buzz#76). Mirrors `sidebarUpdateCardVisibility.ts`'s shape: a pure + * predicate the card component gates on, kept dismissed until the count + * actually gets worse than what the user already dismissed — a rescued + * agent dropping the count should not immediately re-trigger the same + * alert the user just cleared. + */ +export function shouldShowSidebarLowFundsCard( + count: number, + dismissedAtCount: number | null, +): boolean { + if (count <= 0) return false; + if (dismissedAtCount === null) return true; + return count > dismissedAtCount; +}