fix(acp): emit per-section prompt blocks so observer counts every section (#1122)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-18 17:54:43 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 1dc4fb5daf
commit a1cf1db67f
4 changed files with 211 additions and 39 deletions
+71
View File
@@ -3646,6 +3646,77 @@ mod observer_payload_trim_tests {
);
}
#[test]
fn test_multi_block_prompt_retains_every_section_header_after_elision() {
// The real session/prompt fix: format_prompt now emits one block per
// section, so the observer payload is params.prompt = [{text: "[Base]…"},
// {text: "[Agent Memory — core]…"}, … {text: "[Buzz event: …]…<huge>"}].
// An oversized section is its own leaf, so eliding its body keeps the
// leaf's head-3000 (which begins with the section's [Header] line) — every
// header survives, so the desktop "Prompt context" panel counts them all.
// This is the regression the single-fat-leaf shape caused (the trailing
// [Buzz event] header fell into the elided middle and the count collapsed
// to 1).
let sections = [
"[Base]\nyou are a helpful agent".to_string(),
"[System]\npersona text".to_string(),
"[Agent Memory — core]\nremember this".to_string(),
"[Context]\nScope: thread".to_string(),
// The triggering event body, oversized on its own.
format!("[Buzz event: @mention]\nContent: {}", "E".repeat(90_000)),
];
let block_refs: Vec<&str> = sections.iter().map(String::as_str).collect();
// Mirror the wire shape build_prompt_params produces: each block is its
// own {type:"text", text} leaf under params.prompt.
let prompt_blocks: Vec<serde_json::Value> = block_refs
.iter()
.map(|text| serde_json::json!({ "type": "text", "text": text }))
.collect();
let mut event = event_with_payload(
"acp_write",
serde_json::json!({
"method": "session/prompt",
"params": { "sessionId": "sess-1", "prompt": prompt_blocks },
}),
);
assert!(
serialized(&event).len() > OBSERVER_MAX_PLAINTEXT_LEN,
"precondition: oversized event body pushes the frame over the cap"
);
fit_observer_event_to_budget(&mut event);
assert!(
serialized(&event).len() <= OBSERVER_MAX_PLAINTEXT_LEN,
"frame must fit after trimming"
);
let blocks = event.payload["params"]["prompt"]
.as_array()
.expect("prompt array survives");
let texts: Vec<&str> = blocks.iter().map(|b| b["text"].as_str().unwrap()).collect();
for header in [
"[Base]",
"[System]",
"[Agent Memory — core]",
"[Context]",
"[Buzz event: @mention]",
] {
assert!(
texts.iter().any(|t| t.starts_with(header)),
"section header {header} must survive at the head of its own block"
);
}
// The oversized event body was elided in place (header kept, middle cut).
let event_block = texts
.iter()
.find(|t| t.starts_with("[Buzz event: @mention]"))
.unwrap();
assert!(
event_block.contains("…[elided"),
"the oversized event body is elided, not dropped"
);
}
#[test]
fn test_multi_leaf_elides_largest_shrinkable_first_and_stops_when_it_fits() {
// One leaf alone over the cap; a second smaller-but-still-large leaf.
+12 -7
View File
@@ -1050,9 +1050,9 @@ pub async fn run_prompt_task(
// (`prompt[0].text.startsWith("/")`) fires; the wrapped Buzz context
// follows as a second block.
let mut slash_command: Option<String> = None;
let prompt_text = if let Some(text) = prompt_text {
// Pre-built prompt (heartbeat or legacy path).
text
let prompt_sections: Vec<String> = if let Some(text) = prompt_text {
// Pre-built prompt (heartbeat or legacy path) — a single block.
vec![text]
} else if let Some(ref b) = batch {
// Build prompt from batch with context enrichment.
// Try startup cache first; lazy-fetch via REST for dynamic channels.
@@ -1127,11 +1127,16 @@ pub async fn run_prompt_task(
// ── Send the actual prompt ────────────────────────────────────────────
// Slash-command pass-through sends two text blocks: the bare command
// first (so connector detection fires), then the wrapped Buzz context.
// Slash-command pass-through sends the bare command as the first text
// block (so connector detection fires), then each prompt section as its
// own block. Per-section blocks let the observer size trimmer elide a
// section body in place while every `[Header]` line survives at the head
// of its own leaf — so the "Prompt context" panel counts every section.
let prompt_blocks: Vec<&str> = match slash_command {
Some(ref cmd) => vec![cmd.as_str(), prompt_text.as_str()],
None => vec![prompt_text.as_str()],
Some(ref cmd) => std::iter::once(cmd.as_str())
.chain(prompt_sections.iter().map(String::as_str))
.collect(),
None => prompt_sections.iter().map(String::as_str).collect(),
};
// ── Control-aware prompt dispatch ─────────────────────────────────────
+55 -32
View File
@@ -1032,7 +1032,7 @@ pub(crate) fn base_section(base_prompt: &str) -> String {
format!("[Base]\n{}", base_prompt.trim_end())
}
/// Format a [`FlushBatch`] into a prompt string for the agent.
/// Format a [`FlushBatch`] into the per-section prompt blocks for the agent.
///
/// Produces a stable prompt with these sections (in order):
/// 0. `[Base]` — base prompt (only for legacy agents without systemPrompt support)
@@ -1042,9 +1042,17 @@ pub(crate) fn base_section(base_prompt: &str) -> String {
/// 4. `[Thread Context]` or `[Conversation Context]` — if fetched
/// 5. `[Event]` / `[Buzz events]` — the triggering event(s)
///
/// Each section is returned as its own block rather than one joined string so
/// the observer frame's size trimmer (`fit_observer_event_to_budget`) elides
/// the body of an oversized section in place, leaving every `[Header]` line at
/// the head of its own leaf — so the desktop "Prompt context" panel always
/// counts every section. The receiving agent reconstructs the full prompt by
/// joining the blocks (legacy agents see a single `\n` between sections rather
/// than a blank line; sections self-delimit with their `[Header]` line).
///
/// For agents with `protocol_version >= 2`, base_prompt and system_prompt are
/// delivered via the system role in `session/new` and omitted from this message.
pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> String {
pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<String> {
// Scope is always derived from the LAST event in the batch — that's the
// one the agent is responding to. Thread/DM context is supplementary info
// included alongside, not a scope override. This prevents mixed batches
@@ -1053,7 +1061,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> String
Some(e) => e,
None => {
tracing::error!("format_prompt called with empty batch — returning empty prompt");
return String::new();
return Vec::new();
}
};
let thread_tags = parse_thread_tags(&last_event.event);
@@ -1170,7 +1178,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> String
);
}
sections.join("\n\n")
sections
}
// ─── Unit Tests ──────────────────────────────────────────────────────────────
@@ -1394,7 +1402,7 @@ mod tests {
cancelled_events: vec![],
};
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
// Should contain [Context] section before the event.
assert!(prompt.contains("[Context]"));
@@ -1490,7 +1498,7 @@ mod tests {
cancelled_events: vec![],
};
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
assert!(prompt.contains("[Context]"));
assert!(prompt.contains("[Buzz events — 3 events]"));
@@ -1519,7 +1527,7 @@ mod tests {
cancelled_events: vec![],
};
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
// system_prompt and base_prompt are delivered via session/new system role,
// so they must NOT appear in the user message.
assert!(!prompt.contains("[System]"));
@@ -1549,7 +1557,8 @@ mod tests {
agent_core: Some(core),
..Default::default()
},
);
)
.join("\n\n");
assert!(
prompt.starts_with("[Agent Memory — core]\nbe helpful\n\n[Context]"),
"expected core block first, then [Context]; got: {prompt}"
@@ -1579,7 +1588,8 @@ mod tests {
has_system_prompt_support: true,
..Default::default()
},
);
)
.join("\n\n");
assert!(
!prompt.contains("[Agent Memory — core]"),
"modern agents must not get core in the user message; got: {prompt}"
@@ -1607,7 +1617,8 @@ mod tests {
agent_core: Some(core),
..Default::default()
},
);
)
.join("\n\n");
assert!(prompt.starts_with("[Agent Memory — core]\nbe helpful\n\n[Context]"));
}
@@ -1630,7 +1641,7 @@ mod tests {
// format_prompt no longer accepts or emits base_prompt/system_prompt.
// They are delivered via session/new system role instead.
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
assert!(!prompt.contains("[Base]"));
assert!(!prompt.contains("[System]"));
assert!(prompt.starts_with("[Context]"));
@@ -1663,7 +1674,8 @@ mod tests {
agent_core: Some(core),
..Default::default()
},
);
)
.join("\n\n");
// Both sections must be present
assert!(
@@ -1717,7 +1729,8 @@ mod tests {
system_prompt: Some("test system prompt"),
..Default::default()
},
);
)
.join("\n\n");
// Neither section should appear — they are delivered via session/new
assert!(
@@ -1763,7 +1776,8 @@ mod tests {
conversation_context: Some(&ctx),
..Default::default()
},
);
)
.join("\n\n");
// Verify section ordering: [Agent Memory] < [Context] < [Thread Context]
let core_pos = prompt
@@ -2270,7 +2284,8 @@ mod tests {
channel_info: Some(&ci),
..Default::default()
},
);
)
.join("\n\n");
assert!(prompt.contains("engineering (#"));
assert!(prompt.contains("Scope: channel"));
}
@@ -2299,7 +2314,8 @@ mod tests {
channel_info: Some(&ci),
..Default::default()
},
);
)
.join("\n\n");
assert!(prompt.contains("Scope: dm"));
}
@@ -2325,7 +2341,7 @@ mod tests {
cancelled_events: vec![],
};
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
assert!(prompt.contains("Scope: thread"));
assert!(prompt.contains("Thread root: root123"));
}
@@ -2374,7 +2390,8 @@ mod tests {
conversation_context: Some(&ctx),
..Default::default()
},
);
)
.join("\n\n");
assert!(prompt.contains("[Thread Context (2 of 5 messages, truncated)]"));
assert!(prompt.contains("Let's refactor auth"));
assert!(prompt.contains("Thread context included below"));
@@ -2414,7 +2431,8 @@ mod tests {
conversation_context: Some(&ctx),
..Default::default()
},
);
)
.join("\n\n");
assert!(prompt.contains("Scope: dm"));
assert!(prompt.contains("[Conversation Context (1 of 1 messages)]"));
assert!(prompt.contains("Can you deploy?"));
@@ -2473,7 +2491,8 @@ mod tests {
profile_lookup: Some(&profiles),
..Default::default()
},
);
)
.join("\n\n");
assert!(prompt.contains("From: Wes (npub:"));
assert!(prompt.contains(
@@ -2575,7 +2594,8 @@ mod tests {
conversation_context: Some(&ctx),
..Default::default()
},
);
)
.join("\n\n");
// Scope should be "dm", not "thread".
assert!(
prompt.contains("Scope: dm"),
@@ -2620,7 +2640,8 @@ mod tests {
channel_info: Some(&ci),
..Default::default()
},
);
)
.join("\n\n");
assert!(prompt.contains("Scope: dm"));
assert!(
prompt.contains("buzz messages get"),
@@ -2647,7 +2668,7 @@ mod tests {
cancelled_events: vec![],
};
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
assert!(
prompt.contains(&format!("Event ID: {event_id}")),
"prompt should contain the event ID"
@@ -2670,7 +2691,7 @@ mod tests {
cancelled_events: vec![],
};
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
assert!(
prompt.contains(&format!("From: {npub} (hex: {hex})")),
"prompt should contain both npub and hex"
@@ -2692,7 +2713,7 @@ mod tests {
cancelled_events: vec![],
};
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
assert!(
prompt.contains("Tags:"),
"tags should always be included, even for stream messages"
@@ -3016,7 +3037,7 @@ mod tests {
cancelled_events: vec![],
};
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
assert!(
prompt.contains(&format!("--reply-to {event_id}")),
"channel thread reply should include reply instruction with triggering event ID"
@@ -3056,7 +3077,8 @@ mod tests {
channel_info: Some(&ci),
..Default::default()
},
);
)
.join("\n\n");
assert!(
prompt.contains(&format!("--reply-to {event_id}")),
"DM thread reply should include reply instruction"
@@ -3077,7 +3099,7 @@ mod tests {
cancelled_events: vec![],
};
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
assert!(
!prompt.contains("--reply-to"),
"top-level message should NOT include reply instruction"
@@ -3108,7 +3130,8 @@ mod tests {
channel_info: Some(&ci),
..Default::default()
},
);
)
.join("\n\n");
assert!(
!prompt.contains("--reply-to"),
"DM non-reply should NOT include reply instruction"
@@ -3138,7 +3161,7 @@ mod tests {
cancelled_events: vec![],
};
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
// The instruction should use the triggering event's own ID — not root or parent.
assert!(
prompt.contains(&format!("--reply-to {event_id}")),
@@ -3181,7 +3204,7 @@ mod tests {
cancelled_events: vec![],
};
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
assert!(
prompt.contains(&format!("--reply-to {threaded_id}")),
"batched prompt should use last (threaded) event's ID"
@@ -3214,7 +3237,7 @@ mod tests {
cancelled_events: vec![],
};
let prompt = format_prompt(&batch, &FormatPromptArgs::default());
let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n");
assert!(
!prompt.contains("--reply-to"),
"batched prompt where last event is top-level should NOT include reply instruction"
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildTranscript } from "./agentSessionTranscript.ts";
const baseEvent = {
seq: 1,
timestamp: "2026-06-18T00:00:00Z",
kind: "acp_write",
agentIndex: 0,
channelId: "11111111-1111-1111-1111-111111111111",
sessionId: "sess-1",
turnId: "turn-1",
};
// --- stub-overflow vanish (pins the pre-existing degraded-frame behavior) ---
test("buildTranscript drops a session/prompt turn whose frame was stubbed by the size trimmer", () => {
// When fit_observer_event_to_budget cannot shrink a frame below the cap it
// replaces the whole payload with {elided, originalBytes} (no `method`), so
// the method-keyed acp_write dispatch matches no arm and there is no terminal
// else: the turn produces ZERO transcript items. This is worse than a
// "1 section" collapse (the item vanishes entirely) and is pre-existing,
// outside the format_prompt seam. Pin it so a later change can't silently
// regress the vanish-vs-degrade behavior without updating this test.
const stubbed = {
...baseEvent,
payload: {
elided: "acp_write payload too large",
originalBytes: 123456,
},
};
assert.deepEqual(buildTranscript([stubbed]), []);
});
// --- positive control: a well-formed multi-block prompt DOES render ---
test("buildTranscript renders Prompt context + user message for a multi-block session/prompt frame", () => {
// Guards the vanish assertion above against a false pass from a broken
// import or dispatch: a normal per-section prompt frame must still produce a
// user message and a "Prompt context" metadata item.
const event = {
...baseEvent,
payload: {
method: "session/prompt",
params: {
sessionId: "sess-1",
prompt: [
{ type: "text", text: "[Agent Memory — core]\nremember this" },
{ type: "text", text: "[Context]\nScope: thread" },
{
type: "text",
text: `[Buzz event: @mention]\nFrom: x (hex: ${"a".repeat(64)})\nContent: hello`,
},
],
},
},
};
const items = buildTranscript([event]);
const titles = items.map((i) => i.title);
assert.ok(
items.some((i) => i.type === "metadata" && i.title === "Prompt context"),
`expected a Prompt context metadata item, got titles: ${titles.join(", ")}`,
);
const promptContext = items.find((i) => i.title === "Prompt context");
assert.deepEqual(
promptContext.sections.map((s) => s.title),
["Agent Memory — core", "Context", "Buzz event: @mention"],
"every section header is counted",
);
});