feat(agents): group ACP turn prompts into a user-first transcript bundle

- Add turnId/sessionId to transcript items and a presentation-only grouping helper
- Render each turn as user prompt first, with setup status and context integrated inline
- Show turn setup as a CheckCheck tooltip; expose prompt context via a footer toggle
- Expand context sections inside the message bubble; drop the outer grouping border
- Add unit tests for turn metadata attachment and grouped display ordering
This commit is contained in:
Taylor Ho
2026-06-15 15:59:27 -07:00
parent a99a835e1e
commit 9bcbc05825
6 changed files with 957 additions and 62 deletions
@@ -3,6 +3,7 @@ import {
AlertCircle,
Bot,
Brain,
CheckCheck,
ChevronDown,
CircleDot,
Loader2,
@@ -19,9 +20,18 @@ import { cn } from "@/shared/lib/cn";
import { Badge } from "@/shared/ui/badge";
import { Markdown } from "@/shared/ui/markdown";
import { Shimmer } from "@/shared/ui/Shimmer";
import { Toggle } from "@/shared/ui/toggle";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { TranscriptItem } from "./agentSessionTypes";
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
import { ToolItem } from "./AgentSessionToolItem";
import {
buildTranscriptDisplayBlocks,
formatTurnSetupLabel,
turnSetupDetail,
turnSetupTimestamp,
type TranscriptDisplayBlock,
type TranscriptTurnSegment,
} from "./agentSessionTranscriptGrouping";
import { buildTranscriptPresentation } from "./agentSessionTranscriptPresentation";
import { formatTranscriptTime } from "./agentSessionUtils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
@@ -50,6 +60,10 @@ export function AgentSessionTranscriptList({
() => buildTranscriptPresentation(items, isWorking),
[items, isWorking],
);
const displayBlocks = React.useMemo(
() => buildTranscriptDisplayBlocks(items),
[items],
);
if (items.length === 0) {
return (
@@ -81,26 +95,15 @@ export function AgentSessionTranscriptList({
className={cn("w-full", compact ? "py-0.5" : "py-1")}
role="log"
>
{items.map((item) => (
<div
className={cn(
"first:mt-0",
compact ? "mt-2.5" : "mt-4",
getItemSpacingClass(item),
)}
key={item.id}
>
{SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? (
<TranscriptAcpSourceBadge source={item.acpSource} />
) : null}
<TranscriptItemView
agentName={agentName}
compact={compact}
isActive={presentation.activeItemIds.has(item.id)}
item={item}
profiles={profiles}
/>
</div>
{displayBlocks.map((block) => (
<TranscriptDisplayBlockView
activeItemIds={presentation.activeItemIds}
agentName={agentName}
block={block}
compact={compact}
key={getDisplayBlockKey(block)}
profiles={profiles}
/>
))}
</div>
</div>
@@ -315,6 +318,351 @@ function getStateLabel(
}
}
function getDisplayBlockKey(block: TranscriptDisplayBlock) {
if (block.kind === "single") {
return block.item.id;
}
return `turn:${block.turnId}`;
}
function TranscriptDisplayBlockView({
activeItemIds,
agentName,
block,
compact,
profiles,
}: {
activeItemIds: ReadonlySet<string>;
agentName: string;
block: TranscriptDisplayBlock;
compact: boolean;
profiles?: UserProfileLookup;
}) {
if (block.kind === "single") {
return (
<TranscriptItemRow
activeItemIds={activeItemIds}
agentName={agentName}
compact={compact}
item={block.item}
profiles={profiles}
/>
);
}
return (
<div
className={cn("first:mt-0", compact ? "mt-2.5" : "mt-4")}
data-testid="transcript-turn-group"
data-turn-id={block.turnId}
>
{block.segments.map((segment) => (
<TranscriptTurnSegmentView
activeItemIds={activeItemIds}
agentName={agentName}
compact={compact}
key={getTurnSegmentKey(block.turnId, segment)}
profiles={profiles}
segment={segment}
/>
))}
</div>
);
}
function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
if (segment.kind === "setup") {
return `turn:${turnId}:setup`;
}
if (segment.kind === "prompt") {
return `turn:${turnId}:prompt`;
}
return segment.item.id;
}
function TranscriptTurnSegmentView({
activeItemIds,
agentName,
compact,
profiles,
segment,
}: {
activeItemIds: ReadonlySet<string>;
agentName: string;
compact: boolean;
profiles?: UserProfileLookup;
segment: TranscriptTurnSegment;
}) {
if (segment.kind === "prompt") {
return (
<TurnPromptBlock
compact={compact}
context={segment.context}
profiles={profiles}
setup={segment.setup}
user={segment.user}
/>
);
}
if (segment.kind === "setup") {
return <TurnSetupStatus compact={compact} items={segment.items} />;
}
return (
<TranscriptItemRow
activeItemIds={activeItemIds}
agentName={agentName}
compact={compact}
item={segment.item}
profiles={profiles}
/>
);
}
function TurnPromptBlock({
compact,
context,
profiles,
setup,
user,
}: {
compact: boolean;
context: Extract<TranscriptItem, { type: "metadata" }> | null;
profiles?: UserProfileLookup;
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
user: Extract<TranscriptItem, { type: "message" }>;
}) {
return (
<div
className={cn("first:mt-0", compact ? "mt-2.5" : "mt-4")}
data-testid="transcript-prompt-bundle"
>
{SHOW_TRANSCRIPT_ACP_SOURCE ? (
<div className="mb-1 flex flex-wrap gap-1">
<TranscriptAcpSourceBadge source="session/prompt:user" />
{context ? (
<TranscriptAcpSourceBadge source="session/prompt:context" />
) : null}
</div>
) : null}
<PromptUserMessage
compact={compact}
context={context}
item={user}
profiles={profiles}
setup={setup}
/>
</div>
);
}
function PromptUserMessage({
compact,
context = null,
item,
profiles,
setup = [],
}: {
compact: boolean;
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
setup?: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
const [contextOpen, setContextOpen] = React.useState(false);
const text = item.text.trim();
const authorProfile = item.authorPubkey
? profiles?.[item.authorPubkey.toLowerCase()]
: null;
const authorLabel = item.authorPubkey
? resolveUserLabel({
pubkey: item.authorPubkey,
fallbackName: item.title,
profiles,
})
: item.title || "User";
return (
<div
className="flex flex-row"
data-role="user-message"
data-testid="transcript-user-message"
>
<UserAvatar
avatarUrl={authorProfile?.avatarUrl ?? null}
className="mr-2 mt-1 h-5 w-5 shrink-0 text-[8px]"
displayName={authorLabel}
size="xs"
/>
<div className="group relative min-w-0 max-w-[85%] flex flex-col items-start gap-1">
<div
className={cn(
"w-full min-w-0 rounded-2xl bg-muted p-3 text-sm leading-relaxed text-foreground",
compact && "p-2.5",
)}
>
<p className="whitespace-pre-wrap break-words">{text}</p>
{contextOpen && context ? (
<PromptContextSections sections={context.sections} />
) : null}
</div>
<TurnSetupFooter
context={context}
contextOpen={contextOpen}
items={setup}
onContextOpenChange={setContextOpen}
timestamp={item.timestamp}
/>
</div>
</div>
);
}
function PromptContextSections({ sections }: { sections: PromptSection[] }) {
return (
<div
className="mt-2 space-y-2 border-t border-border/40 pt-2"
data-testid="transcript-prompt-context-sections"
>
{sections.map((section) => (
<details
className="group/section"
key={`${section.title}:${section.body.slice(0, 48)}`}
>
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-foreground/80">
<span className="truncate">{section.title}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-open/section:rotate-180" />
</summary>
<pre className="mt-1.5 max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-md bg-background/40 px-2 py-1.5 font-mono text-[11px] leading-5 text-muted-foreground">
{section.body.trim() || "No metadata."}
</pre>
</details>
))}
</div>
);
}
function TurnSetupFooter({
context = null,
contextOpen = false,
items,
onContextOpenChange,
timestamp,
}: {
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
contextOpen?: boolean;
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
onContextOpenChange?: (open: boolean) => void;
timestamp: string;
}) {
const label = formatTurnSetupLabel(items);
const detail = turnSetupDetail(items);
const tooltipText = [label, detail].filter(Boolean).join(" · ");
const showSetup = items.length > 0;
const showContext = context != null && context.sections.length > 0;
if (!showSetup && !showContext) {
return <TranscriptTimestamp timestamp={timestamp} />;
}
return (
<div
className="flex items-center gap-1.5 text-muted-foreground/80"
data-testid="transcript-turn-setup"
>
{showSetup ? (
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70 transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
type="button"
>
<CheckCheck className="h-3.5 w-3.5" />
<span className="sr-only">{tooltipText}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
) : null}
{showContext ? (
<Toggle
aria-label={`${contextOpen ? "Hide" : "Show"} prompt context`}
className="h-5 min-h-0 min-w-0 gap-1 rounded-md px-1.5 text-[10px] font-medium data-[state=on]:bg-muted data-[state=on]:text-foreground"
data-testid="transcript-prompt-context-toggle"
onPressedChange={onContextOpenChange}
pressed={contextOpen}
size="sm"
variant="outline"
>
Context
<span className="text-muted-foreground/70">
{context.sections.length}
</span>
</Toggle>
) : null}
<TranscriptTimestamp timestamp={timestamp} />
</div>
);
}
function TranscriptItemRow({
activeItemIds,
agentName,
compact,
item,
profiles,
}: {
activeItemIds: ReadonlySet<string>;
agentName: string;
compact: boolean;
item: TranscriptItem;
profiles?: UserProfileLookup;
}) {
return (
<div
className={cn(
"first:mt-0",
compact ? "mt-2.5" : "mt-4",
getItemSpacingClass(item),
)}
key={item.id}
>
{SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? (
<TranscriptAcpSourceBadge source={item.acpSource} />
) : null}
<TranscriptItemView
agentName={agentName}
compact={compact}
isActive={activeItemIds.has(item.id)}
item={item}
profiles={profiles}
/>
</div>
);
}
function TurnSetupStatus({
compact,
items,
}: {
compact: boolean;
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
const timestamp = turnSetupTimestamp(items);
if (items.length === 0 || !timestamp) {
return null;
}
return (
<div className={cn("rounded-md px-2 py-1.5", compact ? "mt-2" : "mt-2.5")}>
<TurnSetupFooter items={items} timestamp={timestamp} />
</div>
);
}
function getItemSpacingClass(item: TranscriptItem) {
if (item.type === "lifecycle") {
return "mt-2 first:mt-0";
@@ -495,16 +843,25 @@ function ThoughtItem({
function MetadataItem({
compact,
embedded = false,
item,
}: {
compact: boolean;
embedded?: boolean;
item: Extract<TranscriptItem, { type: "metadata" }>;
}) {
return (
<details
className={cn(
"group not-prose w-full rounded-md border border-border/50 bg-muted/20",
compact ? "px-2 py-1" : "px-2 py-1.5",
"group not-prose w-full",
embedded
? compact
? "px-2 py-1"
: "px-2.5 py-1.5"
: cn(
"rounded-md border border-border/50 bg-muted/20",
compact ? "px-2 py-1" : "px-2 py-1.5",
),
)}
data-testid="transcript-metadata-item"
>
@@ -3,6 +3,101 @@ import test from "node:test";
import { buildTranscript } from "./agentSessionTranscript.ts";
const turnId = "turn-abc";
const sessionId = "sess-1";
const channelId = "channel-1";
const baseTimestamp = "2026-06-14T22:20:23.000Z";
function makeTurnEvents() {
return [
{
seq: 1,
timestamp: baseTimestamp,
kind: "turn_started",
agentIndex: 0,
channelId,
sessionId: null,
turnId,
payload: { triggeringEventIds: ["event-1"] },
},
{
seq: 2,
timestamp: baseTimestamp,
kind: "session_resolved",
agentIndex: 0,
channelId,
sessionId,
turnId,
payload: { sessionId, isNewSession: false },
},
{
seq: 3,
timestamp: baseTimestamp,
kind: "acp_write",
agentIndex: 0,
channelId,
sessionId,
turnId,
payload: {
method: "session/prompt",
params: {
sessionId,
prompt: [
{
type: "text",
text: "[Buzz event: message]\nContent: @Ned deliberate, wider pass\nFrom: Tyler hex: abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
},
],
},
},
},
{
seq: 4,
timestamp: "2026-06-14T22:20:47.000Z",
kind: "acp_read",
agentIndex: 0,
channelId,
sessionId,
turnId,
payload: {
method: "session/update",
params: {
update: {
sessionUpdate: "agent_message_chunk",
messageId: "msg-1",
content: [{ type: "text", text: "On it." }],
},
},
},
},
];
}
test("buildTranscript attaches turnId and sessionId to generated items", () => {
const items = buildTranscript(makeTurnEvents());
assert.ok(items.length >= 4);
for (const item of items) {
assert.equal(item.turnId, turnId);
assert.equal(item.channelId, channelId);
}
const sessionResolved = items.find(
(item) =>
item.type === "lifecycle" && item.acpSource === "session_resolved",
);
assert.equal(sessionResolved?.sessionId, sessionId);
const userPrompt = items.find(
(item) =>
item.type === "message" &&
item.role === "user" &&
item.acpSource === "session/prompt:user",
);
assert.ok(userPrompt);
assert.equal(userPrompt.sessionId, sessionId);
});
test("buildTranscript tags assistant chunks with agent_message_chunk", () => {
const items = buildTranscript([
{
@@ -108,6 +108,12 @@ function sealOpenMessages(d: TranscriptDraft) {
}
}
type TranscriptItemContext = {
channelId: string | null;
turnId: string | null;
sessionId: string | null;
};
function upsertMessage(
d: TranscriptDraft,
id: string,
@@ -115,7 +121,7 @@ function upsertMessage(
title: string,
text: string,
timestamp: string,
channelId: string | null,
ctx: TranscriptItemContext,
authorPubkey: string | null = null,
acpSource?: string,
) {
@@ -127,7 +133,9 @@ function upsertMessage(
replaceItem(d, currentKey, {
...existing,
text: existing.text + text,
channelId,
channelId: ctx.channelId,
turnId: ctx.turnId ?? existing.turnId,
sessionId: ctx.sessionId ?? existing.sessionId,
authorPubkey: authorPubkey ?? existing.authorPubkey,
acpSource: acpSource ?? existing.acpSource,
});
@@ -144,7 +152,9 @@ function upsertMessage(
title,
text,
timestamp,
channelId,
channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
authorPubkey,
acpSource,
});
@@ -159,7 +169,7 @@ function upsertTextItem(
title: string,
text: string,
timestamp: string,
channelId: string | null,
ctx: TranscriptItemContext,
acpSource?: string,
) {
const existing = d.itemsById.get(id);
@@ -167,13 +177,25 @@ function upsertTextItem(
replaceItem(d, id, {
...existing,
text: existing.text + text,
channelId,
channelId: ctx.channelId,
turnId: ctx.turnId ?? existing.turnId,
sessionId: ctx.sessionId ?? existing.sessionId,
acpSource: acpSource ?? existing.acpSource,
});
return;
}
sealOpenMessages(d);
pushItem(d, { id, type, title, text, timestamp, channelId, acpSource });
pushItem(d, {
id,
type,
title,
text,
timestamp,
channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
acpSource,
});
}
function upsertMetadata(
@@ -182,7 +204,7 @@ function upsertMetadata(
title: string,
sections: PromptSection[],
timestamp: string,
channelId: string | null,
ctx: TranscriptItemContext,
acpSource?: string,
) {
const existing = d.itemsById.get(id);
@@ -190,7 +212,9 @@ function upsertMetadata(
replaceItem(d, id, {
...existing,
sections,
channelId,
channelId: ctx.channelId,
turnId: ctx.turnId ?? existing.turnId,
sessionId: ctx.sessionId ?? existing.sessionId,
acpSource: acpSource ?? existing.acpSource,
});
return;
@@ -202,7 +226,9 @@ function upsertMetadata(
title,
sections,
timestamp,
channelId,
channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
acpSource,
});
}
@@ -218,7 +244,7 @@ function upsertTool(
result: string,
isError: boolean,
timestamp: string,
channelId: string | null,
ctx: TranscriptItemContext,
acpSource?: string,
) {
const existing = d.itemsById.get(id);
@@ -248,7 +274,9 @@ function upsertTool(
existing.completedAt == null
? timestamp
: existing.completedAt,
channelId,
channelId: ctx.channelId,
turnId: ctx.turnId ?? existing.turnId,
sessionId: ctx.sessionId ?? existing.sessionId,
acpSource: acpSource ?? existing.acpSource,
});
return;
@@ -267,7 +295,9 @@ function upsertTool(
timestamp,
startedAt: timestamp,
completedAt: null,
channelId,
channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
acpSource,
});
}
@@ -284,6 +314,11 @@ export function processTranscriptEvent(
const channelId = event.channelId ?? null;
const ch = channelId ?? "global";
const ctx: TranscriptItemContext = {
channelId,
turnId: event.turnId,
sessionId: event.sessionId ?? d.latestSessionId,
};
if (event.kind === "turn_started") {
upsertTextItem(
@@ -293,7 +328,7 @@ export function processTranscriptEvent(
"Turn started",
describeTurnStarted(event.payload),
event.timestamp,
channelId,
ctx,
event.kind,
);
} else if (event.kind === "session_resolved") {
@@ -304,7 +339,7 @@ export function processTranscriptEvent(
"Session ready",
describeSessionResolved(event.payload),
event.timestamp,
channelId,
ctx,
event.kind,
);
} else if (event.kind === "acp_parse_error") {
@@ -315,7 +350,7 @@ export function processTranscriptEvent(
"Wire parse error",
extractBlockText(event.payload),
event.timestamp,
channelId,
ctx,
event.kind,
);
} else if (event.kind === "turn_error" || event.kind === "agent_panic") {
@@ -331,7 +366,7 @@ export function processTranscriptEvent(
title,
`${outcome}: ${error}`,
event.timestamp,
channelId,
ctx,
event.kind,
);
} else if (event.kind === "acp_read" || event.kind === "acp_write") {
@@ -350,7 +385,7 @@ export function processTranscriptEvent(
parsedPrompt.userTitle,
parsedPrompt.userText,
event.timestamp,
channelId,
ctx,
parsedPrompt.userPubkey,
"session/prompt:user",
);
@@ -362,7 +397,7 @@ export function processTranscriptEvent(
"Prompt context",
parsedPrompt.sections,
event.timestamp,
channelId,
ctx,
"session/prompt:context",
);
}
@@ -382,7 +417,7 @@ export function processTranscriptEvent(
"Assistant",
extractContentText(update.content),
event.timestamp,
channelId,
ctx,
null,
updateType,
);
@@ -394,7 +429,7 @@ export function processTranscriptEvent(
"User",
extractContentText(update.content),
event.timestamp,
channelId,
ctx,
null,
updateType,
);
@@ -406,7 +441,7 @@ export function processTranscriptEvent(
"Thinking",
extractContentText(update.content),
event.timestamp,
channelId,
ctx,
updateType,
);
} else if (updateType === "tool_call") {
@@ -423,7 +458,7 @@ export function processTranscriptEvent(
extractToolResult(update),
false,
event.timestamp,
channelId,
ctx,
updateType,
);
} else if (updateType === "tool_call_update") {
@@ -443,7 +478,7 @@ export function processTranscriptEvent(
extractToolResult(update),
status === "failed",
event.timestamp,
channelId,
ctx,
updateType,
);
} else if (updateType === "plan") {
@@ -454,7 +489,7 @@ export function processTranscriptEvent(
"Plan",
extractContentText(update.content) || JSON.stringify(update, null, 2),
event.timestamp,
channelId,
ctx,
updateType,
);
}
@@ -0,0 +1,198 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildTranscriptDisplayBlocks,
flattenDisplayBlocks,
formatTurnSetupLabel,
} from "./agentSessionTranscriptGrouping.ts";
const baseTimestamp = "2026-06-14T22:20:23.000Z";
function lifecycle(id, title, acpSource, turnId, text = "") {
return {
id,
type: "lifecycle",
title,
text,
timestamp: baseTimestamp,
acpSource,
turnId,
sessionId: "sess-1",
channelId: "channel-1",
};
}
function userPrompt(id, text, turnId) {
return {
id,
type: "message",
role: "user",
title: "Buzz event",
text,
timestamp: baseTimestamp,
acpSource: "session/prompt:user",
turnId,
sessionId: "sess-1",
channelId: "channel-1",
};
}
function promptContext(id, turnId) {
return {
id,
type: "metadata",
title: "Prompt context",
sections: [{ title: "Channel", body: "general" }],
timestamp: baseTimestamp,
acpSource: "session/prompt:context",
turnId,
sessionId: "sess-1",
channelId: "channel-1",
};
}
function assistantMessage(id, text, turnId) {
return {
id,
type: "message",
role: "assistant",
title: "Assistant",
text,
timestamp: "2026-06-14T22:20:47.000Z",
acpSource: "agent_message_chunk",
turnId,
sessionId: "sess-1",
channelId: "channel-1",
};
}
function toolCall(id, turnId) {
return {
id,
type: "tool",
title: "Shell",
toolName: "buzz-dev-mcp__shell",
buzzToolName: null,
status: "completed",
args: {},
result: "ok",
isError: false,
timestamp: "2026-06-14T22:20:47.000Z",
startedAt: "2026-06-14T22:20:47.000Z",
completedAt: "2026-06-14T22:20:47.400Z",
acpSource: "tool_call_update",
turnId,
sessionId: "sess-1",
channelId: "channel-1",
};
}
test("buildTranscriptDisplayBlocks bundles user prompt, setup, and context together", () => {
const rawItems = [
lifecycle(
"turn",
"Turn started",
"turn_started",
"turn-1",
"Triggered by 1 event.",
),
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
userPrompt("prompt", "@Ned deliberate, wider pass", "turn-1"),
promptContext("context", "turn-1"),
assistantMessage("assistant", "Thinking out loud.", "turn-1"),
toolCall("tool", "turn-1"),
];
const blocks = buildTranscriptDisplayBlocks(rawItems);
const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
assert.deepEqual(displayOrder, [
"prompt",
"turn",
"session",
"context",
"assistant",
"tool",
]);
const turnBlock = blocks[0];
assert.equal(turnBlock?.kind, "turn");
assert.equal(turnBlock.segments[0]?.kind, "prompt");
const promptSegment = turnBlock.segments[0];
assert.equal(promptSegment.user.id, "prompt");
assert.equal(promptSegment.context?.id, "context");
assert.equal(promptSegment.setup.length, 2);
assert.equal(turnBlock.segments[1]?.kind, "item");
});
test("buildTranscriptDisplayBlocks collapses setup lifecycle inside prompt bundle", () => {
const rawItems = [
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
userPrompt("prompt", "hello", "turn-1"),
];
const blocks = buildTranscriptDisplayBlocks(rawItems);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]?.kind, "turn");
const turnBlock = blocks[0];
assert.equal(turnBlock.segments.length, 1);
assert.equal(turnBlock.segments[0]?.kind, "prompt");
assert.equal(
formatTurnSetupLabel(turnBlock.segments[0].setup),
"Turn started · Session ready",
);
});
test("buildTranscriptDisplayBlocks keeps lifecycle visible when prompt is missing", () => {
const rawItems = [
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
];
const blocks = buildTranscriptDisplayBlocks(rawItems);
const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
assert.deepEqual(displayOrder, ["turn", "session"]);
});
test("buildTranscriptDisplayBlocks leaves error lifecycle prominent outside prompt bundle", () => {
const rawItems = [
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
userPrompt("prompt", "hello", "turn-1"),
lifecycle(
"error",
"Turn error",
"turn_error",
"turn-1",
"timeout: agent hung",
),
];
const blocks = buildTranscriptDisplayBlocks(rawItems);
const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
assert.deepEqual(displayOrder, ["prompt", "turn", "error"]);
assert.equal(blocks[0]?.segments[0]?.kind, "prompt");
assert.equal(blocks[0]?.segments[1]?.kind, "item");
assert.equal(blocks[0]?.segments[1]?.item.id, "error");
});
test("buildTranscriptDisplayBlocks passes through items without turnId", () => {
const orphan = {
id: "orphan",
type: "lifecycle",
title: "Wire parse error",
text: "bad json",
timestamp: baseTimestamp,
acpSource: "acp_parse_error",
channelId: "channel-1",
};
const blocks = buildTranscriptDisplayBlocks([orphan]);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]?.kind, "single");
assert.equal(blocks[0]?.item.id, "orphan");
});
@@ -0,0 +1,208 @@
import type { TranscriptItem } from "./agentSessionTypes";
export type TranscriptTurnSegment =
| { kind: "item"; item: TranscriptItem }
| { 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 items.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 segments;
}
/**
* 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;
}
blocks.push({
kind: "turn",
turnId: entry.turnId,
segments: classifyTurnItems(bucket.items),
});
}
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 === "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(" ");
}
@@ -23,8 +23,15 @@ export type ToolStatus = "executing" | "completed" | "failed" | "pending";
/** Observer/ACP wire label for dev-only transcript debugging. */
export type TranscriptAcpSource = string;
/** Shared optional identity fields attached during transcript construction. */
export type TranscriptItemIdentity = {
turnId?: string | null;
sessionId?: string | null;
channelId?: string | null;
};
export type TranscriptItem =
| {
| ({
id: string;
type: "message";
role: "assistant" | "user";
@@ -33,36 +40,32 @@ export type TranscriptItem =
timestamp: string;
acpSource?: TranscriptAcpSource;
authorPubkey?: string | null;
channelId?: string | null;
}
| {
} & TranscriptItemIdentity)
| ({
id: string;
type: "thought";
title: string;
text: string;
timestamp: string;
acpSource?: TranscriptAcpSource;
channelId?: string | null;
}
| {
} & TranscriptItemIdentity)
| ({
id: string;
type: "lifecycle";
title: string;
text: string;
timestamp: string;
acpSource?: TranscriptAcpSource;
channelId?: string | null;
}
| {
} & TranscriptItemIdentity)
| ({
id: string;
type: "metadata";
title: string;
sections: PromptSection[];
timestamp: string;
acpSource?: TranscriptAcpSource;
channelId?: string | null;
}
| {
} & TranscriptItemIdentity)
| ({
id: string;
type: "tool";
title: string;
@@ -76,8 +79,7 @@ export type TranscriptItem =
startedAt: string;
completedAt: string | null;
acpSource?: TranscriptAcpSource;
channelId?: string | null;
};
} & TranscriptItemIdentity);
export type PromptSection = {
title: string;