| 037c45d8 Andy McCall ↶ |
1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
mod common; |
| 7 |
|
| 8 |
use common::{add_agent, run, setup, Serving}; |
| 9 |
use serde_json::{json, Value}; |
| 10 |
use spectra_core::keys::KeyPair; |
| 11 |
use spectra_mcp::client::{rpc_result, Client}; |
| 12 |
use std::time::{Duration, Instant}; |
| 13 |
|
| 14 |
async fn call(c: &Client, name: &str, args: Value) -> Value { |
| 15 |
let req = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": name, "arguments": args, |
| 16 |
"_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }); |
| 17 |
let r = c.mcp("2026-07-28", &req).await.unwrap(); |
| 18 |
assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body)); |
| 19 |
rpc_result(&r).unwrap()["result"].clone() |
| 20 |
} |
| 21 |
|
| 22 |
fn text(v: &Value) -> String { |
| 23 |
v["content"][0]["text"].as_str().unwrap_or("").to_string() |
| 24 |
} |
| 25 |
|
| 26 |
#[tokio::test] |
| 27 |
async fn two_agents_talk_and_every_call_is_on_the_record() { |
| 28 |
let dir = setup(); |
| 29 |
let d = dir.path(); |
| b044d909 Andy McCall ↶ |
30 |
let o = run(d, &["brief", "set", "Keep a changelog."]); |
| 31 |
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); |
| 037c45d8 Andy McCall ↶ |
32 |
let claude: KeyPair = add_agent(d, "claude-oxygen", &[]); |
| 33 |
let kimi: KeyPair = add_agent(d, "kimi-oxygen", &[]); |
| 34 |
let srv = Serving::start(d); |
| 35 |
let (c, k) = (Client::new(&srv.socket, Some(claude)), Client::new(&srv.socket, Some(kimi))); |
| 36 |
|
| 37 |
let b = call(&c, "brief", json!({})).await; |
| 38 |
let t = text(&b); |
| b044d909 Andy McCall ↶ |
39 |
assert!( |
| 40 |
t.contains("You are claude-oxygen · Andy McCall's agent") && t.contains("# Overall brief") && t.contains("Keep a changelog."), |
| 41 |
"{t}" |
| 42 |
); |
| 037c45d8 Andy McCall ↶ |
43 |
assert!(t.contains("kimi-oxygen · Andy McCall's agent"), "{t}"); |
| 44 |
let version = b["structuredContent"]["rules"]["version"].as_str().unwrap().to_string(); |
| 45 |
call(&k, "brief", json!({})).await; |
| 46 |
|
| 47 |
|
| 48 |
let waiter = tokio::spawn({ |
| 49 |
let k = Client::new(&srv.socket, Some(KeyPair::load_home(&d.join("agents/kimi-oxygen")).unwrap())); |
| 50 |
async move { |
| 51 |
let started = Instant::now(); |
| 52 |
let r = call(&k, "wait", json!({ "timeout_s": 20 })).await; |
| 53 |
(r, started.elapsed()) |
| 54 |
} |
| 55 |
}); |
| 56 |
tokio::time::sleep(Duration::from_millis(300)).await; |
| 57 |
let s = call(&c, "say", json!({ "body": "@kimi-oxygen the wire protocol grew a field", "channel": "general" })).await; |
| 58 |
let id = s["structuredContent"]["id"].as_str().unwrap().to_string(); |
| 59 |
let (woke, waited) = waiter.await.unwrap(); |
| 60 |
assert!( |
| 61 |
text(&woke).contains("the wire protocol grew a field") && text(&woke).contains("[mention from claude-oxygen (Andy McCall's agent"), |
| 62 |
"{}", |
| 63 |
text(&woke) |
| 64 |
); |
| 65 |
assert!(waited < Duration::from_secs(5), "woke after {waited:?}, not at its 20 s timeout"); |
| 66 |
|
| 67 |
let r = call(&k, "read", json!({ "thread": id })).await; |
| 68 |
assert!(text(&r).contains("[from claude-oxygen (Andy McCall's agent, claude-code)") && text(&r).contains("[end]"), "{}", text(&r)); |
| 69 |
call(&k, "say", json!({ "body": "thanks, bumping the pin", "reply_to": id })).await; |
| 70 |
call(&k, "ack", json!({ "ids": [id], "reaction": "ack" })).await; |
| 71 |
assert!(text(&call(&k, "inbox", json!({})).await).contains("Nothing unread")); |
| 72 |
let hits = call(&c, "search", json!({ "query": "pin", "since": "1d" })).await; |
| 73 |
assert_eq!(hits["structuredContent"]["hits"].as_array().unwrap().len(), 1, "{hits}"); |
| 74 |
call(&c, "status", json!({ "state": "working", "text": "reviewing the field" })).await; |
| 75 |
let a = call(&k, "agents", json!({})).await; |
| 76 |
assert!(text(&a).contains("busy — \"reviewing the field\""), "{}", text(&a)); |
| 77 |
|
| b044d909 Andy McCall ↶ |
78 |
|
| 79 |
|
| 80 |
let o = run(d, &["brief", "set", "--project", "oxygen", "The wire protocol is frozen until Friday."]); |
| 81 |
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); |
| 037c45d8 Andy McCall ↶ |
82 |
let r = call(&c, "inbox", json!({})).await; |
| b044d909 Andy McCall ↶ |
83 |
assert!(text(&r).starts_with(&format!("Your brief changed ({version} → ")), "{}", text(&r)); |
| 037c45d8 Andy McCall ↶ |
84 |
let rr = call(&c, "rules", json!({})).await; |
| b044d909 Andy McCall ↶ |
85 |
assert!( |
| 86 |
text(&rr).contains("Keep a changelog.") |
| 87 |
&& text(&rr).contains("# Project brief: oxygen") |
| 88 |
&& text(&rr).contains("frozen until Friday"), |
| 89 |
"{}", |
| 90 |
text(&rr) |
| 91 |
); |
| 92 |
assert!(!text(&call(&c, "inbox", json!({})).await).starts_with("Your brief changed")); |
| 037c45d8 Andy McCall ↶ |
93 |
|
| 94 |
|
| 95 |
let mine = call(&c, "say", json!({ "body": "oops, a secret" })).await["structuredContent"]["id"].as_str().unwrap().to_string(); |
| 96 |
assert_eq!(call(&k, "redact", json!({ "id": mine, "reason": "not yours" })).await["isError"], true); |
| 97 |
assert_eq!(call(&c, "redact", json!({ "id": mine, "reason": "pasted by mistake" })).await["isError"], false); |
| 98 |
assert_eq!(call(&c, "say", json!({ "body": "x", "channel": "system" })).await["isError"], true); |
| 99 |
srv.stop(); |
| 100 |
|
| 101 |
let v: Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap(); |
| 102 |
assert!(v["findings"].as_array().unwrap().is_empty(), "{v}"); |
| 103 |
let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap(); |
| 104 |
let calls: Vec<(String, String)> = |
| 105 |
sqlx::query_as("SELECT tool, args FROM tool_calls WHERE agent = 'claude-oxygen' ORDER BY seq").fetch_all(&pool).await.unwrap(); |
| 106 |
assert!(calls.iter().any(|(t, _)| t == "say")); |
| 107 |
let (_, args) = calls.iter().find(|(t, _)| t == "say").unwrap(); |
| 108 |
let stored = spectra_core::attachments::get(&d.join("data"), args).expect("arguments kept in full"); |
| 109 |
assert!(String::from_utf8_lossy(&stored).contains("the wire protocol grew a field")); |
| 110 |
} |
| 111 |
|
| 112 |
#[tokio::test] |
| 113 |
async fn an_idle_wait_loop_is_refused() { |
| 114 |
let dir = setup(); |
| 115 |
let d = dir.path(); |
| 116 |
let a = add_agent(d, "idler", &[]); |
| 117 |
let srv = Serving::start(d); |
| 118 |
let c = Client::new(&srv.socket, Some(a)); |
| 119 |
for _ in 0..3 { |
| 120 |
let r = call(&c, "wait", json!({ "timeout_s": 1 })).await; |
| 121 |
assert_eq!((r["isError"].as_bool(), text(&r).as_str()), (Some(false), "Nothing new.")); |
| 122 |
} |
| 123 |
let started = Instant::now(); |
| 124 |
let r = call(&c, "wait", json!({ "timeout_s": 50 })).await; |
| 125 |
assert_eq!(r["isError"], true); |
| 126 |
assert!(text(&r).contains("End your turn"), "{}", text(&r)); |
| 127 |
assert!(started.elapsed() < Duration::from_secs(2), "refused at once"); |
| 128 |
srv.stop(); |
| 129 |
let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap(); |
| 130 |
let waits: i64 = |
| 131 |
sqlx::query_scalar("SELECT COUNT(*) FROM tool_calls WHERE tool = 'wait' AND is_error = 0").fetch_one(&pool).await.unwrap(); |
| 132 |
assert_eq!(waits, 0, "empty waits are not recorded"); |
| 133 |
} |
| 134 |
|
| 135 |
#[tokio::test] |
| 136 |
async fn the_tool_list_fits_its_token_budget() { |
| 137 |
let dir = setup(); |
| 138 |
let d = dir.path(); |
| 139 |
let a = add_agent(d, "counter", &[]); |
| 140 |
let srv = Serving::start(d); |
| 141 |
let c = Client::new(&srv.socket, Some(a)); |
| 142 |
let r = c.mcp("2026-07-28", &json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} })).await.unwrap(); |
| 143 |
let tools = serde_json::to_string(&r.json().unwrap()["result"]["tools"]).unwrap(); |
| 144 |
|
| 145 |
assert!(tools.len() <= 6_000, "tools/list is {} bytes, about {} tokens", tools.len(), tools.len() / 4); |
| 146 |
srv.stop(); |
| 147 |
} |