mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user