From 036bd38c3bd9dd9ea0b4652ec30e5c4609f36f6d Mon Sep 17 00:00:00 2001 From: ss-dev-02 <9e916f802f7932c38e630ac6b4726f5db7ca326c0d98e9bab495309eef13fe5a@buzz.block.builderlab.xyz> Date: Mon, 24 Aug 2026 19:26:20 -0700 Subject: [PATCH] test(desktop): split the work-block suite and prune the conversation harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AgentSessionWorkBlock.test.mjs` landed at 1,146 lines. Under today's rule tables that passes, but only because the desktop ratchet's script roots allowlist `.ts`/`.tsx` and silently skip `.mjs` — the gap #6736 (`ce1f1d427`) closes. Measured rather than assumed: with that rule table cherry-picked, this file is the one remaining violation on C, and because `allowedLineCount` grandfathers a base that already exceeds the max, whatever count C lands with becomes the file's permanent ceiling on main. 1,146 is not a ceiling worth inheriting for a file that exists to cover a single component. Split on the seam the file already had, at `// -- Orphaned work --`, where the subject changes from live-vs-finished *policy* to what an individual *row* is: - `AgentSessionWorkBlockTestRig.mjs` (311) — jsdom lifecycle, the item fixtures, `settle()`, `renderBlock()`. - `AgentSessionWorkBlock.test.mjs` (352) — Live, Finished, fold animation, reader choice, rail glyph states. - `AgentSessionWorkBlock.orphaned.test.mjs` (528) — orphaned work, the per-kind rail presentation, streaming re-render cost. One rig, not a copy per file. The two suites run in separate processes (node's runner is one process per file), so a second copy of the jsdom and `matchMedia` setup could not *collide* — it would drift, and a drifted ambient pin fails a fixture for a reason that has nothing to do with the markup under test. That trap already cost this suite family two commits (`2b7b0baf6`, `c91819706`). The split forces one real behavioural change. `prefersReducedMotion` was a module-level `let` the reduced-motion test assigned directly; ESM bindings are read-only in importers, so once it lives in the rig that assignment cannot work. It goes through `setPrefersReducedMotion(value)`, with the rig's `afterEach` still resetting it. Proved non-vacuous by stubbing the setter to a no-op: that fails exactly one test — the reduced-motion one — and restoring it passes. Without that check the test would have been asserting against a flag nothing sets, which is precisely the way a split can silently disarm a test. Behaviour is otherwise preserved, checked rather than assumed: the 30 test titles across the two files are an exact set match with the 30 in the single file, and every body is byte-identical apart from the setter call. Harness prune, in the same revision because it is the same debt: with the four `
` disclosure tests gone, `export const domWindow = dom.window` had no consumer anywhere in `desktop/src` — it existed only for `new domWindow.Event("toggle")`. Dropping just the `export` leaves an unused local that Biome flags (`lint/correctness/noUnusedVariables`), so the declaration and its now-false doc comment both go (576 -> 571). `TRIGGER_TITLE` only loses its `export`; it is still used inside the harness. Ratchet with #6736's rules cherry-picked, run in the same shell as `git rev-parse HEAD`: - base `fd2e01799` (the current PR base, which is what CI resolves — `resolveBaseRef` returns `HEAD^1` under `GITHUB_ACTIONS`): exit 0. - base `merge-base(origin/main)` = `db5617dd1`, which is what it resolves to locally and would resolve to if C retargets: exit 0. - negative control at the pre-split tree, same rules, same base: exit 1, `AgentSessionWorkBlock.test.mjs: new -> 1146 lines (allowed 1000)`. The gate is measuring the thing this commit fixes. Both suites green together, 30/30. `conversation` + `conversationChrome` still green. `tsc --noEmit` clean, `pnpm check` findings identical to main's baseline. The split shape, the seam and the reduced-motion proof are dev-01's, handed over as a verified patch (`OUTBOX/C_WORKBLOCK_SPLIT_HARNESS_PRUNE.patch`, sha256 6cfbccc9…) rather than as a commit in this checkout. I read the seam, reconstructed the tree from `fe57b7ac7` + that patch to confirm it is exactly what was proposed, and re-ran the title/body comparison, the setter mutant, both ratchet bases and the negative control here. Co-authored-by: Bradley Axen Signed-off-by: Bradley Axen --- ...sionTranscriptList.conversationHarness.mjs | 7 +- .../AgentSessionWorkBlock.orphaned.test.mjs | 528 +++++++++++ .../agents/ui/AgentSessionWorkBlock.test.mjs | 817 +----------------- .../ui/AgentSessionWorkBlockTestRig.mjs | 311 +++++++ 4 files changed, 852 insertions(+), 811 deletions(-) create mode 100644 desktop/src/features/agents/ui/AgentSessionWorkBlock.orphaned.test.mjs create mode 100644 desktop/src/features/agents/ui/AgentSessionWorkBlockTestRig.mjs diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationHarness.mjs b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationHarness.mjs index 6dda85385d..11c6c89bf5 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationHarness.mjs +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversationHarness.mjs @@ -191,7 +191,7 @@ export const AUTHOR_TRUNCATED = `${AUTHOR.slice(0, 8)}…${AUTHOR.slice(-4)}`; * (`agentSessionTranscriptHelpers.ts` `parsePromptText`). The author row must * never display this as a name. */ -export const TRIGGER_TITLE = "@Mention"; +const TRIGGER_TITLE = "@Mention"; export const AUTHOR_PROFILES = { [AUTHOR]: { displayName: "Ada Lovelace", @@ -569,8 +569,3 @@ afterEach(() => { 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/AgentSessionWorkBlock.orphaned.test.mjs b/desktop/src/features/agents/ui/AgentSessionWorkBlock.orphaned.test.mjs new file mode 100644 index 0000000000..6998e6c899 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionWorkBlock.orphaned.test.mjs @@ -0,0 +1,528 @@ +/** + * Work-block behaviour for work that was abandoned mid-flight, the rail's + * per-kind presentation, and the re-render cost a streaming block must not pay. + * The live/finished contracts live in `AgentSessionWorkBlock.test.mjs`; the + * shared rig is `AgentSessionWorkBlockTestRig.mjs`. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + noteStep, + relayStep, + renderBlock, + step, + thoughtStep, +} from "./AgentSessionWorkBlockTestRig.mjs"; + +// ── Orphaned work ──────────────────────────────────────────────────────────── + +/** + * Reopened history must not present as work in progress. + * + * A tool's `executing` status is written when the step starts and never revised + * if the agent dies first, so an abandoned step keeps it forever. In this block + * that status is not a per-row detail — one `running` entry makes the whole + * block active, which suppresses the folded summary line, keeps the rail + * expanded and pulses a bullet. Scrolling back to a crashed turn therefore + * showed the reader live work indefinitely (Codex P2 on #6536). + */ +test("a block whose running step has no live session folds like finished work", async () => { + const view = await renderBlock( + [step("a"), step("b", { status: "executing", completedAt: null })], + { liveTurnId: null, streamingItemId: null }, + ); + + const summary = view.summary(); + assert.ok( + summary, + "history gets its folded summary line — the orphaned status must not suppress it", + ); + assert.match( + summary.textContent, + /2 steps$/, + "an abandoned step is not known to have failed, so the count stays neutral", + ); + assert.equal(await view.settleToStepCount(0), 0, "the rail folds away"); + assert.equal( + view.qa(".animate-pulse").length, + 0, + "nothing pulses when nothing is running", + ); +}); + +test("the same items still hold the block open while the turn is live", async () => { + // The paired half: this is what makes the gate meaningful rather than a + // blanket "never trust executing". Identical items, live turn. + const view = await renderBlock( + [step("a"), step("b", { status: "executing", completedAt: null })], + { liveTurnId: "turn-1", streamingItemId: null }, + ); + + assert.equal(view.summary(), null, "a live block has no folded line"); + assert.equal(view.stepCount(), 2, "the rail stays open"); + assert.deepEqual(view.glyphStates(), ["settled", "running"]); + assert.equal(view.qa(".animate-pulse").length, 1, "live work pulses"); +}); + +test("an agent live on a later turn does not resurrect an earlier turn's abandoned step", async () => { + // The reason the signal is a turn id and not a boolean: a restarted agent is + // live, but not on this turn, and a global flag would keep this step spinning. + const view = await renderBlock( + [step("a"), step("b", { status: "executing", completedAt: null })], + { liveTurnId: "turn-2", streamingItemId: null }, + ); + + assert.ok( + view.summary(), + "this turn is history even though the agent is busy", + ); + assert.equal(await view.settleToStepCount(0), 0); + assert.equal(view.qa(".animate-pulse").length, 0); +}); + +test("a block live when the session ends folds instead of spinning forever", async () => { + const { act } = await import("@testing-library/react"); + // The bug as the reader meets it live: the agent dies mid-step, so the item + // never reaches a terminal status and the only thing that changes is that no + // turn is live any more. + const live = [ + step("a"), + step("b", { status: "executing", completedAt: null }), + ]; + const view = await renderBlock(live, { + liveTurnId: "turn-1", + streamingItemId: "b", + }); + assert.equal(view.stepCount(), 2, "live: the rail is open"); + assert.equal(view.summary(), null); + + // Session gone. The items are BYTE-IDENTICAL — only liveness changed. + await act(async () => { + view.stream(live, null, null); + }); + + assert.ok(view.summary(), "the block settles when its session goes away"); + assert.match(view.summary().textContent, /2 steps$/); + assert.equal(await view.settleToStepCount(0), 0); + assert.equal(view.qa(".animate-pulse").length, 0); +}); + +test("an orphaned step keeps its own row detail rather than gaining an interrupted marker", async () => { + // Deliberate: we do not know what happened to the step, so it renders as the + // neutral step it is with whatever it recorded. A visible "interrupted" + // treatment would be a design addition, not part of this fix. + const view = await renderBlock( + [step("a"), step("b", { status: "executing", completedAt: null })], + { liveTurnId: null, streamingItemId: null }, + ); + await view.expand(); + await view.settleToStepCount(2); + + assert.deepEqual( + view.glyphStates(), + ["settled", "settled"], + "no third state was invented for an abandoned step", + ); + assert.equal( + view.qa('[data-testid="transcript-tool-item"]').length, + 2, + "both steps still render through the normal tool presenter", + ); +}); + +test("the rail bullet masks the spine with the drawer surface colour", async () => { + // The bullet has to mask the spine passing behind it, and the mask must match + // the surface the transcript is drawn on. A mask in any other colour shows as + // a disc of the wrong shade around every bullet (berd's BOT-1599). + const view = await renderBlock([step("a"), step("b")]); + await view.expand(); + const bullet = view.q("[data-step-state]"); + assert.match(bullet.className, /\bbg-background\b/); + assert.match(bullet.className, /\bring-background\b/); + assert.match(bullet.className, /\brounded-full\b/); +}); + +test("the spine is drawn for every step except the last", async () => { + const view = await renderBlock([step("a"), step("b"), step("c")]); + await view.expand(); + const spines = view.qa(".w-px"); + assert.equal( + spines.length, + 2, + "three steps means two connecting segments; a trailing spine would dangle", + ); +}); + +test("thinking renders as a rail row with its own glyph, not a nested disclosure", async () => { + const view = await renderBlock([thoughtStep("thought:1"), step("a")]); + await view.expand(); + const thought = view.q('[data-testid="transcript-work-block-thought"]'); + assert.ok(thought, "reasoning renders on the rail"); + assert.match(thought.textContent, /weighing the options/); + assert.equal( + view.q('[data-testid="transcript-thought-disclosure"]'), + null, + "the block is already one disclosure — a thought must not add a second", + ); +}); + +/** + * An interim note is progress, not a second reply. + * + * #6720 gives every conversation-variant assistant message a 20px avatar + name + * identity row, which is right for the turn's answer. A rail note is the same + * item type, so routing it through that presenter would render a fully + * attributed agent turn nested inside a muted step row — the reader would see + * the agent apparently reply twice, once inside the work it was doing. berd + * draws the same line: its `progress` entry is a plain rail row. + * + * The suppression is done on this side (a dedicated prose body) rather than by + * reaching into the message presenter, so #6720 keeps one rule for what a + * message looks like. + */ +test("an interim note renders as rail prose with no identity row", async () => { + const view = await renderBlock([ + step("a"), + noteStep("msg:interim", "checked the three call sites"), + ]); + await view.expand(); + + const note = view.q('[data-testid="transcript-work-block-note"]'); + assert.ok(note, "the note renders on the rail"); + assert.match(note.textContent, /checked the three call sites/); + + assert.ok( + view.q('[data-testid="transcript-assistant-identity"]') === null, + "an avatar + name row inside a muted step reads as a second reply", + ); + assert.ok( + view.q('[data-testid="transcript-assistant-message"]') === null, + "the note must not go through the message presenter at all", + ); +}); + +/** + * A relay post is a step, not a reply — the same rule as an interim note, + * reached by a different route. + * + * A note is an assistant *message* the block re-presents as prose. A relay post + * is a *tool call* that classifies as `renderClass: "message"`, so it renders + * through `CompactMessageSummary`: 28px avatar, bordered speech bubble, + * timestamp, delivery-receipt button. That is right in the activity feed, where + * a posted message is a destination to open; on the rail it makes the agent + * appear to reply in the middle of its own work — and it did, in the seeded + * browser preview, which is where this was caught. + * + * Suppressing it needs the presentation signal rather than the transcript + * variant: the same relay step OUTSIDE a block in this variant keeps its + * bubble, which the next test pins. + */ +test("a relay post on the rail is a plain step, with no bubble or avatar", async () => { + const view = await renderBlock([step("a"), relayStep("relay:1")]); + await view.expand(); + + assert.equal( + view.stepCount(), + 2, + "the relay post takes its own rail row, like any other step", + ); + assert.equal( + view.qa('[data-work-block-entry="tool"]').length, + 2, + "a relay post is a tool step — it is something the agent did", + ); + assert.equal( + view.q('[data-testid="transcript-tool-message-preview"]'), + null, + "a speech bubble inside a muted step reads as the agent replying mid-work", + ); + assert.equal( + view.q('[data-testid="transcript-agent-sent-avatar"]'), + null, + "no identity avatar on the rail", + ); + assert.equal( + view.q('[data-testid="transcript-sent-message-context-button"]'), + null, + "no delivery receipt on the rail", + ); + + // It is still a real, expandable tool row carrying its command. + const rows = view.qa('[data-testid="transcript-tool-item"]'); + assert.equal(rows.length, 2, "both steps render as tool rows"); + assert.ok( + rows[1].querySelector("details"), + "the relay step keeps the ordinary step disclosure so its args stay reachable", + ); + assert.match( + rows[1].textContent, + /Sent|posted the findings/, + "the row still says what the step was", + ); +}); + +/** + * The other half of the branch: outside a block the bubble is correct and must + * survive. Without this, suppressing the bubble everywhere in the conversation + * variant would pass the test above. + */ +test("the same relay post outside a work block keeps its message bubble", async () => { + const { createElement } = await import("react"); + const { render } = await import("@testing-library/react"); + const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + ); + const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } = + await import("@tanstack/react-router"); + const { AgentSessionTranscriptVariantProvider } = await import( + "./agentSessionTranscriptContext.ts" + ); + const { TranscriptActivityItem } = await import( + "./activityRenderClasses/TranscriptActivityItem.tsx" + ); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const rootRoute = createRootRoute({ + component: () => + createElement( + QueryClientProvider, + { client: queryClient }, + createElement( + AgentSessionTranscriptVariantProvider, + { value: "conversation" }, + createElement(TranscriptActivityItem, { + agentAvatarUrl: null, + agentName: "Agent", + agentPubkey: "pk", + item: relayStep("relay:1"), + }), + ), + ), + }); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routeTree: rootRoute, + }); + await router.load(); + const view = render(createElement(RouterProvider, { router })); + + assert.ok( + view.container.querySelector( + '[data-testid="transcript-tool-message-preview"]', + ), + "outside a block a posted message is a destination the reader can open — the bubble stays", + ); +}); + +test("the rail glyph is chosen by kind, and prose kinds share the speech bubble", async () => { + // The exhaustive switch is the point: a note that fell through to the tool + // branch would wear a wrench and read as something the agent ran. + const view = await renderBlock([ + thoughtStep("thought:1"), + noteStep("msg:interim"), + step("a"), + step("b", { isError: true, status: "failed" }), + ]); + await view.expand(); + + const glyphClass = (kind, index = 0) => { + const rows = view.qa(`[data-work-block-entry="${kind}"]`); + const icon = rows[index].querySelector("svg"); + return icon.getAttribute("class") ?? ""; + }; + + // lucide stamps each icon with a `lucide-` class, so the glyph + // identity is readable from the DOM without reaching into the icon modules. + assert.match(glyphClass("thought"), /lucide-message-circle/); + assert.match( + glyphClass("note"), + /lucide-message-circle/, + "prose is the agent talking, whether it is reasoning or a note", + ); + assert.match(glyphClass("tool", 0), /lucide-wrench/); + assert.match( + glyphClass("tool", 1), + /lucide-circle/, + "a failed step is a filled dot, not a wrench", + ); +}); + +test("the rail bullet is never red, whatever the step's outcome", async () => { + // A failure is carried by glyph shape and by the folded line's count. Tinting + // the bullet would make one bad step read as an alarm across the whole run. + // + // The running step keeps this block live, so the rail is already open — which + // is also the only state in which a running bullet can be observed at all. + const view = await renderBlock( + [ + step("a"), + step("b", { isError: true, status: "failed" }), + step("c", { status: "executing", completedAt: null }), + ], + { streamingItemId: "c" }, + ); + + assert.deepEqual( + view.glyphStates(), + ["settled", "failed", "running"], + "all three outcomes are on screen", + ); + for (const bullet of view.qa("[data-step-state]")) { + assert.ok( + !/\b(text|bg|ring)-(destructive|red)/.test(bullet.className), + `rail bullet for ${bullet.getAttribute("data-step-state")} must stay muted`, + ); + assert.match(bullet.className, /\btext-muted-foreground\b/); + } +}); + +/** + * berd brightens rail prose with `usePrimaryText={open}`. Here the brightening + * is unconditional, and this test records why that is not a divergence: a closed + * block unmounts its rows rather than dimming them, so there is no state in + * which rail prose is on screen and NOT in an open block. A `primaryText` prop + * would have an unreachable false branch. + */ +test("rail prose is primary text, and a closed block has no prose on screen at all", async () => { + const { act } = await import("@testing-library/react"); + const live = [ + thoughtStep("thought:1"), + noteStep("msg:interim"), + step("b", { status: "executing", completedAt: null }), + ]; + const view = await renderBlock(live, { streamingItemId: "b" }); + + const prose = () => + view.qa( + '[data-testid="transcript-work-block-thought"],[data-testid="transcript-work-block-note"]', + ); + + assert.equal(prose().length, 2, "both prose rows are on the live rail"); + for (const node of prose()) { + assert.match( + node.className, + /\btext-foreground\b/, + "prose the reader can see is primary, not muted", + ); + assert.ok( + !/\btext-muted-foreground\b/.test(node.className), + "the row must not carry both colours", + ); + } + + // Finish the turn: the block folds and takes its prose with it. + await act(async () => { + view.stream([thoughtStep("thought:1"), noteStep("msg:interim"), step("b")]); + }); + assert.equal(await view.settleToStepCount(0), 0, "it folded"); + assert.equal( + prose().length, + 0, + "a folded block renders no prose, so there is no dimmed state to test", + ); + + // And the reader reopening it brings the same primary prose back. + await act(async () => { + view.summary().click(); + }); + assert.equal(await view.settleToStepCount(3), 3, "the reader reopened it"); + assert.equal(prose().length, 2); + for (const node of prose()) { + assert.match(node.className, /\btext-foreground\b/); + } +}); + +// ── Streaming cost ─────────────────────────────────────────────────────────── + +/** + * A block re-renders on every append while work streams. Unchanged steps must + * not re-render with it: each step's presenter rebuilds compact tool summaries, + * parses diffs and renders markdown/images, so an unmemoized step row makes a + * long block cost O(n) of that work per appended step. + * + * Counted at the presenter boundary — `TranscriptActivityItem` looks its + * presenter up in `ACTIVITY_RENDER_CLASS_PRESENTERS` on every render, so + * swapping in a counting presenter observes exactly the work a step row + * triggers, without reaching into React internals. + */ +async function countStepRenders(initialItems, nextItems) { + const { createElement } = await import("react"); + const { render } = await import("@testing-library/react"); + const { ACTIVITY_RENDER_CLASS_PRESENTERS } = await import( + "./activityRenderClasses/TranscriptActivityItem.tsx" + ); + const { AgentSessionTranscriptTurnMetaProvider } = await import( + "./agentSessionTranscriptContext.ts" + ); + const { AgentSessionWorkBlockSegment } = await import( + "./AgentSessionWorkBlock.tsx" + ); + + const renders = []; + const original = ACTIVITY_RENDER_CLASS_PRESENTERS.shell; + ACTIVITY_RENDER_CLASS_PRESENTERS.shell = function CountingPresenter(props) { + renders.push(props.item.id); + return createElement("div", null, props.item.id); + }; + + try { + const element = (items) => + createElement( + AgentSessionTranscriptTurnMetaProvider, + { + value: { + liveTurnId: items[items.length - 1].turnId, + streamingItemId: items[items.length - 1].id, + }, + }, + createElement(AgentSessionWorkBlockSegment, { + agentAvatarUrl: null, + agentName: "Agent", + agentPubkey: "pk", + block: { + id: "work-block:a", + items, + timestamp: items[0].timestamp, + }, + }), + ); + + const view = render(element(initialItems)); + renders.length = 0; + view.rerender(element(nextItems)); + return renders; + } finally { + ACTIVITY_RENDER_CLASS_PRESENTERS.shell = original; + } +} + +test("appending a step does not re-render the steps already on the rail", async () => { + // The block is expanded (a live block with ≤3 steps shows them all), and the + // prior steps are the SAME objects across both renders, as the transcript + // store replaces items rather than mutating them. + const settled = [step("a"), step("b")]; + const appended = [ + ...settled, + step("c", { status: "executing", completedAt: null }), + ]; + + const rendered = await countStepRenders(settled, appended); + + assert.deepEqual(rendered, ["c"]); +}); + +test("a step that actually changed does re-render", async () => { + // Guards the memo from being too aggressive: an executing step settling is a + // new object for that id, and it must re-render to drop its running glyph. + const a = step("a"); + const executing = step("b", { status: "executing", completedAt: null }); + const settled = step("b"); + + const rendered = await countStepRenders([a, executing], [a, settled]); + + assert.deepEqual(rendered, ["b"]); +}); diff --git a/desktop/src/features/agents/ui/AgentSessionWorkBlock.test.mjs b/desktop/src/features/agents/ui/AgentSessionWorkBlock.test.mjs index f0e32aae67..11c5326ba1 100644 --- a/desktop/src/features/agents/ui/AgentSessionWorkBlock.test.mjs +++ b/desktop/src/features/agents/ui/AgentSessionWorkBlock.test.mjs @@ -1,301 +1,19 @@ /** - * Work-block rendering: the live rail, the finished fold, the rail glyphs, and - * the two costs a streaming block must not pay (re-rendering settled steps, and - * losing the reader's disclosure choice to a programmatic toggle echo). + * Work-block rendering while a turn is live and when it finishes: the rail, the + * live window, the fold animation, and the reader's disclosure choice. + * Orphaned work and streaming cost live in + * `AgentSessionWorkBlock.orphaned.test.mjs`; the shared rig is + * `AgentSessionWorkBlockTestRig.mjs`. */ import assert from "node:assert/strict"; -import { after, afterEach, before, test } from "node:test"; +import { test } from "node:test"; -import { JSDOM } from "jsdom"; - -const dom = new JSDOM("", { - url: "http://localhost", -}); - -class NoopObserver { - disconnect() {} - observe() {} - unobserve() {} -} - -let prefersReducedMotion = false; - -before(() => { - Object.assign(globalThis, { - document: dom.window.document, - 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, - 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, - }); - // `motion`'s useReducedMotion reads this query, so the reduced-motion test - // drives it through the same surface the component does. - dom.window.matchMedia = (query) => ({ - matches: - prefersReducedMotion && String(query).includes("prefers-reduced-motion"), - addEventListener() {}, - removeEventListener() {}, - addListener() {}, - removeListener() {}, - }); - globalThis.matchMedia = dom.window.matchMedia; - dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); - dom.window.cancelAnimationFrame = (id) => clearTimeout(id); - globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; - globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame; -}); - -afterEach(async () => { - prefersReducedMotion = false; - const { cleanup } = await import("@testing-library/react"); - cleanup(); -}); - -after(() => dom.window.close()); - -const START = "2026-06-18T00:00:00.000Z"; -const SHARED = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" }; - -function step(id, overrides = {}) { - return { - ...SHARED, - id, - type: "tool", - renderClass: "shell", - descriptor: { - renderClass: "shell", - label: "Ran command", - preview: id, - source: "shell", - groupKey: "shell:command", - }, - title: id, - toolName: "shell", - buzzToolName: null, - status: "completed", - args: {}, - result: "ok", - isError: false, - timestamp: START, - startedAt: START, - completedAt: "2026-06-18T00:00:01.000Z", - ...overrides, - }; -} - -function thoughtStep(id) { - return { - ...SHARED, - id, - type: "thought", - renderClass: "thought", - title: "Thinking", - text: "weighing the options", - timestamp: START, - }; -} - -function noteStep(id, text = "posted the summary to the channel") { - return { - ...SHARED, - id, - type: "message", - renderClass: "message", - role: "assistant", - title: "Agent", - text, - timestamp: START, - }; -} - -/** - * A relay post the agent made mid-turn. - * - * The distinguishing property is `descriptor.renderClass: "message"`, which is - * what `buildCompactToolSummary` turns into `presentation: "message"` and what - * routes the item to the avatar + speech-bubble presenter outside a block. The - * result carries an `event_id` so `getSentMessageLink` resolves too — that is - * the fully-featured shape (bubble, timestamp, delivery receipt), i.e. the one - * that goes most wrong on a muted rail. - */ -function relayStep(id, overrides = {}) { - return step(id, { - renderClass: "message", - descriptor: { - renderClass: "message", - label: "Send Message", - preview: "posted the findings to the channel", - action: { verb: "Sent", object: "posted the findings to the channel" }, - source: "shell", - groupKey: "buzz-cli:messages.send", - operation: "messages.send", - }, - title: "Send Message", - toolName: "buzz_dev_mcp__shell", - args: { - command: - "buzz messages send --channel chan-1 --content 'posted the findings to the channel'", - }, - result: '{"event_id":"ev-1","accepted":true}', - ...overrides, - }); -} - -/** - * Let every pending animation finish and the DOM settle. - * - * A collapsing block keeps its rail mounted until the height animation's exit - * completes, which is the point of the fold — so "has it closed yet?" can only - * be asked after the exit runs, not on the commit that started it. Polling to a - * stable answer keeps these assertions about the end state the reader sees - * rather than about motion's internal frame schedule. - */ -async function settle(read, expected, { timeout = 2000 } = {}) { - const { act } = await import("@testing-library/react"); - const deadline = Date.now() + timeout; - let last = read(); - while (Date.now() < deadline) { - if (last === expected) return last; - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 25)); - }); - last = read(); - } - return last; -} - -/** - * Mount a work block under a router. - * - * Rail rows render agent content through `Markdown`, which resolves in-app links - * via `useAppNavigation` and therefore needs router context — the same reason - * `AgentSessionTranscriptList.conversation.test.mjs` mounts through a memory - * router. Without it a thought row throws while a tool row happens not to. - * - * `liveTurnId` defaults to the fixtures' own turn, i.e. "the agent is working on - * this turn right now", because that is the situation nearly every case here is - * about. Pass `liveTurnId: null` for reopened history — see the orphaned-step - * tests, where the same items must read as finished. - */ -async function renderBlock( - items, - { liveTurnId = SHARED.turnId, streamingItemId = null } = {}, -) { - const { createElement, useState } = await import("react"); - const { render } = await import("@testing-library/react"); - const { QueryClient, QueryClientProvider } = await import( - "@tanstack/react-query" - ); - const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } = - await import("@tanstack/react-router"); - const { AgentSessionTranscriptTurnMetaProvider } = await import( - "./agentSessionTranscriptContext.ts" - ); - const { AgentSessionWorkBlockSegment } = await import( - "./AgentSessionWorkBlock.tsx" - ); - - const element = (blockItems, streaming, live) => - createElement( - AgentSessionTranscriptTurnMetaProvider, - { - value: { - liveTurnId: live, - streamingItemId: streaming, - }, - }, - createElement(AgentSessionWorkBlockSegment, { - agentAvatarUrl: null, - agentName: "Agent", - agentPubkey: "pk", - block: { - id: `work-block:${blockItems[0].id}`, - items: blockItems, - timestamp: blockItems[0].timestamp, - }, - }), - ); - - let applyState; - const Harness = () => { - const [state, setState] = useState({ items, liveTurnId, streamingItemId }); - applyState = setState; - return element(state.items, state.streamingItemId, state.liveTurnId); - }; - // The bubble presenter reads the posted message through `useQuery`. Provided - // unconditionally so that if a rail step ever DID reach that presenter, the - // relay test below would fail on its bubble assertion rather than on a - // missing provider — a test must fail for the reason it claims. - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - const rootRoute = createRootRoute({ - component: () => - createElement( - QueryClientProvider, - { client: queryClient }, - createElement(Harness), - ), - }); - const router = createRouter({ - history: createMemoryHistory({ initialEntries: ["/"] }), - routeTree: rootRoute, - }); - await router.load(); - - const view = render(createElement(RouterProvider, { router })); - const q = (selector) => view.container.querySelector(selector); - const qa = (selector) => [...view.container.querySelectorAll(selector)]; - const stepCount = () => - qa('[data-testid="transcript-work-block-step"]').length; - return { - ...view, - // Re-render the SAME block id, exactly as the transcript does as work - // streams and then finishes. `liveTurnId` is carried through so a test can - // model the session going away under a still-running step (agent death), - // not just the step reaching a terminal status. - stream: (nextItems, streaming = null, live = liveTurnId) => - applyState({ - items: nextItems, - liveTurnId: live, - streamingItemId: streaming, - }), - summary: () => q('[data-testid="transcript-work-block-summary"]'), - previousSteps: () => - q('[data-testid="transcript-work-block-previous-steps"]'), - stepCount, - // A finished block renders folded, so any assertion about what is ON the - // rail has to open it first — exactly as a reader would. - expand: async () => { - const { act } = await import("@testing-library/react"); - await act(async () => { - q('[data-testid="transcript-work-block-summary"]').click(); - }); - }, - // Wait for the rail to reach `expected` rows once animations have run. - settleToStepCount: (expected) => settle(stepCount, expected), - glyphStates: () => - qa("[data-step-state]").map((node) => - node.getAttribute("data-step-state"), - ), - q, - qa, - }; -} +import { + renderBlock, + setPrefersReducedMotion, + step, +} from "./AgentSessionWorkBlockTestRig.mjs"; // ── Live ───────────────────────────────────────────────────────────────────── @@ -444,7 +162,7 @@ test("a block that finishes while mounted stays open for a paint so the collapse test("under reduced motion a finishing block folds immediately, with no settle frames", async () => { const { act } = await import("@testing-library/react"); - prefersReducedMotion = true; + setPrefersReducedMotion(true); const view = await renderBlock([ step("a"), @@ -632,514 +350,3 @@ test("a running step pulses and a failed one does not", async () => { "a settled failure is not in flight", ); }); - -// ── Orphaned work ──────────────────────────────────────────────────────────── - -/** - * Reopened history must not present as work in progress. - * - * A tool's `executing` status is written when the step starts and never revised - * if the agent dies first, so an abandoned step keeps it forever. In this block - * that status is not a per-row detail — one `running` entry makes the whole - * block active, which suppresses the folded summary line, keeps the rail - * expanded and pulses a bullet. Scrolling back to a crashed turn therefore - * showed the reader live work indefinitely (Codex P2 on #6536). - */ -test("a block whose running step has no live session folds like finished work", async () => { - const view = await renderBlock( - [step("a"), step("b", { status: "executing", completedAt: null })], - { liveTurnId: null, streamingItemId: null }, - ); - - const summary = view.summary(); - assert.ok( - summary, - "history gets its folded summary line — the orphaned status must not suppress it", - ); - assert.match( - summary.textContent, - /2 steps$/, - "an abandoned step is not known to have failed, so the count stays neutral", - ); - assert.equal(await view.settleToStepCount(0), 0, "the rail folds away"); - assert.equal( - view.qa(".animate-pulse").length, - 0, - "nothing pulses when nothing is running", - ); -}); - -test("the same items still hold the block open while the turn is live", async () => { - // The paired half: this is what makes the gate meaningful rather than a - // blanket "never trust executing". Identical items, live turn. - const view = await renderBlock( - [step("a"), step("b", { status: "executing", completedAt: null })], - { liveTurnId: "turn-1", streamingItemId: null }, - ); - - assert.equal(view.summary(), null, "a live block has no folded line"); - assert.equal(view.stepCount(), 2, "the rail stays open"); - assert.deepEqual(view.glyphStates(), ["settled", "running"]); - assert.equal(view.qa(".animate-pulse").length, 1, "live work pulses"); -}); - -test("an agent live on a later turn does not resurrect an earlier turn's abandoned step", async () => { - // The reason the signal is a turn id and not a boolean: a restarted agent is - // live, but not on this turn, and a global flag would keep this step spinning. - const view = await renderBlock( - [step("a"), step("b", { status: "executing", completedAt: null })], - { liveTurnId: "turn-2", streamingItemId: null }, - ); - - assert.ok( - view.summary(), - "this turn is history even though the agent is busy", - ); - assert.equal(await view.settleToStepCount(0), 0); - assert.equal(view.qa(".animate-pulse").length, 0); -}); - -test("a block live when the session ends folds instead of spinning forever", async () => { - const { act } = await import("@testing-library/react"); - // The bug as the reader meets it live: the agent dies mid-step, so the item - // never reaches a terminal status and the only thing that changes is that no - // turn is live any more. - const live = [ - step("a"), - step("b", { status: "executing", completedAt: null }), - ]; - const view = await renderBlock(live, { - liveTurnId: "turn-1", - streamingItemId: "b", - }); - assert.equal(view.stepCount(), 2, "live: the rail is open"); - assert.equal(view.summary(), null); - - // Session gone. The items are BYTE-IDENTICAL — only liveness changed. - await act(async () => { - view.stream(live, null, null); - }); - - assert.ok(view.summary(), "the block settles when its session goes away"); - assert.match(view.summary().textContent, /2 steps$/); - assert.equal(await view.settleToStepCount(0), 0); - assert.equal(view.qa(".animate-pulse").length, 0); -}); - -test("an orphaned step keeps its own row detail rather than gaining an interrupted marker", async () => { - // Deliberate: we do not know what happened to the step, so it renders as the - // neutral step it is with whatever it recorded. A visible "interrupted" - // treatment would be a design addition, not part of this fix. - const view = await renderBlock( - [step("a"), step("b", { status: "executing", completedAt: null })], - { liveTurnId: null, streamingItemId: null }, - ); - await view.expand(); - await view.settleToStepCount(2); - - assert.deepEqual( - view.glyphStates(), - ["settled", "settled"], - "no third state was invented for an abandoned step", - ); - assert.equal( - view.qa('[data-testid="transcript-tool-item"]').length, - 2, - "both steps still render through the normal tool presenter", - ); -}); - -test("the rail bullet masks the spine with the drawer surface colour", async () => { - // The bullet has to mask the spine passing behind it, and the mask must match - // the surface the transcript is drawn on. A mask in any other colour shows as - // a disc of the wrong shade around every bullet (berd's BOT-1599). - const view = await renderBlock([step("a"), step("b")]); - await view.expand(); - const bullet = view.q("[data-step-state]"); - assert.match(bullet.className, /\bbg-background\b/); - assert.match(bullet.className, /\bring-background\b/); - assert.match(bullet.className, /\brounded-full\b/); -}); - -test("the spine is drawn for every step except the last", async () => { - const view = await renderBlock([step("a"), step("b"), step("c")]); - await view.expand(); - const spines = view.qa(".w-px"); - assert.equal( - spines.length, - 2, - "three steps means two connecting segments; a trailing spine would dangle", - ); -}); - -test("thinking renders as a rail row with its own glyph, not a nested disclosure", async () => { - const view = await renderBlock([thoughtStep("thought:1"), step("a")]); - await view.expand(); - const thought = view.q('[data-testid="transcript-work-block-thought"]'); - assert.ok(thought, "reasoning renders on the rail"); - assert.match(thought.textContent, /weighing the options/); - assert.equal( - view.q('[data-testid="transcript-thought-disclosure"]'), - null, - "the block is already one disclosure — a thought must not add a second", - ); -}); - -/** - * An interim note is progress, not a second reply. - * - * #6720 gives every conversation-variant assistant message a 20px avatar + name - * identity row, which is right for the turn's answer. A rail note is the same - * item type, so routing it through that presenter would render a fully - * attributed agent turn nested inside a muted step row — the reader would see - * the agent apparently reply twice, once inside the work it was doing. berd - * draws the same line: its `progress` entry is a plain rail row. - * - * The suppression is done on this side (a dedicated prose body) rather than by - * reaching into the message presenter, so #6720 keeps one rule for what a - * message looks like. - */ -test("an interim note renders as rail prose with no identity row", async () => { - const view = await renderBlock([ - step("a"), - noteStep("msg:interim", "checked the three call sites"), - ]); - await view.expand(); - - const note = view.q('[data-testid="transcript-work-block-note"]'); - assert.ok(note, "the note renders on the rail"); - assert.match(note.textContent, /checked the three call sites/); - - assert.ok( - view.q('[data-testid="transcript-assistant-identity"]') === null, - "an avatar + name row inside a muted step reads as a second reply", - ); - assert.ok( - view.q('[data-testid="transcript-assistant-message"]') === null, - "the note must not go through the message presenter at all", - ); -}); - -/** - * A relay post is a step, not a reply — the same rule as an interim note, - * reached by a different route. - * - * A note is an assistant *message* the block re-presents as prose. A relay post - * is a *tool call* that classifies as `renderClass: "message"`, so it renders - * through `CompactMessageSummary`: 28px avatar, bordered speech bubble, - * timestamp, delivery-receipt button. That is right in the activity feed, where - * a posted message is a destination to open; on the rail it makes the agent - * appear to reply in the middle of its own work — and it did, in the seeded - * browser preview, which is where this was caught. - * - * Suppressing it needs the presentation signal rather than the transcript - * variant: the same relay step OUTSIDE a block in this variant keeps its - * bubble, which the next test pins. - */ -test("a relay post on the rail is a plain step, with no bubble or avatar", async () => { - const view = await renderBlock([step("a"), relayStep("relay:1")]); - await view.expand(); - - assert.equal( - view.stepCount(), - 2, - "the relay post takes its own rail row, like any other step", - ); - assert.equal( - view.qa('[data-work-block-entry="tool"]').length, - 2, - "a relay post is a tool step — it is something the agent did", - ); - assert.equal( - view.q('[data-testid="transcript-tool-message-preview"]'), - null, - "a speech bubble inside a muted step reads as the agent replying mid-work", - ); - assert.equal( - view.q('[data-testid="transcript-agent-sent-avatar"]'), - null, - "no identity avatar on the rail", - ); - assert.equal( - view.q('[data-testid="transcript-sent-message-context-button"]'), - null, - "no delivery receipt on the rail", - ); - - // It is still a real, expandable tool row carrying its command. - const rows = view.qa('[data-testid="transcript-tool-item"]'); - assert.equal(rows.length, 2, "both steps render as tool rows"); - assert.ok( - rows[1].querySelector("details"), - "the relay step keeps the ordinary step disclosure so its args stay reachable", - ); - assert.match( - rows[1].textContent, - /Sent|posted the findings/, - "the row still says what the step was", - ); -}); - -/** - * The other half of the branch: outside a block the bubble is correct and must - * survive. Without this, suppressing the bubble everywhere in the conversation - * variant would pass the test above. - */ -test("the same relay post outside a work block keeps its message bubble", async () => { - const { createElement } = await import("react"); - const { render } = await import("@testing-library/react"); - const { QueryClient, QueryClientProvider } = await import( - "@tanstack/react-query" - ); - const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } = - await import("@tanstack/react-router"); - const { AgentSessionTranscriptVariantProvider } = await import( - "./agentSessionTranscriptContext.ts" - ); - const { TranscriptActivityItem } = await import( - "./activityRenderClasses/TranscriptActivityItem.tsx" - ); - - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - const rootRoute = createRootRoute({ - component: () => - createElement( - QueryClientProvider, - { client: queryClient }, - createElement( - AgentSessionTranscriptVariantProvider, - { value: "conversation" }, - createElement(TranscriptActivityItem, { - agentAvatarUrl: null, - agentName: "Agent", - agentPubkey: "pk", - item: relayStep("relay:1"), - }), - ), - ), - }); - const router = createRouter({ - history: createMemoryHistory({ initialEntries: ["/"] }), - routeTree: rootRoute, - }); - await router.load(); - const view = render(createElement(RouterProvider, { router })); - - assert.ok( - view.container.querySelector( - '[data-testid="transcript-tool-message-preview"]', - ), - "outside a block a posted message is a destination the reader can open — the bubble stays", - ); -}); - -test("the rail glyph is chosen by kind, and prose kinds share the speech bubble", async () => { - // The exhaustive switch is the point: a note that fell through to the tool - // branch would wear a wrench and read as something the agent ran. - const view = await renderBlock([ - thoughtStep("thought:1"), - noteStep("msg:interim"), - step("a"), - step("b", { isError: true, status: "failed" }), - ]); - await view.expand(); - - const glyphClass = (kind, index = 0) => { - const rows = view.qa(`[data-work-block-entry="${kind}"]`); - const icon = rows[index].querySelector("svg"); - return icon.getAttribute("class") ?? ""; - }; - - // lucide stamps each icon with a `lucide-` class, so the glyph - // identity is readable from the DOM without reaching into the icon modules. - assert.match(glyphClass("thought"), /lucide-message-circle/); - assert.match( - glyphClass("note"), - /lucide-message-circle/, - "prose is the agent talking, whether it is reasoning or a note", - ); - assert.match(glyphClass("tool", 0), /lucide-wrench/); - assert.match( - glyphClass("tool", 1), - /lucide-circle/, - "a failed step is a filled dot, not a wrench", - ); -}); - -test("the rail bullet is never red, whatever the step's outcome", async () => { - // A failure is carried by glyph shape and by the folded line's count. Tinting - // the bullet would make one bad step read as an alarm across the whole run. - // - // The running step keeps this block live, so the rail is already open — which - // is also the only state in which a running bullet can be observed at all. - const view = await renderBlock( - [ - step("a"), - step("b", { isError: true, status: "failed" }), - step("c", { status: "executing", completedAt: null }), - ], - { streamingItemId: "c" }, - ); - - assert.deepEqual( - view.glyphStates(), - ["settled", "failed", "running"], - "all three outcomes are on screen", - ); - for (const bullet of view.qa("[data-step-state]")) { - assert.ok( - !/\b(text|bg|ring)-(destructive|red)/.test(bullet.className), - `rail bullet for ${bullet.getAttribute("data-step-state")} must stay muted`, - ); - assert.match(bullet.className, /\btext-muted-foreground\b/); - } -}); - -/** - * berd brightens rail prose with `usePrimaryText={open}`. Here the brightening - * is unconditional, and this test records why that is not a divergence: a closed - * block unmounts its rows rather than dimming them, so there is no state in - * which rail prose is on screen and NOT in an open block. A `primaryText` prop - * would have an unreachable false branch. - */ -test("rail prose is primary text, and a closed block has no prose on screen at all", async () => { - const { act } = await import("@testing-library/react"); - const live = [ - thoughtStep("thought:1"), - noteStep("msg:interim"), - step("b", { status: "executing", completedAt: null }), - ]; - const view = await renderBlock(live, { streamingItemId: "b" }); - - const prose = () => - view.qa( - '[data-testid="transcript-work-block-thought"],[data-testid="transcript-work-block-note"]', - ); - - assert.equal(prose().length, 2, "both prose rows are on the live rail"); - for (const node of prose()) { - assert.match( - node.className, - /\btext-foreground\b/, - "prose the reader can see is primary, not muted", - ); - assert.ok( - !/\btext-muted-foreground\b/.test(node.className), - "the row must not carry both colours", - ); - } - - // Finish the turn: the block folds and takes its prose with it. - await act(async () => { - view.stream([thoughtStep("thought:1"), noteStep("msg:interim"), step("b")]); - }); - assert.equal(await view.settleToStepCount(0), 0, "it folded"); - assert.equal( - prose().length, - 0, - "a folded block renders no prose, so there is no dimmed state to test", - ); - - // And the reader reopening it brings the same primary prose back. - await act(async () => { - view.summary().click(); - }); - assert.equal(await view.settleToStepCount(3), 3, "the reader reopened it"); - assert.equal(prose().length, 2); - for (const node of prose()) { - assert.match(node.className, /\btext-foreground\b/); - } -}); - -// ── Streaming cost ─────────────────────────────────────────────────────────── - -/** - * A block re-renders on every append while work streams. Unchanged steps must - * not re-render with it: each step's presenter rebuilds compact tool summaries, - * parses diffs and renders markdown/images, so an unmemoized step row makes a - * long block cost O(n) of that work per appended step. - * - * Counted at the presenter boundary — `TranscriptActivityItem` looks its - * presenter up in `ACTIVITY_RENDER_CLASS_PRESENTERS` on every render, so - * swapping in a counting presenter observes exactly the work a step row - * triggers, without reaching into React internals. - */ -async function countStepRenders(initialItems, nextItems) { - const { createElement } = await import("react"); - const { render } = await import("@testing-library/react"); - const { ACTIVITY_RENDER_CLASS_PRESENTERS } = await import( - "./activityRenderClasses/TranscriptActivityItem.tsx" - ); - const { AgentSessionTranscriptTurnMetaProvider } = await import( - "./agentSessionTranscriptContext.ts" - ); - const { AgentSessionWorkBlockSegment } = await import( - "./AgentSessionWorkBlock.tsx" - ); - - const renders = []; - const original = ACTIVITY_RENDER_CLASS_PRESENTERS.shell; - ACTIVITY_RENDER_CLASS_PRESENTERS.shell = function CountingPresenter(props) { - renders.push(props.item.id); - return createElement("div", null, props.item.id); - }; - - try { - const element = (items) => - createElement( - AgentSessionTranscriptTurnMetaProvider, - { - value: { - liveTurnId: items[items.length - 1].turnId, - streamingItemId: items[items.length - 1].id, - }, - }, - createElement(AgentSessionWorkBlockSegment, { - agentAvatarUrl: null, - agentName: "Agent", - agentPubkey: "pk", - block: { - id: "work-block:a", - items, - timestamp: items[0].timestamp, - }, - }), - ); - - const view = render(element(initialItems)); - renders.length = 0; - view.rerender(element(nextItems)); - return renders; - } finally { - ACTIVITY_RENDER_CLASS_PRESENTERS.shell = original; - } -} - -test("appending a step does not re-render the steps already on the rail", async () => { - // The block is expanded (a live block with ≤3 steps shows them all), and the - // prior steps are the SAME objects across both renders, as the transcript - // store replaces items rather than mutating them. - const settled = [step("a"), step("b")]; - const appended = [ - ...settled, - step("c", { status: "executing", completedAt: null }), - ]; - - const rendered = await countStepRenders(settled, appended); - - assert.deepEqual(rendered, ["c"]); -}); - -test("a step that actually changed does re-render", async () => { - // Guards the memo from being too aggressive: an executing step settling is a - // new object for that id, and it must re-render to drop its running glyph. - const a = step("a"); - const executing = step("b", { status: "executing", completedAt: null }); - const settled = step("b"); - - const rendered = await countStepRenders([a, executing], [a, settled]); - - assert.deepEqual(rendered, ["b"]); -}); diff --git a/desktop/src/features/agents/ui/AgentSessionWorkBlockTestRig.mjs b/desktop/src/features/agents/ui/AgentSessionWorkBlockTestRig.mjs new file mode 100644 index 0000000000..ea6618afc2 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionWorkBlockTestRig.mjs @@ -0,0 +1,311 @@ +/** + * Shared jsdom lifecycle, item fixtures and the `renderBlock` view helper for + * the work-block suites. One owner for the order-sensitive globals: the two + * suites run in separate processes, so a second copy would not collide -- it + * would drift, which is the failure this rig exists to prevent. + */ + +import { after, afterEach, before } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +class NoopObserver { + disconnect() {} + observe() {} + unobserve() {} +} + +let prefersReducedMotion = false; + +/** + * ESM bindings are read-only in importers, so the reduced-motion test cannot + * assign this directly -- it goes through the setter, and `afterEach` below + * resets it for every file that imports this rig. + */ +export function setPrefersReducedMotion(value) { + prefersReducedMotion = value; +} + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + 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, + 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, + }); + // `motion`'s useReducedMotion reads this query, so the reduced-motion test + // drives it through the same surface the component does. + dom.window.matchMedia = (query) => ({ + matches: + prefersReducedMotion && String(query).includes("prefers-reduced-motion"), + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + }); + globalThis.matchMedia = dom.window.matchMedia; + dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); + dom.window.cancelAnimationFrame = (id) => clearTimeout(id); + globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; + globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame; +}); + +afterEach(async () => { + prefersReducedMotion = false; + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +export const START = "2026-06-18T00:00:00.000Z"; +export const SHARED = { + channelId: "chan-1", + sessionId: "sess-1", + turnId: "turn-1", +}; + +export function step(id, overrides = {}) { + return { + ...SHARED, + id, + type: "tool", + renderClass: "shell", + descriptor: { + renderClass: "shell", + label: "Ran command", + preview: id, + source: "shell", + groupKey: "shell:command", + }, + title: id, + toolName: "shell", + buzzToolName: null, + status: "completed", + args: {}, + result: "ok", + isError: false, + timestamp: START, + startedAt: START, + completedAt: "2026-06-18T00:00:01.000Z", + ...overrides, + }; +} + +export function thoughtStep(id) { + return { + ...SHARED, + id, + type: "thought", + renderClass: "thought", + title: "Thinking", + text: "weighing the options", + timestamp: START, + }; +} + +export function noteStep(id, text = "posted the summary to the channel") { + return { + ...SHARED, + id, + type: "message", + renderClass: "message", + role: "assistant", + title: "Agent", + text, + timestamp: START, + }; +} + +/** + * A relay post the agent made mid-turn. + * + * The distinguishing property is `descriptor.renderClass: "message"`, which is + * what `buildCompactToolSummary` turns into `presentation: "message"` and what + * routes the item to the avatar + speech-bubble presenter outside a block. The + * result carries an `event_id` so `getSentMessageLink` resolves too — that is + * the fully-featured shape (bubble, timestamp, delivery receipt), i.e. the one + * that goes most wrong on a muted rail. + */ +export function relayStep(id, overrides = {}) { + return step(id, { + renderClass: "message", + descriptor: { + renderClass: "message", + label: "Send Message", + preview: "posted the findings to the channel", + action: { verb: "Sent", object: "posted the findings to the channel" }, + source: "shell", + groupKey: "buzz-cli:messages.send", + operation: "messages.send", + }, + title: "Send Message", + toolName: "buzz_dev_mcp__shell", + args: { + command: + "buzz messages send --channel chan-1 --content 'posted the findings to the channel'", + }, + result: '{"event_id":"ev-1","accepted":true}', + ...overrides, + }); +} + +/** + * Let every pending animation finish and the DOM settle. + * + * A collapsing block keeps its rail mounted until the height animation's exit + * completes, which is the point of the fold — so "has it closed yet?" can only + * be asked after the exit runs, not on the commit that started it. Polling to a + * stable answer keeps these assertions about the end state the reader sees + * rather than about motion's internal frame schedule. + */ +export async function settle(read, expected, { timeout = 2000 } = {}) { + const { act } = await import("@testing-library/react"); + const deadline = Date.now() + timeout; + let last = read(); + while (Date.now() < deadline) { + if (last === expected) return last; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 25)); + }); + last = read(); + } + return last; +} + +/** + * Mount a work block under a router. + * + * Rail rows render agent content through `Markdown`, which resolves in-app links + * via `useAppNavigation` and therefore needs router context — the same reason + * `AgentSessionTranscriptList.conversation.test.mjs` mounts through a memory + * router. Without it a thought row throws while a tool row happens not to. + * + * `liveTurnId` defaults to the fixtures' own turn, i.e. "the agent is working on + * this turn right now", because that is the situation nearly every case here is + * about. Pass `liveTurnId: null` for reopened history — see the orphaned-step + * tests, where the same items must read as finished. + */ +export async function renderBlock( + items, + { liveTurnId = SHARED.turnId, streamingItemId = null } = {}, +) { + const { createElement, useState } = await import("react"); + const { render } = await import("@testing-library/react"); + const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + ); + const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } = + await import("@tanstack/react-router"); + const { AgentSessionTranscriptTurnMetaProvider } = await import( + "./agentSessionTranscriptContext.ts" + ); + const { AgentSessionWorkBlockSegment } = await import( + "./AgentSessionWorkBlock.tsx" + ); + + const element = (blockItems, streaming, live) => + createElement( + AgentSessionTranscriptTurnMetaProvider, + { + value: { + liveTurnId: live, + streamingItemId: streaming, + }, + }, + createElement(AgentSessionWorkBlockSegment, { + agentAvatarUrl: null, + agentName: "Agent", + agentPubkey: "pk", + block: { + id: `work-block:${blockItems[0].id}`, + items: blockItems, + timestamp: blockItems[0].timestamp, + }, + }), + ); + + let applyState; + const Harness = () => { + const [state, setState] = useState({ items, liveTurnId, streamingItemId }); + applyState = setState; + return element(state.items, state.streamingItemId, state.liveTurnId); + }; + // The bubble presenter reads the posted message through `useQuery`. Provided + // unconditionally so that if a rail step ever DID reach that presenter, the + // relay test below would fail on its bubble assertion rather than on a + // missing provider — a test must fail for the reason it claims. + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const rootRoute = createRootRoute({ + component: () => + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(Harness), + ), + }); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routeTree: rootRoute, + }); + await router.load(); + + const view = render(createElement(RouterProvider, { router })); + const q = (selector) => view.container.querySelector(selector); + const qa = (selector) => [...view.container.querySelectorAll(selector)]; + const stepCount = () => + qa('[data-testid="transcript-work-block-step"]').length; + return { + ...view, + // Re-render the SAME block id, exactly as the transcript does as work + // streams and then finishes. `liveTurnId` is carried through so a test can + // model the session going away under a still-running step (agent death), + // not just the step reaching a terminal status. + stream: (nextItems, streaming = null, live = liveTurnId) => + applyState({ + items: nextItems, + liveTurnId: live, + streamingItemId: streaming, + }), + summary: () => q('[data-testid="transcript-work-block-summary"]'), + previousSteps: () => + q('[data-testid="transcript-work-block-previous-steps"]'), + stepCount, + // A finished block renders folded, so any assertion about what is ON the + // rail has to open it first — exactly as a reader would. + expand: async () => { + const { act } = await import("@testing-library/react"); + await act(async () => { + q('[data-testid="transcript-work-block-summary"]').click(); + }); + }, + // Wait for the rail to reach `expected` rows once animations have run. + settleToStepCount: (expected) => settle(stepCount, expected), + glyphStates: () => + qa("[data-step-state]").map((node) => + node.getAttribute("data-step-state"), + ), + q, + qa, + }; +}