mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
1dc4fb5daf
commit
a1cf1db67f
@@ -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.
|
||||
|
||||
@@ -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 ─────────────────────────────────────
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user