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

@@ -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",
);
});