diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs
index dd57a7f1f5..910932574f 100644
--- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs
@@ -1,559 +1,41 @@
/**
- * Rendering contract for the `conversation` transcript variant (focus mode).
+ * Rendering contract for the `conversation` transcript variant (focus mode):
+ * layout, prompt authorship, thoughts, plans, lifecycle chrome, and the
+ * byte-for-byte guarantee for the other variants.
*
* Mounts the shipping AgentSessionTranscriptList so the variant plumbing
* (variant context + derived turn meta) is exercised end to end rather than
* asserting against re-implemented render classes.
*
+ * The identity row and code-block chrome live in
+ * `AgentSessionTranscriptList.conversationChrome.test.mjs`; shared jsdom setup,
+ * ambient-formatting pins, and render helpers live in the harness both import.
+ *
* The byte-for-byte tests at the bottom are the important ones: `conversation`
- * is purely additive, so the `default` and `compactPreview` markup for the same
+ * is purely additive, so `default` and `compactPreview` markup for the same
* transcript must be byte-identical to the markup captured before the variant
- * existed. That snapshot lives in
- * AgentSessionTranscriptList.conversation.baseline.json and was produced by
- * mounting `baselineItems()` — a transcript containing every renderable item
- * kind across two sessions — on pre-change main (074561233) in a clean
- * throwaway worktree. Regenerate it only when a deliberate change to the other
- * variants is being made.
+ * existed. See the harness for how that fixture was produced.
*/
import assert from "node:assert/strict";
-import { readFileSync } from "node:fs";
-import { after, afterEach, before, test } from "node:test";
+import { test } from "node:test";
-// The captured markup embeds formatted dates and times, so the fixture is only
-// reproducible if every ambient formatting input is pinned. Two of them bite:
-//
-// - **Zone.** `formatTranscriptTimestampTitle` formats in the ambient zone
-// ("… at 7:00:01 PM"), so a capture at UTC-7 fails against CI's UTC.
-// - **Locale.** The session-boundary divider uses a bare `toLocaleString()`
-// (`AgentSessionTranscriptChrome.tsx`), which is locale-sensitive as well as
-// zone-sensitive: "6/14/2026, 7:05:00 PM" becomes "14.6.2026, 19:05:00"
-// under de-DE. Node derives its default locale from LANG/LC_ALL, so this
-// varies by machine independently of the zone.
-//
-// `TZ` can be set here because `Date` reads it lazily. The locale CANNOT: node
-// resolves its default locale once at startup, so assigning `process.env.LANG`
-// at runtime has no effect (verified — it silently keeps the startup locale).
-// Pinning it therefore means overriding the two formatting surfaces the render
-// path can reach: `Intl.DateTimeFormat` when constructed with no explicit
-// locale, and `Date.prototype.toLocale*`, which does NOT route through
-// `Intl.DateTimeFormat` and so needs its own patch.
-//
-// All of this must happen before the transcript modules are imported: their
-// `Intl.DateTimeFormat` instances are module-level constants that resolve zone
-// and locale once, at construction.
-process.env.TZ = "UTC";
-
-const FIXTURE_LOCALE = "en-US";
-const OriginalDateTimeFormat = Intl.DateTimeFormat;
-// A plain function, not an arrow: the render path calls
-// `new Intl.DateTimeFormat(...)`, and an arrow function is not a constructor.
-// Returning a genuine instance keeps `new`, plain calls, and `instanceof` all
-// working.
-function LocalePinnedDateTimeFormat(locales, options) {
- return new OriginalDateTimeFormat(locales ?? FIXTURE_LOCALE, options);
-}
-LocalePinnedDateTimeFormat.prototype = OriginalDateTimeFormat.prototype;
-LocalePinnedDateTimeFormat.supportedLocalesOf =
- OriginalDateTimeFormat.supportedLocalesOf.bind(OriginalDateTimeFormat);
-Intl.DateTimeFormat = LocalePinnedDateTimeFormat;
-for (const method of [
- "toLocaleString",
- "toLocaleDateString",
- "toLocaleTimeString",
-]) {
- const original = Date.prototype[method];
- Date.prototype[method] = function (locales, options) {
- return original.call(this, locales ?? FIXTURE_LOCALE, options);
- };
-}
-
-import { JSDOM } from "jsdom";
-
-const BASELINE_MARKUP = JSON.parse(
- readFileSync(
- new URL(
- "./AgentSessionTranscriptList.conversation.baseline.json",
- import.meta.url,
- ),
- "utf8",
- ),
-);
-
-const dom = new JSDOM("
", {
- url: "http://localhost",
-});
-
-class NoopObserver {
- disconnect() {}
- observe() {}
- unobserve() {}
-}
-
-Object.assign(globalThis, {
- Element: dom.window.Element,
- Event: dom.window.Event,
- HTMLElement: dom.window.HTMLElement,
- IS_REACT_ACT_ENVIRONMENT: true,
- IntersectionObserver: NoopObserver,
- MutationObserver: dom.window.MutationObserver,
- Node: dom.window.Node,
- ResizeObserver: NoopObserver,
- document: dom.window.document,
- getComputedStyle: (...args) => dom.window.getComputedStyle(...args),
- localStorage: dom.window.localStorage,
- self: dom.window,
- window: dom.window,
-});
-Object.defineProperty(globalThis, "navigator", {
- configurable: true,
- value: dom.window.navigator,
- writable: true,
-});
-dom.window.matchMedia = () => ({
- matches: false,
- addEventListener() {},
- removeEventListener() {},
-});
-dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
-dom.window.cancelAnimationFrame = (id) => clearTimeout(id);
-globalThis.requestAnimationFrame = dom.window.requestAnimationFrame;
-globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame;
-
-/**
- * Radix's `AvatarImage` renders nothing until its own preloader reports
- * `loaded` (`react-avatar/dist/index.mjs` `useImageLoadingStatus`), and jsdom
- * never fetches, so a real avatar URL would otherwise stay in `loading` forever
- * and every avatar assertion would see only the initials fallback — the exact
- * bug under test, passing vacuously. This stub reports a decoded image as soon
- * as `src` is assigned. It only affects avatars that HAVE a url: with
- * `avatarUrl: null` radix resolves to `error` and skips the preloader entirely,
- * so the byte-for-byte baseline (whose agent and author carry no avatar) is
- * untouched.
- */
-class LoadedImageStub {
- constructor() {
- this._src = "";
- this.complete = false;
- this.naturalWidth = 0;
- }
- addEventListener() {}
- removeEventListener() {}
- get src() {
- return this._src;
- }
- set src(value) {
- this._src = value;
- this.complete = true;
- this.naturalWidth = 1;
- }
-}
-dom.window.Image = LoadedImageStub;
-
-let act;
-let cleanup;
-let render;
-let createElement;
-let useState;
-let createMemoryHistory;
-let createRootRoute;
-let createRouter;
-let RouterProvider;
-let AgentSessionTranscriptList;
-let ThemeProvider;
-let TooltipProvider;
-let resetActiveAgentTurnsStore;
-let syncAgentTurnsFromEvents;
-
-const AGENT = {
- agentAvatarUrl: null,
- agentName: "Test Agent",
- agentPubkey: "f".repeat(64),
-};
-const AUTHOR = "a".repeat(64);
-const AUTHOR_TRUNCATED = `${AUTHOR.slice(0, 8)}…${AUTHOR.slice(-4)}`;
-/**
- * What the transcript builder actually puts in a prompt item's `title`: a
- * description of the trigger that started the turn, not an identity. Real values
- * are "Prompt", "Buzz event", and title-cased event kinds like "@Mention"
- * (`agentSessionTranscriptHelpers.ts` `parsePromptText`). The author row must
- * never display this as a name.
- */
-const TRIGGER_TITLE = "@Mention";
-const AUTHOR_PROFILES = {
- [AUTHOR]: {
- displayName: "Ada Lovelace",
- avatarUrl: null,
- nip05Handle: null,
- ownerPubkey: null,
- },
-};
-/**
- * A relay-resolved profile for the *agent*. Deliberately not a `/media/`
- * relay URL: `UserAvatar` routes those through the localhost media proxy
- * (`rewriteRelayUrl`), which would make the rendered `src` a moving target.
- */
-const AGENT_AVATAR_URL = "https://cdn.example.test/agent-profile.png";
-const AGENT_PROFILES = {
- [AGENT.agentPubkey]: {
- displayName: "Test Agent",
- avatarUrl: AGENT_AVATAR_URL,
- nip05Handle: null,
- ownerPubkey: null,
- },
-};
-
-function items() {
- const shared = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
- return [
- {
- ...shared,
- id: "msg:user",
- type: "message",
- renderClass: "message",
- role: "user",
- title: TRIGGER_TITLE,
- text: "please summarize the plan",
- timestamp: "2026-06-14T19:00:00.000Z",
- messageId: "event-1",
- authorPubkey: AUTHOR,
- },
- {
- ...shared,
- id: "thought:1",
- type: "thought",
- renderClass: "thought",
- title: "Thinking",
- text: "weighing the options",
- timestamp: "2026-06-14T19:00:02.000Z",
- },
- {
- ...shared,
- id: "plan:1",
- type: "plan",
- renderClass: "plan",
- title: "Plan",
- text: "- [x] read the transcript\n- [ ] write the summary (in progress)\n- [ ] ship it",
- timestamp: "2026-06-14T19:00:07.000Z",
- },
- {
- ...shared,
- id: "msg:assistant",
- type: "message",
- renderClass: "message",
- role: "assistant",
- title: "Test Agent",
- text: "Here is the summary with `code`.",
- timestamp: "2026-06-14T19:00:09.000Z",
- },
- ];
-}
-
-/**
- * Everything the legacy variants can render, in one transcript.
- *
- * The byte-for-byte contract covers `default`/`compactPreview` for EVERY item
- * kind, so the baseline input has to contain every kind rather than the happy
- * path: prompt (with prompt context and setup lifecycle so the ingress chrome
- * renders), assistant message, thought, plan, a tool item, ordinary lifecycle
- * status, error, permission — across two sessions so a session-boundary divider
- * is forced too. Where `compactPreview` deliberately suppresses a kind, that
- * absence is captured in the fixture and is therefore also protected.
- *
- * Single tool item on purpose: a run of three would collapse into a grouped
- * summary and the leaf tool row would never be captured.
- */
-function baselineItems() {
- const first = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
- const second = { channelId: "chan-1", sessionId: "sess-2", turnId: "turn-2" };
- return [
- {
- ...first,
- id: "life:setup",
- type: "lifecycle",
- renderClass: "status",
- title: "Turn started",
- text: "1 trigger",
- timestamp: "2026-06-14T19:00:00.000Z",
- acpSource: "turn_started",
- },
- {
- ...first,
- id: "meta:context",
- type: "metadata",
- renderClass: "raw-rail",
- title: "Prompt context",
- sections: [{ title: "Channel", body: "engineering" }],
- timestamp: "2026-06-14T19:00:00.500Z",
- acpSource: "session/prompt:context",
- },
- {
- ...first,
- id: "msg:user",
- type: "message",
- renderClass: "message",
- role: "user",
- title: "Ada",
- text: "please summarize the plan",
- timestamp: "2026-06-14T19:00:01.000Z",
- messageId: "event-1",
- authorPubkey: AUTHOR,
- acpSource: "session/prompt:user",
- },
- {
- ...first,
- id: "thought:1",
- type: "thought",
- renderClass: "thought",
- title: "Thinking",
- text: "weighing the options",
- timestamp: "2026-06-14T19:00:02.000Z",
- },
- {
- ...first,
- id: "plan:1",
- type: "plan",
- renderClass: "plan",
- title: "Plan",
- text: "- [x] read the transcript\n- [ ] write the summary (in progress)\n- [ ] ship it",
- timestamp: "2026-06-14T19:00:03.000Z",
- },
- {
- ...first,
- id: "tool:1",
- type: "tool",
- renderClass: "shell",
- descriptor: {
- renderClass: "shell",
- label: "Ran a command",
- preview: "cargo test",
- tone: "neutral",
- source: "shell",
- },
- title: "Ran a command",
- toolName: "shell",
- buzzToolName: null,
- status: "completed",
- args: { command: "cargo test" },
- result: "ok",
- isError: false,
- timestamp: "2026-06-14T19:00:04.000Z",
- startedAt: "2026-06-14T19:00:04.000Z",
- completedAt: "2026-06-14T19:00:05.000Z",
- },
- {
- ...first,
- id: "life:permission",
- type: "lifecycle",
- renderClass: "permission",
- title: "Permission requested",
- text: "write src/main.rs\nOptions: Allow, Deny",
- outcome: "Approved (once)",
- timestamp: "2026-06-14T19:00:06.000Z",
- },
- {
- ...first,
- id: "life:status",
- type: "lifecycle",
- renderClass: "status",
- title: "Context compacted",
- text: "",
- timestamp: "2026-06-14T19:00:07.000Z",
- },
- {
- ...first,
- id: "msg:assistant",
- type: "message",
- renderClass: "message",
- role: "assistant",
- title: "Test Agent",
- text: "Here is the summary with `code`.",
- timestamp: "2026-06-14T19:00:08.000Z",
- },
- {
- ...first,
- id: "life:error",
- type: "lifecycle",
- renderClass: "error",
- title: "Turn failed",
- text: "the harness exited",
- timestamp: "2026-06-14T19:00:09.000Z",
- },
- // Second session run: forces a session-boundary divider between the runs.
- {
- ...second,
- id: "msg:user2",
- type: "message",
- renderClass: "message",
- role: "user",
- title: "Ada",
- text: "next task",
- timestamp: "2026-06-14T19:05:00.000Z",
- messageId: "event-2",
- authorPubkey: AUTHOR,
- acpSource: "session/prompt:user",
- },
- {
- ...second,
- id: "msg:assistant2",
- type: "message",
- renderClass: "message",
- role: "assistant",
- title: "Test Agent",
- text: "on it",
- timestamp: "2026-06-14T19:05:01.000Z",
- },
- ];
-}
-
-async function renderTranscript(variant, overrides = {}) {
- const rootRoute = createRootRoute({
- component: () =>
- createElement(AgentSessionTranscriptList, {
- ...AGENT,
- emptyDescription: "nothing yet",
- items: items(),
- variant,
- ...overrides,
- }),
- });
- const router = createRouter({
- history: createMemoryHistory({ initialEntries: ["/"] }),
- routeTree: rootRoute,
- });
- await router.load();
- return render(createElement(RouterProvider, { router }));
-}
-
-/**
- * Same mount, wrapped in the providers a fenced code block needs.
- *
- * `MarkdownCodeBlock` reaches for the theme (shiki highlighting) and a Radix
- * tooltip provider for its copy action, so a transcript containing a fenced
- * block throws without them. Kept as a separate helper rather than folded into
- * `renderTranscript` so the byte-for-byte fixture keeps rendering through the
- * exact tree it was captured with.
- */
-async function renderTranscriptWithCodeChrome(variant, overrides = {}) {
- const rootRoute = createRootRoute({
- component: () =>
- createElement(
- ThemeProvider,
- null,
- createElement(
- TooltipProvider,
- null,
- createElement(AgentSessionTranscriptList, {
- ...AGENT,
- emptyDescription: "nothing yet",
- items: items(),
- variant,
- ...overrides,
- }),
- ),
- ),
- });
- const router = createRouter({
- history: createMemoryHistory({ initialEntries: ["/"] }),
- routeTree: rootRoute,
- });
- await router.load();
- return render(createElement(RouterProvider, { router }));
-}
-
-/** One assistant turn whose body is a fenced code block. */
-function fencedCodeItems() {
- return [
- {
- channelId: "chan-1",
- sessionId: "sess-1",
- turnId: "turn-1",
- id: "msg:assistant",
- type: "message",
- renderClass: "message",
- role: "assistant",
- title: "Test Agent",
- text: "before\n\n```ts\nconst a = 1;\nconst b = 2;\n```\n",
- timestamp: "2026-06-14T19:00:09.000Z",
- },
- ];
-}
-
-/** One *human prompt* whose body is a fenced code block. */
-function fencedCodePromptItems() {
- return [
- {
- channelId: "chan-1",
- sessionId: "sess-1",
- turnId: "turn-1",
- id: "msg:user",
- type: "message",
- renderClass: "message",
- role: "user",
- title: TRIGGER_TITLE,
- text: "fix this\n\n```ts\nconst a = 1;\nconst b = 2;\n```\n",
- timestamp: "2026-06-14T19:00:00.000Z",
- messageId: "event-1",
- authorPubkey: AUTHOR,
- },
- ];
-}
-
-/**
- * Same mount, but the caller can swap the list props afterwards. Needed for the
- * contracts that are only visible across a rerender: a streaming thought
- * folding once the turn moves on, and a plan mutating in place.
- */
-async function renderRerenderableTranscript(variant, initialOverrides = {}) {
- let applyProps;
- const Harness = () => {
- const [overrides, setOverrides] = useState(initialOverrides);
- applyProps = setOverrides;
- return createElement(AgentSessionTranscriptList, {
- ...AGENT,
- emptyDescription: "nothing yet",
- items: items(),
- variant,
- ...overrides,
- });
- };
- const rootRoute = createRootRoute({ component: Harness });
- const router = createRouter({
- history: createMemoryHistory({ initialEntries: ["/"] }),
- routeTree: rootRoute,
- });
- await router.load();
- const utils = render(createElement(RouterProvider, { router }));
- return {
- ...utils,
- async setOverrides(next) {
- await act(async () => {
- applyProps(next);
- });
- },
- };
-}
-
-before(async () => {
- ({ act, cleanup, render } = await import("@testing-library/react"));
- ({ createElement, useState } = await import("react"));
- ({ createMemoryHistory, createRootRoute, createRouter, RouterProvider } =
- await import("@tanstack/react-router"));
- ({ AgentSessionTranscriptList } = await import(
- "./AgentSessionTranscriptList.tsx"
- ));
- ({ resetActiveAgentTurnsStore, syncAgentTurnsFromEvents } = await import(
- "../activeAgentTurnsStore.ts"
- ));
- ({ ThemeProvider } = await import("@/shared/theme/ThemeProvider.tsx"));
- ({ TooltipProvider } = await import("@/shared/ui/tooltip.tsx"));
-});
-
-afterEach(() => {
- cleanup?.();
- resetActiveAgentTurnsStore?.();
-});
-after(() => dom.window.close());
+import {
+ act,
+ AGENT,
+ AUTHOR,
+ AUTHOR_PROFILES,
+ AUTHOR_TRUNCATED,
+ BASELINE_MARKUP,
+ baselineItems,
+ cleanup,
+ domWindow,
+ FIXTURE_LOCALE,
+ items,
+ renderRerenderableTranscript,
+ renderTranscript,
+ syncAgentTurnsFromEvents,
+} from "./AgentSessionTranscriptList.conversationHarness.mjs";
test("conversation marks the transcript container and centers a reading column", async () => {
const { container } = await renderTranscript("conversation");
@@ -612,220 +94,6 @@ test("conversation caps the prompt bubble at a fixed measure, not a percentage",
assert.doesNotMatch(column.className, /max-w-\[\d+%\]/);
});
-test("conversation labels the agent turn with a berd-style identity row", async () => {
- // The single biggest divergence from berd was that agent prose carried no
- // attribution at all. berd puts a 20px round avatar + the agent name at
- // `text-xs` above every reply (MessageBubble.tsx:961-981).
- const { container } = await renderTranscript("conversation");
- const identity = container.querySelector(
- '[data-testid="transcript-assistant-identity"]',
- );
- assert.ok(identity, "conversation should label the agent turn");
- assert.match(identity.textContent, /Test Agent/);
- assert.match(identity.className, /text-xs/);
- assert.match(identity.className, /gap-1(?!\d)/);
- // 20px avatar, berd's size (UserAvatar `size="xs"` → `h-5 w-5`).
- assert.ok(
- identity.querySelector(".h-5.w-5"),
- "identity row should carry a 20px avatar",
- );
- // The prose itself stays unboxed and full-width.
- const message = container.querySelector(
- '[data-testid="transcript-assistant-message"]',
- );
- assert.doesNotMatch(message.innerHTML, /rounded-2xl/);
-});
-
-test("the conversation identity row resolves the agent avatar from the profiles lookup", async () => {
- // Regression guard for the primary channel flow. `ChannelAgentSessionAgent`
- // (useChannelAgentSessions.ts:21-29) has no avatar field at all, so when the
- // focus conversation is opened from a channel the panel passes
- // `agentAvatarUrl: null` — which is exactly this mount. The row must still
- // show the configured avatar by resolving it out of the `profiles` lookup the
- // panel already hands down, the same way the ToolItem row
- // (ToolItem.tsx:45-52) and the panel header above it already do. Before the
- // fix, every channel-opened session fell back to initials.
- const { container } = await renderTranscript("conversation", {
- agentAvatarUrl: null,
- profiles: AGENT_PROFILES,
- });
- const identity = container.querySelector(
- '[data-testid="transcript-assistant-identity"]',
- );
- assert.ok(identity, "the identity row should render");
- const image = identity.querySelector("img");
- assert.ok(
- image,
- "the profile avatar must win over the caller's null agent record avatar",
- );
- assert.equal(image.getAttribute("src"), AGENT_AVATAR_URL);
-});
-
-test("the conversation identity row resolves the agent name the same way the header does", async () => {
- // The row sits directly under the panel header, which labels the same agent
- // through `resolveUserLabel` (AgentSessionThreadPanel.tsx:244-249). Reading
- // the raw `agentName` prop instead let the two disagree whenever the relay
- // profile's display name differed from the caller's agent record.
- const { container } = await renderTranscript("conversation", {
- agentName: "stale-record-name",
- profiles: {
- [AGENT.agentPubkey]: {
- displayName: "Profile Display Name",
- avatarUrl: null,
- nip05Handle: null,
- ownerPubkey: null,
- },
- },
- });
- const identity = container.querySelector(
- '[data-testid="transcript-assistant-identity"]',
- );
- assert.equal(identity.textContent, "Profile Display Name");
-});
-
-test("the conversation identity row keeps the caller's avatar when the lookup has none", async () => {
- // The managed-agent path is the other direction: a locally managed agent can
- // carry an avatar its relay profile never published. The prop stays the
- // fallback, so resolving profile-first must not drop it.
- const localAvatar = "https://cdn.example.test/local-managed.png";
- const { container } = await renderTranscript("conversation", {
- agentAvatarUrl: localAvatar,
- profiles: {
- [AGENT.agentPubkey]: {
- displayName: "Test Agent",
- avatarUrl: null,
- nip05Handle: null,
- ownerPubkey: null,
- },
- },
- });
- const image = container
- .querySelector('[data-testid="transcript-assistant-identity"]')
- .querySelector("img");
- assert.ok(image, "the caller-supplied avatar should still render");
- assert.equal(image.getAttribute("src"), localAvatar);
-});
-
-test("conversation frames fenced code with berd's header row", async () => {
- // berd puts the language in a real header row above the frame, with the copy
- // action opposite it (`code-block.tsx` CodeBlockHeader:388-402), and the code
- // itself in a 10px-radius, page-background, borderless-shadow frame
- // (:528-529). Buzz's `rounded-lg` (`--radius: 0.625rem`) is exactly berd's
- // `rounded-[0.625rem]`.
- const { container } = await renderTranscriptWithCodeChrome("conversation", {
- items: fencedCodeItems(),
- });
- const header = container.querySelector(
- '[data-testid="markdown-code-block-header"]',
- );
- assert.ok(header, "focus mode should render a code-block header row");
- // Language sits in the header, not inside the frame.
- assert.match(header.textContent, /^ts/);
- assert.match(header.className, /justify-between/);
- assert.match(header.className, /items-end/);
- assert.match(header.className, /min-h-7/);
- assert.ok(
- header.querySelector('[aria-label="Copy code block"]'),
- "the copy action is a flow sibling of the language label",
- );
-
- const frame = container.querySelector("pre");
- assert.ok(frame, "the code frame should render");
- assert.match(frame.className, /rounded-lg/);
- assert.match(frame.className, /bg-background/);
- assert.match(frame.className, /border-border\/80/);
- assert.doesNotMatch(
- frame.className,
- /shadow/,
- "berd's code frame carries no shadow",
- );
- // Guards against the default recipe leaking in: it uses a 16px radius, a
- // muted fill, `pr-12` to clear an absolutely-positioned copy button, and an
- // inline `borderRadius` style.
- assert.doesNotMatch(frame.className, /rounded-2xl/);
- assert.doesNotMatch(frame.className, /bg-muted/);
- assert.doesNotMatch(frame.className, /pr-12/);
- assert.equal(frame.style.borderRadius, "");
- // Line numbers come from `.code-block-lines [data-line]` in markdown.css, so
- // the frame only has to keep emitting per-line elements under that class.
- const code = frame.querySelector("code.code-block-lines");
- assert.ok(code, "the code element keeps the line-number class");
- assert.equal(code.querySelectorAll("[data-line]").length, 2);
-});
-
-test("conversation applies the code recipe to a fenced human prompt too", async () => {
- // Regression guard for a real bug quality caught. The provider was first
- // mounted inside `MessageActivity`, which only handles assistant items — the
- // user bubble returns before it, so a fence inside a prompt kept the legacy
- // 16px muted frame nested inside the new 12px bubble. The recipe is a
- // property of the *surface*, not of a role, so the provider now sits at the
- // transcript boundary and both roles inherit it.
- const { container } = await renderTranscriptWithCodeChrome("conversation", {
- items: fencedCodePromptItems(),
- });
- const bubble = container.querySelector(
- '[data-testid="transcript-user-message"]',
- );
- assert.ok(bubble, "the prompt should render");
- assert.ok(
- bubble.querySelector('[data-testid="markdown-code-block-header"]'),
- "a fence inside the prompt gets berd's header row",
- );
- const frame = bubble.querySelector("pre");
- assert.match(frame.className, /rounded-lg/);
- assert.doesNotMatch(
- frame.className,
- /rounded-2xl/,
- "the legacy 16px frame must not nest inside the 12px bubble",
- );
- assert.doesNotMatch(frame.className, /pr-12/);
-});
-
-test("the default transcript variant keeps the legacy code chrome", async () => {
- // The markdown renderer is shared with channel messages, so `focusProse` is
- // opt-in per surface. Rendering the same fenced block through the `default`
- // transcript variant must still produce the original chrome: no header row,
- // 16px radius, muted fill, and the absolutely-positioned copy button.
- //
- // This proves the *variant gate*, not the channel-message row itself — those
- // rows are covered by the markdown tests in `shared/ui/markdown`.
- const { container } = await renderTranscriptWithCodeChrome("default", {
- items: fencedCodeItems(),
- });
- // `assert.ok(x === null)` rather than `assert.equal(x, null)`: on failure the
- // latter serializes the whole matched jsdom element (and its ancestors) to
- // build a diff, which exhausts memory instead of printing the message.
- assert.ok(
- container.querySelector('[data-testid="markdown-code-block-header"]') ===
- null,
- "the default recipe has no header row",
- );
- const frame = container.querySelector("pre");
- assert.match(frame.className, /rounded-2xl/);
- assert.match(frame.className, /bg-muted\/60/);
- assert.match(frame.className, /pr-12/);
- assert.match(frame.className, /shadow-xs/);
- const copy = container.querySelector('[aria-label="Copy code block"]');
- assert.ok(copy, "the default copy button still renders");
- assert.match(copy.className, /absolute/);
-});
-
-test("the identity row is conversation-only", async () => {
- // `default`/`compactPreview` markup is pinned byte-for-byte, so the identity
- // row must not leak into them. The fixture comparison would catch this too;
- // this asserts it directly so the failure names the cause.
- for (const variant of ["default", "compactPreview"]) {
- const { container } = await renderTranscript(variant);
- assert.ok(
- container.querySelector(
- '[data-testid="transcript-assistant-identity"]',
- ) === null,
- `${variant} must not render the identity row`,
- );
- cleanup();
- }
-});
-
test("conversation never shows the trigger title as the prompt author when the sender is unresolved", async () => {
// Regression guard. The label chain's last fallback used to be the prompt
// item's `title`, which is a description of the trigger ("@Mention",
@@ -964,7 +232,7 @@ test("conversation folds the thought when the turn moves on, even after the brow
// event follows rather than causes that state.
await act(async () => {
disclosure.open = true;
- disclosure.dispatchEvent(new dom.window.Event("toggle"));
+ disclosure.dispatchEvent(new domWindow.Event("toggle"));
});
assert.equal(
disclosure.open,
@@ -1021,7 +289,7 @@ test("conversation keeps a reader-opened thought open after the turn moves on",
await act(async () => {
disclosure.open = true;
- disclosure.dispatchEvent(new dom.window.Event("toggle"));
+ disclosure.dispatchEvent(new domWindow.Event("toggle"));
});
await setOverrides({ ...settledItems, items: items() });
diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationChrome.test.mjs b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationChrome.test.mjs
new file mode 100644
index 0000000000..65b28bbab7
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationChrome.test.mjs
@@ -0,0 +1,274 @@
+/**
+ * Presentation contract for the `conversation` transcript variant's *chrome*:
+ * the agent identity row above each reply, and the focus code-block recipe.
+ *
+ * Split out of `AgentSessionTranscriptList.conversation.test.mjs` to stay under
+ * the repo's hard 1000-line/file ceiling (AGENTS.md). The shared jsdom setup,
+ * ambient-formatting pins, and render helpers live in the harness so the two
+ * suites cannot drift apart.
+ */
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import {
+ AGENT,
+ AGENT_AVATAR_URL,
+ AGENT_PROFILES,
+ cleanup,
+ fencedCodeItems,
+ fencedCodePromptItems,
+ renderTranscript,
+ renderTranscriptWithCodeChrome,
+} from "./AgentSessionTranscriptList.conversationHarness.mjs";
+
+test("the conversation identity row announces the agent exactly once", async () => {
+ // `UserAvatar` names itself — either an `
` or
+ // its fallback initials — and the row puts the agent's name in visible text
+ // immediately after it. Left unhidden, assistive tech reads the same identity
+ // twice for every single agent turn in the transcript. The visible name is
+ // the row's one accessible identity; the avatar is decorative.
+ const { container } = await renderTranscript("conversation", {
+ agentAvatarUrl: null,
+ profiles: AGENT_PROFILES,
+ });
+ const identity = container.querySelector(
+ '[data-testid="transcript-assistant-identity"]',
+ );
+ assert.ok(identity, "the identity row should render");
+
+ // The avatar is present and still shows the resolved image ...
+ const decorative = identity.querySelector('[aria-hidden="true"]');
+ assert.ok(decorative, "the avatar must be hidden from the accessible tree");
+ const image = decorative.querySelector("img");
+ assert.ok(image, "hiding the avatar must not stop it rendering visually");
+ assert.equal(image.getAttribute("src"), AGENT_AVATAR_URL);
+
+ // ... but every name-bearing node it contains is inside the hidden subtree,
+ // so the accessible tree is left with exactly one copy of the name.
+ const named = [...identity.querySelectorAll("img[alt], [aria-label]")];
+ assert.ok(named.length > 0, "the avatar should still expose a raw alt/label");
+ for (const node of named) {
+ assert.ok(
+ node.closest('[aria-hidden="true"]') !== null,
+ `${node.tagName} carrying an accessible name escapes the hidden avatar subtree`,
+ );
+ }
+
+ // The one remaining accessible identity is the visible name text.
+ assert.equal(identity.textContent, "Test Agent");
+});
+
+test("conversation labels the agent turn with a berd-style identity row", async () => {
+ // The single biggest divergence from berd was that agent prose carried no
+ // attribution at all. berd puts a 20px round avatar + the agent name at
+ // `text-xs` above every reply (MessageBubble.tsx:961-981).
+ const { container } = await renderTranscript("conversation");
+ const identity = container.querySelector(
+ '[data-testid="transcript-assistant-identity"]',
+ );
+ assert.ok(identity, "conversation should label the agent turn");
+ assert.match(identity.textContent, /Test Agent/);
+ assert.match(identity.className, /text-xs/);
+ assert.match(identity.className, /gap-1(?!\d)/);
+ // 20px avatar, berd's size (UserAvatar `size="xs"` → `h-5 w-5`).
+ assert.ok(
+ identity.querySelector(".h-5.w-5"),
+ "identity row should carry a 20px avatar",
+ );
+ // The prose itself stays unboxed and full-width.
+ const message = container.querySelector(
+ '[data-testid="transcript-assistant-message"]',
+ );
+ assert.doesNotMatch(message.innerHTML, /rounded-2xl/);
+});
+
+test("the conversation identity row resolves the agent avatar from the profiles lookup", async () => {
+ // Regression guard for the primary channel flow. `ChannelAgentSessionAgent`
+ // (useChannelAgentSessions.ts:21-29) has no avatar field at all, so when the
+ // focus conversation is opened from a channel the panel passes
+ // `agentAvatarUrl: null` — which is exactly this mount. The row must still
+ // show the configured avatar by resolving it out of the `profiles` lookup the
+ // panel already hands down, the same way the ToolItem row
+ // (ToolItem.tsx:45-52) and the panel header above it already do. Before the
+ // fix, every channel-opened session fell back to initials.
+ const { container } = await renderTranscript("conversation", {
+ agentAvatarUrl: null,
+ profiles: AGENT_PROFILES,
+ });
+ const identity = container.querySelector(
+ '[data-testid="transcript-assistant-identity"]',
+ );
+ assert.ok(identity, "the identity row should render");
+ const image = identity.querySelector("img");
+ assert.ok(
+ image,
+ "the profile avatar must win over the caller's null agent record avatar",
+ );
+ assert.equal(image.getAttribute("src"), AGENT_AVATAR_URL);
+});
+
+test("the conversation identity row resolves the agent name the same way the header does", async () => {
+ // The row sits directly under the panel header, which labels the same agent
+ // through `resolveUserLabel` (AgentSessionThreadPanel.tsx:244-249). Reading
+ // the raw `agentName` prop instead let the two disagree whenever the relay
+ // profile's display name differed from the caller's agent record.
+ const { container } = await renderTranscript("conversation", {
+ agentName: "stale-record-name",
+ profiles: {
+ [AGENT.agentPubkey]: {
+ displayName: "Profile Display Name",
+ avatarUrl: null,
+ nip05Handle: null,
+ ownerPubkey: null,
+ },
+ },
+ });
+ const identity = container.querySelector(
+ '[data-testid="transcript-assistant-identity"]',
+ );
+ assert.equal(identity.textContent, "Profile Display Name");
+});
+
+test("the conversation identity row keeps the caller's avatar when the lookup has none", async () => {
+ // The managed-agent path is the other direction: a locally managed agent can
+ // carry an avatar its relay profile never published. The prop stays the
+ // fallback, so resolving profile-first must not drop it.
+ const localAvatar = "https://cdn.example.test/local-managed.png";
+ const { container } = await renderTranscript("conversation", {
+ agentAvatarUrl: localAvatar,
+ profiles: {
+ [AGENT.agentPubkey]: {
+ displayName: "Test Agent",
+ avatarUrl: null,
+ nip05Handle: null,
+ ownerPubkey: null,
+ },
+ },
+ });
+ const image = container
+ .querySelector('[data-testid="transcript-assistant-identity"]')
+ .querySelector("img");
+ assert.ok(image, "the caller-supplied avatar should still render");
+ assert.equal(image.getAttribute("src"), localAvatar);
+});
+
+test("conversation frames fenced code with berd's header row", async () => {
+ // berd puts the language in a real header row above the frame, with the copy
+ // action opposite it (`code-block.tsx` CodeBlockHeader:388-402), and the code
+ // itself in a 10px-radius, page-background, borderless-shadow frame
+ // (:528-529). Buzz's `rounded-lg` (`--radius: 0.625rem`) is exactly berd's
+ // `rounded-[0.625rem]`.
+ const { container } = await renderTranscriptWithCodeChrome("conversation", {
+ items: fencedCodeItems(),
+ });
+ const header = container.querySelector(
+ '[data-testid="markdown-code-block-header"]',
+ );
+ assert.ok(header, "focus mode should render a code-block header row");
+ // Language sits in the header, not inside the frame.
+ assert.match(header.textContent, /^ts/);
+ assert.match(header.className, /justify-between/);
+ assert.match(header.className, /items-end/);
+ assert.match(header.className, /min-h-7/);
+ assert.ok(
+ header.querySelector('[aria-label="Copy code block"]'),
+ "the copy action is a flow sibling of the language label",
+ );
+
+ const frame = container.querySelector("pre");
+ assert.ok(frame, "the code frame should render");
+ assert.match(frame.className, /rounded-lg/);
+ assert.match(frame.className, /bg-background/);
+ assert.match(frame.className, /border-border\/80/);
+ assert.doesNotMatch(
+ frame.className,
+ /shadow/,
+ "berd's code frame carries no shadow",
+ );
+ // Guards against the default recipe leaking in: it uses a 16px radius, a
+ // muted fill, `pr-12` to clear an absolutely-positioned copy button, and an
+ // inline `borderRadius` style.
+ assert.doesNotMatch(frame.className, /rounded-2xl/);
+ assert.doesNotMatch(frame.className, /bg-muted/);
+ assert.doesNotMatch(frame.className, /pr-12/);
+ assert.equal(frame.style.borderRadius, "");
+ // Line numbers come from `.code-block-lines [data-line]` in markdown.css, so
+ // the frame only has to keep emitting per-line elements under that class.
+ const code = frame.querySelector("code.code-block-lines");
+ assert.ok(code, "the code element keeps the line-number class");
+ assert.equal(code.querySelectorAll("[data-line]").length, 2);
+});
+
+test("conversation applies the code recipe to a fenced human prompt too", async () => {
+ // Regression guard for a real bug quality caught. The provider was first
+ // mounted inside `MessageActivity`, which only handles assistant items — the
+ // user bubble returns before it, so a fence inside a prompt kept the legacy
+ // 16px muted frame nested inside the new 12px bubble. The recipe is a
+ // property of the *surface*, not of a role, so the provider now sits at the
+ // transcript boundary and both roles inherit it.
+ const { container } = await renderTranscriptWithCodeChrome("conversation", {
+ items: fencedCodePromptItems(),
+ });
+ const bubble = container.querySelector(
+ '[data-testid="transcript-user-message"]',
+ );
+ assert.ok(bubble, "the prompt should render");
+ assert.ok(
+ bubble.querySelector('[data-testid="markdown-code-block-header"]'),
+ "a fence inside the prompt gets berd's header row",
+ );
+ const frame = bubble.querySelector("pre");
+ assert.match(frame.className, /rounded-lg/);
+ assert.doesNotMatch(
+ frame.className,
+ /rounded-2xl/,
+ "the legacy 16px frame must not nest inside the 12px bubble",
+ );
+ assert.doesNotMatch(frame.className, /pr-12/);
+});
+
+test("the default transcript variant keeps the legacy code chrome", async () => {
+ // The markdown renderer is shared with channel messages, so `focusProse` is
+ // opt-in per surface. Rendering the same fenced block through the `default`
+ // transcript variant must still produce the original chrome: no header row,
+ // 16px radius, muted fill, and the absolutely-positioned copy button.
+ //
+ // This proves the *variant gate*, not the channel-message row itself — those
+ // rows are covered by the markdown tests in `shared/ui/markdown`.
+ const { container } = await renderTranscriptWithCodeChrome("default", {
+ items: fencedCodeItems(),
+ });
+ // `assert.ok(x === null)` rather than `assert.equal(x, null)`: on failure the
+ // latter serializes the whole matched jsdom element (and its ancestors) to
+ // build a diff, which exhausts memory instead of printing the message.
+ assert.ok(
+ container.querySelector('[data-testid="markdown-code-block-header"]') ===
+ null,
+ "the default recipe has no header row",
+ );
+ const frame = container.querySelector("pre");
+ assert.match(frame.className, /rounded-2xl/);
+ assert.match(frame.className, /bg-muted\/60/);
+ assert.match(frame.className, /pr-12/);
+ assert.match(frame.className, /shadow-xs/);
+ const copy = container.querySelector('[aria-label="Copy code block"]');
+ assert.ok(copy, "the default copy button still renders");
+ assert.match(copy.className, /absolute/);
+});
+
+test("the identity row is conversation-only", async () => {
+ // `default`/`compactPreview` markup is pinned byte-for-byte, so the identity
+ // row must not leak into them. The fixture comparison would catch this too;
+ // this asserts it directly so the failure names the cause.
+ for (const variant of ["default", "compactPreview"]) {
+ const { container } = await renderTranscript(variant);
+ assert.ok(
+ container.querySelector(
+ '[data-testid="transcript-assistant-identity"]',
+ ) === null,
+ `${variant} must not render the identity row`,
+ );
+ cleanup();
+ }
+});
diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationHarness.mjs b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationHarness.mjs
new file mode 100644
index 0000000000..6dda85385d
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationHarness.mjs
@@ -0,0 +1,576 @@
+/**
+ * Shared test infrastructure for the `conversation` transcript-variant suites.
+ *
+ * Lives in a non-test file for two reasons. The `src/**\/*.test.mjs` glob would
+ * otherwise pick it up as a suite of its own, and — more importantly — the
+ * ambient-formatting pins below are order-sensitive and easy to get subtly
+ * wrong, so the two suites that need them must share one copy rather than
+ * maintain two. See `AgentSessionTranscriptList.conversation.test.mjs` (layout
+ * and lifecycle contracts) and
+ * `AgentSessionTranscriptList.conversationChrome.test.mjs` (identity row and
+ * code-block chrome).
+ *
+ * Importing this module installs the jsdom globals and registers the
+ * `before`/`afterEach`/`after` hooks for the importing suite. Import it before
+ * anything that reaches for React or the DOM.
+ *
+ * The byte-for-byte contract is the important one: `conversation` is purely
+ * additive, so the `default` and `compactPreview` markup for the same
+ * transcript must be byte-identical to the markup captured before the variant
+ * existed. That snapshot lives in
+ * AgentSessionTranscriptList.conversation.baseline.json and was produced by
+ * mounting `baselineItems()` — a transcript containing every renderable item
+ * kind across two sessions — on pre-change main (074561233) in a clean
+ * throwaway worktree. Regenerate it only when a deliberate change to the other
+ * variants is being made.
+ */
+
+import { readFileSync } from "node:fs";
+import { after, afterEach, before } from "node:test";
+
+// The captured markup embeds formatted dates and times, so the fixture is only
+// reproducible if every ambient formatting input is pinned. Two of them bite:
+//
+// - **Zone.** `formatTranscriptTimestampTitle` formats in the ambient zone
+// ("… at 7:00:01 PM"), so a capture at UTC-7 fails against CI's UTC.
+// - **Locale.** The session-boundary divider uses a bare `toLocaleString()`
+// (`AgentSessionTranscriptChrome.tsx`), which is locale-sensitive as well as
+// zone-sensitive: "6/14/2026, 7:05:00 PM" becomes "14.6.2026, 19:05:00"
+// under de-DE. Node derives its default locale from LANG/LC_ALL, so this
+// varies by machine independently of the zone.
+//
+// `TZ` can be set here because `Date` reads it lazily. The locale CANNOT: node
+// resolves its default locale once at startup, so assigning `process.env.LANG`
+// at runtime has no effect (verified — it silently keeps the startup locale).
+// Pinning it therefore means overriding the two formatting surfaces the render
+// path can reach: `Intl.DateTimeFormat` when constructed with no explicit
+// locale, and `Date.prototype.toLocale*`, which does NOT route through
+// `Intl.DateTimeFormat` and so needs its own patch.
+//
+// All of this must happen before the transcript modules are imported: their
+// `Intl.DateTimeFormat` instances are module-level constants that resolve zone
+// and locale once, at construction.
+process.env.TZ = "UTC";
+
+export const FIXTURE_LOCALE = "en-US";
+const OriginalDateTimeFormat = Intl.DateTimeFormat;
+// A plain function, not an arrow: the render path calls
+// `new Intl.DateTimeFormat(...)`, and an arrow function is not a constructor.
+// Returning a genuine instance keeps `new`, plain calls, and `instanceof` all
+// working.
+function LocalePinnedDateTimeFormat(locales, options) {
+ return new OriginalDateTimeFormat(locales ?? FIXTURE_LOCALE, options);
+}
+LocalePinnedDateTimeFormat.prototype = OriginalDateTimeFormat.prototype;
+LocalePinnedDateTimeFormat.supportedLocalesOf =
+ OriginalDateTimeFormat.supportedLocalesOf.bind(OriginalDateTimeFormat);
+Intl.DateTimeFormat = LocalePinnedDateTimeFormat;
+for (const method of [
+ "toLocaleString",
+ "toLocaleDateString",
+ "toLocaleTimeString",
+]) {
+ const original = Date.prototype[method];
+ Date.prototype[method] = function (locales, options) {
+ return original.call(this, locales ?? FIXTURE_LOCALE, options);
+ };
+}
+
+import { JSDOM } from "jsdom";
+
+export const BASELINE_MARKUP = JSON.parse(
+ readFileSync(
+ new URL(
+ "./AgentSessionTranscriptList.conversation.baseline.json",
+ import.meta.url,
+ ),
+ "utf8",
+ ),
+);
+
+const dom = new JSDOM("", {
+ url: "http://localhost",
+});
+
+class NoopObserver {
+ disconnect() {}
+ observe() {}
+ unobserve() {}
+}
+
+Object.assign(globalThis, {
+ Element: dom.window.Element,
+ Event: dom.window.Event,
+ HTMLElement: dom.window.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ IntersectionObserver: NoopObserver,
+ MutationObserver: dom.window.MutationObserver,
+ Node: dom.window.Node,
+ ResizeObserver: NoopObserver,
+ document: dom.window.document,
+ getComputedStyle: (...args) => dom.window.getComputedStyle(...args),
+ localStorage: dom.window.localStorage,
+ self: dom.window,
+ window: dom.window,
+});
+Object.defineProperty(globalThis, "navigator", {
+ configurable: true,
+ value: dom.window.navigator,
+ writable: true,
+});
+dom.window.matchMedia = () => ({
+ matches: false,
+ addEventListener() {},
+ removeEventListener() {},
+});
+dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
+dom.window.cancelAnimationFrame = (id) => clearTimeout(id);
+globalThis.requestAnimationFrame = dom.window.requestAnimationFrame;
+globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame;
+
+/**
+ * Radix's `AvatarImage` renders nothing until its own preloader reports
+ * `loaded` (`react-avatar/dist/index.mjs` `useImageLoadingStatus`), and jsdom
+ * never fetches, so a real avatar URL would otherwise stay in `loading` forever
+ * and every avatar assertion would see only the initials fallback — the exact
+ * bug under test, passing vacuously. This stub reports a decoded image as soon
+ * as `src` is assigned. It only affects avatars that HAVE a url: with
+ * `avatarUrl: null` radix resolves to `error` and skips the preloader entirely,
+ * so the byte-for-byte baseline (whose agent and author carry no avatar) is
+ * untouched.
+ */
+class LoadedImageStub {
+ constructor() {
+ this._src = "";
+ this.complete = false;
+ this.naturalWidth = 0;
+ }
+ addEventListener() {}
+ removeEventListener() {}
+ get src() {
+ return this._src;
+ }
+ set src(value) {
+ this._src = value;
+ this.complete = true;
+ this.naturalWidth = 1;
+ }
+}
+dom.window.Image = LoadedImageStub;
+
+/**
+ * Assigned in `before`. Exported as `let` so importers see the resolved values
+ * through ES module live bindings rather than a snapshot taken at import time.
+ */
+export let act;
+export let cleanup;
+export let render;
+let createElement;
+let useState;
+let createMemoryHistory;
+let createRootRoute;
+let createRouter;
+let RouterProvider;
+let AgentSessionTranscriptList;
+let ThemeProvider;
+let TooltipProvider;
+export let resetActiveAgentTurnsStore;
+export let syncAgentTurnsFromEvents;
+
+export const AGENT = {
+ agentAvatarUrl: null,
+ agentName: "Test Agent",
+ agentPubkey: "f".repeat(64),
+};
+export const AUTHOR = "a".repeat(64);
+export const AUTHOR_TRUNCATED = `${AUTHOR.slice(0, 8)}…${AUTHOR.slice(-4)}`;
+/**
+ * What the transcript builder actually puts in a prompt item's `title`: a
+ * description of the trigger that started the turn, not an identity. Real values
+ * are "Prompt", "Buzz event", and title-cased event kinds like "@Mention"
+ * (`agentSessionTranscriptHelpers.ts` `parsePromptText`). The author row must
+ * never display this as a name.
+ */
+export const TRIGGER_TITLE = "@Mention";
+export const AUTHOR_PROFILES = {
+ [AUTHOR]: {
+ displayName: "Ada Lovelace",
+ avatarUrl: null,
+ nip05Handle: null,
+ ownerPubkey: null,
+ },
+};
+/**
+ * A relay-resolved profile for the *agent*. Deliberately not a `/media/`
+ * relay URL: `UserAvatar` routes those through the localhost media proxy
+ * (`rewriteRelayUrl`), which would make the rendered `src` a moving target.
+ */
+export const AGENT_AVATAR_URL = "https://cdn.example.test/agent-profile.png";
+export const AGENT_PROFILES = {
+ [AGENT.agentPubkey]: {
+ displayName: "Test Agent",
+ avatarUrl: AGENT_AVATAR_URL,
+ nip05Handle: null,
+ ownerPubkey: null,
+ },
+};
+
+export function items() {
+ const shared = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
+ return [
+ {
+ ...shared,
+ id: "msg:user",
+ type: "message",
+ renderClass: "message",
+ role: "user",
+ title: TRIGGER_TITLE,
+ text: "please summarize the plan",
+ timestamp: "2026-06-14T19:00:00.000Z",
+ messageId: "event-1",
+ authorPubkey: AUTHOR,
+ },
+ {
+ ...shared,
+ id: "thought:1",
+ type: "thought",
+ renderClass: "thought",
+ title: "Thinking",
+ text: "weighing the options",
+ timestamp: "2026-06-14T19:00:02.000Z",
+ },
+ {
+ ...shared,
+ id: "plan:1",
+ type: "plan",
+ renderClass: "plan",
+ title: "Plan",
+ text: "- [x] read the transcript\n- [ ] write the summary (in progress)\n- [ ] ship it",
+ timestamp: "2026-06-14T19:00:07.000Z",
+ },
+ {
+ ...shared,
+ id: "msg:assistant",
+ type: "message",
+ renderClass: "message",
+ role: "assistant",
+ title: "Test Agent",
+ text: "Here is the summary with `code`.",
+ timestamp: "2026-06-14T19:00:09.000Z",
+ },
+ ];
+}
+
+/**
+ * Everything the legacy variants can render, in one transcript.
+ *
+ * The byte-for-byte contract covers `default`/`compactPreview` for EVERY item
+ * kind, so the baseline input has to contain every kind rather than the happy
+ * path: prompt (with prompt context and setup lifecycle so the ingress chrome
+ * renders), assistant message, thought, plan, a tool item, ordinary lifecycle
+ * status, error, permission — across two sessions so a session-boundary divider
+ * is forced too. Where `compactPreview` deliberately suppresses a kind, that
+ * absence is captured in the fixture and is therefore also protected.
+ *
+ * Single tool item on purpose: a run of three would collapse into a grouped
+ * summary and the leaf tool row would never be captured.
+ */
+export function baselineItems() {
+ const first = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
+ const second = { channelId: "chan-1", sessionId: "sess-2", turnId: "turn-2" };
+ return [
+ {
+ ...first,
+ id: "life:setup",
+ type: "lifecycle",
+ renderClass: "status",
+ title: "Turn started",
+ text: "1 trigger",
+ timestamp: "2026-06-14T19:00:00.000Z",
+ acpSource: "turn_started",
+ },
+ {
+ ...first,
+ id: "meta:context",
+ type: "metadata",
+ renderClass: "raw-rail",
+ title: "Prompt context",
+ sections: [{ title: "Channel", body: "engineering" }],
+ timestamp: "2026-06-14T19:00:00.500Z",
+ acpSource: "session/prompt:context",
+ },
+ {
+ ...first,
+ id: "msg:user",
+ type: "message",
+ renderClass: "message",
+ role: "user",
+ title: "Ada",
+ text: "please summarize the plan",
+ timestamp: "2026-06-14T19:00:01.000Z",
+ messageId: "event-1",
+ authorPubkey: AUTHOR,
+ acpSource: "session/prompt:user",
+ },
+ {
+ ...first,
+ id: "thought:1",
+ type: "thought",
+ renderClass: "thought",
+ title: "Thinking",
+ text: "weighing the options",
+ timestamp: "2026-06-14T19:00:02.000Z",
+ },
+ {
+ ...first,
+ id: "plan:1",
+ type: "plan",
+ renderClass: "plan",
+ title: "Plan",
+ text: "- [x] read the transcript\n- [ ] write the summary (in progress)\n- [ ] ship it",
+ timestamp: "2026-06-14T19:00:03.000Z",
+ },
+ {
+ ...first,
+ id: "tool:1",
+ type: "tool",
+ renderClass: "shell",
+ descriptor: {
+ renderClass: "shell",
+ label: "Ran a command",
+ preview: "cargo test",
+ tone: "neutral",
+ source: "shell",
+ },
+ title: "Ran a command",
+ toolName: "shell",
+ buzzToolName: null,
+ status: "completed",
+ args: { command: "cargo test" },
+ result: "ok",
+ isError: false,
+ timestamp: "2026-06-14T19:00:04.000Z",
+ startedAt: "2026-06-14T19:00:04.000Z",
+ completedAt: "2026-06-14T19:00:05.000Z",
+ },
+ {
+ ...first,
+ id: "life:permission",
+ type: "lifecycle",
+ renderClass: "permission",
+ title: "Permission requested",
+ text: "write src/main.rs\nOptions: Allow, Deny",
+ outcome: "Approved (once)",
+ timestamp: "2026-06-14T19:00:06.000Z",
+ },
+ {
+ ...first,
+ id: "life:status",
+ type: "lifecycle",
+ renderClass: "status",
+ title: "Context compacted",
+ text: "",
+ timestamp: "2026-06-14T19:00:07.000Z",
+ },
+ {
+ ...first,
+ id: "msg:assistant",
+ type: "message",
+ renderClass: "message",
+ role: "assistant",
+ title: "Test Agent",
+ text: "Here is the summary with `code`.",
+ timestamp: "2026-06-14T19:00:08.000Z",
+ },
+ {
+ ...first,
+ id: "life:error",
+ type: "lifecycle",
+ renderClass: "error",
+ title: "Turn failed",
+ text: "the harness exited",
+ timestamp: "2026-06-14T19:00:09.000Z",
+ },
+ // Second session run: forces a session-boundary divider between the runs.
+ {
+ ...second,
+ id: "msg:user2",
+ type: "message",
+ renderClass: "message",
+ role: "user",
+ title: "Ada",
+ text: "next task",
+ timestamp: "2026-06-14T19:05:00.000Z",
+ messageId: "event-2",
+ authorPubkey: AUTHOR,
+ acpSource: "session/prompt:user",
+ },
+ {
+ ...second,
+ id: "msg:assistant2",
+ type: "message",
+ renderClass: "message",
+ role: "assistant",
+ title: "Test Agent",
+ text: "on it",
+ timestamp: "2026-06-14T19:05:01.000Z",
+ },
+ ];
+}
+
+export async function renderTranscript(variant, overrides = {}) {
+ const rootRoute = createRootRoute({
+ component: () =>
+ createElement(AgentSessionTranscriptList, {
+ ...AGENT,
+ emptyDescription: "nothing yet",
+ items: items(),
+ variant,
+ ...overrides,
+ }),
+ });
+ const router = createRouter({
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ routeTree: rootRoute,
+ });
+ await router.load();
+ return render(createElement(RouterProvider, { router }));
+}
+
+/**
+ * Same mount, wrapped in the providers a fenced code block needs.
+ *
+ * `MarkdownCodeBlock` reaches for the theme (shiki highlighting) and a Radix
+ * tooltip provider for its copy action, so a transcript containing a fenced
+ * block throws without them. Kept as a separate helper rather than folded into
+ * `renderTranscript` so the byte-for-byte fixture keeps rendering through the
+ * exact tree it was captured with.
+ */
+export async function renderTranscriptWithCodeChrome(variant, overrides = {}) {
+ const rootRoute = createRootRoute({
+ component: () =>
+ createElement(
+ ThemeProvider,
+ null,
+ createElement(
+ TooltipProvider,
+ null,
+ createElement(AgentSessionTranscriptList, {
+ ...AGENT,
+ emptyDescription: "nothing yet",
+ items: items(),
+ variant,
+ ...overrides,
+ }),
+ ),
+ ),
+ });
+ const router = createRouter({
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ routeTree: rootRoute,
+ });
+ await router.load();
+ return render(createElement(RouterProvider, { router }));
+}
+
+/** One assistant turn whose body is a fenced code block. */
+export function fencedCodeItems() {
+ return [
+ {
+ channelId: "chan-1",
+ sessionId: "sess-1",
+ turnId: "turn-1",
+ id: "msg:assistant",
+ type: "message",
+ renderClass: "message",
+ role: "assistant",
+ title: "Test Agent",
+ text: "before\n\n```ts\nconst a = 1;\nconst b = 2;\n```\n",
+ timestamp: "2026-06-14T19:00:09.000Z",
+ },
+ ];
+}
+
+/** One *human prompt* whose body is a fenced code block. */
+export function fencedCodePromptItems() {
+ return [
+ {
+ channelId: "chan-1",
+ sessionId: "sess-1",
+ turnId: "turn-1",
+ id: "msg:user",
+ type: "message",
+ renderClass: "message",
+ role: "user",
+ title: TRIGGER_TITLE,
+ text: "fix this\n\n```ts\nconst a = 1;\nconst b = 2;\n```\n",
+ timestamp: "2026-06-14T19:00:00.000Z",
+ messageId: "event-1",
+ authorPubkey: AUTHOR,
+ },
+ ];
+}
+
+/**
+ * Same mount, but the caller can swap the list props afterwards. Needed for the
+ * contracts that are only visible across a rerender: a streaming thought
+ * folding once the turn moves on, and a plan mutating in place.
+ */
+export async function renderRerenderableTranscript(
+ variant,
+ initialOverrides = {},
+) {
+ let applyProps;
+ const Harness = () => {
+ const [overrides, setOverrides] = useState(initialOverrides);
+ applyProps = setOverrides;
+ return createElement(AgentSessionTranscriptList, {
+ ...AGENT,
+ emptyDescription: "nothing yet",
+ items: items(),
+ variant,
+ ...overrides,
+ });
+ };
+ const rootRoute = createRootRoute({ component: Harness });
+ const router = createRouter({
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ routeTree: rootRoute,
+ });
+ await router.load();
+ const utils = render(createElement(RouterProvider, { router }));
+ return {
+ ...utils,
+ async setOverrides(next) {
+ await act(async () => {
+ applyProps(next);
+ });
+ },
+ };
+}
+
+before(async () => {
+ ({ act, cleanup, render } = await import("@testing-library/react"));
+ ({ createElement, useState } = await import("react"));
+ ({ createMemoryHistory, createRootRoute, createRouter, RouterProvider } =
+ await import("@tanstack/react-router"));
+ ({ AgentSessionTranscriptList } = await import(
+ "./AgentSessionTranscriptList.tsx"
+ ));
+ ({ resetActiveAgentTurnsStore, syncAgentTurnsFromEvents } = await import(
+ "../activeAgentTurnsStore.ts"
+ ));
+ ({ ThemeProvider } = await import("@/shared/theme/ThemeProvider.tsx"));
+ ({ TooltipProvider } = await import("@/shared/ui/tooltip.tsx"));
+});
+
+afterEach(() => {
+ cleanup?.();
+ resetActiveAgentTurnsStore?.();
+});
+after(() => dom.window.close());
+/**
+ * The jsdom window itself. Exported for the few tests that must construct a
+ * real DOM event (`new domWindow.Event("toggle")`) to simulate a browser echo.
+ */
+export const domWindow = dom.window;
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx
index 6b42a637a5..9b1f8d588e 100644
--- a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx
+++ b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx
@@ -104,13 +104,28 @@ function MessageItem({
className="mb-0.5 flex items-center gap-1 text-xs"
data-testid="transcript-assistant-identity"
>
- {/* `size="xs"` is already 20px (`h-5 w-5`) in UserAvatar. */}
-
+ {/*
+ * The avatar is decorative here: `UserAvatar` exposes either an
+ * image named `${displayName} avatar` or its fallback initials, and
+ * the agent's name already follows as visible text, so an
+ * unhidden avatar makes a screen reader announce the same identity
+ * twice for every agent turn. The visible name is the row's single
+ * accessible identity. Hidden at this call site rather than by
+ * teaching the shared `UserAvatar` a decorative mode: other rows
+ * that pair an avatar with adjacent name text (for example
+ * `ForumPostCard.tsx:91-99`) have the same shape and would want the
+ * same treatment, but changing the shared component's accessible
+ * name affects all 45 of its call sites and is not this PR's scope.
+ *
+ * `size="xs"` is already 20px (`h-5 w-5`) in UserAvatar.
+ */}
+
+
+
{resolvedAgentName}