Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions desktop/src/features/agents/lib/agentFleetRunway.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
114 changes: 114 additions & 0 deletions desktop/src/features/agents/lib/agentFleetRunway.ts
Original file line number Diff line number Diff line change
@@ -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<AgentFleetRunwayLevel, number> = {
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<T>(
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<AgentFleetRunwayBadge>,
): number {
let count = 0;
for (const badge of badges) {
if (badge) count += 1;
}
return count;
}
41 changes: 41 additions & 0 deletions desktop/src/features/agents/lib/useAgentFleetRunwayBadges.ts
Original file line number Diff line number Diff line change
@@ -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<string, AgentFleetRunwayBadge> {
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<string, AgentFleetRunwayBadge>();
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]);
}
Loading
Loading