From c3ba1f574958a69cbfbb73c19eb307dad3da0dde Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 11 Jul 2026 10:51:42 -0400 Subject: [PATCH 1/6] fix(desktop): show agent core memory as its own observer Prompt context section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseSystemPromptSections previously only recognized [Base] and [System] boundaries, folding any appended core content into whichever section preceded it. On a Base+System+Core prompt the core was silently merged into the System body; on Base+Core it merged into Base; on a core-only frame it was mislabeled Base. Extract the trailing [Agent Memory — core] block using the LAST occurrence of its exact header so an earlier mention of that line inside a persona body stays literal when the real appended core frame follows. The remaining prefix is then split on the existing Base/System logic unchanged. The core block is appended as its own PromptSection at the end, making it a visible 'Agent Memory — core' tab in the observer Prompt context dialog. Six new tests cover Base+System+Core, Base+Core, System+Core, core-only, the embedded-header non-happy-path (last-wins dedup), and the realistic Workspace+Base+System+Core harness shape. All six existing parseSystem PromptSections tests continue to pass unchanged. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../ui/agentSessionTranscriptHelpers.test.mjs | 94 +++++++++++++++++++ .../ui/agentSessionTranscriptHelpers.ts | 80 +++++++++++----- 2 files changed, 149 insertions(+), 25 deletions(-) diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index 70b2bfffaf..1a0b2ad2d3 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -237,3 +237,97 @@ test("parseSystemPromptSections returns no sections for empty input", () => { assert.deepEqual(parseSystemPromptSections(""), []); assert.deepEqual(parseSystemPromptSections(" "), []); }); + +// ── [Agent Memory — core] section tests ────────────────────────────────────── + +test("parseSystemPromptSections extracts core as its own section after Base+System", () => { + const framed = + "[Base]\nbase text\n\n[System]\npersona text\n\n[Agent Memory — core]\nmy memories"; + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Base", body: "base text" }, + { title: "System", body: "persona text" }, + { title: "[Agent Memory — core]", body: "my memories" }, + ]); +}); + +test("parseSystemPromptSections extracts core as its own section after Base only", () => { + const framed = "[Base]\nbase text\n\n[Agent Memory — core]\nmy memories"; + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Base", body: "base text" }, + { title: "[Agent Memory — core]", body: "my memories" }, + ]); +}); + +test("parseSystemPromptSections extracts core as its own section after System only", () => { + const framed = "[System]\npersona text\n\n[Agent Memory — core]\nmy memories"; + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "System", body: "persona text" }, + { title: "[Agent Memory — core]", body: "my memories" }, + ]); +}); + +test("parseSystemPromptSections returns only a core section when no Base/System present", () => { + const framed = "[Agent Memory — core]\nmy memories"; + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "[Agent Memory — core]", body: "my memories" }, + ]); +}); + +test("parseSystemPromptSections keeps an embedded core-like line literal when a real appended core follows", () => { + // The persona body contains a line that looks like the header. The actual + // appended core block comes last — only the LAST boundary should split. + const framed = [ + "[Base]", + "base text", + "", + "[System]", + "persona preamble", + "[Agent Memory — core]", + "this is NOT the core section — it is inside the persona body", + "", + "[Agent Memory — core]", + "this IS the appended core", + ].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Base", body: "base text" }, + { + title: "System", + body: "persona preamble\n[Agent Memory — core]\nthis is NOT the core section — it is inside the persona body", + }, + { + title: "[Agent Memory — core]", + body: "this IS the appended core", + }, + ]); +}); + +test("parseSystemPromptSections pins the realistic Workspace+Base+System+Core harness shape", () => { + // Workspace section preamble is treated as part of Base when [Base] follows. + // This is unchanged behavior; core is extracted as a new fourth section. + const framed = [ + "[Base]", + "You are an assistant.", + "", + "[System]", + "Custom persona instructions.", + "", + "[Agent Memory — core]", + "I am Duncan.", + "## Lessons Learned", + "Always tag on handoff.", + ].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Base", body: "You are an assistant." }, + { title: "System", body: "Custom persona instructions." }, + { + title: "[Agent Memory — core]", + body: "I am Duncan.\n## Lessons Learned\nAlways tag on handoff.", + }, + ]); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index 5daf00d2b4..58ffc97d0b 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -56,42 +56,72 @@ export function parsePromptText(text: string): { } /** - * Split the framed `session/new` `systemPrompt` into its `Base`/`System` - * sub-sections deterministically. + * Split the framed `session/new` `systemPrompt` into its `Base`/`System`/ + * `Agent Memory — core` sub-sections deterministically. * - * The harness frames the value as `[Base]\n{base}\n\n[System]\n{persona}`, with - * either prompt omitted when absent: base-only is `[Base]\n{base}`, persona-only - * is `[System]\n{persona}`. We partition on the FIRST `\n[System]\n` boundary and - * read each labeled body literally. Unlike the generic `parsePromptSections`, - * embedded `[...]` lines inside a body never start a new section — so a persona - * containing a bracketed line, or a mid-string-elided header on an oversize - * prompt, can never drop a label or inflate the section count. + * The harness frames the value as: + * `[Base]\n{base}\n\n[System]\n{persona}\n\n[Agent Memory — core]\n{core}` + * with any section omitted when absent. We first extract the trailing + * `[Agent Memory — core]` block (using the LAST occurrence of the boundary + * so an earlier mention inside a persona body stays literal when the actual + * appended core frame follows). The remaining prefix is then split on the + * FIRST `\n[System]\n` boundary into Base/System. + * + * Unlike the generic `parsePromptSections`, no embedded `[...]` line inside a + * body can start a new section — so a persona containing a bracketed line, or a + * mid-string-elided header on an oversize prompt, can never drop a label or + * inflate the section count. */ export function parseSystemPromptSections( systemPrompt: string, ): PromptSection[] { const sections: PromptSection[] = []; - // Persona-only frame: no [Base], starts directly with [System]. - if (systemPrompt.startsWith("[System]\n")) { - const body = systemPrompt.slice("[System]\n".length).trim(); - if (body) sections.push({ title: "System", body }); - return sections; + // ── 1. Extract the trailing [Agent Memory — core] block ────────────────── + // Use the LAST occurrence so a persona that itself contains an + // "[Agent Memory — core]" line remains literal when the real appended frame + // follows later in the string. + const CORE_HEADER = "[Agent Memory — core]"; + const CORE_MARKER_INLINE = `\n${CORE_HEADER}\n`; // mid-string boundary + let coreBody: string | null = null; + let baseAndSystem = systemPrompt; + + if (systemPrompt.startsWith(`${CORE_HEADER}\n`)) { + // Core-only input (no Base/System). + coreBody = systemPrompt.slice(`${CORE_HEADER}\n`.length).trim(); + baseAndSystem = ""; + } else { + const lastAt = systemPrompt.lastIndexOf(CORE_MARKER_INLINE); + if (lastAt !== -1) { + coreBody = systemPrompt.slice(lastAt + CORE_MARKER_INLINE.length).trim(); + baseAndSystem = systemPrompt.slice(0, lastAt); + } } - // Otherwise the head (up to the first [System] boundary, or the whole string) - // is the [Base] body. - const marker = "\n[System]\n"; - const at = systemPrompt.indexOf(marker); - const head = at === -1 ? systemPrompt : systemPrompt.slice(0, at); - const baseBody = head.replace(/^\[Base]\n/, "").trim(); - if (baseBody) sections.push({ title: "Base", body: baseBody }); - - if (at !== -1) { - const systemBody = systemPrompt.slice(at + marker.length).trim(); - sections.push({ title: "System", body: systemBody }); + // ── 2. Parse Base/System from the remaining prefix ──────────────────────── + if (baseAndSystem) { + // Persona-only frame: no [Base], starts directly with [System]. + if (baseAndSystem.startsWith("[System]\n")) { + const body = baseAndSystem.slice("[System]\n".length).trim(); + if (body) sections.push({ title: "System", body }); + } else { + // Base (up to the first [System] boundary) or base-only. + const marker = "\n[System]\n"; + const at = baseAndSystem.indexOf(marker); + const head = at === -1 ? baseAndSystem : baseAndSystem.slice(0, at); + const baseBody = head.replace(/^\[Base]\n/, "").trim(); + if (baseBody) sections.push({ title: "Base", body: baseBody }); + + if (at !== -1) { + const systemBody = baseAndSystem.slice(at + marker.length).trim(); + if (systemBody) sections.push({ title: "System", body: systemBody }); + } + } } + // ── 3. Append core section last ─────────────────────────────────────────── + if (coreBody) sections.push({ title: CORE_HEADER, body: coreBody }); + return sections; } From 00533b87878a005c08208e3f12ed688d4a21800c Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 11 Jul 2026 10:58:53 -0400 Subject: [PATCH 2/6] fix(desktop): show core memory in observer context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map the wire header [Agent Memory — core] to the display title "Core Memory" in the observer Prompt context section. The parser boundary constant (CORE_HEADER) is unchanged — still matches the exact wire header so an earlier mention inside a persona body stays literal. Update all test assertions to expect "Core Memory" and fix the Workspace+Base+System+Core fixture to include a real [Workspace] preamble before [Base], matching the actual Buzz harness shape. The assertion pins the existing fold-Workspace-into-Base behavior and proves the appended core is returned as a distinct "Core Memory" section. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../ui/agentSessionTranscriptHelpers.test.mjs | 27 +++++++++++-------- .../ui/agentSessionTranscriptHelpers.ts | 3 ++- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index 1a0b2ad2d3..ea256699fa 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -247,7 +247,7 @@ test("parseSystemPromptSections extracts core as its own section after Base+Syst assert.deepEqual(sections, [ { title: "Base", body: "base text" }, { title: "System", body: "persona text" }, - { title: "[Agent Memory — core]", body: "my memories" }, + { title: "Core Memory", body: "my memories" }, ]); }); @@ -256,7 +256,7 @@ test("parseSystemPromptSections extracts core as its own section after Base only const sections = parseSystemPromptSections(framed); assert.deepEqual(sections, [ { title: "Base", body: "base text" }, - { title: "[Agent Memory — core]", body: "my memories" }, + { title: "Core Memory", body: "my memories" }, ]); }); @@ -265,16 +265,14 @@ test("parseSystemPromptSections extracts core as its own section after System on const sections = parseSystemPromptSections(framed); assert.deepEqual(sections, [ { title: "System", body: "persona text" }, - { title: "[Agent Memory — core]", body: "my memories" }, + { title: "Core Memory", body: "my memories" }, ]); }); test("parseSystemPromptSections returns only a core section when no Base/System present", () => { const framed = "[Agent Memory — core]\nmy memories"; const sections = parseSystemPromptSections(framed); - assert.deepEqual(sections, [ - { title: "[Agent Memory — core]", body: "my memories" }, - ]); + assert.deepEqual(sections, [{ title: "Core Memory", body: "my memories" }]); }); test("parseSystemPromptSections keeps an embedded core-like line literal when a real appended core follows", () => { @@ -300,16 +298,20 @@ test("parseSystemPromptSections keeps an embedded core-like line literal when a body: "persona preamble\n[Agent Memory — core]\nthis is NOT the core section — it is inside the persona body", }, { - title: "[Agent Memory — core]", + title: "Core Memory", body: "this IS the appended core", }, ]); }); test("parseSystemPromptSections pins the realistic Workspace+Base+System+Core harness shape", () => { - // Workspace section preamble is treated as part of Base when [Base] follows. - // This is unchanged behavior; core is extracted as a new fourth section. + // The real Buzz harness emits [Workspace] content before [Base]. The parser + // folds [Workspace] into the Base section (existing unchanged behavior); + // core is extracted as a distinct "Core Memory" section last. const framed = [ + "[Workspace]", + "You are operating inside the Buzz platform.", + "", "[Base]", "You are an assistant.", "", @@ -323,10 +325,13 @@ test("parseSystemPromptSections pins the realistic Workspace+Base+System+Core ha ].join("\n"); const sections = parseSystemPromptSections(framed); assert.deepEqual(sections, [ - { title: "Base", body: "You are an assistant." }, + { + title: "Base", + body: "[Workspace]\nYou are operating inside the Buzz platform.\n\n[Base]\nYou are an assistant.", + }, { title: "System", body: "Custom persona instructions." }, { - title: "[Agent Memory — core]", + title: "Core Memory", body: "I am Duncan.\n## Lessons Learned\nAlways tag on handoff.", }, ]); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index 58ffc97d0b..f4484c1eb7 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -120,7 +120,8 @@ export function parseSystemPromptSections( } // ── 3. Append core section last ─────────────────────────────────────────── - if (coreBody) sections.push({ title: CORE_HEADER, body: coreBody }); + // Map the wire header to a human-readable display title. + if (coreBody) sections.push({ title: "Core Memory", body: coreBody }); return sections; } From 3d966ef543d883f1f78f13f96824c81bb11df718 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 11 Jul 2026 11:08:01 -0400 Subject: [PATCH 3/6] fix(desktop): tighten core-memory boundary to producer separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mid-string core extraction marker was `\n[Agent Memory — core]\n` (single preceding newline). The only producer that appends a core block (`with_core()`) emits `\n\n[Agent Memory — core]\n` (blank-line separator). A no-core persona containing that exact header line on its own would be split incorrectly by the looser match. Tighten CORE_MARKER_INLINE to the double-newline form so a bare `[Agent Memory — core]` line inside a persona body (single preceding newline) is never mistaken for the real appended frame. Core-only input (start-of-string) is unchanged. Update the JSDoc comment to distinguish the two extraction cases and name the producer shape explicitly. Add a regression test where a no-core System persona contains the exact single-newline header line and proves the whole body remains in System. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../ui/agentSessionTranscriptHelpers.test.mjs | 19 ++++++++++ .../ui/agentSessionTranscriptHelpers.ts | 38 +++++++++++-------- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index ea256699fa..44f85215f6 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -304,6 +304,25 @@ test("parseSystemPromptSections keeps an embedded core-like line literal when a ]); }); +test("parseSystemPromptSections keeps exact core header literal when only a single newline precedes it (no-core persona)", () => { + // A no-core [System] persona that contains the exact header text on its own + // line (preceded by only a single \n, not the double-newline appended + // separator) must NOT be extracted as a Core Memory section. + const framed = [ + "[System]", + "persona preamble", + "[Agent Memory — core]", + "this is persona text, not a real core block", + ].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { + title: "System", + body: "persona preamble\n[Agent Memory — core]\nthis is persona text, not a real core block", + }, + ]); +}); + test("parseSystemPromptSections pins the realistic Workspace+Base+System+Core harness shape", () => { // The real Buzz harness emits [Workspace] content before [Base]. The parser // folds [Workspace] into the Base section (existing unchanged behavior); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index f4484c1eb7..0177006df3 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -57,32 +57,40 @@ export function parsePromptText(text: string): { /** * Split the framed `session/new` `systemPrompt` into its `Base`/`System`/ - * `Agent Memory — core` sub-sections deterministically. + * `Core Memory` sub-sections deterministically. * * The harness frames the value as: * `[Base]\n{base}\n\n[System]\n{persona}\n\n[Agent Memory — core]\n{core}` - * with any section omitted when absent. We first extract the trailing - * `[Agent Memory — core]` block (using the LAST occurrence of the boundary - * so an earlier mention inside a persona body stays literal when the actual - * appended core frame follows). The remaining prefix is then split on the - * FIRST `\n[System]\n` boundary into Base/System. + * with any section omitted when absent. Two core extraction cases: * - * Unlike the generic `parsePromptSections`, no embedded `[...]` line inside a - * body can start a new section — so a persona containing a bracketed line, or a - * mid-string-elided header on an oversize prompt, can never drop a label or - * inflate the section count. + * - **Start of string** (`[Agent Memory — core]\n…`): core-only input with no + * Base/System prefix. + * - **Appended frame** (`\n\n[Agent Memory — core]\n…`): the blank-line separator + * that `with_core()` always emits before appending the core. Using `LAST` + * occurrence ensures an earlier mention of the header inside a persona body + * (with only a single preceding newline) stays literal. + * + * The remaining prefix after core extraction is split on the FIRST + * `\n[System]\n` boundary into Base/System. Unlike the generic + * `parsePromptSections`, no embedded `[...]` line inside a body can start a new + * section — so a persona containing a bracketed line, or a mid-string-elided + * header on an oversize prompt, can never drop a label or inflate the count. */ export function parseSystemPromptSections( systemPrompt: string, ): PromptSection[] { const sections: PromptSection[] = []; - // ── 1. Extract the trailing [Agent Memory — core] block ────────────────── - // Use the LAST occurrence so a persona that itself contains an - // "[Agent Memory — core]" line remains literal when the real appended frame - // follows later in the string. + // ── 1. Extract the [Agent Memory — core] block ─────────────────────────── + // Two producer shapes: + // • Core-only: systemPrompt starts with the header (no Base/System). + // • Appended: `with_core()` emits "\n\n[Agent Memory — core]\n{core}". + // Using the LAST occurrence of the double-newline-prefixed boundary + // means a bare "[Agent Memory — core]" line inside a persona (which + // can only be preceded by a single newline in the section body) is + // never confused with the real appended frame. const CORE_HEADER = "[Agent Memory — core]"; - const CORE_MARKER_INLINE = `\n${CORE_HEADER}\n`; // mid-string boundary + const CORE_MARKER_INLINE = `\n\n${CORE_HEADER}\n`; // blank-line-prefixed appended boundary let coreBody: string | null = null; let baseAndSystem = systemPrompt; From 050f6f4d617cca8aed63b06d483498bf7cb7e468 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 11 Jul 2026 12:07:33 -0400 Subject: [PATCH 4/6] refactor(observer): consolidate system-prompt as standalone session card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session/new.systemPrompt is session-scoped, not turn-scoped. Previously the grouper held it in a pendingSystemPrompt slot and injected it into the first turn's CheckCheck dialog alongside per-turn prompt context, creating two possible presentations for the same data. After this change the grouper always emits session/new as a standalone 'System prompt' single block placed before the first turn's user bubble, for every session: normal first turn, no-user-prompt, restart, and incomplete archive. The CheckCheck dialog now contains only per-turn context (session/prompt / steer context) and setup status — never Base/System/Core Memory sections. Changes: - buildBlocksForRun: pendingSystemPrompt is emitted standalone before the first turn block in every code path (user-prompt or not). Removed the systemPromptForTurn/consumedSystemPrompts machinery. - classifyTurnItems: removed externalSystemPrompt parameter; prompt segments no longer carry a systemPrompt field. - TranscriptTurnSegment 'prompt' kind: removed systemPrompt field. - TurnPromptBlock / PromptUserMessage: removed systemPrompt prop; contextSections now contains only per-turn context sections. - flattenDisplayBlocks: removed segment.systemPrompt emission. - Tests: inverted firstTurnSequence assertion (standalone present, not in turn bundle); simplified restart and multi-turn ordering tests to check standalone single block; removed stale seg.systemPrompt refs. - E2E test 11: inverted to assert System prompt card is visible in feed and CheckCheck dialog contains only per-turn context (2 sections). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/playwright.config.ts | 1 + .../agents/ui/AgentSessionTranscriptList.tsx | 10 +- .../agents/ui/agentSessionTranscript.test.mjs | 18 +- .../agentSessionTranscriptGrouping.test.mjs | 44 ++-- .../ui/agentSessionTranscriptGrouping.ts | 65 ++---- .../tests/e2e/core-memory-screenshots.spec.ts | 214 ++++++++++++++++++ .../e2e/observer-feed-screenshots.spec.ts | 31 ++- 7 files changed, 282 insertions(+), 101 deletions(-) create mode 100644 desktop/tests/e2e/core-memory-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 507bb79f52..7b2978f5e5 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ "**/profile-active-turn.spec.ts", "**/config-bridge-screenshots.spec.ts", "**/observer-feed-screenshots.spec.ts", + "**/core-memory-screenshots.spec.ts", "**/activity-scope-label-screenshots.spec.ts", "**/local-archive-screenshots.spec.ts", "**/agent-readiness-screenshots.spec.ts", diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 0c0717aae4..3dc6149e93 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -449,7 +449,6 @@ function TranscriptTurnSegmentView({ context={segment.context} profiles={profiles} setup={segment.setup} - systemPrompt={segment.systemPrompt} user={segment.user} /> ); @@ -653,13 +652,11 @@ function TurnPromptBlock({ context, profiles, setup, - systemPrompt, user, }: { context: Extract | null; profiles?: UserProfileLookup; setup: Extract[]; - systemPrompt: Extract | null; user: Extract; }) { return ( @@ -677,7 +674,6 @@ function TurnPromptBlock({ item={user} profiles={profiles} setup={setup} - systemPrompt={systemPrompt} /> ); @@ -688,18 +684,16 @@ function PromptUserMessage({ item, profiles, setup = [], - systemPrompt = null, }: { context?: Extract | null; item: Extract; profiles?: UserProfileLookup; setup?: Extract[]; - systemPrompt?: Extract | null; }) { const [contextOpen, setContextOpen] = React.useState(false); const contextSections = React.useMemo( - () => [...(systemPrompt?.sections ?? []), ...(context?.sections ?? [])], - [context, systemPrompt], + () => [...(context?.sections ?? [])], + [context], ); return ( diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index d31cb558e4..058b12c213 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -1525,17 +1525,10 @@ test("buildTranscript restart sequence: system-prompt renders after session-boun const boundaryIdx = blocks.indexOf(boundaryBlocks[0]); // (b) The system-prompt must appear AFTER the boundary — not before it. - // Production path: system-prompt rides in the prompt bundle of the turn that - // carries the user @mention (acpSource "session/new" in the prompt segment). + // After consolidation: system-prompt always renders as a standalone single + // (never inside the prompt bundle), before the turn's user bubble. const systemPromptBlockIdx = blocks.findIndex( - (b) => - (b.kind === "single" && b.item?.acpSource === "session/new") || - (b.kind === "turn" && - b.segments.some( - (seg) => - seg.kind === "prompt" && - seg.systemPrompt?.acpSource === "session/new", - )), + (b) => b.kind === "single" && b.item?.acpSource === "session/new", ); assert.ok( systemPromptBlockIdx !== -1, @@ -1548,9 +1541,8 @@ test("buildTranscript restart sequence: system-prompt renders after session-boun // (c) sess-2 activity must appear AFTER both boundary AND system-prompt. // This pins the full required order: boundary → system-prompt → activity. - // Because session/prompt:user is present, the system-prompt rides inside the - // prompt bundle of the same turn as the tool activity — they share the same - // block. Use flat item order to assert system-prompt precedes activity. + // The system-prompt standalone single and the tool activity are now in + // distinct blocks; use flat item indices to assert ordering. const flat = flattenDisplayBlocks(blocks); const sess2ActivityItem = flat.find( (i) => i.type === "tool" && i.sessionId === "sess-2", diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs index 8da3e52a1e..9d9f0393b0 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -580,7 +580,6 @@ test("buildTranscriptDisplayBlocks bundles steer message with steer context behi const steerSegment = block.segments[1]; assert.equal(steerSegment.user.id, "steer:chan-1:turn-1"); assert.equal(steerSegment.context?.id, "steer-context:chan-1:turn-1"); - assert.equal(steerSegment.systemPrompt, null); assert.deepEqual(steerSegment.setup, []); // No standalone "Prompt context" metadata row leaks into the feed. assert.ok( @@ -934,20 +933,20 @@ function firstTurnSequence() { ]; } -test("buildTranscriptDisplayBlocks_firstTurnSequence_noStandaloneSystemPrompt", () => { - // Regression for: pre-resolution null-session items (turn_started + session/new) - // were assigned to a synthetic "unknown" run, causing the system prompt to emit - // as a standalone "System prompt" row instead of staying in the prompt bundle. +test("buildTranscriptDisplayBlocks_firstTurnSequence_standaloneSystemPrompt", () => { + // session/new is session-scoped, not turn-scoped. After the consolidation, + // the grouper emits it as a standalone "System prompt" single block that + // appears BEFORE the first user-prompt turn — never inside the prompt bundle. const blocks = buildTranscriptDisplayBlocks(firstTurnSequence()); - // (a) No standalone "System prompt" single block. + // (a) Exactly one standalone "System prompt" single block. const systemPromptSingles = blocks.filter( (b) => b.kind === "single" && b.item.acpSource === "session/new", ); assert.equal( systemPromptSingles.length, - 0, - "system prompt must not appear as a standalone single block", + 1, + "system prompt must appear as exactly one standalone single block", ); // (b) No session-boundary blocks — this is a single-session transcript. @@ -958,7 +957,7 @@ test("buildTranscriptDisplayBlocks_firstTurnSequence_noStandaloneSystemPrompt", "must be zero session-boundary blocks for a first-turn single-session sequence", ); - // (c) System prompt is inside the turn group (consumed by the prompt bundle). + // (c) System prompt is NOT inside any turn group (not in prompt bundle). const turnBlocks = blocks.filter((b) => b.kind === "turn"); assert.ok(turnBlocks.length > 0, "at least one turn block must exist"); const allTurnItems = flattenDisplayBlocks(turnBlocks); @@ -966,8 +965,20 @@ test("buildTranscriptDisplayBlocks_firstTurnSequence_noStandaloneSystemPrompt", (item) => item.acpSource === "session/new", ); assert.ok( - systemPromptInTurn, - "system prompt item must be present inside a turn block (prompt bundle)", + !systemPromptInTurn, + "system prompt item must NOT be present inside a turn block", + ); + + // (d) Standalone system-prompt block appears BEFORE the first turn block. + const systemPromptIdx = blocks.indexOf(systemPromptSingles[0]); + const firstTurnIdx = blocks.findIndex((b) => b.kind === "turn"); + assert.ok( + firstTurnIdx !== -1, + "there must be at least one turn block after the system prompt", + ); + assert.ok( + systemPromptIdx < firstTurnIdx, + `system-prompt single (${systemPromptIdx}) must precede first turn block (${firstTurnIdx})`, ); }); @@ -1134,14 +1145,7 @@ test("buildTranscriptDisplayBlocks_restartScenario_systemPromptAfterBoundary", ( // (b) The session/new item must appear AFTER the boundary, not before it. const boundaryIndex = blocks.indexOf(boundaryBlocks[0]); const systemPromptBlockIndex = blocks.findIndex( - (b) => - (b.kind === "single" && b.item.acpSource === "session/new") || - (b.kind === "turn" && - b.segments.some( - (seg) => - seg.kind === "prompt" && - seg.systemPrompt?.acpSource === "session/new", - )), + (b) => b.kind === "single" && b.item.acpSource === "session/new", ); assert.ok( systemPromptBlockIndex !== -1, @@ -1227,7 +1231,7 @@ test("buildTranscriptDisplayBlocks_firstEverSession_systemPromptInSingleRun", () "no session-boundary block for a first-ever single session", ); - // System prompt must appear somewhere in the output (in the turn bundle). + // System prompt must appear somewhere in the output (as a standalone single). const flat = flattenDisplayBlocks(blocks); assert.ok( flat.some((i) => i.acpSource === "session/new"), diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index 967e08adce..55f1f4303b 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -8,7 +8,6 @@ export type TranscriptTurnSegment = | { kind: "prompt"; user: Extract; - systemPrompt: Extract | null; context: Extract | null; setup: Extract[]; }; @@ -139,13 +138,7 @@ type TurnBucket = { items: TranscriptItem[]; }; -function classifyTurnItems( - items: TranscriptItem[], - externalSystemPrompt: Extract< - TranscriptItem, - { type: "metadata" } - > | null = null, -): TranscriptTurnSegment[] { +function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { const userPrompt = items.find(isUserPrompt) ?? null; const setupLifecycle = items.filter(isSetupLifecycle); const promptContext = items.find(isPromptContext) ?? null; @@ -168,7 +161,6 @@ function classifyTurnItems( return { kind: "prompt", user: item, - systemPrompt: null, context: pendingSteerContexts.shift() ?? null, setup: [], }; @@ -190,7 +182,6 @@ function classifyTurnItems( { kind: "prompt", user: userPrompt, - systemPrompt: externalSystemPrompt, context: promptContext, setup: setupLifecycle, }, @@ -406,8 +397,7 @@ function getRenderClass(item: TranscriptItem) { * Pre-resolution items arrive with `sessionId: null` before any session has * been assigned. We defer those leading null-session items and **prepend them * to the first run that has a non-null sessionId**, so they stay in the same - * session run as the turn they belong to and the `pendingSystemPrompt` slot in - * `buildBlocksForRun` can consume them correctly. + * session run as the turn they belong to. * * **Restart / session-boundary ordering**: on a restart the normalizer stamps * `session/new` with `latestSessionId` (the OLD session's id) because the new @@ -611,8 +601,10 @@ function buildBlocksForRun( { 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. + // session/new items (turnId=null, acpSource "session/new") are session-scoped. + // We hold them here until the first turn is encountered, then emit them as a + // standalone single block BEFORE that turn — placing the System prompt card + // at the head of the session, before any user bubble or tool activity. let pendingSystemPrompt: Extract< TranscriptItem, { type: "metadata" } @@ -622,7 +614,7 @@ function buildBlocksForRun( const turnId = item.turnId; if (!turnId) { if (isSystemPrompt(item)) { - // Hold system-prompt for injection into the next turn's prompt segment. + // Hold for standalone emission before the next turn block. pendingSystemPrompt = item; } else { displayOrder.push({ kind: "single", item }); @@ -639,9 +631,6 @@ function buildBlocksForRun( 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 }); @@ -653,32 +642,16 @@ function buildBlocksForRun( continue; } - // Inject system-prompt into the first turn that has a user-prompt item - // (it surfaces in the prompt bundle before user/context/activity). - // If the turn has no user prompt, emit the system-prompt as a standalone - // block BEFORE the turn so it always leads the session regardless of - // whether a user prompt is present. - let systemPromptForTurn: Extract< - TranscriptItem, - { type: "metadata" } - > | null = null; - if ( - pendingSystemPrompt && - !consumedSystemPrompts.has(pendingSystemPrompt.id) - ) { - if (bucket.items.some(isUserPrompt)) { - // Consume into the prompt bundle. - systemPromptForTurn = pendingSystemPrompt; - consumedSystemPrompts.add(pendingSystemPrompt.id); - } else { - // No user prompt in this turn — emit standalone before the turn block - // so the system prompt leads the session (boundary → prompt → activity). - blocks.push({ kind: "single", item: pendingSystemPrompt }); - consumedSystemPrompts.add(pendingSystemPrompt.id); - } + // Emit a pending system-prompt as a standalone block BEFORE this turn so + // the System prompt card always leads the session (boundary → System prompt + // → first user bubble / tool activity), regardless of whether the turn has + // a user prompt or not. + if (pendingSystemPrompt) { + blocks.push({ kind: "single", item: pendingSystemPrompt }); + pendingSystemPrompt = null; } - const segments = classifyTurnItems(bucket.items, systemPromptForTurn); + const segments = classifyTurnItems(bucket.items); if (segments.length > 0) { blocks.push({ kind: "turn", @@ -691,10 +664,7 @@ function buildBlocksForRun( // If system-prompt was never consumed (session/new arrived without any // subsequent turn — stream still incomplete or mid-restart), emit it as a // standalone single so it remains visible and is not silently dropped. - if ( - pendingSystemPrompt && - !consumedSystemPrompts.has(pendingSystemPrompt.id) - ) { + if (pendingSystemPrompt) { blocks.push({ kind: "single", item: pendingSystemPrompt }); } @@ -724,9 +694,6 @@ export function flattenDisplayBlocks( } else if (segment.kind === "prompt") { result.push(segment.user); result.push(...segment.setup); - if (segment.systemPrompt) { - result.push(segment.systemPrompt); - } if (segment.context) { result.push(segment.context); } diff --git a/desktop/tests/e2e/core-memory-screenshots.spec.ts b/desktop/tests/e2e/core-memory-screenshots.spec.ts new file mode 100644 index 0000000000..1528685967 --- /dev/null +++ b/desktop/tests/e2e/core-memory-screenshots.spec.ts @@ -0,0 +1,214 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const SHOTS = "test-results/core-memory"; + +const OBSERVER_AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey; +const CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; +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"], + }, +]; + +const SYSTEM_PROMPT_WITH_CORE = + "[Base]\nYou are a helpful AI assistant running in Buzz.\n\n" + + "[System]\nYou are Observer Agent. You coordinate multi-agent workflows.\n\n" + + "[Agent Memory — core]\n" + + "I am Duncan — full-stack executor on the Buzz team.\n\n" + + "## Lessons Learned\n\n" + + "### Tag teammates on handoff — ALWAYS (CRITICAL)\n" + + "After completing a task, @mention the next person in the workflow.\n\n" + + "### Source claims require a fresh fetch (CRITICAL)\n" + + "Never quote repo source off a stale clone."; + +async function waitForSeedHook(page: import("@playwright/test").Page) { + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function", + null, + { timeout: 10_000 }, + ); +} + +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"); + 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 }); + 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; +} + +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 }, + ); + await page.waitForTimeout(300); +} + +async function settleAnimations(panel: import("@playwright/test").Locator) { + await panel.evaluate((el) => + Promise.all( + el + .getAnimations({ subtree: true }) + .filter((a) => { + const timing = a.effect?.getTiming(); + return timing?.iterations !== Number.POSITIVE_INFINITY; + }) + .map((a) => a.finished), + ), + ); +} + +test.describe("core memory section screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ page }) => { + page.on("pageerror", (err) => { + console.error("PAGE ERROR:", err.message); + }); + }); + + test("01-core-memory-collapsed", 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: SYSTEM_PROMPT_WITH_CORE }, + }, + }, + ]); + + await expect(feedPanel.getByText("System prompt")).toBeVisible({ + timeout: 5_000, + }); + + // Open the outer details element so the sections are visible. + 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; + } + }); + + // Wait for Core Memory section label to appear (collapsed by default). + await expect(feedPanel.getByText("Core Memory")).toBeVisible({ + timeout: 5_000, + }); + await settleAnimations(feedPanel); + + // Crop to the transcript-metadata-item to show the sections tightly. + const metadataItem = feedPanel.getByTestId("transcript-metadata-item"); + await metadataItem.screenshot({ + path: `${SHOTS}/01-core-memory-collapsed.png`, + }); + }); + + test("02-core-memory-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: SYSTEM_PROMPT_WITH_CORE }, + }, + }, + ]); + + await expect(feedPanel.getByText("System prompt")).toBeVisible({ + timeout: 5_000, + }); + + 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; + } + }); + + // Expand all section accordions including Core Memory. + const sectionButtons = feedPanel + .getByTestId("transcript-metadata-item") + .getByTestId("transcript-prompt-context-sections") + .getByRole("button"); + const allButtons = await sectionButtons.all(); + expect(allButtons.length).toBeGreaterThan(0); + for (const btn of allButtons) { + await btn.click(); + } + + await expect(feedPanel.getByText("Core Memory")).toBeVisible({ + timeout: 5_000, + }); + await settleAnimations(feedPanel); + + const metadataItem = feedPanel.getByTestId("transcript-metadata-item"); + await metadataItem.screenshot({ + path: `${SHOTS}/02-core-memory-expanded.png`, + }); + }); +}); diff --git a/desktop/tests/e2e/observer-feed-screenshots.spec.ts b/desktop/tests/e2e/observer-feed-screenshots.spec.ts index b3f1b87b6a..16065f40e3 100644 --- a/desktop/tests/e2e/observer-feed-screenshots.spec.ts +++ b/desktop/tests/e2e/observer-feed-screenshots.spec.ts @@ -640,7 +640,7 @@ test.describe("observer feed screenshots", () => { }); }); - test("11 — first-turn bundle: user bubble + Checks ingress dialog", async ({ + test("11 — first-turn bundle: standalone system-prompt card + Checks ingress (per-turn context only)", async ({ page, }) => { await installMockBridge(page, { managedAgents: MANAGED_AGENTS }); @@ -648,9 +648,10 @@ test.describe("observer feed screenshots", () => { // Full realistic pool.rs first-turn wire sequence: // turn_started → session/new → session_resolved → session/prompt - // Verifies the prompt bundle keeps context out of the feed: the system - // prompt and prompt context both live in the dialog opened via the - // CheckCheck footer ingress, with system-prompt sections listed first. + // Verifies the consolidated presentation: session/new.systemPrompt always + // renders as a standalone top-level "System prompt" card (never injected into + // the CheckCheck bundle). The CheckCheck dialog contains only per-turn context + // (Buzz event / Thread context) — no Base/System/Core Memory sections. await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [ { seq: 1, @@ -722,12 +723,19 @@ test.describe("observer feed screenshots", () => { await expect(feedPanel.getByTestId("transcript-prompt-bundle")).toBeVisible( { timeout: 5_000 }, ); - // Neither the system prompt nor the prompt context renders in the feed. - await expect(feedPanel.getByText("System prompt")).toHaveCount(0); + + // Standalone "System prompt" card is visible as a top-level feed row — + // it must remain present even after the first prompt arrives. + await expect(feedPanel.getByText("System prompt")).toBeVisible({ + timeout: 5_000, + }); + + // Per-turn prompt context (Buzz event / Thread context) does NOT appear + // as a standalone feed row — it lives behind the CheckCheck toggle. await expect(feedPanel.getByText("Prompt context")).toHaveCount(0); - // Open the dialog: system-prompt sections (Base/System) come before the - // prompt-context sections (Buzz event/Thread context). + // Open the CheckCheck dialog: it contains ONLY per-turn context sections + // (Buzz event, Thread context). Base/System/Core Memory must NOT appear. await feedPanel.getByTestId("transcript-prompt-context-toggle").click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible({ timeout: 5_000 }); @@ -735,9 +743,10 @@ test.describe("observer feed screenshots", () => { .getByTestId("transcript-prompt-context-sections") .locator("article") .allInnerTexts(); - expect(sectionTitles.length).toBe(4); - expect(sectionTitles[0]).toContain("Base"); - expect(sectionTitles[1]).toContain("System"); + // Only per-turn context sections (Buzz event + Thread context) — no system-prompt sections. + expect(sectionTitles.length).toBe(2); + expect(sectionTitles[0]).toContain("Buzz event"); + expect(sectionTitles[1]).toContain("Thread context"); await settleAnimations(dialog); await dialog.screenshot({ path: `${SHOTS}/11-first-turn-ordering.png`, From 9237da6fe75e76a1259a8677791322d8e4a692bb Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 11 Jul 2026 12:35:17 -0400 Subject: [PATCH 5/6] fix(observer): fix system-prompt lifecycle, Channel Canvas parsing, and test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Key system-prompt metadata items by (channel, seq, timestamp) — same dedup pair as observerRelayStore — so distinct sessions across archive rebuilds each retain their own card even when two processes emit the same seq. Removes the per-channel key that caused loss on restart. - Replace pendingNewRunBuffer reinit with accumulate: multiple session/new markers arriving before the new session resolves (rapid restart loop) are now all accumulated in order. Trailing null-session frames between markers are also buffered in the same pending queue and flush together before the first turn, preserving wire order and the documented 'marker(s) plus trailing null-session items' contract. - Replace pendingSystemPrompts + separate null-deferred queue with a unified pendingBeforeTurn buffer in buildBlocksForRun: once the first session/new item opens the buffer, subsequent null-turnId items (lifecycle frames, etc.) are also deferred so the full wire-order sequence emits together before the first turn block. - Add Channel Canvas parser: with_canvas() appends the frame last, so extraction runs in reverse producer order (Canvas first, then Core, then Base/System). Last-occurrence guard prevents an embedded header inside a persona body from being mistaken for the appended frame. - Update stale docs and comments: agentSessionTranscriptGrouping.ts top-level JSDoc, splitIntoSessionRuns marker(s) language, inline comment at session/new handler, restart test comments. - New tests: Canvas extraction variants (full 4-section, canvas-only, Core+Canvas, Base+Canvas, omitted, embedded-single-newline guard, and embedded-with-real-appended-canvas guard); two-session E2E pinning all four standalone card sections and asserting CheckCheck contains only Buzz/thread context; same-seq-different-timestamp collision guard; two pre-resolution session/new markers with interleaved null-frame (proves accumulate path and null-frame survival); grouping restart test assertions updated to two-card/two-session shape. - Playwright shot 11 and 06 updated: four-section fixture, expand card before asserting section headings, explicit forbidden-label check on dialog article texts (Base/System/Core Memory/Channel Canvas absent). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/ui/agentSessionTranscript.test.mjs | 329 +++++++++++++++--- .../agents/ui/agentSessionTranscript.ts | 49 +-- .../agentSessionTranscriptGrouping.test.mjs | 299 ++++++++++++++++ .../ui/agentSessionTranscriptGrouping.ts | 104 ++++-- .../ui/agentSessionTranscriptHelpers.test.mjs | 132 +++++++ .../ui/agentSessionTranscriptHelpers.ts | 89 +++-- .../e2e/observer-feed-screenshots.spec.ts | 64 +++- 7 files changed, 890 insertions(+), 176 deletions(-) diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index 058b12c213..708e827f36 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -950,11 +950,11 @@ test("buildTranscript does not render unknown session/update types (firehose saf 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. + // so turn_started creates the turn bucket first. Without the grouper holding the + // system-prompt as a standalone block, displayOrder becomes + // [turn(turn-1), single(system-prompt)] and System prompt renders after Prompt context. + // The fix: system-prompt items (acpSource "session/new") are held in + // pendingSystemPrompts[] and emitted as standalone blocks BEFORE the first turn. const events = [ { seq: 1, @@ -1050,8 +1050,8 @@ 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. + // On subsequent turns, session/new does not re-fire. The system-prompt standalone + // block emitted before turn-1 must not re-appear in turn-2's display output. const events = [ { seq: 1, @@ -1326,23 +1326,14 @@ test("buildTranscript correctly renders prompt segment when session/prompt arriv // --- session-boundary ordering: restart scenario end-to-end ───────────────── -test("buildTranscript restart sequence: system-prompt renders after session-boundary, not before", () => { +test("buildTranscript restart sequence: both sessions retain their own system-prompt card", () => { // Full two-session restart sequence routed through processTranscriptEvent. - // This is the production scenario that the grouping-only fix missed: - // upsertMetadata's replaceItem kept the system-prompt item at its first-session - // array position (before any sess-1 activity), so splitIntoSessionRuns saw it - // first (currentRun === null) and placed it in run sess-1 — above the boundary. - // - // Fix requires BOTH: - // 1. Normalizer: reposition system-prompt to the stream tail on re-fire - // (removeItem + pushItem with new timestamp) so it arrives at the restart - // event position in stream order. - // 2. Grouping: splitIntoSessionRuns pending-buffer re-anchors the - // stale-stamped tail item into the new session run's head. - // - // Final display order must be: boundary < system-prompt < user-prompt < sess-2 activity. - // The session/prompt:user event mirrors production (a restart is triggered by - // a user @mention; the screenshot that surfaced the bug shows exactly this). + // Each session/new event is keyed by (seq, timestamp) — the same dedup pair + // used by observerRelayStore — producing distinct system-prompt items for + // sess-1 and sess-2. Both must be present in the final transcript and placed + // in the correct run: + // sess-1: system-prompt → sess-1 activity (before boundary) + // sess-2: boundary → system-prompt → user-prompt → sess-2 activity const CH = "33333333-3333-3333-3333-333333333333"; const AUTHOR_HEX = "c".repeat(64); const USER_EVENT_HEX = "e".repeat(64); @@ -1428,8 +1419,9 @@ test("buildTranscript restart sequence: system-prompt renders after session-boun turnId: "turn-2", payload: { source: "channel", triggeringEventIds: [] }, }, - // session/new re-fires for the same channel (restart signal): - // upsertMetadata must REPOSITION to tail, not replace in-place. + // sess-2 session/new: distinct (seq, timestamp) produces a separate item + // (system-prompt:${CH}:6:2026-07-01T11:00:00.100Z) that lands naturally + // at the stream tail and is re-anchored to run sess-2. { seq: 6, timestamp: "2026-07-01T11:00:00.100Z", @@ -1524,56 +1516,283 @@ test("buildTranscript restart sequence: system-prompt renders after session-boun const boundaryIdx = blocks.indexOf(boundaryBlocks[0]); - // (b) The system-prompt must appear AFTER the boundary — not before it. - // After consolidation: system-prompt always renders as a standalone single - // (never inside the prompt bundle), before the turn's user bubble. - const systemPromptBlockIdx = blocks.findIndex( + // (b) Exactly two system-prompt standalone blocks — one per session. + const systemPromptBlocks = blocks.filter( (b) => b.kind === "single" && b.item?.acpSource === "session/new", ); + assert.equal( + systemPromptBlocks.length, + 2, + "must be exactly two system-prompt standalone blocks — one per session", + ); + const sess1PromptIdx = blocks.indexOf(systemPromptBlocks[0]); + const sess2PromptIdx = blocks.indexOf(systemPromptBlocks[1]); + + // sess-1 system-prompt appears BEFORE the boundary (in run sess-1). assert.ok( - systemPromptBlockIdx !== -1, - "system-prompt item must be present in the output", + sess1PromptIdx < boundaryIdx, + `sess-1 system-prompt (idx ${sess1PromptIdx}) must precede boundary (idx ${boundaryIdx})`, ); + // sess-2 system-prompt appears AFTER the boundary (in run sess-2). assert.ok( - boundaryIdx < systemPromptBlockIdx, - `boundary (idx ${boundaryIdx}) must precede system-prompt (idx ${systemPromptBlockIdx})`, + boundaryIdx < sess2PromptIdx, + `boundary (idx ${boundaryIdx}) must precede sess-2 system-prompt (idx ${sess2PromptIdx})`, ); - // (c) sess-2 activity must appear AFTER both boundary AND system-prompt. - // This pins the full required order: boundary → system-prompt → activity. - // The system-prompt standalone single and the tool activity are now in - // distinct blocks; use flat item indices to assert ordering. + // (c) sess-2 activity must appear AFTER both boundary AND sess-2 system-prompt. + // Required order: boundary → sess-2 system-prompt → sess-2 user-prompt/activity. + // Note: the system-prompt item retains sessionId=null (it was created from a + // null-session event); we locate it in the flat array by its item id, not sessionId. const flat = flattenDisplayBlocks(blocks); - const sess2ActivityItem = flat.find( + const sess2SpItemId = systemPromptBlocks[1].item?.id; + const flatSess2PromptIdx = flat.findIndex((i) => i.id === sess2SpItemId); + const flatSess2ActivityIdx = flat.findIndex( (i) => i.type === "tool" && i.sessionId === "sess-2", ); assert.ok( - sess2ActivityItem, + flatSess2PromptIdx !== -1, + "sess-2 system-prompt must appear in flattened output", + ); + assert.ok( + flatSess2ActivityIdx !== -1, "sess-2 tool activity must be present in flattened output", ); - const sess2BlockIdx = blocks.findIndex((b) => - flattenDisplayBlocks([b]).some( - (i) => i.type === "tool" && i.sessionId === "sess-2", - ), + assert.ok( + flatSess2PromptIdx < flatSess2ActivityIdx, + `sess-2 system-prompt (flat idx ${flatSess2PromptIdx}) must precede sess-2 activity (flat idx ${flatSess2ActivityIdx})`, + ); +}); + +// --- same-seq different-timestamp: both system-prompt cards survive (archive collision) ── + +test("buildTranscript same-seq different-timestamp session/new events both produce distinct system-prompt cards", () => { + // Archive rebuild scenario: two ObserverHandle processes both start at seq=1 + // for the same channel. The (seq, timestamp) key pair must distinguish them + // so neither card is lost. If keyed by seq alone, the second would silently + // replace the first. + const CH = "55555555-5555-5555-5555-555555555555"; + const events = [ + // Process A: seq=1, timestamp T1 + { + seq: 1, + timestamp: "2026-07-01T10:00:00.000Z", + kind: "acp_write", + agentIndex: 0, + channelId: CH, + sessionId: "sess-a", + turnId: "turn-a", + payload: { + jsonrpc: "2.0", + id: 1, + method: "session/new", + params: { + systemPrompt: "[Base]\nProcess A.", + }, + }, + }, + // Process B: seq=1 (same!), timestamp T2 (different) + { + seq: 1, + timestamp: "2026-07-01T11:00:00.000Z", + kind: "acp_write", + agentIndex: 0, + channelId: CH, + sessionId: "sess-b", + turnId: "turn-b", + payload: { + jsonrpc: "2.0", + id: 1, + method: "session/new", + params: { + systemPrompt: "[Base]\nProcess B.", + }, + }, + }, + ]; + + const rawItems = buildTranscript(events); + const systemPromptItems = rawItems.filter( + (i) => i.type === "metadata" && i.acpSource === "session/new", + ); + + assert.equal( + systemPromptItems.length, + 2, + "two same-seq different-timestamp session/new events must produce two distinct system-prompt items — not one (key collision guard)", + ); + + const bodies = systemPromptItems.map((i) => + (i.sections ?? []).map((s) => s.body).join("|"), ); assert.ok( - boundaryIdx < sess2BlockIdx, - `boundary (idx ${boundaryIdx}) must precede sess-2 activity (idx ${sess2BlockIdx})`, + bodies.some((b) => b.includes("Process A")), + "Process A system-prompt card must be present", ); - // system-prompt and tool activity may be in the same turn block (prompt bundle - // + activity segment). Assert ordering via flat item indices. - const flatSystemPromptIdx = flat.findIndex( - (i) => i.acpSource === "session/new", + assert.ok( + bodies.some((b) => b.includes("Process B")), + "Process B system-prompt card must be present", ); - const flatSess2ActivityIdx = flat.findIndex( - (i) => i.type === "tool" && i.sessionId === "sess-2", +}); + +test("buildTranscript four-section system prompt card is standalone with all sections; CheckCheck context contains only Buzz/thread context", () => { + // Production scenario: harness emits [Base]/[System]/[Agent Memory — core]/[Channel Canvas] + // in systemPrompt. The display layer must: + // (a) Render it as a standalone single block (acpSource "session/new"), + // NOT inside any turn's prompt bundle. + // (b) The standalone item must carry all four sections in order: + // Base → System → Core Memory → Channel Canvas. + // (c) The prompt segment's context (CheckCheck dialog) must contain only + // the session/prompt:context sections (Buzz event + Thread context), + // never the system-prompt sections. + const CH = "44444444-4444-4444-4444-444444444444"; + const events = [ + { + seq: 1, + timestamp: "2026-07-01T10:00:00.000Z", + kind: "turn_started", + agentIndex: 0, + channelId: CH, + 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, + sessionId: null, + turnId: "turn-1", + payload: { + jsonrpc: "2.0", + id: 1, + method: "session/new", + params: { + systemPrompt: [ + "[Base]", + "You are a helpful assistant.", + "", + "[System]", + "Custom persona.", + "", + "[Agent Memory — core]", + "I am Duncan.", + "", + "[Channel Canvas]", + "## Sprint board", + "Two items.", + ].join("\n"), + }, + }, + }, + { + seq: 3, + timestamp: "2026-07-01T10:00:00.200Z", + kind: "session_resolved", + agentIndex: 0, + channelId: CH, + sessionId: "sess-e2e", + turnId: "turn-1", + payload: { sessionId: "sess-e2e", isNewSession: true }, + }, + { + seq: 4, + timestamp: "2026-07-01T10:00:01.000Z", + kind: "acp_write", + agentIndex: 0, + channelId: CH, + sessionId: "sess-e2e", + turnId: "turn-1", + payload: { + jsonrpc: "2.0", + id: 2, + method: "session/prompt", + params: { + sessionId: "sess-e2e", + 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 rawItems = buildTranscript(events); + const blocks = buildTranscriptDisplayBlocks(rawItems); + const flat = flattenDisplayBlocks(blocks); + + // (a) Exactly one standalone system-prompt single block. + const systemPromptBlocks = blocks.filter( + (b) => b.kind === "single" && b.item?.acpSource === "session/new", + ); + assert.equal( + systemPromptBlocks.length, + 1, + "exactly one standalone system-prompt single block", + ); + + // (b) The standalone item carries all four sections in order. + const spItem = systemPromptBlocks[0].item; + assert.ok(spItem, "system-prompt block must have an item"); + const titles = (spItem.sections ?? []).map((s) => s.title); + assert.deepEqual( + titles, + ["Base", "System", "Core Memory", "Channel Canvas"], + "system-prompt standalone card must carry Base → System → Core Memory → Channel Canvas in order", + ); + + // (c) The system-prompt item must NOT be inside any turn group. + // Verify by checking that no turn block contains it. + const systemPromptInTurnBlock = blocks.some( + (b) => + b.kind === "turn" && + b.segments.some((seg) => + seg.kind === "prompt" + ? seg.user.acpSource === "session/new" || + seg.context?.acpSource === "session/new" + : seg.kind === "item" + ? seg.item.acpSource === "session/new" + : false, + ), ); assert.ok( - flatSystemPromptIdx !== -1, - "system-prompt must appear in flattened output", + !systemPromptInTurnBlock, + "system-prompt must not appear inside any turn block", + ); + + // (d) CheckCheck context (prompt segment's context field) must contain only + // the session/prompt:context item — Buzz/thread context only, no system-prompt sections. + const promptContextItem = flat.find( + (i) => i.acpSource === "session/prompt:context", + ); + assert.ok( + promptContextItem, + "prompt context item (acpSource session/prompt:context) must be present", + ); + const contextSectionTitles = (promptContextItem.sections ?? []).map( + (s) => s.title, + ); + // Must have Buzz event and Thread context sections, NOT Base/System/Core Memory/Channel Canvas. + assert.ok( + contextSectionTitles.some((t) => t.toLowerCase().includes("buzz")), + "prompt context must contain a Buzz event section", ); assert.ok( - flatSystemPromptIdx < flatSess2ActivityIdx, - `system-prompt (flat idx ${flatSystemPromptIdx}) must precede sess-2 activity (flat idx ${flatSess2ActivityIdx})`, + !contextSectionTitles.some( + (t) => + t === "Base" || + t === "System" || + t === "Core Memory" || + t === "Channel Canvas", + ), + "prompt context must NOT contain system-prompt sections (Base/System/Core Memory/Channel Canvas)", ); }); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index cdd2e72616..d17237a2ab 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -115,19 +115,6 @@ function replaceItem(d: TranscriptDraft, id: string, updated: TranscriptItem) { d.itemsById.set(id, updated); } -/** - * Remove an item from the draft by id. Used when an item needs to be - * repositioned — remove it from its current slot then push it to the tail. - */ -function removeItem(d: TranscriptDraft, id: string) { - ensureMutable(d); - const idx = d.items.findIndex((it) => it.id === id); - if (idx !== -1) { - d.items.splice(idx, 1); - } - d.itemsById.delete(id); -} - function pushItem(d: TranscriptDraft, item: TranscriptItem) { ensureMutable(d); d.items.push(item); @@ -579,28 +566,6 @@ function upsertMetadata( ) { const existing = d.itemsById.get(id); if (existing?.type === "metadata") { - // A re-fire of a system-prompt item (acpSource "session/new") is a - // session-restart signal. The existing item sits at the position of the - // FIRST session's event, not the restart event — keeping it there would - // leave it before the new-session boundary divider in the display feed. - // Reposition it to the stream TAIL with the new event's timestamp so the - // display grouper can forward it to the correct (new) session run. - // All other metadata items (prompt-context, steer-context) replace in-place - // as before — they don't carry cross-session boundary semantics. - if (acpSource === "session/new") { - removeItem(d, id); - sealOpenMessages(d); - pushItem(d, { - ...existing, - sections, - timestamp, - channelId: ctx.channelId, - turnId: ctx.turnId ?? existing.turnId, - sessionId: ctx.sessionId ?? existing.sessionId, - acpSource, - }); - return; - } replaceItem(d, id, { ...existing, sections, @@ -905,11 +870,13 @@ 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 — 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. + // the harness as [Base]/[System]/[Agent Memory — core]/[Channel Canvas]. + // Each session/new event is keyed by (seq, timestamp) — the same dedup + // pair used by observerRelayStore — so distinct sessions each retain + // their own system-prompt card even across archive rebuilds where two + // processes may emit the same seq. turnId: null keeps it out of turn + // buckets; acpSource "session/new" lets the display grouper place it + // as a standalone card before the session's first turn. const params = asRecord(payload.params); const systemPrompt = asString(params.systemPrompt); if (systemPrompt) { @@ -917,7 +884,7 @@ export function processTranscriptEvent( if (sections.length > 0) { upsertMetadata( d, - `system-prompt:${ch}`, + `system-prompt:${ch}:${event.seq}:${event.timestamp}`, "System prompt", sections, event.timestamp, diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs index 9d9f0393b0..8e1968dc5d 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -1410,3 +1410,302 @@ test("buildTranscriptDisplayBlocks_sessionBoundary_keyStableAcrossPrepend", () = "runIndex after prepend must be 2, confirming it is unstable", ); }); + +// ── Multiple pre-resolution session/new markers — both survive ──────────────── + +test("buildTranscriptDisplayBlocks_twoPreResolutionSessionNewMarkers_bothSurviveBeforeTurn", () => { + // Restart loop: two session/new items arrive after sess-1 resolves but before + // sess-2 resolves (e.g. a rapid double-restart). A null-session frame arrives + // between them (models a lifecycle event with no session yet). All three must + // appear in the sess-2 run — neither marker nor the interleaved null-session + // frame may be silently dropped. This is the documented + // "marker(s) plus trailing null-session items" contract. + // + // Wire order: + // toolA(sess-1) → sp-a(sess-1 stale) → null-frame(null) → sp-b(sess-1 stale) → toolB(sess-2) + const ts1 = "2026-07-08T12:00:00.000Z"; + const ts2 = "2026-07-08T12:01:00.000Z"; + const ts3 = "2026-07-08T12:01:15.000Z"; + const ts4 = "2026-07-08T12:01:30.000Z"; + const ts5 = "2026-07-08T12:02:00.000Z"; + + // A null-session lifecycle frame (models turn_started or similar arriving + // between two session/new firings during a rapid double-restart). + const nullFrame = { + id: "null-frame", + type: "lifecycle", + renderClass: "lifecycle", + title: "Session starting", + text: "", + timestamp: ts3, + acpSource: "turn_started", + turnId: null, + sessionId: null, + channelId: "chan-1", + }; + + const items = [ + { ...sessionItem("toolA", "sess-1", ts1), turnId: "turn-1" }, + systemPromptItem("sp-a", "sess-1", ts2), + nullFrame, + systemPromptItem("sp-b", "sess-1", ts4), + { ...sessionItem("toolB", "sess-2", ts5), turnId: "turn-2" }, + ]; + + const blocks = buildTranscriptDisplayBlocks(items, "sess-2"); + + // Exactly one boundary between the two sessions. + const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary"); + assert.equal( + boundaryBlocks.length, + 1, + "exactly one session-boundary for two sessions", + ); + const boundaryIdx = blocks.indexOf(boundaryBlocks[0]); + + // Both system-prompt blocks must be present. + const systemPromptBlocks = blocks.filter( + (b) => b.kind === "single" && b.item?.acpSource === "session/new", + ); + assert.equal( + systemPromptBlocks.length, + 2, + "both session/new markers must produce standalone single blocks", + ); + + const spAIdx = blocks.findIndex( + (b) => b.kind === "single" && b.item?.id === "sp-a", + ); + const spBIdx = blocks.findIndex( + (b) => b.kind === "single" && b.item?.id === "sp-b", + ); + const toolBIdx = blocks.findIndex((b) => + flattenDisplayBlocks([b]).some((i) => i.id === "toolB"), + ); + + // Both system-prompt blocks appear AFTER the boundary. + assert.ok( + boundaryIdx < spAIdx, + `boundary (${boundaryIdx}) must precede sp-a (${spAIdx})`, + ); + assert.ok( + boundaryIdx < spBIdx, + `boundary (${boundaryIdx}) must precede sp-b (${spBIdx})`, + ); + + // Both system-prompt blocks appear BEFORE toolB's turn block. + assert.ok( + spAIdx < toolBIdx, + `sp-a (${spAIdx}) must precede toolB (${toolBIdx})`, + ); + assert.ok( + spBIdx < toolBIdx, + `sp-b (${spBIdx}) must precede toolB (${toolBIdx})`, + ); + + // sp-a must appear before sp-b (wire order preserved). + assert.ok( + spAIdx < spBIdx, + `sp-a (${spAIdx}) must precede sp-b (${spBIdx}) — wire order must be preserved`, + ); + + // The interleaved null-session frame must survive and appear in the sess-2 run + // (after the boundary). This proves the "marker plus trailing null-session items" + // contract — the null frame lands in the pending buffer between sp-a and sp-b + // and must not be lost when the second marker arrives. + const flat = flattenDisplayBlocks(blocks); + const nullFrameInFlat = flat.some((i) => i.id === "null-frame"); + assert.ok( + nullFrameInFlat, + "interleaved null-session frame must survive in the flattened output", + ); + const nullFrameBlockIdx = blocks.findIndex((b) => + flattenDisplayBlocks([b]).some((i) => i.id === "null-frame"), + ); + assert.ok( + boundaryIdx < nullFrameBlockIdx, + `null-session frame (${nullFrameBlockIdx}) must appear after boundary (${boundaryIdx}) — it belongs to the new session run`, + ); + // Wire order: sp-a → null-frame → sp-b (all flush in insertion order). + const flatSpAIdx = flat.findIndex((i) => i.id === "sp-a"); + const flatNullIdx = flat.findIndex((i) => i.id === "null-frame"); + const flatSpBIdx = flat.findIndex((i) => i.id === "sp-b"); + assert.ok( + flatSpAIdx < flatNullIdx, + `sp-a (${flatSpAIdx}) must precede null-frame (${flatNullIdx}) in flat output`, + ); + assert.ok( + flatNullIdx < flatSpBIdx, + `null-frame (${flatNullIdx}) must precede sp-b (${flatSpBIdx}) in flat output`, + ); + + // toolA (sess-1) must appear before the boundary. + const toolAIdx = blocks.findIndex((b) => + flattenDisplayBlocks([b]).some((i) => i.id === "toolA"), + ); + assert.ok( + toolAIdx < boundaryIdx, + `toolA (${toolAIdx}) must be before boundary (${boundaryIdx})`, + ); +}); + +// ── Regression suite for pendingForTurn / openBatch sealing ───────────────── + +// Production wire order: turn_started(t1) → session/new → session_resolved(t1). +// The session/new arrives AFTER the bucket for turn-1 already exists. +// The system-prompt must still be hoisted before turn-1. +test("buildTranscriptDisplayBlocks_productionWireOrder_systemPromptBeforeTurn1", () => { + const ts = "2026-07-08T12:00:00.000Z"; + const items = [ + // turn_started creates the bucket BEFORE session/new fires. + { + id: "turn-started-t1", + type: "lifecycle", + renderClass: "lifecycle", + title: "Turn started", + text: "", + timestamp: ts, + acpSource: "turn_started", + turnId: "turn-1", + sessionId: "sess-1", + channelId: "chan-1", + }, + // session/new fires AFTER the bucket exists (stale-stamped session). + systemPromptItem("sp", "sess-0", ts), + // session_resolved reuses the existing turn-1 bucket. + { + id: "session-resolved-t1", + type: "lifecycle", + renderClass: "lifecycle", + title: "Session resolved", + text: "", + timestamp: ts, + acpSource: "session_resolved", + turnId: "turn-1", + sessionId: "sess-1", + channelId: "chan-1", + }, + // A real activity item so the turn block has segments and appears in output. + { + id: "assistant-t1", + type: "message", + role: "assistant", + title: "Assistant", + text: "Hello", + timestamp: ts, + acpSource: "agent_message_chunk", + turnId: "turn-1", + sessionId: "sess-1", + channelId: "chan-1", + }, + ]; + + const blocks = buildTranscriptDisplayBlocks(items); + + // sp must appear as a standalone single block. + const spBlockIdx = blocks.findIndex( + (b) => b.kind === "single" && b.item?.id === "sp", + ); + // turn-1 must appear as a turn block. + const turnBlockIdx = blocks.findIndex( + (b) => b.kind === "turn" && b.turnId === "turn-1", + ); + + assert.ok(spBlockIdx !== -1, "sp block must be present"); + assert.ok(turnBlockIdx !== -1, "turn-1 block must be present"); + + // System-prompt block must appear before the turn-1 block. + assert.ok( + spBlockIdx < turnBlockIdx, + `sp block (${spBlockIdx}) must precede turn-1 block (${turnBlockIdx}) — system-prompt must flush before its anchor turn even when bucket pre-existed`, + ); +}); + +// Later null-session items (after turn-1) must NOT be hoisted before turn-1. +// Sequence: sp(null) → turn-1 item → later-null-frame(null) → turn-2 item +test("buildTranscriptDisplayBlocks_laterNullFrameAfterTurn1_notHoistedBeforeTurn1", () => { + const ts = "2026-07-08T12:00:00.000Z"; + const items = [ + systemPromptItem("sp", "sess-1", ts), + { ...sessionItem("toolA", "sess-1", ts), turnId: "turn-1" }, + { + id: "later-null-frame", + type: "lifecycle", + renderClass: "lifecycle", + title: "Session resolved", + text: "", + timestamp: ts, + acpSource: "session_resolved", + turnId: null, + sessionId: "sess-1", + channelId: "chan-1", + }, + { ...sessionItem("toolB", "sess-1", ts), turnId: "turn-2" }, + ]; + + const blocks = buildTranscriptDisplayBlocks(items); + const flat = flattenDisplayBlocks(blocks); + + const spIdx = flat.findIndex((i) => i.id === "sp"); + const toolAIdx = flat.findIndex((i) => i.id === "toolA"); + const laterNullIdx = flat.findIndex((i) => i.id === "later-null-frame"); + const toolBIdx = flat.findIndex((i) => i.id === "toolB"); + + assert.ok(spIdx !== -1, "sp must be present"); + assert.ok(toolAIdx !== -1, "toolA must be present"); + assert.ok(laterNullIdx !== -1, "later-null-frame must be present"); + assert.ok(toolBIdx !== -1, "toolB must be present"); + + assert.ok(spIdx < toolAIdx, `sp (${spIdx}) must precede toolA (${toolAIdx})`); + assert.ok( + toolAIdx < laterNullIdx, + `toolA (${toolAIdx}) must precede later-null-frame (${laterNullIdx}) — post-turn null items must NOT be hoisted`, + ); + assert.ok( + laterNullIdx < toolBIdx, + `later-null-frame (${laterNullIdx}) must precede toolB (${toolBIdx})`, + ); +}); + +// A leading non-system-prompt null-session item before any session/new must be +// emitted inline and must not prevent subsequent system-prompt hoisting. The +// buffer-open decision must not depend on displayOrder.length === 0. +test("buildTranscriptDisplayBlocks_leadingSingleBeforeSessionNew_emittedInline", () => { + const ts = "2026-07-08T12:00:00.000Z"; + const items = [ + { + id: "early-lifecycle", + type: "lifecycle", + renderClass: "lifecycle", + title: "Agent connected", + text: "", + timestamp: ts, + acpSource: "agent_connected", + turnId: null, + sessionId: "sess-1", + channelId: "chan-1", + }, + systemPromptItem("sp", "sess-1", ts), + { ...sessionItem("toolA", "sess-1", ts), turnId: "turn-1" }, + ]; + + const blocks = buildTranscriptDisplayBlocks(items); + const flat = flattenDisplayBlocks(blocks); + + const earlyIdx = flat.findIndex((i) => i.id === "early-lifecycle"); + const spIdx = flat.findIndex((i) => i.id === "sp"); + const toolAIdx = flat.findIndex((i) => i.id === "toolA"); + + assert.ok(earlyIdx !== -1, "early-lifecycle must be present"); + assert.ok(spIdx !== -1, "sp must be present"); + assert.ok(toolAIdx !== -1, "toolA must be present"); + + assert.ok( + earlyIdx < toolAIdx, + `early-lifecycle (${earlyIdx}) must precede toolA (${toolAIdx})`, + ); + assert.ok( + spIdx < toolAIdx, + `sp (${spIdx}) must precede toolA (${toolAIdx}) — system-prompt must be hoisted even after a leading item`, + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index 55f1f4303b..2914f9f5b1 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -405,13 +405,14 @@ function getRenderClass(item: TranscriptItem) { * (stale), not null. Under the plain grouping rule it lands in the prior run, * causing System Prompt to render ABOVE the session-boundary divider. * - * Fix: a `session/new` marker (`isSystemPrompt`) is a session-START signal. + * Fix: `session/new` markers (`isSystemPrompt`) are session-START signals. * When one arrives and a prior run already exists, park it (and any null-session - * items that follow) in `pendingNewRunBuffer`. When the next distinct non-null - * sessionId resolves, flush the buffer to the HEAD of that new run — placing - * System Prompt after the boundary. If no new session ever resolves after the - * marker (stream ends mid-restart), flush back into the current run so nothing - * is dropped. + * items that follow) in `pendingNewRunBuffer`. Multiple markers may arrive + * before the new session resolves (rapid restart loop) — all are accumulated + * in order. When the next distinct non-null sessionId resolves, flush the + * buffer to the HEAD of that new run — placing all System Prompt cards after + * the boundary. If no new session ever resolves after the marker(s) (stream + * ends mid-restart), flush back into the current run so nothing is dropped. * * Mid-stream null-session items (after at least one session has resolved, and * no pending-new-run buffer is open) are attributed to the most recently seen @@ -429,7 +430,7 @@ function splitIntoSessionRuns( let currentRun: { sessionId: string; items: TranscriptItem[] } | null = null; // Buffer for items that arrive before any session has resolved. const preSessionBuffer: TranscriptItem[] = []; - // Buffer for a session/new marker (and any null-session items trailing it) + // Buffer for session/new marker(s) (and any null-session items trailing them) // that must be re-anchored to the NEXT resolved session (restart scenario). let pendingNewRunBuffer: TranscriptItem[] | null = null; @@ -452,8 +453,15 @@ function splitIntoSessionRuns( // A session/new marker after a prior run is a restart signal: park it in // the pending buffer so it re-anchors to the next distinct session run. + // Multiple session/new markers may arrive before the new session resolves + // (e.g. a rapid restart loop) — accumulate all of them rather than + // reinitializing, so no marker is silently dropped. if (isSystemPrompt(item) && currentRun !== null) { - pendingNewRunBuffer = [item]; + if (pendingNewRunBuffer !== null) { + pendingNewRunBuffer.push(item); + } else { + pendingNewRunBuffer = [item]; + } continue; } @@ -502,10 +510,11 @@ function splitIntoSessionRuns( * 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. + * System-prompt items (acpSource "session/new") are keyed per source-session + * event identity (channel + seq + timestamp) so each session retains its own + * card. They render as standalone single blocks at the head of their session + * run — before the run's first turn — and never enter the prompt segment or + * the CheckCheck (Prompt context) dialog. * * When items span multiple sessions (archived history + live session), a * `session-boundary` block is injected between consecutive session runs. @@ -601,27 +610,50 @@ function buildBlocksForRun( { kind: "single"; item: TranscriptItem } | { kind: "turn"; turnId: string } > = []; - // session/new items (turnId=null, acpSource "session/new") are session-scoped. - // We hold them here until the first turn is encountered, then emit them as a - // standalone single block BEFORE that turn — placing the System prompt card - // at the head of the session, before any user bubble or tool activity. - let pendingSystemPrompt: Extract< - TranscriptItem, - { type: "metadata" } - > | null = null; + // Strategy: accumulate null-session items into `openBatch` while no turn has + // been seen. The batch opens on the first system-prompt item. When the first + // turn-bound item is encountered (whether it creates a new bucket or reuses + // one already started earlier — e.g. turn_started → session/new → + // session_resolved, all on the same turn ID), the open batch is bound to that + // turn ID in `pendingForTurn` and sealed (openBatch = null) so subsequent + // null-session items fall through to inline emission and are NOT hoisted to + // the session head. Append to any existing batch for that turn rather than + // overwrite so a repeated seal on the same turn cannot lose an earlier batch. + // End-of-stream fallback: if no turn ever arrives, emit the open batch as + // standalone singles so the cards remain visible. + let openBatch: TranscriptItem[] | null = null; + // Maps a turn ID to the items that must flush before that turn in the render pass. + const pendingForTurn = new Map(); for (const item of items) { const turnId = item.turnId; if (!turnId) { - if (isSystemPrompt(item)) { - // Hold for standalone emission before the next turn block. - pendingSystemPrompt = item; + if (openBatch !== null) { + // Pre-turn buffering is active — accumulate in wire order. + openBatch.push(item); + } else if (isSystemPrompt(item)) { + // First system-prompt seen and no turn yet — open the batch. + openBatch = [item]; } else { + // No pending batch, or batch already sealed; emit inline. displayOrder.push({ kind: "single", item }); } continue; } + // Seal the open batch on the FIRST turn-bound item encountered while + // buffering is active — new bucket or existing (e.g. session_resolved + // reusing the turn_started bucket). + if (openBatch !== null) { + const existing = pendingForTurn.get(turnId); + if (existing) { + existing.push(...openBatch); + } else { + pendingForTurn.set(turnId, openBatch); + } + openBatch = null; + } + let bucket = turnBuckets.get(turnId); if (!bucket) { bucket = { turnId, items: [] }; @@ -642,13 +674,13 @@ function buildBlocksForRun( continue; } - // Emit a pending system-prompt as a standalone block BEFORE this turn so - // the System prompt card always leads the session (boundary → System prompt - // → first user bubble / tool activity), regardless of whether the turn has - // a user prompt or not. - if (pendingSystemPrompt) { - blocks.push({ kind: "single", item: pendingSystemPrompt }); - pendingSystemPrompt = null; + // Flush any items bound to this turn (system-prompt card(s) plus any + // interleaved null-session frames) as standalone blocks before the turn. + const beforeTurn = pendingForTurn.get(entry.turnId); + if (beforeTurn) { + for (const pending of beforeTurn) { + blocks.push({ kind: "single", item: pending }); + } } const segments = classifyTurnItems(bucket.items); @@ -661,11 +693,13 @@ function buildBlocksForRun( } } - // If system-prompt was never consumed (session/new arrived without any - // subsequent turn — stream still incomplete or mid-restart), emit it as a - // standalone single so it remains visible and is not silently dropped. - if (pendingSystemPrompt) { - blocks.push({ kind: "single", item: pendingSystemPrompt }); + // End-of-stream fallback: session/new arrived but no turn followed yet + // (incomplete stream or mid-restart). Emit as standalone singles so the + // cards remain visible and are not silently dropped. + if (openBatch !== null) { + for (const pending of openBatch) { + blocks.push({ kind: "single", item: pending }); + } } return blocks; diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index 44f85215f6..c4c8087a79 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -355,3 +355,135 @@ test("parseSystemPromptSections pins the realistic Workspace+Base+System+Core ha }, ]); }); + +// ── Channel Canvas extraction ───────────────────────────────────────────────── + +test("parseSystemPromptSections pins the full Base+System+Core+Canvas harness shape", () => { + // with_canvas() appends "\n\n[Channel Canvas]\n{body}" after the core block. + // All four sections must be extracted in display order. + const framed = [ + "[Base]", + "You are an assistant.", + "", + "[System]", + "Persona instructions.", + "", + "[Agent Memory — core]", + "I am Duncan.", + "", + "[Channel Canvas]", + "## Project Board", + "Current sprint items.", + ].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Base", body: "You are an assistant." }, + { title: "System", body: "Persona instructions." }, + { title: "Core Memory", body: "I am Duncan." }, + { + title: "Channel Canvas", + body: "## Project Board\nCurrent sprint items.", + }, + ]); +}); + +test("parseSystemPromptSections extracts canvas when no Base/System/Core present (canvas-only)", () => { + const framed = ["[Channel Canvas]", "Canvas only."].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Channel Canvas", body: "Canvas only." }, + ]); +}); + +test("parseSystemPromptSections extracts Core+Canvas with no Base/System", () => { + const framed = [ + "[Agent Memory — core]", + "Core content.", + "", + "[Channel Canvas]", + "Canvas content.", + ].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Core Memory", body: "Core content." }, + { title: "Channel Canvas", body: "Canvas content." }, + ]); +}); + +test("parseSystemPromptSections extracts Base+Canvas when no System or Core", () => { + const framed = [ + "[Base]", + "Base content.", + "", + "[Channel Canvas]", + "Canvas content.", + ].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Base", body: "Base content." }, + { title: "Channel Canvas", body: "Canvas content." }, + ]); +}); + +test("parseSystemPromptSections omits canvas section when no canvas present (no regression)", () => { + // Existing Base+System+Core shape must still produce exactly three sections. + const framed = [ + "[Base]", + "Base.", + "", + "[System]", + "System.", + "", + "[Agent Memory — core]", + "Core.", + ].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Base", body: "Base." }, + { title: "System", body: "System." }, + { title: "Core Memory", body: "Core." }, + ]); +}); + +test("parseSystemPromptSections keeps embedded canvas-like line literal when only a single newline precedes it (no-canvas persona)", () => { + // A [Channel Canvas] header inside a persona body preceded by a single \n + // (not the double-newline appended separator) must NOT be extracted as a + // Canvas section — same last-occurrence guard as the Core extraction. + const framed = [ + "[System]", + "persona preamble", + "[Channel Canvas]", + "this is persona text, not a real canvas block", + ].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { + title: "System", + body: "persona preamble\n[Channel Canvas]\nthis is persona text, not a real canvas block", + }, + ]); +}); + +test("parseSystemPromptSections keeps an embedded canvas-like line literal when a real appended canvas follows", () => { + // If the persona body contains "[Channel Canvas]\n..." with only a single + // preceding newline AND a real appended canvas (double-newline boundary) + // follows, the LAST-occurrence guard picks the appended one as the real + // boundary, leaving the embedded one in the System body. + const framed = [ + "[System]", + "persona preamble", + "[Channel Canvas]", + "this is inside the persona body — NOT the real canvas", + "", + "[Channel Canvas]", + "this IS the appended canvas", + ].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { + title: "System", + body: "persona preamble\n[Channel Canvas]\nthis is inside the persona body — NOT the real canvas", + }, + { title: "Channel Canvas", body: "this IS the appended canvas" }, + ]); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index 0177006df3..8d53e98205 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -57,63 +57,76 @@ export function parsePromptText(text: string): { /** * Split the framed `session/new` `systemPrompt` into its `Base`/`System`/ - * `Core Memory` sub-sections deterministically. + * `Core Memory`/`Channel Canvas` sub-sections deterministically. * - * The harness frames the value as: - * `[Base]\n{base}\n\n[System]\n{persona}\n\n[Agent Memory — core]\n{core}` - * with any section omitted when absent. Two core extraction cases: + * The harness composes the value in order: + * `[Base]\n{base}\n\n[System]\n{persona}\n\n[Agent Memory — core]\n{core}\n\n[Channel Canvas]\n{canvas}` + * with any section omitted when absent. Extraction runs in reverse producer + * order so that each `lastIndexOf` search operates on the full input and + * each extraction boundary is unambiguous. * - * - **Start of string** (`[Agent Memory — core]\n…`): core-only input with no - * Base/System prefix. - * - **Appended frame** (`\n\n[Agent Memory — core]\n…`): the blank-line separator - * that `with_core()` always emits before appending the core. Using `LAST` - * occurrence ensures an earlier mention of the header inside a persona body - * (with only a single preceding newline) stays literal. + * Three extraction passes before Base/System parsing: * - * The remaining prefix after core extraction is split on the FIRST - * `\n[System]\n` boundary into Base/System. Unlike the generic - * `parsePromptSections`, no embedded `[...]` line inside a body can start a new - * section — so a persona containing a bracketed line, or a mid-string-elided - * header on an oversize prompt, can never drop a label or inflate the count. + * 1. **Canvas** (`[Channel Canvas]`): appended last by `with_canvas()`. + * - Start-of-string: canvas-only input. + * - Appended frame (`\n\n[Channel Canvas]\n`): blank-line separator used by + * `with_canvas()`; LAST occurrence guards against an embedded header in a + * persona body (single preceding newline only). + * + * 2. **Core** (`[Agent Memory — core]`): appended before canvas by `with_core()`. + * Same two cases, same last-occurrence guard. + * + * 3. **Base/System**: remainder after canvas and core extraction. + * Split on the first `\n[System]\n` boundary; no embedded `[...]` line + * inside a body can start a new section. */ export function parseSystemPromptSections( systemPrompt: string, ): PromptSection[] { const sections: PromptSection[] = []; - // ── 1. Extract the [Agent Memory — core] block ─────────────────────────── - // Two producer shapes: - // • Core-only: systemPrompt starts with the header (no Base/System). - // • Appended: `with_core()` emits "\n\n[Agent Memory — core]\n{core}". - // Using the LAST occurrence of the double-newline-prefixed boundary - // means a bare "[Agent Memory — core]" line inside a persona (which - // can only be preceded by a single newline in the section body) is - // never confused with the real appended frame. + // ── 1. Extract [Channel Canvas] ─────────────────────────────────────────── + const CANVAS_HEADER = "[Channel Canvas]"; + const CANVAS_MARKER_INLINE = `\n\n${CANVAS_HEADER}\n`; + let canvasBody: string | null = null; + let remainder = systemPrompt; + + if (remainder.startsWith(`${CANVAS_HEADER}\n`)) { + canvasBody = remainder.slice(`${CANVAS_HEADER}\n`.length).trim(); + remainder = ""; + } else { + const lastCanvas = remainder.lastIndexOf(CANVAS_MARKER_INLINE); + if (lastCanvas !== -1) { + canvasBody = remainder + .slice(lastCanvas + CANVAS_MARKER_INLINE.length) + .trim(); + remainder = remainder.slice(0, lastCanvas); + } + } + + // ── 2. Extract [Agent Memory — core] ────────────────────────────────────── const CORE_HEADER = "[Agent Memory — core]"; - const CORE_MARKER_INLINE = `\n\n${CORE_HEADER}\n`; // blank-line-prefixed appended boundary + const CORE_MARKER_INLINE = `\n\n${CORE_HEADER}\n`; let coreBody: string | null = null; - let baseAndSystem = systemPrompt; - if (systemPrompt.startsWith(`${CORE_HEADER}\n`)) { - // Core-only input (no Base/System). - coreBody = systemPrompt.slice(`${CORE_HEADER}\n`.length).trim(); - baseAndSystem = ""; + if (remainder.startsWith(`${CORE_HEADER}\n`)) { + coreBody = remainder.slice(`${CORE_HEADER}\n`.length).trim(); + remainder = ""; } else { - const lastAt = systemPrompt.lastIndexOf(CORE_MARKER_INLINE); - if (lastAt !== -1) { - coreBody = systemPrompt.slice(lastAt + CORE_MARKER_INLINE.length).trim(); - baseAndSystem = systemPrompt.slice(0, lastAt); + const lastCore = remainder.lastIndexOf(CORE_MARKER_INLINE); + if (lastCore !== -1) { + coreBody = remainder.slice(lastCore + CORE_MARKER_INLINE.length).trim(); + remainder = remainder.slice(0, lastCore); } } - // ── 2. Parse Base/System from the remaining prefix ──────────────────────── + // ── 3. Parse Base/System from the remaining prefix ──────────────────────── + const baseAndSystem = remainder; if (baseAndSystem) { - // Persona-only frame: no [Base], starts directly with [System]. if (baseAndSystem.startsWith("[System]\n")) { const body = baseAndSystem.slice("[System]\n".length).trim(); if (body) sections.push({ title: "System", body }); } else { - // Base (up to the first [System] boundary) or base-only. const marker = "\n[System]\n"; const at = baseAndSystem.indexOf(marker); const head = at === -1 ? baseAndSystem : baseAndSystem.slice(0, at); @@ -127,9 +140,9 @@ export function parseSystemPromptSections( } } - // ── 3. Append core section last ─────────────────────────────────────────── - // Map the wire header to a human-readable display title. + // ── 4. Append core and canvas sections in producer order ────────────────── if (coreBody) sections.push({ title: "Core Memory", body: coreBody }); + if (canvasBody) sections.push({ title: "Channel Canvas", body: canvasBody }); return sections; } diff --git a/desktop/tests/e2e/observer-feed-screenshots.spec.ts b/desktop/tests/e2e/observer-feed-screenshots.spec.ts index 16065f40e3..5b390a404f 100644 --- a/desktop/tests/e2e/observer-feed-screenshots.spec.ts +++ b/desktop/tests/e2e/observer-feed-screenshots.spec.ts @@ -418,7 +418,7 @@ test.describe("observer feed screenshots", () => { 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.", + "[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.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\n## Sprint board\nTwo active items.", }, }, }, @@ -428,6 +428,24 @@ test.describe("observer feed screenshots", () => { timeout: 5_000, }); + // All four section headings must be present in the card — expand it first. + 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 expect(feedPanel.getByText("Base")).toBeVisible({ timeout: 5_000 }); + await expect(feedPanel.getByText("System", { exact: true })).toBeVisible({ + timeout: 5_000, + }); + await expect(feedPanel.getByText("Core Memory")).toBeVisible({ + timeout: 5_000, + }); + await expect(feedPanel.getByText("Channel Canvas")).toBeVisible({ + timeout: 5_000, + }); + // Open the outer ActivityRow
to reveal the section content, // then click each section accordion button to expand it (mirrors shot 05 — // system-prompt sections now render as React button accordions, not native @@ -651,7 +669,7 @@ test.describe("observer feed screenshots", () => { // Verifies the consolidated presentation: session/new.systemPrompt always // renders as a standalone top-level "System prompt" card (never injected into // the CheckCheck bundle). The CheckCheck dialog contains only per-turn context - // (Buzz event / Thread context) — no Base/System/Core Memory sections. + // (Buzz event / Thread context) — no Base/System/Core Memory/Channel Canvas sections. await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [ { seq: 1, @@ -677,7 +695,7 @@ test.describe("observer feed screenshots", () => { 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.", + "[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.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\n## Sprint board\nTwo active items.", }, }, }, @@ -724,29 +742,61 @@ test.describe("observer feed screenshots", () => { { timeout: 5_000 }, ); + // Scroll to the top of the feed to bring the standalone "System prompt" + // card (which precedes the user bubble) into view. + await feedPanel.evaluate((el) => el.scrollTo({ top: 0 })); + // Standalone "System prompt" card is visible as a top-level feed row — // it must remain present even after the first prompt arrives. await expect(feedPanel.getByText("System prompt")).toBeVisible({ timeout: 5_000, }); + // The standalone card shows "4 sections" collapsed — expand it to reveal + // the section headings, then assert all four are present. + 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 expect(feedPanel.getByText("Base")).toBeVisible({ timeout: 5_000 }); + await expect(feedPanel.getByText("System", { exact: true })).toBeVisible({ + timeout: 5_000, + }); + await expect(feedPanel.getByText("Core Memory")).toBeVisible({ + timeout: 5_000, + }); + await expect(feedPanel.getByText("Channel Canvas")).toBeVisible({ + timeout: 5_000, + }); + // Per-turn prompt context (Buzz event / Thread context) does NOT appear // as a standalone feed row — it lives behind the CheckCheck toggle. await expect(feedPanel.getByText("Prompt context")).toHaveCount(0); // Open the CheckCheck dialog: it contains ONLY per-turn context sections - // (Buzz event, Thread context). Base/System/Core Memory must NOT appear. + // (Buzz event, Thread context). Base/System/Core Memory/Channel Canvas must NOT appear. await feedPanel.getByTestId("transcript-prompt-context-toggle").click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible({ timeout: 5_000 }); - const sectionTitles = await dialog + const sectionArticles = dialog .getByTestId("transcript-prompt-context-sections") - .locator("article") - .allInnerTexts(); + .locator("article"); + const sectionTitles = await sectionArticles.allInnerTexts(); // Only per-turn context sections (Buzz event + Thread context) — no system-prompt sections. expect(sectionTitles.length).toBe(2); expect(sectionTitles[0]).toContain("Buzz event"); expect(sectionTitles[1]).toContain("Thread context"); + // Collect all article heading text and assert none of the four + // system-prompt section labels appear — including exact "System" which + // would be ambiguous via substring search on the full dialog text. + const forbidden = ["Base", "System", "Core Memory", "Channel Canvas"]; + for (const title of sectionTitles) { + for (const label of forbidden) { + expect(title).not.toContain(label); + } + } await settleAnimations(dialog); await dialog.screenshot({ path: `${SHOTS}/11-first-turn-ordering.png`, From b795a5b0b1288f663143396ce8aae0e7aeb7afaa Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 11 Jul 2026 13:11:05 -0400 Subject: [PATCH 6/6] test(desktop): use exact render_canvas_section metadata format in fixtures Replace raw canvas body content in E2E screenshot spec and unit test fixtures with the exact metadata format emitted by render_canvas_section: Canvas revision (event ID): Last modified: Fetch current content with: buzz canvas get --channel Timestamps use seconds-only RFC3339 (SecondsFormat::Secs) to match the Rust producer. Updates observer-feed-screenshots.spec.ts (both session/new fixtures), agentSessionTranscript.test.mjs, and agentSessionTranscriptHelpers.test.mjs. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../agents/ui/agentSessionTranscript.test.mjs | 5 +++-- .../agents/ui/agentSessionTranscriptHelpers.test.mjs | 12 +++++++----- desktop/tests/e2e/observer-feed-screenshots.spec.ts | 4 ++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index 708e827f36..f3412f2e9f 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -1680,8 +1680,9 @@ test("buildTranscript four-section system prompt card is standalone with all sec "I am Duncan.", "", "[Channel Canvas]", - "## Sprint board", - "Two items.", + "Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "Last modified: 2026-07-01T10:00:00Z", + "Fetch current content with: buzz canvas get --channel 44444444-4444-4444-4444-444444444444", ].join("\n"), }, }, diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index c4c8087a79..94fd1605aa 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -359,8 +359,9 @@ test("parseSystemPromptSections pins the realistic Workspace+Base+System+Core ha // ── Channel Canvas extraction ───────────────────────────────────────────────── test("parseSystemPromptSections pins the full Base+System+Core+Canvas harness shape", () => { - // with_canvas() appends "\n\n[Channel Canvas]\n{body}" after the core block. - // All four sections must be extracted in display order. + // with_canvas() appends "\n\n[Channel Canvas]\n{metadata}" after the core block. + // render_canvas_section() emits the revision event ID, last-modified timestamp, + // and fetch command — never the canvas body. All four sections are extracted in order. const framed = [ "[Base]", "You are an assistant.", @@ -372,8 +373,9 @@ test("parseSystemPromptSections pins the full Base+System+Core+Canvas harness sh "I am Duncan.", "", "[Channel Canvas]", - "## Project Board", - "Current sprint items.", + "Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "Last modified: 2026-07-11T10:00:00Z", + "Fetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301", ].join("\n"); const sections = parseSystemPromptSections(framed); assert.deepEqual(sections, [ @@ -382,7 +384,7 @@ test("parseSystemPromptSections pins the full Base+System+Core+Canvas harness sh { title: "Core Memory", body: "I am Duncan." }, { title: "Channel Canvas", - body: "## Project Board\nCurrent sprint items.", + body: "Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301", }, ]); }); diff --git a/desktop/tests/e2e/observer-feed-screenshots.spec.ts b/desktop/tests/e2e/observer-feed-screenshots.spec.ts index 5b390a404f..4f06cd355a 100644 --- a/desktop/tests/e2e/observer-feed-screenshots.spec.ts +++ b/desktop/tests/e2e/observer-feed-screenshots.spec.ts @@ -418,7 +418,7 @@ test.describe("observer feed screenshots", () => { 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.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\n## Sprint board\nTwo active items.", + "[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.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\nCanvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301", }, }, }, @@ -695,7 +695,7 @@ test.describe("observer feed screenshots", () => { 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.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\n## Sprint board\nTwo active items.", + "[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.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\nCanvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301", }, }, },