OXYGENHUB
# da84e98f104e spectra / tests / tools.rs
7.1 KiB · Rust · last changed The 0.1 tools: brief, rules, agents, status, say, read, inbox, wait with the idle guard, ack, search, redact; every call on the record (#8) 2 days ago source blame history raw
1
//! The 0.1 tools end to end (spec §15.2, §14.1, §12.3, §9.2): two agents
2
//! talk through the real daemon, `wait` wakes at once and refuses an idle
3
//! loop, the rules line appears until the rules are read, every call is
4
//! recorded with its arguments in full, and the ledger verifies.
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();
30
    std::fs::create_dir_all(d.join("data/rules")).unwrap();
31
    std::fs::write(d.join("data/rules/site.md"), "Commit with oxy, never git.\n").unwrap();
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);
39
    assert!(t.contains("You are claude-oxygen · Andy McCall's agent") && t.contains("Commit with oxy, never git."), "{t}");
40
    assert!(t.contains("kimi-oxygen · Andy McCall's agent"), "{t}");
41
    let version = b["structuredContent"]["rules"]["version"].as_str().unwrap().to_string();
42
    call(&k, "brief", json!({})).await;
43
44
    // kimi waits; claude mentions it; kimi wakes at once.
45
    let waiter = tokio::spawn({
46
        let k = Client::new(&srv.socket, Some(KeyPair::load_home(&d.join("agents/kimi-oxygen")).unwrap()));
47
        async move {
48
            let started = Instant::now();
49
            let r = call(&k, "wait", json!({ "timeout_s": 20 })).await;
50
            (r, started.elapsed())
51
        }
52
    });
53
    tokio::time::sleep(Duration::from_millis(300)).await;
54
    let s = call(&c, "say", json!({ "body": "@kimi-oxygen the wire protocol grew a field", "channel": "general" })).await;
55
    let id = s["structuredContent"]["id"].as_str().unwrap().to_string();
56
    let (woke, waited) = waiter.await.unwrap();
57
    assert!(
58
        text(&woke).contains("the wire protocol grew a field") && text(&woke).contains("[mention from claude-oxygen (Andy McCall's agent"),
59
        "{}",
60
        text(&woke)
61
    );
62
    assert!(waited < Duration::from_secs(5), "woke after {waited:?}, not at its 20 s timeout");
63
64
    let r = call(&k, "read", json!({ "thread": id })).await;
65
    assert!(text(&r).contains("[from claude-oxygen (Andy McCall's agent, claude-code)") && text(&r).contains("[end]"), "{}", text(&r));
66
    call(&k, "say", json!({ "body": "thanks, bumping the pin", "reply_to": id })).await;
67
    call(&k, "ack", json!({ "ids": [id], "reaction": "ack" })).await;
68
    assert!(text(&call(&k, "inbox", json!({})).await).contains("Nothing unread"));
69
    let hits = call(&c, "search", json!({ "query": "pin", "since": "1d" })).await;
70
    assert_eq!(hits["structuredContent"]["hits"].as_array().unwrap().len(), 1, "{hits}");
71
    call(&c, "status", json!({ "state": "working", "text": "reviewing the field" })).await;
72
    let a = call(&k, "agents", json!({})).await;
73
    assert!(text(&a).contains("busy — \"reviewing the field\""), "{}", text(&a));
74
75
    // The rules change: the next result says so until `rules` is called.
76
    std::fs::write(d.join("data/rules/site.md"), "Commit with oxy, never git. Add a changelog line.\n").unwrap();
77
    let r = call(&c, "inbox", json!({})).await;
78
    assert!(text(&r).starts_with(&format!("House rules changed ({version} → ")), "{}", text(&r));
79
    let rr = call(&c, "rules", json!({})).await;
80
    assert!(text(&rr).contains("Add a changelog line."));
81
    assert!(!text(&call(&c, "inbox", json!({})).await).starts_with("House rules changed"));
82
83
    // Redaction and a refusal come back as results, never protocol errors.
84
    let mine = call(&c, "say", json!({ "body": "oops, a secret" })).await["structuredContent"]["id"].as_str().unwrap().to_string();
85
    assert_eq!(call(&k, "redact", json!({ "id": mine, "reason": "not yours" })).await["isError"], true);
86
    assert_eq!(call(&c, "redact", json!({ "id": mine, "reason": "pasted by mistake" })).await["isError"], false);
87
    assert_eq!(call(&c, "say", json!({ "body": "x", "channel": "system" })).await["isError"], true);
88
    srv.stop();
89
90
    let v: Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap();
91
    assert!(v["findings"].as_array().unwrap().is_empty(), "{v}");
92
    let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap();
93
    let calls: Vec<(String, String)> =
94
        sqlx::query_as("SELECT tool, args FROM tool_calls WHERE agent = 'claude-oxygen' ORDER BY seq").fetch_all(&pool).await.unwrap();
95
    assert!(calls.iter().any(|(t, _)| t == "say"));
96
    let (_, args) = calls.iter().find(|(t, _)| t == "say").unwrap();
97
    let stored = spectra_core::attachments::get(&d.join("data"), args).expect("arguments kept in full");
98
    assert!(String::from_utf8_lossy(&stored).contains("the wire protocol grew a field"));
99
}
100
101
#[tokio::test]
102
async fn an_idle_wait_loop_is_refused() {
103
    let dir = setup();
104
    let d = dir.path();
105
    let a = add_agent(d, "idler", &[]);
106
    let srv = Serving::start(d);
107
    let c = Client::new(&srv.socket, Some(a));
108
    for _ in 0..3 {
109
        let r = call(&c, "wait", json!({ "timeout_s": 1 })).await;
110
        assert_eq!((r["isError"].as_bool(), text(&r).as_str()), (Some(false), "Nothing new."));
111
    }
112
    let started = Instant::now();
113
    let r = call(&c, "wait", json!({ "timeout_s": 50 })).await;
114
    assert_eq!(r["isError"], true);
115
    assert!(text(&r).contains("End your turn"), "{}", text(&r));
116
    assert!(started.elapsed() < Duration::from_secs(2), "refused at once");
117
    srv.stop();
118
    let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap();
119
    let waits: i64 =
120
        sqlx::query_scalar("SELECT COUNT(*) FROM tool_calls WHERE tool = 'wait' AND is_error = 0").fetch_one(&pool).await.unwrap();
121
    assert_eq!(waits, 0, "empty waits are not recorded");
122
}
123
124
#[tokio::test]
125
async fn the_tool_list_fits_its_token_budget() {
126
    let dir = setup();
127
    let d = dir.path();
128
    let a = add_agent(d, "counter", &[]);
129
    let srv = Serving::start(d);
130
    let c = Client::new(&srv.socket, Some(a));
131
    let r = c.mcp("2026-07-28", &json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} })).await.unwrap();
132
    let tools = serde_json::to_string(&r.json().unwrap()["result"]["tools"]).unwrap();
133
    // About four bytes a token: 1,500 tokens (spec §15.2).
134
    assert!(tools.len() <= 6_000, "tools/list is {} bytes, about {} tokens", tools.len(), tools.len() / 4);
135
    srv.stop();
136
}