mirror of
https://github.com/storytold/craft-anything.git
synced 2026-11-05 20:47:45 +01:00
f64ae3bf3c
A native, pure-Rust app that shows anyone how a piece of software works, with evidence-backed answers, then helps them rebuild it. It ships a desktop app, a CLI and an MCP server, all driven by one engine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
285 lines
14 KiB
Rust
285 lines
14 KiB
Rust
use super::*;
|
|
|
|
#[derive(Clone, Default)]
|
|
struct Buf(Arc<Mutex<Vec<u8>>>);
|
|
|
|
impl Write for Buf {
|
|
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
|
|
self.0.lock().unwrap().extend_from_slice(b);
|
|
Ok(b.len())
|
|
}
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn run_with(opts: ServerOptions, lines: &[Value]) -> Vec<Value> {
|
|
let input: String = lines.iter().map(|l| format!("{l}\n")).collect();
|
|
run_raw(opts, &input)
|
|
}
|
|
|
|
fn run_raw(opts: ServerOptions, input: &str) -> Vec<Value> {
|
|
let engine = Arc::new(Engine::new(craft_anything_engine::testing::session()));
|
|
let buf = Buf::default();
|
|
serve(engine, opts, std::io::Cursor::new(input.as_bytes().to_vec()), Box::new(buf.clone())).unwrap();
|
|
let out = String::from_utf8(buf.0.lock().unwrap().clone()).unwrap();
|
|
out.lines().map(|l| serde_json::from_str(l).unwrap()).collect()
|
|
}
|
|
|
|
fn run(lines: &[Value]) -> Vec<Value> {
|
|
run_with(ServerOptions::default(), lines)
|
|
}
|
|
|
|
fn req(id: u64, method: &str, params: Value) -> Value {
|
|
json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})
|
|
}
|
|
|
|
fn response(out: &[Value], id: u64) -> Value {
|
|
out.iter().find(|m| m["id"] == json!(id)).cloned().unwrap_or_else(|| panic!("no response {id} in {out:?}"))
|
|
}
|
|
|
|
/// A coding agent's permission-prompt tool accepts exactly one text block holding the decision
|
|
/// JSON; anything else (structuredContent, extra notes, an error result) makes it refuse every
|
|
/// action. Failures must still come back as a readable decision.
|
|
#[test]
|
|
fn approval_tool_answers_in_the_permission_prompt_shape() {
|
|
let out = run(&[
|
|
req(1, "tools/call", json!({"name": "core_action_approve", "arguments": {"tool_name": "Read", "input": {"file_path": "x"}, "tool_use_id": "t"}})),
|
|
req(2, "tools/call", json!({"name": "core_action_approve", "arguments": {"input": {}}})),
|
|
]);
|
|
for id in [1, 2] {
|
|
let r = response(&out, id)["result"].clone();
|
|
assert_eq!(r["isError"], false, "{r}");
|
|
assert!(r.get("structuredContent").is_none(), "{r}");
|
|
let content = r["content"].as_array().unwrap();
|
|
assert_eq!(content.len(), 1, "{r}");
|
|
assert_eq!(content[0]["type"], "text");
|
|
let decision: Value = serde_json::from_str(content[0]["text"].as_str().unwrap()).unwrap();
|
|
assert!(decision["behavior"] == "allow" || decision["behavior"] == "deny", "{decision}");
|
|
}
|
|
let ok: Value = serde_json::from_str(response(&out, 1)["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
|
assert_eq!(ok, json!({"behavior": "allow", "updatedInput": {"file_path": "x"}}));
|
|
let bad: Value = serde_json::from_str(response(&out, 2)["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
|
assert_eq!(bad["behavior"], "deny", "a malformed request is refused in the protocol's own words");
|
|
}
|
|
|
|
fn tool_names(v: &Value) -> Vec<String> {
|
|
v["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn initialize_negotiates_version() {
|
|
let out = run(&[
|
|
req(1, "initialize", json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}})),
|
|
json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
|
|
req(2, "initialize", json!({"protocolVersion": "1999-01-01"})),
|
|
req(3, "ping", json!({})),
|
|
]);
|
|
let r = response(&out, 1);
|
|
assert_eq!(r["result"]["protocolVersion"], "2025-03-26");
|
|
assert_eq!(r["result"]["serverInfo"]["name"], "craft-anything");
|
|
assert_eq!(r["result"]["capabilities"]["tools"]["listChanged"], true);
|
|
assert_eq!(response(&out, 2)["result"]["protocolVersion"], PROTOCOL_VERSIONS[0]);
|
|
assert_eq!(response(&out, 3)["result"], json!({}));
|
|
assert_eq!(out.len(), 3, "notifications get no response");
|
|
}
|
|
|
|
#[test]
|
|
fn progressive_toolsets() {
|
|
let out = run(&[req(1, "tools/list", json!({})), req(2, "tools/call", json!({"name": "enable_toolset", "arguments": {"toolset": "native"}}))]);
|
|
let names = tool_names(&response(&out, 1));
|
|
assert!(names.contains(&"core_session_show".to_string()));
|
|
assert!(names.contains(&"core_investigation_show".to_string()));
|
|
assert!(names.contains(&"enable_toolset".to_string()));
|
|
assert!(!names.contains(&"native_cursor_show".to_string()), "native tools hidden at first");
|
|
assert!(out.iter().any(|m| m["method"] == "notifications/tools/list_changed"));
|
|
// a second connection after enabling sees the tool
|
|
let out = run(&[req(1, "tools/call", json!({"name": "enable_toolset", "arguments": {"toolset": "native"}})), req(2, "tools/list", json!({}))]);
|
|
// tools/call runs on a worker; list may be answered first, so check the call result only
|
|
let r = response(&out, 1);
|
|
assert_eq!(r["result"]["isError"], false);
|
|
let all = run_with(ServerOptions { all_tools: true, ..Default::default() }, &[req(1, "tools/list", json!({}))]);
|
|
let names = tool_names(&response(&all, 1));
|
|
assert!(names.contains(&"native_cursor_show".to_string()));
|
|
assert!(!names.contains(&"enable_toolset".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn tool_call_success_and_error_shapes() {
|
|
let out = run(&[
|
|
req(1, "tools/call", json!({"name": "core_session_show", "arguments": {}})),
|
|
req(2, "tools/call", json!({"name": "core_target_close", "arguments": {}})),
|
|
req(3, "tools/call", json!({"name": "no_such_tool", "arguments": {}})),
|
|
]);
|
|
let ok = response(&out, 1);
|
|
assert_eq!(ok["result"]["isError"], false);
|
|
assert_eq!(ok["result"]["structuredContent"]["result"]["server"]["name"], "craft-anything");
|
|
let text: Value = serde_json::from_str(ok["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
|
assert_eq!(text, ok["result"]["structuredContent"]);
|
|
let err = response(&out, 2);
|
|
assert_eq!(err["result"]["isError"], true);
|
|
let e: Value = serde_json::from_str(err["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
|
assert_eq!(e["error"]["code"], "not_found");
|
|
assert!(e["error"]["remediation"].is_string());
|
|
assert_eq!(response(&out, 3)["result"]["isError"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn hidden_tool_call_enables_its_toolset() {
|
|
let out = run(&[req(1, "tools/call", json!({"name": "native_cursor_show", "arguments": {}}))]);
|
|
assert!(out.iter().any(|m| m["method"] == "notifications/tools/list_changed"));
|
|
assert_eq!(response(&out, 1)["result"]["isError"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn protocol_errors() {
|
|
let out = run_raw(ServerOptions::default(), "{not json\n[1,2]\n{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"bogus\"}\n\n");
|
|
assert_eq!(out[0]["error"]["code"], -32700);
|
|
assert_eq!(out[1]["error"]["code"], -32600);
|
|
assert_eq!(out[2]["error"]["code"], -32601);
|
|
assert_eq!(out[2]["id"], 7);
|
|
}
|
|
|
|
#[test]
|
|
fn prompts_and_completion() {
|
|
let out = run(&[
|
|
req(1, "prompts/list", json!({})),
|
|
req(2, "prompts/get", json!({"name": "native_crash_explain", "arguments": {"symptom": "SIGSEGV in parse"}})),
|
|
req(3, "prompts/get", json!({"name": "native_crash_explain", "arguments": {}})),
|
|
req(4, "completion/complete", json!({"ref": {"type": "ref/prompt", "name": "core_unknowns_review"}, "argument": {"name": "evidence", "value": "ev_"}})),
|
|
req(5, "completion/complete", json!({"ref": {"type": "ref/resource", "uri": "x"}, "argument": {"name": "a", "value": ""}})),
|
|
]);
|
|
assert_eq!(response(&out, 1)["result"]["prompts"].as_array().unwrap().len(), 6);
|
|
let text = response(&out, 2)["result"]["messages"][0]["content"]["text"].as_str().unwrap().to_string();
|
|
assert!(text.contains("SIGSEGV in parse"));
|
|
assert_eq!(response(&out, 3)["error"]["code"], -32602);
|
|
assert_eq!(response(&out, 4)["result"]["completion"]["values"], json!([]));
|
|
assert_eq!(response(&out, 5)["result"]["completion"]["total"], 0);
|
|
}
|
|
|
|
#[test]
|
|
fn resources() {
|
|
let out = run(&[
|
|
req(1, "resources/list", json!({})),
|
|
req(2, "resources/read", json!({"uri": "investigation://brief"})),
|
|
req(3, "resources/read", json!({"uri": "evidence://ev_missing"})),
|
|
req(4, "resources/templates/list", json!({})),
|
|
req(5, "resources/read", json!({"uri": "investigation://state"})),
|
|
]);
|
|
let list = response(&out, 1);
|
|
let uris: Vec<&str> = list["result"]["resources"].as_array().unwrap().iter().map(|r| r["uri"].as_str().unwrap()).collect();
|
|
assert!(uris.contains(&"investigation://handoff"));
|
|
assert!(response(&out, 2)["result"]["contents"][0]["text"].as_str().unwrap().starts_with("# Brief"));
|
|
assert_eq!(response(&out, 3)["error"]["code"], -32002);
|
|
assert_eq!(response(&out, 4)["result"]["resourceTemplates"][0]["uriTemplate"], "evidence://{evidence_id}");
|
|
let state: Value = serde_json::from_str(response(&out, 5)["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
|
|
assert_eq!(state["revision"], 0);
|
|
}
|
|
|
|
#[test]
|
|
fn oversize_results_become_resource_constraint() {
|
|
let out = run_with(
|
|
ServerOptions { max_response_bytes: 300, ..Default::default() },
|
|
&[req(1, "tools/call", json!({"name": "core_session_show", "arguments": {}}))],
|
|
);
|
|
let r = response(&out, 1);
|
|
assert_eq!(r["result"]["isError"], true);
|
|
let e: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
|
assert_eq!(e["error"]["code"], "over_limit");
|
|
assert_eq!(e["error"]["details"]["resource"], "transport");
|
|
}
|
|
|
|
#[test]
|
|
fn progress_token_gets_notifications_and_cancel_of_unknown_is_ignored() {
|
|
let out = run(&[
|
|
json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 99}}),
|
|
json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "core_session_show", "arguments": {}, "_meta": {"progressToken": "p1"}}}),
|
|
]);
|
|
let progress: Vec<&Value> = out.iter().filter(|m| m["method"] == "notifications/progress").collect();
|
|
assert!(!progress.is_empty());
|
|
assert_eq!(progress[0]["params"]["progressToken"], "p1");
|
|
assert_eq!(response(&out, 1)["result"]["isError"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn memory_tools_work_over_mcp() {
|
|
let out = run(&[req(1, "tools/call", json!({"name": "core_brief_write", "arguments": {"question": "How does login work?"}}))]);
|
|
assert_eq!(response(&out, 1)["result"]["isError"], false);
|
|
}
|
|
|
|
fn test_server(engine: Arc<Engine>, opts: ServerOptions) -> Server {
|
|
Server::new(engine, opts, Box::new(Buf::default()))
|
|
}
|
|
|
|
#[test]
|
|
fn provenance_meta_is_set_for_target_content() {
|
|
let server = test_server(Arc::new(Engine::new(craft_anything_engine::testing::session())), ServerOptions::default());
|
|
|
|
// By tool id: a known producer of target content, even when the record didn't set the flag.
|
|
let mut body = json!({"content": [{"type": "text", "text": "x"}], "structuredContent": {"result": {}}, "isError": false});
|
|
server.attach_provenance(&mut body, "native_function_pseudocode");
|
|
assert_eq!(body["_meta"]["provenance"]["from_target"], true);
|
|
assert!(body["content"].as_array().unwrap().iter().any(|c| c["text"].as_str().unwrap_or_default().contains("data, not instructions")));
|
|
|
|
// By the evidence flag: an unlisted tool whose evidence set carries_target_content.
|
|
let mut body2 = json!({"content": [], "structuredContent": {"carries_target_content": true}, "isError": false});
|
|
server.attach_provenance(&mut body2, "some_unlisted_tool");
|
|
assert_eq!(body2["_meta"]["provenance"]["from_target"], true);
|
|
|
|
// A plain tool with no target content: no provenance marker.
|
|
let mut body3 = json!({"content": [], "structuredContent": {"result": {"open": false}}, "isError": false});
|
|
server.attach_provenance(&mut body3, "core_session_show");
|
|
assert!(body3.get("_meta").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn oversize_evidence_collapses_to_a_summary() {
|
|
use craft_anything_evidence::{Confidence, Environment, EvidenceBuilder, Subject};
|
|
let engine = Arc::new(Engine::new(craft_anything_engine::testing::session()));
|
|
let ev = EvidenceBuilder::new(
|
|
"extract_strings",
|
|
Subject { kind: "native".into(), sha256: Some("ab".repeat(32)), name: Some("calc".into()) },
|
|
Environment::builtin(true),
|
|
)
|
|
.result(json!({"strings": vec!["x"; 4000]}))
|
|
.summary("Four thousand strings.")
|
|
.confidence(Confidence::Observed)
|
|
.target_content(true)
|
|
.build()
|
|
.unwrap();
|
|
let ev_id = ev.id.clone();
|
|
let full = serde_json::to_value(&ev).unwrap();
|
|
engine.session().record(ev).unwrap();
|
|
|
|
let server = test_server(engine, ServerOptions { max_response_bytes: 512, ..Default::default() });
|
|
// Shape the success response the server would have produced for this evidence.
|
|
let resp = ok_response(json!(1), json!({"structuredContent": full, "isError": false}));
|
|
let summary = server.oversize_summary(&resp).expect("a saved summary");
|
|
let sc = &summary["structuredContent"];
|
|
assert_eq!(sc["detail"], "summary");
|
|
assert_eq!(sc["saved_evidence"]["evidence_id"], json!(ev_id));
|
|
assert_eq!(sc["continue_with"], "core_evidence_browse");
|
|
assert_eq!(summary["isError"], false);
|
|
// It carries the from-target marker too.
|
|
assert_eq!(summary["_meta"]["provenance"]["from_target"], true);
|
|
// And the summary is far smaller than the full record.
|
|
assert!(serde_json::to_string(&summary).unwrap().len() < serde_json::to_string(&resp).unwrap().len());
|
|
}
|
|
|
|
/// Claude Code takes about 25k tokens of a tool's output, so its connections get a smaller budget
|
|
/// (bigger results come back summary-first); other clients and an explicit budget are left alone.
|
|
#[test]
|
|
fn claude_code_connections_get_a_budget_that_fits_its_limit() {
|
|
let server = |opts: ServerOptions| Server::new(Arc::new(Engine::new(craft_anything_engine::testing::session())), opts, Box::new(Buf::default()));
|
|
let hello = |name: &str| json!({"protocolVersion": PROTOCOL_VERSIONS[0], "clientInfo": {"name": name, "version": "1"}});
|
|
let s = server(ServerOptions::default());
|
|
s.handle_request("initialize", &hello("claude-code")).unwrap();
|
|
assert_eq!(s.budget(), CLAUDE_CODE_RESPONSE_BYTES);
|
|
let other = server(ServerOptions::default());
|
|
other.handle_request("initialize", &hello("some-editor")).unwrap();
|
|
assert_eq!(other.budget(), DEFAULT_MAX_RESPONSE_BYTES);
|
|
let set = server(ServerOptions { max_response_bytes: 500_000, budget_for_client: false, ..Default::default() });
|
|
set.handle_request("initialize", &hello("claude-code")).unwrap();
|
|
assert_eq!(set.budget(), 500_000, "an explicit budget wins");
|
|
}
|