mirror of
https://github.com/block/buzz.git
synced 2026-11-08 18:31:56 +00:00
## 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>
235 lines
7.0 KiB
JavaScript
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",
|
|
);
|
|
});
|