Files
buzz/desktop/src/features/agents/ui/agentSnapshotImportDialog.test.mjs
Tyler eb049ddf81 feat(desktop): Agent Trading Cards — mintable agent-snapshot card PNGs with optional NIP-44 lock (#3278)
## Agent Trading Cards

"Create Agent Card" action in the agent panel that mints an AI-generated
trading card PNG which **is** the agent: the card carries the
`buzz_agent_snapshot` tEXt chunk and is drag-in importable like any
snapshot PNG.

### What's in here
- **Mint pipeline (Rust):** one OpenAI Responses call — `gpt-5.6-sol` as
card designer with `gpt-image-2` via the `image_generation` tool (~2–3
min). New `mint_agent_card` / `save_agent_card` commands; preview with
reroll; save or send as `.agent.png` with round-trip verification before
any bytes leave the app.
- **Snapshot/chunk work stays in Rust,** reusing the existing
encoder/decoder seams (byte-compat golden vector proves the plain path
is identical to the pre-envelope encoder for placeholder, PNG-injection,
and JPEG-transcode paths).
- **Locked cards (NIP-44):** optional `buzz-agent-snapshot-encrypted`
envelope encrypted to the (owner, agent) pair. `parse_canonical_pubkey`
performs lift-x curve validation before any API spend; wrong-key decrypt
returns a fixed refusal; the plain decoder refuses locked cards.
- **Guardrails:** 10 MiB ceiling on final bytes, memory structurally
`none` in the snapshot, full-manifest import disclosure, API-key hygiene
via env layering (record > persona > global > process), fail-early
validation ordering (all key/lock/NIP-44-cap checks before Responses
spend).
- **Import side:** full-manifest disclosure dialog, locked-card import
disclosure, bounded avatar fetch.

### Review
Code reviewed by Wren across the full arc; final locked-card
cross-review **APPROVED 9/9/9** at exactly this head (`64f819dc8`), with
independent same-SHA verification: Rust lib 1,843/1,843, clippy
`--all-targets -D warnings`, desktop file-size gate.

### Live-mint evidence (real API, shipping seams, this SHA)
- **Plain (Honey):** 188s, 1500x2250, 5,101,503 bytes (< 10 MiB);
decoded manifest == built manifest; memory=none.
- **Locked (Fizz):** 176s, 4,670,184 bytes; owner-key and agent-key
decrypt both verified via logical manifest compare; wrong-key refusal
exact; plain decoder refuses.
- **Live finding:** built-in agents' ~171 KB inline avatars exceed the
NIP-44 65,535-byte plaintext cap and the fail-early guard fires before
API spend — clean error path, noted as a UX follow-up for large-avatar
agents choosing lock.

Full evidence (cards + dialog screenshots) posted in the originating
thread.

---------

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
2026-07-31 22:01:17 -04:00

235 lines
7.0 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
// Source-path tests for AgentSnapshotImportDialog ResultBody rendering.
//
// ResultBody is a hook-free component: it accepts a result object and renders
// the summary, partial-memory alert, and per-entry error list. These tests
// call it as a plain function and walk the element tree to verify that
// memoryErrors strings are carried through to a bounded list element with the
// correct data-testid. No DOM or test renderer is needed.
import { PreviewBody, ResultBody } from "./AgentSnapshotImportDialog.tsx";
/**
* Walk a React element tree (breadth-first) and collect all elements that
* match a predicate.
*/
function findAll(element, predicate) {
if (!element || typeof element !== "object") return [];
const matches = [];
const queue = [element];
while (queue.length > 0) {
const node = queue.shift();
if (!node || typeof node !== "object") continue;
if (predicate(node)) matches.push(node);
const children = node.props?.children;
if (Array.isArray(children)) {
queue.push(...children.flat(Infinity).filter(Boolean));
} else if (children && typeof children === "object") {
queue.push(children);
}
}
return matches;
}
/**
* Collect all string leaves in the element tree.
*/
function collectText(element) {
const texts = [];
const queue = [element];
while (queue.length > 0) {
const node = queue.shift();
if (typeof node === "string") {
texts.push(node);
continue;
}
if (!node || typeof node !== "object") continue;
const children = node.props?.children;
if (Array.isArray(children)) {
queue.push(...children.flat(Infinity).filter(Boolean));
} else if (typeof children === "string") {
texts.push(children);
} else if (children && typeof children === "object") {
queue.push(children);
}
}
return texts;
}
function makeResult(overrides = {}) {
return {
displayName: "TestBot",
newPubkey: "abc123",
personaId: "persona-1",
memoryWritten: 0,
memoryTotal: 2,
memoryErrors: [],
profileSyncError: null,
...overrides,
};
}
function makePreview(overrides = {}) {
return {
displayName: "TestBot",
systemPrompt: "Inspect every boundary before changing code.",
avatarUrl: null,
memoryLevel: "none",
memoryEntryCount: 0,
hasSourceAllowlist: true,
sourceAllowlistCount: 2,
sourceAllowlist: ["a".repeat(64), "b".repeat(64)],
manifestJson: '{\n "format": "buzz-agent-snapshot"\n}',
locked: false,
...overrides,
};
}
// ── preview transparency ──────────────────────────────────────────────────────
test("preview_body_discloses_prompt_allowlist_and_full_manifest", () => {
const preview = makePreview();
const element = PreviewBody({
preview,
hasMemory: false,
memoryLevelLabel: "none",
keepAllowlist: false,
onKeepAllowlistChange: () => {},
});
const allText = collectText(element).join(" ");
assert.ok(allText.includes(preview.systemPrompt));
for (const pubkey of preview.sourceAllowlist) {
assert.ok(allText.includes(pubkey), `missing allowlist pubkey ${pubkey}`);
}
assert.ok(allText.includes(preview.manifestJson));
assert.equal(
findAll(
element,
(n) => n.props?.["data-testid"] === "agent-snapshot-import-manifest",
).length,
1,
);
});
// ── locked-card provenance notice ─────────────────────────────────────────────
test("preview_body_shows_locked_notice_only_for_locked_cards", () => {
const lockedNotice = (locked) =>
findAll(
PreviewBody({
preview: makePreview({ locked }),
hasMemory: false,
memoryLevelLabel: "none",
keepAllowlist: false,
onKeepAllowlistChange: () => {},
}),
(n) => n.props?.["data-testid"] === "agent-snapshot-import-locked-notice",
);
assert.equal(lockedNotice(true).length, 1);
assert.equal(lockedNotice(false).length, 0);
});
// ── memory errors detail list ─────────────────────────────────────────────────
test("result_body_renders_memory_errors_list_with_test_id", () => {
const errors = [
'slug "mem/notes": relay timeout',
'slug "core": build failed: key mismatch',
];
const result = makeResult({
memoryWritten: 0,
memoryTotal: 2,
memoryErrors: errors,
});
const element = ResultBody({ result, confirmError: null });
// The bounded list must carry the expected data-testid.
const errLists = findAll(
element,
(n) => n.props?.["data-testid"] === "agent-snapshot-import-memory-errors",
);
assert.equal(
errLists.length,
1,
"exactly one memory-errors list must be rendered",
);
// The list must be vertically bounded (max-h-*) and scrollable
// (overflow-y-auto) so it cannot grow the dialog without bound.
const listNode = errLists[0];
const className = listNode.props?.className ?? "";
assert.ok(
/max-h-/.test(className),
`memory-errors list must have a max-height class (got: "${className}")`,
);
assert.ok(
/overflow-y-auto/.test(className),
`memory-errors list must have overflow-y-auto (got: "${className}")`,
);
// Each item must use break-all (not truncate) so the full error text is
// readable, not clipped.
const items = findAll(element, (n) => n.type === "li");
assert.ok(items.length > 0, "list items must be present");
for (const item of items) {
const cls = item.props?.className ?? "";
assert.ok(
/break-all/.test(cls),
`list item must use break-all for full error visibility (got: "${cls}")`,
);
assert.ok(
!/truncate/.test(cls),
`list item must not use truncate (got: "${cls}")`,
);
}
});
test("result_body_surfaces_both_error_strings_in_tree", () => {
const errors = [
'slug "mem/notes": relay timeout',
'slug "core": build failed: key mismatch',
];
const result = makeResult({
memoryWritten: 0,
memoryTotal: 2,
memoryErrors: errors,
});
const element = ResultBody({ result, confirmError: null });
const allText = collectText(element).join(" ");
assert.ok(
allText.includes('slug "mem/notes"'),
"first error slug must appear in the rendered tree",
);
assert.ok(
allText.includes('slug "core"'),
"second error slug must appear in the rendered tree",
);
});
test("result_body_full_success_omits_memory_errors_list", () => {
const result = makeResult({
memoryWritten: 2,
memoryTotal: 2,
memoryErrors: [],
});
const element = ResultBody({ result, confirmError: null });
const errLists = findAll(
element,
(n) => n.props?.["data-testid"] === "agent-snapshot-import-memory-errors",
);
assert.equal(
errLists.length,
0,
"full-success result must not render the memory-errors list",
);
});