1523 lines
78 KiB
Rust
1523 lines
78 KiB
Rust
//! An agent builds parts through MCP only and verifies them through MCP only.
|
||
|
||
use std::f64::consts::PI;
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::{Headless, PROTOCOL_VERSION, Server, tool_definitions};
|
||
|
||
fn server() -> Server {
|
||
Server::new(Box::new(Headless::default()))
|
||
}
|
||
|
||
/// Screenshots are drawn on the GPU by the renderer the host installs (`solidcraft-cli mcp`); the
|
||
/// tests install the same one. `false`, with the reason printed, on a machine without a GPU
|
||
/// adapter: the screenshots are then left out of the test.
|
||
fn renders() -> bool {
|
||
match solidcraft_gpu::capture::shared() {
|
||
Ok(c) => {
|
||
solidcraft_engine::capture::install(std::sync::Arc::new(move |scene, cam, w, h| {
|
||
c.rgba(scene, cam, w, h).map(|rgba| solidcraft_engine::capture::Picture { rgba, renderer: c.renderer() }).map_err(|e| e.to_string())
|
||
}));
|
||
true
|
||
}
|
||
Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
|
||
eprintln!("screenshots left out: {e}");
|
||
false
|
||
}
|
||
Err(e) => panic!("{e}"),
|
||
}
|
||
}
|
||
|
||
fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
|
||
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
|
||
let r = s.handle_line(&line).expect("reply");
|
||
serde_json::from_str(&r).unwrap()
|
||
}
|
||
|
||
fn raw_tool(s: &mut Server, name: &str, args: Value) -> Value {
|
||
call(s, 99, "tools/call", json!({"name": name, "arguments": args}))
|
||
}
|
||
|
||
/// Call a tool that must succeed; returns its JSON text (or the first content block).
|
||
fn tool(s: &mut Server, name: &str, args: Value) -> Value {
|
||
let r = raw_tool(s, name, args);
|
||
let c = &r["result"]["content"][0];
|
||
assert_ne!(r["result"]["isError"], json!(true), "{name} failed: {c}");
|
||
assert!(r.get("error").is_none(), "{name}: {r}");
|
||
if c["type"] == "text" { serde_json::from_str(c["text"].as_str().unwrap()).unwrap_or(Value::Null) } else { c.clone() }
|
||
}
|
||
|
||
#[test]
|
||
fn crash_reports_are_listed_and_read_without_changing_the_design() {
|
||
let mut s = server();
|
||
let list = tool(&mut s, "crash_reports", json!({"limit": 3}));
|
||
assert!(list["reports"].as_array().is_some_and(|r| r.len() <= 3), "{list}");
|
||
assert!(list["dir"].is_string(), "{list}");
|
||
let r = raw_tool(&mut s, "crash_reports", json!({"id": "../../etc"}));
|
||
assert_eq!(r["result"]["isError"], json!(true), "{r}");
|
||
let r = raw_tool(&mut s, "crash_reports", json!({"id": 5}));
|
||
assert!(r.get("error").is_some() || r["result"]["isError"] == json!(true), "{r}");
|
||
}
|
||
|
||
fn volume(s: &mut Server) -> f64 {
|
||
tool(s, "measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
|
||
}
|
||
|
||
fn rel(a: f64, b: f64) -> f64 {
|
||
(a - b).abs() / b.abs().max(1e-12)
|
||
}
|
||
|
||
#[test]
|
||
fn handshake_and_tools_list() {
|
||
let mut s = server();
|
||
let init =
|
||
call(&mut s, 1, "initialize", json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}}));
|
||
assert_eq!(init["result"]["protocolVersion"], "2025-03-26");
|
||
assert_eq!(init["result"]["serverInfo"]["name"], "solidcraft");
|
||
assert!(init["result"]["capabilities"]["tools"].is_object());
|
||
assert!(init["result"]["instructions"].as_str().unwrap().contains("headless"));
|
||
// An unknown version gets ours.
|
||
let init = call(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"}));
|
||
assert_eq!(init["result"]["protocolVersion"], PROTOCOL_VERSION);
|
||
assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none());
|
||
assert!(s.is_initialized());
|
||
assert_eq!(call(&mut s, 3, "ping", json!({}))["result"], json!({}));
|
||
|
||
let tools = call(&mut s, 4, "tools/list", json!({}));
|
||
let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||
for want in [
|
||
"list_commands",
|
||
"execute",
|
||
"batch",
|
||
"inspect_design",
|
||
"measure",
|
||
"body_topology",
|
||
"set_parameter",
|
||
"screenshot",
|
||
"export",
|
||
"undo",
|
||
"redo",
|
||
"crash_reports",
|
||
"new_design",
|
||
"open",
|
||
"save",
|
||
] {
|
||
assert!(names.contains(&want), "{want} missing from {names:?}");
|
||
}
|
||
for t in tools["result"]["tools"].as_array().unwrap() {
|
||
assert_eq!(t["inputSchema"]["type"], "object", "{t}");
|
||
assert!(t["description"].as_str().unwrap().len() > 20, "{t}");
|
||
}
|
||
|
||
let cmds = tool(&mut s, "list_commands", json!({"filter": "fillet"}));
|
||
assert!(cmds.as_array().unwrap().iter().any(|c| c["id"] == "solid.fillet"), "{cmds}");
|
||
assert!(cmds.as_array().unwrap().len() < 5);
|
||
|
||
let res = call(&mut s, 5, "resources/list", json!({}));
|
||
assert_eq!(res["result"]["resources"].as_array().unwrap().len(), 2);
|
||
let d = call(&mut s, 6, "resources/read", json!({"uri": "solidcraft://design"}));
|
||
assert!(d["result"]["contents"][0]["text"].as_str().unwrap().contains("timeline"));
|
||
assert_eq!(call(&mut s, 7, "resources/read", json!({"uri": "solidcraft://nope"}))["error"]["code"], -32002);
|
||
assert_eq!(call(&mut s, 8, "nope/nope", json!({}))["error"]["code"], -32601);
|
||
}
|
||
|
||
/// A URDF goes through `export` with its own options (#719).
|
||
#[test]
|
||
fn export_tool_writes_a_urdf_package() {
|
||
let mut s = server();
|
||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}));
|
||
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-urdf-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let urdf = dir.join("robot.urdf");
|
||
let r = tool(&mut s, "export", json!({"path": urdf.to_string_lossy(), "mesh_prefix": "package://robot/meshes/"}));
|
||
assert_eq!(r["format"], "Urdf", "{r}");
|
||
assert!(std::fs::read_to_string(&urdf).unwrap().contains(r#"filename="package://robot/meshes/robot-base.stl""#));
|
||
assert!(dir.join("robot-base.stl").exists());
|
||
// effort and velocity reach file.export, which refuses a bad one.
|
||
let r = raw_tool(&mut s, "export", json!({"path": urdf.to_string_lossy(), "effort": -1}));
|
||
assert_eq!(r["result"]["isError"], json!(true), "{r}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Box → fillet → hole in one batch, then verify with measure / inspect_design and look at it.
|
||
#[test]
|
||
fn agent_builds_box_fillet_hole() {
|
||
let mut s = server();
|
||
let r = tool(
|
||
&mut s,
|
||
"batch",
|
||
json!({"commands": [
|
||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}},
|
||
{"command": "sketch.finish"},
|
||
{"command": "solid.extrude", "params": {"distance": 20}},
|
||
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}},
|
||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||
{"command": "sketch.circle.center", "params": {"center": [20, 15], "radius": 5}},
|
||
{"command": "solid.extrude", "params": {"distance": 20, "operation": "cut"}},
|
||
]}),
|
||
);
|
||
assert_eq!(r["completed"], 8, "{r}");
|
||
let expect = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0 - PI * 25.0 * 20.0;
|
||
let v = volume(&mut s);
|
||
assert!(rel(v, expect) < 5e-4, "{v} vs {expect}");
|
||
let m = tool(&mut s, "measure", json!({"body": "Body1"}));
|
||
// 6 box faces + fillet + hole wall (the kernel may keep the wall as two halves).
|
||
assert!((8..=9).contains(&m["bodies"][0]["faces"].as_u64().unwrap()), "{m}");
|
||
assert!(rel(m["bodies"][0]["bbox"]["max"][0].as_f64().unwrap(), 40.0) < 1e-6, "{m}");
|
||
|
||
let d = tool(&mut s, "inspect_design", json!({}));
|
||
assert_eq!(d["timeline"].as_array().unwrap().len(), 5, "{d}");
|
||
assert!(d["timeline"].as_array().unwrap().iter().all(|f| f["error"].is_null()), "{d}");
|
||
assert_eq!(d["sketches"][0]["curves"].as_array().unwrap().len(), 4, "{d}");
|
||
assert!(d["sketches"][0]["status"].is_string(), "{d}");
|
||
assert!(d["bodies"][0]["volume_mm3"].as_f64().unwrap() > 0.0);
|
||
|
||
let topo = tool(&mut s, "body_topology", json!({"body": "Body1"}));
|
||
assert!(topo["edges"].as_array().unwrap().len() >= 12, "{topo}");
|
||
assert!(topo["faces"].as_array().unwrap().len() >= 8, "{topo}");
|
||
|
||
// Undo the hole, redo it.
|
||
tool(&mut s, "undo", json!({}));
|
||
assert!(rel(volume(&mut s), 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0) < 5e-4);
|
||
tool(&mut s, "redo", json!({}));
|
||
assert!(rel(volume(&mut s), expect) < 5e-4);
|
||
|
||
// Look at it from the top: a real PNG comes back as an image block.
|
||
if renders() {
|
||
let img = tool(&mut s, "screenshot", json!({"view": "top", "width": 320, "height": 200}));
|
||
assert_eq!(img["type"], "image");
|
||
assert_eq!(img["mimeType"], "image/png");
|
||
assert!(img["data"].as_str().unwrap().starts_with("iVBORw0KGgo"), "not a PNG");
|
||
}
|
||
|
||
// Export, save, start over, open.
|
||
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-test-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let step = dir.join("part.step");
|
||
tool(&mut s, "export", json!({"path": step.to_string_lossy()}));
|
||
assert!(std::fs::read_to_string(&step).unwrap().contains("ISO-10303-21"));
|
||
let stl = dir.join("part.stl");
|
||
tool(&mut s, "export", json!({"path": stl.to_string_lossy(), "format": "stl"}));
|
||
assert!(std::fs::metadata(&stl).unwrap().len() > 1000);
|
||
let shot = dir.join("shot.png");
|
||
if renders() {
|
||
tool(&mut s, "screenshot", json!({"path": shot.to_string_lossy(), "width": 64, "height": 64}));
|
||
assert!(std::fs::metadata(&shot).unwrap().len() > 100);
|
||
}
|
||
let design = dir.join("part.solidcraft");
|
||
tool(&mut s, "save", json!({"path": design.to_string_lossy()}));
|
||
tool(&mut s, "new_design", json!({"name": "Empty"}));
|
||
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 0);
|
||
tool(&mut s, "open", json!({"path": design.to_string_lossy()}));
|
||
assert!(rel(volume(&mut s), expect) < 5e-4);
|
||
// A STEP file opens as a new design with the same body.
|
||
let r = tool(&mut s, "open", json!({"path": step.to_string_lossy()}));
|
||
assert_eq!(r["features"], 1, "{r}");
|
||
assert!(rel(volume(&mut s), expect) < 5e-4);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// `open` hands `up` and `names` to the STEP import, and the tool's schema refuses other values.
|
||
#[test]
|
||
fn open_stands_a_y_up_step_file_up() {
|
||
let mut s = server();
|
||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 40, "height": 5}}));
|
||
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-up-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let step = dir.join("tall.step").to_string_lossy().to_string();
|
||
tool(&mut s, "export", json!({"path": step}));
|
||
let height = |s: &mut Server| {
|
||
let bbox = tool(s, "measure", json!({}))["bodies"][0]["bbox"].clone();
|
||
bbox["max"][2].as_f64().unwrap() - bbox["min"][2].as_f64().unwrap()
|
||
};
|
||
|
||
let r = tool(&mut s, "open", json!({"path": step}));
|
||
assert_eq!(r["features"], 1, "{r}");
|
||
assert!((height(&mut s) - 5.0).abs() < 1e-6, "as written");
|
||
let r = tool(&mut s, "open", json!({"path": step, "up": "y", "names": "product"}));
|
||
assert_eq!(r["features"], 1, "the import, its component turned: {r}");
|
||
assert!((height(&mut s) - 40.0).abs() < 1e-6, "Y became Z");
|
||
|
||
for args in [json!({"path": step, "up": "x"}), json!({"path": step, "names": "label"}), json!({"path": step, "up": 1})] {
|
||
let r = raw_tool(&mut s, "open", args.clone());
|
||
assert_eq!(r["error"]["code"], -32602, "{args}: {r}");
|
||
}
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn save_selects_container_or_json_and_reopens_by_contents() {
|
||
let mut s = server();
|
||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 20, "height": 30}}));
|
||
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-encodings-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
for (name, format, is_json) in [
|
||
("default.solidcraft", None, false),
|
||
("explicit.solidcraft", Some("json"), true),
|
||
("legacy.solvecraft", None, false),
|
||
("inferred.json", None, true),
|
||
("container.json", Some("container"), false),
|
||
] {
|
||
let path = dir.join(name);
|
||
let mut args = json!({"path": path.to_string_lossy()});
|
||
if let Some(format) = format {
|
||
args["format"] = json!(format);
|
||
}
|
||
tool(&mut s, "save", args);
|
||
let bytes = std::fs::read(&path).unwrap();
|
||
if is_json {
|
||
assert!(serde_json::from_slice::<Value>(&bytes).unwrap().is_object());
|
||
} else {
|
||
assert!(bytes.starts_with(b"PK\x03\x04"));
|
||
}
|
||
tool(&mut s, "new_design", json!({}));
|
||
tool(&mut s, "open", json!({"path": path.to_string_lossy()}));
|
||
assert!(rel(volume(&mut s), 6000.0) < 1e-9);
|
||
}
|
||
std::fs::remove_dir_all(&dir).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn parameters_drive_the_design() {
|
||
let mut s = server();
|
||
tool(&mut s, "set_parameter", json!({"name": "len", "value": "40 mm"}));
|
||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": "len", "width": 30, "height": "len / 2", "corner": [0, 0, 0]}}));
|
||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 20.0) < 1e-9);
|
||
let r = tool(&mut s, "set_parameter", json!({"name": "len", "value": 50}));
|
||
assert_eq!(r["value"], 50.0, "{r}");
|
||
assert_eq!(r["errors"].as_array().unwrap().len(), 0, "{r}");
|
||
assert!(rel(volume(&mut s), 50.0 * 30.0 * 25.0) < 1e-9);
|
||
let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
|
||
assert!(d["params"].as_array().unwrap().iter().any(|p| p["name"] == "len" && p["value"] == 50.0), "{d}");
|
||
}
|
||
|
||
/// Design properties (#1015) are set through `execute` and come back in `inspect_design`.
|
||
#[test]
|
||
fn design_properties_show_in_inspect_design() {
|
||
let mut s = server();
|
||
let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
|
||
assert_eq!(d["metadata"], json!({}), "{d}");
|
||
let params = json!({"values": {"author": "Agent", "com.example.agent": {"session": 7}}});
|
||
tool(&mut s, "execute", json!({"command": "document.metadata.set", "params": params}));
|
||
let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
|
||
assert_eq!(d["metadata"], json!({"author": "Agent", "com.example.agent": {"session": 7}}), "{d}");
|
||
}
|
||
|
||
#[test]
|
||
fn batch_stops_at_first_error_and_rolls_back() {
|
||
let mut s = server();
|
||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}));
|
||
let r = raw_tool(
|
||
&mut s,
|
||
"batch",
|
||
json!({"commands": [
|
||
{"command": "solid.sphere", "params": {"radius": 3, "center": [50, 0, 0]}},
|
||
{"command": "solid.extrude", "params": {"distance": "bogus +"}},
|
||
{"command": "solid.sphere", "params": {"radius": 3, "center": [80, 0, 0]}},
|
||
]}),
|
||
);
|
||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||
let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||
assert_eq!(body["failed"]["index"], 1, "{body}");
|
||
assert_eq!(body["failed"]["command"], "solid.extrude");
|
||
assert_eq!(body["undone"], 1, "{body}");
|
||
assert_eq!(body["results"].as_array().unwrap().len(), 1);
|
||
// Only the first box is left, and its undo step is intact.
|
||
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 1);
|
||
assert_eq!(tool(&mut s, "inspect_design", json!({"measure": false}))["undo"], 1);
|
||
|
||
// Without rollback the completed steps stay.
|
||
let r = raw_tool(
|
||
&mut s,
|
||
"batch",
|
||
json!({"rollback": false, "commands": [{"command": "solid.sphere", "params": {"radius": 3, "center": [50, 0, 0]}}, {"command": "NoSuchCommand"}]}),
|
||
);
|
||
assert_eq!(r["result"]["isError"], true);
|
||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("NoSuchCommand"));
|
||
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 2);
|
||
}
|
||
|
||
/// Regression: with a full undo history a failed batch reported `undone: 0` and left its
|
||
/// sketches behind (an empty "Sketch1", two sketches named "TapHole").
|
||
#[test]
|
||
fn batch_rolls_back_with_a_full_undo_history() {
|
||
let mut s = server();
|
||
let fill: Vec<Value> =
|
||
(0..230).map(|i| json!({"command": "parameters.add", "params": {"name": format!("p{i}"), "expression": "1 mm"}})).collect();
|
||
tool(&mut s, "batch", json!({"commands": fill}));
|
||
let undo = tool(&mut s, "inspect_design", json!({"measure": false}))["undo"].clone();
|
||
for _ in 0..2 {
|
||
let r = raw_tool(
|
||
&mut s,
|
||
"batch",
|
||
json!({"commands": [
|
||
{"command": "sketch.create", "params": {"plane": "XY", "name": "TapHole"}},
|
||
{"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 2}},
|
||
{"command": "sketch.dimension", "params": {"entities": ["nope"], "value": 3}},
|
||
]}),
|
||
);
|
||
let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||
assert_eq!(body["failed"]["index"], 2, "{body}");
|
||
assert_eq!(body["undone"], 2, "{body}");
|
||
}
|
||
let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
|
||
assert_eq!(d["sketches"].as_array().map(Vec::len), Some(0), "{d}");
|
||
assert!(d["active_sketch"].is_null(), "{d}");
|
||
assert_eq!(d["undo"], undo);
|
||
}
|
||
|
||
#[test]
|
||
fn helper_sketches_can_be_hidden() {
|
||
let mut s = server();
|
||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 40, "height": 2}}));
|
||
let sk = tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}))["sketch"].clone();
|
||
tool(&mut s, "execute", json!({"command": "sketch.line", "params": {"points": [[0, 0], [40, 0]], "construction": true}}));
|
||
tool(&mut s, "execute", json!({"command": "sketch.finish"}));
|
||
let visible = |s: &mut Server| tool(s, "inspect_design", json!({"measure": false}))["sketches"][0]["visible"].clone();
|
||
assert_eq!(visible(&mut s), true);
|
||
tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": [sk], "visible": false}}));
|
||
assert_eq!(visible(&mut s), false);
|
||
let r = raw_tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": ["Nope"], "visible": false}}));
|
||
assert_eq!(r["result"]["isError"], true);
|
||
tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"folder": "sketches", "visible": true}}));
|
||
assert_eq!(visible(&mut s), true);
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_tools_and_bad_arguments_are_clean_errors() {
|
||
let mut s = server();
|
||
let r = raw_tool(&mut s, "make_coffee", json!({}));
|
||
assert_eq!(r["error"]["code"], -32602, "{r}");
|
||
assert!(r["error"]["message"].as_str().unwrap().contains("make_coffee"));
|
||
assert_eq!(raw_tool(&mut s, "execute", json!({}))["error"]["code"], -32602);
|
||
assert_eq!(raw_tool(&mut s, "execute", json!({"command": 5}))["error"]["code"], -32602);
|
||
assert_eq!(raw_tool(&mut s, "measure", json!({"bogus": 1}))["error"]["code"], -32602);
|
||
assert_eq!(raw_tool(&mut s, "screenshot", json!({"source": "sideways"}))["error"]["code"], -32602);
|
||
// `view` also takes saved named views, so an unknown name is a tool error naming it.
|
||
let r = raw_tool(&mut s, "screenshot", json!({"view": "sideways"}));
|
||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("sideways"), "{r}");
|
||
assert_eq!(call(&mut s, 1, "tools/call", json!({"arguments": {}}))["error"]["code"], -32602);
|
||
// Engine errors are tool errors the model can read.
|
||
let r = raw_tool(&mut s, "execute", json!({"command": "solid.extrude", "params": {"distance": 10}}));
|
||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||
let r = raw_tool(&mut s, "execute", json!({"command": "NoSuchCommand"}));
|
||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("unknown command"));
|
||
// Framing errors.
|
||
assert!(s.handle_line("{not json").unwrap().contains("-32700"));
|
||
assert!(s.handle_line("[]").unwrap().contains("-32600"));
|
||
assert!(s.handle_line("42").unwrap().contains("-32600"));
|
||
assert!(s.handle_line(" ").is_none());
|
||
assert!(s.handle_line(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#).is_none());
|
||
let both = s.handle_line(r#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/x"}]"#).unwrap();
|
||
assert_eq!(serde_json::from_str::<Value>(&both).unwrap().as_array().unwrap().len(), 1);
|
||
}
|
||
|
||
fn hostile_values() -> Vec<Value> {
|
||
vec![
|
||
Value::Null,
|
||
json!(true),
|
||
json!(-1),
|
||
json!(0),
|
||
json!(1e308),
|
||
json!(-1e308),
|
||
json!(""),
|
||
json!("nope"),
|
||
json!("../../../../../../nonexistent/dir/x.step"),
|
||
json!("1/0"),
|
||
json!([]),
|
||
json!([1e308, -1e308]),
|
||
json!([{"command": "solid.extrude", "params": {"distance": 1e308}}]),
|
||
json!([{"command": 7}, null, "x"]),
|
||
json!({}),
|
||
json!({"x": 1}),
|
||
]
|
||
}
|
||
|
||
/// Every tool, every argument it documents, every hostile value: replies are well-formed
|
||
/// JSON-RPC and nothing panics.
|
||
#[test]
|
||
fn hostile_arguments_never_panic() {
|
||
let mut s = server();
|
||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}));
|
||
let defs = tool_definitions();
|
||
let mut n = 0;
|
||
for def in &defs {
|
||
let name = def["name"].as_str().unwrap();
|
||
// Hostile strings are relative paths: don't let the sweep write files into the crate.
|
||
let writes = matches!(name, "save" | "screenshot" | "export");
|
||
let keys: Vec<String> = def["inputSchema"]["properties"].as_object().unwrap().keys().filter(|k| !(writes && *k == "path")).cloned().collect();
|
||
for k in &keys {
|
||
for v in hostile_values() {
|
||
let mut args = json!({});
|
||
// Fill the other required arguments with something plausible.
|
||
for r in def["inputSchema"]["required"].as_array().into_iter().flatten() {
|
||
args[r.as_str().unwrap()] = json!("Body1");
|
||
}
|
||
args[k.as_str()] = v;
|
||
if name == "screenshot" && (k == "width" || k == "height") {
|
||
args["view"] = json!("front");
|
||
}
|
||
let r = raw_tool(&mut s, name, args.clone());
|
||
assert!(r.get("result").is_some() || r["error"]["code"].is_i64(), "{name} {args}: {r}");
|
||
n += 1;
|
||
}
|
||
}
|
||
}
|
||
assert!(n > 100);
|
||
// Execute every command with hostile params through MCP too.
|
||
let cmds = tool(&mut s, "list_commands", json!({}));
|
||
for c in cmds.as_array().unwrap() {
|
||
let id = c["id"].as_str().unwrap();
|
||
if matches!(id, "doc.open" | "file.save" | "file.save_as" | "file.export" | "file.save_mesh") {
|
||
continue;
|
||
}
|
||
for v in hostile_values() {
|
||
let r = raw_tool(&mut s, "execute", json!({"command": id, "params": {"distance": v.clone(), "edges": v.clone(), "plane": v}}));
|
||
assert!(r.get("result").is_some(), "{id}: {r}");
|
||
}
|
||
}
|
||
// Arguments that are not an object.
|
||
for v in hostile_values().into_iter().filter(|v| !v.is_object() && !v.is_null()) {
|
||
let r = call(&mut s, 1, "tools/call", json!({"name": "measure", "arguments": v}));
|
||
assert_eq!(r["error"]["code"], -32602, "{r}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn base64_matches_rfc4648() {
|
||
use crate::tools::base64;
|
||
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] {
|
||
assert_eq!(base64(i.as_bytes()), o);
|
||
}
|
||
}
|
||
|
||
/// Filtering by a command id returns that command, not every command whose docs mention it (#269).
|
||
#[test]
|
||
fn list_commands_filter_by_a_command_id_matches_ids() {
|
||
let mut s = server();
|
||
let cmds = tool(&mut s, "list_commands", json!({"filter": "sketch.delete"}));
|
||
let ids: Vec<&str> = cmds.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
|
||
assert!(ids.contains(&"sketch.delete"), "{ids:?}");
|
||
assert!(ids.iter().all(|id| id.contains("sketch.delete")), "{} commands matched: {ids:?}", ids.len());
|
||
}
|
||
|
||
/// Compatibility shims (docs/compatibility.md): a design saved before the rename (legacy
|
||
/// extension and format tag) opens through MCP, and the legacy resource URIs still read.
|
||
#[test]
|
||
fn legacy_design_files_and_resource_uris() {
|
||
let mut s = server();
|
||
let saved = String::from_utf8(solidcraft_engine::io::write_design(&solidcraft_engine::doc::Document::default())).unwrap();
|
||
assert!(saved.contains("\"solidcraft/"), "{saved}");
|
||
let legacy = saved.replace("\"solidcraft/", "\"solvecraft/");
|
||
let path = std::env::temp_dir().join(format!("solidcraft-mcp-legacy-{}.solvecraft", std::process::id()));
|
||
std::fs::write(&path, legacy).unwrap();
|
||
tool(&mut s, "open", json!({"path": path.to_string_lossy()}));
|
||
let _ = std::fs::remove_file(&path);
|
||
let d = call(&mut s, 1, "resources/read", json!({"uri": "solvecraft://design"}));
|
||
assert!(d["result"]["contents"][0]["text"].as_str().unwrap().contains("timeline"));
|
||
}
|
||
|
||
/// The default folders (#205): the recovery folder works out of the box, `screenshot` without a
|
||
/// `path` uses the server's own scratch folder, and the open design's folder counts while the
|
||
/// design is open there, without its hidden files. The working folder doesn't count at all.
|
||
#[test]
|
||
fn the_default_folders_are_the_designs_the_recovery_folder_and_the_scratch_folder() {
|
||
let base = solidcraft_engine::io::vfs::temp_dir().join(format!("solidcraft-mcp-defaults-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
let (proj, other, recovery) = (base.join("proj"), base.join("other"), base.join("recovery"));
|
||
std::fs::create_dir_all(&proj).unwrap();
|
||
std::fs::create_dir_all(&other).unwrap();
|
||
std::fs::write(proj.join(".env"), "TOKEN=secret").unwrap();
|
||
let at = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().to_string();
|
||
let scope = crate::agent_scope_with(Some(recovery.clone()), []);
|
||
let cwd = std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap();
|
||
assert!(!scope.roots().contains(&cwd), "the working folder is not a default folder: {scope:?}");
|
||
assert!(scope.roots().contains(&std::fs::canonicalize(&recovery).unwrap()), "{scope:?}");
|
||
assert!(scope.roots().contains(&std::fs::canonicalize(crate::scratch_dir().unwrap()).unwrap()), "{scope:?}");
|
||
|
||
let mut session = solidcraft_engine::Session::default();
|
||
session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||
session.execute("file.save_as", &json!({"path": at(&proj, "part.solidcraft")})).unwrap();
|
||
let mut s = Server::new(Box::new(Headless::new(session))).with_path_scope(Some(scope));
|
||
// Legitimate work in the design's folder: export, save, open, screenshots, the recovery folder.
|
||
tool(&mut s, "export", json!({"path": at(&proj, "part.step")}));
|
||
tool(&mut s, "execute", json!({"command": "parameters.export", "params": {"path": at(&proj, "params.csv")}}));
|
||
tool(&mut s, "save", json!({"path": at(&proj, "copy.solidcraft")}));
|
||
tool(&mut s, "open", json!({"path": at(&proj, "part.solidcraft")}));
|
||
if renders() {
|
||
tool(&mut s, "screenshot", json!({"width": 32, "height": 32}));
|
||
}
|
||
tool(&mut s, "execute", json!({"command": "doc.recovery_list", "params": {"dir": recovery.to_string_lossy()}}));
|
||
assert!(proj.join("part.step").exists() && proj.join("copy.solidcraft").exists());
|
||
// Hidden files in it, and folders beside it, stay out of reach.
|
||
for (name, args) in [
|
||
("export", json!({"path": at(&proj, ".env")})),
|
||
("execute", json!({"command": "parameters.import", "params": {"path": at(&proj, ".env")}})),
|
||
("export", json!({"path": at(&proj, ".git/x.step")})),
|
||
("export", json!({"path": at(&other, "x.step")})),
|
||
] {
|
||
let r = raw_tool(&mut s, name, args);
|
||
assert_eq!(r["result"]["isError"], json!(true), "{name}: {r}");
|
||
}
|
||
assert_eq!(std::fs::read_to_string(proj.join(".env")).unwrap(), "TOKEN=secret");
|
||
assert_eq!(std::fs::read_dir(&other).unwrap().count(), 0);
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
|
||
/// Tool calls reach files only inside the server's folders (#205): the usual workflow (save,
|
||
/// export, open, screenshots) works there; a path outside, directly, through `..` or through a
|
||
/// symlink that leads out, is refused with a clear error and nothing is written.
|
||
#[test]
|
||
fn tool_calls_stay_inside_the_agents_folders() {
|
||
use solidcraft_engine::io::vfs::PathScope;
|
||
let base = solidcraft_engine::io::vfs::temp_dir().join(format!("solidcraft-mcp-scope-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
let (root, out) = (base.join("root"), base.join("out"));
|
||
std::fs::create_dir_all(&root).unwrap();
|
||
std::fs::create_dir_all(&out).unwrap();
|
||
let at = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().to_string();
|
||
let defaults = crate::agent_scope_with(None, [root.clone()]);
|
||
assert!(defaults.roots().contains(&std::fs::canonicalize(&root).unwrap()), "{defaults:?}");
|
||
assert!(!defaults.roots().contains(&std::fs::canonicalize(std::env::temp_dir()).unwrap()), "{defaults:?}");
|
||
// Only `root` here, to tell inside from outside within the temporary folder.
|
||
let mut s = Server::new(Box::new(Headless::default())).with_path_scope(Some(PathScope::new([root.clone()])));
|
||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}));
|
||
tool(&mut s, "save", json!({"path": at(&root, "part.solidcraft")}));
|
||
tool(&mut s, "export", json!({"path": at(&root, "part.step")}));
|
||
tool(&mut s, "open", json!({"path": at(&root, "part.solidcraft")}));
|
||
if renders() {
|
||
tool(&mut s, "screenshot", json!({"path": at(&root, "shot.png"), "width": 64, "height": 64}));
|
||
}
|
||
let escapes = vec![at(&out, "x.step"), at(&root, "../out/x.step")];
|
||
#[cfg(unix)]
|
||
let mut escapes = escapes;
|
||
#[cfg(unix)]
|
||
{
|
||
std::os::unix::fs::symlink(&out, root.join("link")).unwrap();
|
||
escapes.push(at(&root, "link/x.step"));
|
||
}
|
||
for p in escapes {
|
||
for (name, args) in [
|
||
("export", json!({"path": p})),
|
||
("save", json!({"path": p})),
|
||
("open", json!({"path": p})),
|
||
("screenshot", json!({"path": p, "width": 64, "height": 64})),
|
||
("execute", json!({"command": "file.save_as", "params": {"path": p}})),
|
||
("batch", json!({"commands": [{"command": "file.export", "params": {"path": p}}]})),
|
||
] {
|
||
let r = raw_tool(&mut s, name, args);
|
||
assert_eq!(r["result"]["isError"], json!(true), "{name} {p}: {r}");
|
||
assert!(r.to_string().contains("outside the folders this agent may use"), "{name} {p}: {r}");
|
||
}
|
||
}
|
||
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written outside");
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
|
||
/// The short (8.3) spelling of an existing `path` as `cmd` gives it (`NAME~1.XYZ`), or `None`
|
||
/// when it has no short name (the volume makes none).
|
||
#[cfg(windows)]
|
||
fn short_spelling(path: &std::path::Path) -> Option<std::path::PathBuf> {
|
||
use std::os::windows::process::CommandExt;
|
||
let out = std::process::Command::new("cmd").raw_arg(format!("/d /c for %I in (\"{}\") do @echo %~sI", path.display())).output().ok()?;
|
||
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||
.filter(|s| solidcraft_engine::io::vfs::windows_refusal(s).is_some_and(|why| why.contains("8.3")))
|
||
.map(Into::into)
|
||
}
|
||
|
||
/// On Windows the temporary folder's path often has a short (8.3) name (the user folder's
|
||
/// `NAME~1.XYZ` when its name is long). The server's own folders are spelled with long names
|
||
/// there, so `screenshot` without a `path` (the scratch folder) and `doc.recovery_list` (the
|
||
/// recovery folder) work; on such a machine both were refused as short names. Run in a child
|
||
/// process whose `TMP`, `TEMP` and `SOLIDCRAFT_RECOVERY_DIR` are short spellings.
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn the_servers_own_folders_work_when_the_temporary_folder_has_a_short_name() {
|
||
let base = solidcraft_engine::io::vfs::temp_dir().join(format!("solidcraft-mcp-short-names-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
let (temp, recovery) = (base.join("temporary"), base.join("recovery folder"));
|
||
std::fs::create_dir_all(&temp).unwrap();
|
||
std::fs::create_dir_all(&recovery).unwrap();
|
||
let (Some(short_temp), Some(short_recovery)) = (short_spelling(&temp), short_spelling(&recovery)) else {
|
||
eprintln!("skipped: {} has no short (8.3) name on this volume", base.display());
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
return;
|
||
};
|
||
let out = std::process::Command::new(std::env::current_exe().unwrap())
|
||
.args(["--exact", "tests::short_names_child", "--test-threads=1", "--nocapture"])
|
||
.env("TMP", &short_temp)
|
||
.env("TEMP", &short_temp)
|
||
.env("SOLIDCRAFT_RECOVERY_DIR", &short_recovery)
|
||
.env("SOLIDCRAFT_MCP_SHORT_NAMES_CHILD", "1")
|
||
.output()
|
||
.unwrap();
|
||
let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||
assert!(out.status.success() && text.contains("short names: ok"), "{text}");
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
|
||
/// The child process of the test above; does nothing on its own.
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn short_names_child() {
|
||
if std::env::var_os("SOLIDCRAFT_MCP_SHORT_NAMES_CHILD").is_none() {
|
||
return;
|
||
}
|
||
use solidcraft_engine::io::vfs::windows_refusal;
|
||
let temp = std::env::temp_dir();
|
||
assert!(windows_refusal(&temp.to_string_lossy()).is_some_and(|why| why.contains("8.3")), "{}", temp.display());
|
||
let mut s = Server::new(Box::new(Headless::default())).with_path_scope(Some(crate::agent_scope([])));
|
||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}));
|
||
if renders() {
|
||
tool(&mut s, "screenshot", json!({"width": 32, "height": 32}));
|
||
}
|
||
tool(&mut s, "execute", json!({"command": "doc.recovery_list", "params": {}}));
|
||
// An agent's own short spelling of the scratch folder stays refused.
|
||
let scratch = crate::scratch_dir().unwrap();
|
||
let short = std::env::temp_dir().join(scratch.file_name().unwrap()).join("shot.png");
|
||
let r = raw_tool(&mut s, "screenshot", json!({"path": short.to_string_lossy(), "width": 32, "height": 32}));
|
||
assert!(r.to_string().contains("short (8.3) names"), "{r}");
|
||
println!("short names: ok");
|
||
}
|
||
|
||
/// A bridged server sends the agent's folders with every request, so the app confines the
|
||
/// request's files the same way (`allow_paths`, crates/ui-egui/src/control.rs).
|
||
#[test]
|
||
fn a_bridged_request_carries_the_agents_folders() {
|
||
use std::io::{BufRead, BufReader, Write};
|
||
|
||
use solidcraft_engine::io::vfs::{PathScope, scoped};
|
||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||
let addr = listener.local_addr().unwrap().to_string();
|
||
let echo = std::thread::spawn(move || {
|
||
let (stream, _) = listener.accept().unwrap();
|
||
let mut line = String::new();
|
||
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||
let req: Value = serde_json::from_str(&line).unwrap();
|
||
let mut result = req["params"].clone();
|
||
result["sent_token"] = req["token"].clone();
|
||
writeln!(&stream, "{}", json!({"id": req["id"], "ok": true, "result": result})).unwrap();
|
||
});
|
||
// The app's control token goes with the request too (#205).
|
||
let mut b = crate::Remote::connect_with_token(&addr, Some("0123456789abcdef0123456789abcdef")).unwrap();
|
||
let dir = std::fs::canonicalize(std::env::temp_dir()).unwrap();
|
||
let got = scoped(Some(PathScope::new([dir.clone()])), || crate::Backend::call(&mut b, "engine.execute", json!({"command": "file.new"}))).unwrap();
|
||
assert_eq!(got["allow_paths"], json!([dir.to_string_lossy()]), "{got}");
|
||
assert_eq!(got["command"], "file.new");
|
||
assert_eq!(got["sent_token"], "0123456789abcdef0123456789abcdef");
|
||
echo.join().unwrap();
|
||
}
|
||
|
||
/// #528: an agent saves the angle of a product photo as a named view once, then renders the
|
||
/// model from that view after each change. The render must use the saved camera, not a
|
||
/// standard view.
|
||
#[test]
|
||
fn screenshot_renders_a_saved_named_view() {
|
||
if !renders() {
|
||
return;
|
||
}
|
||
let mut s = server();
|
||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 20, "height": 10}}));
|
||
let camera = json!({"target": [20, 10, 5], "yaw": 0.5, "pitch": -0.6, "distance": 150});
|
||
tool(&mut s, "execute", json!({"command": "view.save", "params": {"name": "Photo", "camera": camera.clone()}}));
|
||
let shot = |s: &mut Server, view: &str| {
|
||
let img = tool(s, "screenshot", json!({"view": view, "width": 96, "height": 64}));
|
||
img["data"].as_str().unwrap_or_default().to_string()
|
||
};
|
||
let iso = shot(&mut s, "iso");
|
||
let photo = shot(&mut s, "Photo");
|
||
assert!(photo.starts_with("iVBORw0KGgo"), "not a PNG");
|
||
assert_ne!(photo, iso, "the named view rendered like the standard iso view");
|
||
// The same camera given directly renders the same picture; a standard view turns back.
|
||
let direct = tool(&mut s, "screenshot", json!({"camera": camera, "width": 96, "height": 64}));
|
||
assert_eq!(direct["data"].as_str().unwrap_or_default(), photo);
|
||
assert_eq!(shot(&mut s, "iso"), iso);
|
||
}
|
||
|
||
/// The ground grid lies on Z 0 and cuts through a part that straddles it: `grid: false` and
|
||
/// `axes: false` leave it and the origin's axes out of the render. An axis hidden with
|
||
/// `browser.visibility` stays out whatever `axes` says.
|
||
#[test]
|
||
fn screenshot_can_leave_the_grid_and_the_axes_out() {
|
||
if !renders() {
|
||
return;
|
||
}
|
||
let mut s = server();
|
||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "corner": [10, -15, -10]}}));
|
||
let shot = |s: &mut Server, extra: Value| {
|
||
let mut args = json!({"view": "front", "width": 96, "height": 64});
|
||
args.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||
tool(s, "screenshot", args)["data"].as_str().unwrap_or_default().to_string()
|
||
};
|
||
let all = shot(&mut s, json!({}));
|
||
let no_grid = shot(&mut s, json!({"grid": false}));
|
||
let no_axes = shot(&mut s, json!({"axes": false}));
|
||
let bare = shot(&mut s, json!({"grid": false, "axes": false}));
|
||
assert!(all.starts_with("iVBORw0KGgo"), "not a PNG");
|
||
let pictures = [&all, &no_grid, &no_axes, &bare];
|
||
for (i, a) in pictures.iter().enumerate() {
|
||
for b in &pictures[i + 1..] {
|
||
assert_ne!(a, b, "two of the pictures are the same");
|
||
}
|
||
}
|
||
// Asking for them is the default.
|
||
assert_eq!(shot(&mut s, json!({"grid": true, "axes": true})), all);
|
||
// Hiding the origin leaves the axes out, with or without `axes`; the grid is not the origin.
|
||
tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"folder": "origin", "visible": false}}));
|
||
assert_eq!(shot(&mut s, json!({})), no_axes);
|
||
assert_eq!(shot(&mut s, json!({"axes": true})), no_axes);
|
||
assert_eq!(shot(&mut s, json!({"grid": false})), bare);
|
||
// Other than booleans are schema errors.
|
||
for bad in [json!({"grid": "no"}), json!({"axes": 0})] {
|
||
assert_eq!(raw_tool(&mut s, "screenshot", bad)["error"]["code"], -32602);
|
||
}
|
||
}
|
||
|
||
/// Bridged to an app, `grid` and `axes` ask for a model render and travel with it; without them
|
||
/// the window is captured as before; the window cannot be asked to leave things out.
|
||
#[test]
|
||
fn bridged_screenshots_with_grid_or_axes_are_model_renders() {
|
||
struct App {
|
||
calls: Vec<(String, Value)>,
|
||
}
|
||
impl crate::backend::Backend for App {
|
||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||
self.calls.push((method.to_string(), params.clone()));
|
||
match method {
|
||
"ui.render" | "ui.screenshot" => {
|
||
let path = params["path"].as_str().ok_or("missing `path`")?;
|
||
std::fs::write(path, b"\x89PNG").map_err(|e| e.to_string())?;
|
||
Ok(json!({"path": path, "width": 64, "height": 48}))
|
||
}
|
||
other => Err(format!("unknown method `{other}`")),
|
||
}
|
||
}
|
||
fn has_ui(&self) -> bool {
|
||
true
|
||
}
|
||
fn describe(&self) -> String {
|
||
"app".into()
|
||
}
|
||
}
|
||
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-guides-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let path = dir.join("shot.png").to_string_lossy().to_string();
|
||
let shoot = |extra: Value| {
|
||
let mut args = json!({"path": path, "width": 64, "height": 48});
|
||
args.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||
let mut app = App { calls: Vec::new() };
|
||
let r = crate::tools::call_tool(&mut app, "screenshot", &args);
|
||
(app.calls, r)
|
||
};
|
||
let (calls, r) = shoot(json!({}));
|
||
assert!(r["isError"] != json!(true), "{r}");
|
||
assert_eq!(calls.iter().map(|(m, _)| m.as_str()).collect::<Vec<_>>(), ["ui.screenshot"]);
|
||
let (calls, r) = shoot(json!({"grid": false, "axes": false}));
|
||
assert!(r["isError"] != json!(true), "{r}");
|
||
assert_eq!(calls, [("ui.render".to_string(), json!({"path": path, "width": 64, "height": 48, "grid": false, "axes": false}))]);
|
||
let (calls, _) = shoot(json!({"axes": true}));
|
||
assert_eq!(calls, [("ui.render".to_string(), json!({"path": path, "width": 64, "height": 48, "axes": true}))]);
|
||
let (calls, r) = shoot(json!({"source": "model", "grid": false}));
|
||
assert!(r["isError"] != json!(true), "{r}");
|
||
assert_eq!(calls, [("ui.render".to_string(), json!({"path": path, "width": 64, "height": 48, "grid": false}))]);
|
||
// The window shows what it shows: nothing is called, and the error says what to ask for.
|
||
let (calls, r) = shoot(json!({"source": "window", "grid": false, "view": "top"}));
|
||
assert_eq!(r["isError"], json!(true), "{r}");
|
||
assert!(r["content"][0]["text"].as_str().unwrap().contains("source"), "{r}");
|
||
assert!(calls.is_empty(), "{calls:?}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// #1677: a hidden window gives no picture. With no `source`, a model render comes back and says
|
||
/// it is one (`fallback`); asked for the window, the tool says why there is none, at once.
|
||
#[test]
|
||
fn a_hidden_window_screenshot_says_so() {
|
||
struct Hidden {
|
||
calls: Vec<String>,
|
||
}
|
||
impl crate::backend::Backend for Hidden {
|
||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||
self.calls.push(method.to_string());
|
||
match method {
|
||
"ui.screenshot" => Err("no frame was presented (window hidden?); use ui.render".into()),
|
||
"ui.render" => {
|
||
let path = params["path"].as_str().ok_or("missing `path`")?;
|
||
std::fs::write(path, b"\x89PNG").map_err(|e| e.to_string())?;
|
||
Ok(json!({"path": path, "width": 64, "height": 48}))
|
||
}
|
||
other => Err(format!("unknown method `{other}`")),
|
||
}
|
||
}
|
||
fn has_ui(&self) -> bool {
|
||
true
|
||
}
|
||
fn describe(&self) -> String {
|
||
"app".into()
|
||
}
|
||
}
|
||
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-hidden-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let path = dir.join("shot.png").to_string_lossy().to_string();
|
||
let shoot = |extra: Value| {
|
||
let mut args = json!({"path": path, "width": 64, "height": 48});
|
||
args.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||
let mut app = Hidden { calls: Vec::new() };
|
||
let r = crate::tools::call_tool(&mut app, "screenshot", &args);
|
||
(app.calls, r)
|
||
};
|
||
// The default: the window first, then a model render that says why.
|
||
let (calls, r) = shoot(json!({}));
|
||
assert!(r["isError"] != json!(true), "{r}");
|
||
assert_eq!(calls, ["ui.screenshot", "ui.render"]);
|
||
let info: Value = serde_json::from_str(r["content"][1]["text"].as_str().unwrap()).unwrap();
|
||
assert_eq!(info["source"], "model", "{info}");
|
||
assert!(info["fallback"].as_str().is_some_and(|f| f.contains("window hidden")), "{info}");
|
||
// Asked for the window: an error naming the cause and the way out, and no render.
|
||
let (calls, r) = shoot(json!({"source": "window"}));
|
||
assert_eq!(r["isError"], json!(true), "{r}");
|
||
let text = r["content"][0]["text"].as_str().unwrap();
|
||
assert!(text.contains("window hidden") && text.contains("source \"model\""), "{text}");
|
||
assert_eq!(calls, ["ui.screenshot"]);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// #530: in a design with many bodies every feature result lists all of them, so a batch reply
|
||
/// is mostly the same body list once per step. A compact batch reply names only what each step
|
||
/// added or changed.
|
||
#[test]
|
||
fn compact_batch_reply_does_not_repeat_the_body_list() {
|
||
let mut s = server();
|
||
let cube =
|
||
|x: i64, y: i64| json!({"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1, "corner": [x, y, 0], "operation": "new"}});
|
||
let existing: Vec<Value> = (0..50).map(|i| cube(2 * i, 0)).collect();
|
||
tool(&mut s, "batch", json!({"commands": existing}));
|
||
let more: Vec<Value> = (0..3).map(|i| cube(2 * i, 5)).collect();
|
||
let r = raw_tool(&mut s, "batch", json!({"commands": more, "verbose": false}));
|
||
assert!(r.get("error").is_none() && r["result"]["isError"] != json!(true), "{r}");
|
||
let reply = r["result"]["content"][0]["text"].as_str().unwrap_or_default();
|
||
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 53);
|
||
// The new bodies are named; the 50 untouched ones are not repeated.
|
||
for new in ["Body51", "Body52", "Body53"] {
|
||
assert!(reply.contains(&format!("\"{new}\"")), "{new} missing from the compact reply: {reply}");
|
||
}
|
||
for old in ["Body1", "Body25", "Body50"] {
|
||
assert!(!reply.contains(&format!("\"{old}\"")), "untouched {old} repeated in the compact reply ({} bytes): {reply}", reply.len());
|
||
}
|
||
}
|
||
|
||
/// A compact batch reply keeps what each step did: the body a cut changed, and a sketch
|
||
/// command's new ids once (under `created`); the default reply is unchanged.
|
||
#[test]
|
||
fn compact_batch_reply_keeps_changed_bodies_and_created_ids() {
|
||
let mut s = server();
|
||
let steps = json!([
|
||
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}},
|
||
{"command": "solid.box", "params": {"length": 4, "width": 4, "height": 10, "corner": [3, 3, 5], "operation": "cut", "targets": ["Base"]}},
|
||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||
{"command": "sketch.rectangle.two_point", "params": {"p0": [20, 0], "p1": [30, 10]}},
|
||
]);
|
||
let full = tool(&mut s, "batch", json!({"commands": steps}));
|
||
assert_eq!(full["results"][1]["bodies"], json!(["Base"]), "{full}");
|
||
assert_eq!(full["results"][3]["curves"], full["results"][3]["created"]["curves"], "{full}");
|
||
tool(&mut s, "new_design", json!({}));
|
||
let r = tool(&mut s, "batch", json!({"commands": steps, "verbose": false}));
|
||
let cut = &r["results"][1];
|
||
assert!(cut.get("bodies").is_none(), "{r}");
|
||
assert_eq!(cut["changed_bodies"], json!(["Base"]), "{r}");
|
||
let rect = &r["results"][3];
|
||
assert!(rect.get("curves").is_none() && rect.get("constraints").is_none(), "{r}");
|
||
assert_eq!(rect["created"]["curves"].as_array().map(Vec::len), Some(4), "{r}");
|
||
assert!(rect["sides"].is_object(), "results that are not repeated stay: {r}");
|
||
}
|
||
|
||
/// #531: a product model keeps an optional variant hidden in its default presentation. Hiding
|
||
/// it must survive saving and reopening the design (suppressing it would change the design).
|
||
#[test]
|
||
fn hidden_body_stays_hidden_after_save_and_reopen() {
|
||
let mut s = server();
|
||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}}));
|
||
tool(
|
||
&mut s,
|
||
"execute",
|
||
json!({"command": "solid.box", "params": {"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0], "operation": "new", "body_name": "Variant"}}),
|
||
);
|
||
tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": ["Variant"], "visible": false}}));
|
||
let visible = |s: &mut Server, body: &str| {
|
||
let d = tool(s, "inspect_design", json!({"measure": false}));
|
||
d["bodies"].as_array().into_iter().flatten().find(|b| b["name"] == body).map(|b| b["visible"].clone()).unwrap_or(Value::Null)
|
||
};
|
||
assert_eq!(visible(&mut s, "Variant"), false);
|
||
|
||
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-visibility-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let design = dir.join("variants.solidcraft");
|
||
tool(&mut s, "save", json!({"path": design.to_string_lossy()}));
|
||
tool(&mut s, "new_design", json!({"name": "Empty"}));
|
||
tool(&mut s, "open", json!({"path": design.to_string_lossy()}));
|
||
let (base, variant) = (visible(&mut s, "Base"), visible(&mut s, "Variant"));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
assert_eq!(base, true);
|
||
assert_eq!(variant, false, "the hidden body is visible again after reopening");
|
||
}
|
||
|
||
/// Single execute calls share batch compaction without changing geometry or inspection.
|
||
#[test]
|
||
fn compact_execute_keeps_changes_ids_and_default_replies() {
|
||
let mut s = server();
|
||
let commands = json!([
|
||
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}},
|
||
{"command": "solid.box", "params": {"length": 4, "width": 4, "height": 10, "corner": [3, 3, 5], "operation": "cut", "targets": ["Base"]}},
|
||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||
{"command": "sketch.rectangle.two_point", "params": {"p0": [20, 0], "p1": [30, 10]}}
|
||
]);
|
||
let full: Vec<Value> = commands.as_array().unwrap().iter().map(|a| tool(&mut s, "execute", a.clone())).collect();
|
||
assert_eq!(full[0]["bodies"], json!(["Base"]));
|
||
assert_eq!(full[3]["curves"], full[3]["created"]["curves"]);
|
||
let expected_volume = volume(&mut s);
|
||
for verbose in [true, false] {
|
||
tool(&mut s, "new_design", json!({}));
|
||
for (a, expected) in commands.as_array().unwrap().iter().zip(&full) {
|
||
let mut a = a.clone();
|
||
a["verbose"] = json!(verbose);
|
||
let mut actual = tool(&mut s, "execute", a);
|
||
let mut expected = expected.clone();
|
||
// Independent executions have different timings; the field must still be present.
|
||
if expected.get("ms").is_some() {
|
||
assert!(actual["ms"].is_number());
|
||
actual.as_object_mut().unwrap().remove("ms");
|
||
expected.as_object_mut().unwrap().remove("ms");
|
||
}
|
||
if !verbose {
|
||
// Compare against the full result with only the documented repeated fields removed.
|
||
let o = expected.as_object_mut().unwrap();
|
||
if o.contains_key("created_bodies") {
|
||
o.remove("bodies");
|
||
}
|
||
for key in ["points", "curves", "constraints", "dimensions"] {
|
||
if o.get(key).is_some_and(|v| o.get("created").and_then(|c| c.get(key)) == Some(v)) {
|
||
o.remove(key);
|
||
}
|
||
}
|
||
}
|
||
assert_eq!(actual, expected);
|
||
}
|
||
assert!((volume(&mut s) - expected_volume).abs() < 1e-9);
|
||
let inspect = tool(&mut s, "execute", json!({"command": "sketch.inspect", "verbose": false}));
|
||
assert_eq!(inspect["curves"].as_array().map(Vec::len), Some(4));
|
||
let invalid = raw_tool(&mut s, "execute", json!({"command": "solid.box", "verbose": "false"}));
|
||
assert_eq!(invalid["error"]["code"], -32602);
|
||
assert!((volume(&mut s) - expected_volume).abs() < 1e-9);
|
||
let error = raw_tool(&mut s, "execute", json!({"command": "not.a.command", "verbose": false}));
|
||
assert_eq!(error["result"]["isError"], true);
|
||
}
|
||
}
|
||
|
||
/// A connected app sees exactly one engine call; no inventory inspection is needed.
|
||
#[test]
|
||
fn compact_execute_uses_one_backend_call_and_keeps_removed_bodies() {
|
||
struct ReplyOnly {
|
||
calls: usize,
|
||
}
|
||
impl crate::backend::Backend for ReplyOnly {
|
||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||
assert_eq!(method, "engine.execute");
|
||
assert_eq!(params, json!({"command": "test.feature", "params": {}}));
|
||
self.calls += 1;
|
||
Ok(
|
||
json!({"feature": 7, "name": "Combine1", "bodies": ["Base"], "created_bodies": [], "changed_bodies": ["Base"], "removed_bodies": ["Tool"]}),
|
||
)
|
||
}
|
||
fn has_ui(&self) -> bool {
|
||
true
|
||
}
|
||
fn describe(&self) -> String {
|
||
"test".into()
|
||
}
|
||
}
|
||
let mut b = ReplyOnly { calls: 0 };
|
||
let r = crate::tools::call_tool(&mut b, "execute", &json!({"command": "test.feature", "verbose": false}));
|
||
let v: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
|
||
assert_eq!(b.calls, 1);
|
||
assert_eq!(v, json!({"feature": 7, "name": "Combine1", "created_bodies": [], "changed_bodies": ["Base"], "removed_bodies": ["Tool"]}));
|
||
}
|
||
|
||
/// The worked example in `skills/solidcraft-modeling/reference.md` is what agents copy, so it has
|
||
/// to keep building with the volume the file documents, and follow a parameter change.
|
||
#[test]
|
||
fn skill_reference_example_builds_as_documented() {
|
||
let doc = include_str!("../../../skills/solidcraft-modeling/reference.md");
|
||
let start = doc.find("{\"commands\":[").expect("the reference has a worked example");
|
||
let end = start + doc[start..].find("```").expect("the example's code block ends");
|
||
let example: Value = serde_json::from_str(&doc[start..end]).expect("the example is JSON");
|
||
let mut s = server();
|
||
tool(&mut s, "batch", example);
|
||
// Plate with four r5 corners, minus four counterbored holes (Ø3.4 through, Ø6.5 × 3.3).
|
||
let expected = |l: f64| {
|
||
let (w, t) = (50.0, 6.0);
|
||
let plate = l * w * t - 4.0 * 25.0 * (1.0 - PI / 4.0) * t;
|
||
let hole = PI * 1.7 * 1.7 * (t - 3.3) + PI * 3.25 * 3.25 * 3.3;
|
||
plate - 4.0 * hole
|
||
};
|
||
assert!((expected(80.0) - 23335.2).abs() < 0.05, "the reference states {}", expected(80.0));
|
||
// Volume can't see where the holes are: each counterbore floor (z = T - 3.3) is centred on its
|
||
// hole, at (±(L/2 - inset), ±(W/2 - inset)).
|
||
let holes_at = |s: &mut Server, x: f64| {
|
||
let topo = tool(s, "body_topology", json!({"body": "Body1"}));
|
||
let mut floors: Vec<(i64, i64)> = topo["faces"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.filter_map(|f| f["centroid"].as_array())
|
||
.filter(|c| (c[2].as_f64().unwrap() - 2.7).abs() < 1e-6)
|
||
.map(|c| ((c[0].as_f64().unwrap() * 1000.0).round() as i64, (c[1].as_f64().unwrap() * 1000.0).round() as i64))
|
||
.collect();
|
||
floors.sort_unstable();
|
||
let x = (x * 1000.0).round() as i64;
|
||
assert_eq!(floors, vec![(-x, -17000), (-x, 17000), (x, -17000), (x, 17000)], "{topo}");
|
||
};
|
||
let m = tool(&mut s, "measure", json!({}));
|
||
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), expected(80.0)) < 1e-5, "{m}");
|
||
assert_eq!(m["bodies"][0]["faces"], json!(22), "{m}");
|
||
holes_at(&mut s, 32.0);
|
||
let r = tool(&mut s, "set_parameter", json!({"name": "L", "value": "120 mm"}));
|
||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
|
||
assert!(rel(volume(&mut s), expected(120.0)) < 1e-5);
|
||
holes_at(&mut s, 52.0);
|
||
}
|
||
|
||
/// The skill reference's sketch-plane table: where sketch (10, 20) lands and which way +distance goes.
|
||
#[test]
|
||
fn sketch_plane_axes_match_the_skill_reference() {
|
||
let mut s = server();
|
||
for plane in ["XY", "XZ", "YZ"] {
|
||
tool(
|
||
&mut s,
|
||
"batch",
|
||
json!({"commands": [
|
||
{"command": "sketch.create", "params": {"plane": plane}},
|
||
{"command": "sketch.circle.center", "params": {"center": [10, 20], "radius": 1}},
|
||
{"command": "sketch.finish"},
|
||
{"command": "solid.extrude", "params": {"distance": 3, "operation": "new"}},
|
||
]}),
|
||
);
|
||
}
|
||
let m = tool(&mut s, "measure", json!({}));
|
||
let centres: Vec<Vec<f64>> = m["bodies"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|b| b["center_of_mass"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect())
|
||
.collect();
|
||
let expected = [[10.0, 20.0, 1.5], [10.0, 1.5, -20.0], [1.5, 10.0, 20.0]];
|
||
assert_eq!(centres.len(), 3, "{m}");
|
||
for (got, want) in centres.iter().zip(expected) {
|
||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-6), "{got:?} != {want:?}");
|
||
}
|
||
}
|
||
|
||
/// The worked example in `skills/solidcraft-mechanisms/reference.md` is what agents copy: the
|
||
/// actuator has to follow the hinge with the stroke the file computes, the mirrored panel its link,
|
||
/// and the drive has to stop where the rod reaches its limit.
|
||
#[test]
|
||
fn mechanisms_skill_example_moves_as_documented() {
|
||
let doc = include_str!("../../../skills/solidcraft-mechanisms/reference.md");
|
||
let start = doc.find("{\"commands\":[").expect("the reference has a worked example");
|
||
let end = start + doc[start..].find("```").expect("the example's code block ends");
|
||
let example: Value = serde_json::from_str(&doc[start..end]).expect("the example is JSON");
|
||
let mut s = server();
|
||
tool(&mut s, "batch", example);
|
||
let exec = |s: &mut Server, command: &str, params: Value| tool(s, "execute", json!({"command": command, "params": params}));
|
||
let value = |list: &Value, joint: &str| {
|
||
let j = list["joints"].as_array().unwrap().iter().find(|j| j["name"] == joint).unwrap_or_else(|| panic!("no joint {joint}: {list}"));
|
||
j["values"][0]["value"].as_f64().unwrap()
|
||
};
|
||
let com = |s: &mut Server, occurrence: &str| -> Vec<f64> {
|
||
let p = exec(s, "occurrence.properties", json!({"occurrence": occurrence}));
|
||
p["total"]["center_of_mass"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()
|
||
};
|
||
// Frame 0, the panels and the actuator one motion between them; nothing open.
|
||
let list = exec(&mut s, "joint.list", json!({}));
|
||
assert_eq!(list["conflicts"], json!([]), "{list}");
|
||
let mut dof: Vec<u64> = list["dof"].as_array().unwrap().iter().map(|d| d["dof"].as_u64().unwrap()).collect();
|
||
dof.sort_unstable();
|
||
assert_eq!(dof, [0, 0, 1, 1, 1, 1], "root, Frame, then Wing, Wing 2, barrel and rod: {list}");
|
||
let near = |got: &[f64], want: [f64; 3]| got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3);
|
||
let wing = com(&mut s, "Wing:1");
|
||
assert!(near(&wing, [0.0, 20.0, 1.5]), "joined where it was modelled: {wing:?}");
|
||
|
||
// The tip at (0, 30 cos θ, 30 sin θ), the root at (0, 0, -40): L² = 2500 + 2400 sin θ.
|
||
let stroke = |deg: f64| (2500.0 + 2400.0 * deg.to_radians().sin()).sqrt() - 50.0;
|
||
assert!((stroke(20.0) - 7.6268).abs() < 5e-5, "the reference states {}", stroke(20.0));
|
||
exec(&mut s, "joint.drive", json!({"joint": "Hinge", "value": 20}));
|
||
let list = exec(&mut s, "joint.list", json!({}));
|
||
assert!((value(&list, "Stroke") - stroke(20.0)).abs() < 1e-6, "{list}");
|
||
assert!((value(&list, "Hinge 2") + 20.0).abs() < 1e-9, "{list}");
|
||
let (wing, wing2) = (com(&mut s, "Wing:1"), com(&mut s, "Wing 2:1"));
|
||
assert!(near(&wing, [0.0, 18.281, 8.250]), "{wing:?}");
|
||
assert!(near(&wing2, [0.0, -18.281, 8.250]), "{wing2:?}");
|
||
|
||
// The rod reaches +10 mm where L = 60: sin θ = 11/24.
|
||
let stop = (11.0f64 / 24.0).asin().to_degrees();
|
||
assert!((stop - 27.280).abs() < 5e-4, "the reference states {stop}");
|
||
let r = exec(&mut s, "joint.drive", json!({"joint": "Hinge", "value": 45}));
|
||
assert_eq!(r["stopped_by_loop"]["joint"], "Stroke", "{r}");
|
||
let list = exec(&mut s, "joint.list", json!({}));
|
||
let hinge = value(&list, "Hinge");
|
||
assert!((hinge - stop).abs() < 0.01, "stopped at {hinge}, the rod's limit is at {stop}");
|
||
assert!(value(&list, "Stroke") <= 10.0 + 1e-6 && (value(&list, "Stroke") - stroke(hinge)).abs() < 1e-6, "{list}");
|
||
}
|
||
|
||
/// Every tool and command the agent skills call exists, so a rename or removal can't leave the
|
||
/// skills sending agents to names the server no longer has.
|
||
#[test]
|
||
fn skills_name_only_real_tools_and_commands() {
|
||
let docs = [
|
||
include_str!("../../../skills/solidcraft-modeling/SKILL.md"),
|
||
include_str!("../../../skills/solidcraft-modeling/reference.md"),
|
||
include_str!("../../../skills/solidcraft-editing/SKILL.md"),
|
||
include_str!("../../../skills/solidcraft-checking/SKILL.md"),
|
||
include_str!("../../../skills/solidcraft-checking/reference/porting.md"),
|
||
include_str!("../../../skills/solidcraft-mechanisms/SKILL.md"),
|
||
include_str!("../../../skills/solidcraft-mechanisms/reference.md"),
|
||
];
|
||
let mut s = server();
|
||
let tools: Vec<String> = tool_definitions().iter().filter_map(|t| t["name"].as_str()).map(str::to_owned).collect();
|
||
let commands: Vec<String> =
|
||
tool(&mut s, "list_commands", json!({})).as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).map(str::to_owned).collect();
|
||
let mut checked = 0;
|
||
for doc in docs {
|
||
// A call in backticks is `name {}`, `name {"json":…}` or `name {placeholder}`: a tool when the
|
||
// name is snake_case, a command when it is a dotted id. Field lists such as
|
||
// `sides {bottom,right,top,left}` and `created: {…}` aren't calls. JSON examples name
|
||
// commands as "command":"id".
|
||
let ident = |w: &str| !w.is_empty() && w.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.');
|
||
let calls = doc.split('`').skip(1).step_by(2).filter_map(|span| {
|
||
let (head, args) = span.split_once(" {")?;
|
||
let name = head.rsplit(' ').next()?;
|
||
let args = args.strip_suffix('}')?;
|
||
let call_like = args.is_empty() || args.starts_with('"') || args.chars().all(|c| c.is_ascii_lowercase() || c == '_');
|
||
(ident(name) && call_like).then_some(name)
|
||
});
|
||
let in_json = doc.split("\"command\":\"").skip(1).filter_map(|rest| rest.split('"').next());
|
||
for name in calls.chain(in_json) {
|
||
let known = if name.contains('.') { commands.iter().any(|c| c == name) } else { tools.iter().any(|t| t == name) };
|
||
assert!(known, "a skill calls `{name}`, which is neither a tool nor a command");
|
||
checked += 1;
|
||
}
|
||
}
|
||
assert!(checked > 30, "only {checked} tool and command names found in the skills");
|
||
}
|
||
|
||
/// Every tool the server instructions name (a bare `snake_case` word in backticks) is a real tool.
|
||
#[test]
|
||
fn instructions_name_only_real_tools() {
|
||
let mut s = server();
|
||
let init = call(&mut s, 1, "initialize", json!({"protocolVersion": PROTOCOL_VERSION, "capabilities": {}}));
|
||
let text = init["result"]["instructions"].as_str().unwrap().to_owned();
|
||
let tools: Vec<String> = tool_definitions().iter().map(|t| t["name"].as_str().unwrap().to_owned()).collect();
|
||
let named: Vec<&str> =
|
||
text.split('`').skip(1).step_by(2).filter(|w| !w.is_empty() && w.chars().all(|c| c.is_ascii_lowercase() || c == '_')).collect();
|
||
assert!(named.contains(&"body_topology"), "{named:?}");
|
||
for name in named {
|
||
assert!(tools.iter().any(|t| t == name), "the instructions name `{name}`, which is not a tool");
|
||
}
|
||
}
|
||
|
||
/// A fake app that records what it is asked and answers `engine.batch` as configured.
|
||
struct Recorder {
|
||
calls: Vec<(String, Value)>,
|
||
batch: Result<Value, String>,
|
||
}
|
||
|
||
impl crate::backend::Backend for Recorder {
|
||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||
self.calls.push((method.to_string(), params.clone()));
|
||
match method {
|
||
"engine.batch" => self.batch.clone(),
|
||
"engine.execute" => match params["command"].as_str() {
|
||
Some("edit.checkpoint") => Ok(json!({"checkpoint": 3})),
|
||
Some(c) => Ok(json!({"ran": c})),
|
||
None => Err("missing `command`".into()),
|
||
},
|
||
"document.inspect" => Ok(json!({"undo": 0})),
|
||
other => Err(format!("unknown method `{other}`")),
|
||
}
|
||
}
|
||
fn has_ui(&self) -> bool {
|
||
true
|
||
}
|
||
fn describe(&self) -> String {
|
||
"recorder".into()
|
||
}
|
||
}
|
||
|
||
fn recorded_batch(batch: Result<Value, String>, args: Value) -> (Recorder, Value, bool) {
|
||
let mut b = Recorder { calls: Vec::new(), batch };
|
||
let r = crate::tools::call_tool(&mut b, "batch", &args);
|
||
let body = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap_or_else(|_| r["content"][0]["text"].clone());
|
||
let is_error = r["isError"] == json!(true);
|
||
(b, body, is_error)
|
||
}
|
||
|
||
fn methods(b: &Recorder) -> Vec<&str> {
|
||
b.calls.iter().map(|(m, _)| m.as_str()).collect()
|
||
}
|
||
|
||
/// #743: bridged to an app that has `engine.batch`, a batch is one control request.
|
||
#[test]
|
||
fn batch_is_one_engine_batch_call() {
|
||
let reply = json!({"completed": 2, "results": [{"feature": 1, "bodies": ["B"], "created_bodies": ["B"]}, {"x": 2}], "undone": 0});
|
||
let steps = json!([{"command": "a.one", "params": {"v": 1}}, {"command": "a.two"}]);
|
||
let (b, body, is_error) = recorded_batch(Ok(reply.clone()), json!({"commands": steps}));
|
||
assert_eq!(methods(&b), ["engine.batch"]);
|
||
assert_eq!(b.calls[0].1, json!({"commands": steps, "rollback": true}));
|
||
assert!(!is_error);
|
||
assert_eq!(body, json!({"ok": true, "completed": 2, "results": reply["results"]}));
|
||
// `verbose: false` compacts each step's result as the per-step path does.
|
||
let (_, body, _) = recorded_batch(Ok(reply), json!({"commands": steps, "verbose": false}));
|
||
assert_eq!(body["results"][0], json!({"feature": 1, "created_bodies": ["B"]}), "{body}");
|
||
}
|
||
|
||
#[test]
|
||
fn batch_reports_a_failed_engine_batch_like_before() {
|
||
let reply = json!({"completed": 1, "results": [{"x": 1}], "failed": {"index": 1, "command": "a.two", "error": "boom"}, "undone": 1});
|
||
let (b, body, is_error) = recorded_batch(Ok(reply), json!({"commands": [{"command": "a.one"}, {"command": "a.two"}]}));
|
||
assert_eq!(methods(&b), ["engine.batch"]);
|
||
assert!(is_error);
|
||
assert_eq!(
|
||
body,
|
||
json!({"ok": false, "completed": 1, "failed": {"index": 1, "command": "a.two", "error": "boom"}, "rolled_back": true, "undone": 1, "results": [{"x": 1}]})
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn batch_falls_back_when_the_app_lacks_engine_batch() {
|
||
let (b, body, is_error) =
|
||
recorded_batch(Err("unknown method `engine.batch`".into()), json!({"commands": [{"command": "a.one"}, {"command": "a.two"}]}));
|
||
assert!(!is_error, "{body}");
|
||
let ran: Vec<&str> = b.calls.iter().filter(|(m, _)| m == "engine.execute").map(|(_, p)| p["command"].as_str().unwrap_or("")).collect();
|
||
assert!(ran.contains(&"a.one") && ran.contains(&"a.two"), "{ran:?}");
|
||
assert_eq!(body["results"], json!([{"ran": "a.one"}, {"ran": "a.two"}]));
|
||
}
|
||
|
||
#[test]
|
||
fn batch_falls_back_while_the_agent_cursor_is_shown() {
|
||
let (b, body, is_error) =
|
||
recorded_batch(Err("engine.batch unavailable: the agent cursor is shown".into()), json!({"commands": [{"command": "a.one"}]}));
|
||
assert!(!is_error, "{body}");
|
||
assert!(b.calls.iter().any(|(m, p)| m == "engine.execute" && p["command"] == "a.one"), "{:?}", b.calls);
|
||
}
|
||
|
||
/// The app may have run the batch: sending the steps again would apply them twice.
|
||
#[test]
|
||
fn batch_does_not_resend_after_a_transport_error() {
|
||
let (b, body, is_error) =
|
||
recorded_batch(Err("SolidCraft at 127.0.0.1:1: timed out".into()), json!({"commands": [{"command": "a.one"}, {"command": "a.two"}]}));
|
||
assert_eq!(methods(&b), ["engine.batch"]);
|
||
assert!(is_error);
|
||
assert!(body.as_str().is_some_and(|t| t.contains("may have run") && t.contains("timed out")), "{body}");
|
||
}
|
||
|
||
/// A batch longer than one control line goes step by step instead of being refused unrun.
|
||
#[test]
|
||
fn batch_too_large_for_one_control_line_goes_per_step() {
|
||
let big = "x".repeat(solidcraft_engine::MAX_LINE);
|
||
let (b, body, is_error) = recorded_batch(Ok(json!({})), json!({"commands": [{"command": "a.one", "params": {"text": big}}]}));
|
||
assert!(!is_error, "{body}");
|
||
assert!(!methods(&b).contains(&"engine.batch"), "{:?}", methods(&b));
|
||
assert!(b.calls.iter().any(|(m, p)| m == "engine.execute" && p["command"] == "a.one"));
|
||
}
|
||
|
||
/// A fake app on a loopback port: counts connections and `engine.batch` lines, never replies to
|
||
/// them (`close_first`: close a connection as soon as it is accepted).
|
||
fn silent_app(close_first: bool) -> (String, std::sync::Arc<std::sync::Mutex<(usize, usize)>>) {
|
||
use std::io::BufRead;
|
||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||
let addr = listener.local_addr().unwrap().to_string();
|
||
let seen = std::sync::Arc::new(std::sync::Mutex::new((0usize, 0usize)));
|
||
let counts = seen.clone();
|
||
std::thread::spawn(move || {
|
||
for stream in listener.incoming().flatten() {
|
||
counts.lock().unwrap().0 += 1;
|
||
let counts = counts.clone();
|
||
std::thread::spawn(move || {
|
||
if close_first && counts.lock().unwrap().0 > 1 {
|
||
return;
|
||
}
|
||
let mut r = std::io::BufReader::new(stream);
|
||
let mut line = String::new();
|
||
while r.read_line(&mut line).is_ok_and(|n| n > 0) {
|
||
if line.contains("engine.batch") {
|
||
counts.lock().unwrap().1 += 1;
|
||
return;
|
||
}
|
||
line.clear();
|
||
}
|
||
});
|
||
}
|
||
});
|
||
(addr, seen)
|
||
}
|
||
|
||
#[test]
|
||
fn remote_engine_batch_is_not_resent_after_a_lost_reply() {
|
||
use crate::backend::Backend;
|
||
for close_first in [false, true] {
|
||
let (addr, seen) = silent_app(close_first);
|
||
let mut remote = crate::Remote::connect(&addr).unwrap();
|
||
let r = remote.call("engine.batch", json!({"commands": [{"command": "solid.box"}]}));
|
||
assert!(r.is_err(), "{r:?}");
|
||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||
let (connections, batches) = *seen.lock().unwrap();
|
||
// The connection from `connect`, and one fresh one for the batch: no resend.
|
||
assert_eq!(connections, 2, "close_first {close_first}: {connections} connections");
|
||
assert!(batches <= 1, "close_first {close_first}: the batch arrived {batches} times");
|
||
if !close_first {
|
||
assert_eq!(batches, 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A bridge made before the app runs keeps its tools (#270): calls say the app is not running and
|
||
/// how to start it (a batch was never sent, so it may not "have run"), and reach the app once it
|
||
/// listens.
|
||
#[test]
|
||
fn a_bridge_made_before_the_app_runs_connects_once_it_does() {
|
||
use std::io::{BufRead, BufReader, Write};
|
||
|
||
use crate::backend::Backend;
|
||
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||
let addr = format!("127.0.0.1:{port}");
|
||
let mut remote = crate::Remote::new(&addr, Some("0123456789abcdef0123456789abcdef"));
|
||
// Still "connected to the SolidCraft app", which the skills read as bridged.
|
||
assert!(remote.describe().starts_with("connected to the SolidCraft app") && remote.describe().contains("not running"), "{}", remote.describe());
|
||
let e = remote.call("engine.execute", json!({"command": "inspect.measure"})).unwrap_err();
|
||
assert!(e.starts_with(crate::backend::NOT_RUNNING) && e.contains(&format!("solidcraft --control {port}")), "{e}");
|
||
let batch = crate::call_tool(&mut remote, "batch", &json!({"commands": [{"command": "solid.box"}]}));
|
||
let text = batch["content"][0]["text"].as_str().unwrap_or_default();
|
||
assert_eq!(batch["isError"], true);
|
||
assert!(text.starts_with(crate::backend::NOT_RUNNING) && !text.contains("may have run"), "{text}");
|
||
|
||
let listener = std::net::TcpListener::bind(&addr).unwrap();
|
||
let app = std::thread::spawn(move || {
|
||
let (stream, _) = listener.accept().unwrap();
|
||
let mut line = String::new();
|
||
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||
let req: Value = serde_json::from_str(&line).unwrap();
|
||
writeln!(&stream, "{}", json!({"id": req["id"], "ok": true, "result": {"answered": req["params"]["command"]}})).unwrap();
|
||
});
|
||
let got = remote.call("engine.execute", json!({"command": "inspect.measure"})).unwrap();
|
||
assert_eq!(got["answered"], "inspect.measure");
|
||
assert!(remote.describe().starts_with("connected") && !remote.describe().contains("not running"), "{}", remote.describe());
|
||
app.join().unwrap();
|
||
}
|
||
|
||
/// An app that quits while it runs a request may have applied it: the retry then finds nothing
|
||
/// listening, and the error says the call may have run, not just that the app is not running.
|
||
#[test]
|
||
fn an_app_that_quits_during_a_call_may_have_run_it() {
|
||
use std::io::{BufRead, BufReader};
|
||
|
||
use crate::backend::Backend;
|
||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||
let addr = listener.local_addr().unwrap().to_string();
|
||
let app = std::thread::spawn(move || {
|
||
let (stream, _) = listener.accept().unwrap();
|
||
let mut line = String::new();
|
||
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||
// Quit without answering: the port closes first, so the retry is refused.
|
||
drop(listener);
|
||
drop(stream);
|
||
});
|
||
let mut remote = crate::Remote::connect(&addr).unwrap();
|
||
let e = remote.call("engine.execute", json!({"command": "solid.box"})).unwrap_err();
|
||
app.join().unwrap();
|
||
assert!(!e.starts_with(crate::backend::NOT_RUNNING) && e.contains("may have quit") && e.contains("Inspect the design"), "{e}");
|
||
}
|
||
|
||
/// Tool annotations never understate what a tool may do (#205): `screenshot` writes a file when
|
||
/// given a `path`, and `execute`, `batch`, `save`, `export` and `open` can overwrite or delete
|
||
/// files, so none of them is read-only and all are destructive and reach the file system.
|
||
#[test]
|
||
fn tool_annotations_tell_the_whole_truth() {
|
||
let tools = crate::tool_definitions();
|
||
let hints = |name: &str| {
|
||
let t = tools.iter().find(|t| t["name"] == name).unwrap_or_else(|| panic!("no tool {name}"));
|
||
let a = &t["annotations"];
|
||
(a["readOnlyHint"].as_bool().unwrap(), a["destructiveHint"].as_bool().unwrap(), a["openWorldHint"].as_bool().unwrap())
|
||
};
|
||
for name in ["execute", "batch", "save", "export", "open", "screenshot"] {
|
||
assert_eq!(hints(name), (false, true, true), "{name}");
|
||
}
|
||
for name in ["list_commands", "inspect_design", "measure", "body_topology", "crash_reports"] {
|
||
assert_eq!(hints(name), (true, false, false), "{name}");
|
||
}
|
||
assert_eq!(hints("new_design"), (false, true, false));
|
||
for name in ["set_parameter", "undo", "redo"] {
|
||
assert_eq!(hints(name), (false, false, false), "{name}");
|
||
}
|
||
assert!(tools.iter().all(|t| t["annotations"]["readOnlyHint"].is_boolean()), "every tool is annotated");
|
||
}
|
||
|
||
/// `measure` with `items` (#1392): a sketch circle's diameter and radius, and two circles'
|
||
/// centre-to-centre with the closest and farthest distances.
|
||
#[test]
|
||
fn measure_items_gives_diameters_and_centre_distances() {
|
||
let mut s = server();
|
||
tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}));
|
||
let a = tool(&mut s, "execute", json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 3}}));
|
||
let b = tool(&mut s, "execute", json!({"command": "sketch.circle.center", "params": {"center": [30, 40], "radius": 2}}));
|
||
let id = |r: &Value| r["curves"][0].as_str().or(r["result"]["curves"][0].as_str()).unwrap().to_string();
|
||
let (a, b) = (id(&a), id(&b));
|
||
let one = tool(&mut s, "measure", json!({"items": [{"type": "sketch_curve", "id": a}]}));
|
||
assert_eq!((one["diameter"].as_f64(), one["radius"].as_f64()), (Some(6.0), Some(3.0)), "{one}");
|
||
let two = tool(&mut s, "measure", json!({"items": [{"type": "sketch_curve", "id": a}, {"type": "sketch_curve", "id": b}]}));
|
||
assert!((two["center_distance"].as_f64().unwrap() - 50.0).abs() < 1e-9, "{two}");
|
||
assert!((two["min"].as_f64().unwrap() - 45.0).abs() < 1e-9 && (two["max"].as_f64().unwrap() - 55.0).abs() < 1e-9, "{two}");
|
||
assert!(two["distance_mm"].is_number() && two["from"].is_array(), "{two}");
|
||
}
|
||
|
||
/// #1833: `timings` / `profile` time every step run, the failing one included, headless with the
|
||
/// kernel's share; bridged, the app is asked to time them and its numbers are passed on.
|
||
#[test]
|
||
fn batch_reports_timings_and_profile() {
|
||
let mut s = server();
|
||
let steps = json!([
|
||
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}},
|
||
{"command": "solid.box", "params": {"length": 4, "width": 4, "height": 30, "operation": "join"}},
|
||
]);
|
||
let r = raw_tool(&mut s, "batch", json!({"commands": steps, "profile": true}));
|
||
let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||
let rows = body["timings"].as_array().unwrap();
|
||
assert_eq!(rows.len(), 2, "{body}");
|
||
assert_eq!((rows[1]["index"].as_u64(), rows[1]["command"].as_str()), (Some(1), Some("solid.box")));
|
||
assert!(rows[1]["profile"]["boolean_ms"].as_f64().unwrap() > 0.0, "{body}");
|
||
assert!(body["total_ms"].as_f64().is_some(), "{body}");
|
||
|
||
let r = raw_tool(
|
||
&mut s,
|
||
"batch",
|
||
json!({"timings": true, "commands": [{"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1}}, {"command": "NoSuchCommand"}]}),
|
||
);
|
||
let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||
assert_eq!(body["timings"].as_array().map(Vec::len), Some(2), "the failing step is timed too: {body}");
|
||
assert!(body["timings"][0].get("profile").is_none());
|
||
// Not asked: no timings.
|
||
let d = tool(&mut s, "batch", json!({"commands": [{"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1}}]}));
|
||
assert!(d.get("timings").is_none(), "{d}");
|
||
|
||
let reply =
|
||
json!({"completed": 1, "results": [{"x": 1}], "undone": 0, "timings": [{"index": 0, "command": "a.one", "ms": 2.5}], "total_ms": 2.6});
|
||
let (b, body, _) = recorded_batch(Ok(reply), json!({"commands": [{"command": "a.one"}], "profile": true}));
|
||
assert_eq!(b.calls[0].1["timings"], true);
|
||
assert_eq!(b.calls[0].1["profile"], true);
|
||
assert_eq!((body["timings"][0]["ms"].as_f64(), body["total_ms"].as_f64()), (Some(2.5), Some(2.6)), "{body}");
|
||
// An app without engine.batch is timed step by step here, without a kernel split it can't measure.
|
||
let (_, body, _) = recorded_batch(Err("unknown method `engine.batch`".into()), json!({"commands": [{"command": "a.one"}], "profile": true}));
|
||
assert_eq!(body["timings"][0]["command"], "a.one", "{body}");
|
||
assert!(body["timings"][0].get("profile").is_none(), "{body}");
|
||
}
|