feat(agents): group activity tool bursts (#1571)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-07-07 09:34:21 -06:00
committed by GitHub
co-authored by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f
parent c97263901f
commit 426f044844
8 changed files with 704 additions and 60 deletions
@@ -17,6 +17,7 @@ import {
DialogTitle,
} from "@/shared/ui/dialog";
import { Toggle } from "@/shared/ui/toggle";
import { AnimatedCount } from "@/shared/ui/AnimatedCount";
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
import { TurnLivenessIndicator } from "./TurnLivenessIndicator";
@@ -34,6 +35,7 @@ import {
ActivityRowContent,
ActivityRowLabel,
type ActivityRowStats,
splitActivityRowCountedObject,
splitActivityRowLabel,
} from "./activityRenderClasses/ActivityRow";
import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp";
@@ -386,7 +388,9 @@ function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
return `turn:${turnId}:setup`;
}
if (segment.kind === "prompt") {
return `turn:${turnId}:prompt`;
// A turn can hold multiple prompt segments (initial prompt + mid-turn
// steers), so key on the user message id rather than the bare turn id.
return `turn:${turnId}:prompt:${segment.user.id}`;
}
if (segment.kind === "summary") {
return segment.summary.id;
@@ -455,10 +459,10 @@ function SameKindSummaryItem({
}) {
const groupedFileEditDiffs = React.useMemo(
() =>
summary.renderClass === "file-edit"
summary.renderClass === "file-edit" || summary.variant === "mixed"
? getGroupedFileEditDiffs(summary.items)
: [],
[summary.items, summary.renderClass],
[summary.items, summary.renderClass, summary.variant],
);
const groupedFileEditStats = summarizeFileEditDiffs(groupedFileEditDiffs);
const expandsToToolItems = summary.items.every(
@@ -467,6 +471,10 @@ function SameKindSummaryItem({
const variant = useAgentSessionTranscriptVariant();
const timestampsEnabled = useTranscriptTimestampsEnabled();
const showTimestamp = timestampsEnabled && variant !== "compactPreview";
// Mixed bursts expand to their child segments in original order: raw tool
// rows plus nested same-kind summaries that joined the burst (which stay
// expandable to their own child rows).
const childSegments = summary.segments ?? null;
return (
<>
@@ -483,30 +491,52 @@ function SameKindSummaryItem({
<ActivityRowContent
className={cn(
"flex flex-col",
expandsToToolItems ? "gap-0.5" : "gap-1 pl-5",
expandsToToolItems || childSegments ? "gap-0.5" : "gap-1 pl-5",
)}
>
{expandsToToolItems
? summary.items.map((item) => (
<TranscriptItemView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
item={item}
key={item.id}
profiles={profiles}
/>
))
: summary.items.map((item) => (
<p
className="truncate text-xs text-muted-foreground"
key={item.id}
>
{item.type === "tool"
? item.descriptor.preview || item.descriptor.label
: item.title}
</p>
))}
{childSegments
? childSegments.map((child) =>
child.kind === "summary" ? (
<SameKindSummaryItem
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
key={child.summary.id}
profiles={profiles}
summary={child.summary}
/>
) : (
<TranscriptItemView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
item={child.item}
key={child.item.id}
profiles={profiles}
/>
),
)
: expandsToToolItems
? summary.items.map((item) => (
<TranscriptItemView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
item={item}
key={item.id}
profiles={profiles}
/>
))
: summary.items.map((item) => (
<p
className="truncate text-xs text-muted-foreground"
key={item.id}
>
{item.type === "tool"
? item.descriptor.preview || item.descriptor.label
: item.title}
</p>
))}
</ActivityRowContent>
</ActivityRow>
{showTimestamp ? (
@@ -550,15 +580,33 @@ function ToolRunSummaryLabel({
label: string;
stats?: ActivityRowStats | null;
}) {
const animationPreferenceEnabled = useTranscriptAnimationEnabled();
const parts = splitActivityRowLabel(label);
if (!parts) {
return <span className="truncate text-sm font-medium">{label}</span>;
}
// Streaming bursts grow their count in place ("Ran 16 tool calls" →
// "Ran 17 tool calls"); rolling the digits odometer-style makes the
// increment legible. AnimatedCount keeps an sr-only static value and
// falls back to static text under prefers-reduced-motion.
const countedObject =
animationPreferenceEnabled && typeof parts.object === "string"
? splitActivityRowCountedObject(parts.object)
: null;
const object = countedObject ? (
<>
<AnimatedCount value={countedObject.count} />
{countedObject.rest}
</>
) : (
parts.object
);
return (
<ActivityRowLabel
object={parts.object}
object={object}
openToneScope="summary"
stats={stats}
verb={parts.verb}
@@ -191,3 +191,24 @@ export function splitActivityRowLabel(
);
return match ? { verb: match[1], object: match[2] } : null;
}
export type ActivityRowCountedObject = {
count: number;
rest: string;
};
/**
* Split a summary label object like "16 tool calls" into its leading count
* and the trailing text (" tool calls"), so the number can animate through
* AnimatedCount while streaming bursts grow. Returns null when the object
* does not lead with a count.
*/
export function splitActivityRowCountedObject(
object: string,
): ActivityRowCountedObject | null {
const match = object.match(/^(\d+)(\s.+)$/);
if (!match) return null;
const count = Number(match[1]);
if (!Number.isFinite(count)) return null;
return { count, rest: match[2] };
}
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
splitActivityRowCountedObject,
splitActivityRowLabel,
} from "./ActivityRow.tsx";
test("splitActivityRowLabel splits verb-led summary labels", () => {
assert.deepEqual(splitActivityRowLabel("Ran 16 tool calls"), {
verb: "Ran",
object: "16 tool calls",
});
assert.equal(splitActivityRowLabel("Thinking"), null);
});
test("splitActivityRowCountedObject splits a leading count", () => {
assert.deepEqual(splitActivityRowCountedObject("16 tool calls"), {
count: 16,
rest: " tool calls",
});
assert.deepEqual(splitActivityRowCountedObject("3 files"), {
count: 3,
rest: " files",
});
assert.deepEqual(splitActivityRowCountedObject("12 Buzz relay ops"), {
count: 12,
rest: " Buzz relay ops",
});
});
test("splitActivityRowCountedObject leaves non-counted objects alone", () => {
assert.equal(splitActivityRowCountedObject("npm install"), null);
assert.equal(splitActivityRowCountedObject("2 "), null);
assert.equal(splitActivityRowCountedObject("16"), null);
assert.equal(splitActivityRowCountedObject("file 16"), null);
});
@@ -1179,3 +1179,56 @@ test("observer feed renders system-prompt before prompt-context in display order
"system-prompt item must have turnId=null",
);
});
test("steer ingress bundles its prompt context into the steer prompt segment, not a standalone row", () => {
// The stray "Prompt context · 1 section" row was leaking from the goose
// session/steer path: it upserted metadata without an acpSource, so display
// grouping never consumed it into a prompt bundle. It must now ride behind
// the steer message bubble's checks-icon dialog like session/prompt context.
const events = [
{
seq: 1,
timestamp: "2026-07-01T10:00:00.000Z",
kind: "acp_write",
agentIndex: 0,
channelId: "ch-1",
sessionId: "sess-1",
turnId: "turn-1",
payload: {
jsonrpc: "2.0",
id: 5,
method: "_goose/unstable/session/steer",
params: {
sessionId: "sess-1",
prompt: [
{
type: "text",
text: `[Buzz event: @mention]\nEvent ID: ${"e".repeat(64)}\nFrom: x (hex: ${"f".repeat(64)})\nContent: steer me`,
},
{ type: "text", text: "[Thread context]\nPrior messages here." },
],
},
},
},
];
const rawItems = buildTranscript(events);
const steerMessage = rawItems.find((item) => item.type === "message");
const steerContext = rawItems.find((item) => item.type === "metadata");
assert.equal(steerMessage?.acpSource, "session/steer:user");
assert.equal(steerContext?.acpSource, "session/steer:context");
const [block] = buildTranscriptDisplayBlocks(rawItems);
assert.equal(block.kind, "turn");
const promptSegment = block.segments.find(
(segment) => segment.kind === "prompt",
);
assert.ok(promptSegment, "expected steer message to render a prompt bundle");
assert.equal(promptSegment.context?.id, steerContext.id);
assert.ok(
!block.segments.some(
(segment) => segment.kind === "item" && segment.item.type === "metadata",
),
"steer context must not leak as a standalone metadata row",
);
});
@@ -906,7 +906,7 @@ export function processTranscriptEvent(
event.timestamp,
ctx,
parsedPrompt.userPubkey,
undefined,
"session/steer:user",
parsedPrompt.userEventId,
);
}
@@ -918,6 +918,7 @@ export function processTranscriptEvent(
parsedPrompt.sections,
event.timestamp,
ctx,
"session/steer:context",
);
}
}
@@ -285,8 +285,335 @@ test("buildTranscriptDisplayBlocks groups consecutive file edit tool runs", () =
);
});
test("buildTranscriptDisplayBlocks keeps non-contiguous same-kind runs expanded", () => {
const mkTool = (id, label, renderClass = "generic", groupKey = label) => ({
test("buildTranscriptDisplayBlocks groups mixed consecutive eligible tool runs", () => {
const [block] = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "file-read", "read_file"),
mkTool("shell-1", "Ran command", "shell", "shell:command"),
mkTool("read-2", "Read file", "file-read", "read_file"),
mkTool("read-3", "Read file", "file-read", "read_file"),
]);
assert.equal(block.kind, "turn");
assert.equal(block.segments.length, 1);
assert.equal(block.segments[0].kind, "summary");
assert.equal(block.segments[0].summary.variant, "mixed");
assert.equal(block.segments[0].summary.label, "Ran 4 tool calls");
assert.deepEqual(
block.segments[0].summary.items.map((item) => item.id),
["read-1", "shell-1", "read-2", "read-3"],
);
});
test("buildTranscriptDisplayBlocks groups tool bursts at threshold 2", () => {
const [block] = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "file-read", "read_file"),
mkTool("shell-1", "Ran command", "shell", "shell:command"),
]);
assert.equal(block.kind, "turn");
assert.equal(block.segments.length, 1);
assert.equal(block.segments[0].kind, "summary");
assert.equal(block.segments[0].summary.variant, "mixed");
assert.equal(block.segments[0].summary.label, "Ran 2 tool calls");
});
test("buildTranscriptDisplayBlocks keeps a lone eligible tool row expanded", () => {
const [block] = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "file-read", "read_file"),
]);
assert.equal(block.kind, "turn");
assert.deepEqual(
block.segments.map((segment) => segment.kind),
["item"],
);
});
test("buildTranscriptDisplayBlocks nests same-kind summaries inside tool bursts", () => {
const [block] = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "file-read", "read_file"),
mkTool("read-2", "Read file", "file-read", "read_file"),
mkTool("read-3", "Read file", "file-read", "read_file"),
mkTool("shell-1", "Ran command", "shell", "shell:command"),
mkTool("skill-1", "Read skill", "skill-read", "skill:load"),
]);
assert.equal(block.kind, "turn");
assert.equal(block.segments.length, 1);
assert.equal(block.segments[0].kind, "summary");
assert.equal(block.segments[0].summary.variant, "mixed");
assert.equal(block.segments[0].summary.label, "Ran 5 tool calls");
// Mixed summaries are the only visible burst summary; nested same-kind
// summaries flatten back to leaf rows to avoid redundant rows such as
// "Ran 16 tool calls" → "Ran 12 commands".
assert.deepEqual(
block.segments[0].summary.segments.map((child) =>
child.kind === "item" ? child.item.id : child.summary.label,
),
["read-1", "read-2", "read-3", "shell-1", "skill-1"],
);
// Flat leaf items preserve original order.
assert.deepEqual(
block.segments[0].summary.items.map((item) => item.id),
["read-1", "read-2", "read-3", "shell-1", "skill-1"],
);
});
test("buildTranscriptDisplayBlocks collapses alternating search/read bursts into one summary", () => {
const [block] = buildTranscriptDisplayBlocks([
mkTool("shell-1", "Ran command", "shell", "shell:command"),
mkTool("read-1", "Read file", "file-read", "read_file"),
mkTool("read-2", "Read file", "file-read", "read_file"),
mkTool("read-3", "Read file", "file-read", "read_file"),
mkTool("shell-2", "Ran command", "shell", "shell:command"),
mkTool("read-4", "Read file", "file-read", "read_file"),
mkTool("read-5", "Read file", "file-read", "read_file"),
mkTool("read-6", "Read file", "file-read", "read_file"),
]);
assert.equal(block.kind, "turn");
assert.equal(block.segments.length, 1);
assert.equal(block.segments[0].kind, "summary");
assert.equal(block.segments[0].summary.variant, "mixed");
assert.equal(block.segments[0].summary.label, "Ran 8 tool calls");
assert.deepEqual(
block.segments[0].summary.segments.map((child) =>
child.kind === "summary" ? child.summary.label : child.item.id,
),
[
"shell-1",
"read-1",
"read-2",
"read-3",
"shell-2",
"read-4",
"read-5",
"read-6",
],
);
});
test("buildTranscriptDisplayBlocks keeps messages out of mixed tool runs", () => {
const [block] = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "file-read", "read_file"),
mkTool("shell-1", "Ran command", "shell", "shell:command"),
assistantMessage("assistant", "Here is what I found.", "turn-1"),
mkTool("read-2", "Read file", "file-read", "read_file"),
mkTool("shell-2", "Ran command", "shell", "shell:command"),
]);
assert.equal(block.kind, "turn");
assert.deepEqual(
block.segments.map((segment) => segment.kind),
["summary", "item", "summary"],
);
assert.equal(block.segments[1].item.id, "assistant");
assert.deepEqual(
block.segments[0].summary.items.map((item) => item.id),
["read-1", "shell-1"],
);
assert.deepEqual(
block.segments[2].summary.items.map((item) => item.id),
["read-2", "shell-2"],
);
});
test("buildTranscriptDisplayBlocks breaks failed tools out of mixed tool runs", () => {
const failed = {
...mkTool("shell-fail", "Ran command failed", "error", "shell:command"),
isError: true,
};
const [block] = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "file-read", "read_file"),
mkTool("shell-1", "Ran command", "shell", "shell:command"),
mkTool("skill-1", "Read skill", "skill-read", "skill:load"),
failed,
mkTool("read-2", "Read file", "file-read", "read_file"),
mkTool("shell-2", "Ran command", "shell", "shell:command"),
mkTool("image-1", "Viewed image", "image", "view_image"),
]);
assert.equal(block.kind, "turn");
assert.deepEqual(
block.segments.map((segment) => segment.kind),
["summary", "item", "summary"],
);
assert.equal(block.segments[0].summary.variant, "mixed");
assert.equal(block.segments[0].summary.label, "Ran 3 tool calls");
assert.equal(block.segments[1].item.id, "shell-fail");
assert.equal(block.segments[2].summary.variant, "mixed");
assert.deepEqual(
block.segments[2].summary.items.map((item) => item.id),
["read-2", "shell-2", "image-1"],
);
});
test("flattenDisplayBlocks preserves child order through mixed summaries", () => {
const blocks = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "file-read", "read_file"),
mkTool("shell-1", "Ran command", "shell", "shell:command"),
mkTool("edit-1", "Edited file", "file-edit", "file-edit:str_replace"),
]);
assert.deepEqual(
flattenDisplayBlocks(blocks).map((item) => item.id),
["read-1", "shell-1", "edit-1"],
);
});
test("buildTranscriptDisplayBlocks never same-kind groups failed tools", () => {
const mkFailed = (id) => ({
...mkTool(id, "Ran command failed", "error", "shell:command"),
isError: true,
});
const [block] = buildTranscriptDisplayBlocks([
mkFailed("fail-1"),
mkFailed("fail-2"),
mkFailed("fail-3"),
]);
assert.equal(block.kind, "turn");
assert.deepEqual(
block.segments.map((segment) => segment.kind),
["item", "item", "item"],
);
});
test("buildTranscriptDisplayBlocks never same-kind groups status tool rows", () => {
const [block] = buildTranscriptDisplayBlocks([
mkTool("status-1", "Context compacted", "status", "status:post-compact"),
mkTool("status-2", "Context compacted", "status", "status:post-compact"),
mkTool("status-3", "Context compacted", "status", "status:post-compact"),
]);
assert.equal(block.kind, "turn");
assert.deepEqual(
block.segments.map((segment) => segment.kind),
["item", "item", "item"],
);
});
test("buildTranscriptDisplayBlocks never same-kind groups suppressed tool rows", () => {
const [block] = buildTranscriptDisplayBlocks([
mkTool("stop-1", "Checked todos", "suppressed", "suppressed:stop-hook"),
mkTool("stop-2", "Checked todos", "suppressed", "suppressed:stop-hook"),
mkTool("stop-3", "Checked todos", "suppressed", "suppressed:stop-hook"),
]);
assert.equal(block.kind, "turn");
assert.deepEqual(
block.segments.map((segment) => segment.kind),
["item", "item", "item"],
);
});
test("buildTranscriptDisplayBlocks breaks same-kind runs on an ineligible row", () => {
const failed = {
...mkTool("fail-1", "Read file failed", "error", "read_file"),
isError: true,
};
const [block] = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "file-read", "read_file"),
mkTool("read-2", "Read file", "file-read", "read_file"),
failed,
mkTool("read-3", "Read file", "file-read", "read_file"),
mkTool("read-4", "Read file", "file-read", "read_file"),
]);
assert.equal(block.kind, "turn");
assert.deepEqual(
block.segments.map((segment) => segment.kind),
["summary", "item", "summary"],
);
assert.equal(block.segments[1].item.id, "fail-1");
assert.deepEqual(
block.segments[0].summary.items.map((item) => item.id),
["read-1", "read-2"],
);
assert.deepEqual(
block.segments[2].summary.items.map((item) => item.id),
["read-3", "read-4"],
);
});
test("buildTranscriptDisplayBlocks bundles steer message with steer context behind the prompt segment", () => {
const steerMessage = {
id: "steer:chan-1:turn-1",
type: "message",
role: "user",
title: "Buzz event",
text: "@Bart new steer instruction",
timestamp: baseTimestamp,
acpSource: "session/steer:user",
turnId: "turn-1",
sessionId: "sess-1",
channelId: "chan-1",
};
const steerContext = {
id: "steer-context:chan-1:turn-1",
type: "metadata",
title: "Prompt context",
sections: [{ title: "Thread history", body: "prior messages" }],
timestamp: baseTimestamp,
acpSource: "session/steer:context",
turnId: "turn-1",
sessionId: "sess-1",
channelId: "chan-1",
};
const [block] = buildTranscriptDisplayBlocks([
assistantMessage("assistant", "Working on it.", "turn-1"),
steerMessage,
steerContext,
toolCall("tool", "turn-1"),
]);
assert.equal(block.kind, "turn");
assert.deepEqual(
block.segments.map((segment) => segment.kind),
["item", "prompt", "item"],
);
const steerSegment = block.segments[1];
assert.equal(steerSegment.user.id, "steer:chan-1:turn-1");
assert.equal(steerSegment.context?.id, "steer-context:chan-1:turn-1");
assert.equal(steerSegment.systemPrompt, null);
assert.deepEqual(steerSegment.setup, []);
// No standalone "Prompt context" metadata row leaks into the feed.
assert.ok(
!block.segments.some(
(segment) => segment.kind === "item" && segment.item.type === "metadata",
),
);
});
test("buildTranscriptDisplayBlocks keeps orphan steer context visible when no steer message exists", () => {
const steerContext = {
id: "steer-context:chan-1:turn-1",
type: "metadata",
title: "Prompt context",
sections: [{ title: "Thread history", body: "prior messages" }],
timestamp: baseTimestamp,
acpSource: "session/steer:context",
turnId: "turn-1",
sessionId: "sess-1",
channelId: "chan-1",
};
const [block] = buildTranscriptDisplayBlocks([
steerContext,
toolCall("tool", "turn-1"),
]);
assert.equal(block.kind, "turn");
const flattened = flattenDisplayBlocks([block]).map((item) => item.id);
assert.ok(flattened.includes("steer-context:chan-1:turn-1"));
});
function mkTool(id, label, renderClass = "generic", groupKey = label) {
return {
id,
type: "tool",
renderClass,
@@ -310,18 +637,5 @@ test("buildTranscriptDisplayBlocks keeps non-contiguous same-kind runs expanded"
turnId: "turn-1",
sessionId: "sess-1",
channelId: "chan-1",
});
const [block] = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "file-read", "read_file"),
mkTool("shell-1", "Ran command", "shell", "shell:command"),
mkTool("read-2", "Read file", "file-read", "read_file"),
mkTool("read-3", "Read file", "file-read", "read_file"),
]);
assert.equal(block.kind, "turn");
assert.deepEqual(
block.segments.map((segment) => segment.kind),
["item", "item", "item", "item"],
);
});
};
}
@@ -3,7 +3,7 @@ import { classifyToolItem } from "./agentSessionToolClassifier";
export type TranscriptTurnSegment =
| { kind: "item"; item: TranscriptItem }
| { kind: "summary"; summary: TranscriptSameKindSummary }
| { kind: "summary"; summary: TranscriptToolRunSummary }
| { kind: "setup"; items: Extract<TranscriptItem, { type: "lifecycle" }>[] }
| {
kind: "prompt";
@@ -17,12 +17,30 @@ export type TranscriptDisplayBlock =
| { kind: "single"; item: TranscriptItem }
| { kind: "turn"; turnId: string; segments: TranscriptTurnSegment[] };
export type TranscriptSameKindSummary = {
export type TranscriptToolRunChildSegment =
| { kind: "item"; item: TranscriptItem }
| { kind: "summary"; summary: TranscriptToolRunSummary };
export type TranscriptToolRunSummary = {
id: string;
label: string;
count: number;
/** Flat leaf tool items in original order (nested summaries expanded). */
items: TranscriptItem[];
renderClass: TranscriptItem["renderClass"] | null;
/**
* "same-kind" summaries collapse runs sharing one semantic groupKey and get
* specific labels ("Read 3 files"). "mixed" summaries collapse broader
* bursts of routine tool work ("Ran 9 tool calls") and may contain nested
* same-kind summaries as children.
*/
variant: "same-kind" | "mixed";
/**
* Child segments in original order for mixed bursts raw tool rows plus
* any same-kind summaries that joined the burst. Absent on same-kind
* summaries, whose children are just `items`.
*/
segments?: TranscriptToolRunChildSegment[];
timestamp: string;
};
@@ -36,6 +54,16 @@ function isUserPrompt(
);
}
function isSteerPrompt(
item: TranscriptItem,
): item is Extract<TranscriptItem, { type: "message" }> {
return (
item.type === "message" &&
item.role === "user" &&
item.acpSource === "session/steer:user"
);
}
function isPromptContext(
item: TranscriptItem,
): item is Extract<TranscriptItem, { type: "metadata" }> {
@@ -44,6 +72,12 @@ function isPromptContext(
);
}
function isSteerContext(
item: TranscriptItem,
): item is Extract<TranscriptItem, { type: "metadata" }> {
return item.type === "metadata" && item.acpSource === "session/steer:context";
}
function isSystemPrompt(
item: TranscriptItem,
): item is Extract<TranscriptItem, { type: "metadata" }> {
@@ -74,18 +108,41 @@ function classifyTurnItems(
const userPrompt = items.find(isUserPrompt) ?? null;
const setupLifecycle = items.filter(isSetupLifecycle);
const promptContext = items.find(isPromptContext) ?? null;
// Steer context rides behind the steer message bubble's checks-icon dialog
// (same ingress treatment as session/prompt context) instead of rendering
// as a standalone "Prompt context" metadata row.
const steerContexts = items.filter(isSteerContext);
const consumed = new Set<TranscriptItem>();
if (userPrompt) consumed.add(userPrompt);
for (const item of setupLifecycle) consumed.add(item);
if (promptContext) consumed.add(promptContext);
for (const item of steerContexts) consumed.add(item);
const activity = items.filter((item) => !consumed.has(item));
const pendingSteerContexts = [...steerContexts];
const activitySegments: TranscriptTurnSegment[] = activity.map((item) => {
if (isSteerPrompt(item)) {
return {
kind: "prompt",
user: item,
systemPrompt: null,
context: pendingSteerContexts.shift() ?? null,
setup: [],
};
}
return { kind: "item", item };
});
// Steer context without a matched steer message keeps its standalone row so
// the metadata is never silently dropped.
for (const orphan of pendingSteerContexts) {
activitySegments.push({ kind: "item", item: orphan });
}
if (!userPrompt) {
return groupSameKindSegments(
activity.map((item) => ({ kind: "item", item })),
);
return groupToolSegments(activitySegments);
}
const segments: TranscriptTurnSegment[] = [
@@ -96,13 +153,26 @@ function classifyTurnItems(
context: promptContext,
setup: setupLifecycle,
},
...activitySegments,
];
for (const item of activity) {
segments.push({ kind: "item", item });
}
return groupToolSegments(segments);
}
return groupSameKindSegments(segments);
/**
* Two-pass tool grouping:
* 1. Same-kind runs collapse into summaries with specific labels
* ("Read 3 files", "Edited 2 files").
* 2. Leftover adjacent eligible tool rows of differing kinds collapse into a
* mixed fallback summary ("Ran 5 tool calls").
*
* Messages, errors, permissions, and status/lifecycle rows never join either
* pass, so intervention points stay visible.
*/
function groupToolSegments(
segments: TranscriptTurnSegment[],
): TranscriptTurnSegment[] {
return groupMixedToolRuns(groupSameKindSegments(segments));
}
function groupSameKindSegments(
@@ -137,6 +207,7 @@ function groupSameKindSegments(
count: run.length,
items: run,
renderClass: getRenderClass(run[0]),
variant: "same-kind",
timestamp: run[0].timestamp,
},
});
@@ -149,14 +220,113 @@ function groupSameKindSegments(
return grouped;
}
function sameKindKey(item: TranscriptItem): string | null {
if (item.type !== "tool") return null;
const renderClass = getRenderClass(item);
if (renderClass === "message") {
return null;
const MIXED_RUN_MINIMUM_SEGMENTS = 2;
/**
* Burst pass: collapse an interleave-tolerant run of routine tool work into
* one "Ran N tool calls" summary. Both leftover raw eligible tool rows and
* same-kind summaries produced by the first pass participate, so alternating
* patterns like search read-summary search read-summary collapse into a
* single supervision row whose children are the original segments in order.
* Messages, permissions, errors/failed tools, and status/suppressed rows
* break bursts, so intervention points stay visible.
*/
function groupMixedToolRuns(
segments: TranscriptTurnSegment[],
): TranscriptTurnSegment[] {
const grouped: TranscriptTurnSegment[] = [];
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
if (!isBurstParticipant(segment)) {
grouped.push(segment);
continue;
}
const run: TranscriptToolRunChildSegment[] = [segment];
let j = i + 1;
while (j < segments.length) {
const next = segments[j];
if (!isBurstParticipant(next)) break;
run.push(next);
j += 1;
}
if (run.length >= MIXED_RUN_MINIMUM_SEGMENTS) {
const items = run.flatMap((child) =>
child.kind === "item" ? [child.item] : child.summary.items,
);
// Mixed bursts are already the visual summary. Expanding nested
// same-kind summaries here creates redundant rows like
// "Ran 16 tool calls" → "Ran 12 commands". Keep same-kind summaries as
// grouping inputs, but flatten the mixed summary's visible children back
// to leaf tool rows.
const childSegments = items.map((item) => ({
kind: "item" as const,
item,
}));
grouped.push({
kind: "summary",
summary: {
id: `summary:mixed:${items[0].id}`,
label: `Ran ${items.length} tool calls`,
count: items.length,
items,
renderClass: null,
variant: "mixed",
segments: childSegments,
timestamp: items[0].timestamp,
},
});
} else {
grouped.push(...run);
}
i = j - 1;
}
return grouped;
}
/**
* Burst participants are raw eligible tool rows and same-kind summaries
* (already-collapsed routine tool work). Mixed summaries never re-enter.
*/
function isBurstParticipant(
segment: TranscriptTurnSegment,
): segment is TranscriptToolRunChildSegment {
if (segment.kind === "item") {
return isGroupingEligible(segment.item);
}
return segment.kind === "summary" && segment.summary.variant === "same-kind";
}
const GROUPING_ELIGIBLE_RENDER_CLASSES = new Set<
NonNullable<TranscriptItem["renderClass"]>
>([
"file-read",
"skill-read",
"shell",
"relay-op",
"file-edit",
"image",
"plan",
"generic",
]);
/**
* Shared eligibility for both grouping passes. Failed tools (isError or
* reclassified renderClass "error"), messages, permissions, status, and
* suppressed rows are never grouped and break runs, so intervention points
* stay visible.
*/
function isGroupingEligible(item: TranscriptItem): boolean {
if (item.type !== "tool" || item.isError) return false;
const renderClass = getRenderClass(item);
return (
renderClass != null && GROUPING_ELIGIBLE_RENDER_CLASSES.has(renderClass)
);
}
function sameKindKey(item: TranscriptItem): string | null {
if (!isGroupingEligible(item) || item.type !== "tool") return null;
const descriptor = item.descriptor ?? classifyToolItem(item);
return descriptor.groupKey ?? renderClass;
return descriptor.groupKey ?? getRenderClass(item);
}
function sameKindLabel(item: TranscriptItem, count: number): string {
@@ -402,7 +402,7 @@ export function AgentSessionThreadPanel({
<ManagedAgentSessionPanel
agent={agent}
channelId={sessionChannelId}
className="border-0 bg-transparent p-0 shadow-none"
className="border-0 bg-transparent px-0 py-2 shadow-none"
emptyDescription={
sessionChannelId
? `Mention ${agent.name} in the channel to see its work here.`