From 6115a10f779cf6e399033210c06e2193c43e22d4 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 30 Jun 2026 19:28:03 -0400 Subject: [PATCH 01/16] fix(desktop): restore observer-feed regressions from #1381 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three regressions introduced by #1381's activity-feed rebuild: 1. Per-turn prompt context sections are now visible inline in the feed body. Previously they were hidden behind a modal accessible only via a small CheckCheck toggle inside the user-message bubble footer. Now the sections render directly below the user bubble with each section title visible and body expandable on click. A "View full" button opens the scrollable modal for focused reading. 2. Metadata items (system prompt, prompt context) now display their semantic title instead of the anonymous "Captured N raw sections" label. RawRailActivity now uses item.title as the verb, so "System prompt" and "Prompt context" render with their real names. 3. Permission rows now show the decided outcome. The permission response arrives as an acp_write event with result.outcome and the same JSON-RPC id as the request. A pendingPermissions map on TranscriptState indexes request id → lifecycle item id + option kind map. On the response the outcome is appended: Approved (allow_once) / Denied (reject_once) / Cancelled. Also restores metadata participation in isMeaningfulItem() for the Now summary bar. Raw JSON-RPC frames remain excluded (acpSource=raw_json_rpc); all semantic metadata (system prompt, prompt context) now contribute again, matching pre-#1381 behavior. System-prompt key (system-prompt:${ch}) is intentionally unchanged — the frame predates session creation and correctly reflects current channel setup by replacing on each session/new. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/scripts/check-file-sizes.mjs | 7 +- .../agents/ui/AgentSessionTranscriptList.tsx | 161 ++++++++---------- .../activityRenderClasses/RawRailActivity.tsx | 9 +- .../agents/ui/agentSessionTranscript.test.mjs | 97 +++++++++++ .../agents/ui/agentSessionTranscript.ts | 91 +++++++++- ...gentSessionTranscriptPresentation.test.mjs | 35 +++- .../ui/agentSessionTranscriptPresentation.ts | 6 +- 7 files changed, 307 insertions(+), 99 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 3bbe4e914..9816a52aa 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -132,7 +132,12 @@ 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, 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. + ["src/features/agents/ui/agentSessionTranscript.ts", 1026], // 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/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 9515be309..2290443c1 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -6,11 +6,9 @@ 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 { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem"; import { @@ -393,35 +391,93 @@ function PromptUserMessage({ profiles?: UserProfileLookup; setup?: Extract[]; }) { - const [contextOpen, setContextOpen] = React.useState(false); - return ( <> } item={item} profiles={profiles} /> + {context && context.sections.length > 0 ? ( + + ) : null} + + ); +} + +function PromptContextInline({ + context, +}: { + context: Extract; +}) { + const [dialogOpen, setDialogOpen] = React.useState(false); + + return ( + <> +
+
+

+ Prompt context +

+ +
+ +
); } +function PromptContextDialog({ + context, + onOpenChange, + open, +}: { + context: Extract; + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + if (!open || context.sections.length === 0) { + return null; + } + + return ( + + +
+ + Prompt context + +
+ +
+
+
+
+ ); +} + function PromptContextSections({ className, sections, @@ -431,7 +487,7 @@ function PromptContextSections({ }) { return (
{sections.map((section) => ( @@ -495,68 +551,14 @@ function PromptContextSectionAccordion({ ); } -function PromptContextDialog({ - context, - onOpenChange, - open, - setup, -}: { - context: Extract | null; - onOpenChange: (open: boolean) => void; - open: boolean; - setup: Extract[]; -}) { - if (!open || !context || context.sections.length === 0) { - return null; - } - - const setupText = formatPromptSetupSummary(setup); - - return ( - - -
- - Prompt context - {setupText ? ( -
- - {setupText} -
- ) : null} -
- -
- -
-
-
-
- ); -} - -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 +566,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/activityRenderClasses/RawRailActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.tsx index f7f34880e..2474d56d0 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.tsx @@ -17,17 +17,18 @@ export function RawRailActivity(props: ActivityRenderClassItemProps) { return null; } + const sectionCount = props.item.sections.length; + const sectionSuffix = `${sectionCount} section${sectionCount === 1 ? "" : "s"}`; + return ( {props.item.sections.map((section) => ( diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index 49b8104de..1755edbac 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -639,3 +639,100 @@ 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.match(item.text, /Approved \(allow_once\)/); +}); + +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.match(item.text, /Denied \(reject_once\)/); +}); + +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.match(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.doesNotMatch(item.text ?? "", /Approved/); + assert.doesNotMatch(item.text ?? "", /Denied/); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index cc30a5268..f72ba46e1 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,28 @@ 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; +} + function describeFreeformStatus(payload: Record) { const statusType = asString(payload.type) ?? asString(payload.status); const title = @@ -677,9 +730,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 +742,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 = asString(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 = asString(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) { + 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, + text: joinLifecycleText(existing.text, 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) { @@ -918,6 +1006,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/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs index 8cf7a59cb..c1344b20f 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs @@ -47,7 +47,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 +57,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({ diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts index 5fec4f269..d01cd92a6 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts @@ -56,7 +56,11 @@ 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 should contribute to the "Now" summary — restoring the + // pre-#1381 behavior where these were first-class feed items. + return item.acpSource !== "raw_json_rpc"; } return true; } From 335e687aaa0a1696211ba70c4291e0889d5911bc Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 30 Jun 2026 19:33:49 -0400 Subject: [PATCH 02/16] fix(desktop): handle numeric JSON-RPC ids in permission outcome correlation JSON-RPC 2.0 allows both string and numeric ids. The ACP runtime preserves permission request ids as serde_json::Value for exactly this reason. The prior commit used asString(payload.id) for both the request index and response lookup in pendingPermissions, silently dropping any numeric id and leaving the outcome unattached. Add a jsonRpcId() helper that accepts string or finite-number values and keys them via JSON.stringify (preventing collisions between the number 1 and the string "1"). Use it at both the request-index site and the response-lookup site instead of asString. Regression tests: numeric id with selected allow_once, numeric id with cancelled, and a collision test verifying number 1 and string "1" attach to their respective items independently. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/scripts/check-file-sizes.mjs | 11 ++--- .../agents/ui/agentSessionTranscript.test.mjs | 43 +++++++++++++++++++ .../agents/ui/agentSessionTranscript.ts | 17 +++++++- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 9816a52aa..f95338bf6 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -133,11 +133,12 @@ const overrides = new Map([ ["src/shared/ui/VideoPlayer.tsx", 2199], ["src/shared/ui/sidebar.tsx", 1042], // permission-outcome (fix #1381 regression): pendingPermissions state map, - // describePermissionOutcome helper, 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. - ["src/features/agents/ui/agentSessionTranscript.ts", 1026], + // 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. + ["src/features/agents/ui/agentSessionTranscript.ts", 1039], // 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/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index 1755edbac..5e794ec12 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -736,3 +736,46 @@ test("buildTranscript no-ops on a permission response with an unmatched id", () 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.match(item.text, /Approved \(allow_once\)/); +}); + +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.match(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.match(transcriptNumeric[0].text, /Approved \(allow_once\)/); + assert.match(transcriptString[0].text, /Denied \(reject_once\)/); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index f72ba46e1..aefb945f4 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -248,6 +248,19 @@ function describePermissionOutcome( 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 = @@ -744,7 +757,7 @@ export function processTranscriptEvent( ); // 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 = asString(payload.id); + const requestId = jsonRpcId(payload.id); if (requestId) { d.pendingPermissions = new Map(d.pendingPermissions); d.pendingPermissions.set(requestId, { @@ -754,7 +767,7 @@ export function processTranscriptEvent( } } else if (event.kind === "acp_write" && !method) { // Permission response: {"id": , "result": {"outcome": {...}}} - const responseId = asString(payload.id); + 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; From 3deddd75f29d6ea63440ab7db91e3d7b3118057e Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 30 Jun 2026 19:45:35 -0400 Subject: [PATCH 03/16] fix(desktop): narrow responseId in permission-outcome guard for tsc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside the acp_write response branch, `responseId` is typed `string | null`. The `if (pending && outcomeKind)` guard only narrowed `pending`, not `responseId`, so `d.pendingPermissions.delete(responseId)` was rejected by tsc (argument of type 'string | null' not assignable to 'string'). Add `&& responseId` to the guard. This is semantically a no-op — `pending` can only be truthy when the earlier `responseId ?` branch took the non-null path — but TS requires the explicit narrowing. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/features/agents/ui/agentSessionTranscript.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index aefb945f4..0d1648960 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -771,7 +771,7 @@ export function processTranscriptEvent( const result = asRecord(asRecord(payload.result).outcome); const outcomeKind = asString(result.outcome); const pending = responseId ? d.pendingPermissions.get(responseId) : null; - if (pending && outcomeKind) { + if (pending && outcomeKind && responseId) { const optionId = asString(result.optionId) ?? null; const outcomeText = describePermissionOutcome( outcomeKind, From 32fa71050631bcceff66beb7f69f70a6197363f8 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 30 Jun 2026 20:19:39 -0400 Subject: [PATCH 04/16] test(desktop): add E2E screenshot spec for observer-feed #1381 regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Playwright smoke spec that seeds observer relay events directly into the production appendAgentEvent → processTranscriptEvent ingestion path via a new __BUZZ_E2E_SEED_OBSERVER_EVENTS__ e2e bridge hook, then screenshots four surfaces restored by PR #1412: - 01-prompt-context-inline: collapsed inline prompt-context block - 02-system-prompt-title: system prompt with title visible in feed - 03-permission-approved: permission row with Approved (allow_once) outcome - 04-permission-cancelled: permission row with Cancelled outcome Supporting changes: - injectObserverEventsForE2E exported from observerRelayStore.ts - __BUZZ_E2E_SEED_OBSERVER_EVENTS__ Window hook wired in e2eBridge.ts - spec added to smoke project in playwright.config.ts Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/playwright.config.ts | 1 + .../src/features/agents/observerRelayStore.ts | 19 ++ desktop/src/testing/e2eBridge.ts | 17 + .../e2e/observer-feed-screenshots.spec.ts | 323 ++++++++++++++++++ 4 files changed, 360 insertions(+) create mode 100644 desktop/tests/e2e/observer-feed-screenshots.spec.ts 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/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/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..93207845b --- /dev/null +++ b/desktop/tests/e2e/observer-feed-screenshots.spec.ts @@ -0,0 +1,323 @@ +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: the system prompt that RawRailActivity renders with the + // real "System prompt" title (not "Captured N raw sections" as #1381 broke). + 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`, + }); + }); +}); From 33f33eff95193045ce43cc5d850804ef943b4322 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 30 Jun 2026 20:33:41 -0400 Subject: [PATCH 05/16] fix(desktop): restructure permission feed item into three-part block Store the resolved permission outcome on a new `outcome?` field of the lifecycle item type instead of appending it to `text` via joinLifecycleText. This separates the data model cleanly: `text` holds request detail + options; `outcome` holds the final decision. LifecycleActivity.tsx renders permission items in three visually distinct parts: 1. Request row: shield icon + title + request description 2. Options sub-line: muted indented 'Options: ...' text 3. Decision row (when resolved): divider + colored icon + outcome label - green + CheckCircle2 for 'Approved (...)' - destructive + XCircle for 'Denied (...)' - muted + XCircle for 'Cancelled' Also adds expanded E2E screenshot variants: - 05-prompt-context-expanded.png: all three accordion sections open - 06-system-prompt-expanded.png: outer
+ inner section
open Unit tests updated to assert outcome is on item.outcome (not in item.text). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../LifecycleActivity.tsx | 77 ++++++++++++++- .../agents/ui/agentSessionTranscript.test.mjs | 20 ++-- .../agents/ui/agentSessionTranscript.ts | 2 +- .../features/agents/ui/agentSessionTypes.ts | 2 + .../e2e/observer-feed-screenshots.spec.ts | 96 +++++++++++++++++++ 5 files changed, 184 insertions(+), 13 deletions(-) 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/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index 5e794ec12..001f36037 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -696,7 +696,8 @@ test("buildTranscript appends Approved outcome when allow_once is selected", () const item = transcript[0]; assert.equal(item.type, "lifecycle"); assert.equal(item.renderClass, "permission"); - assert.match(item.text, /Approved \(allow_once\)/); + assert.equal(item.outcome, "Approved (allow_once)"); + assert.doesNotMatch(item.text ?? "", /Approved/); }); test("buildTranscript appends Denied outcome when reject_once is selected", () => { @@ -707,7 +708,8 @@ test("buildTranscript appends Denied outcome when reject_once is selected", () = const item = transcript[0]; assert.equal(item.type, "lifecycle"); - assert.match(item.text, /Denied \(reject_once\)/); + assert.equal(item.outcome, "Denied (reject_once)"); + assert.doesNotMatch(item.text ?? "", /Denied/); }); test("buildTranscript appends Cancelled outcome on cancelled response", () => { @@ -718,7 +720,8 @@ test("buildTranscript appends Cancelled outcome on cancelled response", () => { const item = transcript[0]; assert.equal(item.type, "lifecycle"); - assert.match(item.text, /Cancelled/); + assert.equal(item.outcome, "Cancelled"); + assert.doesNotMatch(item.text ?? "", /Cancelled/); }); test("buildTranscript no-ops on a permission response with an unmatched id", () => { @@ -733,6 +736,7 @@ test("buildTranscript no-ops on a permission response with an unmatched id", () 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/); }); @@ -750,7 +754,8 @@ test("buildTranscript appends Approved outcome for a numeric JSON-RPC id (select const item = transcript[0]; assert.equal(item.type, "lifecycle"); assert.equal(item.renderClass, "permission"); - assert.match(item.text, /Approved \(allow_once\)/); + assert.equal(item.outcome, "Approved (allow_once)"); + assert.doesNotMatch(item.text ?? "", /Approved/); }); test("buildTranscript appends Cancelled outcome for a numeric JSON-RPC id (cancelled)", () => { @@ -761,7 +766,8 @@ test("buildTranscript appends Cancelled outcome for a numeric JSON-RPC id (cance const item = transcript[0]; assert.equal(item.type, "lifecycle"); - assert.match(item.text, /Cancelled/); + assert.equal(item.outcome, "Cancelled"); + assert.doesNotMatch(item.text ?? "", /Cancelled/); }); test('buildTranscript does not collide between numeric id 1 and string id "1"', () => { @@ -776,6 +782,6 @@ test('buildTranscript does not collide between numeric id 1 and string id "1"', makePermissionResponse(2, "1", "selected", "reject_once"), ]); - assert.match(transcriptNumeric[0].text, /Approved \(allow_once\)/); - assert.match(transcriptString[0].text, /Denied \(reject_once\)/); + assert.equal(transcriptNumeric[0].outcome, "Approved (allow_once)"); + assert.equal(transcriptString[0].outcome, "Denied (reject_once)"); }); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 0d1648960..42d249c96 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -782,7 +782,7 @@ export function processTranscriptEvent( if (existing?.type === "lifecycle") { replaceItem(d, pending.itemId, { ...existing, - text: joinLifecycleText(existing.text, outcomeText), + outcome: outcomeText, }); // Remove from pending map — the outcome is now recorded. d.pendingPermissions = new Map(d.pendingPermissions); 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/tests/e2e/observer-feed-screenshots.spec.ts b/desktop/tests/e2e/observer-feed-screenshots.spec.ts index 93207845b..3806c1c10 100644 --- a/desktop/tests/e2e/observer-feed-screenshots.spec.ts +++ b/desktop/tests/e2e/observer-feed-screenshots.spec.ts @@ -320,4 +320,100 @@ test.describe("observer feed screenshots", () => { 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 all native
elements inside the metadata item. + 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; + } + }); + await settleAnimations(feedPanel); + await feedPanel.screenshot({ + path: `${SHOTS}/06-system-prompt-expanded.png`, + }); + }); }); From 82870bb7343495449e68ae690f0f8cf712969fa9 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Tue, 30 Jun 2026 20:57:17 -0400 Subject: [PATCH 06/16] fix(desktop): narrow metadata headline eligibility in BotActivityBar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds isSpineItem() predicate — true for tools, messages, thoughts, plans, and meaningful lifecycle events; false for metadata items (system prompt, prompt context). These are 'reads' that should recede when real work is present, per VISION_ACTIVITY.md:49,61 (failures rise; reads recede; suppression is what makes signal legible). BotActivityBar now uses a two-tier headline scan: first pass collects spine-only headlines; if none found (session start / idle), falls back to all meaningful items via isMeaningfulItem so the bar isn't left empty. Feed visibility for metadata items is unaffected — AgentSessionTranscriptList renders them independently of this predicate. Also fixes the isMeaningfulItem docstring, which incorrectly claimed the function feeds a 'Now summary' (it only feeds the headline scan). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- ...gentSessionTranscriptPresentation.test.mjs | 103 ++++++++++++++++++ .../ui/agentSessionTranscriptPresentation.ts | 21 +++- .../features/channels/ui/BotActivityBar.tsx | 9 +- 3 files changed, 129 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs index c1344b20f..1b8d5b700 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"; @@ -147,3 +148,105 @@ 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", + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts index d01cd92a6..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; @@ -58,9 +58,24 @@ export function isMeaningfulItem(item: TranscriptItem): boolean { if (item.type === "metadata") { // Raw JSON-RPC frames ("Raw ACP payload") are infrastructure noise; all // other metadata items (system prompt, prompt context) are semantically - // meaningful and should contribute to the "Now" summary — restoring the - // pre-#1381 behavior where these were first-class feed items. + // 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/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); From 1efcf527889d1421edc5ee94b5c39ae39d2cb949 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 1 Jul 2026 00:28:36 -0400 Subject: [PATCH 07/16] fix(observer): unify system-prompt render with polished section accordion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RawRailActivity now branches on acpSource: raw_json_rpc items keep the existing
/details raw treatment (the ambient safety net), while all
other named metadata items (system prompt, steer-turn prompt context)
render via the shared PromptSectionList accordion — the same polished
rounded-2xl card used for per-turn prompt context.

Extract PromptContextSections + PromptContextSectionAccordion from
AgentSessionTranscriptList into PromptSectionAccordion.tsx and import
the shared component back in both AgentSessionTranscriptList and
RawRailActivity. No visual change to prompt-context; the raw-rail
treatment is now reserved for genuine raw payloads only.

Co-authored-by: Will Pfleger 
Signed-off-by: Will Pfleger 
---
 .../agents/ui/AgentSessionTranscriptList.tsx  | 78 +------------------
 .../agents/ui/PromptSectionAccordion.tsx      | 78 +++++++++++++++++++
 .../activityRenderClasses/RawRailActivity.tsx | 34 ++++----
 ...gentSessionTranscriptPresentation.test.mjs | 54 +++++++++++++
 4 files changed, 155 insertions(+), 89 deletions(-)
 create mode 100644 desktop/src/features/agents/ui/PromptSectionAccordion.tsx

diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
index 2290443c1..6c5a4ac93 100644
--- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
@@ -1,5 +1,5 @@
 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";
@@ -9,7 +9,8 @@ import {
   DialogHeader,
   DialogTitle,
 } from "@/shared/ui/dialog";
-import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
+import type { TranscriptItem } from "./agentSessionTypes";
+import { PromptSectionList as PromptContextSections } from "./PromptSectionAccordion";
 import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
 import {
   ActivityRow,
@@ -478,79 +479,6 @@ function PromptContextDialog({
   );
 }
 
-function PromptContextSections({
-  className,
-  sections,
-}: {
-  className?: string;
-  sections: PromptSection[];
-}) {
-  return (
-    
- {sections.map((section) => ( - - ))} -
- ); -} - -function PromptContextSectionAccordion({ - section, -}: { - section: PromptSection; -}) { - const [open, setOpen] = React.useState(false); - const body = section.body.trim(); - - return ( -
- -
- ); -} - function TurnSetupFooter({ items, messageLink = 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/RawRailActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.tsx index 2474d56d0..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) { @@ -19,6 +20,7 @@ export function RawRailActivity(props: ActivityRenderClassItemProps) { 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/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs index 1b8d5b700..cf844ce97 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs @@ -250,3 +250,57 @@ test("two-tier headline: metadata headlines when it is the only activity (sessio "Prompt context should headline when no spine work exists", ); }); + +// ---- acpSource discriminator (polished accordion vs raw
) ----
+// Verifies which metadata items are treated as raw payloads (keep 
)
+// versus polished content (use PromptSectionList accordion).
+
+test("acpSource discriminator: raw_json_rpc is a raw payload", () => {
+  const rawItem = {
+    id: "meta:raw",
+    type: "metadata",
+    renderClass: "raw-rail",
+    title: "Raw ACP payload",
+    sections: [{ title: "body", body: "{}" }],
+    timestamp: baseTimestamp,
+    acpSource: "raw_json_rpc",
+  };
+  assert.equal(
+    rawItem.acpSource === "raw_json_rpc",
+    true,
+    "raw_json_rpc item uses 
 raw treatment",
+  );
+});
+
+test("acpSource discriminator: system prompt (no acpSource) is polished content", () => {
+  const sysPrompt = {
+    id: "meta:sys",
+    type: "metadata",
+    renderClass: "raw-rail",
+    title: "System prompt",
+    sections: [{ title: "Instructions", body: "You are an agent." }],
+    timestamp: baseTimestamp,
+  };
+  assert.equal(
+    sysPrompt.acpSource === "raw_json_rpc",
+    false,
+    "system prompt uses polished accordion (acpSource is not raw_json_rpc)",
+  );
+});
+
+test("acpSource discriminator: prompt context (session/prompt:context) is polished content", () => {
+  const promptCtx = {
+    id: "meta:ctx",
+    type: "metadata",
+    renderClass: "raw-rail",
+    title: "Prompt context",
+    sections: [{ title: "Thread history", body: "..." }],
+    timestamp: baseTimestamp,
+    acpSource: "session/prompt:context",
+  };
+  assert.equal(
+    promptCtx.acpSource === "raw_json_rpc",
+    false,
+    "prompt context uses polished accordion (acpSource is not raw_json_rpc)",
+  );
+});

From 3abf4b6597e87bef82ddbbf5b0d3c05baa6a7b70 Mon Sep 17 00:00:00 2001
From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
 
Date: Wed, 1 Jul 2026 00:40:16 -0400
Subject: [PATCH 08/16] test(observer): replace tautological tests with render
 assertions; fix shot 06
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Replace the three acpSource boolean tests in agentSessionTranscriptPresentation.test.mjs
(which only asserted item.acpSource === 'raw_json_rpc' on hand-built objects — they would
pass with the isRawPayload branch deleted) with proper renderToStaticMarkup render tests in
RawRailActivity.render.test.mjs. The new tests render RawRailActivity for three metadata
items and assert the HTML: raw_json_rpc includes 
 and no rounded-2xl; system prompt
and steer-turn prompt context include rounded-2xl and no 
.

Fix shot 06 in observer-feed-screenshots.spec.ts: after the render unification,
system-prompt sections are React button accordions inside a native ActivityRow 
. Shot 06 now opens the outer
first, then clicks each inner section button to expand it — matching the interaction pattern in shot 05. Shot 06 confirmed to show both Base and System sections fully expanded in the polished card UI. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../RawRailActivity.render.test.mjs | 90 +++++++++++++++++++ ...gentSessionTranscriptPresentation.test.mjs | 57 +----------- .../e2e/observer-feed-screenshots.spec.ts | 12 ++- 3 files changed, 105 insertions(+), 54 deletions(-) create mode 100644 desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.render.test.mjs 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..f45d65a5e --- /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("rounded-2xl"),
+    "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("rounded-2xl"),
+    "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("rounded-2xl"),
+    "steer-turn prompt context should render polished accordion",
+  );
+  assert.ok(
+    !html.includes("",
+  );
+});
diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs
index cf844ce97..374124261 100644
--- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs
+++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs
@@ -251,56 +251,7 @@ test("two-tier headline: metadata headlines when it is the only activity (sessio
   );
 });
 
-// ---- acpSource discriminator (polished accordion vs raw 
) ----
-// Verifies which metadata items are treated as raw payloads (keep 
)
-// versus polished content (use PromptSectionList accordion).
-
-test("acpSource discriminator: raw_json_rpc is a raw payload", () => {
-  const rawItem = {
-    id: "meta:raw",
-    type: "metadata",
-    renderClass: "raw-rail",
-    title: "Raw ACP payload",
-    sections: [{ title: "body", body: "{}" }],
-    timestamp: baseTimestamp,
-    acpSource: "raw_json_rpc",
-  };
-  assert.equal(
-    rawItem.acpSource === "raw_json_rpc",
-    true,
-    "raw_json_rpc item uses 
 raw treatment",
-  );
-});
-
-test("acpSource discriminator: system prompt (no acpSource) is polished content", () => {
-  const sysPrompt = {
-    id: "meta:sys",
-    type: "metadata",
-    renderClass: "raw-rail",
-    title: "System prompt",
-    sections: [{ title: "Instructions", body: "You are an agent." }],
-    timestamp: baseTimestamp,
-  };
-  assert.equal(
-    sysPrompt.acpSource === "raw_json_rpc",
-    false,
-    "system prompt uses polished accordion (acpSource is not raw_json_rpc)",
-  );
-});
-
-test("acpSource discriminator: prompt context (session/prompt:context) is polished content", () => {
-  const promptCtx = {
-    id: "meta:ctx",
-    type: "metadata",
-    renderClass: "raw-rail",
-    title: "Prompt context",
-    sections: [{ title: "Thread history", body: "..." }],
-    timestamp: baseTimestamp,
-    acpSource: "session/prompt:context",
-  };
-  assert.equal(
-    promptCtx.acpSource === "raw_json_rpc",
-    false,
-    "prompt context uses polished accordion (acpSource is not raw_json_rpc)",
-  );
-});
+// 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/tests/e2e/observer-feed-screenshots.spec.ts b/desktop/tests/e2e/observer-feed-screenshots.spec.ts
index 3806c1c10..125be3df9 100644
--- a/desktop/tests/e2e/observer-feed-screenshots.spec.ts
+++ b/desktop/tests/e2e/observer-feed-screenshots.spec.ts
@@ -404,13 +404,23 @@ test.describe("observer feed screenshots", () => {
       timeout: 5_000,
     });
 
-    // Open all native 
elements inside the metadata item. + // 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"); + for (const btn of await sectionButtons.all()) { + await btn.click(); + } await settleAnimations(feedPanel); await feedPanel.screenshot({ path: `${SHOTS}/06-system-prompt-expanded.png`, From 934192acaa017b1c75f82af879b383ea70bc5612 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 1 Jul 2026 00:50:26 -0400 Subject: [PATCH 09/16] test(observer-feed): harden shot-06 guard and use semantic testid marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add count assertion before the section-button click loop in shot 06 so a future testid/nesting break fails loudly instead of silently producing a collapsed screenshot. Swap the render-test accordion marker from the style token 'rounded-2xl' to 'data-testid="transcript-prompt-context-sections"' — the semantic shared-list marker emitted by PromptSectionList. Style tokens can drift without breaking behavior; the testid is stable and specific. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../activityRenderClasses/RawRailActivity.render.test.mjs | 6 +++--- desktop/tests/e2e/observer-feed-screenshots.spec.ts | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.render.test.mjs b/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.render.test.mjs index f45d65a5e..8a485f9a8 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.render.test.mjs +++ b/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.render.test.mjs @@ -36,7 +36,7 @@ test("RawRailActivity render: raw_json_rpc keeps
 safety net", () => {
   );
   assert.ok(html.includes("");
   assert.ok(
-    !html.includes("rounded-2xl"),
+    !html.includes("transcript-prompt-context-sections"),
     "raw_json_rpc should not render polished accordion",
   );
 });
@@ -57,7 +57,7 @@ test("RawRailActivity render: system prompt (no acpSource) uses polished accordi
     }),
   );
   assert.ok(
-    html.includes("rounded-2xl"),
+    html.includes("transcript-prompt-context-sections"),
     "system prompt should render polished accordion",
   );
   assert.ok(!html.includes("");
@@ -80,7 +80,7 @@ test("RawRailActivity render: steer-turn prompt context uses polished accordion"
     }),
   );
   assert.ok(
-    html.includes("rounded-2xl"),
+    html.includes("transcript-prompt-context-sections"),
     "steer-turn prompt context should render polished accordion",
   );
   assert.ok(
diff --git a/desktop/tests/e2e/observer-feed-screenshots.spec.ts b/desktop/tests/e2e/observer-feed-screenshots.spec.ts
index 125be3df9..3b4248280 100644
--- a/desktop/tests/e2e/observer-feed-screenshots.spec.ts
+++ b/desktop/tests/e2e/observer-feed-screenshots.spec.ts
@@ -418,7 +418,9 @@ test.describe("observer feed screenshots", () => {
       .getByTestId("transcript-metadata-item")
       .getByTestId("transcript-prompt-context-sections")
       .getByRole("button");
-    for (const btn of await sectionButtons.all()) {
+    const allSectionButtons = await sectionButtons.all();
+    expect(allSectionButtons.length).toBeGreaterThan(0);
+    for (const btn of allSectionButtons) {
       await btn.click();
     }
     await settleAnimations(feedPanel);

From e54c62c35534e537ff65e004aa38f44f51affe5a Mon Sep 17 00:00:00 2001
From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
 
Date: Wed, 1 Jul 2026 14:38:25 -0400
Subject: [PATCH 10/16] feat(observer-feed): classify current_mode_update,
 usage_update, available_commands_update, config_option_update
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Add 4 named session/update classifier cases before the existing else-drop
safety net in agentSessionTranscript.ts:

- current_mode_update: lifecycle 'Mode / {currentModeId}'; suppressed when
  currentModeId is absent.
- usage_update: lifecycle 'Usage / Tokens: {used}/{size}' (+ cost if
  present); uses replaceLifecycleItem so repeated frames per turn coalesce
  to the latest value rather than accumulating.
- available_commands_update: lifecycle 'Commands / Commands available: N'.
- config_option_update: lifecycle 'Config / {name} = {val}, ...' joined
  from configOptions array; suppressed when array is empty.

The else branch is left unchanged — unknown/future types still drop,
preserving the firehose safety net. keepalive stays dropped.

Add replaceLifecycleItem helper (parallel to upsertLifecycleItem but
replaces text on update instead of appending via joinLifecycleText).

Tests: 12 new unit tests covering each case, edge paths (missing fields,
zero-length arrays), usage coalescing (3 frames → 1 item with last value),
keepalive drop, and unknown-type drop.

File-size override bumped to 1162 with a narrowly scoped justification
note.

Co-authored-by: Will Pfleger 
Signed-off-by: Will Pfleger 
---
 desktop/scripts/check-file-sizes.mjs          |   6 +-
 .../agents/ui/agentSessionTranscript.test.mjs | 156 ++++++++++++++++++
 .../agents/ui/agentSessionTranscript.ts       | 123 ++++++++++++++
 3 files changed, 284 insertions(+), 1 deletion(-)

diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index f95338bf6..a31b1e609 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -138,7 +138,11 @@ const overrides = new Map([
   // 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.
-  ["src/features/agents/ui/agentSessionTranscript.ts", 1039],
+  // +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.
+  // Load-bearing feature growth; queued to split in next transcript refactor.
+  ["src/features/agents/ui/agentSessionTranscript.ts", 1162],
   // 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/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs
index 001f36037..091b82c8d 100644
--- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs
+++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs
@@ -785,3 +785,159 @@ test('buildTranscript does not collide between numeric id 1 and string id "1"',
   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);
+});
diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts
index 42d249c96..0311914a0 100644
--- a/desktop/src/features/agents/ui/agentSessionTranscript.ts
+++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts
@@ -439,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,
@@ -971,6 +1017,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

From b4f85574ed2f595b15615d8184afac285c5b330d Mon Sep 17 00:00:00 2001
From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
 
Date: Wed, 1 Jul 2026 14:42:58 -0400
Subject: [PATCH 11/16] test(observer-feed): add E2E screenshot specs 07-10 for
 new session/update types

Co-authored-by: Will Pfleger 
Signed-off-by: Will Pfleger 
---
 .../e2e/observer-feed-screenshots.spec.ts     | 187 ++++++++++++++++++
 1 file changed, 187 insertions(+)

diff --git a/desktop/tests/e2e/observer-feed-screenshots.spec.ts b/desktop/tests/e2e/observer-feed-screenshots.spec.ts
index 3b4248280..7014ce646 100644
--- a/desktop/tests/e2e/observer-feed-screenshots.spec.ts
+++ b/desktop/tests/e2e/observer-feed-screenshots.spec.ts
@@ -428,4 +428,191 @@ test.describe("observer feed screenshots", () => {
       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`,
+    });
+  });
 });

From 9f01fa09602bcf68208181786dff1795b58c48ce Mon Sep 17 00:00:00 2001
From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
 
Date: Wed, 1 Jul 2026 15:39:39 -0400
Subject: [PATCH 12/16] fix(observer): give system-prompt item turnId=null so
 it renders before per-turn prompt-context
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The session/new arm in processTranscriptEvent was passing ctx (which
includes the active turnId) to upsertMetadata for the system-prompt
item. When session/new and session/prompt share the same turnId — as
the harness always does on the first turn — the system-prompt metadata
item landed in the same turn bucket as the prompt-context item.

buildTranscriptDisplayBlocks / classifyTurnItems treats unrecognized
turn-bucket items as activity, which renders after the prompt segment
(user message + setup + prompt-context). Result: system-prompt appeared
below prompt-context in the feed.

Fix: pass { ...ctx, turnId: null } so the item is always treated as a
standalone single entry by the display grouper. This is semantically
correct — the system prompt is per-channel, not per-turn. The upsert
update path handles null via ctx.turnId ?? existing.turnId, so
re-created sessions also keep turnId=null.

Two new tests added to agentSessionTranscript.test.mjs covering:
- First-turn shared-turnId ordering (the bug case)
- Multi-turn ordering (system-prompt stays before all prompt-context items)

Co-authored-by: Will Pfleger 
Signed-off-by: Will Pfleger 
---
 desktop/scripts/check-file-sizes.mjs          |   5 +-
 .../agents/ui/agentSessionTranscript.test.mjs | 162 ++++++++++++++++++
 .../agents/ui/agentSessionTranscript.ts       |   7 +-
 3 files changed, 171 insertions(+), 3 deletions(-)

diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index a31b1e609..fa714392f 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -140,9 +140,10 @@ const overrides = new Map([
   // 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.
+  // 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", 1162],
+  ["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/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs
index 091b82c8d..0d5b94b7d 100644
--- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs
+++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs
@@ -941,3 +941,165 @@ test("buildTranscript does not render unknown session/update types (firehose saf
   ]);
   assert.equal(transcript.length, 0);
 });
+
+// --- system-prompt ordering ---
+
+test("buildTranscript gives system-prompt item a null turnId so it sorts before per-turn prompt-context (first turn, shared turnId)", () => {
+  // Reproduces the ordering bug: session/new and session/prompt arrive with the
+  // same turnId (as the harness emits on the first turn). Without the fix the
+  // system-prompt item lands in the same turn bucket as the prompt-context and
+  // renders AFTER it; with the fix it has turnId=null and sorts before.
+  const events = [
+    {
+      seq: 1,
+      timestamp: "2026-07-01T10:00:00.000Z",
+      kind: "acp_write",
+      agentIndex: 0,
+      channelId: "ch-1",
+      sessionId: null,
+      turnId: "turn-1", // same as session/prompt — the bug trigger
+      payload: {
+        jsonrpc: "2.0",
+        id: 1,
+        method: "session/new",
+        params: {
+          systemPrompt:
+            "[Base]\nYou are a helpful assistant.\n\n[System]\nObserver Agent.",
+        },
+      },
+    },
+    {
+      seq: 2,
+      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." },
+          ],
+        },
+      },
+    },
+  ];
+
+  const items = buildTranscript(events);
+  const systemPromptIdx = items.findIndex((i) => i.title === "System prompt");
+  const promptContextIdx = items.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})`,
+  );
+  // Verify the system-prompt item has no turnId so the display grouper treats
+  // it as a standalone item, not a turn-scoped one.
+  const systemPromptItem = items[systemPromptIdx];
+  assert.equal(
+    systemPromptItem.turnId ?? null,
+    null,
+    "system-prompt item must have turnId=null to avoid turn-bucket grouping",
+  );
+});
+
+test("buildTranscript system-prompt stays before prompt-context on subsequent turns (multi-turn)", () => {
+  const events = [
+    {
+      seq: 1,
+      timestamp: "2026-07-01T10:00:00.000Z",
+      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: 2,
+      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: 3,
+      timestamp: "2026-07-01T10:05:00.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 items = buildTranscript(events);
+  const systemPromptIdx = items.findIndex((i) => i.title === "System prompt");
+  // Both turns produce a Prompt context — grab the first one (turn-1).
+  const firstPromptContextIdx = items.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})`,
+  );
+  assert.equal(
+    items[systemPromptIdx].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 0311914a0..301d792cb 100644
--- a/desktop/src/features/agents/ui/agentSessionTranscript.ts
+++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts
@@ -872,6 +872,11 @@ export function processTranscriptEvent(
       // 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.
+      //
+      // System prompt is per-channel, not per-turn. Passing turnId: null keeps
+      // the item out of any turn bucket in the display grouping layer so it
+      // always renders before the per-turn prompt-context block, regardless of
+      // which turnId the session/new event carries on the wire.
       const params = asRecord(payload.params);
       const systemPrompt = asString(params.systemPrompt);
       if (systemPrompt) {
@@ -883,7 +888,7 @@ export function processTranscriptEvent(
             "System prompt",
             sections,
             event.timestamp,
-            ctx,
+            { ...ctx, turnId: null },
           );
         }
       }

From a70fbcbbfe46775c8222db85b8f0c44691dedf48 Mon Sep 17 00:00:00 2001
From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
 
Date: Wed, 1 Jul 2026 15:45:09 -0400
Subject: [PATCH 13/16] test(observer): route ordering assertions through
 display layer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The two system-prompt ordering tests were asserting on raw buildTranscript
output (d.items insertion order). Since session/new always fires before
session/prompt on the wire, system-prompt was always at a lower index in
d.items regardless of the fix — making the ordering assertions tautological.

Fix: route through buildTranscriptDisplayBlocks + flattenDisplayBlocks, the
layer that actually contained the bug (classifyTurnItems placed system-prompt
as an 'activity' item after prompt-context when both shared the same turnId).
The turnId=null assertions remain on raw items — they verify the fix input
that makes the display grouper behave correctly.

Verified: both tests fail when the { ...ctx, turnId: null } one-liner is
reverted, pass when it is present.

Co-authored-by: Will Pfleger 
Signed-off-by: Will Pfleger 
---
 .../agents/ui/agentSessionTranscript.test.mjs | 62 +++++++++++++------
 1 file changed, 44 insertions(+), 18 deletions(-)

diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs
index 0d5b94b7d..ee34945b3 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 = {
@@ -944,11 +948,13 @@ test("buildTranscript does not render unknown session/update types (firehose saf
 
 // --- system-prompt ordering ---
 
-test("buildTranscript gives system-prompt item a null turnId so it sorts before per-turn prompt-context (first turn, shared turnId)", () => {
+test("observer feed renders system-prompt before prompt-context in display order (first turn, shared turnId)", () => {
   // Reproduces the ordering bug: session/new and session/prompt arrive with the
-  // same turnId (as the harness emits on the first turn). Without the fix the
-  // system-prompt item lands in the same turn bucket as the prompt-context and
-  // renders AFTER it; with the fix it has turnId=null and sorts before.
+  // same turnId (as the harness emits on the first turn). Without turnId=null on
+  // the system-prompt item, both items land in the same turn bucket and
+  // classifyTurnItems renders system-prompt AFTER prompt-context (as "activity").
+  // The ordering assertion runs through the display layer — buildTranscript alone
+  // is a tautology because session/new always arrives before session/prompt.
   const events = [
     {
       seq: 1,
@@ -994,26 +1000,38 @@ test("buildTranscript gives system-prompt item a null turnId so it sorts before
     },
   ];
 
-  const items = buildTranscript(events);
-  const systemPromptIdx = items.findIndex((i) => i.title === "System prompt");
-  const promptContextIdx = items.findIndex((i) => i.title === "Prompt context");
+  // Route through the display layer — this is the layer that contained the bug.
+  // buildTranscriptDisplayBlocks groups items into turn buckets; flattenDisplayBlocks
+  // returns them in final render order.
+  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})`,
+    `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",
   );
-  // Verify the system-prompt item has no turnId so the display grouper treats
-  // it as a standalone item, not a turn-scoped one.
-  const systemPromptItem = items[systemPromptIdx];
   assert.equal(
-    systemPromptItem.turnId ?? null,
+    rawItems[systemPromptRawIdx].turnId ?? null,
     null,
     "system-prompt item must have turnId=null to avoid turn-bucket grouping",
   );
 });
 
-test("buildTranscript system-prompt stays before prompt-context on subsequent turns (multi-turn)", () => {
+test("observer feed renders system-prompt before prompt-context in display order (multi-turn)", () => {
   const events = [
     {
       seq: 1,
@@ -1082,10 +1100,15 @@ test("buildTranscript system-prompt stays before prompt-context on subsequent tu
     },
   ];
 
-  const items = buildTranscript(events);
-  const systemPromptIdx = items.findIndex((i) => i.title === "System prompt");
+  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 = items.findIndex(
+  const firstPromptContextIdx = displayItems.findIndex(
     (i) => i.title === "Prompt context",
   );
   assert.ok(systemPromptIdx !== -1, "expected a System prompt item");
@@ -1095,10 +1118,13 @@ test("buildTranscript system-prompt stays before prompt-context on subsequent tu
   );
   assert.ok(
     systemPromptIdx < firstPromptContextIdx,
-    `expected System prompt (idx ${systemPromptIdx}) before first Prompt context (idx ${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(
-    items[systemPromptIdx].turnId ?? null,
+    rawItems[systemPromptRawIdx].turnId ?? null,
     null,
     "system-prompt item must have turnId=null",
   );

From d0afbce6ffb6f79c35c5b38dc1a69e1a763bd0e7 Mon Sep 17 00:00:00 2001
From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
 
Date: Wed, 1 Jul 2026 16:00:52 -0400
Subject: [PATCH 14/16] fix(observer): inject system-prompt into prompt segment
 for correct display order
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

pool.rs emits turn_started before session/new on the first turn, so
turn_started creates the turn bucket before session/new fires. With
turnId=null, system-prompt lands as a standalone single AFTER the turn
block in displayOrder — rendering after prompt-context.

Fix: tag system-prompt items with acpSource "session/new". In
buildTranscriptDisplayBlocks, hold system-prompt singles until the
first turn with a user-prompt item, then pass them to classifyTurnItems
as externalSystemPrompt. classifyTurnItems slots the item into the
prompt segment between the user message and prompt-context. The
flattenDisplayBlocks order is: user → systemPrompt → context.

Tests updated to use the full realistic wire sequence (turn_started →
session/new → session_resolved → session/prompt). Fails-on-revert
confirmed: removing acpSource "session/new" makes both ordering tests
fail (1473 pass / 2 fail); restored → 1475/1475.

Co-authored-by: Will Pfleger 
Signed-off-by: Will Pfleger 
---
 .../agents/ui/AgentSessionTranscriptList.tsx  |  9 +++
 .../agents/ui/agentSessionTranscript.test.mjs | 76 +++++++++++++++----
 .../agents/ui/agentSessionTranscript.ts       | 13 ++--
 .../ui/agentSessionTranscriptGrouping.ts      | 58 +++++++++++++-
 4 files changed, 132 insertions(+), 24 deletions(-)

diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
index 6c5a4ac93..cd8c27d00 100644
--- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
@@ -206,6 +206,7 @@ function TranscriptTurnSegmentView({
         context={segment.context}
         profiles={profiles}
         setup={segment.setup}
+        systemPrompt={segment.systemPrompt}
         user={segment.user}
       />
     );
@@ -354,11 +355,13 @@ function TurnPromptBlock({
   context,
   profiles,
   setup,
+  systemPrompt,
   user,
 }: {
   context: Extract | null;
   profiles?: UserProfileLookup;
   setup: Extract[];
+  systemPrompt: Extract | null;
   user: Extract;
 }) {
   return (
@@ -376,6 +379,7 @@ function TurnPromptBlock({
         item={user}
         profiles={profiles}
         setup={setup}
+        systemPrompt={systemPrompt}
       />
     
); @@ -386,11 +390,13 @@ function PromptUserMessage({ item, profiles, setup = [], + systemPrompt = null, }: { context?: Extract | null; item: Extract; profiles?: UserProfileLookup; setup?: Extract[]; + systemPrompt?: Extract | null; }) { return ( <> @@ -406,6 +412,9 @@ function PromptUserMessage({ item={item} profiles={profiles} /> + {systemPrompt && systemPrompt.sections.length > 0 ? ( + + ) : null} {context && context.sections.length > 0 ? ( ) : null} diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index ee34945b3..0b3fc8500 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -948,22 +948,32 @@ test("buildTranscript does not render unknown session/update types (firehose saf // --- system-prompt ordering --- -test("observer feed renders system-prompt before prompt-context in display order (first turn, shared turnId)", () => { - // Reproduces the ordering bug: session/new and session/prompt arrive with the - // same turnId (as the harness emits on the first turn). Without turnId=null on - // the system-prompt item, both items land in the same turn bucket and - // classifyTurnItems renders system-prompt AFTER prompt-context (as "activity"). - // The ordering assertion runs through the display layer — buildTranscript alone - // is a tautology because session/new always arrives before session/prompt. +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", // same as session/prompt — the bug trigger + turnId: "turn-1", payload: { jsonrpc: "2.0", id: 1, @@ -975,7 +985,17 @@ test("observer feed renders system-prompt before prompt-context in display order }, }, { - seq: 2, + 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, @@ -1001,8 +1021,6 @@ test("observer feed renders system-prompt before prompt-context in display order ]; // Route through the display layer — this is the layer that contained the bug. - // buildTranscriptDisplayBlocks groups items into turn buckets; flattenDisplayBlocks - // returns them in final render order. const rawItems = buildTranscript(events); const displayItems = flattenDisplayBlocks( buildTranscriptDisplayBlocks(rawItems), @@ -1032,10 +1050,22 @@ test("observer feed renders system-prompt before prompt-context in display order }); 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", @@ -1051,7 +1081,17 @@ test("observer feed renders system-prompt before prompt-context in display order }, }, { - seq: 2, + 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, @@ -1075,8 +1115,18 @@ test("observer feed renders system-prompt before prompt-context in display order }, }, { - seq: 3, + 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", diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 301d792cb..720daa94c 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -869,14 +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. - // - // System prompt is per-channel, not per-turn. Passing turnId: null keeps - // the item out of any turn bucket in the display grouping layer so it - // always renders before the per-turn prompt-context block, regardless of - // which turnId the session/new event carries on the wire. + // 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) { @@ -889,6 +885,7 @@ export function processTranscriptEvent( sections, event.timestamp, { ...ctx, turnId: null }, + "session/new", ); } } diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index 39869b06a..f388a0a61 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", @@ -243,6 +292,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); } From 4145ca9842581aefceb18bc0cd23986077904857 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 1 Jul 2026 16:06:05 -0400 Subject: [PATCH 15/16] fix(observer): use metadata item title in prompt inline block heading PromptContextInline hard-coded "Prompt context" for both the inline heading and the dialog title, so the system-prompt metadata block (title: "System prompt") rendered with the wrong visible label. Use context.title in both places so system-prompt shows "System prompt" and prompt-context shows "Prompt context" as expected. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index cd8c27d00..709d1a110 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -437,7 +437,7 @@ function PromptContextInline({ >

- Prompt context + {context.title}