diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 7f39639d099..faf1f9bb797 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -435,12 +435,7 @@ async fn create_session_and_apply_model( // for the session/new request. Only sent when the agent declares protocol // version >= 2 (supports systemPrompt); legacy agents ignore it. let combined_system_prompt: Option = if agent.protocol_version >= 2 { - match (ctx.base_prompt, ctx.system_prompt.as_deref()) { - (Some(bp), Some(sp)) => Some(format!("{}\n\n{sp}", bp.trim_end())), - (Some(bp), None) => Some(bp.trim_end().to_string()), - (None, Some(sp)) => Some(sp.to_string()), - (None, None) => None, - } + framed_system_prompt(ctx.base_prompt, ctx.system_prompt.as_deref()) } else { None }; @@ -660,6 +655,26 @@ pub(crate) fn prepend_base_for_legacy( } } +/// Frame the `session/new` `systemPrompt` so each present prompt carries its own +/// header, keeping the base/persona boundary recoverable downstream. +/// +/// The header framing matches the legacy per-turn path (`queue::base_section` +/// for `[Base]`, `[System]\n{...}` for the persona) so the desktop observer can +/// split the combined value into labeled sub-sections. Each prompt is wrapped +/// only when present, so a persona-only agent yields `[System]\n{persona}` +/// rather than an unlabeled blob that would be mislabeled as `[Base]`. +fn framed_system_prompt(base_prompt: Option<&str>, system_prompt: Option<&str>) -> Option { + match (base_prompt, system_prompt) { + (Some(bp), Some(sp)) => Some(format!( + "{}\n\n[System]\n{sp}", + crate::queue::base_section(bp) + )), + (Some(bp), None) => Some(crate::queue::base_section(bp)), + (None, Some(sp)) => Some(format!("[System]\n{sp}")), + (None, None) => None, + } +} + /// Core async function spawned for each prompt. /// /// Lifecycle: @@ -2336,6 +2351,36 @@ mod tests { assert_eq!(composed, "hello channel"); } + // ── framed_system_prompt tests ─────────────────────────────────────────── + // Pin the session/new systemPrompt framing: each present prompt carries its + // own header so the desktop observer can split into labeled sub-sections. + + #[test] + fn test_framed_system_prompt_both_present_carries_both_headers() { + let framed = framed_system_prompt(Some("base text"), Some("persona text")) + .expect("both present yields Some"); + assert_eq!(framed, "[Base]\nbase text\n\n[System]\npersona text"); + } + + #[test] + fn test_framed_system_prompt_base_only_labels_base() { + let framed = framed_system_prompt(Some("base text"), None).expect("base yields Some"); + assert_eq!(framed, "[Base]\nbase text"); + } + + #[test] + fn test_framed_system_prompt_persona_only_labels_system() { + // A bare persona would be mislabeled "Base" downstream — it must carry + // its own [System] header even when no base prompt exists. + let framed = framed_system_prompt(None, Some("persona text")).expect("persona yields Some"); + assert_eq!(framed, "[System]\npersona text"); + } + + #[test] + fn test_framed_system_prompt_neither_is_none() { + assert!(framed_system_prompt(None, None).is_none()); + } + // ── parse_thread_response tests ────────────────────────────────────────── #[test] diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 60cf7f62b40..dec0ee320ea 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -20,6 +20,7 @@ import { extractToolIdentity, extractToolResult, parsePromptText, + parseSystemPromptSections, } from "./agentSessionTranscriptHelpers"; export { describeRawEvent } from "./agentSessionTranscriptHelpers"; @@ -335,6 +336,27 @@ export function processTranscriptEvent( ); } } + } else if (event.kind === "acp_write" && method === "session/new") { + // The base + persona prompts ride session/new's systemPrompt, framed by + // the harness as [Base]/[System]. Surface them as one "System prompt" item + // keyed per channel-session — the frame carries no session id (it predates + // session creation), and session/new fires once per channel-session, so a + // re-created session correctly replaces the prior item. + const params = asRecord(payload.params); + const systemPrompt = asString(params.systemPrompt); + if (systemPrompt) { + const sections = parseSystemPromptSections(systemPrompt); + if (sections.length > 0) { + upsertMetadata( + d, + `system-prompt:${ch}`, + "System prompt", + sections, + event.timestamp, + channelId, + ); + } + } } else if (event.kind === "acp_read" && method === "session/update") { const params = asRecord(payload.params); const update = asRecord(params.update); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index e84af146839..a0acdd16206 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { extractPromptText, parsePromptText, + parseSystemPromptSections, } from "./agentSessionTranscriptHelpers.ts"; const HEX = "a".repeat(64); @@ -122,3 +123,51 @@ test("extractPromptText returns empty string when prompt is missing or not an ar assert.equal(extractPromptText({}), ""); assert.equal(extractPromptText({ params: { prompt: "nope" } }), ""); }); + +// --- parseSystemPromptSections: deterministic Base/System split --- + +test("parseSystemPromptSections splits both prompts into Base and System", () => { + const framed = "[Base]\nbase text\n\n[System]\npersona text"; + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Base", body: "base text" }, + { title: "System", body: "persona text" }, + ]); +}); + +test("parseSystemPromptSections yields one Base section for a base-only frame", () => { + const sections = parseSystemPromptSections("[Base]\nbase text"); + assert.deepEqual(sections, [{ title: "Base", body: "base text" }]); +}); + +test("parseSystemPromptSections yields one System section for a persona-only frame", () => { + const sections = parseSystemPromptSections("[System]\npersona text"); + assert.deepEqual(sections, [{ title: "System", body: "persona text" }]); +}); + +test("parseSystemPromptSections keeps embedded bracket lines literal in bodies", () => { + // A persona that itself contains a [Context]-like line must NOT split into a + // spurious sub-section — the body is read literally after the first boundary. + const framed = "[Base]\nbase\n\n[System]\nrule one\n[Context]\nrule two"; + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Base", body: "base" }, + { title: "System", body: "rule one\n[Context]\nrule two" }, + ]); +}); + +test("parseSystemPromptSections degrades to a labeled Base when [System] header is elided", () => { + // Oversize trim can drop the [System] header mid-string. Without a boundary + // the whole value stays under a correctly-labeled Base — no missing label, + // no inflated count, just a truncated body. + const elided = "[Base]\nbase text …[elided 900000 bytes]… persona tail"; + const sections = parseSystemPromptSections(elided); + assert.deepEqual(sections, [ + { title: "Base", body: "base text …[elided 900000 bytes]… persona tail" }, + ]); +}); + +test("parseSystemPromptSections returns no sections for empty input", () => { + assert.deepEqual(parseSystemPromptSections(""), []); + assert.deepEqual(parseSystemPromptSections(" "), []); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index 00c13c05679..cebf2bb576f 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -49,6 +49,46 @@ export function parsePromptText(text: string): { }; } +/** + * Split the framed `session/new` `systemPrompt` into its `Base`/`System` + * 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. + */ +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; + } + + // 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 }); + } + + return sections; +} + function parsePromptSections(text: string): PromptSection[] { const sections: PromptSection[] = []; let current: PromptSection | null = null;