mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user