mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f
parent
c97263901f
commit
426f044844
@@ -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.`
|
||||
|
||||
Reference in New Issue
Block a user