test(desktop): split the work-block suite and prune the conversation harness

`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 `<details>` 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 <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
ss-dev-02
2026-08-24 19:26:20 -07:00
parent fe57b7ac7b
commit 036bd38c3b
4 changed files with 852 additions and 811 deletions

View File

@@ -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;

View File

@@ -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-<kebab-name>` 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"]);
});

View File

@@ -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("<!doctype html><html><body></body></html>", {
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-<kebab-name>` 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"]);
});

View File

@@ -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("<!doctype html><html><body></body></html>", {
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,
};
}