From cc2139b9470ec561efa330af503224e6a2b8a3f9 Mon Sep 17 00:00:00 2001 From: Damilola Ogunrotimi <98775983+Fury03@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:25:37 +0000 Subject: [PATCH] test: add focused unit tests for orchestrator budget guardrails --- .github/workflows/test.yml | 31 +++ package.json | 3 +- src/agents/budget.js | 159 ++++++++++++++++ src/agents/orchestrator.js | 48 +++-- tests/orchestrator.budget.test.js | 307 ++++++++++++++++++++++++++++++ 5 files changed, 522 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 src/agents/budget.js create mode 100644 tests/orchestrator.budget.test.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a31211a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: Tests + +on: + push: + branches: [ "**" ] + pull_request: + branches: [ "**" ] + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm test + + # Fast, dependency-free guardrail suite — also runs on its own so a + # failure here is unambiguous even if install/other suites have issues. + - name: Budget guardrail tests + run: npm run test:budget diff --git a/package.json b/package.json index 187fef1..bdd522f 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,9 @@ "record:narrated": "node src/render-narrated-demo.js", "setup": "node src/setup-wallets.js", "setup:usdc": "node src/setup-usdc.js", - "test": "node src/agents/settlement-header.test.js", + "test": "node src/agents/settlement-header.test.js && node tests/orchestrator.budget.test.js", "test:parser": "node src/agents/settlement-header.test.js", + "test:budget": "node tests/orchestrator.budget.test.js", "test:demo": "node src/demo.js" }, "keywords": [ diff --git a/src/agents/budget.js b/src/agents/budget.js new file mode 100644 index 0000000..c4ee7a2 --- /dev/null +++ b/src/agents/budget.js @@ -0,0 +1,159 @@ +/** + * Budget guardrails & payment-outcome accounting for the orchestrator. + * + * This module is intentionally dependency-free (no SDK, network, or config + * imports) so the guardrail logic can be unit-tested deterministically in + * isolation — mirroring the `settlement-header.js` pattern. `orchestrator.js` + * imports these helpers so the tests guard the *real* code path rather than a + * re-implementation. + * + * All monetary values are USDC. Costs are derived from `agent.price` (a string + * in the registry), matching the orchestrator's historical behavior. + */ + +/** + * Resolve an agent's per-call cost as a number. + * Returns NaN for malformed prices (preserving `parseFloat` semantics). + * @param {{ price?: string }} agent + * @returns {number} + */ +export function agentCost(agent) { + return parseFloat(agent?.price); +} + +/** + * Remaining budget headroom (may be negative if already overspent). + * @param {number} budget + * @param {number} totalSpent + * @returns {number} + */ +export function remainingBudget(budget, totalSpent) { + return budget - totalSpent; +} + +/** + * Format a USDC amount for display/reporting (4 decimal places). + * @param {number} value + * @returns {string} + */ +export function formatAmount(value) { + return Number(value).toFixed(4); +} + +/** + * Core guardrail predicate: would running this step push spend over budget? + * Strict greater-than means a step whose cost exactly consumes the remaining + * budget is still allowed to run. + * @param {number} totalSpent + * @param {number} cost + * @param {number} budget + * @returns {boolean} + */ +export function exceedsBudget(totalSpent, cost, budget) { + return totalSpent + cost > budget; +} + +/** + * Build the skipped-step record pushed onto `results` when a step is denied + * by the budget guardrail. + * @param {{ id: string, price: string }} agent + * @param {number} budget + * @param {number} totalSpent + * @returns {{ agentId: string, skipped: true, reason: string }} + */ +export function buildSkipResult(agent, budget, totalSpent) { + return { + agentId: agent.id, + skipped: true, + reason: `Budget limit (${formatAmount(remainingBudget(budget, totalSpent))} USDC remaining, need ${agent.price})`, + }; +} + +/** + * Build the `budget_limit` broadcast event payload (without a timestamp; the + * caller is responsible for stamping it so this stays deterministic). + * @param {{ name: string, price: string }} agent + * @param {number} budget + * @param {number} totalSpent + * @returns {{ type: 'budget_limit', agent: string, cost: string, remaining: string }} + */ +export function buildBudgetLimitEvent(agent, budget, totalSpent) { + return { + type: 'budget_limit', + agent: agent.name, + cost: agent.price, + remaining: formatAmount(remainingBudget(budget, totalSpent)), + }; +} + +/** + * Classify a single step's payment outcome into an accounting bucket. + * Anything that is not a confirmed x402 or direct-XLM payment counts as unpaid. + * @param {string|undefined|null} paidVia + * @returns {'x402' | 'stellar-xlm' | 'unpaid'} + */ +export function paymentBucket(paidVia) { + if (paidVia === 'x402') return 'x402'; + if (paidVia === 'stellar-xlm-direct') return 'stellar-xlm'; + return 'unpaid'; +} + +/** + * Tally a list of `paidVia` values into the three counters the orchestrator + * reports. Empty input yields all zeros. + * @param {Array} paidViaList + * @returns {{ x402PaymentCount: number, xlmFallbackCount: number, unpaidCount: number }} + */ +export function tallyPaymentOutcomes(paidViaList) { + const counts = { x402PaymentCount: 0, xlmFallbackCount: 0, unpaidCount: 0 }; + for (const paidVia of paidViaList || []) { + const bucket = paymentBucket(paidVia); + if (bucket === 'x402') counts.x402PaymentCount += 1; + else if (bucket === 'stellar-xlm') counts.xlmFallbackCount += 1; + else counts.unpaidCount += 1; + } + return counts; +} + +/** + * Summarize the dominant payment protocol used across a run. + * @param {number} x402Count + * @param {number} xlmFallbackCount + * @returns {'x402' | 'stellar-xlm' | 'mixed' | 'none'} + */ +export function paymentProtocolSummary(x402Count, xlmFallbackCount) { + if (x402Count > 0 && xlmFallbackCount === 0) return 'x402'; + if (x402Count === 0 && xlmFallbackCount > 0) return 'stellar-xlm'; + if (x402Count > 0 && xlmFallbackCount > 0) return 'mixed'; + return 'none'; +} + +/** + * Whether the budget has been fully consumed (used for the final summary). + * @param {number} totalSpent + * @param {number} budget + * @returns {boolean} + */ +export function isBudgetExhausted(totalSpent, budget) { + return totalSpent >= budget; +} + +/** + * Count steps that actually ran (everything not explicitly skipped). + * NOTE: this intentionally counts "agent not found" error entries as used, + * preserving the orchestrator's existing behavior — see the test that pins it. + * @param {Array<{ skipped?: boolean }>} results + * @returns {number} + */ +export function countUsed(results) { + return results.filter((r) => !r.skipped).length; +} + +/** + * Count steps skipped by the budget guardrail. + * @param {Array<{ skipped?: boolean }>} results + * @returns {number} + */ +export function countSkipped(results) { + return results.filter((r) => r.skipped).length; +} diff --git a/src/agents/orchestrator.js b/src/agents/orchestrator.js index a6b13f0..405cb4f 100644 --- a/src/agents/orchestrator.js +++ b/src/agents/orchestrator.js @@ -6,6 +6,17 @@ import { getBalance, sendPayment } from '../stellar/wallet.js'; import { x402Client, x402HTTPClient, wrapFetchWithPayment } from '@x402/fetch'; import { ExactStellarScheme, createEd25519Signer } from '@x402/stellar'; import { parseSettlementHeader, extractTxHash } from './settlement-header.js'; +import { + agentCost, + exceedsBudget, + buildSkipResult, + buildBudgetLimitEvent, + paymentBucket, + paymentProtocolSummary, + isBudgetExhausted, + countUsed, + countSkipped, +} from './budget.js'; // const anthropic = ... (imported from services.js) @@ -119,13 +130,6 @@ async function parseResponseBody(response) { } } -function paymentProtocolSummary(x402Count, xlmFallbackCount) { - if (x402Count > 0 && xlmFallbackCount === 0) return 'x402'; - if (x402Count === 0 && xlmFallbackCount > 0) return 'stellar-xlm'; - if (x402Count > 0 && xlmFallbackCount > 0) return 'mixed'; - return 'none'; -} - async function callAgentViaX402(agent, input, broadcastFn) { const baseUrl = config.internalBaseUrl; const endpointFn = PREMIUM_ENDPOINT_MAP[agent.id]; @@ -352,21 +356,14 @@ Respond ONLY with valid JSON (no markdown, no code fences): continue; } - const cost = parseFloat(agent.price); + const cost = agentCost(agent); - if (totalSpent + cost > budget) { + if (exceedsBudget(totalSpent, cost, budget)) { broadcastFn?.({ - type: 'budget_limit', - agent: agent.name, - cost: agent.price, - remaining: (budget - totalSpent).toFixed(4), + ...buildBudgetLimitEvent(agent, budget, totalSpent), timestamp: new Date().toISOString(), }); - results.push({ - agentId: agent.id, - skipped: true, - reason: `Budget limit (${(budget - totalSpent).toFixed(4)} USDC remaining, need ${agent.price})`, - }); + results.push(buildSkipResult(agent, budget, totalSpent)); continue; } @@ -394,8 +391,9 @@ Respond ONLY with valid JSON (no markdown, no code fences): } totalSpent += cost; - if (agentResponse.paidVia === 'x402') x402PaymentCount += 1; - else if (agentResponse.paidVia === 'stellar-xlm-direct') xlmFallbackCount += 1; + const bucket = paymentBucket(agentResponse.paidVia); + if (bucket === 'x402') x402PaymentCount += 1; + else if (bucket === 'stellar-xlm') xlmFallbackCount += 1; else unpaidCount += 1; const agentResult = { @@ -443,7 +441,7 @@ Respond ONLY with valid JSON (no markdown, no code fences): } const elapsed = Date.now() - startTime; - const budgetExhausted = totalSpent >= budget; + const budgetExhausted = isBudgetExhausted(totalSpent, budget); const paymentProtocol = paymentProtocolSummary(x402PaymentCount, xlmFallbackCount); const successfulPayments = payments.filter(p => p.paymentSuccess); const successfulTxs = successfulPayments.filter(p => p.txHash); @@ -451,8 +449,8 @@ Respond ONLY with valid JSON (no markdown, no code fences): broadcastFn?.({ type: 'orchestrator_complete', totalSpent: totalSpent.toFixed(4), - agentsUsed: results.filter(r => !r.skipped).length, - agentsSkipped: results.filter(r => r.skipped).length, + agentsUsed: countUsed(results), + agentsSkipped: countSkipped(results), elapsed: `${elapsed}ms`, budgetExhausted, paymentProtocol, @@ -470,8 +468,8 @@ Respond ONLY with valid JSON (no markdown, no code fences): budget, totalSpent: totalSpent.toFixed(4), budgetExhausted, - agentsUsed: results.filter(r => !r.skipped).length, - agentsSkipped: results.filter(r => r.skipped).length, + agentsUsed: countUsed(results), + agentsSkipped: countSkipped(results), paymentProtocol, x402PaymentCount, xlmFallbackCount, diff --git a/tests/orchestrator.budget.test.js b/tests/orchestrator.budget.test.js new file mode 100644 index 0000000..a8d5df7 --- /dev/null +++ b/tests/orchestrator.budget.test.js @@ -0,0 +1,307 @@ +/** + * Focused unit tests for orchestrator budget guardrails. + * + * Covers (per acceptance criteria): + * - `totalSpent + cost > budget` skip behavior (incl. the strict-inequality boundary) + * - mixed payment outcomes and fallback modes + * - final summary totals and skipped-step reporting + * - edge cases: tiny budgets, zero budget, parse failures (NaN) + * + * These import the SAME functions `orchestrator.js` uses, so a regression in the + * real guardrail fails this suite. Dependency-free → deterministic and fast. + * + * Run: node tests/orchestrator.budget.test.js + */ + +import assert from 'node:assert'; +import { + agentCost, + remainingBudget, + formatAmount, + exceedsBudget, + buildSkipResult, + buildBudgetLimitEvent, + paymentBucket, + tallyPaymentOutcomes, + paymentProtocolSummary, + isBudgetExhausted, + countUsed, + countSkipped, +} from '../src/agents/budget.js'; + +// ─── Tiny test harness (collect-all, fail-fast exit) ───────────── +const failures = []; +let passed = 0; + +function test(name, fn) { + try { + fn(); + passed += 1; + console.log(` ✓ ${name}`); + } catch (err) { + failures.push({ name, err }); + console.error(` ✗ ${name}\n ${err.message.replace(/\n/g, '\n ')}`); + } +} + +// Registry-accurate fixtures (prices mirror src/agents/registry.js) +const research = { id: 'research-bot', name: '🔬 Research Agent', price: '0.01' }; +const summary = { id: 'summary-bot', name: '📝 Summary Agent', price: '0.01' }; +const analyst = { id: 'analyst-bot', name: '📊 Analysis Agent', price: '0.05' }; +const code = { id: 'code-bot', name: '💻 Code Agent', price: '0.03' }; + +// ─── agentCost / parse failures ────────────────────────────────── +console.log('agentCost'); +test('parses a well-formed price string', () => { + assert.strictEqual(agentCost(analyst), 0.05); + assert.strictEqual(agentCost(code), 0.03); +}); +test('returns NaN for a malformed price (parse failure)', () => { + assert.ok(Number.isNaN(agentCost({ price: 'abc' }))); + assert.ok(Number.isNaN(agentCost({ price: undefined }))); + assert.ok(Number.isNaN(agentCost({}))); +}); + +// ─── exceedsBudget: core skip behavior ─────────────────────────── +console.log('exceedsBudget (totalSpent + cost > budget)'); +test('allows a step that fits under budget', () => { + assert.strictEqual(exceedsBudget(0, 0.01, 0.10), false); +}); +test('allows a step whose cost exactly consumes remaining budget (strict >)', () => { + // 0 + 0.05 > 0.05 → false → must NOT skip. Guards against `>` becoming `>=`. + assert.strictEqual(exceedsBudget(0, 0.05, 0.05), false); + assert.strictEqual(exceedsBudget(0.04, 0.01, 0.05), false); +}); +test('skips a step that would overshoot by any amount', () => { + assert.strictEqual(exceedsBudget(0, 0.05, 0.04), true); + assert.strictEqual(exceedsBudget(0.05, 0.01, 0.05), true); +}); +test('skips on accumulated spend even when each step is individually cheap', () => { + // research(0.01)+summary(0.01)=0.02 spent; analyst(0.05) would hit 0.07 > 0.06 + assert.strictEqual(exceedsBudget(0.02, 0.05, 0.06), true); +}); + +// ─── Edge cases: zero & tiny budgets ───────────────────────────── +console.log('exceedsBudget edge cases (zero / tiny budgets)'); +test('zero budget skips any non-zero-cost step', () => { + assert.strictEqual(exceedsBudget(0, 0.01, 0), true); +}); +test('zero budget allows a zero-cost step', () => { + assert.strictEqual(exceedsBudget(0, 0, 0), false); +}); +test('tiny budget below the cheapest agent skips it', () => { + assert.strictEqual(exceedsBudget(0, 0.01, 0.005), true); +}); +test('tiny budget exactly equal to cost allows the step', () => { + assert.strictEqual(exceedsBudget(0, 0.01, 0.01), false); +}); +test('NaN cost (parse failure) does not skip — comparison is false', () => { + // NaN > budget is always false, so a malformed price falls through the guard. + // Pinned so this real behavior changes deliberately, not by accident. + assert.strictEqual(exceedsBudget(0, NaN, 0.05), false); +}); +test('NaN budget does not skip — comparison is false', () => { + assert.strictEqual(exceedsBudget(0, 0.01, NaN), false); +}); +test('documents floating-point behavior at the 0.1 boundary', () => { + // 0.07 + 0.03 === 0.09999999999999999 in IEEE-754, which is NOT > 0.1. + assert.strictEqual(0.07 + 0.03 > 0.1, false); + assert.strictEqual(exceedsBudget(0.07, 0.03, 0.1), false); + // ...but it IS > 0.09, so a 0.09 budget correctly skips the step. + assert.strictEqual(exceedsBudget(0.07, 0.03, 0.09), true); +}); + +// ─── remainingBudget / formatAmount ────────────────────────────── +console.log('remainingBudget / formatAmount'); +test('remainingBudget can go negative when overspent', () => { + // Raw subtraction carries IEEE-754 noise (0.05 - 0.03 = 0.0200000000…4), + // which is exactly why production only ever surfaces it via formatAmount. + assert.strictEqual(formatAmount(remainingBudget(0.05, 0.03)), '0.0200'); + assert.strictEqual(formatAmount(remainingBudget(0.05, 0.06)), '-0.0100'); + assert.strictEqual(remainingBudget(0.05, 0.06) < 0, true); +}); +test('formatAmount always renders 4 decimal places', () => { + assert.strictEqual(formatAmount(0.01), '0.0100'); + assert.strictEqual(formatAmount(0), '0.0000'); + assert.strictEqual(formatAmount(-0.01), '-0.0100'); +}); + +// ─── buildSkipResult: skipped-step reporting ───────────────────── +console.log('buildSkipResult (skipped-step record)'); +test('produces a skipped record with agentId and reason', () => { + const r = buildSkipResult(analyst, 0.04, 0.0); + assert.deepStrictEqual(r, { + agentId: 'analyst-bot', + skipped: true, + reason: 'Budget limit (0.0400 USDC remaining, need 0.05)', + }); +}); +test('reason reflects remaining headroom after prior spend', () => { + const r = buildSkipResult(analyst, 0.06, 0.04); + assert.strictEqual(r.reason, 'Budget limit (0.0200 USDC remaining, need 0.05)'); +}); + +// ─── buildBudgetLimitEvent: broadcast payload ──────────────────── +console.log('buildBudgetLimitEvent (broadcast payload, no timestamp)'); +test('builds a budget_limit event without a timestamp', () => { + const ev = buildBudgetLimitEvent(analyst, 0.04, 0.0); + assert.deepStrictEqual(ev, { + type: 'budget_limit', + agent: '📊 Analysis Agent', + cost: '0.05', + remaining: '0.0400', + }); + // Timestamp is added by the orchestrator so the pure payload stays deterministic. + assert.strictEqual('timestamp' in ev, false); +}); + +// ─── paymentBucket: payment outcomes & fallback modes ──────────── +console.log('paymentBucket (payment outcomes / fallback modes)'); +test('classifies a confirmed x402 payment', () => { + assert.strictEqual(paymentBucket('x402'), 'x402'); +}); +test('classifies the direct-XLM fallback', () => { + assert.strictEqual(paymentBucket('stellar-xlm-direct'), 'stellar-xlm'); +}); +test('classifies unpaid / unknown / missing outcomes as unpaid', () => { + assert.strictEqual(paymentBucket('none'), 'unpaid'); + assert.strictEqual(paymentBucket(undefined), 'unpaid'); + assert.strictEqual(paymentBucket(null), 'unpaid'); + assert.strictEqual(paymentBucket(''), 'unpaid'); + assert.strictEqual(paymentBucket('something-else'), 'unpaid'); +}); + +// ─── tallyPaymentOutcomes ──────────────────────────────────────── +console.log('tallyPaymentOutcomes'); +test('tallies a mixed run correctly', () => { + const counts = tallyPaymentOutcomes(['x402', 'stellar-xlm-direct', 'none', 'x402', undefined]); + assert.deepStrictEqual(counts, { x402PaymentCount: 2, xlmFallbackCount: 1, unpaidCount: 2 }); +}); +test('empty / missing input yields all zeros', () => { + assert.deepStrictEqual(tallyPaymentOutcomes([]), { + x402PaymentCount: 0, xlmFallbackCount: 0, unpaidCount: 0, + }); + assert.deepStrictEqual(tallyPaymentOutcomes(undefined), { + x402PaymentCount: 0, xlmFallbackCount: 0, unpaidCount: 0, + }); +}); + +// ─── paymentProtocolSummary ────────────────────────────────────── +console.log('paymentProtocolSummary'); +test('reports x402-only, xlm-only, mixed, and none', () => { + assert.strictEqual(paymentProtocolSummary(2, 0), 'x402'); + assert.strictEqual(paymentProtocolSummary(0, 3), 'stellar-xlm'); + assert.strictEqual(paymentProtocolSummary(1, 1), 'mixed'); + assert.strictEqual(paymentProtocolSummary(0, 0), 'none'); +}); + +// ─── isBudgetExhausted: summary flag ───────────────────────────── +console.log('isBudgetExhausted'); +test('true only when spend meets or exceeds budget', () => { + assert.strictEqual(isBudgetExhausted(0.04, 0.05), false); + assert.strictEqual(isBudgetExhausted(0.05, 0.05), true); + assert.strictEqual(isBudgetExhausted(0.06, 0.05), true); +}); +test('zero budget is considered exhausted from the start', () => { + assert.strictEqual(isBudgetExhausted(0, 0), true); +}); + +// ─── countUsed / countSkipped: summary totals ──────────────────── +console.log('countUsed / countSkipped (summary totals)'); +test('counts used vs skipped across a mixed results array', () => { + const results = [ + { agentId: 'research-bot', skipped: undefined }, // ran + { agentId: 'analyst-bot', skipped: true }, // skipped + { agentId: 'code-bot' }, // ran + ]; + assert.strictEqual(countUsed(results), 2); + assert.strictEqual(countSkipped(results), 1); +}); +test('an "agent not found" error entry counts as used (pins current behavior)', () => { + // orchestrator pushes { agentId, error } with no `skipped` flag; today that + // entry is counted among agentsUsed. Documented here so a fix is intentional. + const results = [{ agentId: 'ghost-bot', error: 'Agent not found' }]; + assert.strictEqual(countUsed(results), 1); + assert.strictEqual(countSkipped(results), 0); +}); +test('empty results yield zero used and zero skipped', () => { + assert.strictEqual(countUsed([]), 0); + assert.strictEqual(countSkipped([]), 0); +}); + +// ─── Integration: deterministic end-to-end budget run ──────────── +// Mirrors orchestrate()'s loop using the exported helpers to prove the pieces +// compose into correct overrun prevention + summary, without any SDK/network. +console.log('integration: simulated budget run'); +function simulateRun(agents, budget, paidViaFor = () => 'x402') { + const results = []; + let totalSpent = 0; + const paidViaList = []; + for (const agent of agents) { + const cost = agentCost(agent); + if (exceedsBudget(totalSpent, cost, budget)) { + results.push(buildSkipResult(agent, budget, totalSpent)); + continue; + } + totalSpent += cost; + const paidVia = paidViaFor(agent); + paidViaList.push(paidVia); + results.push({ agentId: agent.id, skipped: false, paidVia }); + } + const { x402PaymentCount, xlmFallbackCount, unpaidCount } = tallyPaymentOutcomes(paidViaList); + return { + totalSpent: formatAmount(totalSpent), + budgetExhausted: isBudgetExhausted(totalSpent, budget), + agentsUsed: countUsed(results), + agentsSkipped: countSkipped(results), + paymentProtocol: paymentProtocolSummary(x402PaymentCount, xlmFallbackCount), + x402PaymentCount, + xlmFallbackCount, + unpaidCount, + results, + }; +} + +test('a generous budget runs every step and never overspends', () => { + const out = simulateRun([research, summary, analyst, code], 1.0); + assert.strictEqual(out.agentsUsed, 4); + assert.strictEqual(out.agentsSkipped, 0); + assert.strictEqual(out.totalSpent, '0.1000'); + assert.strictEqual(out.budgetExhausted, false); +}); + +test('a low budget runs the affordable steps and skips the rest (overrun prevention)', () => { + // budget 0.02: research(0.01) ✓, summary(0.01) ✓ → 0.02 spent; + // analyst(0.05) skip, code(0.03) skip. totalSpent never exceeds budget. + const out = simulateRun([research, summary, analyst, code], 0.02); + assert.strictEqual(out.agentsUsed, 2); + assert.strictEqual(out.agentsSkipped, 2); + assert.strictEqual(out.totalSpent, '0.0200'); + assert.strictEqual(out.budgetExhausted, true); + assert.strictEqual(parseFloat(out.totalSpent) <= 0.02, true); + const skipped = out.results.filter((r) => r.skipped).map((r) => r.agentId); + assert.deepStrictEqual(skipped, ['analyst-bot', 'code-bot']); +}); + +test('zero budget skips everything and spends nothing', () => { + const out = simulateRun([research, summary, analyst, code], 0); + assert.strictEqual(out.agentsUsed, 0); + assert.strictEqual(out.agentsSkipped, 4); + assert.strictEqual(out.totalSpent, '0.0000'); + assert.strictEqual(out.paymentProtocol, 'none'); +}); + +test('mixed payment outcomes produce a mixed protocol summary', () => { + const paidViaFor = (a) => (a.id === 'analyst-bot' ? 'stellar-xlm-direct' : 'x402'); + const out = simulateRun([research, summary, analyst, code], 1.0, paidViaFor); + assert.strictEqual(out.x402PaymentCount, 3); + assert.strictEqual(out.xlmFallbackCount, 1); + assert.strictEqual(out.unpaidCount, 0); + assert.strictEqual(out.paymentProtocol, 'mixed'); +}); + +// ─── Report ────────────────────────────────────────────────────── +console.log(`\n${passed} passed, ${failures.length} failed`); +if (failures.length > 0) { + process.exit(1); +}