//! Tests for the registry, dispatch, the denial sentences, the result cap and the fake tools. //! Do not edit. mod support; use loopd::tools::{ Dispatch, FakeTools, Pending, Registry, ToolPort, cap_result, denial_text, dispatch, }; use proto::{CallId, DenyReason, SessionId, ToolRequest, ToolResponse}; fn req(tool: &str, arguments: &str) -> ToolRequest { ToolRequest { session: SessionId::new("s").unwrap(), call: CallId(1), tool: tool.to_string(), arguments: arguments.to_string(), } } #[test] fn the_core_schemas_are_the_fixed_tools_array() { let names: Vec = Registry::m2b() .core_schemas() .into_iter() .map(|s| s.name) .collect(); assert_eq!(names, ["clock", "find_tool", "call_tool"]); let find = &Registry::m2b().core_schemas()[1]; assert_eq!(find.parameters["required"], serde_json::json!(["query"])); let call = &Registry::m2b().core_schemas()[2]; assert_eq!( call.parameters["required"], serde_json::json!(["name", "arguments"]) ); } #[test] fn find_matches_name_or_description_case_insensitively() { let r = Registry::m2b(); let names = |q: &str| r.find(q).iter().map(|s| s.name.clone()).collect::>(); assert_eq!(names("echo"), ["echo"]); assert_eq!(names("ECHO"), ["echo"]); assert_eq!(names("unchanged"), ["echo"], "matches the description too"); assert_eq!(names("time"), ["clock"]); assert!(names("weather").is_empty()); assert!( names("").is_empty(), "an empty query matches nothing, not everything" ); assert!(names(" ").is_empty()); } #[test] fn find_tool_is_answered_locally() { let r = Registry::m2b(); match dispatch(&r, "find_tool", r#"{"query":"echo"}"#) { Dispatch::Local(text) => { assert!(text.starts_with("1 tool(s) match:\n"), "{text}"); assert!(text.contains("\"name\":\"echo\""), "{text}"); assert!( text.contains("\"parameters\""), "the schema is included: {text}" ); assert!(text.ends_with("Call it with call_tool."), "{text}"); } other => panic!("{other:?}"), } assert_eq!( dispatch(&r, "find_tool", r#"{"query":"weather"}"#), Dispatch::Local("No tool matches.".to_string()) ); assert!( matches!(dispatch(&r, "find_tool", "not json"), Dispatch::Local(t) if t == "No tool matches.") ); } #[test] fn call_tool_unwraps_a_known_non_core_tool_and_nothing_else() { let r = Registry::m2b(); let args = r#"{"name":"echo","arguments":{"text": "box"}}"#; match dispatch(&r, "call_tool", args) { Dispatch::Port { tool, arguments } => { assert_eq!(tool, "echo"); assert_eq!( serde_json::from_str::(&arguments).unwrap(), serde_json::json!({"text": "box"}) ); } other => panic!("{other:?}"), } let cases = [ ( r#"{"name":"weather","arguments":{}}"#, "No tool named \"weather\"", ), (r#"{"name":"clock","arguments":{}}"#, "core tool"), (r#"{"arguments":{}}"#, "No tool named \"\""), ("not json", "needs a JSON object"), ]; for (args, want) in cases { match dispatch(&r, "call_tool", args) { Dispatch::Local(text) => assert!(text.contains(want), "{args}: {text}"), other => panic!("{args}: {other:?}, must never reach the port"), } } } #[test] fn any_other_tool_goes_to_the_port_as_it_is() { let r = Registry::m2b(); // Even one the registry does not know: the port (brokerd) decides, not loopd. let want = Dispatch::Port { tool: "read_file".to_string(), arguments: r#"{"path":"/x"}"#.to_string(), }; assert_eq!(dispatch(&r, "read_file", r#"{"path":"/x"}"#), want); let want = Dispatch::Port { tool: "weather".to_string(), arguments: "not json".to_string(), }; assert_eq!(dispatch(&r, "weather", "not json"), want); } #[test] fn the_clock_is_answered_locally_whatever_its_arguments() { for registry in [Registry::m2b(), Registry::m3a(), Registry::new(vec![])] { for arguments in ["{}", r#"{"zone":"UTC"}"#, "not json", ""] { let before = proto::Timestamp::now(); match dispatch(®istry, "clock", arguments) { Dispatch::Local(text) => { let time = proto::Timestamp::parse(&text) .unwrap_or_else(|e| panic!("an RFC 3339 time, got {text:?}: {e:?}")); assert!(time >= before, "{text}"); assert!(text.ends_with('Z'), "UTC: {text}"); } other => panic!("{arguments:?}: {other:?}, the clock must not reach the port"), } } } } #[test] fn the_m3a_registry_has_the_same_core_and_the_four_broker_tools() { let r = Registry::m3a(); assert_eq!( r.core_schemas(), Registry::m2b().core_schemas(), "the tools array is part of the baseline: it must not change" ); let names = |q: &str| r.find(q).iter().map(|s| s.name.clone()).collect::>(); assert_eq!(names("file"), ["read_file", "write_file"]); assert_eq!(names("shell"), ["shell"]); assert_eq!(names("fetch"), ["http_fetch"]); assert_eq!(names("https"), ["http_fetch"]); assert!(names("echo").is_empty(), "echo is a test tool only"); // The argument schemas are section 3's table: exactly these properties, all strings. let table: [(&str, &[&str], &[&str]); 4] = [ ("read_file", &["path"], &["path"]), ("write_file", &["content", "path"], &["path", "content"]), ("shell", &["command", "cwd"], &["command"]), ("http_fetch", &["url"], &["url"]), ]; for (name, properties, required) in table { let entry = r.get(name).unwrap_or_else(|| panic!("{name} is missing")); assert!(!entry.core, "{name} is found with find_tool, not declared"); let p = &entry.schema.parameters; assert_eq!(p["type"], "object", "{name}"); let mut got: Vec<&str> = p["properties"] .as_object() .unwrap() .keys() .map(String::as_str) .collect(); got.sort_unstable(); assert_eq!(got, properties, "{name}: properties"); for property in properties { assert_eq!( p["properties"][property]["type"], "string", "{name}.{property}" ); assert!( p["properties"][property]["description"].is_string(), "{name}.{property} needs a description" ); } assert_eq!( p["required"], serde_json::json!(required), "{name}: required" ); } // call_tool lets the four through to the port and nothing else. match dispatch( &r, "call_tool", r#"{"name":"shell","arguments":{"command":"ls"}}"#, ) { Dispatch::Port { tool, arguments } => { assert_eq!(tool, "shell"); assert_eq!(arguments, r#"{"command":"ls"}"#); } other => panic!("{other:?}"), } assert!(matches!( dispatch(&r, "call_tool", r#"{"name":"echo","arguments":{}}"#), Dispatch::Local(t) if t.contains("No tool named \"echo\"") )); } #[test] fn every_deny_reason_has_its_sentence() { let table = [ (DenyReason::NoGrant, "Denied: no grant allows this call."), ( DenyReason::GrantExpired, "Denied: the grant for this call has expired.", ), ( DenyReason::TaintTooHigh, "Denied: this session has seen data too sensitive for this call.", ), ( DenyReason::DeniedByGrant, "Denied: a grant forbids this call.", ), ( DenyReason::ApprovalRefused, "Denied: the owner refused this call.", ), ( DenyReason::ApprovalExpired, "Denied: the approval request expired without an answer.", ), ( DenyReason::InvalidArguments, "Denied: the arguments are not valid for this tool.", ), ( DenyReason::GrantsInvalid, "Denied: the grant files have an error; the owner has been told.", ), ( DenyReason::AuditUnavailable, "Denied: the audit log cannot be written; the owner has been told.", ), ( DenyReason::StateUnreadable, "Denied: this session's broker state is damaged; the owner has been told.", ), ]; for (reason, want) in table { assert_eq!(denial_text(reason), want, "{reason:?}"); } } #[test] fn results_are_cut_on_a_character_boundary_and_marked() { assert_eq!(cap_result("short", 100), ("short".to_string(), false)); assert_eq!(cap_result("exactly", 7), ("exactly".to_string(), false)); let (text, truncated) = cap_result("abcdefghij", 4); assert_eq!(text, "abcd\n[truncated]"); assert!(truncated); // 3 ASCII bytes, then 2-byte characters: byte 4 is inside a character. let (text, truncated) = cap_result("abc\u{e9}\u{e9}\u{e9}", 4); assert_eq!(text, "abc\n[truncated]"); assert!(truncated); assert_eq!(cap_result("", 0), (String::new(), false)); assert_eq!(cap_result("x", 0), ("\n[truncated]".to_string(), true)); } #[test] fn fake_tools_answer_echo_deny_the_rest_and_record_calls() { let fake = FakeTools::new(); let mut seen = 0; let mut on_pending = |_: &Pending| seen += 1; match fake.call(&req("echo", r#"{"text":"box"}"#), &mut on_pending) { ToolResponse::Result { content, class, untrusted, truncated, } => { assert_eq!(content, "box"); assert_eq!(class, proto::DataClass::Public); assert!(!untrusted && !truncated); } other => panic!("{other:?}"), } assert!(matches!( fake.call(&req("echo", r#"{"tex":"box"}"#), &mut on_pending), ToolResponse::Failed { .. } )); for tool in ["weather", "read_file", "clock"] { assert_eq!( fake.call(&req(tool, "{}"), &mut on_pending), ToolResponse::Denied { reason: DenyReason::NoGrant }, "{tool}: the clock is loopd's own now, not the port's" ); } assert_eq!(seen, 0, "the fake never asks for approval"); assert_eq!(fake.calls().len(), 5); assert_eq!(fake.calls()[0].tool, "echo"); }