mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 10:36:46 -06:00
Reject oversized batches before any side effects instead of reporting success after truncating calls. Closes #460 Co-Authored-By: OpenAI Codex <noreply@openai.com>
208 lines
10 KiB
Rust
208 lines
10 KiB
Rust
//! An agent completes realistic DAW tasks through MCP only, and verifies each through MCP.
|
||
|
||
use serde_json::{Value, json};
|
||
use soundcraft_automation::{Headless, mcp::Server};
|
||
use soundcraft_engine::Engine;
|
||
|
||
fn rpc(s: &mut Server<Headless>, id: i64, method: &str, params: Value) -> Value {
|
||
s.handle(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap()
|
||
}
|
||
|
||
fn tool(s: &mut Server<Headless>, name: &str, args: Value) -> Value {
|
||
let r = rpc(s, 1, "tools/call", json!({"name": name, "arguments": args}));
|
||
let res = &r["result"];
|
||
assert_eq!(res["isError"], json!(false), "{name} failed: {res}");
|
||
let text = res["content"][0]["text"].as_str().unwrap();
|
||
serde_json::from_str(text).unwrap_or(Value::String(text.to_string()))
|
||
}
|
||
|
||
fn session(s: &mut Server<Headless>) -> Value {
|
||
tool(s, "inspect_session", json!({"detail": "full"}))
|
||
}
|
||
|
||
fn track<'a>(sess: &'a Value, name: &str) -> &'a Value {
|
||
sess["tracks"].as_array().unwrap().iter().find(|t| t["name"] == name).unwrap_or_else(|| panic!("no track {name}"))
|
||
}
|
||
|
||
#[test]
|
||
fn handshake_and_tool_list() {
|
||
let mut s = Server::new(Headless::new(Engine::default()));
|
||
let r = rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-06-18"}));
|
||
assert_eq!(r["result"]["serverInfo"]["name"], "soundcraft");
|
||
let r = rpc(&mut s, 2, "tools/list", json!({}));
|
||
assert!(r["result"]["tools"].as_array().unwrap().len() >= 10);
|
||
let batch = r["result"]["tools"].as_array().unwrap().iter().find(|tool| tool["name"] == "batch").unwrap();
|
||
assert_eq!(batch["inputSchema"]["properties"]["calls"]["maxItems"], 1000);
|
||
assert!(s.handle(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).is_none());
|
||
let r = rpc(&mut s, 3, "nope", json!({}));
|
||
assert_eq!(r["error"]["code"], -32601);
|
||
}
|
||
|
||
#[test]
|
||
fn agent_builds_and_mixes_a_session() {
|
||
let mut s = Server::new(Headless::new(Engine::default()));
|
||
// 1. New demo session.
|
||
tool(&mut s, "new_session", json!({"name": "Agent Mix", "template": "demo"}));
|
||
let sess = session(&mut s);
|
||
assert_eq!(sess["name"], "Agent Mix");
|
||
assert!(sess["tracks"].as_array().unwrap().len() >= 9);
|
||
|
||
// 2. Turn the kick down and pan the hats.
|
||
tool(&mut s, "execute", json!({"command": "mix.volume", "params": {"track": "Kick", "db": -8.5}}));
|
||
tool(&mut s, "execute", json!({"command": "mix.pan", "params": {"track": "Hats", "pan": -0.5}}));
|
||
let sess = session(&mut s);
|
||
assert_eq!(track(&sess, "Kick")["volume_db"], json!(-8.5));
|
||
assert_eq!(track(&sess, "Hats")["pan"][0], json!(-0.5));
|
||
|
||
// 3. Put an EQ on the bass with a low-shelf boost, then bypass it.
|
||
let r = tool(&mut s, "execute", json!({"command": "mix.insert", "params": {"track": "Bass", "slot": "c", "plugin": "eq_7band"}}));
|
||
assert_eq!(r["slot"], 2);
|
||
let sess = session(&mut s);
|
||
assert_eq!(track(&sess, "Bass")["inserts"].as_array().unwrap().iter().filter(|i| i["plugin"] == "eq_7band").count(), 1);
|
||
tool(&mut s, "execute", json!({"command": "mix.insert_bypass", "params": {"track": "Bass", "slot": 2}}));
|
||
let sess = session(&mut s);
|
||
assert_eq!(track(&sess, "Bass")["inserts"].as_array().unwrap().iter().find(|i| i["slot"] == 2).unwrap()["bypass"], true);
|
||
|
||
// 4. New stereo aux fed from a send.
|
||
tool(
|
||
&mut s,
|
||
"batch",
|
||
json!({"calls": [
|
||
{"command": "track.new", "params": {"kind": "aux", "format": "stereo", "name": "Delay"}},
|
||
{"command": "track.input", "params": {"track": "Delay", "input": "DelayBus"}},
|
||
{"command": "mix.insert", "params": {"track": "Delay", "plugin": "mod_delay"}},
|
||
{"command": "mix.send", "params": {"track": "Lead", "slot": 1, "bus": "DelayBus", "level_db": -6}},
|
||
]}),
|
||
);
|
||
let sess = session(&mut s);
|
||
let lead = track(&sess, "Lead");
|
||
assert_eq!(lead["sends"].as_array().unwrap().iter().find(|x| x["slot"] == 1).unwrap()["level_db"], json!(-6.0));
|
||
|
||
// 5. Edit: select bars 5–9 on Snare, cut, undo, verify the clip count is restored.
|
||
let before = track(&session(&mut s), "Snare")["clips"].as_array().unwrap().len();
|
||
tool(
|
||
&mut s,
|
||
"execute",
|
||
json!({"command": "edit.select", "params": {"tracks": ["Snare"], "start": "bars_beats:6|1|000", "end": "bars_beats:9|1|000"}}),
|
||
);
|
||
tool(&mut s, "execute", json!({"command": "edit.cut", "params": {}}));
|
||
let mid = track(&session(&mut s), "Snare")["clips"].as_array().unwrap().len();
|
||
assert_ne!(mid, before);
|
||
tool(&mut s, "execute", json!({"command": "edit.undo", "params": {}}));
|
||
assert_eq!(track(&session(&mut s), "Snare")["clips"].as_array().unwrap().len(), before);
|
||
|
||
// 6. Markers and tempo.
|
||
tool(&mut s, "execute", json!({"command": "markers.add", "params": {"name": "Bridge", "at": {"seconds": 30.0}}}));
|
||
tool(&mut s, "execute", json!({"command": "event.tempo", "params": {"bpm": 110.0}}));
|
||
let sess = session(&mut s);
|
||
assert!(sess["markers"].as_array().unwrap().iter().any(|m| m["name"] == "Bridge"));
|
||
assert_eq!(sess["tempo"][0]["bpm"], json!(110.0));
|
||
|
||
// 7. Volume automation ride on the pad.
|
||
tool(
|
||
&mut s,
|
||
"execute",
|
||
json!({"command": "automation.write_range", "params": {"track": "Pad", "param": "volume", "start": {"seconds": 10.0}, "end": {"seconds": 12.0}, "value": -20.0}}),
|
||
);
|
||
let pad = track(&session(&mut s), "Pad").clone();
|
||
assert!(pad["automation"].as_array().unwrap().iter().any(|l| l["param"] == "volume"));
|
||
|
||
// 8. Bounce 4 seconds and check loudness is sane.
|
||
let dir = std::env::temp_dir().join(format!("soundcraft-agent-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let path = dir.join("bounce.wav");
|
||
let r =
|
||
tool(&mut s, "bounce_mix", json!({"path": path.to_string_lossy(), "start": {"seconds": 10.0}, "end": {"seconds": 14.0}, "bit_depth": "24"}));
|
||
assert!(r["peak_db"].as_f64().unwrap() > -40.0, "{r}");
|
||
let bytes = std::fs::read(&path).unwrap();
|
||
assert_eq!(&bytes[0..4], b"RIFF");
|
||
|
||
// 9. Save, reopen, and the mix survives.
|
||
let sp = dir.join("Agent Mix").join("Agent Mix.scraft");
|
||
std::fs::create_dir_all(sp.parent().unwrap()).unwrap();
|
||
tool(&mut s, "save_session", json!({"path": sp.to_string_lossy()}));
|
||
tool(&mut s, "new_session", json!({}));
|
||
tool(&mut s, "open_session", json!({"path": sp.to_string_lossy()}));
|
||
let sess = session(&mut s);
|
||
assert_eq!(track(&sess, "Kick")["volume_db"], json!(-8.5));
|
||
assert!(sess["sources"].as_array().unwrap().len() >= 5);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn errors_are_reported_not_panics() {
|
||
let mut s = Server::new(Headless::new(Engine::default()));
|
||
let r = rpc(&mut s, 1, "tools/call", json!({"name": "execute", "arguments": {"command": "mix.volume", "params": {"track": "Nope", "db": 0}}}));
|
||
assert_eq!(r["result"]["isError"], true);
|
||
let r = rpc(&mut s, 2, "tools/call", json!({"name": "screenshot", "arguments": {}}));
|
||
assert_eq!(r["result"]["isError"], true);
|
||
}
|
||
|
||
#[test]
|
||
fn batch_keep_going_still_sets_is_error_on_partial_failure() {
|
||
let mut s = Server::new(Headless::new(Engine::default()));
|
||
let r = rpc(
|
||
&mut s,
|
||
1,
|
||
"tools/call",
|
||
json!({"name": "batch", "arguments": {"keep_going": true, "calls": [
|
||
{"command": "track.new", "params": {"count": 1, "name": "Kept"}},
|
||
{"command": "mix.volume", "params": {"track": "Nope", "db": -6}},
|
||
{"command": "track.new", "params": {"count": 1, "name": "After"}},
|
||
]}}),
|
||
);
|
||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||
let text = r["result"]["content"][0]["text"].as_str().unwrap();
|
||
let results: Value = serde_json::from_str(text).unwrap();
|
||
let arr = results.as_array().unwrap();
|
||
assert_eq!(arr.len(), 3, "{results}");
|
||
assert_eq!(arr[0]["ok"], true);
|
||
assert_eq!(arr[1]["ok"], false);
|
||
assert_eq!(arr[2]["ok"], true);
|
||
let sess = session(&mut s);
|
||
assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "Kept"));
|
||
assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "After"));
|
||
}
|
||
|
||
#[test]
|
||
fn batch_rejects_non_array_calls() {
|
||
let mut s = Server::new(Headless::new(Engine::default()));
|
||
let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": {"command": "track.new"}}}));
|
||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||
let text = r["result"]["content"][0]["text"].as_str().unwrap();
|
||
assert!(text.contains("`calls` must be an array"), "{text}");
|
||
assert_eq!(session(&mut s)["tracks"].as_array().unwrap().len(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn batch_rejects_oversize_calls_before_any_mutation() {
|
||
for keep_going in [false, true] {
|
||
let mut s = Server::new(Headless::new(Engine::default()));
|
||
let before = session(&mut s);
|
||
let mut calls = vec![json!({"command": "engine.history"}); 1001];
|
||
calls[0] = json!({"command": "track.new", "params": {"name": "Must not be created"}});
|
||
let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": calls, "keep_going": keep_going}}));
|
||
assert_eq!(session(&mut s), before, "oversize batch mutated the session (keep_going={keep_going})");
|
||
assert!(s.backend.engine.journal.is_empty());
|
||
assert!(!s.backend.engine.can_undo());
|
||
assert_eq!(r["result"]["isError"], true);
|
||
let text = r["result"]["content"][0]["text"].as_str().unwrap();
|
||
assert!(text.contains("1000"), "{text}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn batch_accepts_empty_and_limit_sized_calls_in_order() {
|
||
for count in [0, 1000] {
|
||
let mut s = Server::new(Headless::new(Engine::default()));
|
||
let calls: Vec<_> = (0..count).map(|at| json!({"command": "session.format_time", "params": {"at": at, "format": "samples"}})).collect();
|
||
let results = tool(&mut s, "batch", json!({"calls": calls}));
|
||
let results = results.as_array().unwrap();
|
||
assert_eq!(results.len(), count);
|
||
for (at, result) in results.iter().enumerate() {
|
||
assert_eq!(result["ok"], true);
|
||
assert_eq!(result["result"]["text"], at.to_string());
|
||
}
|
||
}
|
||
}
|