Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 51 additions & 6 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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
};
Expand Down Expand Up @@ -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<String> {
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:
Expand Down Expand Up @@ -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]
Expand Down
22 changes: 22 additions & 0 deletions desktop/src/features/agents/ui/agentSessionTranscript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
extractToolIdentity,
extractToolResult,
parsePromptText,
parseSystemPromptSections,
} from "./agentSessionTranscriptHelpers";

export { describeRawEvent } from "./agentSessionTranscriptHelpers";
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
extractPromptText,
parsePromptText,
parseSystemPromptSections,
} from "./agentSessionTranscriptHelpers.ts";

const HEX = "a".repeat(64);
Expand Down Expand Up @@ -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(" "), []);
});
40 changes: 40 additions & 0 deletions desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down