diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 65b347f20..79f5c3b32 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 3bbe4e914..fa714392f 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -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. diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 6aa397edb..9dafffdd5 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -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; diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 9515be309..709d1a110 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -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 | null; profiles?: UserProfileLookup; setup: Extract[]; + systemPrompt: Extract | null; user: Extract; }) { return ( @@ -377,6 +379,7 @@ function TurnPromptBlock({ item={user} profiles={profiles} setup={setup} + systemPrompt={systemPrompt} /> ); @@ -387,111 +390,72 @@ function PromptUserMessage({ item, profiles, setup = [], + systemPrompt = null, }: { context?: Extract | null; item: Extract; profiles?: UserProfileLookup; setup?: Extract[]; + systemPrompt?: Extract | null; }) { - const [contextOpen, setContextOpen] = React.useState(false); - return ( <> } item={item} profiles={profiles} /> - + {systemPrompt && systemPrompt.sections.length > 0 ? ( + + ) : null} + {context && context.sections.length > 0 ? ( + + ) : null} ); } -function PromptContextSections({ - className, - sections, -}: { - className?: string; - sections: PromptSection[]; -}) { - return ( -
- {sections.map((section) => ( - - ))} -
- ); -} - -function PromptContextSectionAccordion({ - section, +function PromptContextInline({ + context, }: { - section: PromptSection; + context: Extract; }) { - const [open, setOpen] = React.useState(false); - const body = section.body.trim(); + const [dialogOpen, setDialogOpen] = React.useState(false); return ( -
- - -
+ + + + ); } @@ -499,33 +463,22 @@ function PromptContextDialog({ context, onOpenChange, open, - setup, }: { - context: Extract | null; + context: Extract; onOpenChange: (open: boolean) => void; open: boolean; - setup: Extract[]; }) { - if (!open || !context || context.sections.length === 0) { + if (!open || context.sections.length === 0) { return null; } - const setupText = formatPromptSetupSummary(setup); - return (
- Prompt context - {setupText ? ( -
- - {setupText} -
- ) : null} + {context.title}
-
@@ -535,28 +488,14 @@ function PromptContextDialog({ ); } -function formatPromptSetupSummary( - items: Extract[], -) { - 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 | null; - contextOpen?: boolean; items: Extract[]; 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 ? ( ) : null; } - const contextToggle = showContext ? ( - - - ) : null; - return (
- {showContext && showSetup ? contextToggle : null} - {!showContext && showSetup ? ( - - - {tooltipText} - - ) : null} - {showContext && !showSetup ? contextToggle : null} + + + {tooltipText} + {showTimestamp ? ( ) : null} diff --git a/desktop/src/features/agents/ui/PromptSectionAccordion.tsx b/desktop/src/features/agents/ui/PromptSectionAccordion.tsx new file mode 100644 index 000000000..4fb25f820 --- /dev/null +++ b/desktop/src/features/agents/ui/PromptSectionAccordion.tsx @@ -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 ( +
+ {sections.map((section) => ( + + ))} +
+ ); +} + +export function PromptSectionAccordion({ + section, +}: { + section: PromptSection; +}) { + const [open, setOpen] = React.useState(false); + const body = section.body.trim(); + + return ( +
+ +
+ ); +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx index 88cb3f055..da2b2f436 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx @@ -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 ; @@ -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 (
- - {props.item.title} - {props.item.text ? ( - · {props.item.text} + {/* Row 1: request */} +
+ + {props.item.title} + {requestLines ? ( + · {requestLines} + ) : null} +
+ {/* Row 2: options (muted sub-line) */} + {optionsLine ? ( +
{optionsLine}
+ ) : null} + {/* Row 3: decision — only when outcome is resolved */} + {outcome && tone ? ( + <> +
+
+ {tone === "approve" ? ( + + ) : tone === "deny" ? ( + + ) : ( + + )} + {outcome} +
+ ) : null}
); diff --git a/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.render.test.mjs b/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.render.test.mjs new file mode 100644 index 000000000..8a485f9a8 --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.render.test.mjs @@ -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
 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("");
+  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("");
+});
+
+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("",
+  );
+});
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.tsx
index f7f34880e..e2df337c7 100644
--- a/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.tsx
+++ b/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.tsx
@@ -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 (
     
       
       
-        {props.item.sections.map((section) => (
-          
- - {section.title} - - -
-              {section.body.trim() || "No metadata."}
-            
-
- ))} + {isRawPayload ? ( + props.item.sections.map((section) => ( +
+ + {section.title} + + +
+                {section.body.trim() || "No metadata."}
+              
+
+ )) + ) : ( + + )}
); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index 49b8104de..0b3fc8500 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -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", + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index cc30a5268..720daa94c 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -35,6 +35,16 @@ export type TranscriptState = { activeMessageKey: Map; sealedKeys: Set; triggeringEventIdsByTurn: Map; + /** + * 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 } + >; 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; sealedKeys: Set; triggeringEventIdsByTurn: Map; + pendingPermissions: Map< + string, + { itemId: string; optionNames: Map } + >; 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) { 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(); + 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) { }; } +/** + * 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 { + 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) { 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": , "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>) + : []; + 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, }; diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index 39869b06a..46e99eb09 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -8,6 +8,7 @@ export type TranscriptTurnSegment = | { kind: "prompt"; user: Extract; + systemPrompt: Extract | null; context: Extract | null; setup: Extract[]; }; @@ -43,6 +44,12 @@ function isPromptContext( ); } +function isSystemPrompt( + item: TranscriptItem, +): item is Extract { + return item.type === "metadata" && item.acpSource === "session/new"; +} + function isSetupLifecycle( item: TranscriptItem, ): item is Extract { @@ -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(); + 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); } diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs index 8cf7a59cb..374124261 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs @@ -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", - title: "Prompt context", + 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", + }), + 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 →
, 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.
diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts
index 5fec4f269..eabc0e8a4 100644
--- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts
+++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts
@@ -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";
+}
diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts
index 2fb65817c..c64702a98 100644
--- a/desktop/src/features/agents/ui/agentSessionTypes.ts
+++ b/desktop/src/features/agents/ui/agentSessionTypes.ts
@@ -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;
diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx
index f6e390730..17c267c2d 100644
--- a/desktop/src/features/channels/ui/BotActivityBar.tsx
+++ b/desktop/src/features/channels/ui/BotActivityBar.tsx
@@ -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);
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index e4c6b9933..ee3766592 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -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;
@@ -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,
diff --git a/desktop/tests/e2e/observer-feed-screenshots.spec.ts b/desktop/tests/e2e/observer-feed-screenshots.spec.ts
new file mode 100644
index 000000000..bb177d21d
--- /dev/null
+++ b/desktop/tests/e2e/observer-feed-screenshots.spec.ts
@@ -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 
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 + //
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`, + }); + }); +});