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
118 changes: 118 additions & 0 deletions desktop/src/features/profile/lib/agentNetworkFlow.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
135 changes: 135 additions & 0 deletions desktop/src/features/profile/lib/agentNetworkFlow.ts
Original file line number Diff line number Diff line change
@@ -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";
}
3 changes: 2 additions & 1 deletion desktop/src/features/profile/ui/UserProfilePanelMoneyTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ function NetworkSpendPlaceholder() {
<Wallet className="h-4 w-4 shrink-0 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
</section>
Expand Down
Loading