Files
buzz/crates/buzz-agent/tests/regressions.rs
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

3710 lines
135 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Regression tests for round 4-6 hardening:
//! - assistant text preserved in history
//! - MCP init timeout (with explicit child kill)
//! - tool metadata caps (description bytes, count)
//! - cancellation leaves history valid for the next prompt
//! - empty-content assistant turn doesn't poison OpenAI history
use std::collections::VecDeque;
use std::process::Stdio;
use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, Instant};
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
struct CapturingLlm {
url: String,
captured: Arc<Mutex<Vec<Value>>>,
}
async fn spawn_capturing_llm(responses: Vec<Value>) -> CapturingLlm {
spawn_capturing_llm_with_status(responses.into_iter().map(|v| (200u16, v)).collect()).await
}
/// Like `spawn_capturing_llm` but each canned response carries its own HTTP
/// status, so a test can serve a real provider rejection (e.g. a context-window
/// 400) instead of only success bodies.
async fn spawn_capturing_llm_with_status(responses: Vec<(u16, Value)>) -> CapturingLlm {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let queue = Arc::new(Mutex::new(VecDeque::from(responses)));
let captured: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
let cap2 = captured.clone();
tokio::spawn(async move {
loop {
let (mut sock, _) = match listener.accept().await {
Ok(p) => p,
Err(_) => return,
};
let queue = queue.clone();
let captured = cap2.clone();
tokio::spawn(async move {
let mut buf = Vec::new();
let mut tmp = [0u8; 8192];
// Read until headers complete.
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
match sock.read(&mut tmp).await {
Ok(0) | Err(_) => return,
Ok(n) => buf.extend_from_slice(&tmp[..n]),
}
if buf.len() > 4_000_000 {
return;
}
}
// Parse Content-Length and read body.
let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
let headers = &buf[..header_end];
let mut body_len = 0usize;
for line in headers.split(|b| *b == b'\n') {
let line = std::str::from_utf8(line).unwrap_or("");
if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-length:") {
body_len = rest.trim().trim_end_matches('\r').parse().unwrap_or(0);
}
}
while buf.len() < header_end + body_len {
match sock.read(&mut tmp).await {
Ok(0) | Err(_) => return,
Ok(n) => buf.extend_from_slice(&tmp[..n]),
}
}
if let Ok(req) = serde_json::from_slice::<Value>(&buf[header_end..]) {
captured.lock().await.push(req);
}
let (status, body) = queue
.lock()
.await
.pop_front()
.unwrap_or_else(|| (200, json!({ "error": "no canned response" })));
let body_s = serde_json::to_string(&body).unwrap();
let reason = match status {
200 => "OK",
400 => "Bad Request",
_ => "Error",
};
let resp = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body_s.len(), body_s,
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.shutdown().await;
});
}
});
CapturingLlm { url, captured }
}
struct Harness {
child: tokio::process::Child,
stdin: tokio::process::ChildStdin,
stdout: BufReader<tokio::process::ChildStdout>,
stderr: Arc<StdMutex<String>>,
next_id: i64,
}
impl Harness {
async fn spawn_with_env(base_url: &str, extra: &[(&str, &str)]) -> Self {
let bin = env!("CARGO_BIN_EXE_buzz-agent");
let mut cmd = tokio::process::Command::new(bin);
cmd.env("BUZZ_AGENT_PROVIDER", "openai")
.env("OPENAI_COMPAT_API_KEY", "test")
.env("OPENAI_COMPAT_MODEL", "fake-model")
.env("OPENAI_COMPAT_BASE_URL", base_url)
.env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5")
.env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5")
.env("BUZZ_AGENT_MAX_ROUNDS", "8")
.env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2");
for (k, v) in extra {
cmd.env(k, v);
}
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn().expect("spawn buzz-agent");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let stderr = child.stderr.take().unwrap();
let stderr_buf = Arc::new(StdMutex::new(String::new()));
let stderr_out = Arc::clone(&stderr_buf);
tokio::spawn(async move {
let mut reader = BufReader::new(stderr);
let mut line = String::new();
loop {
line.clear();
let n = match reader.read_line(&mut line).await {
Ok(n) => n,
Err(_) => break,
};
if n == 0 {
break;
}
if let Ok(mut out) = stderr_out.lock() {
out.push_str(&line);
}
}
});
Self {
child,
stdin,
stdout,
stderr: stderr_buf,
next_id: 1,
}
}
async fn spawn(base_url: &str) -> Self {
Self::spawn_with_env(base_url, &[]).await
}
async fn send(&mut self, method: &str, params: Value) -> i64 {
let id = self.next_id;
self.next_id += 1;
self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }))
.await;
id
}
async fn notify(&mut self, method: &str, params: Value) {
self.write(json!({ "jsonrpc": "2.0", "method": method, "params": params }))
.await;
}
async fn write(&mut self, msg: Value) {
let mut s = serde_json::to_string(&msg).unwrap();
s.push('\n');
self.stdin.write_all(s.as_bytes()).await.unwrap();
self.stdin.flush().await.unwrap();
}
async fn recv(&mut self) -> Value {
let mut line = String::new();
let n = tokio::time::timeout(Duration::from_secs(15), self.stdout.read_line(&mut line))
.await
.expect("recv timeout")
.expect("read line");
assert!(n > 0, "agent EOF");
serde_json::from_str(&line).expect("non-JSON line")
}
async fn recv_until<F: FnMut(&Value) -> bool>(&mut self, mut pred: F) -> Value {
loop {
let v = self.recv().await;
if pred(&v) {
return v;
}
}
}
async fn shutdown(mut self) {
drop(self.stdin);
let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await;
let _ = self.child.start_kill();
}
fn stderr_text(&self) -> String {
self.stderr.lock().map(|s| s.clone()).unwrap_or_default()
}
}
fn openai_text(content: &str) -> Value {
json!({
"id": "cc-1", "object": "chat.completion", "model": "fake-model",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": content },
"finish_reason": "stop",
}],
})
}
/// Like [`openai_text`] but attaches a `usage` block so tests can drive the
/// token-based handoff gate. `prompt_tokens` is the input-token count the
/// agent will read and compare against the configured context budget.
fn openai_text_with_usage(content: &str, prompt_tokens: u64) -> Value {
let mut v = openai_text(content);
v["usage"] = json!({
"prompt_tokens": prompt_tokens,
"completion_tokens": 1,
"total_tokens": prompt_tokens + 1,
});
v
}
fn openai_max_tokens(content: &str, tool_calls: Value) -> Value {
json!({
"id": "cc-max", "object": "chat.completion", "model": "fake-model",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": content,
"tool_calls": tool_calls,
},
"finish_reason": "length",
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 100,
"total_tokens": 110,
},
})
}
fn openai_tool_call(id: &str, name: &str, args: Value) -> Value {
json!({
"id": "cc-2", "object": "chat.completion", "model": "fake-model",
"choices": [{
"index": 0,
"message": {
"role": "assistant", "content": null,
"tool_calls": [{
"id": id, "type": "function",
"function": { "name": name, "arguments": args.to_string() },
}],
},
"finish_reason": "tool_calls",
}],
})
}
async fn init_session(h: &mut Harness, mcp_servers: Value) -> String {
h.send(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await;
let _ = h.recv().await;
h.send(
"session/new",
json!({"cwd":"/tmp","mcpServers": mcp_servers}),
)
.await;
let r = h
.recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
.await;
r["result"]["sessionId"]
.as_str()
.unwrap_or_else(|| {
panic!(
"session/new did not return sessionId: response={r}, stderr={}",
h.stderr_text()
)
})
.to_owned()
}
/// After a text-only assistant response, the next prompt's request must
/// include that assistant text in `messages` history. Round 4 fix.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn assistant_text_preserved_across_prompts() {
let llm = spawn_capturing_llm(vec![openai_text("hello world"), openai_text("done")]).await;
let mut h = Harness::spawn(&llm.url).await;
let sid = init_session(&mut h, json!([])).await;
// Prompt 1.
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"first"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
// Prompt 2 — should carry assistant text from prompt 1.
let p2 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"second"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p2)).await;
let captured = llm.captured.lock().await;
assert_eq!(captured.len(), 2, "expected 2 LLM requests");
let msgs = captured[1]["messages"].as_array().unwrap();
let assistants: Vec<&Value> = msgs.iter().filter(|m| m["role"] == "assistant").collect();
assert!(
assistants.iter().any(|m| m["content"] == "hello world"),
"assistant text was dropped: messages={msgs:?}"
);
h.shutdown().await;
}
/// MCP init that hangs forever must time out within ~2s, surface an error,
/// and the child process must be killed (not lingering).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_init_timeout_kills_child() {
let llm = spawn_capturing_llm(vec![]).await;
let mut h = Harness::spawn(&llm.url).await;
let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
h.send(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await;
let _ = h.recv().await;
let start = Instant::now();
h.send(
"session/new",
json!({
"cwd": "/tmp",
"mcpServers": [{
"name": "stuck",
"command": fake_mcp,
"args": [],
"env": [{ "name": "FAKE_MCP_HANG_INIT", "value": "1" }],
}],
}),
)
.await;
let r = h
.recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
.await;
let elapsed = start.elapsed();
assert!(r.get("error").is_some(), "expected error, got {r}");
let msg = r["error"]["message"].as_str().unwrap_or("");
assert!(msg.contains("timeout"), "error not a timeout: {msg}");
// 2s timeout + small slack. Generous to cover slow CI.
assert!(
elapsed < Duration::from_secs(8),
"timeout took too long: {elapsed:?}"
);
h.shutdown().await;
}
/// A real MCP server that returns 200 tools with 100KB descriptions must
/// be capped: tool count ≤ MAX_TOOLS_PER_SESSION (128) — we expect spawn_all
/// to either reject (too many) OR truncate. We assert the spawn succeeds with
/// a bounded count, and that descriptions sent to the LLM are ≤ 1024 bytes.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_metadata_caps_enforced() {
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
let mut h = Harness::spawn(&llm.url).await;
let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
h.send(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await;
let _ = h.recv().await;
h.send(
"session/new",
json!({
"cwd": "/tmp",
"mcpServers": [{
"name": "many",
"command": fake_mcp,
"args": [],
"env": [
{ "name": "FAKE_MCP_TOOL_COUNT", "value": "200" },
{ "name": "FAKE_MCP_HUGE_DESC", "value": "1" },
],
}],
}),
)
.await;
let r = h
.recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
.await;
// Either spawn rejects (200 > 128 cap) — that's acceptable hardening —
// OR it accepts and we verify the LLM request stays bounded.
if r.get("error").is_some() {
let msg = r["error"]["message"].as_str().unwrap_or("");
assert!(msg.contains("too many"), "unexpected error: {msg}");
h.shutdown().await;
return;
}
let sid = r["result"]["sessionId"].as_str().unwrap().to_owned();
let p = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
let captured = llm.captured.lock().await;
assert!(!captured.is_empty(), "no LLM request captured");
let tools = captured[0]["tools"].as_array().unwrap();
assert!(tools.len() <= 128, "tool count not capped: {}", tools.len());
for t in tools {
let desc = t["function"]["description"].as_str().unwrap_or("");
assert!(
desc.len() <= 1024,
"description not capped: {} bytes",
desc.len()
);
}
h.shutdown().await;
}
/// Cap on MCP server count: 17 servers must be rejected.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_server_count_cap() {
let llm = spawn_capturing_llm(vec![]).await;
let mut h = Harness::spawn(&llm.url).await;
h.send(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await;
let _ = h.recv().await;
let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
let servers: Vec<Value> = (0..17)
.map(|i| {
json!({
"name": format!("s{i}"),
"command": fake_mcp,
"args": [],
"env": [],
})
})
.collect();
h.send("session/new", json!({"cwd":"/tmp","mcpServers": servers}))
.await;
let r = h
.recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
.await;
assert!(r.get("error").is_some(), "expected error for 17 servers");
let msg = r["error"]["message"].as_str().unwrap_or("");
assert!(msg.contains("too many"), "wrong error: {msg}");
h.shutdown().await;
}
/// After cancelling mid-tool-loop, the next prompt must succeed without
/// the LLM seeing a malformed history (assistant tool_use with no
/// matching tool_result). Round 5 fix.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_leaves_history_valid_for_next_prompt() {
// Round 1: tool call (unknown — fails fast, no permission flow).
// Round 2: text "ok".
// After cancel, prompt 2 returns text immediately.
let llm = spawn_capturing_llm(vec![
openai_tool_call("tc1", "fake__t", json!({})),
openai_text("after-cancel"),
openai_text("p2-done"),
])
.await;
let mut h = Harness::spawn(&llm.url).await;
let sid = init_session(&mut h, json!([])).await;
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"first"}]}),
)
.await;
// Cancel right away; the agent races between cancellation and the LLM
// round trip — either way history must remain valid.
h.notify("session/cancel", json!({"sessionId": sid})).await;
let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
// Prompt 2 — must NOT error from a malformed history.
let p2 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"second"}]}),
)
.await;
let r = h.recv_until(|v| v["id"] == json!(p2)).await;
assert!(r.get("result").is_some(), "p2 errored: {r}");
h.shutdown().await;
}
/// Empty assistant content + no tool_calls must serialize as "" (not null)
/// for OpenAI, so subsequent prompts don't get rejected. Round 7 fix 6.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn empty_assistant_serializes_as_empty_string() {
// First call returns content="" finish_reason=stop — agent records an
// empty assistant turn. Second call's request body is what we inspect.
let llm = spawn_capturing_llm(vec![openai_text(""), openai_text("done")]).await;
let mut h = Harness::spawn(&llm.url).await;
let sid = init_session(&mut h, json!([])).await;
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"a"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
let p2 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"b"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p2)).await;
let captured = llm.captured.lock().await;
let msgs = captured[1]["messages"].as_array().unwrap();
let empty_assistant = msgs
.iter()
.find(|m| m["role"] == "assistant" && m.get("tool_calls").is_none())
.expect("no plain assistant turn");
// Must be empty string, NOT null.
assert_eq!(
empty_assistant["content"],
json!(""),
"expected empty string content, got {empty_assistant}"
);
h.shutdown().await;
}
fn openai_n_tool_calls(n: usize) -> Value {
let calls: Vec<Value> = (0..n)
.map(|i| {
json!({
"id": format!("c{i}"),
"type": "function",
"function": { "name": "many__tool_0", "arguments": "{}" },
})
})
.collect();
json!({
"id": "cc-n", "object": "chat.completion", "model": "fake-model",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": null, "tool_calls": calls },
"finish_reason": "tool_calls",
}],
})
}
/// History budget evicts old turns: after many prompts, the LLM request
/// body stays below a sane bound. Round 7 fix; round 8 test.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn history_budget_evicts_old_turns() {
// Budget = 1 MB (MIN allowed by config). Each prompt is ~200 KB, so
// 12 prompts × 200 KB = ~2.4 MB blows the cap and forces eviction.
// We expect the captured request body to stay under 3× the cap.
const BUDGET: usize = 1024 * 1024; // 1 MB — must be >= MAX_PROMPT_BYTES
const PROMPT_BYTES: usize = 200 * 1024; // 200 KB per turn
let responses: Vec<Value> = (0..12).map(|_| openai_text(&"y".repeat(200))).collect();
let llm = spawn_capturing_llm(responses).await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_HISTORY_BYTES", &BUDGET.to_string()),
("BUZZ_AGENT_MAX_HANDOFFS", "0"), // exercise truncation, not handoff
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
for i in 0..12 {
let user = "x".repeat(PROMPT_BYTES);
let p = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text": format!("{i}:{user}")}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
}
let captured = llm.captured.lock().await;
assert_eq!(captured.len(), 12);
// The last request must show eviction: body well under unbounded 12 × 200 KB = 2.4 MB.
let last = &captured[captured.len() - 1];
let body_bytes = serde_json::to_vec(last).unwrap().len();
assert!(
body_bytes < BUDGET * 3,
"history not evicted: request body is {body_bytes} bytes"
);
let msgs = last["messages"].as_array().unwrap();
// We must NEVER drop the latest user prompt.
assert!(
msgs.iter()
.any(|m| m["role"] == "user" && m["content"].as_str().unwrap_or("").starts_with("11:")),
"newest user turn missing"
);
h.shutdown().await;
}
/// Per-turn tool-call cap: an LLM that returns 100 tool_calls in one
/// response must only have 64 (MAX_TOOL_CALLS_PER_TURN) executed.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn per_turn_tool_call_cap_enforced() {
let llm = spawn_capturing_llm(vec![openai_n_tool_calls(100), openai_text("done")]).await;
let mut h = Harness::spawn(&llm.url).await;
let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
h.send(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await;
let _ = h.recv().await;
h.send(
"session/new",
json!({
"cwd": "/tmp",
"mcpServers": [{
"name": "many",
"command": fake_mcp,
"args": [],
"env": [{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }],
}],
}),
)
.await;
let r = h
.recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
.await;
let sid = r["result"]["sessionId"].as_str().unwrap().to_owned();
let p = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
)
.await;
// Count distinct tool_call (pending) notifications until final response.
let mut tool_call_ids = std::collections::HashSet::new();
loop {
let v = h.recv().await;
if v.get("method") == Some(&json!("session/request_permission")) {
let id = v["id"].clone();
h.write(json!({
"jsonrpc": "2.0",
"id": id,
"result": { "outcome": { "outcome": "selected", "optionId": "allow" } },
}))
.await;
continue;
}
if v.get("method") == Some(&json!("session/update"))
&& v["params"]["update"]["sessionUpdate"] == "tool_call"
{
if let Some(id) = v["params"]["update"]["toolCallId"].as_str() {
tool_call_ids.insert(id.to_owned());
}
continue;
}
if v["id"] == json!(p) {
break;
}
}
// MAX_TOOL_CALLS_PER_TURN = 64.
assert_eq!(
tool_call_ids.len(),
64,
"expected 64 tool_calls, got {}",
tool_call_ids.len()
);
h.shutdown().await;
}
/// Description clamping: a 5000-byte description from MCP must be
/// truncated to ≤ 1024 bytes in the LLM request.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn description_clamping_enforced() {
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
let mut h = Harness::spawn(&llm.url).await;
let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
h.send(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await;
let _ = h.recv().await;
h.send(
"session/new",
json!({
"cwd": "/tmp",
"mcpServers": [{
"name": "big",
"command": fake_mcp,
"args": [],
"env": [
{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" },
{ "name": "FAKE_MCP_DESC_SIZE", "value": "5000" },
],
}],
}),
)
.await;
let r = h
.recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
.await;
let sid = r["result"]["sessionId"].as_str().unwrap().to_owned();
let p = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
let captured = llm.captured.lock().await;
let tools = captured[0]["tools"].as_array().unwrap();
// The MCP tool is "big__tool_0"; load_skill may also be present when
// global skills are discovered from HOME. Find the MCP tool by name.
let mcp_tool = tools
.iter()
.find(|t| t["function"]["name"].as_str() == Some("big__tool_0"))
.expect("big__tool_0 not found in tool list");
let desc = mcp_tool["function"]["description"].as_str().unwrap_or("");
assert!(
desc.len() <= 1024,
"description not clamped: {} bytes (expected ≤ 1024)",
desc.len()
);
// Sanity: the original was 5000 bytes, so we did clamp something.
assert!(
desc.len() < 5000,
"description not actually truncated: {} bytes",
desc.len()
);
h.shutdown().await;
}
/// Helper: spawn a session with a fake MCP server exposing one regular tool
/// plus an optional `_Stop` hook controlled by env vars.
async fn init_session_with_fake_mcp(h: &mut Harness, extra_mcp_env: &[(&str, &str)]) -> String {
let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
let env: Vec<Value> = extra_mcp_env
.iter()
.map(|(k, v)| json!({ "name": k, "value": v }))
.collect();
h.send(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await;
let _ = h.recv().await;
h.send(
"session/new",
json!({
"cwd": "/tmp",
"mcpServers": [{
"name": "fake",
"command": fake_mcp,
"args": [],
"env": env,
}],
}),
)
.await;
let r = h
.recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
.await;
r["result"]["sessionId"]
.as_str()
.expect("sessionId")
.to_owned()
}
/// `_Stop` hook objects on the first end_turn → agent must NOT stop.
/// The hook returns an objection only on its first invocation; on the
/// second end_turn (after a tool round), the hook stays silent so the
/// agent ends cleanly. Verifies that the gate rerolls the LLM at least
/// once, and that the objection appears in history as a tool-role
/// message with the JSON-encoded source attribution.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn hook_stop_blocks_premature_end() {
// LLM sequence:
// 1. text "premature" (triggers _Stop objection — call #1)
// 2. tool_call to fake__tool_0 (regular tool round)
// 3. text "really done" (hook returns empty on call #2 → end)
let llm = spawn_capturing_llm(vec![
openai_text("premature"),
openai_tool_call("tc1", "fake__tool_0", json!({})),
openai_text("really done"),
])
.await;
// stop_max_rejections=10 so the budget never trips. The hook itself
// stays silent on its second call (FAKE_MCP_STOP_COUNT=1) so the
// second end_turn is accepted by the agent — this exercises the
// genuine "objected then later cleared" path, not a budget cap.
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("MCP_HOOK_SERVERS", "fake"),
("BUZZ_AGENT_STOP_MAX_REJECTIONS", "10"),
],
)
.await;
let sid = init_session_with_fake_mcp(
&mut h,
&[
("FAKE_MCP_TOOL_COUNT", "1"),
("FAKE_MCP_STOP_HOOK", "1"),
("FAKE_MCP_STOP_TEXT", "you have open work"),
// Objection text returned for the first STOP_COUNT calls;
// empty string thereafter.
("FAKE_MCP_STOP_COUNT", "1"),
],
)
.await;
let p = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
)
.await;
let r = h.recv_until(|v| v["id"] == json!(p)).await;
assert!(r.get("result").is_some(), "errored: {r}");
assert_eq!(r["result"]["stopReason"], "end_turn");
// Agent must have called LLM ≥2 times (initial end_turn was rejected,
// forcing another LLM round). We expect exactly 3 here: text → tool → text.
let captured = llm.captured.lock().await;
assert!(
captured.len() >= 2,
"agent did not loop after objection: {} LLM calls",
captured.len()
);
// Round 2's request must carry the objection as a tool-role message
// (synthetic tool result), not a user/assistant message. Content is
// a JSON object with hook/server/text fields — never escapable.
let msgs = captured[1]["messages"].as_array().unwrap();
let objection_present = msgs.iter().any(|m| {
if m["role"] != "tool" {
return false;
}
let content = m["content"].as_str().unwrap_or("");
let parsed: Value = match serde_json::from_str(content) {
Ok(v) => v,
Err(_) => return false,
};
parsed["hook"] == "_Stop"
&& parsed["server"] == "fake"
&& parsed["text"]
.as_str()
.unwrap_or("")
.contains("you have open work")
});
assert!(
objection_present,
"objection (role=tool, JSON-encoded) missing from messages: {msgs:?}"
);
h.shutdown().await;
}
/// After `stop_max_rejections` objections, the agent honors end_turn
/// even if `_Stop` would still object. Set max=1 so it trips quickly.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn hook_stop_budget_exhausted() {
// LLM sequence:
// 1. text → triggers _Stop objection (rejections: 0→1)
// 2. tool_call (regular tool round)
// 3. text → gate sees rejections>=max, returns end_turn (no _Stop call)
let llm = spawn_capturing_llm(vec![
openai_text("first"),
openai_tool_call("tc1", "fake__tool_0", json!({})),
openai_text("second"),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("MCP_HOOK_SERVERS", "fake"),
("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"),
],
)
.await;
let sid = init_session_with_fake_mcp(
&mut h,
&[
("FAKE_MCP_TOOL_COUNT", "1"),
("FAKE_MCP_STOP_HOOK", "1"),
("FAKE_MCP_STOP_TEXT", "still working"),
],
)
.await;
let p = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
)
.await;
let r = h.recv_until(|v| v["id"] == json!(p)).await;
assert!(r.get("result").is_some(), "errored: {r}");
assert_eq!(r["result"]["stopReason"], "end_turn");
// Three LLM calls expected: budget cap stops the loop on the 3rd end_turn.
let captured = llm.captured.lock().await;
assert_eq!(
captured.len(),
3,
"expected exactly 3 LLM calls (budget cap), got {}",
captured.len()
);
h.shutdown().await;
}
/// A persistent `_Stop` objection must keep the turn alive through repeated
/// consecutive end_turn responses. The configured rejection budget is the
/// bounded escape hatch; accepting the second response would silently idle a
/// session that still has open work.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn hook_stop_consecutive_end_turn_uses_rejection_budget() {
// Three consecutive end_turn responses. With max=2, both objections must
// reroll the LLM and the third response is accepted by the budget cap.
let llm = spawn_capturing_llm(vec![
openai_text("done-1"),
openai_text("done-2"),
openai_text("done-3"),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("MCP_HOOK_SERVERS", "fake"),
("BUZZ_AGENT_STOP_MAX_REJECTIONS", "2"),
],
)
.await;
let sid = init_session_with_fake_mcp(
&mut h,
&[
("FAKE_MCP_TOOL_COUNT", "1"),
("FAKE_MCP_STOP_HOOK", "1"),
("FAKE_MCP_STOP_TEXT", "keep going"),
],
)
.await;
let p = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
)
.await;
let r = h.recv_until(|v| v["id"] == json!(p)).await;
assert!(r.get("result").is_some(), "errored: {r}");
assert_eq!(r["result"]["stopReason"], "end_turn");
// Both objections force another round; the budget permits the third end.
let captured = llm.captured.lock().await;
assert_eq!(
captured.len(),
3,
"expected 3 LLM calls (two objections, then budget cap), got {}",
captured.len()
);
h.shutdown().await;
}
/// The `_Stop` rejection budget is per prompt: exhausting it on one prompt
/// must not disable the stop guard for the rest of the session. A second
/// prompt gets a fresh budget and its end_turn is objected to again.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn hook_stop_budget_resets_per_prompt() {
// Each prompt: text → objection (budget 0→1) → text → cap. With max=1,
// both prompts take exactly 2 LLM calls; a session-cumulative budget
// would accept prompt 2's first end_turn without calling _Stop (3 total).
let llm = spawn_capturing_llm(vec![
openai_text("p1-a"),
openai_text("p1-b"),
openai_text("p2-a"),
openai_text("p2-b"),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("MCP_HOOK_SERVERS", "fake"),
("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"),
],
)
.await;
let sid = init_session_with_fake_mcp(
&mut h,
&[
("FAKE_MCP_TOOL_COUNT", "1"),
("FAKE_MCP_STOP_HOOK", "1"),
("FAKE_MCP_STOP_TEXT", "keep going"),
],
)
.await;
for prompt in ["one", "two"] {
let p = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text": prompt}]}),
)
.await;
let r = h.recv_until(|v| v["id"] == json!(p)).await;
assert!(r.get("result").is_some(), "errored: {r}");
assert_eq!(r["result"]["stopReason"], "end_turn");
}
let captured = llm.captured.lock().await;
assert_eq!(
captured.len(),
4,
"expected 4 LLM calls (fresh budget objected on both prompts), got {}",
captured.len()
);
h.shutdown().await;
}
/// Regression: an LLM that tries to call a hidden hook tool (e.g.
/// `fake___Stop`) directly must get an "unknown tool" error result —
/// the MCP server must NOT be invoked. This guarantees a malicious or
/// confused model can't trigger lifecycle hooks itself.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn hook_tools_hidden_from_llm() {
// LLM sequence:
// 1. tool_call to fake___Stop (hidden hook, must fail closed)
// 2. text "done"
let llm = spawn_capturing_llm(vec![
openai_tool_call("tc1", "fake___Stop", json!({})),
openai_text("done"),
])
.await;
// We deliberately leave MCP_HOOK_SERVERS unset so the
// agent's hook gate is disabled — hook-tool hiding must hold even
// when hooks aren't allowlisted (defense in depth).
let mut h = Harness::spawn(&llm.url).await;
let sid = init_session_with_fake_mcp(
&mut h,
&[
("FAKE_MCP_TOOL_COUNT", "1"),
("FAKE_MCP_STOP_HOOK", "1"),
// Distinct text we can scan for. If the MCP server is ever
// invoked, this string would appear in the captured history.
("FAKE_MCP_STOP_TEXT", "HOOK_LEAKED_TO_LLM"),
],
)
.await;
let p = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
)
.await;
let r = h.recv_until(|v| v["id"] == json!(p)).await;
assert!(r.get("result").is_some(), "errored: {r}");
// The tool result fed back to the LLM (round 2) must be the
// synthetic "unknown tool" error, not the hook's actual output.
let captured = llm.captured.lock().await;
assert_eq!(
captured.len(),
2,
"expected 2 LLM calls, got {}",
captured.len()
);
let msgs = captured[1]["messages"].as_array().unwrap();
let tool_msg = msgs
.iter()
.find(|m| m["role"] == "tool")
.expect("expected a tool result message in round 2");
let content = tool_msg["content"].as_str().unwrap_or("");
assert!(
content.contains("unknown tool"),
"expected unknown-tool error, got: {content}"
);
assert!(
!content.contains("HOOK_LEAKED_TO_LLM"),
"MCP hook was invoked from the LLM path: {content}"
);
// Defense-in-depth: also confirm the *advertised* tools never
// included the hook in the first place.
let round1_tools = captured[0]["tools"].as_array().unwrap();
for t in round1_tools {
let name = t["function"]["name"].as_str().unwrap_or("");
assert!(
!name.contains("_Stop"),
"hook tool advertised to LLM: {name}"
);
}
h.shutdown().await;
}
/// `_PostCompact` hook fires after a context-handoff and its output is
/// folded into the fresh `[Context Handoff]` user-context block as explicitly
/// untrusted text. The next LLM request must therefore see the post-compact
/// text without any orphan `role=tool` messages — proving the hook ran on the
/// *new* context, not the discarded one.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn hook_post_compact_injects_after_handoff() {
// Sequence of canned LLM responses consumed in order:
// 1-3. Three `session/prompt` rounds returning short text. Each
// prompt body is ~300 KB, so by the 4th prompt we'll be over
// the 90% (= ~922 KB) threshold of a 1 MB budget.
// 4. Handoff `summarize()` call returns the summary text.
// 5. Next regular `complete()` call after the handoff returns
// a final "done" message; we inspect this request's body.
let llm = spawn_capturing_llm(vec![
openai_text("ack-1"),
openai_text("ack-2"),
openai_text("ack-3"),
openai_text("handoff summary text"),
openai_text("done"),
])
.await;
// 1 MB budget = MIN allowed. Threshold = ~922 KB. Each ~300 KB prompt
// fills the budget on the 4th turn, triggering handoff.
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("MCP_HOOK_SERVERS", "fake"),
("BUZZ_AGENT_MAX_HISTORY_BYTES", &(1024 * 1024).to_string()),
// Allow at least one handoff.
("BUZZ_AGENT_MAX_HANDOFFS", "3"),
],
)
.await;
let sid = init_session_with_fake_mcp(
&mut h,
&[
("FAKE_MCP_TOOL_COUNT", "1"),
// No _Stop hook here — _PostCompact only.
("FAKE_MCP_POSTCOMPACT_HOOK", "1"),
("FAKE_MCP_POSTCOMPACT_TEXT", "todo state here"),
],
)
.await;
// Drive prompts until we observe a handoff. We detect it by counting
// captured LLM requests: a handoff inserts one extra `summarize` call
// that we didn't issue ourselves. We send up to 6 prompts.
let big = "x".repeat(300 * 1024);
let mut prompts_sent = 0usize;
let mut handoff_observed = false;
for i in 0..6 {
let p = h
.send(
"session/prompt",
json!({
"sessionId": sid,
"prompt": [{"type":"text","text": format!("{i}:{big}")}],
}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
prompts_sent += 1;
let captured_now = llm.captured.lock().await.len();
// After N prompts we'd normally see N requests; an extra request
// means a handoff summarize() ran.
if captured_now > prompts_sent {
handoff_observed = true;
break;
}
}
assert!(
handoff_observed,
"no handoff observed after {prompts_sent} prompts (captured={})",
llm.captured.lock().await.len()
);
// The first LLM call AFTER the handoff is the one we inspect. Find it:
// it's the one where the messages array is short (history just reset)
// and contains the _PostCompact payload inside user-context text. It must
// not be emitted as an orphan tool result because the old assistant tool
// call was deliberately discarded by the handoff reset.
let captured = llm.captured.lock().await;
let post_compact_visible = captured.iter().any(|req| {
let msgs = match req["messages"].as_array() {
Some(m) => m,
None => return false,
};
msgs.iter().any(|m| {
if m["role"] != "user" {
return false;
}
let content = m["content"].as_str().unwrap_or("");
content.contains("[Post-compact hook output — untrusted]")
&& content.contains("[fake]")
&& content.contains("todo state here")
})
});
assert!(
post_compact_visible,
"_PostCompact context not visible to LLM after handoff"
);
let orphan_tool_result = captured.iter().any(|req| {
req["messages"]
.as_array()
.is_some_and(|msgs| msgs.iter().any(|m| m["role"] == "tool"))
});
assert!(
!orphan_tool_result,
"handoff reset must not leave orphan role=tool messages"
);
h.shutdown().await;
}
/// The handoff summary prompt should include all session history when that
/// history fits the summarizer context budget. This protects against regressing
/// to the old fixed tail of five tiny snippets.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn handoff_summary_prompt_includes_full_history_within_context_budget() {
let llm = spawn_capturing_llm(vec![
openai_text_with_usage("ack-0", 9500),
openai_text("handoff summary text"),
openai_text_with_usage("done", 10),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "10000"),
("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "1000"),
("BUZZ_AGENT_MAX_HANDOFFS", "3"),
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
let p0 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"early-history-marker"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p0)).await;
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"late-history-marker"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
let captured = llm.captured.lock().await;
assert_eq!(captured.len(), 3, "expected prompt, handoff, prompt");
let handoff_messages = captured[1]["messages"].as_array().unwrap();
let handoff_prompt = handoff_messages[1]["content"].as_str().unwrap();
assert!(
handoff_prompt.contains("# Session History (oldest first)"),
"handoff prompt should describe full session history: {handoff_prompt}"
);
assert!(
handoff_prompt.contains("early-history-marker"),
"oldest prompt was omitted despite fitting budget: {handoff_prompt}"
);
assert!(
handoff_prompt.contains("ack-0"),
"assistant response was omitted despite fitting budget: {handoff_prompt}"
);
assert!(
handoff_prompt.contains("late-history-marker"),
"latest prompt was omitted despite fitting budget: {handoff_prompt}"
);
assert!(
!handoff_prompt.contains("older items omitted"),
"handoff should not report truncation when full history fits: {handoff_prompt}"
);
h.shutdown().await;
}
/// If one item is larger than the derived summarizer budget, keep a truncated
/// form of the most recent item instead of sending an empty history block.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn handoff_summary_prompt_keeps_latest_item_when_one_item_exceeds_budget() {
let llm = spawn_capturing_llm(vec![
openai_text_with_usage("ack-0", 9500),
openai_text("handoff summary text"),
openai_text_with_usage("done", 10),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "10000"),
("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "1000"),
("BUZZ_AGENT_MAX_HANDOFFS", "3"),
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
let huge = format!("oversize-latest-marker {}", "x".repeat(12000));
let p0 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"early-history-marker"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p0)).await;
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text": huge}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
let captured = llm.captured.lock().await;
assert_eq!(captured.len(), 3, "expected prompt, handoff, prompt");
let handoff_messages = captured[1]["messages"].as_array().unwrap();
let handoff_prompt = handoff_messages[1]["content"].as_str().unwrap();
assert!(
handoff_prompt.contains("oversize-latest-marker"),
"latest oversized item should be kept in truncated form: {handoff_prompt}"
);
assert!(
handoff_prompt.contains("older items omitted"),
"handoff should report truncation when history exceeds budget: {handoff_prompt}"
);
h.shutdown().await;
}
/// Regression for the original bug: context fills, the provider 400s on the
/// next request, and the handoff never fires because the old gate measured
/// BYTES (16 MiB threshold) while the limit is in TOKENS. The fix gates on
/// provider-reported input tokens. Here the prompts are tiny (bytes nowhere
/// near any byte threshold), but the fake LLM reports `usage.prompt_tokens`
/// over the configured token budget — so the handoff MUST fire on the token
/// signal alone, before the next normal `complete()`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn token_usage_over_budget_triggers_handoff() {
// Context window 1000 tokens, output 100 -> threshold = min(900, 900) = 900.
// First response reports 950 input tokens (> 900). The agent stores that;
// the next prompt's pre-flight gate sees 950 >= 900 and hands off, which
// inserts an extra summarize() call we didn't issue.
// req 1: prompt #0 -> text + usage(950)
// req 2: summarize() (the handoff) -> summary text
// req 3: prompt #1's actual complete() -> done
let llm = spawn_capturing_llm(vec![
openai_text_with_usage("ack-0", 950),
openai_text("handoff summary text"),
openai_text_with_usage("done", 10),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"),
("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"),
("BUZZ_AGENT_MAX_HANDOFFS", "3"),
// Huge byte budget so the byte path can NOT be what fires — only
// the token gate can explain a handoff on these tiny prompts.
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
// Prompt #0: small body; response carries usage(950) -> over threshold.
let p0 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"hello 0"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p0)).await;
assert_eq!(
llm.captured.lock().await.len(),
1,
"first prompt should produce exactly one LLM request (no handoff yet)"
);
// Prompt #1: also small. The pre-flight gate sees the stored 800 tokens
// and hands off BEFORE issuing this prompt's complete() -> an extra
// summarize request appears (3 total, not 2).
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"hello 1"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
let captured = llm.captured.lock().await.len();
assert_eq!(
captured, 3,
"expected handoff summarize() between the two prompts (3 reqs), saw {captured} — \
token gate did not fire on usage over budget"
);
let stderr = h.stderr_text();
assert!(
stderr.contains("handoff #1 (history"),
"expected handoff log line in stderr, got: {stderr}"
);
assert!(
stderr.contains(" -> ") && stderr.contains(" tokens"),
"expected handoff log to include before/after token counts, got: {stderr}"
);
h.shutdown().await;
}
/// Regression for the stale-usage gap (caught in review): the exact token
/// count describes the PREVIOUS request, but history grows afterward (tool
/// results, next prompt). If the gate trusted only the stale `Some(tokens)`
/// and skipped the byte signal, a previously-under-threshold session could
/// still 400 once a large tool result lands. The fix adds a conservative
/// token estimate of the bytes grown since the measurement. Here usage is
/// reported UNDER threshold, then a large tool result grows history enough
/// that the projection crosses — so the handoff must fire.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stale_usage_plus_history_growth_triggers_handoff() {
// window 10_000, output 1_000 -> threshold = min(9_000, 9_000) = 9_000.
// req1 reports usage 8_500 (UNDER 9_000). Its response is a tool_call;
// the fake MCP returns a ~6 KB result, appended to history. At the
// conservative 1 byte/token estimate that's ~6_000 projected tokens, so
// projected ~14_500 >= 9_000 -> the next loop iteration hands off before
// the follow-up complete().
// req1: tool_call + usage(8500)
// (tool result ~6KB appended)
// req2: summarize() (handoff)
// req3: final text
let llm = spawn_capturing_llm(vec![
{
let mut v = openai_tool_call("tc1", "fake__tool_0", json!({}));
v["usage"] =
json!({"prompt_tokens": 8500, "completion_tokens": 1, "total_tokens": 8501});
v
},
openai_text("handoff summary text"),
openai_text("done"),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "10000"),
("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "1000"),
("BUZZ_AGENT_MAX_HANDOFFS", "3"),
// Huge byte budget so the None-path byte fallback can't be what
// fires — only the token-mode growth estimate can explain it.
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
],
)
.await;
let sid = init_session_with_fake_mcp(
&mut h,
&[
("FAKE_MCP_TOOL_COUNT", "1"),
("FAKE_MCP_RESULT_SIZE", "6000"),
],
)
.await;
let p = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
// req1 (tool_call) + summarize (handoff) + req2 (done) = 3. Without the
// growth estimate we'd see only 2 (stale 8500 < 9000, no handoff).
let captured = llm.captured.lock().await.len();
assert_eq!(
captured, 3,
"expected handoff after history grew past threshold (3 reqs), saw {captured} — \
stale under-threshold usage skipped the growth estimate"
);
h.shutdown().await;
}
/// `_Stop` hook that takes longer than `BUZZ_AGENT_HOOK_TIMEOUT_MS`
/// must be treated as no-objection (fail-open). Agent stops normally.
///
/// Note on server-kill-on-timeout: `call_hooks` calls `kill_server` on a
/// timed-out hook so a wedged server can't poison subsequent calls. We
/// don't add a separate per-test assertion for this — the
/// `mcp_init_timeout_kills_child` test already exercises the same
/// kill-on-timeout codepath through `kill_server`, and the harness here
/// (spawn-then-shutdown) makes a follow-up "tool still works" check
/// fragile because the server we just killed is the only one in the
/// session. The timeout assertion below (elapsed < 2.5s) implicitly
/// covers the kill: if the hook child kept running past the timeout,
/// we'd block on it during shutdown.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn hook_stop_timeout_failopen() {
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("MCP_HOOK_SERVERS", "fake"),
// Hook delay (3s) >> hook timeout (200ms) → fail-open.
("BUZZ_AGENT_HOOK_TIMEOUT_MS", "200"),
],
)
.await;
let sid = init_session_with_fake_mcp(
&mut h,
&[
("FAKE_MCP_TOOL_COUNT", "1"),
("FAKE_MCP_STOP_HOOK", "1"),
("FAKE_MCP_STOP_TEXT", "would object"),
("FAKE_MCP_STOP_DELAY", "3"),
],
)
.await;
let started = Instant::now();
let p = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
)
.await;
let r = h.recv_until(|v| v["id"] == json!(p)).await;
let elapsed = started.elapsed();
assert!(r.get("result").is_some(), "errored: {r}");
assert_eq!(r["result"]["stopReason"], "end_turn");
// Hook delay is 3s; if we waited for it the test would take ≥3s.
// 1.5s gives slack for CI without masking a regression.
assert!(
elapsed < Duration::from_millis(2500),
"did not fail-open: prompt took {elapsed:?}"
);
// Only the initial LLM call — agent did NOT loop after the timeout.
let captured = llm.captured.lock().await;
assert_eq!(
captured.len(),
1,
"expected 1 LLM call, got {}",
captured.len()
);
h.shutdown().await;
}
/// When a session is cancelled while a tool call is in-flight, the agent
/// sends `notifications/cancelled` to the MCP server. With buzz-dev-mcp,
/// this cancels the CancellationToken and kills the running shell process
/// group. We verify:
/// 1. The prompt completes in under 5s (not 60s).
/// 2. The `sleep 60` process is actually dead after cancel.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_kills_inflight_tool_via_mcp_notification() {
// buzz-dev-mcp is a separate crate; locate its binary relative to
// the buzz-agent test binary (they share the same target dir).
let self_bin = std::path::PathBuf::from(env!("CARGO_BIN_EXE_buzz-agent"));
let dev_mcp_bin = self_bin.parent().unwrap().join("buzz-dev-mcp");
let dev_mcp_is_executable = std::fs::metadata(&dev_mcp_bin)
.map(|metadata| {
if !metadata.is_file() || metadata.len() == 0 {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
})
.unwrap_or(false);
if !dev_mcp_is_executable {
eprintln!(
"SKIP: buzz-dev-mcp not built at {}; run `cargo build -p buzz-dev-mcp` first",
dev_mcp_bin.display()
);
return;
}
let dev_mcp_bin = dev_mcp_bin.to_string_lossy().to_string();
// Use a unique marker (PID + timestamp) to avoid stale-file collisions.
let marker = format!(
"buzz_cancel_test_{}_{:x}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let pid_file = format!("/tmp/{marker}.pid");
let _ = std::fs::remove_file(&pid_file); // clean any stale file
let cmd = format!("echo $$ > /tmp/{marker}.pid && exec sleep 60");
// LLM returns a shell tool call, then text after cancel.
let llm = spawn_capturing_llm(vec![
openai_tool_call("tc1", "dev__shell", json!({"command": cmd})),
openai_text("done"),
])
.await;
let mut h = Harness::spawn(&llm.url).await;
let sid = init_session(
&mut h,
json!([{
"name": "dev",
"command": &dev_mcp_bin,
"args": [],
"env": []
}]),
)
.await;
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"run"}]}),
)
.await;
// Wait for the tool call to be in-progress.
h.recv_until(|v| {
v.get("params")
.and_then(|p| p.get("update"))
.and_then(|u| u.get("status"))
.and_then(Value::as_str)
== Some("in_progress")
})
.await;
// Wait for the shell to spawn and write its PID (bounded).
let pid_deadline = Instant::now() + Duration::from_secs(3);
let shell_pid: u32 = loop {
if let Ok(content) = std::fs::read_to_string(&pid_file) {
if let Ok(pid) = content.trim().parse::<u32>() {
break pid;
}
}
assert!(
Instant::now() < pid_deadline,
"shell did not write PID file within 3s"
);
tokio::time::sleep(Duration::from_millis(50)).await;
};
// Cancel the session — measure latency from here.
let cancel_start = Instant::now();
h.notify("session/cancel", json!({"sessionId": sid})).await;
// Wait for prompt to complete.
let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
let cancel_latency = cancel_start.elapsed();
// Cancellation itself should complete in well under 3s. The 60s sleep
// must NOT run to completion. We allow generous CI slack.
assert!(
cancel_latency < Duration::from_secs(3),
"cancel latency too high: {cancel_latency:?} (expected < 3s)"
);
// Verify the shell process is actually dead (bounded poll).
let kill_deadline = Instant::now() + Duration::from_secs(2);
loop {
let alive = std::process::Command::new("kill")
.args(["-0", &shell_pid.to_string()])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !alive {
break;
}
assert!(
Instant::now() < kill_deadline,
"shell process {shell_pid} still alive 2s after cancel"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
// Cleanup.
let _ = std::fs::remove_file(&pid_file);
h.shutdown().await;
}
/// Protocol-level test: verify that `notifications/cancelled` is sent to
/// any MCP server (not just buzz-dev-mcp) when a session is cancelled
/// during an in-flight tool call. Uses fake_mcp with FAKE_MCP_CANCEL_LOG
/// to capture the raw notification on stdin.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_sends_notifications_cancelled_to_any_mcp_server() {
let cancel_log = std::env::temp_dir()
.join(format!(
"buzz_cancel_proto_{}_{:x}.log",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
.to_string_lossy()
.to_string();
let _ = std::fs::remove_file(&cancel_log);
let call_received_marker = format!("{cancel_log}.call_received");
let _ = std::fs::remove_file(&call_received_marker);
let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
// LLM returns a tool call; fake_mcp will delay 999s (never responds).
let llm = spawn_capturing_llm(vec![
openai_tool_call("tc1", "fake__tool_0", json!({})),
openai_text("done"),
])
.await;
let mut h = Harness::spawn(&llm.url).await;
let sid = init_session(
&mut h,
json!([{
"name": "fake",
"command": fake_mcp,
"args": [],
"env": [
{"name": "FAKE_MCP_TOOL_DELAY", "value": "999"},
{"name": "FAKE_MCP_CANCEL_LOG", "value": &cancel_log},
{"name": "FAKE_MCP_CALL_RECEIVED", "value": &call_received_marker},
]
}]),
)
.await;
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
)
.await;
// Wait for tool call to be in-progress.
h.recv_until(|v| {
v.get("params")
.and_then(|p| p.get("update"))
.and_then(|u| u.get("status"))
.and_then(Value::as_str)
== Some("in_progress")
})
.await;
// Wait until fake_mcp has received the tools/call request (bounded).
// The marker file contains the JSON-RPC request id.
let call_deadline = Instant::now() + Duration::from_secs(3);
let call_request_id: Value = loop {
if let Ok(content) = std::fs::read_to_string(&call_received_marker) {
if let Ok(id) = serde_json::from_str::<Value>(content.trim()) {
break id;
}
}
assert!(
Instant::now() < call_deadline,
"fake_mcp did not receive tools/call within 3s"
);
tokio::time::sleep(Duration::from_millis(20)).await;
};
// Cancel the session.
h.notify("session/cancel", json!({"sessionId": sid})).await;
// Wait for prompt to complete.
let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
// Poll the cancel log with bounded timeout (replaces fixed sleep).
let poll_deadline = Instant::now() + Duration::from_secs(2);
let log_content = loop {
let content = std::fs::read_to_string(&cancel_log).unwrap_or_default();
if content.contains("notifications/cancelled") {
break content;
}
assert!(
Instant::now() < poll_deadline,
"cancel notification not received within 2s; log: {content:?}"
);
tokio::time::sleep(Duration::from_millis(50)).await;
};
// Parse the logged notification and verify requestId matches the
// actual tools/call request id that fake_mcp received.
let cancel_msg: Value = serde_json::from_str(log_content.trim()).unwrap_or(json!(null));
let cancelled_id = &cancel_msg["params"]["requestId"];
assert!(
cancelled_id.is_number(),
"expected numeric requestId in cancel notification, got: {cancel_msg}"
);
assert_eq!(
cancelled_id, &call_request_id,
"cancelled requestId ({cancelled_id}) != tools/call id ({call_request_id})"
);
// Cleanup.
let _ = std::fs::remove_file(&cancel_log);
let _ = std::fs::remove_file(&call_received_marker);
h.shutdown().await;
}
// ---------------------------------------------------------------------------
// Reply guard (`BUZZ_AGENT_REQUIRE_REPLY`)
//
// The guard reminds the model to publish when a turn is about to end without
// any recognized attempt to post to Buzz. It rides the existing `_Stop` gate
// and shares its rejection budget, so most of these tests count LLM calls:
// each reminder costs exactly one extra round.
// ---------------------------------------------------------------------------
/// Number of reply-guard reminders present in one captured LLM request.
///
/// A reminder is a tool-role message whose JSON body is attributed to the
/// in-process guard (`server: "buzz-agent"`) at the `_Stop` hook point — the
/// same lower-trust shape as real hook output.
fn reply_nag_count(request: &Value) -> usize {
request["messages"]
.as_array()
.map(|msgs| {
msgs.iter()
.filter(|m| {
m["role"] == "tool"
&& serde_json::from_str::<Value>(m["content"].as_str().unwrap_or(""))
.map(|p| p["hook"] == "_Stop" && p["server"] == "buzz-agent")
.unwrap_or(false)
})
.count()
})
.unwrap_or(0)
}
/// A publish-shaped call to a real registered shell tool.
fn openai_shell_send(id: &str) -> Value {
openai_tool_call(
id,
"fake__shell",
json!({ "command": "buzz messages send --channel c --content hi" }),
)
}
/// Run one prompt to completion, answering any permission requests, and
/// return the final response.
async fn prompt_to_completion(h: &mut Harness, sid: &str) -> Value {
let p = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
)
.await;
loop {
let v = h.recv().await;
if v.get("method") == Some(&json!("session/request_permission")) {
let id = v["id"].clone();
h.write(json!({
"jsonrpc": "2.0",
"id": id,
"result": { "outcome": { "outcome": "selected", "optionId": "allow" } },
}))
.await;
continue;
}
if v["id"] == json!(p) {
return v;
}
}
}
/// Default off: a silent turn ends on the first end_turn with no extra round.
/// This is the invariant that keeps the feature free for everyone who hasn't
/// opted in.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reply_guard_off_by_default() {
let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await;
let mut h = Harness::spawn(&llm.url).await;
let sid = init_session(&mut h, json!([])).await;
let r = prompt_to_completion(&mut h, &sid).await;
assert_eq!(r["result"]["stopReason"], "end_turn");
let captured = llm.captured.lock().await;
assert_eq!(
captured.len(),
1,
"guard must be inert when unset, got {} LLM calls",
captured.len()
);
h.shutdown().await;
}
/// `BUZZ_AGENT_REQUIRE_REPLY=0` is off too — the toggle is numeric, so a
/// literal `0` must not read as "set, therefore on".
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reply_guard_explicit_zero_is_off() {
let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await;
let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "0")]).await;
let sid = init_session(&mut h, json!([])).await;
let r = prompt_to_completion(&mut h, &sid).await;
assert_eq!(r["result"]["stopReason"], "end_turn");
let captured = llm.captured.lock().await;
assert_eq!(
captured.len(),
1,
"REQUIRE_REPLY=0 must behave as off, got {} LLM calls",
captured.len()
);
h.shutdown().await;
}
/// Opted in and silent: exactly two reminders, then the turn is allowed to
/// end. The guard is advisory — it must never trap a turn.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reply_guard_nags_twice_then_lets_the_turn_end() {
// Budget defaults to 3, so the cap that stops the loop here is
// MAX_REPLY_NAGS = 2, not the rejection budget.
let llm = spawn_capturing_llm(vec![
openai_text("silent-1"),
openai_text("silent-2"),
openai_text("silent-3"),
openai_text("must-not-be-requested"),
])
.await;
let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "1")]).await;
let sid = init_session(&mut h, json!([])).await;
let r = prompt_to_completion(&mut h, &sid).await;
assert_eq!(r["result"]["stopReason"], "end_turn");
let captured = llm.captured.lock().await;
assert_eq!(
captured.len(),
3,
"expected 2 reminders then end_turn (3 LLM calls), got {}",
captured.len()
);
assert_eq!(
reply_nag_count(&captured[0]),
0,
"reminder before any end_turn"
);
assert_eq!(reply_nag_count(&captured[1]), 1);
assert_eq!(reply_nag_count(&captured[2]), 2);
// The reminder must name the command it wants and license silence, so it
// cannot fight the base prompt's "silence is usually correct".
let msgs = captured[2]["messages"].as_array().unwrap();
let nag = msgs
.iter()
.filter_map(|m| serde_json::from_str::<Value>(m["content"].as_str().unwrap_or("")).ok())
.find(|p| p["server"] == "buzz-agent")
.expect("reminder body");
let text = nag["text"].as_str().unwrap_or("");
assert!(
text.contains("buzz messages send"),
"reminder should name the command: {text}"
);
assert!(
text.contains("silence is genuinely correct"),
"reminder must license silence: {text}"
);
h.shutdown().await;
}
/// A real publish attempt through a registered shell tool satisfies the guard:
/// no reminder, no extra round.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reply_guard_satisfied_by_registered_shell_send() {
let llm = spawn_capturing_llm(vec![
openai_shell_send("tc1"),
openai_text("posted"),
openai_text("must-not-be-requested"),
])
.await;
let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "1")]).await;
let sid = init_session_with_fake_mcp(
&mut h,
&[("FAKE_MCP_TOOL_COUNT", "1"), ("FAKE_MCP_SHELL_TOOL", "1")],
)
.await;
let r = prompt_to_completion(&mut h, &sid).await;
assert_eq!(r["result"]["stopReason"], "end_turn");
let captured = llm.captured.lock().await;
assert_eq!(
captured.len(),
2,
"a recognized send must not be nagged, got {} LLM calls",
captured.len()
);
assert_eq!(reply_nag_count(&captured[1]), 0);
h.shutdown().await;
}
/// A publish-shaped call to a shell tool that is *not registered* never runs —
/// preflight rejects it — so it must not disarm the guard. This is what the
/// `has`/`is_hook` checks in the predicate buy.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reply_guard_ignores_unregistered_shell_tool() {
// FAKE_MCP_SHELL_TOOL is absent, so `fake__shell` is a hallucination.
let llm = spawn_capturing_llm(vec![
openai_shell_send("tc1"),
openai_text("silent-1"),
openai_text("silent-2"),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_REQUIRE_REPLY", "1"),
("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"),
],
)
.await;
let sid = init_session_with_fake_mcp(&mut h, &[("FAKE_MCP_TOOL_COUNT", "1")]).await;
let r = prompt_to_completion(&mut h, &sid).await;
assert_eq!(r["result"]["stopReason"], "end_turn");
let captured = llm.captured.lock().await;
assert_eq!(
captured.len(),
3,
"expected the hallucinated call to still be nagged, got {} LLM calls",
captured.len()
);
let msgs = captured[1]["messages"].as_array().unwrap();
assert!(
msgs.iter()
.any(|m| m["role"] == "tool"
&& m["content"].as_str().unwrap_or("").contains("unknown tool")),
"expected preflight to reject the call: {msgs:?}"
);
assert_eq!(reply_nag_count(&captured[2]), 1);
h.shutdown().await;
}
/// A publish-shaped call discarded by the per-turn tool-call cap never runs,
/// so it must not suppress the reminder either. Pins the check's placement
/// after `calls.truncate(MAX_TOOL_CALLS_PER_TURN)`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reply_guard_ignores_calls_lost_to_the_turn_cap() {
// 64 filler calls (the cap) followed by the publish attempt, which is
// therefore truncated away. The shell tool *is* registered here, so only
// the placement — not tool identity — can explain the reminder.
let mut calls: Vec<Value> = (0..64)
.map(|i| {
json!({
"id": format!("c{i}"),
"type": "function",
"function": { "name": "fake__tool_0", "arguments": "{}" },
})
})
.collect();
calls.push(json!({
"id": "c-send",
"type": "function",
"function": {
"name": "fake__shell",
"arguments": json!({ "command": "buzz messages send --channel c --content hi" })
.to_string(),
},
}));
let truncated_send = json!({
"id": "cc-trunc", "object": "chat.completion", "model": "fake-model",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": null, "tool_calls": calls },
"finish_reason": "tool_calls",
}],
});
let llm = spawn_capturing_llm(vec![
truncated_send,
openai_text("silent-1"),
openai_text("silent-2"),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_REQUIRE_REPLY", "1"),
("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"),
],
)
.await;
let sid = init_session_with_fake_mcp(
&mut h,
&[("FAKE_MCP_TOOL_COUNT", "1"), ("FAKE_MCP_SHELL_TOOL", "1")],
)
.await;
let r = prompt_to_completion(&mut h, &sid).await;
assert_eq!(r["result"]["stopReason"], "end_turn");
let captured = llm.captured.lock().await;
assert_eq!(
captured.len(),
3,
"a truncated send must still be nagged, got {} LLM calls",
captured.len()
);
assert_eq!(reply_nag_count(&captured[2]), 1);
h.shutdown().await;
}
/// The shared `_Stop` rejection budget is the outer cap: at 1 the guard gets
/// one reminder instead of two. Documented degradation, not a bug.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reply_guard_bounded_by_stop_rejection_budget() {
let llm = spawn_capturing_llm(vec![
openai_text("silent-1"),
openai_text("silent-2"),
openai_text("must-not-be-requested"),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_REQUIRE_REPLY", "1"),
("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
let r = prompt_to_completion(&mut h, &sid).await;
assert_eq!(r["result"]["stopReason"], "end_turn");
let captured = llm.captured.lock().await;
assert_eq!(
captured.len(),
2,
"budget 1 must allow exactly one reminder, got {} LLM calls",
captured.len()
);
assert_eq!(reply_nag_count(&captured[1]), 1);
h.shutdown().await;
}
/// Budget 0 disables every objection at the gate, including this one.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reply_guard_off_when_stop_budget_is_zero() {
let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_REQUIRE_REPLY", "1"),
("BUZZ_AGENT_STOP_MAX_REJECTIONS", "0"),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
let r = prompt_to_completion(&mut h, &sid).await;
assert_eq!(r["result"]["stopReason"], "end_turn");
let captured = llm.captured.lock().await;
assert_eq!(
captured.len(),
1,
"budget 0 must disable the guard, got {} LLM calls",
captured.len()
);
h.shutdown().await;
}
/// The two axes are independent inside one shared budget: a round carrying
/// both a `_Stop` hook objection and a reminder costs one rejection and
/// delivers both texts, and once the reminders are spent the hook objection
/// continues alone.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reply_guard_combines_with_stop_hook_objection() {
// The hook objects on its first 3 calls, then clears. Reminders stop
// after 2, so round 3 must carry the hook text and no new reminder.
let llm = spawn_capturing_llm(vec![
openai_text("silent-1"),
openai_text("silent-2"),
openai_text("silent-3"),
openai_text("silent-4"),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_REQUIRE_REPLY", "1"),
("MCP_HOOK_SERVERS", "fake"),
("BUZZ_AGENT_STOP_MAX_REJECTIONS", "10"),
],
)
.await;
let sid = init_session_with_fake_mcp(
&mut h,
&[
("FAKE_MCP_TOOL_COUNT", "1"),
("FAKE_MCP_STOP_HOOK", "1"),
("FAKE_MCP_STOP_TEXT", "you have open todos"),
("FAKE_MCP_STOP_COUNT", "3"),
],
)
.await;
let r = prompt_to_completion(&mut h, &sid).await;
assert_eq!(r["result"]["stopReason"], "end_turn");
let captured = llm.captured.lock().await;
assert_eq!(
captured.len(),
4,
"expected 3 objecting rounds then a clear end, got {}",
captured.len()
);
let hook_objections = |req: &Value| -> usize {
req["messages"]
.as_array()
.map(|msgs| {
msgs.iter()
.filter(|m| {
m["content"]
.as_str()
.unwrap_or("")
.contains("you have open todos")
})
.count()
})
.unwrap_or(0)
};
// Round 2 carries one of each — a single rejection bought both texts.
assert_eq!(reply_nag_count(&captured[1]), 1);
assert_eq!(hook_objections(&captured[1]), 1);
// Round 4: the hook objected three times, the guard only twice.
assert_eq!(reply_nag_count(&captured[3]), 2);
assert_eq!(hook_objections(&captured[3]), 3);
h.shutdown().await;
}
/// An unparseable toggle is a startup error, not a silent default. `parse_env`
/// is generic over `FromStr`, so this also pins the numeric type: a `bool`
/// field would have rejected the documented `1`.
#[test]
fn reply_guard_rejects_unparseable_toggle() {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_buzz-agent"))
.env("BUZZ_AGENT_PROVIDER", "openai")
.env("OPENAI_COMPAT_API_KEY", "test")
.env("OPENAI_COMPAT_MODEL", "fake-model")
.env("BUZZ_AGENT_REQUIRE_REPLY", "true")
.stdin(Stdio::null())
.output()
.expect("run buzz-agent");
assert!(
!out.status.success(),
"expected a config error exit, got {:?}",
out.status
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("BUZZ_AGENT_REQUIRE_REPLY"),
"expected the offending key in the error, got: {stderr}"
);
}
/// A prompt large enough that the recovery ladder's halving stays above
/// `HANDOFF_MIN_PROMPT_BUDGET_BYTES` (4 KiB) for all three rungs.
///
/// This is load-bearing, not decoration: with a tiny history the ladder
/// correctly refuses on the FIRST rung (halving a 49-byte history lands at 24
/// bytes, far under the floor), so a small fixture cannot exercise recovery at
/// all — it exercises the floor. `marker` is embedded so the prompt is still
/// identifiable in a captured request body.
fn large_prompt(marker: &str) -> String {
let mut s = String::with_capacity(64 * 1024 + marker.len());
s.push_str(marker);
s.push(' ');
while s.len() < 64 * 1024 {
s.push_str("filler context to make the history realistically large. ");
}
s
}
/// OpenAI-compatible context-window rejection body, matching the shape the
/// provider actually returns on overflow.
fn openai_context_length_error() -> Value {
json!({
"error": {
"message": "This model's maximum context length is 8192 tokens. \
However, your messages resulted in 20000 tokens.",
"type": "invalid_request_error",
"code": "context_length_exceeded",
}
})
}
/// A 400 that is NOT a context-window overflow — the negative control for the
/// matcher. Deliberately quotes "tokens" and "model", the words a sloppy
/// matcher would key on.
fn openai_ordinary_400() -> Value {
json!({
"error": {
"message": "Invalid value for 'max_tokens': must be an integer for this model",
"type": "invalid_request_error",
"code": "invalid_value",
}
})
}
/// THE BUG. A provider context-window 400 must be recovered from in-loop, not
/// propagated out of `run()`.
///
/// Without the reactive path this is a permanent stick, and the mechanism is
/// what makes it permanent rather than transient: a failed request reports no
/// usage, so `last_request_input_tokens` stays frozen at the last SUCCESSFUL
/// (sub-threshold) reading, `should_handoff()` therefore returns false forever,
/// and the in-memory session keeps the same oversized history. Every later
/// prompt in that session fails identically, for the life of the session.
/// (Restarting the agent clears it — history is not written to disk — which is
/// why the only workaround today is a restart.)
///
/// The sequence here reproduces exactly that state: request 1 succeeds and
/// reports usage well UNDER the threshold (so the proactive gate is provably
/// not what fires), request 2 is rejected with a context-window 400. The agent
/// must force a handoff and retry, so the prompt still ends in a normal
/// `end_turn` rather than a JSON-RPC error.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn context_window_400_recovers_instead_of_sticking() {
let llm = spawn_capturing_llm_with_status(vec![
// req 1: succeeds, usage 10 tokens — far under any threshold.
(200, openai_text_with_usage("ack", 10)),
// req 2: the overflow rejection.
(400, openai_context_length_error()),
// req 3: the forced handoff's summarize() call.
(200, openai_text("recovered handoff summary")),
// req 4: the retried completion, now on fresh history.
(200, openai_text_with_usage("done after recovery", 10)),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
// Large window + large byte budget: neither proactive gate can be
// what produces the handoff, so a handoff here is attributable to
// the reactive path alone.
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "8192"),
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
// Cap of 0: proves the forced path bypasses `max_handoffs`. Any
// gated handoff is impossible under this setting.
("BUZZ_AGENT_MAX_HANDOFFS", "0"),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
let p0 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"first prompt, succeeds"}]}),
)
.await;
let r0 = h.recv_until(|v| v["id"] == json!(p0)).await;
assert!(
r0["result"].get("stopReason").is_some(),
"first prompt should succeed: {r0}"
);
// Second prompt: its first completion is rejected for context overflow.
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("second-prompt-overflows")}]}),
)
.await;
let r1 = h.recv_until(|v| v["id"] == json!(p1)).await;
assert!(
r1.get("error").is_none(),
"context-window 400 must be recovered in-loop, not returned as an error: {r1} \
stderr={}",
h.stderr_text()
);
assert_eq!(
r1["result"]["stopReason"],
"end_turn",
"expected the turn to finish after recovery: {r1} stderr={}",
h.stderr_text()
);
// 4 requests = the rejected one, the summarize, and the retry. 2 would mean
// no recovery was attempted.
let captured = llm.captured.lock().await.len();
assert_eq!(
captured,
4,
"expected reject + summarize + retry (4 reqs total), saw {captured} — stderr={}",
h.stderr_text()
);
let stderr = h.stderr_text();
assert!(
stderr.contains("provider reported context overflow; forcing handoff"),
"expected the forced-handoff log line, got: {stderr}"
);
h.shutdown().await;
}
/// A provider output-token stop is an interrupted round, not completion. The
/// agent must preserve any text, discard a possibly partial tool call, provide
/// actionable feedback, and let the same prompt finish normally.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn max_tokens_recovers_in_turn_without_running_partial_tool_call() {
const PARTIAL: &str = "partial-before-limit";
let partial_call = json!([{
"id": "partial-call", "type": "function",
"function": { "name": "dev__shell", "arguments": "{\"command\":\"echo" },
}]);
let llm = spawn_capturing_llm(vec![
openai_max_tokens(PARTIAL, partial_call),
openai_text("done after truncation"),
])
.await;
let mut h = Harness::spawn(&llm.url).await;
let sid = init_session(&mut h, json!([])).await;
let prompt_id = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"do the task"}]}),
)
.await;
let reply = h.recv_until(|v| v["id"] == json!(prompt_id)).await;
assert_eq!(reply["result"]["stopReason"], "end_turn", "{reply}");
let requests = llm.captured.lock().await;
assert_eq!(requests.len(), 2, "truncation should trigger one retry");
let retry = &requests[1]["messages"];
let serialized = retry.to_string();
assert!(
serialized.contains(PARTIAL),
"partial text was lost: {retry}"
);
assert!(
serialized.contains("output token limit")
&& serialized.contains("smaller steps")
&& serialized.contains("tool call"),
"retry lacks actionable truncation feedback: {retry}"
);
assert!(
!serialized.contains("partial-call") && !serialized.contains("tool_call_id"),
"partial tool call must not be replayed or executed: {retry}"
);
drop(requests);
h.shutdown().await;
}
/// `max_rounds` counts max-token responses because they are successful, billed
/// provider requests. Recovery must not grant them the refund reserved for a
/// rejected context-overflow request.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn max_tokens_recovery_respects_finite_round_cap() {
let llm = spawn_capturing_llm(vec![
openai_max_tokens("cut off", json!([])),
openai_text("must not be requested"),
])
.await;
let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_MAX_ROUNDS", "1")]).await;
let sid = init_session(&mut h, json!([])).await;
let prompt_id = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
)
.await;
let reply = h.recv_until(|v| v["id"] == json!(prompt_id)).await;
assert_eq!(
reply["result"]["stopReason"], "max_turn_requests",
"{reply}"
);
assert_eq!(llm.captured.lock().await.len(), 1);
h.shutdown().await;
}
/// With the production-unbounded round setting, a model that always fills its
/// output allowance still has to return. Two recovery prompts are allowed; the
/// third truncation surfaces the original stop reason.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn repeated_max_tokens_is_bounded() {
let responses = (0..4)
.map(|_| openai_max_tokens("still truncated", json!([])))
.collect();
let llm = spawn_capturing_llm(responses).await;
let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_MAX_ROUNDS", "0")]).await;
let sid = init_session(&mut h, json!([])).await;
let prompt_id = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
)
.await;
let reply = tokio::time::timeout(
Duration::from_secs(10),
h.recv_until(|v| v["id"] == json!(prompt_id)),
)
.await
.expect("max-token recovery must be bounded");
assert_eq!(reply["result"]["stopReason"], "max_tokens", "{reply}");
assert_eq!(llm.captured.lock().await.len(), 3);
h.shutdown().await;
}
/// A successful recovery must actually send the recovered completion, even when
/// `max_rounds` is finite. `round` is incremented BEFORE the completion that
/// gets rejected, so a naive `continue` after recovery re-enters the loop with
/// the rejected attempt already charged against the cap: with
/// `BUZZ_AGENT_MAX_ROUNDS=1` the turn would return `max_turn_requests` after
/// destructively resetting history, having never sent the retry. That silently
/// converts "recovered" into "history destroyed, question unanswered" — worse
/// than the error it replaced, because the user gets a stop reason rather than a
/// failure.
///
/// The default `max_rounds` is 0 (unbounded), which is why the rest of the
/// matrix cannot see this: the cap check at the top of the loop never fires.
///
/// `max_rounds=1` is also the tightest possible setting, so it pins the
/// boundary: exactly one round is authorized, the rejected request must not
/// consume it, and the retry must be the request that spends it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn recovery_retry_is_sent_under_a_finite_round_cap() {
let llm = spawn_capturing_llm_with_status(vec![
// req 1: the overflow rejection (round 1 charged before it is sent).
(400, openai_context_length_error()),
// req 2: the forced handoff's summarize() call.
(200, openai_text("recovered handoff summary")),
// req 3: the retried completion. Under the bug this is never sent.
(200, openai_text_with_usage("done after recovery", 10)),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "8192"),
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
("BUZZ_AGENT_MAX_HANDOFFS", "0"),
// The whole point: a finite cap, at its tightest.
("BUZZ_AGENT_MAX_ROUNDS", "1"),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
let p0 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("overflows-under-finite-cap")}]}),
)
.await;
let r0 = h.recv_until(|v| v["id"] == json!(p0)).await;
assert!(
r0.get("error").is_none(),
"context-window 400 must be recovered in-loop: {r0} stderr={}",
h.stderr_text()
);
// The discriminator. `max_turn_requests` here means recovery ran, history
// was reset, and the turn ended without ever asking the model again.
assert_eq!(
r0["result"]["stopReason"],
"end_turn",
"a recovered turn must finish by answering, not by hitting the round cap: {r0} \
stderr={}",
h.stderr_text()
);
// 3 requests = reject + summarize + retry. 2 would mean the retry was
// never sent (the bug); the outcome assertion alone cannot tell those apart
// if the stop reason were ever produced some other way.
let captured = llm.captured.lock().await.len();
assert_eq!(
captured,
3,
"expected reject + summarize + retry (3 reqs), saw {captured} — stderr={}",
h.stderr_text()
);
h.shutdown().await;
}
/// The finite round cap must still bind for ORDINARY rounds — the recovery
/// refund must not become a general amnesty. With `max_rounds=1` and no context
/// overflow anywhere, a model that keeps requesting tool calls gets exactly one
/// completion and then `max_turn_requests`.
///
/// Without this arm, "make the recovered retry possible" is satisfiable by
/// deleting the cap, and the test above would still pass.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn finite_round_cap_still_binds_without_a_context_overflow() {
let llm = spawn_capturing_llm_with_status(vec![
// Round 1: a tool call, which would normally drive another round.
(
200,
openai_tool_call("tc1", "dev__shell", json!({"command": "true"})),
),
// Never reached: the cap must stop the turn before a second completion.
(200, openai_text_with_usage("should not be sent", 10)),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
("BUZZ_AGENT_MAX_ROUNDS", "1"),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
let p0 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"drive a tool call"}]}),
)
.await;
let r0 = h.recv_until(|v| v["id"] == json!(p0)).await;
assert_eq!(
r0["result"]["stopReason"],
"max_turn_requests",
"an ordinary finite cap must still bind: {r0} stderr={}",
h.stderr_text()
);
let captured = llm.captured.lock().await.len();
assert_eq!(
captured,
1,
"exactly one completion is authorized by max_rounds=1, saw {captured} — stderr={}",
h.stderr_text()
);
h.shutdown().await;
}
/// Prompt-exactly-once across a forced handoff: the live user prompt must be
/// retained in the fresh history exactly once — not dropped (the model would
/// answer a question it can no longer see) and not duplicated (a doubled prompt
/// re-inflates the context we just shrank, and can produce a doubled action).
///
/// Asserted on the retry request's own message array, which is the only place
/// the post-reset history is observable from outside.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn forced_handoff_retains_live_prompt_exactly_once() {
const MARKER: &str = "unique-live-prompt-marker-7f3a";
let llm = spawn_capturing_llm_with_status(vec![
(200, openai_text_with_usage("ack", 10)),
(400, openai_context_length_error()),
(200, openai_text("summary body")),
(200, openai_text_with_usage("done", 10)),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
("BUZZ_AGENT_MAX_HANDOFFS", "0"),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
let p0 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"warmup"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p0)).await;
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt(MARKER)}]}),
)
.await;
let r1 = h.recv_until(|v| v["id"] == json!(p1)).await;
assert!(r1.get("error").is_none(), "expected recovery: {r1}");
let captured = llm.captured.lock().await;
let retry = captured
.last()
.expect("at least one captured request")
.clone();
drop(captured);
let messages = retry["messages"]
.as_array()
.unwrap_or_else(|| panic!("retry request had no messages array: {retry}"));
let occurrences = messages
.iter()
.filter(|m| {
m["content"]
.as_str()
.map(|s| s.contains(MARKER))
.unwrap_or(false)
})
.count();
assert_eq!(
occurrences, 1,
"live prompt must appear exactly once in post-handoff history, saw {occurrences} in \
{messages:#?}"
);
h.shutdown().await;
}
/// Negative control at the loop layer: an ordinary 400 must stay terminal.
///
/// This is the arm that keeps the recovery narrow. If the matcher were loose,
/// this request would be classified as recoverable, the agent would spend its
/// whole recovery budget summarizing, and a clear immediate failure would
/// become a slow one — with three wasted provider round-trips. Exactly one
/// request, and the prompt returns an error.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ordinary_400_stays_terminal_and_triggers_no_recovery() {
let llm = spawn_capturing_llm_with_status(vec![(400, openai_ordinary_400())]).await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
("BUZZ_AGENT_MAX_HANDOFFS", "3"),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
let p0 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"hello"}]}),
)
.await;
let r0 = h.recv_until(|v| v["id"] == json!(p0)).await;
assert!(
r0.get("error").is_some(),
"an ordinary 400 must surface as an error, got: {r0}"
);
let captured = llm.captured.lock().await.len();
assert_eq!(
captured,
1,
"an ordinary 400 must not trigger a recovery attempt; saw {captured} requests — \
stderr={}",
h.stderr_text()
);
let stderr = h.stderr_text();
assert!(
!stderr.contains("provider reported context overflow"),
"ordinary 400 must not be classified as a context overflow, got: {stderr}"
);
h.shutdown().await;
}
/// The recovery budget must be finite: a provider that rejects every request
/// for context overflow — including the retries — has to surface the error
/// rather than being rescued forever. `max_rounds` cannot bound this (it
/// defaults to 0/unbounded), so the per-`run()` recovery budget is the only
/// thing standing between this case and an infinite loop.
///
/// The stub returns a context-400 to EVERY request, so a missing bound shows up
/// as a hang rather than a wrong answer — hence the explicit timeout, which is
/// part of the assertion.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn context_recovery_budget_exhaustion_surfaces_the_error() {
// Enough canned 400s that the queue is never the thing that stops the loop;
// the fallback response is also a 400-shaped body under this helper only if
// queued, so keep the queue generously long.
let responses: Vec<(u16, Value)> = (0..40)
.map(|_| (400, openai_context_length_error()))
.collect();
let llm = spawn_capturing_llm_with_status(responses).await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
("BUZZ_AGENT_MAX_HANDOFFS", "0"),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
let p0 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("always-overflows")}]}),
)
.await;
let r0 = tokio::time::timeout(
Duration::from_secs(20),
h.recv_until(|v| v["id"] == json!(p0)),
)
.await
.expect("recovery must be bounded — prompt never returned, so the rescue loop is unbounded");
assert!(
r0.get("error").is_some(),
"exhausted recovery must surface the provider error, got: {r0}"
);
let msg = r0["error"]["message"].as_str().unwrap_or_default();
assert!(
msg.contains("context"),
"surfaced error should be the provider's own context-window error, got: {msg}"
);
// Discriminate WHICH bound stopped the loop. Both the budget and the prompt
// floor produce a surfaced error, so the assertion above passes either way
// — and the floor can fire on the first rung without the budget ever being
// consumed, which would make this test silently exercise a different
// mechanism than its name claims. Pin the budget explicitly.
let stderr = h.stderr_text();
assert!(
stderr.contains("context recovery budget spent"),
"the per-run recovery BUDGET must be what stops the loop here, not the prompt floor; \
got: {stderr}"
);
// Corroboration: every rung actually ran a forced handoff.
let rungs = stderr
.matches("provider reported context overflow; forcing handoff")
.count();
assert_eq!(
rungs, 3,
"expected all 3 recovery rungs to be attempted before giving up, saw {rungs} — \
stderr={stderr}"
);
h.shutdown().await;
}
/// The prompt-budget floor, observed on its own. A context-window 400 on a
/// SMALL history must refuse to rescue rather than halve toward zero: the
/// overflow is then dominated by what a handoff cannot shrink (system prompt,
/// tool schemas, the live user prompt), so shrinking history further would only
/// issue smaller doomed requests in place of a clear error.
///
/// The outcome — a surfaced error — is identical to budget exhaustion, so this
/// asserts the discriminating evidence instead: the floor log line, and that
/// ZERO forced handoffs were attempted. Without the floor the ladder would spend
/// all three rungs summarizing a 40-byte history, which is the behavior this
/// arm exists to forbid.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn small_history_context_400_refuses_rescue_at_the_prompt_floor() {
let responses: Vec<(u16, Value)> = (0..10)
.map(|_| (400, openai_context_length_error()))
.collect();
let llm = spawn_capturing_llm_with_status(responses).await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
("BUZZ_AGENT_MAX_HANDOFFS", "0"),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
let p0 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"tiny"}]}),
)
.await;
let r0 = tokio::time::timeout(
Duration::from_secs(20),
h.recv_until(|v| v["id"] == json!(p0)),
)
.await
.expect("must not loop — the floor should stop the rescue immediately");
assert!(
r0.get("error").is_some(),
"a context 400 with no shrinkable history must surface the error, got: {r0}"
);
let stderr = h.stderr_text();
assert!(
stderr.contains("below the") && stderr.contains("floor"),
"the prompt-budget FLOOR must be what stops this, not the recovery budget; got: {stderr}"
);
let rungs = stderr
.matches("provider reported context overflow; forcing handoff")
.count();
assert_eq!(
rungs, 0,
"no rescue should be attempted below the floor, saw {rungs} — stderr={stderr}"
);
// Exactly one request: the rejected one. No summarize, no retry.
let captured = llm.captured.lock().await.len();
assert_eq!(
captured, 1,
"expected no rescue round-trips below the floor, saw {captured} requests"
);
h.shutdown().await;
}
/// The recovery ladder must actually SHRINK, not just re-summarize at the size
/// that was already rejected.
///
/// Observed on the summarize request's own body — the only externally visible
/// consequence of the prompt budget. The rejected completion carried the full
/// history; the rescue's summarize prompt must be materially smaller. Without
/// this arm, deleting the halving entirely leaves every other test green: they
/// assert that a handoff HAPPENED, and a handoff at the rejected size still
/// happens (it just cannot escape a real overflow, which a stub does not
/// reproduce).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn recovery_shrinks_the_summarize_prompt_below_the_rejected_size() {
let llm = spawn_capturing_llm_with_status(vec![
(400, openai_context_length_error()),
(200, openai_text("summary")),
(200, openai_text_with_usage("done", 10)),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
("BUZZ_AGENT_MAX_HANDOFFS", "0"),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
let p0 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("shrink-probe")}]}),
)
.await;
let r0 = h.recv_until(|v| v["id"] == json!(p0)).await;
assert!(r0.get("error").is_none(), "expected recovery: {r0}");
let captured = llm.captured.lock().await.clone();
assert!(
captured.len() >= 2,
"expected at least reject + summarize, saw {}",
captured.len()
);
let content_bytes = |req: &Value| -> usize {
req["messages"]
.as_array()
.map(|ms| {
ms.iter()
.filter_map(|m| m["content"].as_str())
.map(str::len)
.sum()
})
.unwrap_or(0)
};
let rejected = content_bytes(&captured[0]);
let summarize = content_bytes(&captured[1]);
assert!(
rejected > 0 && summarize > 0,
"empty measurement is not a result: rejected={rejected} summarize={summarize}"
);
// Halving from the rejected size lands near 0.5x; 0.75x leaves headroom for
// the summarizer's fixed frame while still failing if no shrink happened.
assert!(
(summarize as f64) < 0.75 * (rejected as f64),
"rescue summarize prompt ({summarize} bytes) must be materially smaller than the \
rejected request ({rejected} bytes) — the ladder is not shrinking"
);
h.shutdown().await;
}
/// The ladder must shrink between RUNGS, not just once on entry.
///
/// This arm exists because a mutant that pins `shift` to `1` — deleting the
/// `attempts` dependence, so every rung rebuilds the same budget — SURVIVED the
/// whole suite. It had to: `attempts` is 0 on the first rung, so `shift = 1` IS
/// production there, and every other arm stops at rung 1. The single-rung shrink
/// arm above cannot see this; only a fixture that forces a SECOND rung can.
///
/// The forcing move is the realistic one the ladder was designed for: the
/// summarize call travels the same provider path, so rung 1's summarize is
/// itself rejected for context overflow (`Skipped`), and rung 2 must come back
/// with a materially smaller summarizer prompt.
///
/// Budgets: history is ~64 KB, so rung 1 asks for ~32 KB and rung 2 for ~16 KB,
/// both comfortably above the 4 KiB floor — the floor must not be what
/// separates them, or this would measure the wrong mechanism.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn recovery_shrinks_further_on_each_rung() {
let llm = spawn_capturing_llm_with_status(vec![
// 1: the completion that overflows.
(400, openai_context_length_error()),
// 2: rung-1 summarize, rejected the same way -> Skipped -> next rung.
(400, openai_context_length_error()),
// 3: rung-2 summarize succeeds.
(200, openai_text("summary")),
// 4: the retried completion.
(200, openai_text_with_usage("done", 10)),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
("BUZZ_AGENT_MAX_HANDOFFS", "0"),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
let p0 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("rung-shrink-probe")}]}),
)
.await;
let r0 = h.recv_until(|v| v["id"] == json!(p0)).await;
assert!(
r0.get("error").is_none(),
"expected recovery on the second rung: {r0}"
);
// The second rung must actually have been taken — otherwise the byte
// comparison below would compare rung 1 against the retry.
let stderr = h.stderr_text();
assert!(
stderr.contains("did not run; shrinking further"),
"rung 1 must have been Skipped so rung 2 runs; got: {stderr}"
);
assert!(
!stderr.contains("below the"),
"the prompt FLOOR must not be involved in this fixture; got: {stderr}"
);
let captured = llm.captured.lock().await.clone();
assert_eq!(
captured.len(),
4,
"expected reject + rung1 summarize + rung2 summarize + retry, saw {}",
captured.len()
);
let content_bytes = |req: &Value| -> usize {
req["messages"]
.as_array()
.map(|ms| {
ms.iter()
.filter_map(|m| m["content"].as_str())
.map(str::len)
.sum()
})
.unwrap_or(0)
};
let rung1 = content_bytes(&captured[1]);
let rung2 = content_bytes(&captured[2]);
assert!(
rung1 > 0 && rung2 > 0,
"empty measurement is not a result: rung1={rung1} rung2={rung2}"
);
assert!(
(rung2 as f64) < 0.75 * (rung1 as f64),
"each rung must shrink: rung2 ({rung2} bytes) is not materially smaller than rung1 \
({rung1} bytes) — the budget is not tracking `attempts`"
);
h.shutdown().await;
}
/// Gate 5, and the DIRECTION the clearing protects: not a spurious handoff, a
/// MISSED one. After a reactive reset the stale `last_request_input_tokens`
/// describes history that no longer exists, and its paired byte baseline
/// describes the pre-reset (larger) history — so `grown` stays near zero and the
/// projection collapses to the stale sub-threshold token count. The gate goes
/// BLIND until history exceeds its pre-reset size.
///
/// Constructing the divergence takes three turns, and two of the constraints are
/// load-bearing — a first attempt with a simpler fixture produced traces
/// BYTE-IDENTICAL between the fix and its deletion:
/// * Turn 1 must stay UNDER the gate threshold, or the proactive handoff fires
/// first and consumes the queue slot the overflow was meant to land in — no
/// usage is ever recorded, both variants sit at `None`, and the test measures
/// nothing.
/// * The post-recovery retry must report NO usage. A usage-bearing response
/// overwrites both fields with coherent values on the spot, which makes the
/// clear genuinely redundant and the mutant equivalent. The reachable window
/// is exactly when the retry omits usage and the stale pair survives.
/// Turn 3 then carries a large prompt: a cleared baseline falls through to the
/// byte signal and hands off, while the stale pair projects
/// `10 + (190KB - 100KB)` = ~90k tokens, under the 180k threshold, and does not.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reactive_reset_clears_usage_baseline_so_the_gate_is_not_blind() {
// ~100 KB: under the 180 KB byte-fallback threshold, so turn 1 does NOT
// trip the proactive gate, but large enough to be the stale `measured_bytes`
// that suppresses `grown` later.
let mut medium = String::with_capacity(100 * 1024);
medium.push_str("turn-one-medium ");
while medium.len() < 100 * 1024 {
medium.push_str("padding under the byte fallback threshold. ");
}
// ~190 KB: over the threshold, so a CLEARED baseline must hand off.
let mut big = String::with_capacity(190 * 1024);
big.push_str("turn-three-large ");
while big.len() < 190 * 1024 {
big.push_str("padding to exceed the byte fallback threshold. ");
}
let llm = spawn_capturing_llm_with_status(vec![
// Turn 1: succeeds, reporting a SMALL usage reading against a ~100 KB
// history. This is the pair that goes stale.
(200, openai_text_with_usage("ack-medium", 10)),
// Turn 2: the overflow.
(400, openai_context_length_error()),
// Turn 2: the forced handoff's summarize.
(200, openai_text("forced summary")),
// Turn 2: the retry — NO usage block, so the baseline is not refreshed.
(200, openai_text("recovered, no usage reported")),
// Turn 3: with a cleared baseline a gated summarize comes first; with a
// stale one this slot is the completion instead. Spares so an exhausted
// queue is never what ends a turn.
(200, openai_text("gated summary")),
(200, openai_text_with_usage("done", 10)),
(200, openai_text_with_usage("spare-1", 10)),
(200, openai_text_with_usage("spare-2", 10)),
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"),
("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "8192"),
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
// Must permit a GATED handoff — turn 3 observes the proactive gate,
// which a cap of 0 would forbid.
("BUZZ_AGENT_MAX_HANDOFFS", "5"),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
// Turn 1: under threshold, records the usage pair.
let p0 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text": medium}]}),
)
.await;
let r0 = tokio::time::timeout(
Duration::from_secs(25),
h.recv_until(|v| v["id"] == json!(p0)),
)
.await
.expect("turn 1 must return");
assert!(r0.get("error").is_none(), "turn 1 should succeed: {r0}");
assert!(
!h.stderr_text().contains("handoff #"),
"precondition: turn 1 must NOT hand off, or no usage pair is recorded and this test \
measures nothing. stderr={}",
h.stderr_text()
);
// Turn 2: small prompt, overflow, reactive recovery.
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"small, overflows"}]}),
)
.await;
let r1 = tokio::time::timeout(
Duration::from_secs(25),
h.recv_until(|v| v["id"] == json!(p1)),
)
.await
.expect("turn 2 must return");
assert!(r1.get("error").is_none(), "turn 2 should recover: {r1}");
assert!(
h.stderr_text()
.contains("provider reported context overflow; forcing handoff"),
"precondition: the reactive path must have run in turn 2. stderr={}",
h.stderr_text()
);
let handoffs_after_turn2 = h.stderr_text().matches("handoff #").count();
// Turn 3: large prompt. A cleared baseline sees it via the byte signal and
// hands off; a stale pair under-projects and stays blind.
let p2 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text": big}]}),
)
.await;
let r2 = tokio::time::timeout(
Duration::from_secs(25),
h.recv_until(|v| v["id"] == json!(p2)),
)
.await
.expect("turn 3 must return");
assert!(r2.get("error").is_none(), "turn 3 should succeed: {r2}");
let stderr = h.stderr_text();
let handoffs_after_turn3 = stderr.matches("handoff #").count();
assert!(
handoffs_after_turn3 > handoffs_after_turn2,
"turn 3 must produce a GATED handoff ({handoffs_after_turn2} before, \
{handoffs_after_turn3} after): the reactive reset must clear the usage baseline, or the \
proactive gate under-projects and stays blind to an oversized history. stderr={stderr}"
);
h.shutdown().await;
}
// ─── Tests: per-turn handoff cap semantics ───────────────────────────────────
/// A session that has already performed N handoffs in previous turns must still
/// compact on subsequent turns — the per-session lifetime kill switch is gone.
///
/// Mechanism: the gate fires at the start of each round, comparing
/// `last_request_input_tokens` (stored by the previous response) against the
/// token threshold. So:
/// - Turn 1 complete() returns usage=950 (> threshold=900). Turn ends; usage stored.
/// - Turn 2 round 0: 950 >= 900 → handoff. post-handoff complete() returns usage=950.
/// Session `handoff_count` is now 1; `turn_handoff_count` was just reset to 0 at
/// turn start and is now 1.
/// - Turn 3 round 0: `turn_handoff_count` resets to 0; session count is 1 but
/// the gate uses `turn_handoff_count` → cap not reached → handoff fires again.
///
/// Without the fix (`handoff_count` compared against cap, never reset):
/// session count after turn 2 = 1 >= max_handoffs=1 → gate permanently blocked
/// for all subsequent turns → history grows until provider wall.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn handoff_cap_resets_per_turn_not_per_session() {
// LLM call sequence:
// req 1: turn 1 complete() → usage=950 (over threshold)
// req 2: turn 2 pre-flight summarize → summary text
// req 3: turn 2 complete() → usage=950 (re-arms gate for turn 3)
// req 4: turn 3 pre-flight summarize → summary text ← cap reset proves this fires
// req 5: turn 3 complete() → done
let llm = spawn_capturing_llm(vec![
openai_text_with_usage("ack-t1", 950), // turn 1: stores high usage
openai_text("summary-t2"), // turn 2: pre-flight summarize
openai_text_with_usage("done-t2", 950), // turn 2: post-handoff, re-arms gate
openai_text("summary-t3"), // turn 3: pre-flight summarize (cap reset)
openai_text_with_usage("done-t3", 10), // turn 3: post-handoff complete
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"),
("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"),
// Cap of 1 per turn. Before the fix this permanently disables the
// gate once session handoff_count reaches 1.
("BUZZ_AGENT_MAX_HANDOFFS", "1"),
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
],
)
.await;
let sid = init_session(&mut h, json!([])).await;
// Turn 1: no prior usage; preflight skips (byte-fallback not triggered by
// tiny prompt). complete() stores usage=950.
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 1"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
assert_eq!(
llm.captured.lock().await.len(),
1,
"turn 1 must produce exactly 1 LLM request"
);
// Turn 2: 950 >= threshold=900 → handoff fires. Session handoff_count: 1.
let p2 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 2"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p2)).await;
assert_eq!(
llm.captured.lock().await.len(),
3,
"turn 2 must produce 2 LLM requests (summarize + complete), 3 total"
);
let stderr = h.stderr_text();
assert!(
stderr.contains("handoff #1"),
"expected first handoff log after turn 2; got: {stderr}"
);
// Turn 3: turn_handoff_count resets to 0 → gate fires again despite
// session handoff_count=1 == cap=1.
let p3 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 3"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p3)).await;
assert_eq!(
llm.captured.lock().await.len(),
5,
"turn 3 must also produce 2 LLM requests (per-turn cap reset → handoff fires again), \
5 total"
);
let stderr = h.stderr_text();
assert!(
stderr.contains("handoff #2"),
"expected second handoff log after turn 3 (cap reset); got: {stderr}"
);
h.shutdown().await;
}
/// Within a single turn, the per-turn cap still bounds the number of handoffs.
/// A turn that exceeds `max_handoffs` compaction attempts must emit a WARN and
/// fall back to truncation — it must NOT compact indefinitely.
///
/// Mechanism: with cap=1 and a multi-round turn (tool call in round 1 → round 2),
/// the pre-flight handoff fires at the start of round 1 (usage from a *previous*
/// turn is high). After the compaction, the post-handoff complete() in round 1
/// returns a tool call, causing a second round. Round 2's preflight sees that
/// turn_handoff_count=1 == max_handoffs=1, so it refuses and emits WARN.
///
/// A steer is injected while the run is active to prove that the steer path
/// does NOT reset `handoff_attempts` — the cap must still fire on round 1 with
/// no second summarize call.
///
/// This test requires a fake MCP server to produce a tool-call round.
/// It drives via `fake-mcp` — the same binary used in other multi-round tests.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn handoff_cap_binds_within_a_single_turn() {
// LLM call sequence in turn 2 (turn 1 seeds the usage):
// req 1: turn 1 complete() → usage=950 (over threshold=900)
// req 2: turn 2 round 0 summarize() → summary (handoff_attempts: 0→1)
// req 3: turn 2 round 0 complete() → tool_call + usage=950 (re-arms gate)
// [fake-mcp tool executes; steer queued while run is active]
// req 4: turn 2 round 1 preflight → 950 >= 900 AND attempts=1 >= max=1
// → WARN, skip (cap exhausted for this turn)
// req 5: turn 2 round 1 complete() → end_turn (steer text folded into messages)
let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
// Build a tool-call response that also carries usage so the gate re-arms
// on round 1's preflight (without usage, last_request_input_tokens is None
// after the handoff clears it, and the byte-fallback won't fire on tiny history).
let tool_call_with_usage = {
let mut v = openai_tool_call("tc-1", "test_tool", json!({}));
v["usage"] = json!({
"prompt_tokens": 950u64,
"completion_tokens": 5,
"total_tokens": 955,
});
v
};
let llm = spawn_capturing_llm(vec![
openai_text_with_usage("seed", 950), // turn 1: seed high usage
openai_text("handoff-summary"), // turn 2 round 0: summarize
tool_call_with_usage, // turn 2 round 0: tool call + usage (re-arms)
openai_text_with_usage("end_turn_text", 10), // turn 2 round 1: final answer
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"),
("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"),
("BUZZ_AGENT_MAX_HANDOFFS", "1"),
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
],
)
.await;
// Init with the fake MCP server so test_tool is available.
h.send(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await;
let _ = h.recv().await;
h.send(
"session/new",
json!({
"cwd": "/tmp",
"mcpServers": [{
"name": "cap_test",
"command": fake_mcp,
"args": [],
"env": [{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }],
}],
}),
)
.await;
let r = h
.recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
.await;
let sid = r["result"]["sessionId"].as_str().unwrap().to_owned();
// Turn 1: seed high usage.
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"seed"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
// Turn 2: triggers a handoff at round 0, then a tool call, then round 1
// where the cap is already exhausted. A steer is injected while the run
// is active to prove mid-turn steers cannot reset `handoff_attempts`.
let p2 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"do work"}]}),
)
.await;
// Drain until the final response, approving tool-permission requests,
// capturing the activeRunId once it is broadcast, sending one steer,
// and verifying that it is accepted in the live run.
let mut run_id: Option<String> = None;
let mut steer_id: i64 = -1;
let mut steer_accepted = false;
loop {
let v = h.recv().await;
// Capture the run id from the first session/update that carries it,
// then immediately queue a steer. This must happen before round 1 so
// the steer text is present but the cap check still fires — proving
// the counter is not reset by the steer path.
if run_id.is_none() {
if let Some(rid) = v["params"]["update"]["_meta"]["goose"]["activeRunId"].as_str() {
run_id = Some(rid.to_owned());
steer_id = h
.send(
"_goose/unstable/session/steer",
json!({
"sessionId": sid,
"expectedRunId": rid,
"prompt": [{"type":"text","text":"STEER-CANARY: also consider the edge case"}],
}),
)
.await;
}
}
// Steer response: assert it was accepted in the live run.
if steer_id >= 0 && v["id"] == json!(steer_id) {
assert!(
v.get("result").is_some(),
"steer must be accepted while the run is active; got: {v}"
);
assert_eq!(
v["result"]["runId"].as_str(),
run_id.as_deref(),
"steer must reference the live run id"
);
steer_accepted = true;
continue;
}
if v.get("method") == Some(&json!("session/request_permission")) {
let id = v["id"].clone();
h.write(json!({
"jsonrpc": "2.0",
"id": id,
"result": { "outcome": { "outcome": "selected", "optionId": "allow" } },
}))
.await;
continue;
}
if v["id"] == json!(p2) {
assert!(
v.get("result").is_some(),
"turn 2 must succeed even when cap blocks round-1 handoff; got: {v}"
);
break;
}
}
assert!(
steer_accepted,
"steer was never accepted during turn 2; the steer arm is missing coverage"
);
// 4 LLM requests: seed + summarize + tool-call-with-usage + final-complete.
let count = llm.captured.lock().await.len();
assert_eq!(
count, 4,
"expected 4 LLM requests (seed + summarize + tool-call + final); got {count}"
);
let stderr = h.stderr_text();
assert!(
stderr.contains("handoff cap reached"),
"expected cap-reached WARN in stderr; got: {stderr}"
);
assert!(
stderr.contains("reason=\"preflight\""),
"expected reason=\"preflight\" field in cap WARN; got: {stderr}"
);
assert!(
stderr.contains("handoff_attempts="),
"expected handoff_attempts field in cap WARN; got: {stderr}"
);
assert!(
stderr.contains("max_handoffs="),
"expected max_handoffs field in cap WARN; got: {stderr}"
);
h.shutdown().await;
}
/// A failing `summarize()` call must still consume one slot from the per-turn
/// handoff-attempt budget. Before the fix, `handoff_count` was incremented only
/// on a successful compaction; a flaky summarizer could be retried indefinitely
/// within a turn. The fix moves the increment to before `summarize()`.
///
/// Proof: with `max_handoffs=1` and a multi-round turn:
/// - Round 0 preflight: threshold met, attempts: 0→1, summarize() fails → Skipped.
/// - Round 1 preflight: attempts=1 >= cap=1 → WARN (cap hit despite no successful
/// compaction). Without the pre-summarize increment, attempts would still be 0
/// here and a second summarize() would be attempted — the bug.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn failed_summarize_burns_handoff_attempt_budget() {
// We need the summarize() call to fail. The summarize path uses the same
// fake LLM server; we queue an HTTP error body for the summarize request.
// But our spawn_capturing_llm always returns 200, so we use a non-OpenAI-
// shaped response that the agent will treat as an error (missing `choices`).
//
// LLM call sequence:
// req 1: turn 1 complete() → usage=950 (seeds the gate)
// req 2: turn 2 round 0 summarize() → malformed response (treated as error)
// handoff_attempts incremented to 1 BEFORE this
// req 3: turn 2 round 0 complete() → tool_call + usage=950 (re-arms gate)
// req 4: turn 2 round 1 preflight → cap reached: WARN (attempts=1 >= max=1)
// req 5: turn 2 round 1 complete() → end_turn
let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
let bad_summary_response = json!({ "error": "upstream unavailable" }); // no `choices`
let tool_call_with_usage = {
let mut v = openai_tool_call("tc-2", "test_tool", json!({}));
v["usage"] = json!({
"prompt_tokens": 950u64,
"completion_tokens": 5,
"total_tokens": 955,
});
v
};
let llm = spawn_capturing_llm(vec![
openai_text_with_usage("seed", 950), // turn 1: seed usage
bad_summary_response, // turn 2 round 0: summarize fails
tool_call_with_usage, // turn 2 round 0: complete → tool call
openai_text_with_usage("done", 10), // turn 2 round 1: final answer
])
.await;
let mut h = Harness::spawn_with_env(
&llm.url,
&[
("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"),
("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"),
("BUZZ_AGENT_MAX_HANDOFFS", "1"),
(
"BUZZ_AGENT_MAX_HISTORY_BYTES",
&(16 * 1024 * 1024).to_string(),
),
],
)
.await;
h.send(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await;
let _ = h.recv().await;
h.send(
"session/new",
json!({
"cwd": "/tmp",
"mcpServers": [{
"name": "budget_test",
"command": fake_mcp,
"args": [],
"env": [{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }],
}],
}),
)
.await;
let r = h
.recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
.await;
let sid = r["result"]["sessionId"].as_str().unwrap().to_owned();
// Turn 1: seed high usage.
let p1 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"seed"}]}),
)
.await;
let _ = h.recv_until(|v| v["id"] == json!(p1)).await;
// Turn 2: round 0 summarize fails, but attempts was already incremented.
// Round 1 preflight must see cap hit and emit WARN.
let p2 = h
.send(
"session/prompt",
json!({"sessionId": sid, "prompt": [{"type":"text","text":"work"}]}),
)
.await;
loop {
let v = h.recv().await;
if v.get("method") == Some(&json!("session/request_permission")) {
let id = v["id"].clone();
h.write(json!({
"jsonrpc": "2.0",
"id": id,
"result": { "outcome": { "outcome": "selected", "optionId": "allow" } },
}))
.await;
continue;
}
if v["id"] == json!(p2) {
assert!(v.get("result").is_some(), "turn 2 must succeed; got: {v}");
break;
}
}
let stderr = h.stderr_text();
// Round 0: the failed summarize should warn about the failure.
assert!(
stderr.contains("handoff failed") || stderr.contains("handoff returned empty"),
"expected summarize-failure WARN; got: {stderr}"
);
// Round 1: cap must be hit (attempts=1 from the failed attempt).
assert!(
stderr.contains("handoff cap reached"),
"expected cap-reached WARN after failed summarize burned the attempt; got: {stderr}"
);
h.shutdown().await;
}