diff --git a/desktop/src/features/profile/lib/agentNetworkFlow.test.mjs b/desktop/src/features/profile/lib/agentNetworkFlow.test.mjs new file mode 100644 index 00000000000..55707633b5c --- /dev/null +++ b/desktop/src/features/profile/lib/agentNetworkFlow.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deriveNetworkRunway, + isEarning, + netSpendableBaseUnits, + networkRunwayCaption, + shouldSuppressLowFundsAlert, +} from "./agentNetworkFlow.ts"; + +/** + * Covers buzz#86: net flow (income beside spend, same channel) and a + * runway that accounts for income without lying about a single lucky job. + * Pure-function tests in the `paymentsOverview` / `huddleFeeQuote` mold — + * no DOM, no network. + */ + +const NO_INCOME = { + depositBaseUnits: 10_000_000n, + owedBaseUnits: 4_000_000n, + creditedBaseUnits: 0n, + burnRateBaseUnitsPerSec: 100, + incomeRateBaseUnitsPerSec: 0, + incomeSampleCount: 0, +}; + +test("net spendable nets income into the same channel, never a second pot", () => { + const read = { + ...NO_INCOME, + creditedBaseUnits: 2_000_000n, + }; + // deposit 10 - owed 4 + credited 2 = 8 + assert.equal(netSpendableBaseUnits(read), 8_000_000n); +}); + +test("net spendable floors at zero rather than going negative", () => { + const read = { + ...NO_INCOME, + depositBaseUnits: 1_000_000n, + owedBaseUnits: 1_500_000n, + }; + assert.equal(netSpendableBaseUnits(read), 0n); +}); + +test("a depleted balance reports depleted regardless of rates", () => { + const state = deriveNetworkRunway({ + ...NO_INCOME, + depositBaseUnits: 1_000_000n, + owedBaseUnits: 1_000_000n, + }); + assert.deepEqual(state, { kind: "depleted" }); + assert.match(networkRunwayCaption(state), /depleted/); +}); + +test("no income yet — runway is finite, driven by burn rate alone", () => { + const state = deriveNetworkRunway(NO_INCOME); + assert.equal(state.kind, "finite"); + if (state.kind === "finite") { + // 6,000,000 remaining / 100 base units per sec = 60,000s + assert.equal(state.runwaySeconds, 60_000); + } + assert.match(networkRunwayCaption(state), /runway left/); +}); + +test("a single payment does not flip runway to indefinite", () => { + // Income currently outpaces burn, but only one sample has ever landed — + // must not be trusted as self-funding yet. + const read = { + ...NO_INCOME, + incomeRateBaseUnitsPerSec: 500, + incomeSampleCount: 1, + }; + const state = deriveNetworkRunway(read); + assert.equal(state.kind, "finite"); + assert.equal(isEarning(read), false); +}); + +test("sustained income that covers burn is self-funding — no depletion date", () => { + const read = { + ...NO_INCOME, + incomeRateBaseUnitsPerSec: 150, + incomeSampleCount: 5, + }; + const state = deriveNetworkRunway(read); + assert.deepEqual(state, { + kind: "self-funding", + remainingBaseUnits: 6_000_000n, + }); + assert.match(networkRunwayCaption(state), /no depletion date/); + assert.equal(isEarning(read), true); + assert.equal(shouldSuppressLowFundsAlert(read), true); +}); + +test("sustained income that only partially covers burn extends but does not erase runway", () => { + const read = { + ...NO_INCOME, + incomeRateBaseUnitsPerSec: 40, + incomeSampleCount: 4, + }; + const state = deriveNetworkRunway(read); + assert.equal(state.kind, "finite"); + if (state.kind === "finite") { + // net burn = 100 - 40 = 60/sec; 6,000,000 / 60 = 100,000s + assert.equal(state.runwaySeconds, 100_000); + } + assert.equal(isEarning(read), false); + assert.equal(shouldSuppressLowFundsAlert(read), false); +}); + +test("low-funds alert is not suppressed while income is unproven", () => { + const read = { + ...NO_INCOME, + incomeRateBaseUnitsPerSec: 500, + incomeSampleCount: 2, + }; + assert.equal(shouldSuppressLowFundsAlert(read), false); +}); diff --git a/desktop/src/features/profile/lib/agentNetworkFlow.ts b/desktop/src/features/profile/lib/agentNetworkFlow.ts new file mode 100644 index 00000000000..f9af995a220 --- /dev/null +++ b/desktop/src/features/profile/lib/agentNetworkFlow.ts @@ -0,0 +1,135 @@ +import { formatUsdcBaseUnits } from "@/features/onboarding/toon/toonOnboardingFormat"; + +/** + * Net-flow domain logic for the Money tab's Network spend block (buzz#86). + * + * toon-meta#262 decision 9 puts earning on the SAME channel an agent spends + * from: `spendable = deposit − owed + credited`, one balance, never a + * second "earnings" pot. #261 decision 4 modelled money as net flow for + * exactly this reason — earning lands here without a UI rewrite. + * + * This module is deliberately decoupled from any live data source. The + * connector claim-state read this feeds from (toon-client#494's + * `getClaimState()`) is not yet vendored in this repo's pinned + * `@toon-protocol/client` (0.25.1), and the Network spend block itself + * (#80 — balance/allowance/refill, `onPaidWrite` live spend) has not + * landed, so there is no per-agent channel read to attach a UI to yet. + * These are the pure derivations #80 and the AgentIdentityCard earning + * badge / low-funds alert can wire straight into once that read exists — + * mirrors `paymentsOverview.ts`'s pure-derivation-first idiom. + * + * Per the Gotchas, income here is never read from a self-reported + * money-report event — only from `NetworkFlowRead`, the shape a connector + * claim-state read produces. + */ + +/** + * A single lucky job must not flip runway to "indefinite" — that is a lie + * that strands someone once the job stream dries up. Require sustained + * income over several samples before treating an agent as self-funding. + */ +const MIN_INCOME_SAMPLES_TO_TRUST = 3; + +/** What a connector claim-state read reports for one agent's channel. */ +export type NetworkFlowRead = { + depositBaseUnits: bigint; + /** Claimed/spent against the deposit so far. */ + owedBaseUnits: bigint; + /** Earned into this same channel — never a separate balance. */ + creditedBaseUnits: bigint; + /** Trailing-window spend rate. */ + burnRateBaseUnitsPerSec: number; + /** Trailing-window income rate. */ + incomeRateBaseUnitsPerSec: number; + /** Distinct income events observed in the trailing window. */ + incomeSampleCount: number; +}; + +/** + * `spendable = deposit − owed + credited`, floored at zero — a stale or + * racy read must never show a negative balance. + */ +export function netSpendableBaseUnits(read: NetworkFlowRead): bigint { + const net = + read.depositBaseUnits - read.owedBaseUnits + read.creditedBaseUnits; + return net > 0n ? net : 0n; +} + +/** Whether `read`'s income has enough evidence behind it to be trusted. */ +function hasTrustedIncome(read: NetworkFlowRead): boolean { + return read.incomeSampleCount >= MIN_INCOME_SAMPLES_TO_TRUST; +} + +/** The runway half of the Network spend block. */ +export type NetworkRunwayState = + | { kind: "depleted" } + /** Trusted income covers or exceeds burn — no depletion date to show. */ + | { kind: "self-funding"; remainingBaseUnits: bigint } + | { kind: "finite"; remainingBaseUnits: bigint; runwaySeconds: number }; + +/** + * Derive runway from a net-flow read. Untrusted income (too few samples) + * is excluded from the burn-rate offset entirely, so an agent's runway + * degrades to "burn rate alone" — the honest, conservative default — + * until income has proven itself sustained. + */ +export function deriveNetworkRunway(read: NetworkFlowRead): NetworkRunwayState { + const remainingBaseUnits = netSpendableBaseUnits(read); + if (remainingBaseUnits <= 0n) return { kind: "depleted" }; + + const trustedIncomeRate = hasTrustedIncome(read) + ? read.incomeRateBaseUnitsPerSec + : 0; + const netBurnRateBaseUnitsPerSec = + read.burnRateBaseUnitsPerSec - trustedIncomeRate; + + if (netBurnRateBaseUnitsPerSec <= 0) { + return { kind: "self-funding", remainingBaseUnits }; + } + + const runwaySeconds = Number(remainingBaseUnits) / netBurnRateBaseUnitsPerSec; + return { kind: "finite", remainingBaseUnits, runwaySeconds }; +} + +function formatRunwayDuration(seconds: number): string { + if (seconds < 60) return "under a minute"; + const minutes = seconds / 60; + if (minutes < 60) return `${Math.round(minutes)} min`; + const hours = minutes / 60; + if (hours < 24) return `${Math.round(hours)} hr`; + const days = Math.round(hours / 24); + return `${days} day${days === 1 ? "" : "s"}`; +} + +/** The caption the Network spend block shows under the runway row. */ +export function networkRunwayCaption(state: NetworkRunwayState): string { + switch (state.kind) { + case "depleted": + return "Balance is depleted — writes will fail until it is topped up."; + case "self-funding": + return `${formatUsdcBaseUnits(state.remainingBaseUnits)} available — income is covering spend, so there's no depletion date.`; + case "finite": + return `${formatUsdcBaseUnits(state.remainingBaseUnits)} available — about ${formatRunwayDuration(state.runwaySeconds)} of runway left.`; + } +} + +/** + * Whether an agent pays for itself — the `AgentIdentityCard` earning badge + * predicate. Requires the same trusted-income bar as runway, so the fleet + * glance never claims self-funding off one job. + */ +export function isEarning(read: NetworkFlowRead): boolean { + return ( + hasTrustedIncome(read) && + read.incomeRateBaseUnitsPerSec >= read.burnRateBaseUnitsPerSec + ); +} + +/** + * A rescue prompt for a self-funding agent is noise that teaches people to + * ignore the alert — suppress it only once `deriveNetworkRunway` has + * actually concluded the agent is self-funding. + */ +export function shouldSuppressLowFundsAlert(read: NetworkFlowRead): boolean { + return deriveNetworkRunway(read).kind === "self-funding"; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx b/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx index 8e33408ff68..14aad69a80c 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx @@ -175,7 +175,8 @@ function NetworkSpendPlaceholder() {

Balance, runway, and refill will land here — never summed with model - usage above. + usage above. Any income this agent earns nets into this same balance; + there's no separate earnings account to check.