Files
buzz/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.render.test.mjs
Duncan 242dbf2680 fix(acp): close Thufir addendum gaps — unconditional first attempt, interactive delivery-seam test
F2 (retransmit structural fix): move delivery-deadline check to after the first
attempt. The first publish is now unconditional — an already-expired deadline
supplied by the caller (e.g. ordinary-timeout path where entry_deadline was past
at resolution time) no longer silently skips the one relay write that resolves
the card. Retries still consult the deadline.

F2 (test fixes): replace the channel-closed bounded-exit test with two focused
tests:
- retransmit_resolved_edit_unconditional_first_attempt_on_expired_deadline:
  supplies an already-expired deadline, verifies the kind-40003 event is still
  published. Mutation: revert !first_attempt guard → zero publishes.
- retransmit_resolved_edit_always_uncertain_bounded_exit: uses test_pair_silent
  (always Uncertain) under paused time, advances past the 4s delivery window,
  verifies the loop exits. Mutation: large RESOLVED_DELIVERY_WINDOW_SECS → hangs.

F3 (interactive delivery-seam): add _deliveryFn testability seam to
PermissionDecisionButtons and thread it through LifecycleActivity. Add
test_f3_interactive_delivery_seam_allow_once_and_reject_once_fire_delivery:
builds an acp_read transcript card (four option kinds), renders via
LifecycleActivity with a mock _deliveryFn, clicks allow_once button, asserts
delivery called with opt-allow-once + nonce-interactive; clicks reject_once
button in a fresh render, asserts delivery called with opt-reject-once.
allow_always/reject_always produce no buttons (ACTIONABLE_KINDS contract).
JS suite: 10/10 pass. Mutation: remove kind from ACTIONABLE_KINDS → button
absent → delivery assertion fails.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-09-01 11:41:18 -04:00

691 lines
24 KiB
JavaScript

import assert from "node:assert/strict";
import { after, afterEach, before, mock, test } from "node:test";
import { JSDOM } from "jsdom";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { LifecycleActivity } from "./LifecycleActivity.tsx";
import { buildTranscript } from "../agentSessionTranscript.ts";
// ---------------------------------------------------------------------------
// Shared fixtures
// ---------------------------------------------------------------------------
const BASE_PROPS = {
agentAvatarUrl: null,
agentName: "Test Agent",
agentPubkey: "pubkey123",
};
const BASE_IDENTITY = {
turnId: "turn-1",
sessionId: "session-1",
channelId: "channel-1",
};
/**
* Build a pending permission lifecycle item with the given options array.
* The card is actionable (awaiting a user decision) and has a request nonce.
*/
function pendingPermissionItem(options) {
return {
id: "perm-1",
type: "lifecycle",
renderClass: "permission",
title: "Tool requires approval",
text: "Run shell command",
timestamp: "2026-08-10T00:00:00.000Z",
requestNonce: "nonce-abc",
actionable: true,
options,
...BASE_IDENTITY,
};
}
// ---------------------------------------------------------------------------
// jsdom + fake-timer setup (required for the interactive delivery-seam tests)
// The static renderToStaticMarkup tests do not use document/window but the
// setup is harmless for them: it only assigns globals they never read.
// ---------------------------------------------------------------------------
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
// Deterministic wall-clock epoch — far from real time to avoid expiry surprises.
// expiresAt is set to FAKE_NOW_SECS + 9_999_999 in the interactive tests so
// the card never expires during the test.
const FAKE_NOW_MS = 1_000_000_000_000;
before(() => {
mock.timers.enable({ apis: ["setInterval", "Date"], now: FAKE_NOW_MS });
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
window: dom.window,
MutationObserver: class {
observe() {}
disconnect() {}
takeRecords() {
return [];
}
},
ResizeObserver: class {
observe() {}
unobserve() {}
disconnect() {}
},
});
dom.window.matchMedia = () => ({
matches: false,
addEventListener() {},
removeEventListener() {},
});
dom.window.MutationObserver = globalThis.MutationObserver;
dom.window.ResizeObserver = globalThis.ResizeObserver;
});
afterEach(async () => {
const { cleanup } = await import("@testing-library/react");
cleanup();
mock.timers.reset();
mock.timers.enable({ apis: ["setInterval", "Date"], now: FAKE_NOW_MS });
});
after(() => {
mock.timers.reset();
dom.window.close();
});
test("test_allow_once_renders_actionable_allow_button", () => {
const html = renderToStaticMarkup(
React.createElement(LifecycleActivity, {
...BASE_PROPS,
item: pendingPermissionItem([
{ optionId: "opt-allow", kind: "allow_once", label: "Allow once" },
]),
}),
);
// The button must be present and labelled correctly.
assert.ok(
html.includes("permission-decision-opt-allow"),
"allow_once option should render a button with its optionId testid",
);
assert.ok(
html.includes("Allow once"),
"allow_once option should show its label",
);
// The persistent-grant badge must NOT appear for a pure allow_once card.
assert.ok(
!html.includes("permission-decision-persistent-grant"),
"allow_once card should not render the persistent-grant badge",
);
});
// ---------------------------------------------------------------------------
// reject_once — renders a red actionable Deny button
// ---------------------------------------------------------------------------
test("test_reject_once_renders_actionable_deny_button", () => {
const html = renderToStaticMarkup(
React.createElement(LifecycleActivity, {
...BASE_PROPS,
item: pendingPermissionItem([
{ optionId: "opt-deny", kind: "reject_once" },
]),
}),
);
assert.ok(
html.includes("permission-decision-opt-deny"),
"reject_once option should render a button with its optionId testid",
);
// Deny button uses destructive styling; verify at least the testid is there.
assert.ok(
!html.includes("permission-decision-persistent-grant"),
"reject_once card should not render the persistent-grant badge",
);
});
// ---------------------------------------------------------------------------
// allow_always — not actionable, no badge (F3: persistent-grant badge removed)
// ---------------------------------------------------------------------------
test("test_allow_always_renders_no_button_and_no_badge", () => {
// After F3: allow_always is NOT in ACTIONABLE_KINDS and the persistent-grant
// badge has been removed. A card with only allow_always renders nothing
// actionable — no button and no badge — because the two-option contract
// (allow_once / reject_once only) is enforced at both the Rust sentinel and
// the observer surface.
const html = renderToStaticMarkup(
React.createElement(LifecycleActivity, {
...BASE_PROPS,
item: pendingPermissionItem([
{ optionId: "opt-always", kind: "allow_always", label: "Always allow" },
]),
}),
);
// No button for allow_always.
assert.ok(
!html.includes("permission-decision-opt-always"),
"allow_always option must not render an actionable button",
);
// No <button> element at all — no actionable options.
assert.ok(
!html.includes("<button"),
"allow_always-only card must not render any button element",
);
// The persistent-grant badge is gone — it was the only surface that showed
// allow_always and it has been removed in F3.
assert.ok(
!html.includes("permission-decision-persistent-grant"),
"persistent-grant badge must not render after F3 removal",
);
assert.ok(
!html.includes("Permanent grant"),
"persistent-grant copy must not render after F3 removal",
);
});
// ---------------------------------------------------------------------------
// Unknown kind — fail closed: renders nothing actionable, no badge
// ---------------------------------------------------------------------------
test("test_unknown_kind_fails_closed_renders_nothing", () => {
const html = renderToStaticMarkup(
React.createElement(LifecycleActivity, {
...BASE_PROPS,
item: pendingPermissionItem([
{ optionId: "opt-mystery", kind: "future_unknown_verb" },
]),
}),
);
// No button for the unknown kind.
assert.ok(
!html.includes("permission-decision-opt-mystery"),
"unknown kind must not render an actionable button",
);
// No persistent-grant badge either.
assert.ok(
!html.includes("permission-decision-persistent-grant"),
"unknown kind must not render the persistent-grant badge",
);
// No button element at all.
assert.ok(
!html.includes("<button"),
"unknown-kind-only card must not render any button element",
);
// The outer permission card shell is still rendered (title row etc.).
assert.ok(
html.includes("transcript-permission-item"),
"unknown kind still renders the permission card shell",
);
});
// ---------------------------------------------------------------------------
// Unknown reject_*-prefixed kind — fail closed: exact allowlist, not prefix
// ---------------------------------------------------------------------------
test("test_unknown_reject_prefixed_kind_fails_closed_renders_nothing", () => {
const html = renderToStaticMarkup(
React.createElement(LifecycleActivity, {
...BASE_PROPS,
item: pendingPermissionItem([
{ optionId: "opt-reject-future", kind: "reject_later_v2" },
]),
}),
);
// A reject-prefixed but unrecognized kind must NOT render a trusted button:
// recognition is an exact allowlist, not a prefix match.
assert.ok(
!html.includes("permission-decision-opt-reject-future"),
"unknown reject_*-prefixed kind must not render an actionable button",
);
assert.ok(
!html.includes("<button"),
"unknown reject_*-prefixed-only card must not render any button element",
);
assert.ok(
!html.includes("permission-decision-persistent-grant"),
"unknown reject_*-prefixed kind must not render the persistent-grant badge",
);
// The outer permission card shell is still rendered.
assert.ok(
html.includes("transcript-permission-item"),
"unknown reject_*-prefixed kind still renders the permission card shell",
);
});
// ---------------------------------------------------------------------------
// reject_always — not actionable (F3: removed from ACTIONABLE_KINDS)
// ---------------------------------------------------------------------------
test("test_reject_always_renders_no_button", () => {
// After F3: reject_always is removed from ACTIONABLE_KINDS. The thread card
// cannot grant permanent denial; the ACP read loop accepts only allow_once and
// reject_once. A reject_always option must render as inert — no clickable
// button. The outer card shell is still rendered (the activity still appears
// in the transcript), but no action can be taken on it.
const html = renderToStaticMarkup(
React.createElement(LifecycleActivity, {
...BASE_PROPS,
item: pendingPermissionItem([
{ optionId: "opt-reject-always", kind: "reject_always" },
]),
}),
);
// Must NOT render a clickable button for reject_always.
assert.ok(
!html.includes("permission-decision-opt-reject-always"),
"reject_always must not render an actionable button after F3",
);
// No button element at all — no actionable options present.
assert.ok(
!html.includes("<button"),
"reject_always-only card must not render any button element after F3",
);
// No persistent-grant badge either.
assert.ok(
!html.includes("permission-decision-persistent-grant"),
"reject_always card must not render the persistent-grant badge",
);
// The outer card shell IS rendered.
assert.ok(
html.includes("transcript-permission-item"),
"reject_always still renders the outer permission card shell",
);
});
// ---------------------------------------------------------------------------
// Mixed options — allow_once + allow_always: only allow_once actionable
// No persistent-grant badge after F3 removal
// ---------------------------------------------------------------------------
test("test_mixed_allow_once_and_allow_always_only_allow_once_actionable", () => {
// After F3: allow_always is not in ACTIONABLE_KINDS and the persistent-grant
// badge is removed. A mixed card renders only the allow_once button; allow_always
// is inert context with no UI surface.
const html = renderToStaticMarkup(
React.createElement(LifecycleActivity, {
...BASE_PROPS,
item: pendingPermissionItem([
{ optionId: "opt-once", kind: "allow_once" },
{ optionId: "opt-always", kind: "allow_always" },
]),
}),
);
// allow_once produces a button.
assert.ok(
html.includes("permission-decision-opt-once"),
"allow_once in mixed card should render a button",
);
// allow_always does NOT produce a button.
assert.ok(
!html.includes("permission-decision-opt-always"),
"allow_always in mixed card must not render a button",
);
// No persistent-grant badge — it has been removed.
assert.ok(
!html.includes("permission-decision-persistent-grant"),
"mixed card must not render the persistent-grant badge after F3 removal",
);
assert.ok(
!html.includes("Permanent grant"),
"mixed card must not render persistent-grant copy after F3 removal",
);
});
// ---------------------------------------------------------------------------
// F3 contract: all four adapter option kinds — only allow_once + reject_once
// are actionable; allow_always and reject_always are inert.
// ---------------------------------------------------------------------------
test("test_four_option_contract_only_allow_once_and_reject_once_actionable", () => {
// The two-option contract: the thread card may only action allow_once and
// reject_once. This test covers all four recognized adapter option kinds in
// a single card and verifies the exact set of rendered buttons.
//
// Mutation proof: removing "reject_once" from ACTIONABLE_KINDS in
// LifecycleActivity.tsx makes "permission-decision-opt-deny" absent — the
// assertion on opt-deny goes red. Removing "allow_once" makes opt-allow
// absent similarly. The ACP read loop on the Rust side accepts only the
// two option IDs snapshotted into CardActions (allow_once / reject_once);
// sending an allow_always or reject_always option ID is silently ignored.
const html = renderToStaticMarkup(
React.createElement(LifecycleActivity, {
...BASE_PROPS,
item: pendingPermissionItem([
{ optionId: "opt-allow", kind: "allow_once", label: "Allow once" },
{ optionId: "opt-deny", kind: "reject_once", label: "Deny" },
{ optionId: "opt-always", kind: "allow_always", label: "Always allow" },
{
optionId: "opt-reject-always",
kind: "reject_always",
label: "Always deny",
},
]),
}),
);
// Only allow_once and reject_once render buttons.
assert.ok(
html.includes("permission-decision-opt-allow"),
"allow_once must render an actionable button",
);
assert.ok(
html.includes("permission-decision-opt-deny"),
"reject_once must render an actionable button",
);
// allow_always and reject_always must NOT render buttons.
assert.ok(
!html.includes("permission-decision-opt-always"),
"allow_always must not render a button in a mixed four-option card",
);
assert.ok(
!html.includes("permission-decision-opt-reject-always"),
"reject_always must not render a button in a mixed four-option card",
);
// No persistent-grant badge — removed in F3.
assert.ok(
!html.includes("permission-decision-persistent-grant"),
"four-option card must not render the persistent-grant badge",
);
// Exactly two <button> elements (allow_once + reject_once).
const buttonCount = (html.match(/<button/g) ?? []).length;
assert.equal(
buttonCount,
2,
`four-option card must render exactly 2 buttons (allow_once + reject_once); got ${buttonCount}`,
);
});
// ---------------------------------------------------------------------------
// F3 cross-layer: acp_read → buildTranscript → LifecycleActivity
//
// Starts with all four adapter option kinds in the request payload.
// Drives the event through the full transcript reducer so the card is built
// from the real processing path, not a hand-rolled fixture.
// Then renders via LifecycleActivity and confirms the two-button contract.
// ---------------------------------------------------------------------------
test("test_f3_cross_layer_four_options_acp_read_to_lifecycle_activity_two_buttons", () => {
// Build an acp_read event carrying all four adapter option kinds.
// This is the real wire shape the observer feed emits when the agent
// requests permission with a full four-option set.
const acpReadEvent = {
seq: 1,
timestamp: "2026-09-01T10:00:00.000Z",
kind: "acp_read",
agentIndex: 0,
channelId: "ch-f3-cross",
sessionId: "sess-f3-cross",
turnId: "turn-f3-cross",
payload: {
jsonrpc: "2.0",
id: "req-f3",
method: "session/request_permission",
params: {
title: "Tool requires approval",
toolCallId: "tc-f3",
// Four option kinds offered by the adapter.
options: [
{
optionId: "opt-allow-once",
kind: "allow_once",
name: "Allow once",
},
{ optionId: "opt-reject-once", kind: "reject_once", name: "Deny" },
{
optionId: "opt-allow-always",
kind: "allow_always",
name: "Always allow",
},
{
optionId: "opt-reject-always",
kind: "reject_always",
name: "Always deny",
},
],
},
},
// Authorization envelope: marks the card as actionable with a nonce.
authorization: {
requestNonce: "nonce-f3-cross",
actionable: true,
},
};
// 1. Drive through the transcript reducer.
const transcript = buildTranscript([acpReadEvent]);
const card = transcript.find((item) => item.renderClass === "permission");
assert.ok(card, "transcript must contain a permission card");
assert.equal(
card.requestNonce,
"nonce-f3-cross",
"card must carry the request nonce",
);
assert.ok(card.actionable, "card must be actionable");
assert.ok(Array.isArray(card.options), "card must have options");
assert.equal(card.options.length, 4, "all four options must be on the card");
// 2. Render via LifecycleActivity and assert the two-button contract.
const html = renderToStaticMarkup(
React.createElement(LifecycleActivity, {
...BASE_PROPS,
item: card,
}),
);
// Only allow_once and reject_once render buttons (ACTIONABLE_KINDS contract).
assert.ok(
html.includes("permission-decision-opt-allow-once"),
"allow_once must render a button via cross-layer path",
);
assert.ok(
html.includes("permission-decision-opt-reject-once"),
"reject_once must render a button via cross-layer path",
);
// allow_always and reject_always must NOT render buttons.
assert.ok(
!html.includes("permission-decision-opt-allow-always"),
"allow_always must not render a button via cross-layer path",
);
assert.ok(
!html.includes("permission-decision-opt-reject-always"),
"reject_always must not render a button via cross-layer path",
);
// Exactly two <button> elements.
const buttonCount = (html.match(/<button/g) ?? []).length;
assert.equal(
buttonCount,
2,
`cross-layer four-option card must render exactly 2 buttons; got ${buttonCount}`,
);
});
// ---------------------------------------------------------------------------
// F3 interactive delivery-seam: acp_read → buildTranscript → LifecycleActivity
// click buttons → assert _deliveryFn called with ruled allow_once/reject_once IDs
//
// Mutation proof: removing `allow_once` from ACTIONABLE_KINDS → the allow_once
// button is not rendered → fireEvent.click finds no element → first delivery
// assertion fails. Removing `reject_once` → same for reject_once.
// Removing both → zero delivery calls → both assertions fail.
// ---------------------------------------------------------------------------
test("test_f3_interactive_delivery_seam_allow_once_and_reject_once_fire_delivery", async () => {
const { createElement, act } = await import("react");
const { render, fireEvent } = await import("@testing-library/react");
const FAKE_NOW_SECS = Math.floor(FAKE_NOW_MS / 1000);
const FUTURE_EXPIRY = FAKE_NOW_SECS + 9_999_999;
// Record delivery calls: { optionId, requestNonce }[]
const deliveryCalls = [];
function mockDeliveryFn({ optionId, requestNonce }) {
deliveryCalls.push({ optionId, requestNonce });
// Resolve as "acked" so the component doesn't re-enable the button.
return Promise.resolve("acked");
}
// Build the transcript card from a real acp_read event carrying all four
// option kinds — same wire shape as the static cross-layer test above.
const acpReadEvent = {
seq: 1,
timestamp: "2026-09-01T10:00:00.000Z",
kind: "acp_read",
agentIndex: 0,
channelId: "ch-interactive",
sessionId: "sess-interactive",
turnId: "turn-interactive",
payload: {
jsonrpc: "2.0",
id: "req-interactive",
method: "session/request_permission",
params: {
title: "Tool requires approval",
toolCallId: "tc-interactive",
options: [
{
optionId: "opt-allow-once",
kind: "allow_once",
name: "Allow once",
},
{ optionId: "opt-reject-once", kind: "reject_once", name: "Deny" },
{
optionId: "opt-allow-always",
kind: "allow_always",
name: "Always allow",
},
{
optionId: "opt-reject-always",
kind: "reject_always",
name: "Always deny",
},
],
},
},
authorization: {
requestNonce: "nonce-interactive",
actionable: true,
expiresAt: FUTURE_EXPIRY,
},
};
const transcript = buildTranscript([acpReadEvent]);
const card = transcript.find((item) => item.renderClass === "permission");
assert.ok(card, "transcript must contain a permission card");
assert.ok(card.actionable, "card must be actionable");
let container;
await act(async () => {
({ container } = render(
createElement(LifecycleActivity, {
...BASE_PROPS,
item: card,
_deliveryFn: mockDeliveryFn,
}),
));
});
// ── Click allow_once — must call delivery with opt-allow-once ─────────────
const allowBtn = container.querySelector(
'[data-testid="permission-decision-opt-allow-once"]',
);
assert.ok(
allowBtn !== null,
"allow_once button must be present (ACTIONABLE_KINDS must include allow_once)",
);
await act(async () => {
fireEvent.click(allowBtn);
// Drain microtasks so the async delivery fn resolves.
await Promise.resolve();
await Promise.resolve();
});
assert.equal(
deliveryCalls.length,
1,
"exactly one delivery call after clicking allow_once; mutation: remove allow_once from ACTIONABLE_KINDS → zero calls",
);
assert.equal(
deliveryCalls[0].optionId,
"opt-allow-once",
"delivery must be called with the allow_once optionId; mutation: wrong id → fails",
);
assert.equal(
deliveryCalls[0].requestNonce,
"nonce-interactive",
"delivery must carry the card's requestNonce",
);
// ── allow_always must NOT have a button (not in ACTIONABLE_KINDS) ─────────
assert.equal(
container.querySelector(
'[data-testid="permission-decision-opt-allow-always"]',
),
null,
"allow_always must not render a clickable button",
);
// ── reject_always must NOT have a button either ───────────────────────────
assert.equal(
container.querySelector(
'[data-testid="permission-decision-opt-reject-always"]',
),
null,
"reject_always must not render a clickable button",
);
// ── Render a fresh card and click reject_once ──────────────────────────────
// Use a separate render to avoid the pending-state from the allow_once click
// disabling the reject_once button.
deliveryCalls.length = 0;
let container2;
await act(async () => {
({ container: container2 } = render(
createElement(LifecycleActivity, {
...BASE_PROPS,
item: card,
_deliveryFn: mockDeliveryFn,
}),
));
});
const rejectBtn = container2.querySelector(
'[data-testid="permission-decision-opt-reject-once"]',
);
assert.ok(
rejectBtn !== null,
"reject_once button must be present (ACTIONABLE_KINDS must include reject_once)",
);
await act(async () => {
fireEvent.click(rejectBtn);
await Promise.resolve();
await Promise.resolve();
});
assert.equal(
deliveryCalls.length,
1,
"exactly one delivery call after clicking reject_once; mutation: remove reject_once from ACTIONABLE_KINDS → zero calls",
);
assert.equal(
deliveryCalls[0].optionId,
"opt-reject-once",
"delivery must be called with the reject_once optionId; mutation: wrong id → fails",
);
});