//! The 0.1 tools end to end (spec §15.2, §14.1, §12.3, §9.2): two agents //! talk through the real daemon, `wait` wakes at once and refuses an idle //! loop, the rules line appears until the rules are read, every call is //! recorded with its arguments in full, and the ledger verifies. mod common; use common::{add_agent, run, setup, Serving}; use serde_json::{json, Value}; use spectra_core::keys::KeyPair; use spectra_mcp::client::{rpc_result, Client}; use std::time::{Duration, Instant}; async fn call(c: &Client, name: &str, args: Value) -> Value { let req = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": name, "arguments": args, "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }); let r = c.mcp("2026-07-28", &req).await.unwrap(); assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body)); rpc_result(&r).unwrap()["result"].clone() } fn text(v: &Value) -> String { v["content"][0]["text"].as_str().unwrap_or("").to_string() } #[tokio::test] async fn two_agents_talk_and_every_call_is_on_the_record() { let dir = setup(); let d = dir.path(); std::fs::create_dir_all(d.join("data/rules")).unwrap(); std::fs::write(d.join("data/rules/site.md"), "Commit with oxy, never git.\n").unwrap(); let claude: KeyPair = add_agent(d, "claude-oxygen", &[]); let kimi: KeyPair = add_agent(d, "kimi-oxygen", &[]); let srv = Serving::start(d); let (c, k) = (Client::new(&srv.socket, Some(claude)), Client::new(&srv.socket, Some(kimi))); let b = call(&c, "brief", json!({})).await; let t = text(&b); assert!(t.contains("You are claude-oxygen · Andy McCall's agent") && t.contains("Commit with oxy, never git."), "{t}"); assert!(t.contains("kimi-oxygen · Andy McCall's agent"), "{t}"); let version = b["structuredContent"]["rules"]["version"].as_str().unwrap().to_string(); call(&k, "brief", json!({})).await; // kimi waits; claude mentions it; kimi wakes at once. let waiter = tokio::spawn({ let k = Client::new(&srv.socket, Some(KeyPair::load_home(&d.join("agents/kimi-oxygen")).unwrap())); async move { let started = Instant::now(); let r = call(&k, "wait", json!({ "timeout_s": 20 })).await; (r, started.elapsed()) } }); tokio::time::sleep(Duration::from_millis(300)).await; let s = call(&c, "say", json!({ "body": "@kimi-oxygen the wire protocol grew a field", "channel": "general" })).await; let id = s["structuredContent"]["id"].as_str().unwrap().to_string(); let (woke, waited) = waiter.await.unwrap(); assert!( text(&woke).contains("the wire protocol grew a field") && text(&woke).contains("[mention from claude-oxygen (Andy McCall's agent"), "{}", text(&woke) ); assert!(waited < Duration::from_secs(5), "woke after {waited:?}, not at its 20 s timeout"); let r = call(&k, "read", json!({ "thread": id })).await; assert!(text(&r).contains("[from claude-oxygen (Andy McCall's agent, claude-code)") && text(&r).contains("[end]"), "{}", text(&r)); call(&k, "say", json!({ "body": "thanks, bumping the pin", "reply_to": id })).await; call(&k, "ack", json!({ "ids": [id], "reaction": "ack" })).await; assert!(text(&call(&k, "inbox", json!({})).await).contains("Nothing unread")); let hits = call(&c, "search", json!({ "query": "pin", "since": "1d" })).await; assert_eq!(hits["structuredContent"]["hits"].as_array().unwrap().len(), 1, "{hits}"); call(&c, "status", json!({ "state": "working", "text": "reviewing the field" })).await; let a = call(&k, "agents", json!({})).await; assert!(text(&a).contains("busy — \"reviewing the field\""), "{}", text(&a)); // The rules change: the next result says so until `rules` is called. std::fs::write(d.join("data/rules/site.md"), "Commit with oxy, never git. Add a changelog line.\n").unwrap(); let r = call(&c, "inbox", json!({})).await; assert!(text(&r).starts_with(&format!("House rules changed ({version} → ")), "{}", text(&r)); let rr = call(&c, "rules", json!({})).await; assert!(text(&rr).contains("Add a changelog line.")); assert!(!text(&call(&c, "inbox", json!({})).await).starts_with("House rules changed")); // Redaction and a refusal come back as results, never protocol errors. let mine = call(&c, "say", json!({ "body": "oops, a secret" })).await["structuredContent"]["id"].as_str().unwrap().to_string(); assert_eq!(call(&k, "redact", json!({ "id": mine, "reason": "not yours" })).await["isError"], true); assert_eq!(call(&c, "redact", json!({ "id": mine, "reason": "pasted by mistake" })).await["isError"], false); assert_eq!(call(&c, "say", json!({ "body": "x", "channel": "system" })).await["isError"], true); srv.stop(); let v: Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap(); assert!(v["findings"].as_array().unwrap().is_empty(), "{v}"); let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap(); let calls: Vec<(String, String)> = sqlx::query_as("SELECT tool, args FROM tool_calls WHERE agent = 'claude-oxygen' ORDER BY seq").fetch_all(&pool).await.unwrap(); assert!(calls.iter().any(|(t, _)| t == "say")); let (_, args) = calls.iter().find(|(t, _)| t == "say").unwrap(); let stored = spectra_core::attachments::get(&d.join("data"), args).expect("arguments kept in full"); assert!(String::from_utf8_lossy(&stored).contains("the wire protocol grew a field")); } #[tokio::test] async fn an_idle_wait_loop_is_refused() { let dir = setup(); let d = dir.path(); let a = add_agent(d, "idler", &[]); let srv = Serving::start(d); let c = Client::new(&srv.socket, Some(a)); for _ in 0..3 { let r = call(&c, "wait", json!({ "timeout_s": 1 })).await; assert_eq!((r["isError"].as_bool(), text(&r).as_str()), (Some(false), "Nothing new.")); } let started = Instant::now(); let r = call(&c, "wait", json!({ "timeout_s": 50 })).await; assert_eq!(r["isError"], true); assert!(text(&r).contains("End your turn"), "{}", text(&r)); assert!(started.elapsed() < Duration::from_secs(2), "refused at once"); srv.stop(); let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap(); let waits: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tool_calls WHERE tool = 'wait' AND is_error = 0").fetch_one(&pool).await.unwrap(); assert_eq!(waits, 0, "empty waits are not recorded"); } #[tokio::test] async fn the_tool_list_fits_its_token_budget() { let dir = setup(); let d = dir.path(); let a = add_agent(d, "counter", &[]); let srv = Serving::start(d); let c = Client::new(&srv.socket, Some(a)); let r = c.mcp("2026-07-28", &json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} })).await.unwrap(); let tools = serde_json::to_string(&r.json().unwrap()["result"]["tools"]).unwrap(); // About four bytes a token: 1,500 tokens (spec §15.2). assert!(tools.len() <= 6_000, "tools/list is {} bytes, about {} tokens", tools.len(), tools.len() / 4); srv.stop(); }