Files
agents-marketplace/tests/unit/supplier-state.test.ts
david 2f7b0194a5 feat(supplier,gateway,buyer): N-slot chat suppliers + entry-ticket settle mode
Two structural fixes for demo concurrency (3 single-slot suppliers thrashed
under Open WebUI's ~3 conversations per message):

Multi-slot suppliers (MAX_CHAT_SESSIONS, default 1):
- SupplierState is now capacity-aware: a Set of active escrow refs with
  free-until-full status semantics, ref-scoped release(ref) (also fixes the
  latent bug where handler catch-alls released another request's slot), and
  a walletMutex serializing every wallet-spending chain op — Claim, Submit,
  and wallet-health consolidation queue instead of racing the single
  working/collateral UTxO. /status gains active_sessions/max_sessions.

Entry-ticket settle mode (CHAT_SETTLE_MODE=ticket, default full, both sides):
- The buyer's escrow post at session open is the ONLY chain op. The supplier
  verifies the Open escrow as an entry ticket but never Claims/Submits
  (/v1/chat/start → {status:"ticket"}; /v1/chat/end → receipt-less
  {status:"ended"}); no hard-cap timer, so sessions may outlive deliver_by.
  Used tickets can't reopen sessions (records retained; 409 session_exists).
- Buyer SDK: startChat/endChat are settle-mode aware, driven by the
  supplier's response — mixed-mode rollout windows settle correctly.
- Gateway: ticket closes bill cost 0; the sweeper Reclaims Open tickets
  after deliver_by WITHOUT closing live sessions (the conversation and its
  affinity mirror survive their reclaimed ticket), gates reclaims on
  deliver_by, and keeps an in-process settled-refs memory so terminal
  indexer rows aren't retried every sweep forever.

Deployment (kimi-chat trio + gateway): ticket mode, 8 slots per supplier,
idle policy gateway 10 min / supplier 15 min (fixes the inversion where the
supplier idle-closed before the gateway janitor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:48:52 +02:00

296 lines
9.3 KiB
TypeScript

/**
* supplier-state.test.ts — tests for supplier/src/state.ts
*
* Tests the SupplierState session-slot admission (capacity-aware) + the
* wallet mutex:
* - tryAcquire(escrowRef): boolean — slot admission (default capacity 1)
* - release(escrowRef) — frees exactly that ref's slot
* - markOffline()
* - snapshot(): { status, currentEscrowRef?, activeSessions, maxSessions, ... }
* - walletMutex.run — strict serialization of wallet chain ops
*/
import { describe, it, expect, beforeEach } from "vitest";
import { SupplierState } from "../../supplier/src/state.js";
const ESCROW_REF_A = `${"f".repeat(64)}#0`;
const ESCROW_REF_B = `${"e".repeat(64)}#1`;
const ESCROW_REF_C = `${"d".repeat(64)}#2`;
describe("SupplierState — initial state", () => {
it("starts in free state", () => {
const state = new SupplierState();
expect(state.snapshot().status).toBe("free");
});
it("initial snapshot has no currentEscrowRef", () => {
const state = new SupplierState();
expect(state.snapshot().currentEscrowRef).toBeUndefined();
});
it("initial snapshot has a valid ISO timestamp in lastSeenIso", () => {
const state = new SupplierState();
const { lastSeenIso } = state.snapshot();
expect(lastSeenIso).toBeTruthy();
expect(new Date(lastSeenIso).getTime()).not.toBeNaN();
});
});
describe("SupplierState.tryAcquire()", () => {
let state: SupplierState;
beforeEach(() => {
state = new SupplierState();
});
it("returns true when free", () => {
expect(state.tryAcquire(ESCROW_REF_A)).toBe(true);
});
it("transitions to working after acquire", () => {
state.tryAcquire(ESCROW_REF_A);
expect(state.snapshot().status).toBe("working");
});
it("sets currentEscrowRef after acquire", () => {
state.tryAcquire(ESCROW_REF_A);
expect(state.snapshot().currentEscrowRef).toBe(ESCROW_REF_A);
});
it("returns false when already working", () => {
state.tryAcquire(ESCROW_REF_A);
expect(state.tryAcquire(ESCROW_REF_B)).toBe(false);
});
it("status remains working after second tryAcquire fails", () => {
state.tryAcquire(ESCROW_REF_A);
state.tryAcquire(ESCROW_REF_B);
expect(state.snapshot().status).toBe("working");
});
it("currentEscrowRef remains the FIRST ref after second tryAcquire fails", () => {
state.tryAcquire(ESCROW_REF_A);
state.tryAcquire(ESCROW_REF_B);
expect(state.snapshot().currentEscrowRef).toBe(ESCROW_REF_A);
});
it("returns false when offline", () => {
state.markOffline();
expect(state.tryAcquire(ESCROW_REF_A)).toBe(false);
});
it("status remains offline after tryAcquire fails", () => {
state.markOffline();
state.tryAcquire(ESCROW_REF_A);
expect(state.snapshot().status).toBe("offline");
});
it("updates lastSeenIso on successful acquire", () => {
const before = state.snapshot().lastSeenIso;
// Small sleep to ensure timestamp changes
state.tryAcquire(ESCROW_REF_A);
const after = state.snapshot().lastSeenIso;
// lastSeenIso must be a valid ISO string; may be equal if clock resolution is coarse
expect(new Date(after).getTime()).not.toBeNaN();
expect(after >= before).toBe(true);
});
});
describe("SupplierState.release()", () => {
let state: SupplierState;
beforeEach(() => {
state = new SupplierState();
});
it("transitions working → free", () => {
state.tryAcquire(ESCROW_REF_A);
state.release(ESCROW_REF_A);
expect(state.snapshot().status).toBe("free");
});
it("clears currentEscrowRef on release", () => {
state.tryAcquire(ESCROW_REF_A);
state.release(ESCROW_REF_A);
expect(state.snapshot().currentEscrowRef).toBeUndefined();
});
it("allows re-acquire after release", () => {
state.tryAcquire(ESCROW_REF_A);
state.release(ESCROW_REF_A);
expect(state.tryAcquire(ESCROW_REF_B)).toBe(true);
});
it("re-acquire after release sets new currentEscrowRef", () => {
state.tryAcquire(ESCROW_REF_A);
state.release(ESCROW_REF_A);
state.tryAcquire(ESCROW_REF_B);
expect(state.snapshot().currentEscrowRef).toBe(ESCROW_REF_B);
});
it("updates lastSeenIso on release", () => {
state.tryAcquire(ESCROW_REF_A);
const beforeRelease = state.snapshot().lastSeenIso;
state.release(ESCROW_REF_A);
const afterRelease = state.snapshot().lastSeenIso;
expect(new Date(afterRelease).getTime()).not.toBeNaN();
expect(afterRelease >= beforeRelease).toBe(true);
});
});
describe("SupplierState.markOffline()", () => {
let state: SupplierState;
beforeEach(() => {
state = new SupplierState();
});
it("transitions free → offline", () => {
state.markOffline();
expect(state.snapshot().status).toBe("offline");
});
it("transitions working → offline", () => {
state.tryAcquire(ESCROW_REF_A);
state.markOffline();
expect(state.snapshot().status).toBe("offline");
});
it("is idempotent (offline → offline)", () => {
state.markOffline();
state.markOffline();
expect(state.snapshot().status).toBe("offline");
});
it("clears currentEscrowRef when going offline from working", () => {
state.tryAcquire(ESCROW_REF_A);
state.markOffline();
expect(state.snapshot().currentEscrowRef).toBeUndefined();
});
it("updates lastSeenIso when going offline", () => {
const before = state.snapshot().lastSeenIso;
state.markOffline();
const after = state.snapshot().lastSeenIso;
expect(new Date(after).getTime()).not.toBeNaN();
expect(after >= before).toBe(true);
});
});
describe("SupplierState.snapshot()", () => {
it("returns status, lastSeenIso but no currentEscrowRef when free", () => {
const state = new SupplierState();
const snap = state.snapshot();
expect(snap.status).toBe("free");
expect("currentEscrowRef" in snap).toBe(false);
expect(snap.lastSeenIso).toBeTruthy();
});
it("returns currentEscrowRef only when working", () => {
const state = new SupplierState();
state.tryAcquire(ESCROW_REF_A);
const snap = state.snapshot();
expect(snap.currentEscrowRef).toBe(ESCROW_REF_A);
});
it("snapshot is a copy — mutating it does not affect state", () => {
const state = new SupplierState();
state.tryAcquire(ESCROW_REF_A);
const snap = state.snapshot();
// Mutate the returned snapshot
(snap as Record<string, unknown>).status = "free";
// State must remain working
expect(state.snapshot().status).toBe("working");
});
it("reports active/max session counts", () => {
const state = new SupplierState();
expect(state.snapshot().activeSessions).toBe(0);
expect(state.snapshot().maxSessions).toBe(1);
state.tryAcquire(ESCROW_REF_A);
expect(state.snapshot().activeSessions).toBe(1);
expect(state.snapshot().activeEscrowRefs).toEqual([ESCROW_REF_A]);
});
});
describe("SupplierState — capacity > 1", () => {
let state: SupplierState;
beforeEach(() => {
state = new SupplierState(2);
});
it("stays free until every slot is taken (free-until-full)", () => {
expect(state.tryAcquire(ESCROW_REF_A)).toBe(true);
expect(state.snapshot().status).toBe("free");
expect(state.snapshot().activeSessions).toBe(1);
expect("currentEscrowRef" in state.snapshot()).toBe(false);
expect(state.tryAcquire(ESCROW_REF_B)).toBe(true);
expect(state.snapshot().status).toBe("working");
expect(state.snapshot().currentEscrowRef).toBe(ESCROW_REF_A); // oldest
});
it("rejects the N+1th acquire", () => {
state.tryAcquire(ESCROW_REF_A);
state.tryAcquire(ESCROW_REF_B);
expect(state.tryAcquire(ESCROW_REF_C)).toBe(false);
});
it("rejects a duplicate ref while held", () => {
state.tryAcquire(ESCROW_REF_A);
expect(state.tryAcquire(ESCROW_REF_A)).toBe(false);
});
it("release frees exactly that ref's slot", () => {
state.tryAcquire(ESCROW_REF_A);
state.tryAcquire(ESCROW_REF_B);
state.release(ESCROW_REF_A);
expect(state.snapshot().status).toBe("free");
expect(state.snapshot().activeEscrowRefs).toEqual([ESCROW_REF_B]);
expect(state.tryAcquire(ESCROW_REF_C)).toBe(true);
});
it("release of an unheld ref is a no-op", () => {
state.tryAcquire(ESCROW_REF_A);
state.release(ESCROW_REF_B);
expect(state.snapshot().activeSessions).toBe(1);
});
it("markOffline clears all slots and refuses new acquires", () => {
state.tryAcquire(ESCROW_REF_A);
state.tryAcquire(ESCROW_REF_B);
state.markOffline();
expect(state.snapshot().status).toBe("offline");
expect(state.snapshot().activeSessions).toBe(0);
expect(state.tryAcquire(ESCROW_REF_C)).toBe(false);
});
it("rejects a non-positive capacity", () => {
expect(() => new SupplierState(0)).toThrow();
});
});
describe("SupplierState.walletMutex", () => {
it("serializes runs in submission order and survives rejection", async () => {
const state = new SupplierState(4);
const order: number[] = [];
const p1 = state.walletMutex.run(async () => {
await new Promise((r) => setTimeout(r, 20));
order.push(1);
});
const p2 = state.walletMutex.run(async () => {
order.push(2);
});
await Promise.all([p1, p2]);
expect(order).toEqual([1, 2]);
await state.walletMutex
.run(async () => {
throw new Error("boom");
})
.catch(() => undefined);
expect(await state.walletMutex.run(async () => 42)).toBe(42);
});
});