diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx
index 85a484deb5..86a77de671 100644
--- a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx
+++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx
@@ -59,9 +59,10 @@ export function ToolItem({
[],
);
- // On a work block's rail a posted message is a step the agent took, so it
- // takes the same muted, expandable row as every other step. Everywhere else
- // it keeps the bubble: see `useIsInsideWorkBlockRail`.
+ // Message presentations keep their readable bubble whenever they are standalone.
+ // The conversation grouping keeps message sends standalone rather than placing
+ // them on a work block rail; this guard preserves the muted row if another
+ // transcript composition explicitly embeds one in a rail.
if (compactSummary.presentation === "message" && !insideWorkBlockRail) {
return (
{
assert.deepEqual(blockItemIds(grouped[2]), ["tool:2"]);
});
+test("message sends stay outside the work block as chat bubbles", () => {
+ const send = tool("tool:send", {
+ args: { content: "Posted an update" },
+ buzzToolName: "send_message",
+ descriptor: {
+ renderClass: "message",
+ label: "Sent message",
+ preview: "Posted an update",
+ tone: "neutral",
+ source: "buzz",
+ },
+ renderClass: "message",
+ toolName: "send_message",
+ });
+ const grouped = groupConversationWorkBlocks(
+ itemSegments(tool("tool:1"), send, tool("tool:2")),
+ );
+
+ assert.deepEqual(kinds(grouped), ["work-block", "item", "work-block"]);
+ assert.deepEqual(blockItemIds(grouped[0]), ["tool:1"]);
+ assert.equal(
+ grouped[1].item.id,
+ "tool:send",
+ "the existing message presenter can render the send as a readable bubble",
+ );
+ assert.deepEqual(blockItemIds(grouped[2]), ["tool:2"]);
+});
+
test("permission gates and errors stay loud, in position", () => {
for (const renderClass of ["permission", "error", "status"]) {
const grouped = groupConversationWorkBlocks(
diff --git a/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts b/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts
index 9d076f84f1..abdefb7109 100644
--- a/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts
+++ b/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts
@@ -2,6 +2,7 @@ import type {
TranscriptTurnSegment,
TranscriptDisplayBlock,
} from "./agentSessionTranscriptGrouping";
+import { buildCompactToolSummary } from "./agentSessionToolSummary";
import type { TranscriptItem } from "./agentSessionTypes";
/** Reasoning. */
@@ -63,12 +64,14 @@ export type TranscriptConversationSegment =
* Which items are *work* — the material the block absorbs.
*
* - **thoughts** — reasoning is a step on the rail, not a sibling disclosure.
- * - **tool steps**, including failed ones. A failure belongs to the work it
- * happened in; the folded line reports it (`N steps · 1 failed`) so it is
- * never hidden behind a neutral count.
+ * - **tool steps**, including failed ones, except message sends. A failure
+ * belongs to the work it happened in; the folded line reports it
+ * (`N steps · 1 failed`) so it is never hidden behind a neutral count.
+ * Message sends stay outside as chat bubbles: they are communication the
+ * reader should be able to read and open, not generic work telemetry.
* - **interim agent notes** — an assistant message that is not the turn's
- * answer. A relay post the agent makes mid-turn is a tool step and lands
- * here for the same reason: it reads as progress, not as the reply.
+ * answer. Unlike a message-send tool, a note is narration within the turn,
+ * so it stays on the rail as progress.
*
* Everything else stays outside and keeps its own row. Plans stay a sibling
* (Buzz's checklist is a first-class surface, not a step), and lifecycle rows —
@@ -84,7 +87,10 @@ function isWorkItem(
item: TranscriptItem,
finalAnswerId: string | null,
): item is WorkBlockItem {
- if (item.type === "thought" || item.type === "tool") return true;
+ if (item.type === "thought") return true;
+ if (item.type === "tool") {
+ return buildCompactToolSummary(item).presentation !== "message";
+ }
return (
item.type === "message" &&
item.role === "assistant" &&