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 `${displayName} avatar` 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}