Files
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

575 lines
21 KiB
Rust

//! 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;
}