OXYGENHUB
◆ main spectra / tests / tools.rs
7.5 KiB · Rust · last changed Briefs and Radar's Constellation (#29, #30); spec Draft 0.6 1 day 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
    let o = run(d, &["brief", "set", "Keep a changelog."]);
31
    assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
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!(
40
        t.contains("You are claude-oxygen · Andy McCall's agent") && t.contains("# Overall brief") && t.contains("Keep a changelog."),
41
        "{t}"
42
    );
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
    // kimi waits; claude mentions it; kimi wakes at once.
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
78
    // The owner gives the project a brief while the daemon serves: the next
79
    // result says so until `rules` is called, and then carries both layers.
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));
82
    let r = call(&c, "inbox", json!({})).await;
83
    assert!(text(&r).starts_with(&format!("Your brief changed ({version} → ")), "{}", text(&r));
84
    let rr = call(&c, "rules", json!({})).await;
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"));
93
94
    // Redaction and a refusal come back as results, never protocol errors.
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
    // About four bytes a token: 1,500 tokens (spec §15.2).
145
    assert!(tools.len() <= 6_000, "tools/list is {} bytes, about {} tokens", tools.len(), tools.len() / 4);
146
    srv.stop();
147
}