feat: import Chinese-localized Buzz source snapshot
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
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
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
//! Tiny fake MCP server for integration tests.
|
||||
//!
|
||||
//! Reads JSON-RPC line frames on stdin and replies on stdout. Driven by
|
||||
//! environment variables so tests can simulate misbehavior:
|
||||
//!
|
||||
//! FAKE_MCP_HANG_INIT=1 — never reply to `initialize` (init timeout)
|
||||
//! FAKE_MCP_HANG_TOOLS=1 — never reply to `tools/list` (list timeout)
|
||||
//! FAKE_MCP_TOOL_COUNT=N — return N tools (default: 1)
|
||||
//! FAKE_MCP_HUGE_DESC=1 — every tool description is 100 KB
|
||||
//! FAKE_MCP_DESC_SIZE=N — every tool description is N bytes (overrides HUGE_DESC)
|
||||
//! FAKE_MCP_TOOL_DELAY=N — `tools/call` sleeps N seconds before replying
|
||||
//! (use a large value, e.g. 999, to simulate hang)
|
||||
//! FAKE_MCP_RESULT_SIZE=N — `tools/call` returns an N-byte text result
|
||||
//! (default: the literal "ok"); grows history
|
||||
//! FAKE_MCP_IMAGE_RESULT=1 — `tools/call` returns text plus a PNG image block
|
||||
//! FAKE_MCP_PID_FILE=path — write the child PID to `path` on startup
|
||||
//! (for tests that want to verify the child died)
|
||||
//! FAKE_MCP_SPAWN_GRANDCHILD=1
|
||||
//! — on `tools/call`, spawn a `sleep 999`
|
||||
//! grandchild before hanging. Its PID is
|
||||
//! written to FAKE_MCP_GRANDCHILD_PID_FILE
|
||||
//! so a test can verify the entire process
|
||||
//! tree dies on timeout.
|
||||
//! FAKE_MCP_GRANDCHILD_PID_FILE=path
|
||||
//! — path to write the grandchild PID to.
|
||||
//! FAKE_MCP_STOP_HOOK=1 — expose a `_Stop` hook tool
|
||||
//! FAKE_MCP_STOP_TEXT=text — `_Stop` returns this text (default: "keep going")
|
||||
//! FAKE_MCP_STOP_DELAY=N — `_Stop` sleeps N seconds before replying
|
||||
//! (use a large value to simulate hang/timeout)
|
||||
//! FAKE_MCP_STOP_COUNT=N — `_Stop` returns STOP_TEXT for the first N
|
||||
//! invocations; empty string thereafter. If
|
||||
//! unset, every call returns STOP_TEXT.
|
||||
//! FAKE_MCP_POSTCOMPACT_HOOK=1
|
||||
//! — expose a `_PostCompact` hook tool
|
||||
//! FAKE_MCP_POSTCOMPACT_TEXT=text
|
||||
//! — `_PostCompact` returns this (default: "")
|
||||
//! FAKE_MCP_SHELL_TOOL=1 — expose a tool whose bare name is `shell`
|
||||
//! (registered as `<server>__shell`), taking a
|
||||
//! `command` string. Lets a test drive the
|
||||
//! reply guard's recognition of a real,
|
||||
//! registered shell tool.
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn env_flag(k: &str) -> bool {
|
||||
std::env::var(k).map(|v| v != "0").unwrap_or(false)
|
||||
}
|
||||
|
||||
fn env_usize(k: &str, default: usize) -> usize {
|
||||
std::env::var(k)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn env_u64(k: &str, default: u64) -> u64 {
|
||||
std::env::var(k)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn write_response(id: Value, result: Value) {
|
||||
let msg = json!({ "jsonrpc": "2.0", "id": id, "result": result });
|
||||
let mut s = serde_json::to_string(&msg).expect("serialize");
|
||||
s.push('\n');
|
||||
let mut out = std::io::stdout().lock();
|
||||
out.write_all(s.as_bytes()).expect("write");
|
||||
out.flush().expect("flush");
|
||||
}
|
||||
|
||||
fn hang_forever() -> ! {
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
|
||||
fn make_tools(
|
||||
count: usize,
|
||||
desc: &str,
|
||||
include_stop_hook: bool,
|
||||
include_post_compact_hook: bool,
|
||||
include_shell_tool: bool,
|
||||
) -> Vec<Value> {
|
||||
let mut tools: Vec<Value> = (0..count)
|
||||
.map(|i| {
|
||||
json!({
|
||||
"name": format!("tool_{i}"),
|
||||
"description": desc,
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if include_stop_hook {
|
||||
tools.push(json!({
|
||||
"name": "_Stop",
|
||||
"description": "stop hook",
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
}));
|
||||
}
|
||||
if include_post_compact_hook {
|
||||
tools.push(json!({
|
||||
"name": "_PostCompact",
|
||||
"description": "post compact hook",
|
||||
"inputSchema": { "type": "object", "properties": {} },
|
||||
}));
|
||||
}
|
||||
if include_shell_tool {
|
||||
tools.push(json!({
|
||||
"name": "shell",
|
||||
"description": "run a shell command",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": { "command": { "type": "string" } },
|
||||
"required": ["command"],
|
||||
},
|
||||
}));
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Optional: write our own PID so a test can later check the process is gone.
|
||||
if let Ok(path) = std::env::var("FAKE_MCP_PID_FILE") {
|
||||
let pid = std::process::id().to_string();
|
||||
let _ = std::fs::write(&path, pid);
|
||||
}
|
||||
|
||||
let hang_init = env_flag("FAKE_MCP_HANG_INIT");
|
||||
let hang_tools = env_flag("FAKE_MCP_HANG_TOOLS");
|
||||
let tool_count = env_usize("FAKE_MCP_TOOL_COUNT", 1);
|
||||
// FAKE_MCP_DESC_SIZE wins over FAKE_MCP_HUGE_DESC when set.
|
||||
let desc: String = if let Some(n) = std::env::var("FAKE_MCP_DESC_SIZE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
{
|
||||
"x".repeat(n)
|
||||
} else if env_flag("FAKE_MCP_HUGE_DESC") {
|
||||
"x".repeat(100_000)
|
||||
} else {
|
||||
"fake tool".to_owned()
|
||||
};
|
||||
let tool_delay_secs = env_u64("FAKE_MCP_TOOL_DELAY", 0);
|
||||
// Tool-call result text size in bytes (default: the literal "ok"). Lets a
|
||||
// test grow session history by a controlled amount via a tool result.
|
||||
let result_size = env_u64("FAKE_MCP_RESULT_SIZE", 0) as usize;
|
||||
let stop_hook = env_flag("FAKE_MCP_STOP_HOOK");
|
||||
let stop_text = std::env::var("FAKE_MCP_STOP_TEXT").unwrap_or_else(|_| "keep going".to_owned());
|
||||
let stop_delay_secs = env_u64("FAKE_MCP_STOP_DELAY", 0);
|
||||
// 0 means "unset" → unlimited; any positive value caps the number of
|
||||
// calls that return STOP_TEXT before flipping to empty string.
|
||||
let stop_count_limit: usize = env_usize("FAKE_MCP_STOP_COUNT", usize::MAX);
|
||||
let mut stop_calls_seen: usize = 0;
|
||||
let post_compact_hook = env_flag("FAKE_MCP_POSTCOMPACT_HOOK");
|
||||
let shell_tool = env_flag("FAKE_MCP_SHELL_TOOL");
|
||||
let post_compact_text = std::env::var("FAKE_MCP_POSTCOMPACT_TEXT").unwrap_or_default();
|
||||
|
||||
// Use a channel-based stdin reader so notifications (which carry no id)
|
||||
// are captured even while the main thread is sleeping during a tool call.
|
||||
let cancel_log_path = std::env::var("FAKE_MCP_CANCEL_LOG").ok();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<(Value, Option<Value>)>();
|
||||
let cancel_log_for_thread = cancel_log_path.clone();
|
||||
std::thread::spawn(move || {
|
||||
let stdin = std::io::stdin();
|
||||
let lines = stdin.lock().lines();
|
||||
for line in lines {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => return,
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let msg: Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
|
||||
let id = msg.get("id").cloned();
|
||||
// Notifications carry no id. Log cancellations if configured.
|
||||
if id.is_none() || id == Some(Value::Null) {
|
||||
if method == "notifications/cancelled" {
|
||||
if let Some(ref path) = cancel_log_for_thread {
|
||||
use std::io::Write as _;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
{
|
||||
let _ = writeln!(f, "{}", line.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Send requests (with id) to the main processing loop.
|
||||
let _ = tx.send((msg, id));
|
||||
}
|
||||
});
|
||||
|
||||
while let Ok((msg, id_opt)) = rx.recv() {
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
|
||||
let id = id_opt.unwrap_or(Value::Null);
|
||||
|
||||
match method {
|
||||
"initialize" => {
|
||||
if hang_init {
|
||||
hang_forever();
|
||||
}
|
||||
write_response(
|
||||
id,
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": { "name": "fake-mcp", "version": "0.0.0" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
"tools/list" => {
|
||||
if hang_tools {
|
||||
hang_forever();
|
||||
}
|
||||
write_response(
|
||||
id,
|
||||
json!({
|
||||
"tools": make_tools(
|
||||
tool_count,
|
||||
&desc,
|
||||
stop_hook,
|
||||
post_compact_hook,
|
||||
shell_tool,
|
||||
)
|
||||
}),
|
||||
);
|
||||
}
|
||||
"tools/call" => {
|
||||
// Signal that the request was received (for tests that
|
||||
// need to wait until the call is in-flight before cancelling).
|
||||
// Write the request id so tests can correlate with cancel.
|
||||
if let Ok(path) = std::env::var("FAKE_MCP_CALL_RECEIVED") {
|
||||
let id_str = serde_json::to_string(&id).unwrap_or_else(|_| "?".into());
|
||||
let _ = std::fs::write(&path, id_str);
|
||||
}
|
||||
let called_name = msg
|
||||
.get("params")
|
||||
.and_then(|p| p.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
// Optionally spawn a long-sleeping grandchild so the test
|
||||
// can verify process-group killing reaches the whole tree.
|
||||
if env_flag("FAKE_MCP_SPAWN_GRANDCHILD") {
|
||||
let child = std::process::Command::new("sleep")
|
||||
.arg("999")
|
||||
.spawn()
|
||||
.expect("spawn grandchild");
|
||||
if let Ok(path) = std::env::var("FAKE_MCP_GRANDCHILD_PID_FILE") {
|
||||
let _ = std::fs::write(&path, child.id().to_string());
|
||||
}
|
||||
std::mem::forget(child);
|
||||
}
|
||||
if called_name == "_Stop" {
|
||||
if stop_delay_secs > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_secs(stop_delay_secs));
|
||||
}
|
||||
// Once we exceed the configured count, return empty
|
||||
// text so the agent treats it as no objection. This
|
||||
// lets a test exercise the "objected then cleared"
|
||||
// path without relying on the rejection budget.
|
||||
let payload = if stop_calls_seen < stop_count_limit {
|
||||
stop_text.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
stop_calls_seen = stop_calls_seen.saturating_add(1);
|
||||
write_response(
|
||||
id,
|
||||
json!({
|
||||
"content": [{ "type": "text", "text": payload }],
|
||||
"isError": false,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if called_name == "_PostCompact" {
|
||||
write_response(
|
||||
id,
|
||||
json!({
|
||||
"content": [{ "type": "text", "text": post_compact_text }],
|
||||
"isError": false,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if tool_delay_secs > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_secs(tool_delay_secs));
|
||||
}
|
||||
let result_text = if result_size > 0 {
|
||||
"x".repeat(result_size)
|
||||
} else {
|
||||
"ok".to_owned()
|
||||
};
|
||||
let content = if env_flag("FAKE_MCP_IMAGE_RESULT") {
|
||||
json!([
|
||||
{ "type": "text", "text": result_text },
|
||||
{ "type": "image", "data": "aW1n", "mimeType": "image/png" },
|
||||
])
|
||||
} else {
|
||||
json!([{ "type": "text", "text": result_text }])
|
||||
};
|
||||
write_response(
|
||||
id,
|
||||
json!({
|
||||
"content": content,
|
||||
"isError": false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Unknown method: respond with an error so rmcp doesn't hang.
|
||||
let err = json!({
|
||||
"jsonrpc": "2.0", "id": id,
|
||||
"error": { "code": -32601, "message": format!("method not found: {method}") },
|
||||
});
|
||||
let mut s = serde_json::to_string(&err).unwrap();
|
||||
s.push('\n');
|
||||
let mut out = std::io::stdout().lock();
|
||||
let _ = out.write_all(s.as_bytes());
|
||||
let _ = out.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,944 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
struct Harness {
|
||||
child: tokio::process::Child,
|
||||
stdin: tokio::process::ChildStdin,
|
||||
stdout: BufReader<tokio::process::ChildStdout>,
|
||||
next_id: i64,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
async fn spawn(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("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5")
|
||||
.env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5")
|
||||
.env("BUZZ_AGENT_MAX_ROUNDS", "4")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
for (k, v) in extra {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let mut child = cmd.spawn().expect("spawn buzz-agent");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
Self {
|
||||
child,
|
||||
stdin,
|
||||
stdout,
|
||||
next_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(&mut self, method: &str, params: Value) -> i64 {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
self.write_json(json!({
|
||||
"jsonrpc": "2.0", "id": id, "method": method, "params": params
|
||||
}))
|
||||
.await;
|
||||
id
|
||||
}
|
||||
|
||||
async fn notify(&mut self, method: &str, params: Value) {
|
||||
self.write_json(json!({
|
||||
"jsonrpc": "2.0", "method": method, "params": params
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn write_json(&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 write_raw(&mut self, raw: &[u8]) {
|
||||
let _ = self.stdin.write_all(raw).await;
|
||||
let _ = self.stdin.flush().await;
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Value {
|
||||
let mut line = String::new();
|
||||
let n = tokio::time::timeout(Duration::from_secs(10), 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_for_id(&mut self, id: i64) -> Value {
|
||||
loop {
|
||||
let v = self.recv().await;
|
||||
if v["id"] == json!(id) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_fake_llm(responses: Vec<Value>) -> String {
|
||||
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)));
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut sock, _) = match listener.accept().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
let queue = queue.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = Vec::new();
|
||||
let mut tmp = [0u8; 4096];
|
||||
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() > 1_000_000 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let body = queue
|
||||
.lock()
|
||||
.await
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| json!({ "error": "no canned response" }));
|
||||
let body_s = serde_json::to_string(&body).unwrap();
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\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;
|
||||
});
|
||||
}
|
||||
});
|
||||
url
|
||||
}
|
||||
|
||||
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",
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
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 handshake(h: &mut Harness) -> String {
|
||||
let init_id = h
|
||||
.send(
|
||||
"initialize",
|
||||
json!({ "protocolVersion": 2, "clientCapabilities": {} }),
|
||||
)
|
||||
.await;
|
||||
let init = h.recv_for_id(init_id).await;
|
||||
assert_eq!(init["result"]["protocolVersion"], 2);
|
||||
assert_eq!(init["result"]["agentInfo"]["name"], "buzz-agent");
|
||||
assert_eq!(
|
||||
init["result"]["agentCapabilities"]["promptCapabilities"]["image"],
|
||||
false
|
||||
);
|
||||
|
||||
let new_id = h
|
||||
.send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] }))
|
||||
.await;
|
||||
let new = h.recv_for_id(new_id).await;
|
||||
let sid = new["result"]["sessionId"].as_str().unwrap().to_owned();
|
||||
assert!(sid.starts_with("ses_"));
|
||||
sid
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_text_only_response() {
|
||||
let url = spawn_fake_llm(vec![openai_text("hello back")]).await;
|
||||
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
|
||||
|
||||
let sid = handshake(&mut h).await;
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({
|
||||
"sessionId": sid,
|
||||
"prompt": [{ "type": "text", "text": "hi" }],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let result = h.recv_for_id(p).await;
|
||||
assert_eq!(result["result"]["stopReason"], "end_turn");
|
||||
assert!(result.get("error").is_none());
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_full_tool_call_transcript() {
|
||||
let url = spawn_fake_llm(vec![
|
||||
openai_tool_call("call_xyz", "fake__do_thing", json!({ "foo": "bar" })),
|
||||
openai_text("done"),
|
||||
])
|
||||
.await;
|
||||
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
|
||||
|
||||
let sid = handshake(&mut h).await;
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({
|
||||
"sessionId": sid,
|
||||
"prompt": [{ "type": "text", "text": "use the tool" }],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let failed = h
|
||||
.recv_until(|v| {
|
||||
v.get("method") == Some(&json!("session/update"))
|
||||
&& v["params"]["update"]["sessionUpdate"] == "tool_call_update"
|
||||
&& v["params"]["update"]["status"] == "failed"
|
||||
})
|
||||
.await;
|
||||
assert_eq!(failed["params"]["sessionId"], sid);
|
||||
assert_eq!(failed["params"]["update"]["toolCallId"], "call_xyz");
|
||||
assert_eq!(
|
||||
failed["params"]["update"]["rawOutput"]["error"],
|
||||
"unknown tool: fake__do_thing"
|
||||
);
|
||||
|
||||
let final_resp = h.recv_for_id(p).await;
|
||||
assert_eq!(final_resp["result"]["stopReason"], "end_turn");
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_permission_denied_continues() {
|
||||
let url = spawn_fake_llm(vec![openai_text("ok with no tool")]).await;
|
||||
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
|
||||
|
||||
let sid = handshake(&mut h).await;
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({
|
||||
"sessionId": sid,
|
||||
"prompt": [{ "type": "text", "text": "hi" }],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let final_resp = h.recv_for_id(p).await;
|
||||
assert_eq!(final_resp["result"]["stopReason"], "end_turn");
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_initialize_version_check() {
|
||||
let url = spawn_fake_llm(vec![]).await;
|
||||
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
|
||||
|
||||
let id = h
|
||||
.send(
|
||||
"initialize",
|
||||
json!({ "protocolVersion": 99, "clientCapabilities": {} }),
|
||||
)
|
||||
.await;
|
||||
let resp = h.recv_for_id(id).await;
|
||||
assert_eq!(resp["result"]["protocolVersion"], 2);
|
||||
|
||||
let id2 = h
|
||||
.send(
|
||||
"initialize",
|
||||
json!({ "protocolVersion": 1, "clientCapabilities": {} }),
|
||||
)
|
||||
.await;
|
||||
let ok = h.recv_for_id(id2).await;
|
||||
assert_eq!(ok["result"]["protocolVersion"], 1);
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_session_new_rejects_relative_cwd() {
|
||||
let url = spawn_fake_llm(vec![]).await;
|
||||
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
|
||||
|
||||
let _ = h
|
||||
.send(
|
||||
"initialize",
|
||||
json!({ "protocolVersion": 1, "clientCapabilities": {} }),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv().await;
|
||||
|
||||
let id = h
|
||||
.send(
|
||||
"session/new",
|
||||
json!({ "cwd": "relative/path", "mcpServers": [] }),
|
||||
)
|
||||
.await;
|
||||
let resp = h.recv_for_id(id).await;
|
||||
assert_eq!(resp["error"]["code"], -32602);
|
||||
assert!(resp["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("cwd must be an absolute path"));
|
||||
|
||||
let id_empty = h
|
||||
.send("session/new", json!({ "cwd": "", "mcpServers": [] }))
|
||||
.await;
|
||||
let resp = h.recv_for_id(id_empty).await;
|
||||
assert_eq!(resp["error"]["code"], -32602);
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_malformed_json_rpc() {
|
||||
let url = spawn_fake_llm(vec![]).await;
|
||||
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
|
||||
|
||||
h.write_raw(b"this is not json\n").await;
|
||||
let v = h.recv().await;
|
||||
assert_eq!(v["error"]["code"], -32700);
|
||||
assert_eq!(v["id"], Value::Null);
|
||||
|
||||
h.write_json(json!({ "jsonrpc": "1.0", "method": "initialize", "id": 1 }))
|
||||
.await;
|
||||
let v = h.recv().await;
|
||||
assert_eq!(v["error"]["code"], -32600);
|
||||
|
||||
h.write_json(json!({ "jsonrpc": "2.0" })).await;
|
||||
let v = h.recv().await;
|
||||
assert_eq!(v["error"]["code"], -32600);
|
||||
|
||||
let init_id = h
|
||||
.send(
|
||||
"initialize",
|
||||
json!({ "protocolVersion": 1, "clientCapabilities": {} }),
|
||||
)
|
||||
.await;
|
||||
let ok = h.recv_for_id(init_id).await;
|
||||
assert_eq!(ok["result"]["protocolVersion"], 1);
|
||||
|
||||
let bad_id = h.send("nonsense/method", json!({})).await;
|
||||
let v = h.recv_for_id(bad_id).await;
|
||||
assert_eq!(v["error"]["code"], -32601);
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_unsupported_content_block() {
|
||||
let url = spawn_fake_llm(vec![openai_text("ok")]).await;
|
||||
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
|
||||
|
||||
let sid = handshake(&mut h).await;
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({
|
||||
"sessionId": sid,
|
||||
"prompt": [{ "type": "image", "data": "..." }],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let resp = h.recv_for_id(p).await;
|
||||
assert_eq!(resp["error"]["code"], -32602);
|
||||
assert!(resp["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("unsupported content block"));
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_concurrent_prompt_rejected() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
let mut tmp = [0u8; 4096];
|
||||
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
let n = sock.read(&mut tmp).await.unwrap_or(0);
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
buf.extend_from_slice(&tmp[..n]);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let body = openai_text("done").to_string();
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
let _ = sock.shutdown().await;
|
||||
});
|
||||
|
||||
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
|
||||
let sid = handshake(&mut h).await;
|
||||
|
||||
let p1 = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({ "sessionId": sid, "prompt": [{"type":"text","text":"go"}] }),
|
||||
)
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let p2 = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({ "sessionId": sid, "prompt": [{"type":"text","text":"again"}] }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut p1_ok = false;
|
||||
let mut p2_err = false;
|
||||
for _ in 0..10 {
|
||||
let v = h.recv().await;
|
||||
if v["id"] == json!(p1) {
|
||||
assert_eq!(v["result"]["stopReason"], "end_turn");
|
||||
p1_ok = true;
|
||||
} else if v["id"] == json!(p2) {
|
||||
assert_eq!(v["error"]["code"], -32602);
|
||||
p2_err = true;
|
||||
}
|
||||
if p1_ok && p2_err {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(p1_ok && p2_err, "expected p1=ok, p2=busy");
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_oversized_line_kills_agent() {
|
||||
let url = spawn_fake_llm(vec![]).await;
|
||||
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", &url)
|
||||
.env("BUZZ_AGENT_MAX_LINE_BYTES", "256")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
let mut child = cmd.spawn().unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let big = "x".repeat(1024);
|
||||
let _ = stdin.write_all(big.as_bytes()).await;
|
||||
let _ = stdin.write_all(b"\n").await;
|
||||
drop(stdin);
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), child.wait())
|
||||
.await
|
||||
.expect("agent did not exit on oversized line");
|
||||
}
|
||||
|
||||
/// Build an Anthropic Messages API response with an optional `thinking` block
|
||||
/// followed by a `text` block. The `thinking` field is omitted when `None`.
|
||||
fn anthropic_thinking_response(thinking: Option<&str>, text: &str) -> Value {
|
||||
let mut content: Vec<Value> = Vec::new();
|
||||
if let Some(t) = thinking {
|
||||
content.push(json!({ "type": "thinking", "thinking": t }));
|
||||
}
|
||||
content.push(json!({ "type": "text", "text": text }));
|
||||
json!({
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-fake",
|
||||
"stop_reason": "end_turn",
|
||||
"content": content,
|
||||
"usage": { "input_tokens": 10, "output_tokens": 5 },
|
||||
})
|
||||
}
|
||||
|
||||
/// Build an OpenAI Responses API response with a `reasoning` output item
|
||||
/// (containing a single `summary_text` entry) followed by a message item.
|
||||
fn responses_reasoning_response(reasoning: &str, text: &str) -> Value {
|
||||
json!({
|
||||
"id": "resp_1",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_1",
|
||||
"summary": [{ "type": "summary_text", "text": reasoning }],
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"content": [{ "type": "output_text", "text": text }],
|
||||
},
|
||||
],
|
||||
"usage": { "input_tokens": 10 },
|
||||
})
|
||||
}
|
||||
|
||||
/// Drain all `session/update` notifications until the `session/prompt` reply
|
||||
/// arrives for `prompt_id`, collecting notification payloads in order.
|
||||
async fn collect_updates_until_done(h: &mut Harness, prompt_id: i64) -> Vec<Value> {
|
||||
let mut updates = Vec::new();
|
||||
loop {
|
||||
let v = h.recv().await;
|
||||
if v.get("id") == Some(&json!(prompt_id)) {
|
||||
return updates;
|
||||
}
|
||||
if v.get("method") == Some(&json!("session/update")) {
|
||||
if let Some(u) = v["params"].get("update") {
|
||||
updates.push(u.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts that `agent_thought_chunk` appears in `updates` BEFORE
|
||||
/// `agent_message_chunk`, and that both are present.
|
||||
fn assert_thought_before_message(updates: &[Value]) {
|
||||
let thought_pos = updates
|
||||
.iter()
|
||||
.position(|u| u["sessionUpdate"] == "agent_thought_chunk");
|
||||
let message_pos = updates
|
||||
.iter()
|
||||
.position(|u| u["sessionUpdate"] == "agent_message_chunk");
|
||||
assert!(
|
||||
thought_pos.is_some(),
|
||||
"expected agent_thought_chunk in updates: {updates:?}"
|
||||
);
|
||||
assert!(
|
||||
message_pos.is_some(),
|
||||
"expected agent_message_chunk in updates: {updates:?}"
|
||||
);
|
||||
assert!(
|
||||
thought_pos.unwrap() < message_pos.unwrap(),
|
||||
"agent_thought_chunk must precede agent_message_chunk, got updates: {updates:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_thought_chunk_emitted_before_message_chunk_anthropic() {
|
||||
// Anthropic extended-thinking: the response contains a `thinking` block
|
||||
// followed by a `text` block. We expect agent_thought_chunk to be emitted
|
||||
// before agent_message_chunk on the wire.
|
||||
let url = spawn_fake_llm(vec![anthropic_thinking_response(
|
||||
Some("Let me reason about this carefully."),
|
||||
"Here is my answer.",
|
||||
)])
|
||||
.await;
|
||||
let mut h = Harness::spawn(&[
|
||||
("BUZZ_AGENT_PROVIDER", "anthropic"),
|
||||
("ANTHROPIC_API_KEY", "test"),
|
||||
("ANTHROPIC_MODEL", "claude-fake"),
|
||||
("ANTHROPIC_BASE_URL", &url),
|
||||
("OPENAI_COMPAT_BASE_URL", ""),
|
||||
])
|
||||
.await;
|
||||
|
||||
let sid = handshake(&mut h).await;
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({
|
||||
"sessionId": sid,
|
||||
"prompt": [{ "type": "text", "text": "think hard" }],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let updates = collect_updates_until_done(&mut h, p).await;
|
||||
assert_thought_before_message(&updates);
|
||||
|
||||
let thought = updates
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_thought_chunk")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
thought["content"]["text"],
|
||||
"Let me reason about this carefully."
|
||||
);
|
||||
|
||||
let message = updates
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_message_chunk")
|
||||
.unwrap();
|
||||
assert_eq!(message["content"]["text"], "Here is my answer.");
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_thought_chunk_emitted_before_message_chunk_responses_api() {
|
||||
// OpenAI Responses API: reasoning item followed by message item.
|
||||
// Setting OPENAI_COMPAT_API=responses forces the Responses API parse path.
|
||||
let url = spawn_fake_llm(vec![responses_reasoning_response(
|
||||
"Thinking step by step.",
|
||||
"Final answer.",
|
||||
)])
|
||||
.await;
|
||||
let mut h = Harness::spawn(&[
|
||||
("BUZZ_AGENT_PROVIDER", "openai"),
|
||||
("OPENAI_COMPAT_API_KEY", "test"),
|
||||
("OPENAI_COMPAT_MODEL", "fake-model"),
|
||||
("OPENAI_COMPAT_API", "responses"),
|
||||
("OPENAI_COMPAT_BASE_URL", &url),
|
||||
])
|
||||
.await;
|
||||
|
||||
let sid = handshake(&mut h).await;
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({
|
||||
"sessionId": sid,
|
||||
"prompt": [{ "type": "text", "text": "reason it out" }],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let updates = collect_updates_until_done(&mut h, p).await;
|
||||
assert_thought_before_message(&updates);
|
||||
|
||||
let thought = updates
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_thought_chunk")
|
||||
.unwrap();
|
||||
assert_eq!(thought["content"]["text"], "Thinking step by step.");
|
||||
|
||||
let message = updates
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_message_chunk")
|
||||
.unwrap();
|
||||
assert_eq!(message["content"]["text"], "Final answer.");
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_thought_chunk_emitted_before_message_chunk_chat_completions_reasoning_content() {
|
||||
// OpenAI chat/completions path with DeepSeek-style `reasoning_content` field
|
||||
// on the message object. OPENAI_COMPAT_API defaults to Auto, which routes
|
||||
// non-openai.com hosts to chat/completions — this is the live path for
|
||||
// self-hosted reasoning models (DeepSeek, vLLM, etc.).
|
||||
let response = json!({
|
||||
"id": "cc-r1", "object": "chat.completion", "model": "fake-model",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Here is the answer.",
|
||||
"reasoning_content": "Let me think through this step by step.",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
});
|
||||
let url = spawn_fake_llm(vec![response]).await;
|
||||
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
|
||||
|
||||
let sid = handshake(&mut h).await;
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({
|
||||
"sessionId": sid,
|
||||
"prompt": [{ "type": "text", "text": "solve it" }],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let updates = collect_updates_until_done(&mut h, p).await;
|
||||
assert_thought_before_message(&updates);
|
||||
|
||||
let thought = updates
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_thought_chunk")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
thought["content"]["text"],
|
||||
"Let me think through this step by step."
|
||||
);
|
||||
|
||||
let message = updates
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_message_chunk")
|
||||
.unwrap();
|
||||
assert_eq!(message["content"]["text"], "Here is the answer.");
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_no_reasoning_no_thought_chunk() {
|
||||
// Plain text response with no reasoning content — no agent_thought_chunk
|
||||
// should appear on the wire. This guards against empty thought emissions.
|
||||
let url = spawn_fake_llm(vec![openai_text("just text, no thinking")]).await;
|
||||
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
|
||||
|
||||
let sid = handshake(&mut h).await;
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({
|
||||
"sessionId": sid,
|
||||
"prompt": [{ "type": "text", "text": "hi" }],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let updates = collect_updates_until_done(&mut h, p).await;
|
||||
|
||||
let has_thought = updates
|
||||
.iter()
|
||||
.any(|u| u["sessionUpdate"] == "agent_thought_chunk");
|
||||
assert!(
|
||||
!has_thought,
|
||||
"expected no agent_thought_chunk for a plain text response, got: {updates:?}"
|
||||
);
|
||||
|
||||
let has_message = updates
|
||||
.iter()
|
||||
.any(|u| u["sessionUpdate"] == "agent_message_chunk");
|
||||
assert!(has_message, "expected agent_message_chunk in updates");
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_cancel_notification_no_reply() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
let mut tmp = [0u8; 4096];
|
||||
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
let n = sock.read(&mut tmp).await.unwrap_or(0);
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
buf.extend_from_slice(&tmp[..n]);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
let body = openai_text("done").to_string();
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
let _ = sock.shutdown().await;
|
||||
});
|
||||
|
||||
let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await;
|
||||
let sid = handshake(&mut h).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({ "sessionId": sid, "prompt": [{"type":"text","text":"go"}] }),
|
||||
)
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
h.notify("session/cancel", json!({ "sessionId": sid }))
|
||||
.await;
|
||||
|
||||
let final_resp = h.recv_for_id(p).await;
|
||||
let stop = final_resp["result"]["stopReason"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
stop == "cancelled" || stop == "end_turn",
|
||||
"unexpected stopReason {stop}"
|
||||
);
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// ACP v2 ContentChunk compliance: both `agent_thought_chunk` and
|
||||
/// `agent_message_chunk` must carry `messageId` and `content` when the
|
||||
/// client negotiates protocol version 2.
|
||||
///
|
||||
/// ACP v2 requires `ContentChunk.messageId` (required in v2 schema at
|
||||
/// agentclientprotocol/agent-client-protocol schema/v2/schema.json @d13d1baa).
|
||||
/// ACP v1 allows the field, so adding it is backwards-safe.
|
||||
///
|
||||
/// Additional invariants verified here:
|
||||
/// - The thought and assistant message IDs are **distinct** (two logical messages).
|
||||
/// - IDs do **not** recur across two consecutive `session/prompt` calls in the same
|
||||
/// ACP session (`run_id` is fresh per prompt, so no cross-turn collision).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_acp_v2_chunks_carry_message_id() {
|
||||
// OpenAI Responses API: reasoning item + text item. Both emitted chunks
|
||||
// must have messageId + content on a v2 connection.
|
||||
// Two responses so we can send two session/prompt calls and verify no ID reuse.
|
||||
let url = spawn_fake_llm(vec![
|
||||
responses_reasoning_response("Thinking about it.", "Here is my response."),
|
||||
responses_reasoning_response("Thinking again.", "Second response."),
|
||||
])
|
||||
.await;
|
||||
let mut h = Harness::spawn(&[
|
||||
("BUZZ_AGENT_PROVIDER", "openai"),
|
||||
("OPENAI_COMPAT_API_KEY", "test"),
|
||||
("OPENAI_COMPAT_MODEL", "fake-model"),
|
||||
("OPENAI_COMPAT_API", "responses"),
|
||||
("OPENAI_COMPAT_BASE_URL", &url),
|
||||
])
|
||||
.await;
|
||||
|
||||
let sid = handshake(&mut h).await; // negotiates protocolVersion: 2
|
||||
|
||||
// ── First prompt ──────────────────────────────────────────────────────────
|
||||
let p1 = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({
|
||||
"sessionId": sid,
|
||||
"prompt": [{ "type": "text", "text": "think and respond" }],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let updates1 = collect_updates_until_done(&mut h, p1).await;
|
||||
|
||||
let thought1 = updates1
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_thought_chunk")
|
||||
.expect("agent_thought_chunk must be emitted on prompt 1");
|
||||
let message1 = updates1
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_message_chunk")
|
||||
.expect("agent_message_chunk must be emitted on prompt 1");
|
||||
|
||||
// ACP v2 ContentChunk compliance: messageId must be present and non-empty.
|
||||
let thought_id1 = thought1["messageId"]
|
||||
.as_str()
|
||||
.expect("agent_thought_chunk must carry messageId (ACP v2 required field)");
|
||||
assert!(
|
||||
!thought_id1.is_empty(),
|
||||
"agent_thought_chunk messageId must not be empty"
|
||||
);
|
||||
|
||||
let message_id1 = message1["messageId"]
|
||||
.as_str()
|
||||
.expect("agent_message_chunk must carry messageId (ACP v2 required field)");
|
||||
assert!(
|
||||
!message_id1.is_empty(),
|
||||
"agent_message_chunk messageId must not be empty"
|
||||
);
|
||||
|
||||
// Thought and assistant message are two distinct logical messages — their IDs must differ.
|
||||
assert_ne!(
|
||||
thought_id1, message_id1,
|
||||
"agent_thought_chunk and agent_message_chunk are distinct logical messages; their messageIds must differ"
|
||||
);
|
||||
|
||||
// content must be present and correct.
|
||||
assert_eq!(
|
||||
thought1["content"]["text"], "Thinking about it.",
|
||||
"thought content mismatch"
|
||||
);
|
||||
assert_eq!(
|
||||
message1["content"]["text"], "Here is my response.",
|
||||
"message content mismatch"
|
||||
);
|
||||
|
||||
// ── Second prompt (same ACP session) ─────────────────────────────────────
|
||||
let p2 = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({
|
||||
"sessionId": sid,
|
||||
"prompt": [{ "type": "text", "text": "think again" }],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let updates2 = collect_updates_until_done(&mut h, p2).await;
|
||||
|
||||
let thought2 = updates2
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_thought_chunk")
|
||||
.expect("agent_thought_chunk must be emitted on prompt 2");
|
||||
let message2 = updates2
|
||||
.iter()
|
||||
.find(|u| u["sessionUpdate"] == "agent_message_chunk")
|
||||
.expect("agent_message_chunk must be emitted on prompt 2");
|
||||
|
||||
let thought_id2 = thought2["messageId"]
|
||||
.as_str()
|
||||
.expect("agent_thought_chunk must carry messageId on prompt 2");
|
||||
let message_id2 = message2["messageId"]
|
||||
.as_str()
|
||||
.expect("agent_message_chunk must carry messageId on prompt 2");
|
||||
|
||||
// IDs from prompt 2 must be distinct from each other.
|
||||
assert_ne!(
|
||||
thought_id2, message_id2,
|
||||
"prompt 2: thought and message IDs must differ"
|
||||
);
|
||||
|
||||
// IDs must NOT recur across prompts — ACP requires session-unique messageIds.
|
||||
assert_ne!(
|
||||
thought_id1, thought_id2,
|
||||
"thought messageId must not recur across session/prompt calls (run_id must differ)"
|
||||
);
|
||||
assert_ne!(
|
||||
message_id1, message_id2,
|
||||
"message messageId must not recur across session/prompt calls (run_id must differ)"
|
||||
);
|
||||
|
||||
h.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
//! Integration tests for AGENTS.md / SKILL.md hint loading.
|
||||
//!
|
||||
//! Uses the same subprocess + capturing-LLM pattern as `regressions.rs`.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
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 {
|
||||
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];
|
||||
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;
|
||||
}
|
||||
}
|
||||
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 body = queue
|
||||
.lock()
|
||||
.await
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| json!({ "error": "no canned response" }));
|
||||
let body_s = serde_json::to_string(&body).unwrap();
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
||||
Content-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>,
|
||||
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::inherit())
|
||||
.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());
|
||||
Self {
|
||||
child,
|
||||
stdin,
|
||||
stdout,
|
||||
next_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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",
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
async fn init_session(h: &mut Harness, cwd: &str) -> String {
|
||||
h.send(
|
||||
"initialize",
|
||||
json!({"protocolVersion": 1, "clientCapabilities": {}}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv().await;
|
||||
h.send("session/new", json!({"cwd": cwd, "mcpServers": []}))
|
||||
.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()
|
||||
}
|
||||
|
||||
/// AGENTS.md in cwd is loaded into the system prompt.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn hints_loaded_from_cwd_agents_md() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
let marker = "BUZZ_HINTS_MARKER_42";
|
||||
std::fs::write(cwd.join("AGENTS.md"), marker).unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
|
||||
let sid = init_session(&mut h, cwd.to_str().unwrap()).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;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
system.contains(marker),
|
||||
"system prompt does not contain AGENTS.md marker: {system}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// BUZZ_AGENT_NO_HINTS=1 suppresses hint loading.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn hints_suppressed_with_env_var() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
let marker = "SUPPRESS_CHECK_MARKER_99";
|
||||
std::fs::write(cwd.join("AGENTS.md"), marker).unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_NO_HINTS", "1")]).await;
|
||||
let sid = init_session(&mut h, cwd.to_str().unwrap()).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;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
!system.contains(marker),
|
||||
"system prompt should NOT contain marker when hints disabled: {system}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// SKILL.md files in .agents/skills/ are loaded into the system prompt as metadata only.
|
||||
/// The body is NOT inlined; the agent uses `load_skill` to fetch it on demand.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn skills_loaded_from_agents_skills_dir() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
let skill_dir = cwd.join(".agents/skills/test-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: test-skill\ndescription: A test skill\n---\nSKILL_BODY_MARKER_77\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
|
||||
let sid = init_session(&mut h, cwd.to_str().unwrap()).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;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
// Skill name must appear in the metadata listing.
|
||||
assert!(
|
||||
system.contains("test-skill"),
|
||||
"system prompt missing skill name: {system}"
|
||||
);
|
||||
// Body must NOT be inlined — lazy loading only.
|
||||
assert!(
|
||||
!system.contains("SKILL_BODY_MARKER_77"),
|
||||
"skill body must not be inlined in system prompt: {system}"
|
||||
);
|
||||
// The load_skill instruction must be present.
|
||||
assert!(
|
||||
system.contains("load_skill"),
|
||||
"system prompt missing load_skill instruction: {system}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// AGENTS.md files at git root and subdirectory are both loaded, root first.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn git_root_hints_included() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir(root.join(".git")).unwrap();
|
||||
std::fs::write(root.join("AGENTS.md"), "ROOT_HINT_MARKER_11").unwrap();
|
||||
let sub = root.join("sub");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
std::fs::write(sub.join("AGENTS.md"), "SUB_HINT_MARKER_22").unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
|
||||
let sid = init_session(&mut h, sub.to_str().unwrap()).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;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
system.contains("ROOT_HINT_MARKER_11"),
|
||||
"system prompt missing root hint: {system}"
|
||||
);
|
||||
assert!(
|
||||
system.contains("SUB_HINT_MARKER_22"),
|
||||
"system prompt missing sub hint: {system}"
|
||||
);
|
||||
let root_pos = system.find("ROOT_HINT_MARKER_11").unwrap();
|
||||
let sub_pos = system.find("SUB_HINT_MARKER_22").unwrap();
|
||||
assert!(
|
||||
root_pos < sub_pos,
|
||||
"root hint should appear before sub hint in system prompt"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// ~/AGENTS.md (global) is loaded before CWD AGENTS.md when HOME is set.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn global_agents_md_loaded() {
|
||||
let home_tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd_tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(home_tmp.path().join("AGENTS.md"), "GLOBAL_HINT_MARKER_55").unwrap();
|
||||
std::fs::write(cwd_tmp.path().join("AGENTS.md"), "LOCAL_HINT_MARKER_66").unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h =
|
||||
Harness::spawn_with_env(&llm.url, &[("HOME", home_tmp.path().to_str().unwrap())]).await;
|
||||
let sid = init_session(&mut h, cwd_tmp.path().to_str().unwrap()).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;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
system.contains("GLOBAL_HINT_MARKER_55"),
|
||||
"system prompt missing global hint: {system}"
|
||||
);
|
||||
assert!(
|
||||
system.contains("LOCAL_HINT_MARKER_66"),
|
||||
"system prompt missing local hint: {system}"
|
||||
);
|
||||
let global_pos = system.find("GLOBAL_HINT_MARKER_55").unwrap();
|
||||
let local_pos = system.find("LOCAL_HINT_MARKER_66").unwrap();
|
||||
assert!(
|
||||
global_pos < local_pos,
|
||||
"global hint should appear before local hint in system prompt"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// Global skills from ~/.agents/skills/ are loaded; project-level wins on name conflict.
|
||||
/// Bodies are NOT inlined — only metadata (name + description) appears in the system prompt.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn global_skills_loaded_and_project_wins() {
|
||||
let home_tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd_tmp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let global_only_dir = home_tmp.path().join(".agents/skills/global-only");
|
||||
std::fs::create_dir_all(&global_only_dir).unwrap();
|
||||
std::fs::write(
|
||||
global_only_dir.join("SKILL.md"),
|
||||
"---\nname: global-only\ndescription: A global skill\n---\nGLOBAL_SKILL_BODY_88\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let global_shared_dir = home_tmp.path().join(".agents/skills/shared-name");
|
||||
std::fs::create_dir_all(&global_shared_dir).unwrap();
|
||||
std::fs::write(
|
||||
global_shared_dir.join("SKILL.md"),
|
||||
"---\nname: shared-name\ndescription: Global version\n---\nGLOBAL_SHARED_BODY_LOSE\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let project_shared_dir = cwd_tmp.path().join(".agents/skills/shared-name");
|
||||
std::fs::create_dir_all(&project_shared_dir).unwrap();
|
||||
std::fs::write(
|
||||
project_shared_dir.join("SKILL.md"),
|
||||
"---\nname: shared-name\ndescription: Project version\n---\nPROJECT_SHARED_BODY_WIN\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h =
|
||||
Harness::spawn_with_env(&llm.url, &[("HOME", home_tmp.path().to_str().unwrap())]).await;
|
||||
let sid = init_session(&mut h, cwd_tmp.path().to_str().unwrap()).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;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
// Both skill names must appear in the metadata listing.
|
||||
assert!(
|
||||
system.contains("global-only"),
|
||||
"system prompt missing global-only skill name: {system}"
|
||||
);
|
||||
assert!(
|
||||
system.contains("shared-name"),
|
||||
"system prompt missing shared-name skill: {system}"
|
||||
);
|
||||
// Project description wins over global for the shared name.
|
||||
assert!(
|
||||
system.contains("Project version"),
|
||||
"system prompt should show project description for shared-name: {system}"
|
||||
);
|
||||
assert!(
|
||||
!system.contains("Global version"),
|
||||
"system prompt should NOT show global description for shared-name: {system}"
|
||||
);
|
||||
// Bodies must NOT be inlined.
|
||||
assert!(
|
||||
!system.contains("GLOBAL_SKILL_BODY_88"),
|
||||
"skill body must not be inlined: {system}"
|
||||
);
|
||||
assert!(
|
||||
!system.contains("PROJECT_SHARED_BODY_WIN"),
|
||||
"skill body must not be inlined: {system}"
|
||||
);
|
||||
assert!(
|
||||
!system.contains("GLOBAL_SHARED_BODY_LOSE"),
|
||||
"shadowed skill body must not be inlined: {system}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// Skill directories that are symlinks (e.g. managed by ai-rules) are discovered
|
||||
/// correctly — `DirEntry::file_type()` returns `FileType::Symlink` for symlinks,
|
||||
/// so the old `is_dir()` check silently dropped them. We now use
|
||||
/// `std::fs::metadata()` which follows the symlink.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn symlinked_skill_dir_is_discovered() {
|
||||
let real_skill_root = tempfile::TempDir::new().unwrap();
|
||||
let real_skill_dir = real_skill_root.path().join("symlinked-skill");
|
||||
std::fs::create_dir_all(&real_skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
real_skill_dir.join("SKILL.md"),
|
||||
"---\nname: symlinked-skill\ndescription: A symlinked skill\n---\nSYMLINK_SKILL_BODY_42\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
let skills_dir = cwd.join(".agents/skills");
|
||||
std::fs::create_dir_all(&skills_dir).unwrap();
|
||||
|
||||
// Create a symlink: .agents/skills/symlinked-skill -> real_skill_dir
|
||||
std::os::unix::fs::symlink(&real_skill_dir, skills_dir.join("symlinked-skill")).unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
|
||||
let sid = init_session(&mut h, cwd.to_str().unwrap()).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;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
// The symlinked skill name must appear in the metadata listing.
|
||||
assert!(
|
||||
system.contains("symlinked-skill"),
|
||||
"system prompt missing symlinked skill name: {system}"
|
||||
);
|
||||
// Body must NOT be inlined.
|
||||
assert!(
|
||||
!system.contains("SYMLINK_SKILL_BODY_42"),
|
||||
"symlinked skill body must not be inlined in system prompt: {system}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// `load_skill` tool is advertised when skills exist, and returns the skill body.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn load_skill_tool_returns_body() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
let skill_dir = cwd.join(".agents/skills/my-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: my-skill\ndescription: A skill\n---\nSKILL_BODY_CONTENT_99\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Round 1: LLM calls load_skill("my-skill").
|
||||
// Round 2: LLM returns end_turn after seeing the body.
|
||||
let load_skill_call = json!({
|
||||
"id": "cc-ls", "object": "chat.completion", "model": "fake-model",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant", "content": null,
|
||||
"tool_calls": [{
|
||||
"id": "tc-1", "type": "function",
|
||||
"function": {
|
||||
"name": "load_skill",
|
||||
"arguments": "{\"name\":\"my-skill\"}"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}]
|
||||
});
|
||||
let end_turn = openai_text("done");
|
||||
|
||||
let llm = spawn_capturing_llm(vec![load_skill_call, end_turn]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
|
||||
let sid = init_session(&mut h, cwd.to_str().unwrap()).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"use my-skill"}]}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
|
||||
|
||||
// The second LLM request (round 2) should contain the skill body in tool results.
|
||||
let reqs = llm.captured.lock().await;
|
||||
assert!(
|
||||
reqs.len() >= 2,
|
||||
"expected at least 2 LLM requests, got {}",
|
||||
reqs.len()
|
||||
);
|
||||
let round2_str = serde_json::to_string(&reqs[1]).unwrap();
|
||||
assert!(
|
||||
round2_str.contains("SKILL_BODY_CONTENT_99"),
|
||||
"load_skill result must contain skill body in round 2 request.\nGot: {round2_str}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Integration test for OpenAI auto-upgrade chat→responses.
|
||||
//!
|
||||
//! Starts a tiny HTTP server that:
|
||||
//! 1. accepts a POST to /chat/completions, replies 400 with a body that
|
||||
//! mentions `/v1/responses` (mirrors the Databricks GPT-5.5 signal);
|
||||
//! 2. accepts a POST to /responses, replies 200 with a Responses-shaped
|
||||
//! JSON envelope.
|
||||
//!
|
||||
//! Spawns `buzz-agent` with `provider=openai` + `OPENAI_COMPAT_API=auto`
|
||||
//! pointed at the fake server, drives one prompt through the ACP wire
|
||||
//! protocol, and verifies the prompt completes with `stopReason=end_turn`
|
||||
//! — which can only happen if the second (Responses) request succeeded.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Spawns a single-shot fake provider. Returns the base URL (e.g.
|
||||
/// `http://127.0.0.1:54321`). The server stays up for the lifetime of
|
||||
/// the process — we don't need to clean it up explicitly.
|
||||
fn spawn_fake_provider() -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(false).unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let chat_hits = Arc::new(AtomicUsize::new(0));
|
||||
let responses_hits = Arc::new(AtomicUsize::new(0));
|
||||
let chat = chat_hits.clone();
|
||||
let resp = responses_hits.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
let (mut sock, _) = match listener.accept() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
let chat = chat.clone();
|
||||
let resp = resp.clone();
|
||||
std::thread::spawn(move || {
|
||||
sock.set_read_timeout(Some(Duration::from_secs(5))).ok();
|
||||
// Read request head + body. Naive: read until we have the
|
||||
// request line + headers, then read Content-Length bytes.
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
let mut tmp = [0u8; 4096];
|
||||
loop {
|
||||
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
match sock.read(&mut tmp) {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => buf.extend_from_slice(&tmp[..n]),
|
||||
}
|
||||
if buf.len() > 256 * 1024 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let head_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
|
||||
let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
|
||||
// Drain the body to satisfy keep-alive; we don't actually
|
||||
// need it.
|
||||
let cl = head
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
l.strip_prefix("content-length:")
|
||||
.or_else(|| l.strip_prefix("Content-Length:"))
|
||||
})
|
||||
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
while buf.len() < head_end + cl {
|
||||
match sock.read(&mut tmp) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => buf.extend_from_slice(&tmp[..n]),
|
||||
}
|
||||
}
|
||||
|
||||
let (status, body) = if head.contains("POST /chat/completions") {
|
||||
chat.fetch_add(1, Ordering::SeqCst);
|
||||
let body = json!({
|
||||
"error": {
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "Function tools with reasoning_effort are not supported for gpt-5.5 in /v1/chat/completions. Please use /v1/responses instead."
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
(400u16, body)
|
||||
} else if head.contains("POST /responses") {
|
||||
resp.fetch_add(1, Ordering::SeqCst);
|
||||
let body = json!({
|
||||
"status": "completed",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "ok from responses"}]
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
(200u16, body)
|
||||
} else {
|
||||
(404u16, "{}".to_string())
|
||||
};
|
||||
let reason = match status {
|
||||
200 => "OK",
|
||||
400 => "Bad Request",
|
||||
_ => "Not Found",
|
||||
};
|
||||
let resp_text = format!(
|
||||
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(), body
|
||||
);
|
||||
let _ = sock.write_all(resp_text.as_bytes());
|
||||
let _ = sock.shutdown(std::net::Shutdown::Write);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
(url, chat_hits, responses_hits)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_auto_upgrades_chat_to_responses_on_databricks_signal() {
|
||||
let (base_url, chat_hits, resp_hits) = spawn_fake_provider();
|
||||
|
||||
let bin = env!("CARGO_BIN_EXE_buzz-agent");
|
||||
let mut cmd = Command::new(bin);
|
||||
cmd.env("BUZZ_AGENT_PROVIDER", "openai")
|
||||
.env("OPENAI_COMPAT_API_KEY", "test")
|
||||
.env("OPENAI_COMPAT_MODEL", "gpt-5.5")
|
||||
.env("OPENAI_COMPAT_BASE_URL", &base_url)
|
||||
// No OPENAI_COMPAT_API — must default to "auto" so the upgrade
|
||||
// path is enabled.
|
||||
.env_remove("OPENAI_COMPAT_API")
|
||||
.env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5")
|
||||
.env("BUZZ_AGENT_MAX_ROUNDS", "4")
|
||||
.env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true);
|
||||
|
||||
let mut child = cmd.spawn().expect("spawn buzz-agent");
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
|
||||
async fn send(stdin: &mut tokio::process::ChildStdin, v: serde_json::Value) {
|
||||
let line = format!("{v}\n");
|
||||
stdin.write_all(line.as_bytes()).await.unwrap();
|
||||
stdin.flush().await.unwrap();
|
||||
}
|
||||
async fn recv(stdout: &mut BufReader<tokio::process::ChildStdout>) -> serde_json::Value {
|
||||
let mut line = String::new();
|
||||
timeout(Duration::from_secs(8), stdout.read_line(&mut line))
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("recv io");
|
||||
serde_json::from_str(&line).expect("recv json")
|
||||
}
|
||||
|
||||
send(
|
||||
&mut stdin,
|
||||
json!({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"protocolVersion": 1, "clientCapabilities": {},
|
||||
"clientInfo": {"name": "auto-upgrade-test"}}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let init = recv(&mut stdout).await;
|
||||
assert!(init.get("result").is_some(), "initialize: {init}");
|
||||
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
send(
|
||||
&mut stdin,
|
||||
json!({
|
||||
"jsonrpc": "2.0", "id": 2, "method": "session/new",
|
||||
"params": {"cwd": cwd.to_string_lossy(), "mcpServers": []}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let sess = recv(&mut stdout).await;
|
||||
let sid = sess["result"]["sessionId"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("session/new failed: {sess}"))
|
||||
.to_string();
|
||||
|
||||
send(
|
||||
&mut stdin,
|
||||
json!({
|
||||
"jsonrpc": "2.0", "id": 3, "method": "session/prompt",
|
||||
"params": {"sessionId": sid,
|
||||
"prompt": [{"type": "text", "text": "hi"}]}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Drain notifications until we see the response for id=3.
|
||||
let mut stop_reason: Option<String> = None;
|
||||
for _ in 0..40 {
|
||||
let msg = recv(&mut stdout).await;
|
||||
if msg.get("id") == Some(&json!(3)) {
|
||||
if let Some(r) = msg.get("result") {
|
||||
stop_reason = r
|
||||
.get("stopReason")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(stop_reason.as_deref(), Some("end_turn"));
|
||||
assert_eq!(
|
||||
chat_hits.load(Ordering::SeqCst),
|
||||
1,
|
||||
"must have tried chat first"
|
||||
);
|
||||
assert!(
|
||||
resp_hits.load(Ordering::SeqCst) >= 1,
|
||||
"must have upgraded to responses"
|
||||
);
|
||||
|
||||
drop(stdin);
|
||||
let _ = child.wait().await;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user