feat(desktop): restore observer-feed regressions from #1381 and classify 4 new session/update types (#1412)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-01 16:33:26 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent bdeab23b59
commit fec7684366
17 changed files with 2087 additions and 169 deletions
+1
View File
@@ -35,6 +35,7 @@ export default defineConfig({
"**/active-turn-resilience.spec.ts",
"**/profile-active-turn.spec.ts",
"**/config-bridge-screenshots.spec.ts",
"**/observer-feed-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/image-attachment-gallery.spec.ts",
"**/video-attachment.spec.ts",
+12 -1
View File
@@ -132,7 +132,18 @@ const overrides = new Map([
["src/shared/ui/markdown.tsx", 2119],
["src/shared/ui/VideoPlayer.tsx", 2199],
["src/shared/ui/sidebar.tsx", 1042],
// Option C databricks-model-discovery: parse/HTTP logic moved to buzz-agent
// permission-outcome (fix #1381 regression): pendingPermissions state map,
// describePermissionOutcome helper, jsonRpcId key helper (handles both
// string and finite-number JSON-RPC ids per spec), and the acp_write
// response correlation branch are all tightly coupled to the existing
// request handler. Load-bearing logic growth, not generic debt. Queued to
// split into a dedicated permission module in the next transcript refactor.
// +123: observer parity — 4 new named session/update classifier cases
// (current_mode_update, usage_update, available_commands_update,
// config_option_update) + replaceLifecycleItem helper for usage coalescing +
// system-prompt ordering fix (turnId: null for per-channel items).
// Load-bearing feature growth; queued to split in next transcript refactor.
["src/features/agents/ui/agentSessionTranscript.ts", 1167],
// catalog module; agent_models.rs retains the thin wrapper (~50 lines).
// File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess
// fallback. Queued to split into dedicated discovery modules.
@@ -424,6 +424,25 @@ export function useManagedAgentObserverBridge(
}, [queryClient]);
}
/**
* E2E-only: inject synthetic observer events directly into the store, bypassing
* the relay-security knownAgentPubkeys filter. Exercises the real
* appendAgentEvent → processTranscriptEvent ingestion path so screenshot specs
* prove the production render, not a stub.
*
* Never call this from production code — it is intentionally not re-exported
* from the public agent feature barrel.
*/
export function injectObserverEventsForE2E(
agentPubkey: string,
events: ObserverEvent[],
) {
for (const event of events) {
appendAgentEvent(agentPubkey, event);
}
notifyListeners();
}
export function resetAgentObserverStore() {
generation += 1;
const unsubscribe = unsubscribeRelay;
@@ -1,17 +1,16 @@
import * as React from "react";
import { CheckCheck, ChevronDown, Radio } from "lucide-react";
import { CheckCheck, Radio } from "lucide-react";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Toggle } from "@/shared/ui/toggle";
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
import type { TranscriptItem } from "./agentSessionTypes";
import { PromptSectionList as PromptContextSections } from "./PromptSectionAccordion";
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
import {
ActivityRow,
@@ -207,6 +206,7 @@ function TranscriptTurnSegmentView({
context={segment.context}
profiles={profiles}
setup={segment.setup}
systemPrompt={segment.systemPrompt}
user={segment.user}
/>
);
@@ -355,11 +355,13 @@ function TurnPromptBlock({
context,
profiles,
setup,
systemPrompt,
user,
}: {
context: Extract<TranscriptItem, { type: "metadata" }> | null;
profiles?: UserProfileLookup;
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
systemPrompt: Extract<TranscriptItem, { type: "metadata" }> | null;
user: Extract<TranscriptItem, { type: "message" }>;
}) {
return (
@@ -377,6 +379,7 @@ function TurnPromptBlock({
item={user}
profiles={profiles}
setup={setup}
systemPrompt={systemPrompt}
/>
</div>
);
@@ -387,111 +390,72 @@ function PromptUserMessage({
item,
profiles,
setup = [],
systemPrompt = null,
}: {
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
setup?: Extract<TranscriptItem, { type: "lifecycle" }>[];
systemPrompt?: Extract<TranscriptItem, { type: "metadata" }> | null;
}) {
const [contextOpen, setContextOpen] = React.useState(false);
return (
<>
<UserMessageBubble
bubbleClassName="p-2.5"
footer={
<TurnSetupFooter
context={context}
contextOpen={contextOpen}
items={setup}
messageLink={getTranscriptMessageLink(item)}
onContextOpenChange={setContextOpen}
timestamp={item.timestamp}
/>
}
item={item}
profiles={profiles}
/>
<PromptContextDialog
context={context}
onOpenChange={setContextOpen}
open={contextOpen}
setup={setup}
/>
{systemPrompt && systemPrompt.sections.length > 0 ? (
<PromptContextInline context={systemPrompt} />
) : null}
{context && context.sections.length > 0 ? (
<PromptContextInline context={context} />
) : null}
</>
);
}
function PromptContextSections({
className,
sections,
function PromptContextInline({
context,
}: {
className?: string;
sections: PromptSection[];
context: Extract<TranscriptItem, { type: "metadata" }>;
}) {
return (
<div
className={cn("space-y-3", className)}
data-testid="transcript-prompt-context-sections"
>
{sections.map((section) => (
<PromptContextSectionAccordion
key={`${section.title}:${section.body.slice(0, 48)}`}
section={section}
/>
))}
</div>
);
}
function PromptContextSectionAccordion({
section,
}: {
section: PromptSection;
}) {
const [open, setOpen] = React.useState(false);
const body = section.body.trim();
const [dialogOpen, setDialogOpen] = React.useState(false);
return (
<article className="overflow-hidden rounded-2xl bg-muted/40">
<button
aria-expanded={open}
className="w-full px-4 py-3 text-left transition-colors hover:bg-muted/50"
onClick={() => setOpen((value) => !value)}
type="button"
<>
<div
className="mt-1 space-y-2 pl-2"
data-testid="transcript-prompt-context-inline"
>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div
className={cn(
"text-sm font-semibold text-foreground",
!open && "line-clamp-2",
)}
>
{section.title}
</div>
<div
className={cn(
"mt-1 text-xs leading-5 text-foreground/70",
open ? "whitespace-pre-wrap wrap-break-word" : "line-clamp-2",
)}
>
{body.length > 0 ? (
body
) : (
<span className="italic text-foreground/50">No metadata.</span>
)}
</div>
</div>
<ChevronDown
className={cn(
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-muted-foreground/70">
{context.title}
</p>
<button
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors"
data-testid="transcript-prompt-context-expand"
onClick={() => setDialogOpen(true)}
type="button"
>
View full
</button>
</div>
</button>
</article>
<PromptContextSections sections={context.sections} />
</div>
<PromptContextDialog
context={context}
onOpenChange={setDialogOpen}
open={dialogOpen}
/>
</>
);
}
@@ -499,33 +463,22 @@ function PromptContextDialog({
context,
onOpenChange,
open,
setup,
}: {
context: Extract<TranscriptItem, { type: "metadata" }> | null;
context: Extract<TranscriptItem, { type: "metadata" }>;
onOpenChange: (open: boolean) => void;
open: boolean;
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
if (!open || !context || context.sections.length === 0) {
if (!open || context.sections.length === 0) {
return null;
}
const setupText = formatPromptSetupSummary(setup);
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-xl overflow-hidden p-0">
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="px-6 pb-3 pt-5 pr-14">
<DialogTitle>Prompt context</DialogTitle>
{setupText ? (
<div className="flex items-center gap-1.5">
<CheckCheck className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<DialogDescription>{setupText}</DialogDescription>
</div>
) : null}
<DialogTitle>{context.title}</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-2">
<PromptContextSections sections={context.sections} />
</div>
@@ -535,28 +488,14 @@ function PromptContextDialog({
);
}
function formatPromptSetupSummary(
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
) {
const label = formatTurnSetupLabel(items);
const detail = turnSetupDetail(items);
return [label, detail].filter(Boolean).join(" · ");
}
function TurnSetupFooter({
context = null,
contextOpen = false,
items,
messageLink = null,
onContextOpenChange,
showTimestamp = true,
timestamp,
}: {
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
contextOpen?: boolean;
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
messageLink?: { channelId: string; messageId: string } | null;
onContextOpenChange?: (open: boolean) => void;
showTimestamp?: boolean;
timestamp: string;
}) {
@@ -564,41 +503,22 @@ function TurnSetupFooter({
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) {
if (!showSetup) {
return showTimestamp ? (
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
) : null;
}
const contextToggle = showContext ? (
<Toggle
aria-label={`${contextOpen ? "Hide" : "Show"} prompt context`}
data-testid="transcript-prompt-context-toggle"
className="data-[state=on]:bg-primary/10 data-[state=on]:text-primary dark:data-[state=on]:bg-primary/15"
onPressedChange={onContextOpenChange}
pressed={contextOpen}
size="xs"
variant="ghost"
>
<CheckCheck aria-hidden="true" />
</Toggle>
) : null;
return (
<div
className="flex items-center gap-1.5 text-muted-foreground/80"
data-testid="transcript-turn-setup"
>
{showContext && showSetup ? contextToggle : null}
{!showContext && showSetup ? (
<span className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70">
<CheckCheck className="h-3.5 w-3.5" />
<span className="sr-only">{tooltipText}</span>
</span>
) : null}
{showContext && !showSetup ? contextToggle : null}
<span className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70">
<CheckCheck className="h-3.5 w-3.5" />
<span className="sr-only">{tooltipText}</span>
</span>
{showTimestamp ? (
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
) : null}
@@ -0,0 +1,78 @@
import React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import type { PromptSection } from "./agentSessionTypes";
export function PromptSectionList({
className,
sections,
}: {
className?: string;
sections: PromptSection[];
}) {
return (
<div
className={cn("space-y-2", className)}
data-testid="transcript-prompt-context-sections"
>
{sections.map((section) => (
<PromptSectionAccordion
key={`${section.title}:${section.body.slice(0, 48)}`}
section={section}
/>
))}
</div>
);
}
export function PromptSectionAccordion({
section,
}: {
section: PromptSection;
}) {
const [open, setOpen] = React.useState(false);
const body = section.body.trim();
return (
<article className="overflow-hidden rounded-2xl bg-muted/40">
<button
aria-expanded={open}
className="w-full px-4 py-3 text-left transition-colors hover:bg-muted/50"
onClick={() => setOpen((value) => !value)}
type="button"
>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div
className={cn(
"text-sm font-semibold text-foreground",
!open && "line-clamp-2",
)}
>
{section.title}
</div>
<div
className={cn(
"mt-1 text-xs leading-5 text-foreground/70",
open ? "whitespace-pre-wrap wrap-break-word" : "line-clamp-2",
)}
>
{body.length > 0 ? (
body
) : (
<span className="italic text-foreground/50">No metadata.</span>
)}
</div>
</div>
<ChevronDown
className={cn(
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
</div>
</button>
</article>
);
}
@@ -1,10 +1,42 @@
import { AlertCircle, ShieldCheck } from "lucide-react";
import { AlertCircle, CheckCircle2, ShieldCheck, XCircle } from "lucide-react";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import { ActivityRow, ActivityRowLabel } from "./ActivityRow";
import { ToolActivity } from "./ToolActivity";
import type { ActivityRenderClassItemProps } from "./types";
/**
* Split the permission item's text into the request description lines and the
* options line. The text is newline-joined by describePermissionRequest:
* [request title?] [toolCallId?] ["Options: ..."]
* We surface the options line separately so the render can style it distinctly.
*/
function splitPermissionText(text: string): {
requestLines: string;
optionsLine: string | null;
} {
const lines = text.split("\n");
const optionsIdx = lines.findIndex((l) => l.startsWith("Options: "));
if (optionsIdx === -1) {
return { requestLines: text, optionsLine: null };
}
return {
requestLines: lines.slice(0, optionsIdx).join("\n"),
optionsLine: lines[optionsIdx],
};
}
/**
* Derive the visual tone and icon for a resolved permission outcome string.
* Outcome strings come from describePermissionOutcome:
* "Approved (...)" | "Denied (...)" | "Cancelled"
*/
function permissionOutcomeTone(outcome: string): "approve" | "deny" | "cancel" {
if (outcome.startsWith("Approved")) return "approve";
if (outcome.startsWith("Denied")) return "deny";
return "cancel";
}
export function LifecycleActivity(props: ActivityRenderClassItemProps) {
if (props.item.type === "tool") {
return <ToolActivity {...props} />;
@@ -20,16 +52,51 @@ export function LifecycleActivity(props: ActivityRenderClassItemProps) {
const timestampTitle = formatTranscriptTimestampTitle(props.item.timestamp);
if (isPermission) {
const { requestLines, optionsLine } = splitPermissionText(props.item.text);
const outcome = props.item.outcome;
const tone = outcome ? permissionOutcomeTone(outcome) : null;
return (
<div
className="rounded-md border border-amber-500/20 bg-amber-500/5 px-2 py-1.5 text-left text-xs text-amber-700 dark:text-amber-400"
data-testid="transcript-permission-item"
title={timestampTitle}
>
<ShieldCheck className="mr-1.5 inline h-3.5 w-3.5 align-text-bottom" />
<span className="font-medium">{props.item.title}</span>
{props.item.text ? (
<span className="opacity-80"> · {props.item.text}</span>
{/* Row 1: request */}
<div>
<ShieldCheck className="mr-1.5 inline h-3.5 w-3.5 align-text-bottom" />
<span className="font-medium">{props.item.title}</span>
{requestLines ? (
<span className="opacity-80"> · {requestLines}</span>
) : null}
</div>
{/* Row 2: options (muted sub-line) */}
{optionsLine ? (
<div className="mt-0.5 pl-5 opacity-60">{optionsLine}</div>
) : null}
{/* Row 3: decision — only when outcome is resolved */}
{outcome && tone ? (
<>
<div className="my-1 border-t border-amber-500/20" />
<div
className={
tone === "approve"
? "flex items-center gap-1 font-medium text-green-600 dark:text-green-400"
: tone === "deny"
? "flex items-center gap-1 font-medium text-destructive"
: "flex items-center gap-1 font-medium text-muted-foreground"
}
data-testid="transcript-permission-outcome"
>
{tone === "approve" ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
) : tone === "deny" ? (
<XCircle className="h-3.5 w-3.5 shrink-0" />
) : (
<XCircle className="h-3.5 w-3.5 shrink-0 opacity-50" />
)}
{outcome}
</div>
</>
) : null}
</div>
);
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { RawRailActivity } from "./RawRailActivity.tsx";
const baseProps = {
agentAvatarUrl: null,
agentName: "Test Agent",
agentPubkey: "pubkey123",
};
const baseTimestamp = "2026-06-14T19:00:00.000Z";
const baseIdentity = {
agentPubkey: "pubkey123",
sessionId: "session-001",
turnId: null,
};
test("RawRailActivity render: raw_json_rpc keeps <pre> safety net", () => {
const html = renderToStaticMarkup(
React.createElement(RawRailActivity, {
...baseProps,
item: {
...baseIdentity,
id: "meta:raw",
type: "metadata",
renderClass: "raw-rail",
title: "Raw ACP payload",
sections: [{ title: "body", body: "{}" }],
timestamp: baseTimestamp,
acpSource: "raw_json_rpc",
},
}),
);
assert.ok(html.includes("<pre"), "raw_json_rpc should render <pre>");
assert.ok(
!html.includes("transcript-prompt-context-sections"),
"raw_json_rpc should not render polished accordion",
);
});
test("RawRailActivity render: system prompt (no acpSource) uses polished accordion", () => {
const html = renderToStaticMarkup(
React.createElement(RawRailActivity, {
...baseProps,
item: {
...baseIdentity,
id: "meta:sys",
type: "metadata",
renderClass: "raw-rail",
title: "System prompt",
sections: [{ title: "Instructions", body: "You are an agent." }],
timestamp: baseTimestamp,
},
}),
);
assert.ok(
html.includes("transcript-prompt-context-sections"),
"system prompt should render polished accordion",
);
assert.ok(!html.includes("<pre"), "system prompt should not render <pre>");
});
test("RawRailActivity render: steer-turn prompt context uses polished accordion", () => {
const html = renderToStaticMarkup(
React.createElement(RawRailActivity, {
...baseProps,
item: {
...baseIdentity,
id: "meta:ctx",
type: "metadata",
renderClass: "raw-rail",
title: "Prompt context",
sections: [{ title: "Thread history", body: "..." }],
timestamp: baseTimestamp,
acpSource: "session/prompt:context",
},
}),
);
assert.ok(
html.includes("transcript-prompt-context-sections"),
"steer-turn prompt context should render polished accordion",
);
assert.ok(
!html.includes("<pre"),
"steer-turn prompt context should not render <pre>",
);
});
@@ -7,6 +7,7 @@ import {
} from "./ActivityRow";
import { ToolActivity } from "./ToolActivity";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import { PromptSectionList } from "../PromptSectionAccordion";
import type { ActivityRenderClassItemProps } from "./types";
export function RawRailActivity(props: ActivityRenderClassItemProps) {
@@ -17,33 +18,39 @@ export function RawRailActivity(props: ActivityRenderClassItemProps) {
return null;
}
const sectionCount = props.item.sections.length;
const sectionSuffix = `${sectionCount} section${sectionCount === 1 ? "" : "s"}`;
const isRawPayload = props.item.acpSource === "raw_json_rpc";
return (
<ActivityRow
testId="transcript-metadata-item"
title={formatTranscriptTimestampTitle(props.item.timestamp)}
>
<ActivityRowLabel
object={`${props.item.sections.length} raw section${
props.item.sections.length === 1 ? "" : "s"
}`}
object={sectionSuffix}
openToneScope="tool"
verb="Captured"
verb={props.item.title}
/>
<ActivityRowContent className="flex flex-col gap-3 py-2">
{props.item.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-muted-foreground/60 group-open/section:text-foreground">
<span className="truncate">{section.title}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform group-open/section:rotate-180 group-open/section:text-foreground" />
</summary>
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap wrap-break-word rounded-md bg-muted/50 px-3 py-2 font-mono text-xs leading-5 text-muted-foreground">
{section.body.trim() || "No metadata."}
</pre>
</details>
))}
{isRawPayload ? (
props.item.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-muted-foreground/60 group-open/section:text-foreground">
<span className="truncate">{section.title}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform group-open/section:rotate-180 group-open/section:text-foreground" />
</summary>
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap wrap-break-word rounded-md bg-muted/50 px-3 py-2 font-mono text-xs leading-5 text-muted-foreground">
{section.body.trim() || "No metadata."}
</pre>
</details>
))
) : (
<PromptSectionList sections={props.item.sections} />
)}
</ActivityRowContent>
</ActivityRow>
);
@@ -2,6 +2,10 @@ import assert from "node:assert/strict";
import test from "node:test";
import { buildTranscript } from "./agentSessionTranscript.ts";
import {
buildTranscriptDisplayBlocks,
flattenDisplayBlocks,
} from "./agentSessionTranscriptGrouping.ts";
import { formatToolTitle } from "./agentSessionToolCatalog.ts";
const baseEvent = {
@@ -639,3 +643,539 @@ test("buildTranscript separates repeated lifecycle text", () => {
assert.equal(item.type, "lifecycle");
assert.equal(item.text, "recovered: first\nrecovered: second");
});
// --- permission outcome (Fix #3) ---
function makePermissionRequest(seq, requestId, turnId = "turn-1") {
return {
seq,
timestamp: "2026-06-30T10:00:00.000Z",
kind: "acp_read",
agentIndex: 0,
channelId: "channel-1",
sessionId: "session-1",
turnId,
payload: {
jsonrpc: "2.0",
id: requestId,
method: "session/request_permission",
params: {
title: "Confirm push",
toolCallId: "tool-1",
options: [
{ optionId: "allow_once", kind: "allow_once", name: "Allow" },
{ optionId: "reject_once", kind: "reject_once", name: "Reject" },
],
},
},
};
}
function makePermissionResponse(seq, requestId, outcome, optionId = null) {
const resultOutcome =
outcome === "selected" ? { outcome: "selected", optionId } : { outcome };
return {
seq,
timestamp: "2026-06-30T10:00:01.000Z",
kind: "acp_write",
agentIndex: 0,
channelId: "channel-1",
sessionId: "session-1",
turnId: "turn-1",
payload: {
jsonrpc: "2.0",
id: requestId,
result: { outcome: resultOutcome },
},
};
}
test("buildTranscript appends Approved outcome when allow_once is selected", () => {
const transcript = buildTranscript([
makePermissionRequest(1, "req-1"),
makePermissionResponse(2, "req-1", "selected", "allow_once"),
]);
assert.equal(transcript.length, 1);
const item = transcript[0];
assert.equal(item.type, "lifecycle");
assert.equal(item.renderClass, "permission");
assert.equal(item.outcome, "Approved (allow_once)");
assert.doesNotMatch(item.text ?? "", /Approved/);
});
test("buildTranscript appends Denied outcome when reject_once is selected", () => {
const transcript = buildTranscript([
makePermissionRequest(1, "req-2"),
makePermissionResponse(2, "req-2", "selected", "reject_once"),
]);
const item = transcript[0];
assert.equal(item.type, "lifecycle");
assert.equal(item.outcome, "Denied (reject_once)");
assert.doesNotMatch(item.text ?? "", /Denied/);
});
test("buildTranscript appends Cancelled outcome on cancelled response", () => {
const transcript = buildTranscript([
makePermissionRequest(1, "req-3"),
makePermissionResponse(2, "req-3", "cancelled"),
]);
const item = transcript[0];
assert.equal(item.type, "lifecycle");
assert.equal(item.outcome, "Cancelled");
assert.doesNotMatch(item.text ?? "", /Cancelled/);
});
test("buildTranscript no-ops on a permission response with an unmatched id", () => {
const transcript = buildTranscript([
makePermissionRequest(1, "req-4"),
makePermissionResponse(2, "req-WRONG", "selected", "allow_once"),
]);
// The permission item exists but has no outcome appended — the mismatched
// response id must not crash or attach to the wrong item.
assert.equal(transcript.length, 1);
const item = transcript[0];
assert.equal(item.type, "lifecycle");
assert.equal(item.renderClass, "permission");
assert.equal(item.outcome, undefined);
assert.doesNotMatch(item.text ?? "", /Approved/);
assert.doesNotMatch(item.text ?? "", /Denied/);
});
test("buildTranscript appends Approved outcome for a numeric JSON-RPC id (selected allow_once)", () => {
// JSON-RPC 2.0 allows numeric ids; the ACP runtime preserves them as
// serde_json::Value. asString() drops numbers, so this exercises the
// jsonRpcId() helper path that handles finite-number ids.
const transcript = buildTranscript([
makePermissionRequest(1, 42),
makePermissionResponse(2, 42, "selected", "allow_once"),
]);
assert.equal(transcript.length, 1);
const item = transcript[0];
assert.equal(item.type, "lifecycle");
assert.equal(item.renderClass, "permission");
assert.equal(item.outcome, "Approved (allow_once)");
assert.doesNotMatch(item.text ?? "", /Approved/);
});
test("buildTranscript appends Cancelled outcome for a numeric JSON-RPC id (cancelled)", () => {
const transcript = buildTranscript([
makePermissionRequest(1, 99),
makePermissionResponse(2, 99, "cancelled"),
]);
const item = transcript[0];
assert.equal(item.type, "lifecycle");
assert.equal(item.outcome, "Cancelled");
assert.doesNotMatch(item.text ?? "", /Cancelled/);
});
test('buildTranscript does not collide between numeric id 1 and string id "1"', () => {
// JSON.stringify produces "1" for number 1 and "\"1\"" for string "1",
// so requests with these two different id types must NOT cross-attach.
const transcriptNumeric = buildTranscript([
makePermissionRequest(1, 1),
makePermissionResponse(2, 1, "selected", "allow_once"),
]);
const transcriptString = buildTranscript([
makePermissionRequest(1, "1"),
makePermissionResponse(2, "1", "selected", "reject_once"),
]);
assert.equal(transcriptNumeric[0].outcome, "Approved (allow_once)");
assert.equal(transcriptString[0].outcome, "Denied (reject_once)");
});
// ─── observer parity: new session/update classifier cases ────────────────────
test("buildTranscript renders current_mode_update as a lifecycle status line", () => {
const transcript = buildTranscript([
acpToolUpdate(1, {
sessionUpdate: "current_mode_update",
currentModeId: "plan",
}),
]);
assert.equal(transcript.length, 1);
const item = transcript[0];
assert.equal(item.type, "lifecycle");
assert.equal(item.renderClass, "status");
assert.equal(item.title, "Mode");
assert.equal(item.text, "plan");
assert.equal(item.acpSource, "current_mode_update");
});
test("buildTranscript suppresses current_mode_update when currentModeId is missing", () => {
const transcript = buildTranscript([
acpToolUpdate(1, { sessionUpdate: "current_mode_update" }),
]);
assert.equal(transcript.length, 0);
});
test("buildTranscript renders usage_update as a lifecycle status line with tokens", () => {
const transcript = buildTranscript([
acpToolUpdate(1, {
sessionUpdate: "usage_update",
used: 1500,
size: 8192,
}),
]);
assert.equal(transcript.length, 1);
const item = transcript[0];
assert.equal(item.type, "lifecycle");
assert.equal(item.renderClass, "status");
assert.equal(item.title, "Usage");
assert.equal(item.text, "Tokens: 1500/8192");
assert.equal(item.acpSource, "usage_update");
});
test("buildTranscript renders usage_update with cost when present", () => {
const transcript = buildTranscript([
acpToolUpdate(1, {
sessionUpdate: "usage_update",
used: 800,
size: 4096,
cost: { amount: 0.0025, currency: "USD" },
}),
]);
assert.equal(transcript.length, 1);
assert.equal(transcript[0].text, "Tokens: 800/4096 ($0.0025 USD)");
});
test("buildTranscript coalesces usage_update to latest-per-turn (replace, not append)", () => {
// Three usage frames in the same turn must produce exactly ONE lifecycle item
// showing the LAST value — not an accumulation of all three.
const transcript = buildTranscript([
acpToolUpdate(1, { sessionUpdate: "usage_update", used: 100, size: 8192 }),
acpToolUpdate(2, { sessionUpdate: "usage_update", used: 300, size: 8192 }),
acpToolUpdate(3, { sessionUpdate: "usage_update", used: 600, size: 8192 }),
]);
const usageItems = transcript.filter((i) => i.acpSource === "usage_update");
assert.equal(usageItems.length, 1, "must coalesce to one item");
assert.equal(usageItems[0].text, "Tokens: 600/8192");
});
test("buildTranscript suppresses usage_update when used or size is missing", () => {
const transcript = buildTranscript([
acpToolUpdate(1, { sessionUpdate: "usage_update", used: 100 }), // size absent
acpToolUpdate(2, { sessionUpdate: "usage_update", size: 8192 }), // used absent
]);
assert.equal(transcript.length, 0);
});
test("buildTranscript renders available_commands_update as a lifecycle status line", () => {
const transcript = buildTranscript([
acpToolUpdate(1, {
sessionUpdate: "available_commands_update",
availableCommands: [
{ name: "create_plan", description: "Create a plan" },
{ name: "research_codebase", description: "Research the codebase" },
],
}),
]);
assert.equal(transcript.length, 1);
const item = transcript[0];
assert.equal(item.type, "lifecycle");
assert.equal(item.renderClass, "status");
assert.equal(item.title, "Commands");
assert.equal(item.text, "Commands available: 2");
assert.equal(item.acpSource, "available_commands_update");
});
test("buildTranscript renders available_commands_update with zero commands", () => {
const transcript = buildTranscript([
acpToolUpdate(1, {
sessionUpdate: "available_commands_update",
availableCommands: [],
}),
]);
assert.equal(transcript.length, 1);
assert.equal(transcript[0].text, "Commands available: 0");
});
test("buildTranscript renders config_option_update as a lifecycle status line", () => {
const transcript = buildTranscript([
acpToolUpdate(1, {
sessionUpdate: "config_option_update",
configOptions: [
{ id: "model", name: "Model", type: "select", currentValue: "gpt-4o" },
{ id: "mode", name: "Mode", type: "select", currentValue: "auto" },
],
}),
]);
assert.equal(transcript.length, 1);
const item = transcript[0];
assert.equal(item.type, "lifecycle");
assert.equal(item.renderClass, "status");
assert.equal(item.title, "Config");
assert.equal(item.text, "Model = gpt-4o, Mode = auto");
assert.equal(item.acpSource, "config_option_update");
});
test("buildTranscript suppresses config_option_update when configOptions is empty", () => {
const transcript = buildTranscript([
acpToolUpdate(1, {
sessionUpdate: "config_option_update",
configOptions: [],
}),
]);
assert.equal(transcript.length, 0);
});
test("buildTranscript does not render keepalive (stays in else-dropped bucket)", () => {
const transcript = buildTranscript([
acpToolUpdate(1, { sessionUpdate: "keepalive" }),
]);
assert.equal(transcript.length, 0);
});
test("buildTranscript does not render unknown session/update types (firehose safety net)", () => {
const transcript = buildTranscript([
acpToolUpdate(1, { sessionUpdate: "some_future_type", value: 42 }),
]);
assert.equal(transcript.length, 0);
});
// --- system-prompt ordering ---
test("observer feed renders system-prompt before prompt-context in display order (first turn, realistic pool.rs sequence)", () => {
// Reproduces the real ordering bug: pool.rs emits turn_started BEFORE session/new,
// so turn_started creates the turn bucket first. Without the injection mechanism,
// displayOrder becomes [turn(turn-1), single(system-prompt)] and System prompt
// renders after the entire turn block — after Prompt context.
// The fix: system-prompt items (acpSource "session/new") are held and injected
// into the prompt segment of the first turn that follows them in stream order.
const events = [
{
seq: 1,
timestamp: "2026-07-01T10:00:00.000Z",
kind: "turn_started",
agentIndex: 0,
channelId: "ch-1",
sessionId: null,
turnId: "turn-1",
payload: { source: "channel", triggeringEventIds: [] },
},
{
seq: 2,
timestamp: "2026-07-01T10:00:00.100Z",
kind: "acp_write",
agentIndex: 0,
channelId: "ch-1",
sessionId: null,
turnId: "turn-1",
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/new",
params: {
systemPrompt:
"[Base]\nYou are a helpful assistant.\n\n[System]\nObserver Agent.",
},
},
},
{
seq: 3,
timestamp: "2026-07-01T10:00:00.200Z",
kind: "session_resolved",
agentIndex: 0,
channelId: "ch-1",
sessionId: "sess-1",
turnId: "turn-1",
payload: { sessionId: "sess-1", isNewSession: true },
},
{
seq: 4,
timestamp: "2026-07-01T10:00:01.000Z",
kind: "acp_write",
agentIndex: 0,
channelId: "ch-1",
sessionId: "sess-1",
turnId: "turn-1",
payload: {
jsonrpc: "2.0",
id: 2,
method: "session/prompt",
params: {
sessionId: "sess-1",
prompt: [
{
type: "text",
text: `[Buzz event: @mention]\nEvent ID: ${"a".repeat(64)}\nFrom: x (hex: ${"b".repeat(64)})\nContent: hello`,
},
{ type: "text", text: "[Thread context]\nPrior messages here." },
],
},
},
},
];
// Route through the display layer — this is the layer that contained the bug.
const rawItems = buildTranscript(events);
const displayItems = flattenDisplayBlocks(
buildTranscriptDisplayBlocks(rawItems),
);
const systemPromptIdx = displayItems.findIndex(
(i) => i.title === "System prompt",
);
const promptContextIdx = displayItems.findIndex(
(i) => i.title === "Prompt context",
);
assert.ok(systemPromptIdx !== -1, "expected a System prompt item");
assert.ok(promptContextIdx !== -1, "expected a Prompt context item");
assert.ok(
systemPromptIdx < promptContextIdx,
`expected System prompt (idx ${systemPromptIdx}) before Prompt context (idx ${promptContextIdx}) in display order`,
);
// Also verify the fix input: system-prompt item must have turnId=null so the
// display grouper treats it as a standalone entry, not a turn-bucket item.
const systemPromptRawIdx = rawItems.findIndex(
(i) => i.title === "System prompt",
);
assert.equal(
rawItems[systemPromptRawIdx].turnId ?? null,
null,
"system-prompt item must have turnId=null to avoid turn-bucket grouping",
);
});
test("observer feed renders system-prompt before prompt-context in display order (multi-turn)", () => {
// On subsequent turns, session/new does not re-fire. The system-prompt item
// injected into turn-1 must not re-appear in turn-2's prompt segment.
const events = [
{
seq: 1,
timestamp: "2026-07-01T10:00:00.000Z",
kind: "turn_started",
agentIndex: 0,
channelId: "ch-1",
sessionId: null,
turnId: "turn-1",
payload: { source: "channel", triggeringEventIds: [] },
},
{
seq: 2,
timestamp: "2026-07-01T10:00:00.100Z",
kind: "acp_write",
agentIndex: 0,
channelId: "ch-1",
sessionId: null,
turnId: "turn-1",
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/new",
params: {
systemPrompt: "[Base]\nYou are helpful.\n\n[System]\nObserver.",
},
},
},
{
seq: 3,
timestamp: "2026-07-01T10:00:00.200Z",
kind: "session_resolved",
agentIndex: 0,
channelId: "ch-1",
sessionId: "sess-1",
turnId: "turn-1",
payload: { sessionId: "sess-1", isNewSession: true },
},
{
seq: 4,
timestamp: "2026-07-01T10:00:01.000Z",
kind: "acp_write",
agentIndex: 0,
channelId: "ch-1",
sessionId: "sess-1",
turnId: "turn-1",
payload: {
jsonrpc: "2.0",
id: 2,
method: "session/prompt",
params: {
sessionId: "sess-1",
prompt: [
{
type: "text",
text: `[Buzz event: @mention]\nEvent ID: ${"a".repeat(64)}\nFrom: x (hex: ${"b".repeat(64)})\nContent: turn 1`,
},
{ type: "text", text: "[Thread context]\nEmpty." },
],
},
},
},
{
seq: 5,
timestamp: "2026-07-01T10:05:00.000Z",
kind: "turn_started",
agentIndex: 0,
channelId: "ch-1",
sessionId: "sess-1",
turnId: "turn-2",
payload: { source: "channel", triggeringEventIds: [] },
},
{
seq: 6,
timestamp: "2026-07-01T10:05:01.000Z",
kind: "acp_write",
agentIndex: 0,
channelId: "ch-1",
sessionId: "sess-1",
turnId: "turn-2",
payload: {
jsonrpc: "2.0",
id: 3,
method: "session/prompt",
params: {
sessionId: "sess-1",
prompt: [
{
type: "text",
text: `[Buzz event: @mention]\nEvent ID: ${"c".repeat(64)}\nFrom: x (hex: ${"d".repeat(64)})\nContent: turn 2`,
},
{ type: "text", text: "[Thread context]\nOne prior message." },
],
},
},
},
];
const rawItems = buildTranscript(events);
const displayItems = flattenDisplayBlocks(
buildTranscriptDisplayBlocks(rawItems),
);
const systemPromptIdx = displayItems.findIndex(
(i) => i.title === "System prompt",
);
// Both turns produce a Prompt context — grab the first one (turn-1).
const firstPromptContextIdx = displayItems.findIndex(
(i) => i.title === "Prompt context",
);
assert.ok(systemPromptIdx !== -1, "expected a System prompt item");
assert.ok(
firstPromptContextIdx !== -1,
"expected at least one Prompt context item",
);
assert.ok(
systemPromptIdx < firstPromptContextIdx,
`expected System prompt (idx ${systemPromptIdx}) before first Prompt context (idx ${firstPromptContextIdx}) in display order`,
);
const systemPromptRawIdx = rawItems.findIndex(
(i) => i.title === "System prompt",
);
assert.equal(
rawItems[systemPromptRawIdx].turnId ?? null,
null,
"system-prompt item must have turnId=null",
);
});
@@ -35,6 +35,16 @@ export type TranscriptState = {
activeMessageKey: Map<string, string>;
sealedKeys: Set<string>;
triggeringEventIdsByTurn: Map<string, string[]>;
/**
* Maps JSON-RPC request id → { itemId, optionNames }.
* Populated when a `session/request_permission` request is ingested so the
* matching response (which carries the same JSON-RPC id, no `method`) can
* correlate and append the outcome to the lifecycle item.
*/
pendingPermissions: Map<
string,
{ itemId: string; optionNames: Map<string, string> }
>;
continuationSeq: number;
latestSessionId: string | null;
};
@@ -46,6 +56,7 @@ export function createEmptyTranscriptState(): TranscriptState {
activeMessageKey: new Map(),
sealedKeys: new Set(),
triggeringEventIdsByTurn: new Map(),
pendingPermissions: new Map(),
continuationSeq: 0,
latestSessionId: null,
};
@@ -62,6 +73,10 @@ type TranscriptDraft = {
activeMessageKey: Map<string, string>;
sealedKeys: Set<string>;
triggeringEventIdsByTurn: Map<string, string[]>;
pendingPermissions: Map<
string,
{ itemId: string; optionNames: Map<string, string> }
>;
continuationSeq: number;
latestSessionId: string | null;
changed: boolean;
@@ -74,6 +89,7 @@ function draftFrom(state: TranscriptState): TranscriptDraft {
activeMessageKey: state.activeMessageKey,
sealedKeys: state.sealedKeys,
triggeringEventIdsByTurn: state.triggeringEventIdsByTurn,
pendingPermissions: state.pendingPermissions,
continuationSeq: state.continuationSeq,
latestSessionId: state.latestSessionId,
changed: false,
@@ -178,9 +194,24 @@ function describePermissionRequest(payload: Record<string, unknown>) {
if (title !== "Permission requested") detail.push(title);
if (toolCallId) detail.push(`Tool call: ${toolCallId}`);
if (options.length > 0) detail.push(`Options: ${options.join(", ")}`);
// Build optionId → kind map for outcome labeling on the response.
const optionNames = new Map<string, string>();
if (Array.isArray(params.options)) {
for (const option of params.options) {
const record = asRecord(option);
const optionId = asString(record.optionId);
const kind = asString(record.kind);
if (optionId && kind) {
optionNames.set(optionId, kind);
}
}
}
return {
title,
text: detail.join("\n"),
optionNames,
descriptor: {
renderClass: "permission" as const,
label: "Permission requested",
@@ -195,6 +226,41 @@ function describePermissionRequest(payload: Record<string, unknown>) {
};
}
/**
* Format a human-readable outcome label from a permission response.
* kind values from ACP: allow_once, allow_always, reject_once, reject_always.
* "reject_*" kinds are denials; anything else that is selected is an approval.
*/
function describePermissionOutcome(
outcome: string,
optionId: string | null,
optionNames: Map<string, string>,
): string {
if (outcome === "cancelled") {
return "Cancelled";
}
if (outcome === "selected" && optionId) {
const kind = optionNames.get(optionId) ?? optionId;
const isDenial = kind.startsWith("reject");
const verb = isDenial ? "Denied" : "Approved";
return `${verb} (${kind})`;
}
return outcome;
}
/**
* Stable map key for a JSON-RPC id, which may be a string or a finite number
* per the spec. Using JSON.stringify avoids collisions between the number 1 and
* the string "1". Returns null for null, undefined, or non-id values (objects,
* booleans) so callers can gate on presence without a separate type check.
*/
function jsonRpcId(value: unknown): string | null {
if (typeof value === "string") return JSON.stringify(value);
if (typeof value === "number" && Number.isFinite(value))
return JSON.stringify(value);
return null;
}
function describeFreeformStatus(payload: Record<string, unknown>) {
const statusType = asString(payload.type) ?? asString(payload.status);
const title =
@@ -373,6 +439,52 @@ function upsertLifecycleItem(
});
}
// Like upsertLifecycleItem but REPLACES the text on update instead of
// appending. Used for coalescing fields (e.g. usage_update) where only the
// latest value is meaningful — repeated updates must not accumulate.
function replaceLifecycleItem(
d: TranscriptDraft,
id: string,
renderClass: Extract<
AgentActivityRenderClass,
"status" | "permission" | "error"
>,
title: string,
text: string,
timestamp: string,
ctx: TranscriptItemContext,
acpSource?: string,
) {
const existing = d.itemsById.get(id);
if (existing?.type === "lifecycle") {
replaceItem(d, id, {
...existing,
renderClass,
title,
text,
channelId: ctx.channelId,
turnId: ctx.turnId ?? existing.turnId,
sessionId: ctx.sessionId ?? existing.sessionId,
acpSource: acpSource ?? existing.acpSource,
});
return;
}
sealOpenMessages(d);
pushItem(d, {
id,
type: "lifecycle",
renderClass,
title,
text,
timestamp,
channelId: ctx.channelId,
turnId: ctx.turnId,
sessionId: ctx.sessionId,
acpSource,
});
}
function upsertPlan(
d: TranscriptDraft,
id: string,
@@ -677,9 +789,10 @@ export function processTranscriptEvent(
if (method === "session/request_permission") {
const request = describePermissionRequest(payload);
const itemId = `permission:${ch}:${event.turnId ?? event.seq}`;
upsertLifecycleItem(
d,
`permission:${ch}:${event.turnId ?? event.seq}`,
itemId,
"permission",
"Permission requested",
request.text,
@@ -688,6 +801,40 @@ export function processTranscriptEvent(
"permission_request",
request.descriptor,
);
// Index by JSON-RPC id so the response (acp_write with result.outcome,
// no method) can correlate by id rather than by turn/seq.
const requestId = jsonRpcId(payload.id);
if (requestId) {
d.pendingPermissions = new Map(d.pendingPermissions);
d.pendingPermissions.set(requestId, {
itemId,
optionNames: request.optionNames,
});
}
} else if (event.kind === "acp_write" && !method) {
// Permission response: {"id": <same as request>, "result": {"outcome": {...}}}
const responseId = jsonRpcId(payload.id);
const result = asRecord(asRecord(payload.result).outcome);
const outcomeKind = asString(result.outcome);
const pending = responseId ? d.pendingPermissions.get(responseId) : null;
if (pending && outcomeKind && responseId) {
const optionId = asString(result.optionId) ?? null;
const outcomeText = describePermissionOutcome(
outcomeKind,
optionId,
pending.optionNames,
);
const existing = d.itemsById.get(pending.itemId);
if (existing?.type === "lifecycle") {
replaceItem(d, pending.itemId, {
...existing,
outcome: outcomeText,
});
// Remove from pending map — the outcome is now recorded.
d.pendingPermissions = new Map(d.pendingPermissions);
d.pendingPermissions.delete(responseId);
}
}
} else if (event.kind === "acp_write" && method === "session/prompt") {
const promptText = extractPromptText(payload);
if (promptText) {
@@ -722,9 +869,10 @@ export function processTranscriptEvent(
} else if (event.kind === "acp_write" && method === "session/new") {
// The base + persona prompts ride session/new's systemPrompt, framed by
// the harness as [Base]/[System]. Surface them as one "System prompt" item
// keyed per channel-session — the frame carries no session id (it predates
// session creation), and session/new fires once per channel-session, so a
// re-created session correctly replaces the prior item.
// keyed per channel-session — session/new fires once per channel-session,
// so a re-created session correctly replaces the prior item.
// turnId: null keeps it out of turn buckets; acpSource "session/new" lets
// the display grouper inject it before prompt-context in the prompt segment.
const params = asRecord(payload.params);
const systemPrompt = asString(params.systemPrompt);
if (systemPrompt) {
@@ -736,7 +884,8 @@ export function processTranscriptEvent(
"System prompt",
sections,
event.timestamp,
ctx,
{ ...ctx, turnId: null },
"session/new",
);
}
}
@@ -870,6 +1019,83 @@ export function processTranscriptEvent(
updateType,
`plan-update:${ch}:${turnKey}:${event.seq}`,
);
} else if (updateType === "current_mode_update") {
const mode = asString(update.currentModeId) ?? "";
if (mode) {
upsertLifecycleItem(
d,
`mode:${ch}:${turnKey}`,
"status",
"Mode",
mode,
event.timestamp,
ctx,
updateType,
);
}
} else if (updateType === "usage_update") {
const used = typeof update.used === "number" ? update.used : null;
const size = typeof update.size === "number" ? update.size : null;
if (used !== null && size !== null) {
const costRecord = asRecord(update.cost);
const costAmount =
typeof costRecord.amount === "number" ? costRecord.amount : null;
const costCurrency = asString(costRecord.currency);
const costStr =
costAmount !== null && costCurrency
? ` ($${costAmount.toFixed(4)} ${costCurrency})`
: "";
replaceLifecycleItem(
d,
`usage:${ch}:${turnKey}`,
"status",
"Usage",
`Tokens: ${used}/${size}${costStr}`,
event.timestamp,
ctx,
updateType,
);
}
} else if (updateType === "available_commands_update") {
const cmds = Array.isArray(update.availableCommands)
? update.availableCommands
: [];
upsertLifecycleItem(
d,
`commands:${ch}:${turnKey}`,
"status",
"Commands",
`Commands available: ${cmds.length}`,
event.timestamp,
ctx,
updateType,
);
} else if (updateType === "config_option_update") {
const opts = Array.isArray(update.configOptions)
? (update.configOptions as Array<Record<string, unknown>>)
: [];
const optText = opts
.map((o) => {
const name = asString(o.name) ?? asString(o.id) ?? "?";
const val =
asString(o.currentValue) ??
(typeof o.value === "boolean" ? String(o.value) : null) ??
"";
return val ? `${name} = ${val}` : name;
})
.join(", ");
if (optText) {
upsertLifecycleItem(
d,
`config:${ch}:${turnKey}`,
"status",
"Config",
optText,
event.timestamp,
ctx,
updateType,
);
}
} else {
// Free-form observer status records are not part of the ACP session/update
// union. Surface only explicit title/text payloads; leave all other
@@ -918,6 +1144,7 @@ export function processTranscriptEvent(
activeMessageKey: d.activeMessageKey,
sealedKeys: d.sealedKeys,
triggeringEventIdsByTurn: d.triggeringEventIdsByTurn,
pendingPermissions: d.pendingPermissions,
continuationSeq: d.continuationSeq,
latestSessionId: d.latestSessionId,
};
@@ -8,6 +8,7 @@ export type TranscriptTurnSegment =
| {
kind: "prompt";
user: Extract<TranscriptItem, { type: "message" }>;
systemPrompt: Extract<TranscriptItem, { type: "metadata" }> | null;
context: Extract<TranscriptItem, { type: "metadata" }> | null;
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
};
@@ -43,6 +44,12 @@ function isPromptContext(
);
}
function isSystemPrompt(
item: TranscriptItem,
): item is Extract<TranscriptItem, { type: "metadata" }> {
return item.type === "metadata" && item.acpSource === "session/new";
}
function isSetupLifecycle(
item: TranscriptItem,
): item is Extract<TranscriptItem, { type: "lifecycle" }> {
@@ -57,7 +64,13 @@ type TurnBucket = {
items: TranscriptItem[];
};
function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] {
function classifyTurnItems(
items: TranscriptItem[],
externalSystemPrompt: Extract<
TranscriptItem,
{ type: "metadata" }
> | null = null,
): TranscriptTurnSegment[] {
const userPrompt = items.find(isUserPrompt) ?? null;
const setupLifecycle = items.filter(isSetupLifecycle);
const promptContext = items.find(isPromptContext) ?? null;
@@ -79,6 +92,7 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] {
{
kind: "prompt",
user: userPrompt,
systemPrompt: externalSystemPrompt,
context: promptContext,
setup: setupLifecycle,
},
@@ -173,6 +187,11 @@ function getRenderClass(item: TranscriptItem) {
* 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.
*
* System-prompt items (acpSource "session/new") are per-channel singles with
* turnId=null. They are injected into the prompt segment of the first turn
* that follows them in stream order — placing System prompt between the user
* message bubble and the Prompt context sections in the rendered output.
*/
export function buildTranscriptDisplayBlocks(
items: TranscriptItem[],
@@ -185,10 +204,22 @@ export function buildTranscriptDisplayBlocks(
{ kind: "single"; item: TranscriptItem } | { kind: "turn"; turnId: string }
> = [];
// System-prompt items (turnId=null, acpSource "session/new") accumulate here
// until consumed by the first turn that follows them in stream order.
let pendingSystemPrompt: Extract<
TranscriptItem,
{ type: "metadata" }
> | null = null;
for (const item of items) {
const turnId = item.turnId;
if (!turnId) {
displayOrder.push({ kind: "single", item });
if (isSystemPrompt(item)) {
// Hold system-prompt for injection into the next turn's prompt segment.
pendingSystemPrompt = item;
} else {
displayOrder.push({ kind: "single", item });
}
continue;
}
@@ -201,6 +232,9 @@ export function buildTranscriptDisplayBlocks(
bucket.items.push(item);
}
// Track per-turn injected system-prompt so multi-turn streams don't re-inject.
const consumedSystemPrompts = new Set<string>();
for (const entry of displayOrder) {
if (entry.kind === "single") {
blocks.push({ kind: "single", item: entry.item });
@@ -212,7 +246,22 @@ export function buildTranscriptDisplayBlocks(
continue;
}
const segments = classifyTurnItems(bucket.items);
// Inject system-prompt into the first turn that has a user-prompt item.
// On subsequent turns, system-prompt stays null (session/new doesn't re-fire).
let systemPromptForTurn: Extract<
TranscriptItem,
{ type: "metadata" }
> | null = null;
if (
pendingSystemPrompt &&
!consumedSystemPrompts.has(pendingSystemPrompt.id) &&
bucket.items.some(isUserPrompt)
) {
systemPromptForTurn = pendingSystemPrompt;
consumedSystemPrompts.add(pendingSystemPrompt.id);
}
const segments = classifyTurnItems(bucket.items, systemPromptForTurn);
if (segments.length > 0) {
blocks.push({
kind: "turn",
@@ -222,6 +271,16 @@ export function buildTranscriptDisplayBlocks(
}
}
// If system-prompt was never consumed (no session/prompt followed — e.g.
// session/new arrived without a subsequent turn, or the stream is still
// incomplete), emit it as a standalone single so it remains visible.
if (
pendingSystemPrompt &&
!consumedSystemPrompts.has(pendingSystemPrompt.id)
) {
blocks.push({ kind: "single", item: pendingSystemPrompt });
}
return blocks;
}
@@ -243,6 +302,9 @@ export function flattenDisplayBlocks(
} else if (segment.kind === "prompt") {
result.push(segment.user);
result.push(...segment.setup);
if (segment.systemPrompt) {
result.push(segment.systemPrompt);
}
if (segment.context) {
result.push(segment.context);
}
@@ -4,6 +4,7 @@ import test from "node:test";
import {
getActivityHeadline,
isMeaningfulItem,
isSpineItem,
} from "./agentSessionTranscriptPresentation.ts";
const baseTimestamp = "2026-06-14T19:00:00.000Z";
@@ -47,7 +48,7 @@ test("getActivityHeadline formats tool titles and assistant text", () => {
assert.equal(getActivityHeadline(makeMessage({ text: " " })), "Responding");
});
test("isMeaningfulItem ignores lifecycle noise and metadata", () => {
test("isMeaningfulItem ignores lifecycle noise and raw JSON-RPC metadata", () => {
assert.equal(
isMeaningfulItem({
id: "life:1",
@@ -57,16 +58,45 @@ test("isMeaningfulItem ignores lifecycle noise and metadata", () => {
timestamp: baseTimestamp,
}),
false,
"turn started is lifecycle noise → not meaningful",
);
assert.equal(
isMeaningfulItem({
id: "meta:1",
id: "meta:raw",
type: "metadata",
renderClass: "raw-rail",
title: "Raw ACP payload",
sections: [],
timestamp: baseTimestamp,
acpSource: "raw_json_rpc",
}),
false,
"raw_json_rpc metadata is infrastructure noise → not meaningful",
);
assert.equal(
isMeaningfulItem({
id: "meta:ctx",
type: "metadata",
renderClass: "raw-rail",
title: "Prompt context",
sections: [],
timestamp: baseTimestamp,
acpSource: "session/prompt:context",
}),
false,
true,
"prompt context metadata is semantic → meaningful",
);
assert.equal(
isMeaningfulItem({
id: "meta:sys",
type: "metadata",
renderClass: "raw-rail",
title: "System prompt",
sections: [],
timestamp: baseTimestamp,
}),
true,
"system prompt metadata (no acpSource) is semantic → meaningful",
);
assert.equal(
isMeaningfulItem({
@@ -118,3 +148,110 @@ test("isMeaningfulItem ignores suppressed tools", () => {
false,
);
});
const metadataSystemPrompt = {
id: "meta:sys",
type: "metadata",
renderClass: "raw-rail",
title: "System prompt",
sections: [],
timestamp: baseTimestamp,
};
const metadataPromptContext = {
id: "meta:ctx",
type: "metadata",
renderClass: "raw-rail",
title: "Prompt context",
sections: [],
timestamp: baseTimestamp,
acpSource: "session/prompt:context",
};
test("isSpineItem excludes metadata items (reads recede)", () => {
assert.equal(
isSpineItem(metadataSystemPrompt),
false,
"system prompt metadata is not spine work",
);
assert.equal(
isSpineItem(metadataPromptContext),
false,
"prompt context metadata is not spine work",
);
assert.equal(isSpineItem(makeTool()), true, "tool items are spine work");
assert.equal(
isSpineItem(makeMessage()),
true,
"message items are spine work",
);
assert.equal(
isSpineItem({
id: "meta:raw",
type: "metadata",
renderClass: "raw-rail",
title: "Raw ACP payload",
sections: [],
timestamp: baseTimestamp,
acpSource: "raw_json_rpc",
}),
false,
"raw_json_rpc is already filtered by isMeaningfulItem → not spine",
);
});
test("isSpineItem: metadata still meaningful via isMeaningfulItem (feed visibility unchanged)", () => {
// isMeaningfulItem must still return true for non-raw metadata — the feed
// renders metadata items independently of isSpineItem.
assert.equal(isMeaningfulItem(metadataSystemPrompt), true);
assert.equal(isMeaningfulItem(metadataPromptContext), true);
});
test("two-tier headline: metadata excluded when spine work is present", () => {
// Simulate what BotActivityBar does: when any spine item exists, only spine
// items are eligible for the headline rotation.
const transcript = [metadataSystemPrompt, makeTool()];
const hasSpine = transcript.some(isSpineItem);
const passFilter = hasSpine ? isSpineItem : isMeaningfulItem;
const headlines = transcript
.filter(passFilter)
.map((item) => getActivityHeadline(item))
.filter(Boolean);
assert.ok(
!headlines.includes("System prompt"),
"System prompt should not headline when spine work exists",
);
assert.ok(
headlines.some((h) => h?.includes("Send Message")),
"Tool headline should appear",
);
});
test("two-tier headline: metadata headlines when it is the only activity (session start / idle)", () => {
// When no spine items exist, fall back to isMeaningfulItem so the bar is not
// empty at session start.
const transcript = [metadataSystemPrompt, metadataPromptContext];
const hasSpine = transcript.some(isSpineItem);
const passFilter = hasSpine ? isSpineItem : isMeaningfulItem;
const headlines = transcript
.filter(passFilter)
.map((item) => getActivityHeadline(item))
.filter(Boolean);
assert.ok(
headlines.includes("System prompt"),
"System prompt should headline when no spine work exists",
);
assert.ok(
headlines.includes("Prompt context"),
"Prompt context should headline when no spine work exists",
);
});
// Render-tier tests (raw_json_rpc → <pre>, non-raw → polished accordion) live in
// activityRenderClasses/RawRailActivity.render.test.mjs — they use
// renderToStaticMarkup and would fail if the isRawPayload branch in
// RawRailActivity were removed.
@@ -47,7 +47,7 @@ function isLifecycleNoise(
return LIFECYCLE_NOISE.has(item.title.toLowerCase());
}
/** Whether an item should contribute to the "Now" summary and headline scan. */
/** Whether an item should contribute to the headline scan (noise gate). */
export function isMeaningfulItem(item: TranscriptItem): boolean {
if (item.type === "tool" && item.renderClass === "suppressed") {
return false;
@@ -56,7 +56,26 @@ export function isMeaningfulItem(item: TranscriptItem): boolean {
return !isLifecycleNoise(item);
}
if (item.type === "metadata") {
return false;
// Raw JSON-RPC frames ("Raw ACP payload") are infrastructure noise; all
// other metadata items (system prompt, prompt context) are semantically
// meaningful and visible in the feed.
return item.acpSource !== "raw_json_rpc";
}
return true;
}
/**
* Whether an item is "spine" work — eligible to headline over setup/context.
* Tools, messages, thoughts, plans, and meaningful lifecycle events qualify.
* Metadata items (system prompt, prompt context) are reads that should recede
* when real work is present; they are NOT spine items.
*
* Used by BotActivityBar for the two-tier headline scan:
* 1. Collect spine headlines first.
* 2. If none found, fall back to including metadata so the bar isn't empty at
* session start / idle.
*/
export function isSpineItem(item: TranscriptItem): boolean {
if (!isMeaningfulItem(item)) return false;
return item.type !== "metadata";
}
@@ -103,6 +103,8 @@ export type TranscriptItem =
renderClass: "status" | "permission" | "error";
title: string;
text: string;
/** Resolved outcome for permission items (e.g. "Approved (allow_once)", "Denied (reject_once)", "Cancelled"). */
outcome?: string;
timestamp: string;
descriptor?: AgentActivityDescriptor;
acpSource?: TranscriptAcpSource;
@@ -5,6 +5,7 @@ import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents";
import {
getActivityHeadline,
isMeaningfulItem,
isSpineItem,
} from "@/features/agents/ui/agentSessionTranscriptPresentation";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ManagedAgent } from "@/shared/api/types";
@@ -67,9 +68,15 @@ export function BotActivityComposerAction({
? transcript.filter((item) => item.channelId === channelId)
: transcript;
// Two-tier scan: spine items first (reads recede when real work is present).
// If no spine headlines are found (session start / idle), fall back to all
// meaningful items so the bar isn't left empty.
const passFilter: (item: (typeof scopedTranscript)[number]) => boolean =
scopedTranscript.some(isSpineItem) ? isSpineItem : isMeaningfulItem;
for (let i = scopedTranscript.length - 1; i >= 0; i--) {
const item = scopedTranscript[i];
if (!isMeaningfulItem(item)) {
if (!passFilter(item)) {
continue;
}
const headline = getActivityHeadline(item);
+17
View File
@@ -8,6 +8,7 @@ import { relayClient } from "@/shared/api/relayClient";
import type { ConnectionState } from "@/shared/api/relayClientShared";
import type { RelayEvent } from "@/shared/api/types";
import { syncAgentTurnsFromEvents } from "@/features/agents/activeAgentTurnsStore";
import { injectObserverEventsForE2E } from "@/features/agents/observerRelayStore";
import {
CUSTOM_EMOJI_SET_D_TAG,
KIND_EMOJI_SET,
@@ -663,6 +664,19 @@ declare global {
channelId: string;
turnId: string;
}) => void;
__BUZZ_E2E_SEED_OBSERVER_EVENTS__?: (input: {
agentPubkey: string;
events: Array<{
seq: number;
timestamp: string;
kind: string;
agentIndex: number | null;
channelId: string | null;
sessionId: string | null;
turnId: string | null;
payload: unknown;
}>;
}) => void;
__BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: {
clientId: string;
contexts: Record<string, number>;
@@ -6942,6 +6956,9 @@ export function maybeInstallE2eTauriMocks() {
},
]);
};
window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ = ({ agentPubkey, events }) => {
injectObserverEventsForE2E(agentPubkey, events);
};
const meshNodeStatus = (
state: "off" | "running",
mode: "serve" | "client" | null,
@@ -0,0 +1,714 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const SHOTS = "test-results/observer-feed";
// A running managed agent whose pubkey maps to the "agents" channel.
// Using tyler's pubkey so the mock bridge already knows this identity.
const OBSERVER_AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
const CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; // #agents
const NOW = new Date("2025-06-15T12:00:00Z").toISOString();
const MANAGED_AGENTS = [
{
pubkey: OBSERVER_AGENT_PUBKEY,
name: "Observer Agent",
status: "running" as const,
channelNames: ["agents"],
},
];
// Helper: wait until the seed hook is available in the page.
async function waitForSeedHook(page: import("@playwright/test").Page) {
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function",
null,
{ timeout: 10_000 },
);
}
// Helper: open the observer feed panel by navigating to #agents, clicking the
// agent avatar to open the profile panel, then clicking "View activity".
async function openObserverFeedPanel(
page: import("@playwright/test").Page,
agentPubkey: string,
) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForSeedHook(page);
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
// Click any message row button to open the profile panel.
const messageRow = page
.getByTestId("message-row")
.filter({ has: page.getByText("Observer Agent", { exact: false }) });
await expect(messageRow.first()).toBeVisible({ timeout: 8_000 });
await messageRow.first().getByRole("button").first().click();
const profilePanel = page.getByTestId("user-profile-panel");
await expect(profilePanel).toBeVisible({ timeout: 10_000 });
// Click "View activity" to open the observer feed panel.
const activityBtn = page.getByTestId(
`user-profile-view-activity-${agentPubkey}`,
);
await expect(activityBtn).toBeVisible({ timeout: 5_000 });
await activityBtn.click();
const feedPanel = page.getByTestId("agent-session-thread-panel");
await expect(feedPanel).toBeVisible({ timeout: 10_000 });
return feedPanel;
}
// Helper: seed observer events into the store and wait for the panel to update.
async function seedObserverEvents(
page: import("@playwright/test").Page,
agentPubkey: string,
events: Array<{
seq: number;
timestamp: string;
kind: string;
agentIndex: number | null;
channelId: string | null;
sessionId: string | null;
turnId: string | null;
payload: unknown;
}>,
) {
await page.evaluate(
({ pubkey, evts }) => {
window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({
agentPubkey: pubkey,
events: evts,
});
},
{ pubkey: agentPubkey, evts: events },
);
// Let React re-render after the store update.
await page.waitForTimeout(300);
}
async function settleAnimations(panel: import("@playwright/test").Locator) {
await panel.evaluate((el) =>
Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)),
);
}
test.describe("observer feed screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
console.error("CONSOLE ERROR:", msg.text().slice(0, 500));
}
});
});
test("01 — prompt context inline (collapsed-but-labeled)", async ({
page,
}) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
// session/prompt event: the per-turn prompt context that #1381 stopped
// rendering inline. The payload contains sections that parsePromptText
// extracts and the transcript renders as a collapsed PromptContextInline.
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/prompt",
params: {
prompt: [
{
type: "text",
text: "[Buzz event: Kind 9]\nContent: @Observer Agent help me debug this",
},
{
type: "text",
text: "[Thread context]\nThis is the thread history with 3 prior messages.",
},
{
type: "text",
text: "[Channel context]\nYou are in #agents, a channel for AI coordination.",
},
],
},
},
},
]);
// The inline context element should be visible with collapsed sections.
await expect(
feedPanel.getByTestId("transcript-prompt-context-inline"),
).toBeVisible({ timeout: 5_000 });
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/01-prompt-context-inline.png`,
});
});
test("02 — system prompt title restored", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
// session/new event without a subsequent session/prompt: the system-prompt
// item is never consumed by a turn bucket, so the grouper emits it as a
// standalone single rendered by RawRailActivity with the "System prompt"
// title. (This is the isolated test; see shot 11 for the full turn bundle.)
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: null,
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/new",
params: {
systemPrompt:
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.",
},
},
},
]);
// The system prompt rail item should show the restored title.
await expect(feedPanel.getByText("System prompt")).toBeVisible({
timeout: 5_000,
});
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/02-system-prompt-title-restored.png`,
});
});
test("03 — permission outcome (approved)", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
// Permission request followed by a selected (approved) response,
// correlated by JSON-RPC id (fix #3: was broken for numeric ids before).
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
// Request
{
seq: 1,
timestamp: NOW,
kind: "acp_read",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: 42,
method: "session/request_permission",
params: {
title: "Read file system",
message: "Agent wants to read ~/.config/goose/config.yaml",
options: [
{ optionId: "opt-allow", kind: "allow_once", name: "Allow once" },
{ optionId: "opt-deny", kind: "reject_once", name: "Deny" },
],
},
},
},
// Response (numeric id 42 — the exact case fix #3 restores)
{
seq: 2,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: 42,
result: {
outcome: {
outcome: "selected",
optionId: "opt-allow",
},
},
},
},
]);
// The permission row should show the "Approved (allow_once)" outcome.
await expect(feedPanel.getByText(/Approved.*allow_once/)).toBeVisible({
timeout: 5_000,
});
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/03-permission-approved.png`,
});
});
test("04 — permission outcome (cancelled)", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
// Request
{
seq: 1,
timestamp: NOW,
kind: "acp_read",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: "perm-cancelled-1",
method: "session/request_permission",
params: {
title: "Write to output file",
options: [
{ optionId: "opt-yes", kind: "allow_once", name: "Allow" },
],
},
},
},
// Cancelled response
{
seq: 2,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: "perm-cancelled-1",
result: {
outcome: {
outcome: "cancelled",
},
},
},
},
]);
await expect(feedPanel.getByText("Cancelled")).toBeVisible({
timeout: 5_000,
});
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/04-permission-cancelled.png`,
});
});
test("05 — prompt context inline (sections expanded)", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/prompt",
params: {
prompt: [
{
type: "text",
text: "[Buzz event: Kind 9]\nContent: @Observer Agent help me debug this",
},
{
type: "text",
text: "[Thread context]\nThis is the thread history with 3 prior messages.",
},
{
type: "text",
text: "[Channel context]\nYou are in #agents, a channel for AI coordination.",
},
],
},
},
},
]);
await expect(
feedPanel.getByTestId("transcript-prompt-context-inline"),
).toBeVisible({ timeout: 5_000 });
// Click each section accordion button to expand it.
const sectionButtons = feedPanel
.getByTestId("transcript-prompt-context-sections")
.getByRole("button");
for (const btn of await sectionButtons.all()) {
await btn.click();
}
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/05-prompt-context-expanded.png`,
});
});
test("06 — system prompt sections expanded", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: null,
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/new",
params: {
systemPrompt:
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.",
},
},
},
]);
await expect(feedPanel.getByText("System prompt")).toBeVisible({
timeout: 5_000,
});
// Open the outer ActivityRow <details> to reveal the section content,
// then click each section accordion button to expand it (mirrors shot 05 —
// system-prompt sections now render as React button accordions, not native
// <details> elements).
await feedPanel.getByTestId("transcript-metadata-item").evaluate((el) => {
if (el.tagName === "DETAILS") (el as HTMLDetailsElement).open = true;
for (const details of el.querySelectorAll("details")) {
details.open = true;
}
});
const sectionButtons = feedPanel
.getByTestId("transcript-metadata-item")
.getByTestId("transcript-prompt-context-sections")
.getByRole("button");
const allSectionButtons = await sectionButtons.all();
expect(allSectionButtons.length).toBeGreaterThan(0);
for (const btn of allSectionButtons) {
await btn.click();
}
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/06-system-prompt-expanded.png`,
});
});
test("07 — current_mode_update lifecycle status line", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_read",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-007",
turnId: "turn-007",
payload: {
method: "session/update",
params: {
sessionId: "session-007",
update: {
sessionUpdate: "current_mode_update",
currentModeId: "plan",
},
},
},
},
]);
await expect(feedPanel.getByText("Mode")).toBeVisible({ timeout: 5_000 });
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/07-current-mode-update.png`,
});
});
test("08 — usage_update lifecycle status line (coalesced)", async ({
page,
}) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
// Two usage frames — only the last should be visible.
{
seq: 1,
timestamp: NOW,
kind: "acp_read",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-008",
turnId: "turn-008",
payload: {
method: "session/update",
params: {
sessionId: "session-008",
update: {
sessionUpdate: "usage_update",
used: 1200,
size: 8192,
},
},
},
},
{
seq: 2,
timestamp: NOW,
kind: "acp_read",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-008",
turnId: "turn-008",
payload: {
method: "session/update",
params: {
sessionId: "session-008",
update: {
sessionUpdate: "usage_update",
used: 3450,
size: 8192,
cost: { amount: 0.0018, currency: "USD" },
},
},
},
},
]);
await expect(feedPanel.getByText("Usage")).toBeVisible({ timeout: 5_000 });
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/08-usage-update-coalesced.png`,
});
});
test("09 — available_commands_update lifecycle status line", async ({
page,
}) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_read",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-009",
turnId: "turn-009",
payload: {
method: "session/update",
params: {
sessionId: "session-009",
update: {
sessionUpdate: "available_commands_update",
availableCommands: [
{
name: "create_plan",
description: "Create a structured plan for the task",
},
{
name: "research_codebase",
description: "Research and understand the codebase",
},
{
name: "execute_steps",
description: "Execute the planned steps",
},
],
},
},
},
},
]);
await expect(feedPanel.getByText("Commands")).toBeVisible({
timeout: 5_000,
});
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/09-available-commands-update.png`,
});
});
test("10 — config_option_update lifecycle status line", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "acp_read",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-010",
turnId: "turn-010",
payload: {
method: "session/update",
params: {
sessionId: "session-010",
update: {
sessionUpdate: "config_option_update",
configOptions: [
{
id: "model",
name: "Model",
type: "select",
currentValue: "gpt-4o",
},
{
id: "mode",
name: "Mode",
type: "select",
currentValue: "auto",
},
],
},
},
},
},
]);
await expect(feedPanel.getByText("Config")).toBeVisible({ timeout: 5_000 });
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/10-config-option-update.png`,
});
});
test("11 — first-turn ordering: user bubble → System prompt → Prompt context", async ({
page,
}) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
// Full realistic pool.rs first-turn wire sequence:
// turn_started → session/new → session_resolved → session/prompt
// Verifies the ordering fix: System prompt renders between the user message
// bubble and the Prompt context inline block (not after it).
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
timestamp: NOW,
kind: "turn_started",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: null,
turnId: "turn-001",
payload: { source: "channel", triggeringEventIds: [] },
},
{
seq: 2,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: null,
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/new",
params: {
systemPrompt:
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.",
},
},
},
{
seq: 3,
timestamp: NOW,
kind: "session_resolved",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: { sessionId: "session-001", isNewSession: true },
},
{
seq: 4,
timestamp: NOW,
kind: "acp_write",
agentIndex: 0,
channelId: CHANNEL_ID,
sessionId: "session-001",
turnId: "turn-001",
payload: {
jsonrpc: "2.0",
id: 2,
method: "session/prompt",
params: {
prompt: [
{
type: "text",
text: "[Buzz event: Kind 9]\nContent: @Observer Agent help me debug this",
},
{
type: "text",
text: "[Thread context]\nThis is the thread history with 3 prior messages.",
},
],
},
},
},
]);
// User message bubble should be visible (anchors the prompt bundle).
await expect(feedPanel.getByTestId("transcript-prompt-bundle")).toBeVisible(
{ timeout: 5_000 },
);
// System prompt should appear inside the bundle, above prompt context.
await expect(feedPanel.getByText("System prompt")).toBeVisible({
timeout: 5_000,
});
await expect(feedPanel.getByText("Prompt context")).toBeVisible({
timeout: 5_000,
});
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/11-first-turn-ordering.png`,
});
});
});