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
Signed-off-by: cls_宁波本机 <908705107@qq.com>
575 lines
21 KiB
Rust
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;
|
|
}
|