Files
buzz/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts
Taylor Ho df72cc9f93 fix(agents): group activity tools and hide orphan prompt context
- Update desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts to group consecutive tool calls into transcript tool groups and suppress setup/context-only turn noise when no user prompt bubble exists
- Update desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx and AgentSessionToolItem.tsx to render grouped tool rows with tighter activity-feed spacing
- Expand desktop/src/features/agents/ui/agentSessionToolSummary.ts so Buzz and generic tools use the compact summary presentation with useful previews
- Adjust desktop/src/shared/ui/toggle.tsx to support the ghost prompt-context toggle styling used by the transcript footer
- Add focused unit coverage for grouped tools, Buzz tool summaries, promptless turns, and setup/context-only turns
2026-06-15 15:59:29 -07:00

244 lines
6.5 KiB
TypeScript

import type { TranscriptItem } from "./agentSessionTypes";
export type TranscriptTurnSegment =
| { kind: "item"; item: TranscriptItem }
| { kind: "tool_group"; items: Extract<TranscriptItem, { type: "tool" }>[] }
| { kind: "setup"; items: Extract<TranscriptItem, { type: "lifecycle" }>[] }
| {
kind: "prompt";
user: Extract<TranscriptItem, { type: "message" }>;
context: Extract<TranscriptItem, { type: "metadata" }> | null;
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
};
export type TranscriptDisplayBlock =
| { kind: "single"; item: TranscriptItem }
| { kind: "turn"; turnId: string; segments: TranscriptTurnSegment[] };
function isUserPrompt(
item: TranscriptItem,
): item is Extract<TranscriptItem, { type: "message" }> {
return (
item.type === "message" &&
item.role === "user" &&
item.acpSource === "session/prompt:user"
);
}
function isPromptContext(
item: TranscriptItem,
): item is Extract<TranscriptItem, { type: "metadata" }> {
return (
item.type === "metadata" && item.acpSource === "session/prompt:context"
);
}
function isSetupLifecycle(
item: TranscriptItem,
): item is Extract<TranscriptItem, { type: "lifecycle" }> {
return (
item.type === "lifecycle" &&
(item.acpSource === "turn_started" || item.acpSource === "session_resolved")
);
}
function isErrorLifecycle(
item: TranscriptItem,
): item is Extract<TranscriptItem, { type: "lifecycle" }> {
return (
item.type === "lifecycle" && item.title.toLowerCase().includes("error")
);
}
type TurnBucket = {
turnId: string;
items: TranscriptItem[];
};
function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] {
const userPrompt = items.find(isUserPrompt) ?? null;
const setupLifecycle = items.filter(isSetupLifecycle);
const promptContext = items.find(isPromptContext) ?? null;
const consumed = new Set<TranscriptItem>();
if (userPrompt) consumed.add(userPrompt);
for (const item of setupLifecycle) consumed.add(item);
if (promptContext) consumed.add(promptContext);
const activity = items.filter((item) => !consumed.has(item));
if (!userPrompt) {
return groupConsecutiveToolSegments(
activity.map((item) => ({ kind: "item", item })),
);
}
const segments: TranscriptTurnSegment[] = [
{
kind: "prompt",
user: userPrompt,
context: promptContext,
setup: setupLifecycle,
},
];
for (const item of activity) {
if (isErrorLifecycle(item)) {
segments.push({ kind: "item", item });
continue;
}
if (isSetupLifecycle(item)) {
continue;
}
segments.push({ kind: "item", item });
}
return groupConsecutiveToolSegments(segments);
}
function groupConsecutiveToolSegments(
segments: TranscriptTurnSegment[],
): TranscriptTurnSegment[] {
const grouped: TranscriptTurnSegment[] = [];
let toolBuffer: Extract<TranscriptItem, { type: "tool" }>[] = [];
const flushTools = () => {
if (toolBuffer.length === 0) {
return;
}
grouped.push({ kind: "tool_group", items: toolBuffer });
toolBuffer = [];
};
for (const segment of segments) {
if (segment.kind === "item" && segment.item.type === "tool") {
toolBuffer.push(segment.item);
continue;
}
flushTools();
grouped.push(segment);
}
flushTools();
return grouped;
}
/**
* Build presentation-only display blocks from normalized transcript items.
* Raw observer order is preserved in the source items; this only reorders
* within a turn for user-facing narrative flow.
*/
export function buildTranscriptDisplayBlocks(
items: TranscriptItem[],
): TranscriptDisplayBlock[] {
const blocks: TranscriptDisplayBlock[] = [];
const turnBuckets = new Map<string, TurnBucket>();
const displayOrder: Array<
{ kind: "single"; item: TranscriptItem } | { kind: "turn"; turnId: string }
> = [];
for (const item of items) {
const turnId = item.turnId;
if (!turnId) {
displayOrder.push({ kind: "single", item });
continue;
}
let bucket = turnBuckets.get(turnId);
if (!bucket) {
bucket = { turnId, items: [] };
turnBuckets.set(turnId, bucket);
displayOrder.push({ kind: "turn", turnId });
}
bucket.items.push(item);
}
for (const entry of displayOrder) {
if (entry.kind === "single") {
blocks.push({ kind: "single", item: entry.item });
continue;
}
const bucket = turnBuckets.get(entry.turnId);
if (!bucket || bucket.items.length === 0) {
continue;
}
const segments = classifyTurnItems(bucket.items);
if (segments.length > 0) {
blocks.push({
kind: "turn",
turnId: entry.turnId,
segments,
});
}
}
return blocks;
}
/** Flatten display blocks back to items for testing display order. */
export function flattenDisplayBlocks(
blocks: TranscriptDisplayBlock[],
): TranscriptItem[] {
const result: TranscriptItem[] = [];
for (const block of blocks) {
if (block.kind === "single") {
result.push(block.item);
continue;
}
for (const segment of block.segments) {
if (segment.kind === "item") {
result.push(segment.item);
} else if (segment.kind === "tool_group") {
result.push(...segment.items);
} else if (segment.kind === "prompt") {
result.push(segment.user);
result.push(...segment.setup);
if (segment.context) {
result.push(segment.context);
}
} else {
result.push(...segment.items);
}
}
}
return result;
}
/** Human-readable labels for a collapsed turn setup row. */
export function formatTurnSetupLabel(
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
): string {
const labels = items.map((item) => item.title);
return labels.join(" · ");
}
/** Earliest timestamp among setup lifecycle items. */
export function turnSetupTimestamp(
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
): string | null {
if (items.length === 0) return null;
return items.reduce(
(earliest, item) =>
Date.parse(item.timestamp) < Date.parse(earliest)
? item.timestamp
: earliest,
items[0].timestamp,
);
}
/** Optional detail text from setup lifecycle items (e.g. trigger count). */
export function turnSetupDetail(
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
): string | null {
const details = items
.map((item) => item.text.trim())
.filter((text) => text.length > 0);
if (details.length === 0) return null;
return details.join(" ");
}