Files
vectorcraft/crates/mcp/src/tests_conventions.rs
T
bflatasticandClaude Opus 5.5 ed80181d70 #1163: command_batch can be one undo step
An agent's build over MCP left one undo step per batch step (about 280
for one job). `command_batch` takes `atomic: true` (and an optional
`label` naming the step) to make the whole batch one undo step. It runs
the batch in an undo group, opened and closed by the new commands
`edit.undoGroup.begin {label?}` and `edit.undoGroup.end {cancel?}`, so
every backend (headless or the connected app) supports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:22:45 +07:00

173 lines
8.5 KiB
Rust

use crate::{Backend, Headless, Server, tool_definitions};
use serde_json::{Value, json};
fn rpc(s: &mut Server, method: &str, params: Value) -> Value {
s.handle(json!({"jsonrpc":"2.0","id":1,"method":method,"params":params})).unwrap()
}
fn call(s: &mut Server, name: &str, arguments: Value) -> Value {
let r = rpc(s, "tools/call", json!({"name":name,"arguments":arguments}));
assert!(r.get("error").is_none(), "{r}");
r["result"].clone()
}
fn payload(r: &Value) -> Value {
serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap()
}
#[test]
fn conventions_catalog_and_strict_keys() {
let mut s = Server::new(Box::new(Headless::with_document()));
let tools = tool_definitions();
for name in [
"command_list",
"command_run",
"command_batch",
"doc_inspect",
"render_preview",
"ui_inspect",
"ui_screenshot",
"list_commands",
"run_command",
] {
assert!(tools.iter().any(|t| t["name"] == name), "missing {name}");
}
for t in tools {
for hint in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] {
assert!(t["annotations"][hint].is_boolean(), "{t}");
}
let r = rpc(&mut s, "tools/call", json!({"name":t["name"],"arguments":{"typo":1}}));
assert_eq!(r["error"]["code"], -32602, "{r}");
}
assert_eq!(rpc(&mut s, "ping", json!({}))["result"], json!({}));
}
#[test]
fn conventions_commands_batch_and_bounded_preview() {
let mut s = Server::new(Box::new(Headless::with_document()));
let list = call(&mut s, "command_list", json!({"filter":"shape.rectangle","enabled_only":true}));
assert_eq!(list["isError"], false, "{list}");
assert!(payload(&list).as_array().unwrap().iter().any(|c| c["id"] == "shape.rectangle"));
let steps = json!([{"id":"shape.rectangle","params":{"x":10,"y":20,"width":30,"height":40}}, {"id":"no.such.command"}, {"id":"shape.rectangle","params":{"x":0,"y":0,"width":20,"height":20}}]);
for (stop, total) in [(true, 2), (false, 3)] {
let r = call(&mut s, "command_batch", json!({"steps":steps,"stop_on_error":stop}));
assert_eq!(r["isError"], true, "{r}");
let p = payload(&r);
assert_eq!(p["results"].as_array().unwrap().len(), total);
assert_eq!(p["failed"], 1);
assert_eq!(p["completed"], total - 1);
}
// atomic: the whole batch is one undo step, named by `label` (#1163).
let undo_steps = |s: &mut Server| payload(&call(s, "doc_inspect", json!({"depth":0})))["history"].as_array().map_or(0, Vec::len);
let before = undo_steps(&mut s);
let two = json!([{"id":"shape.rectangle","params":{"x":0,"y":0,"width":5,"height":5}}, {"id":"shape.ellipse","params":{"x":9,"y":0,"width":5,"height":5}}]);
let r = call(&mut s, "command_batch", json!({"steps":two,"atomic":true,"label":"Agent build"}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(undo_steps(&mut s), before + 1);
assert_eq!(payload(&call(&mut s, "run_command", json!({"command":"edit.undo"})))["undone"], "Agent build");
assert_eq!(undo_steps(&mut s), before);
for bad in [json!({"steps":two,"label":"x"}), json!({"steps":two,"atomic":"yes"}), json!({"steps":two,"atomic":true,"label":""})] {
assert_eq!(call(&mut s, "command_batch", bad.clone())["isError"], true, "{bad}");
}
let before = call(&mut s, "doc_inspect", json!({}));
let r = call(&mut s, "render_preview", json!({"max_side":64}));
assert_eq!(r["isError"], false, "{r}");
let png = vectorcraft_format::base64_decode(r["content"][0]["data"].as_str().unwrap()).unwrap();
let img = image::load_from_memory(&png).unwrap();
assert!(img.width() <= 64 && img.height() <= 64);
assert_eq!(before, call(&mut s, "doc_inspect", json!({})));
for max in [json!(0), json!(4097), json!("64")] {
assert_eq!(call(&mut s, "render_preview", json!({"max_side":max}))["isError"], true);
}
// Another artboard, three times wider than tall, previews by its index (#1163).
let r = call(&mut s, "run_command", json!({"command":"artboard.new","params":{"x":2000,"y":0,"width":300,"height":100}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, "render_preview", json!({"max_side":60,"artboard":1}));
assert_eq!(r["isError"], false, "{r}");
let png = vectorcraft_format::base64_decode(r["content"][0]["data"].as_str().unwrap()).unwrap();
let img = image::load_from_memory(&png).unwrap();
assert_eq!((img.width(), img.height()), (60, 20));
for board in [json!(2), json!(-1), json!("1")] {
assert_eq!(call(&mut s, "render_preview", json!({"artboard":board}))["isError"], true, "{board}");
}
for name in ["ui_inspect", "ui_screenshot"] {
assert_eq!(call(&mut s, name, json!({}))["isError"], true);
}
}
struct Panics;
impl Backend for Panics {
fn call(&mut self, _: &str, _: Value) -> Result<Value, String> {
panic!("backend bug")
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"test".into()
}
}
#[test]
fn conventions_tool_panics_are_tool_errors() {
let mut s = Server::new(Box::new(Panics));
let r = call(&mut s, "list_commands", json!({}));
assert_eq!(r["isError"], true);
assert!(r["content"][0]["text"].as_str().unwrap().contains("backend bug"));
assert_eq!(rpc(&mut s, "ping", json!({}))["result"], json!({}));
}
#[test]
fn conventions_resources_and_versioned_results() {
let mut s = Server::new(Box::new(Headless::with_document()));
let modern = json!({"io.modelcontextprotocol/protocolVersion":"2026-07-28"});
for (method, extra, ttl) in [
("tools/list", json!({}), 600_000),
("resources/list", json!({}), 600_000),
("resources/templates/list", json!({}), 600_000),
("prompts/list", json!({}), 600_000),
("resources/read", json!({"uri":"vectorcraft://document"}), 0),
("resources/read", json!({"uri":"vectorcraft://commands"}), 0),
] {
let legacy = rpc(&mut s, method, extra.clone());
assert!(legacy["result"].is_object(), "{legacy}");
assert!(legacy["result"].get("resultType").is_none());
let mut params = extra.clone();
params["_meta"] = modern.clone();
let r = rpc(&mut s, method, params);
assert_eq!(r["result"]["resultType"], "complete", "{r}");
assert_eq!(r["result"]["ttlMs"], ttl);
assert_eq!(r["result"]["cacheScope"], "private");
let mut stripped = r["result"].clone();
for key in ["resultType", "ttlMs", "cacheScope"] {
stripped.as_object_mut().unwrap().remove(key);
}
assert_eq!(stripped, legacy["result"]);
assert!(rpc(&mut s, method, extra)["result"].get("resultType").is_none());
}
let r = rpc(&mut s, "resources/list", json!({}));
assert!(r["result"]["resources"].as_array().unwrap().iter().any(|r| r["uri"] == "vectorcraft://commands"));
let commands = rpc(&mut s, "resources/read", json!({"uri":"vectorcraft://commands"}));
let commands: Value = serde_json::from_str(commands["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(commands, payload(&call(&mut s, "command_list", json!({}))));
assert_eq!(rpc(&mut s, "initialize", json!({"protocolVersion":"2026-07-28"}))["result"]["protocolVersion"], "2026-07-28");
assert_eq!(rpc(&mut s, "tools/list", json!({}))["result"]["resultType"], "complete");
rpc(&mut s, "initialize", json!({"protocolVersion":"2025-06-18"}));
assert!(rpc(&mut s, "tools/list", json!({}))["result"].get("resultType").is_none());
}
/// #785: a batch step can use an earlier step's result (`"$N.path"`); a reference to nothing
/// fails that step.
#[test]
fn batch_steps_refer_to_earlier_results() {
let mut s = Server::new(Box::new(Headless::with_document()));
let steps = json!([
{"id":"text.create","params":{"x":10,"y":60,"text":"plain and bold words","size":30}},
{"id":"shape.rectangle","params":{"x":0,"y":0,"width":20,"height":20}},
{"id":"text.setRangeStyle","params":{"id":"$0.id","start":10,"end":14,"style":"Bold"}},
{"id":"text.setRangeStyle","params":{"id":"$9.id","start":0,"end":1,"style":"Bold"}}
]);
let r = call(&mut s, "command_batch", json!({"steps":steps,"stop_on_error":false}));
let p = payload(&r);
assert_eq!((p["completed"].clone(), p["failed"].clone()), (json!(3), json!(1)), "{p}");
assert_eq!(p["results"][2]["result"]["id"], p["results"][0]["result"]["id"], "the text, not the selected rectangle");
assert!(p["results"][3]["error"].as_str().unwrap().contains("$9.id"), "{p}");
}