feat(acp): deliver system prompt via _meta.systemPrompt for claude-agent-acp (#4395)

`claude-agent-acp` (since v0.6.0 / PR #91) accepts `_meta.systemPrompt:
{append: text}` on `session/new` to append to the adapter's native
preset while keeping its tool-use prompt intact — the same non-standard
extension pattern as `_session/steering` was before it was standardised.

## What changes

**Rust (`crates/buzz-acp/`)**

- Adds `SystemPromptTransport` enum to `acp.rs`: `Field(&str)` (ACP
protocol v2, unchanged) vs `ClaudeMeta(&str)` (new `_meta.systemPrompt:
{append: text}`). When both `ClaudeMeta` and `session_title` are present
the two `_meta` members are merged into one object so neither clobbers
the other.
- Gates on exact adapter identity
`@agentclientprotocol/claude-agent-acp` in `pool.rs`:
`session_new_system_prompt()` routes that name to `ClaudeMeta`
regardless of reported `protocolVersion` (CC declares v1).
`has_system_prompt_support()` gains the same name check so user-message
`[Base]`/`[System]` framing is suppressed for CC sessions.
- All other paths — goose post-hoc method, protocol-v2 `Field`, legacy
user-message framing — are byte-identical to before.

**Desktop (`desktop/src/features/agents/ui/`)**

- `agentSessionTranscript.ts`: the `session/new` extractor now checks
`params._meta.systemPrompt.append` as a fallback when bare
`params.systemPrompt` is absent. Bare field takes precedence. Net line
count stays at 1173 (ratchet limit).
- `agentSessionTranscript.test.mjs`: two new tests — one verifying the
`_meta` transport produces the identical standalone card (same five
sections, same `turnId: null`, same placement before the first turn) as
the bare-field transport; one proving bare field wins when both
transports are present.

## Gate claim

`@agentclientprotocol/claude-agent-acp` implies `_meta.systemPrompt`
support because the feature landed in v0.6.0 (Oct 2025, commit
`ea796f3`) before the `@zed-industries/claude-code-acp` →
`@agentclientprotocol/claude-agent-acp` package rename (Mar 2026, commit
`b409782`). The new name is therefore a reliable capability gate; the
old name falls through to the protocol-version gate (status quo, no
regression).

## Tests

- Rust: Claude append serialization; `_meta` coexistence with
`sessionTitle`; protocol-v2 bare field byte-identical; codex/old-zed
omission; claude-name support/suppression gate; old `@zed-industries`
name falls through to protocol-version gate.
- Desktop: `_meta` transport → identical standalone card; bare field
wins over `_meta` when both present.

## Pre-existing failures

`just mobile-check` and `just mobile-test` fail identically on clean
`origin/main` (5 `compose_bar` / `channels_page` tests + 3 Flutter lint
warnings) — not caused by this change. All other `just ci` jobs are
green.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-08-02 19:10:07 -04:00
committed by GitHub
parent 318fbf896e
commit 7ff5fc3189
4 changed files with 386 additions and 25 deletions

View File

@@ -619,29 +619,46 @@ impl AcpClient {
/// Send `session/new` and return the full response alongside the session ID.
///
/// `cwd` must be an absolute path. `mcp_servers` may be empty.
/// `system_prompt` is included in the request when `Some` — agents that
/// support the field will use it; others ignore unknown fields per JSON-RPC.
///
/// `system_prompt` controls how the prompt text is delivered:
///
/// - `None` — no system-prompt field in the request (legacy framing).
/// - `Some(SystemPromptTransport::Field(text))` — bare `systemPrompt` field
/// (ACP protocol v2, buzz-agent, goose unused).
/// - `Some(SystemPromptTransport::ClaudeMeta(text))` — `_meta.systemPrompt`
/// as `{"append": text}`, keeping claude-agent-acp's native preset intact.
///
/// `session_title` rides in `_meta.sessionTitle` when `Some`; `_meta` is
/// omitted entirely otherwise, since adapters may distinguish an absent
/// member from a null one.
/// member from a null one. When both `ClaudeMeta` and `session_title` are
/// present the two `_meta` members are merged into a single object.
///
/// Callers use [`extract_model_config_options`] and [`extract_model_state`]
/// to pull model info from the raw result.
pub async fn session_new_full(
&mut self,
cwd: &str,
mcp_servers: Vec<McpServer>,
system_prompt: Option<&str>,
system_prompt: Option<SystemPromptTransport<'_>>,
session_title: Option<&str>,
) -> Result<SessionNewResponse, AcpError> {
let mut params = serde_json::json!({
"cwd": cwd,
"mcpServers": mcp_servers,
});
if let Some(sp) = system_prompt {
params["systemPrompt"] = serde_json::Value::String(sp.to_owned());
match system_prompt {
Some(SystemPromptTransport::Field(sp)) => {
params["systemPrompt"] = serde_json::Value::String(sp.to_owned());
}
Some(SystemPromptTransport::ClaudeMeta(sp)) => {
// Merge into _meta so sessionTitle (set below) is not clobbered.
params["_meta"]["systemPrompt"] = serde_json::json!({ "append": sp });
}
None => {}
}
if let Some(title) = session_title {
params["_meta"] = serde_json::json!({ "sessionTitle": title });
// Merge — _meta may already carry systemPrompt from ClaudeMeta above.
params["_meta"]["sessionTitle"] = serde_json::Value::String(title.to_owned());
}
let result = self.send_request("session/new", params).await?;
let session_id = result["sessionId"]
@@ -663,7 +680,7 @@ impl AcpClient {
&mut self,
cwd: &str,
mcp_servers: Vec<McpServer>,
system_prompt: Option<&str>,
system_prompt: Option<SystemPromptTransport<'_>>,
session_title: Option<&str>,
) -> Result<String, AcpError> {
Ok(self
@@ -2038,6 +2055,22 @@ pub struct SessionNewResponse {
pub raw: serde_json::Value,
}
/// How to deliver a system prompt on `session/new`.
///
/// The two variants match the two mechanisms supported by current adapters:
///
/// - **`Field`** — bare `systemPrompt` field (ACP protocol v2, buzz-agent).
/// - **`ClaudeMeta`** — `_meta.systemPrompt: {"append": text}`, used by
/// `claude-agent-acp` to append to the adapter's own native system prompt
/// while keeping its tool-use preset intact.
#[derive(Debug, Clone, PartialEq)]
pub enum SystemPromptTransport<'a> {
/// Deliver as a bare top-level `systemPrompt` field.
Field(&'a str),
/// Deliver as `_meta.systemPrompt: {"append": text}`.
ClaudeMeta(&'a str),
}
/// How to switch to a particular model on a session.
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[serde(tag = "type")]
@@ -3271,7 +3304,12 @@ mod tests {
.expect("initialize should succeed");
let resp = client
.session_new_full("/tmp", vec![], Some("Custom system prompt"), None)
.session_new_full(
"/tmp",
vec![],
Some(SystemPromptTransport::Field("Custom system prompt")),
None,
)
.await
.expect("session_new_full should succeed");
@@ -3423,6 +3461,87 @@ mod tests {
);
}
// ── claude-agent-acp _meta.systemPrompt transport ─────────────────────
#[tokio::test]
async fn session_new_full_sends_claude_meta_system_prompt_when_claude_meta_transport() {
// When ClaudeMeta transport is requested, the prompt must appear as
// _meta.systemPrompt: {"append": text} — never as a bare systemPrompt field.
let script = r#"
read -t 2 _init
echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}'
read -t 2 REQ
echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_claude","_receivedRequest":'"$REQ"'}}'
sleep 1
"#;
let mut client = spawn_script(script).await;
client
.initialize()
.await
.expect("initialize should succeed");
let resp = client
.session_new_full(
"/tmp",
vec![],
Some(SystemPromptTransport::ClaudeMeta("Be concise")),
None,
)
.await
.expect("session_new_full should succeed");
let received = &resp.raw["_receivedRequest"];
assert!(
received["params"].get("systemPrompt").is_none(),
"bare systemPrompt must not be present for ClaudeMeta transport"
);
assert_eq!(
received["params"]["_meta"]["systemPrompt"]["append"].as_str(),
Some("Be concise"),
"_meta.systemPrompt.append must carry the prompt text"
);
}
#[tokio::test]
async fn session_new_full_merges_claude_meta_and_session_title_into_single_meta_object() {
// Both ClaudeMeta prompt and session_title must coexist under _meta —
// the prompt must not clobber sessionTitle or vice versa.
let script = r#"
read -t 2 _init
echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}'
read -t 2 REQ
echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_merged","_receivedRequest":'"$REQ"'}}'
sleep 1
"#;
let mut client = spawn_script(script).await;
client
.initialize()
.await
.expect("initialize should succeed");
let resp = client
.session_new_full(
"/tmp",
vec![],
Some(SystemPromptTransport::ClaudeMeta("Be concise")),
Some("Fizz · #buzz-dev"),
)
.await
.expect("session_new_full should succeed");
let received = &resp.raw["_receivedRequest"];
assert_eq!(
received["params"]["_meta"]["systemPrompt"]["append"].as_str(),
Some("Be concise"),
"_meta.systemPrompt.append must be present"
);
assert_eq!(
received["params"]["_meta"]["sessionTitle"].as_str(),
Some("Fizz · #buzz-dev"),
"_meta.sessionTitle must be present alongside systemPrompt"
);
}
// ── Goose-native steer scaffold (PR follow-up to #1160) ──────────────
/// Helper: spawn an inert `cat` subprocess so we have a real AcpClient

View File

@@ -32,6 +32,7 @@ use uuid::Uuid;
use crate::acp::{
extract_model_config_options, extract_model_state, model_in_catalog,
resolve_model_switch_method, AcpClient, AcpError, McpServer, ModelSwitchMethod, StopReason,
SystemPromptTransport,
};
use crate::config::{compose_session_title, DedupMode, PermissionMode};
use crate::observer;
@@ -171,6 +172,13 @@ pub struct OwnedAgent {
pub protocol_version: u32,
}
/// Package name reported by `claude-agent-acp` in its `initialize` response.
/// Any adapter reporting this name supports `_meta.systemPrompt: {append: ...}`
/// on `session/new` — the feature landed in v0.6.0 (Oct 2025), before the
/// `@zed-industries/claude-code-acp` → `@agentclientprotocol/claude-agent-acp`
/// rename, so the new name is a reliable capability gate.
const CLAUDE_AGENT_ACP_NAME: &str = "@agentclientprotocol/claude-agent-acp";
fn has_system_prompt_support(
protocol_version: u32,
agent_name: &str,
@@ -178,20 +186,25 @@ fn has_system_prompt_support(
) -> bool {
if agent_name == "goose" {
goose_system_prompt_supported == Some(true)
} else if agent_name == CLAUDE_AGENT_ACP_NAME {
true
} else {
protocol_version >= 2
}
}
fn session_new_system_prompt(
fn session_new_system_prompt<'a>(
is_goose: bool,
protocol_version: u32,
prompt: Option<&str>,
) -> Option<&str> {
if is_goose || protocol_version < 2 {
agent_name: &str,
prompt: Option<&'a str>,
) -> Option<SystemPromptTransport<'a>> {
if is_goose || (protocol_version < 2 && agent_name != CLAUDE_AGENT_ACP_NAME) {
None
} else if agent_name == CLAUDE_AGENT_ACP_NAME {
prompt.map(SystemPromptTransport::ClaudeMeta)
} else {
prompt
prompt.map(SystemPromptTransport::Field)
}
}
@@ -907,6 +920,7 @@ async fn create_session_and_apply_model(
session_new_system_prompt(
is_goose,
agent.protocol_version,
&agent.agent_name,
combined_system_prompt.as_deref(),
),
session_title.as_deref(),
@@ -4003,18 +4017,48 @@ mod tests {
assert!(has_system_prompt_support(2, "goose", Some(true)));
assert!(has_system_prompt_support(1, "goose", Some(true)));
assert!(has_system_prompt_support(2, "buzz-agent", None));
// Goose never receives system prompt via session/new (uses post-hoc method).
assert_eq!(
session_new_system_prompt(true, 2, Some("instructions")),
session_new_system_prompt(true, 2, "goose", Some("instructions")),
None
);
// Protocol-v2 non-goose gets Field transport.
assert_eq!(
session_new_system_prompt(false, 2, Some("instructions")),
Some("instructions")
session_new_system_prompt(false, 2, "buzz-agent", Some("instructions")),
Some(SystemPromptTransport::Field("instructions"))
);
// Protocol-v1 non-goose, non-claude gets None (legacy user-message framing).
assert_eq!(
session_new_system_prompt(false, 1, Some("instructions")),
session_new_system_prompt(false, 1, "codex", Some("instructions")),
None
);
// claude-agent-acp gets ClaudeMeta transport regardless of protocol version.
assert_eq!(
session_new_system_prompt(false, 1, CLAUDE_AGENT_ACP_NAME, Some("instructions")),
Some(SystemPromptTransport::ClaudeMeta("instructions"))
);
assert_eq!(
session_new_system_prompt(true, 1, CLAUDE_AGENT_ACP_NAME, Some("instructions")),
None,
"goose path must never produce a transport even when agent_name matches"
);
}
#[test]
fn claude_agent_acp_has_system_prompt_support_regardless_of_protocol_version() {
// claude-agent-acp declares protocolVersion:1 but supports _meta.systemPrompt;
// has_system_prompt_support must return true so user-message framing is suppressed.
assert!(has_system_prompt_support(1, CLAUDE_AGENT_ACP_NAME, None));
assert!(has_system_prompt_support(2, CLAUDE_AGENT_ACP_NAME, None));
}
#[test]
fn old_zed_adapter_name_falls_through_to_protocol_version_gate() {
// The renamed @zed-industries package predates the _meta.systemPrompt support,
// so it must not be treated as capable and stays on legacy user-message framing.
let old_name = "@zed-industries/claude-code-acp";
assert!(!has_system_prompt_support(1, old_name, None));
assert!(has_system_prompt_support(2, old_name, None));
}
#[test]

View File

@@ -1879,3 +1879,201 @@ test("buildTranscript five-section system prompt card is standalone with all sec
"prompt context must NOT contain system-prompt sections (Base/System/Team Instructions/Core Memory/Channel Canvas)",
);
});
// --- claude-agent-acp _meta.systemPrompt.append transport ---
test("buildTranscript session/new via _meta.systemPrompt.append produces identical standalone card as bare systemPrompt field", () => {
// claude-agent-acp delivers the system prompt at _meta.systemPrompt.append
// instead of the bare systemPrompt field. The observer must extract it and
// build the identical standalone card (same five sections, same acpSource,
// same turnId: null, same placement before the first turn).
const CH = "55555555-5555-5555-5555-555555555555";
const SYSTEM_PROMPT = [
"[Base]",
"You are a helpful assistant.",
"",
"[System]",
"Custom persona.",
"",
"---",
"# Team Instructions",
"Always tag on handoff.",
"",
"[Agent Memory \u2014 core]",
"I am Duncan.",
"",
"[Channel Canvas]",
"Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"Last modified: 2026-07-01T10:00:00Z",
"Fetch current content with: buzz canvas get --channel 55555555-5555-5555-5555-555555555555",
].join("\n");
const makeEvents = (params) => [
{
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 },
},
{
seq: 3,
timestamp: "2026-07-01T10:00:00.200Z",
kind: "session_resolved",
agentIndex: 0,
channelId: CH,
sessionId: "sess-cc",
turnId: "turn-1",
payload: { sessionId: "sess-cc", isNewSession: true },
},
{
seq: 4,
timestamp: "2026-07-01T10:00:01.000Z",
kind: "acp_write",
agentIndex: 0,
channelId: CH,
sessionId: "sess-cc",
turnId: "turn-1",
payload: {
jsonrpc: "2.0",
id: 2,
method: "session/prompt",
params: {
sessionId: "sess-cc",
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." },
],
},
},
},
];
// Build transcript from the claude-agent-acp _meta transport.
const metaEvents = makeEvents({
_meta: { systemPrompt: { append: SYSTEM_PROMPT } },
});
const metaRaw = buildTranscript(metaEvents);
const metaBlocks = buildTranscriptDisplayBlocks(metaRaw);
const metaFlat = flattenDisplayBlocks(metaBlocks);
// Also build from the standard bare-field transport for comparison.
const fieldEvents = makeEvents({ systemPrompt: SYSTEM_PROMPT });
const fieldRaw = buildTranscript(fieldEvents);
const fieldBlocks = buildTranscriptDisplayBlocks(fieldRaw);
// (a) Both produce exactly one standalone system-prompt single block.
const metaSPBlocks = metaBlocks.filter(
(b) => b.kind === "single" && b.item?.acpSource === "session/new",
);
const fieldSPBlocks = fieldBlocks.filter(
(b) => b.kind === "single" && b.item?.acpSource === "session/new",
);
assert.equal(
metaSPBlocks.length,
1,
"_meta: exactly one standalone system-prompt block",
);
assert.equal(
fieldSPBlocks.length,
1,
"field: exactly one standalone system-prompt block",
);
// (b) Both carry the same five ordered sections.
const EXPECTED_TITLES = [
"Base",
"System",
"Team Instructions",
"Core Memory",
"Channel Canvas",
];
const metaTitles = (metaSPBlocks[0].item?.sections ?? []).map((s) => s.title);
const fieldTitles = (fieldSPBlocks[0].item?.sections ?? []).map(
(s) => s.title,
);
assert.deepEqual(
metaTitles,
EXPECTED_TITLES,
"_meta: five sections in order",
);
assert.deepEqual(
fieldTitles,
EXPECTED_TITLES,
"field: five sections in order",
);
// (c) System prompt appears before Prompt context in both display orders.
const metaSPIdx = metaFlat.findIndex((i) => i.title === "System prompt");
const metaPCIdx = metaFlat.findIndex((i) => i.title === "Prompt context");
assert.ok(metaSPIdx !== -1, "_meta: System prompt item present");
assert.ok(metaPCIdx !== -1, "_meta: Prompt context item present");
assert.ok(
metaSPIdx < metaPCIdx,
`_meta: System prompt (${metaSPIdx}) must precede Prompt context (${metaPCIdx})`,
);
// (d) The _meta item has turnId: null (standalone, not in a turn bucket).
const metaSPRawIdx = metaRaw.findIndex((i) => i.title === "System prompt");
assert.equal(
metaRaw[metaSPRawIdx]?.turnId ?? null,
null,
"_meta: system-prompt item must have turnId=null",
);
});
test("buildTranscript session/new bare systemPrompt field takes precedence over _meta.systemPrompt.append", () => {
// When both transports are present (non-standard but must not regress),
// the standard bare field must win — a reversed ?? would silently use the
// wrong text and the card body would differ from the wire source of truth.
const CH = "66666666-6666-6666-6666-666666666666";
const events = [
{
seq: 1,
timestamp: "2026-07-01T10:00:00.000Z",
kind: "acp_write",
agentIndex: 0,
channelId: CH,
sessionId: "sess-both",
turnId: "turn-1",
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/new",
params: {
systemPrompt: "[Base]\nWinner.",
_meta: { systemPrompt: { append: "[Base]\nLoser." } },
},
},
},
];
const rawItems = buildTranscript(events);
const spItem = rawItems.find((i) => i.title === "System prompt");
assert.ok(spItem, "System prompt item must be present");
const bodies = (spItem.sections ?? []).map((s) => s.body).join("|");
assert.ok(
bodies.includes("Winner"),
"bare systemPrompt must win over _meta.systemPrompt.append",
);
assert.ok(
!bodies.includes("Loser"),
"_meta.systemPrompt.append must not appear when bare field is present",
);
});

View File

@@ -872,14 +872,14 @@ 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]/[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.
// claude-agent-acp uses _meta.systemPrompt.append instead; both paths
// produce the same standalone card (turnId: null, acpSource "session/new");
// the bare field takes precedence when both are present.
const params = asRecord(payload.params);
const systemPrompt = asString(params.systemPrompt);
const metaPrompt = asString(
asRecord(asRecord(params._meta).systemPrompt).append,
);
const systemPrompt = asString(params.systemPrompt) ?? metaPrompt;
if (systemPrompt) {
const sections = parseSystemPromptSections(systemPrompt);
if (sections.length > 0) {