1use std::collections::HashMap;
23use std::sync::Arc;
24
25use crate::error::LuaError;
26use crate::types::serde_json_to_lua;
27use mlua::{Lua, Table};
28use orcs_component::tool::RustTool;
29use orcs_component::ComponentError;
30use orcs_types::intent::{IntentDef, IntentResolver};
31
32pub struct IntentRegistry {
45 defs: Vec<IntentDef>,
46 index: HashMap<String, usize>,
48 tools: HashMap<String, Arc<dyn RustTool>>,
52}
53
54impl Default for IntentRegistry {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60impl IntentRegistry {
61 pub fn new() -> Self {
63 let builtin_tools = crate::builtin_tools::builtin_rust_tools();
64
65 let mut defs: Vec<IntentDef> = builtin_tools.iter().map(|t| t.intent_def()).collect();
67 let tools: HashMap<String, Arc<dyn RustTool>> = builtin_tools
68 .into_iter()
69 .map(|t| (t.name().to_string(), t))
70 .collect();
71
72 defs.push(exec_intent_def());
74
75 let index = defs
76 .iter()
77 .enumerate()
78 .map(|(i, d)| (d.name.clone(), i))
79 .collect();
80
81 Self { defs, index, tools }
82 }
83
84 pub fn get(&self, name: &str) -> Option<&IntentDef> {
86 self.index.get(name).map(|&i| &self.defs[i])
87 }
88
89 pub fn get_tool(&self, name: &str) -> Option<&Arc<dyn RustTool>> {
91 self.tools.get(name)
92 }
93
94 pub fn register(&mut self, def: IntentDef) -> Result<(), String> {
96 if self.index.contains_key(&def.name) {
97 return Err(format!("intent already registered: {}", def.name));
98 }
99 let idx = self.defs.len();
100 self.index.insert(def.name.clone(), idx);
101 self.defs.push(def);
102 Ok(())
103 }
104
105 pub fn register_tool(&mut self, tool: Arc<dyn RustTool>) -> Result<(), String> {
108 let def = tool.intent_def();
109 let name = def.name.clone();
110 self.register(def)?;
111 self.tools.insert(name, tool);
112 Ok(())
113 }
114
115 pub fn all(&self) -> &[IntentDef] {
117 &self.defs
118 }
119
120 pub fn len(&self) -> usize {
122 self.defs.len()
123 }
124
125 pub fn is_empty(&self) -> bool {
127 self.defs.is_empty()
128 }
129}
130
131fn exec_intent_def() -> IntentDef {
139 IntentDef {
140 name: "exec".into(),
141 description: "Execute shell command. cwd = project root.".into(),
142 parameters: serde_json::json!({
143 "type": "object",
144 "properties": {
145 "cmd": {
146 "type": "string",
147 "description": "Shell command to execute",
148 }
149 },
150 "required": ["cmd"],
151 }),
152 resolver: IntentResolver::Internal,
153 }
154}
155
156fn dispatch_tool(lua: &Lua, name: &str, args: &Table) -> mlua::Result<Table> {
164 let resolver = {
165 let registry = ensure_registry(lua)?;
166 match registry.get(name) {
167 Some(def) => def.resolver.clone(),
168 None => {
169 let result = lua.create_table()?;
170 set_error(&result, &format!("unknown intent: {name}"))?;
171 return Ok(result);
172 }
173 }
174 };
175
176 let start = std::time::Instant::now();
177 let result = match resolver {
178 IntentResolver::Internal => dispatch_internal(lua, name, args),
179 IntentResolver::Component {
180 component_fqn,
181 operation,
182 timeout_ms,
183 } => dispatch_component(lua, name, &component_fqn, &operation, args, timeout_ms),
184 IntentResolver::Mcp {
185 server_name,
186 tool_name,
187 } => dispatch_mcp(lua, name, &server_name, &tool_name, args),
188 };
189 let duration_ms = start.elapsed().as_millis() as u64;
190 let ok = result
191 .as_ref()
192 .map(|t| t.get::<bool>("ok").unwrap_or(false))
193 .unwrap_or(false);
194 tracing::info!(
195 "intent dispatch: {name} → {ok} ({duration_ms}ms)",
196 ok = if ok { "ok" } else { "err" }
197 );
198 result
199}
200
201fn check_mutation_approval(lua: &Lua, name: &str, args: &Table) -> mlua::Result<()> {
219 let wrapper = match lua.app_data_ref::<crate::context_wrapper::ContextWrapper>() {
222 Some(w) => w,
223 None => return Ok(()),
224 };
225 let ctx = wrapper.0.lock();
226
227 let intent_cmd = format!("intent:{name}");
230 if ctx.is_command_granted(&intent_cmd) {
231 return Ok(());
232 }
233
234 let approval_id = format!("ap-{}", uuid::Uuid::new_v4());
236 let detail = build_intent_description(name, args);
237
238 tracing::info!(
239 approval_id = %approval_id,
240 intent = %name,
241 grant_pattern = %intent_cmd,
242 "intent requires approval, suspending"
243 );
244
245 Err(mlua::Error::ExternalError(std::sync::Arc::new(
246 ComponentError::Suspended {
247 approval_id,
248 grant_pattern: intent_cmd.clone(),
249 pending_request: serde_json::json!({
250 "command": intent_cmd,
251 "description": detail,
252 }),
253 },
254 )))
255}
256
257fn build_intent_description(name: &str, args: &Table) -> String {
259 let path_or = |key: &str| -> String {
260 args.get::<String>(key)
261 .ok()
262 .filter(|s| !s.is_empty())
263 .unwrap_or_else(|| "<unknown>".to_string())
264 };
265
266 match name {
267 "write" => format!("Write to file: {}", path_or("path")),
268 "remove" => format!("Remove: {}", path_or("path")),
269 "mv" => format!("Move: {} -> {}", path_or("src"), path_or("dst")),
270 "mkdir" => format!("Create directory: {}", path_or("path")),
271 _ => format!("Execute intent: {name}"),
272 }
273}
274
275fn dispatch_internal(lua: &Lua, name: &str, args: &Table) -> mlua::Result<Table> {
285 let tool = {
287 let registry = ensure_registry(lua)?;
288 registry.get_tool(name).cloned()
289 };
290
291 if name != "exec" {
294 let is_read_only = tool.as_ref().is_some_and(|t| t.is_read_only());
295 if !is_read_only {
296 check_mutation_approval(lua, name, args)?;
297 }
298 }
299
300 if let Some(tool) = tool {
301 return dispatch_rust_tool(lua, &*tool, args);
302 }
303
304 dispatch_internal_lua(lua, name, args)
306}
307
308pub(crate) fn dispatch_rust_tool(
328 lua: &Lua,
329 tool: &dyn RustTool,
330 args: &Table,
331) -> mlua::Result<Table> {
332 use crate::context_wrapper::ContextWrapper;
333 use orcs_component::tool::{ToolContext, ToolError};
334
335 let sandbox: Arc<dyn orcs_runtime::sandbox::SandboxPolicy> = Arc::clone(
337 &*lua
338 .app_data_ref::<Arc<dyn orcs_runtime::sandbox::SandboxPolicy>>()
339 .ok_or_else(|| mlua::Error::RuntimeError("sandbox not available".into()))?,
340 );
341 let json_args = crate::types::lua_to_json(mlua::Value::Table(args.clone()), lua)?;
342
343 let cap = tool.required_capability();
344
345 let exec_result: Result<serde_json::Value, ToolError> =
349 match lua.app_data_ref::<ContextWrapper>() {
350 Some(wrapper) => {
351 let guard = wrapper.0.lock();
352 let child_ctx: &dyn orcs_component::ChildContext = &**guard;
353
354 if !child_ctx.capabilities().contains(cap) {
355 Err(ToolError::new(format!(
356 "permission denied: {cap} not granted"
357 )))
358 } else {
359 let ctx = ToolContext::new(&*sandbox).with_child_ctx(child_ctx);
360 tool.execute(json_args, &ctx)
361 }
362 }
363 None => {
364 let ctx = ToolContext::new(&*sandbox);
365 tool.execute(json_args, &ctx)
366 }
367 };
368
369 let result_table = lua.create_table()?;
370 match exec_result {
371 Ok(value) => {
372 result_table.set("ok", true)?;
373 if let Some(obj) = value.as_object() {
375 for (k, v) in obj {
376 let lua_val = crate::types::serde_json_to_lua(v, lua)?;
377 result_table.set(k.as_str(), lua_val)?;
378 }
379 }
380 }
381 Err(e) => {
382 result_table.set("ok", false)?;
383 result_table.set("error", e.message().to_string())?;
384 }
385 }
386
387 Ok(result_table)
388}
389
390fn dispatch_internal_lua(lua: &Lua, name: &str, args: &Table) -> mlua::Result<Table> {
392 let orcs: Table = lua.globals().get("orcs")?;
393
394 match name {
395 "exec" => {
396 let cmd: String = get_required_arg(args, "cmd")?;
397 let f: mlua::Function = orcs.get("exec")?;
398 f.call(cmd)
399 }
400 _ => {
401 let result = lua.create_table()?;
404 set_error(
405 &result,
406 &format!("internal dispatch error: no handler for '{name}'"),
407 )?;
408 Ok(result)
409 }
410 }
411}
412
413fn dispatch_component(
422 lua: &Lua,
423 intent_name: &str,
424 component_fqn: &str,
425 operation: &str,
426 args: &Table,
427 timeout_ms: Option<u64>,
428) -> mlua::Result<Table> {
429 let orcs: Table = lua.globals().get("orcs")?;
430
431 let request_fn = match orcs.get::<mlua::Function>("request") {
432 Ok(f) => f,
433 Err(_) => {
434 let result = lua.create_table()?;
435 set_error(
436 &result,
437 "component dispatch unavailable: no execution context (orcs.request not registered)",
438 )?;
439 return Ok(result);
440 }
441 };
442
443 let payload = lua.create_table()?;
445 for pair in args.pairs::<mlua::Value, mlua::Value>() {
446 let (k, v) = pair?;
447 payload.set(k, v)?;
448 }
449
450 let opts = match timeout_ms {
452 Some(ms) => {
453 let t = lua.create_table()?;
454 t.set("timeout_ms", ms)?;
455 mlua::Value::Table(t)
456 }
457 None => mlua::Value::Nil,
458 };
459
460 let start = std::time::Instant::now();
462 let rpc_result: Table = request_fn.call((component_fqn, operation, payload, opts))?;
463 let duration_ms = start.elapsed().as_millis() as u64;
464
465 tracing::debug!(
466 "component dispatch: {intent_name} → {component_fqn}::{operation} ({duration_ms}ms)"
467 );
468
469 let result = lua.create_table()?;
471 let success: bool = rpc_result.get("success").unwrap_or(false);
472 result.set("ok", success)?;
473 result.set("duration_ms", duration_ms)?;
474
475 if success {
476 if let Ok(data) = rpc_result.get::<mlua::Value>("data") {
478 result.set("data", data)?;
479 }
480 } else {
481 let error_msg: String = rpc_result
483 .get("error")
484 .unwrap_or_else(|_| format!("component RPC failed: {component_fqn}::{operation}"));
485 result.set("error", error_msg)?;
486 }
487
488 Ok(result)
489}
490
491pub(crate) struct SharedMcpManager(pub Arc<orcs_mcp::McpClientManager>);
497
498pub(crate) struct PendingMcpDefs(pub Vec<orcs_types::intent::IntentDef>);
510
511fn dispatch_mcp(
525 lua: &Lua,
526 intent_name: &str,
527 server_name: &str,
528 tool_name: &str,
529 args: &Table,
530) -> mlua::Result<Table> {
531 let manager = match lua.app_data_ref::<SharedMcpManager>() {
533 Some(m) => Arc::clone(&m.0),
534 None => {
535 let result = lua.create_table()?;
536 set_error(
537 &result,
538 &format!("MCP client not initialized: {intent_name} (server={server_name})"),
539 )?;
540 return Ok(result);
541 }
542 };
543
544 let json_args = crate::types::lua_to_json(mlua::Value::Table(args.clone()), lua)?;
546
547 let handle = tokio::runtime::Handle::try_current()
549 .map_err(|_| mlua::Error::RuntimeError("no tokio runtime available for MCP call".into()))?;
550
551 let namespaced = format!("mcp:{server_name}:{tool_name}");
552 let call_result =
553 tokio::task::block_in_place(|| handle.block_on(manager.call_tool(&namespaced, json_args)));
554
555 let result = lua.create_table()?;
557 match call_result {
558 Ok(tool_result) => {
559 let is_error = tool_result.is_error.unwrap_or(false);
560 result.set("ok", !is_error)?;
561
562 let text = orcs_mcp::content_to_text(&tool_result.content);
564 if !text.is_empty() {
565 if is_error {
566 result.set("error", text)?;
567 } else {
568 result.set("content", text)?;
569 }
570 }
571 }
572 Err(e) => {
573 set_error(&result, &format!("MCP call failed: {e}"))?;
574 }
575 }
576
577 Ok(result)
578}
579
580pub(crate) fn ensure_registry(lua: &Lua) -> mlua::Result<mlua::AppDataRef<'_, IntentRegistry>> {
584 if lua.app_data_ref::<IntentRegistry>().is_none() {
585 lua.set_app_data(IntentRegistry::new());
586 }
587
588 if let Some(pending) = lua.remove_app_data::<PendingMcpDefs>() {
590 if let Some(mut registry) = lua.app_data_mut::<IntentRegistry>() {
591 for def in pending.0 {
592 if let Err(e) = registry.register(def) {
593 tracing::warn!(error = %e, "Failed to register deferred MCP intent");
594 }
595 }
596 }
597 }
598
599 lua.app_data_ref::<IntentRegistry>().ok_or_else(|| {
600 mlua::Error::RuntimeError("IntentRegistry not available after initialization".into())
601 })
602}
603
604pub fn generate_descriptions(lua: &Lua) -> String {
606 let registry = match ensure_registry(lua) {
607 Ok(r) => r,
608 Err(_) => return "IntentRegistry not available.\n".to_string(),
609 };
610
611 let mut out = String::from("Available tools:\n\n");
612
613 for def in registry.all() {
614 let args_fmt = extract_arg_names(&def.parameters);
616 out.push_str(&format!(
617 "{}({}) - {}\n",
618 def.name, args_fmt, def.description
619 ));
620 }
621
622 out.push_str("\norcs.pwd - Project root path (string).\n");
623 out
624}
625
626fn extract_arg_names(schema: &serde_json::Value) -> String {
628 let properties = match schema.get("properties").and_then(|p| p.as_object()) {
629 Some(p) => p,
630 None => return String::new(),
631 };
632
633 let required: Vec<&str> = schema
634 .get("required")
635 .and_then(|r| r.as_array())
636 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
637 .unwrap_or_default();
638
639 properties
640 .keys()
641 .map(|name| {
642 if required.contains(&name.as_str()) {
643 name.clone()
644 } else {
645 format!("{name}?")
646 }
647 })
648 .collect::<Vec<_>>()
649 .join(", ")
650}
651
652fn get_required_arg(args: &Table, name: &str) -> mlua::Result<String> {
656 args.get::<String>(name)
657 .map_err(|_| mlua::Error::RuntimeError(format!("missing required argument: {name}")))
658}
659
660fn set_error(result: &Table, msg: &str) -> mlua::Result<()> {
662 result.set("ok", false)?;
663 result.set("error", msg.to_string())?;
664 Ok(())
665}
666
667pub fn register_dispatch_functions(lua: &Lua) -> Result<(), LuaError> {
677 if lua.app_data_ref::<IntentRegistry>().is_none() {
679 lua.set_app_data(IntentRegistry::new());
680 }
681
682 let orcs_table: Table = lua.globals().get("orcs")?;
683
684 let dispatch_fn =
686 lua.create_function(|lua, (name, args): (String, Table)| dispatch_tool(lua, &name, &args))?;
687 orcs_table.set("dispatch", dispatch_fn)?;
688
689 let schemas_fn = lua.create_function(|lua, ()| {
691 let registry = ensure_registry(lua)?;
692 let result = lua.create_table()?;
693
694 for (i, def) in registry.all().iter().enumerate() {
695 let entry = lua.create_table()?;
696 entry.set("name", def.name.as_str())?;
697 entry.set("description", def.description.as_str())?;
698
699 let args_table = lua.create_table()?;
701 if let Some(properties) = def.parameters.get("properties").and_then(|p| p.as_object()) {
702 let required: Vec<&str> = def
703 .parameters
704 .get("required")
705 .and_then(|r| r.as_array())
706 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
707 .unwrap_or_default();
708
709 for (j, (prop_name, prop_schema)) in properties.iter().enumerate() {
710 let arg_entry = lua.create_table()?;
711 arg_entry.set("name", prop_name.as_str())?;
712
713 let is_required = required.contains(&prop_name.as_str());
714 let type_str = if is_required { "string" } else { "string?" };
715 arg_entry.set("type", type_str)?;
716 arg_entry.set("required", is_required)?;
717
718 let description = prop_schema
719 .get("description")
720 .and_then(|d| d.as_str())
721 .unwrap_or("");
722 arg_entry.set("description", description)?;
723
724 args_table.set(j + 1, arg_entry)?;
725 }
726 }
727 entry.set("args", args_table)?;
728 result.set(i + 1, entry)?;
729 }
730
731 Ok(result)
732 })?;
733 orcs_table.set("tool_schemas", schemas_fn)?;
734
735 let intent_defs_fn = lua.create_function(|lua, ()| {
737 let registry = ensure_registry(lua)?;
738 let result = lua.create_table()?;
739
740 for (i, def) in registry.all().iter().enumerate() {
741 let entry = lua.create_table()?;
742 entry.set("name", def.name.as_str())?;
743 entry.set("description", def.description.as_str())?;
744
745 let params_value = serde_json_to_lua(&def.parameters, lua)?;
747 entry.set("parameters", params_value)?;
748
749 result.set(i + 1, entry)?;
750 }
751
752 Ok(result)
753 })?;
754 orcs_table.set("intent_defs", intent_defs_fn)?;
755
756 let register_fn = lua.create_function(|lua, def_table: Table| {
758 let name: String = def_table
759 .get("name")
760 .map_err(|_| mlua::Error::RuntimeError("register_intent: 'name' is required".into()))?;
761 let description: String = def_table.get("description").map_err(|_| {
762 mlua::Error::RuntimeError("register_intent: 'description' is required".into())
763 })?;
764
765 let component_fqn: String = def_table.get("component").map_err(|_| {
767 mlua::Error::RuntimeError("register_intent: 'component' is required".into())
768 })?;
769 let operation: String = def_table
770 .get("operation")
771 .unwrap_or_else(|_| "execute".to_string());
772
773 let parameters = match def_table.get::<Table>("params") {
775 Ok(params_table) => {
776 let mut properties = serde_json::Map::new();
778 let mut required = Vec::new();
779
780 for pair in params_table.pairs::<String, Table>() {
781 let (param_name, param_def) = pair?;
782 let type_str: String = param_def
783 .get("type")
784 .unwrap_or_else(|_| "string".to_string());
785 let desc: String = param_def
786 .get("description")
787 .unwrap_or_else(|_| String::new());
788 let is_required: bool = param_def.get("required").unwrap_or(false);
789
790 properties.insert(
791 param_name.clone(),
792 serde_json::json!({
793 "type": type_str,
794 "description": desc,
795 }),
796 );
797 if is_required {
798 required.push(serde_json::Value::String(param_name));
799 }
800 }
801
802 serde_json::json!({
803 "type": "object",
804 "properties": properties,
805 "required": required,
806 })
807 }
808 Err(_) => serde_json::json!({"type": "object", "properties": {}}),
809 };
810
811 let timeout_ms: Option<u64> = def_table.get("timeout_ms").ok();
813
814 let intent_def = IntentDef {
815 name: name.clone(),
816 description,
817 parameters,
818 resolver: IntentResolver::Component {
819 component_fqn,
820 operation,
821 timeout_ms,
822 },
823 };
824
825 if let Some(mut registry) = lua.remove_app_data::<IntentRegistry>() {
827 let result = registry.register(intent_def);
828 lua.set_app_data(registry);
829
830 let result_table = lua.create_table()?;
831 match result {
832 Ok(()) => {
833 result_table.set("ok", true)?;
834 }
835 Err(e) => {
836 result_table.set("ok", false)?;
837 result_table.set("error", e)?;
838 }
839 }
840 Ok(result_table)
841 } else {
842 Err(mlua::Error::RuntimeError(
843 "IntentRegistry not initialized".into(),
844 ))
845 }
846 })?;
847 orcs_table.set("register_intent", register_fn)?;
848
849 let tool_desc_fn = lua.create_function(|lua, ()| Ok(generate_descriptions(lua)))?;
851 orcs_table.set("tool_descriptions", tool_desc_fn)?;
852
853 let mcp_servers_fn = lua.create_function(|lua, ()| {
857 let result = lua.create_table()?;
858
859 let manager = match lua.app_data_ref::<SharedMcpManager>() {
860 Some(m) => Arc::clone(&m.0),
861 None => {
862 result.set("ok", true)?;
863 result.set("servers", lua.create_table()?)?;
864 return Ok(result);
865 }
866 };
867
868 let handle = tokio::runtime::Handle::try_current().map_err(|_| {
869 mlua::Error::RuntimeError("no tokio runtime available for mcp_servers".into())
870 })?;
871
872 let names = tokio::task::block_in_place(|| handle.block_on(manager.connected_servers()));
873
874 let servers = lua.create_table()?;
875 for (i, name) in names.iter().enumerate() {
876 let entry = lua.create_table()?;
877 entry.set("name", name.as_str())?;
878 servers.set(i + 1, entry)?;
879 }
880
881 result.set("ok", true)?;
882 result.set("servers", servers)?;
883 Ok(result)
884 })?;
885 orcs_table.set("mcp_servers", mcp_servers_fn)?;
886
887 let mcp_tools_fn = lua.create_function(|lua, server_filter: Option<String>| {
889 let result = lua.create_table()?;
890
891 let registry = match lua.app_data_ref::<IntentRegistry>() {
892 Some(r) => r,
893 None => {
894 result.set("ok", true)?;
895 result.set("tools", lua.create_table()?)?;
896 return Ok(result);
897 }
898 };
899
900 let tools = lua.create_table()?;
901 let mut idx = 0usize;
902
903 for def in registry.all() {
904 if let IntentResolver::Mcp {
905 ref server_name,
906 ref tool_name,
907 } = def.resolver
908 {
909 if let Some(ref filter) = server_filter {
911 if server_name != filter {
912 continue;
913 }
914 }
915
916 idx += 1;
917 let entry = lua.create_table()?;
918 entry.set("name", def.name.as_str())?;
919 entry.set("description", def.description.as_str())?;
920 entry.set("server", server_name.as_str())?;
921 entry.set("tool", tool_name.as_str())?;
922
923 let params_value = serde_json_to_lua(&def.parameters, lua)?;
924 entry.set("parameters", params_value)?;
925
926 tools.set(idx, entry)?;
927 }
928 }
929
930 result.set("ok", true)?;
931 result.set("tools", tools)?;
932 Ok(result)
933 })?;
934 orcs_table.set("mcp_tools", mcp_tools_fn)?;
935
936 let mcp_call_fn =
938 lua.create_function(|lua, (server, tool, args): (String, String, Table)| {
939 let namespaced = format!("mcp:{server}:{tool}");
940 dispatch_mcp(lua, &namespaced, &server, &tool, &args)
941 })?;
942 orcs_table.set("mcp_call", mcp_call_fn)?;
943
944 Ok(())
945}
946
947#[cfg(test)]
948mod tests {
949 use super::*;
950 use crate::orcs_helpers::register_base_orcs_functions;
951 use orcs_runtime::sandbox::{ProjectSandbox, SandboxPolicy};
952 use orcs_runtime::WorkDir;
953 use std::fs;
954 use std::sync::Arc;
955
956 fn test_sandbox() -> (WorkDir, Arc<dyn SandboxPolicy>) {
957 let wd = WorkDir::temporary().expect("should create temp work dir");
958 let dir = wd
959 .path()
960 .canonicalize()
961 .expect("should canonicalize temp dir");
962 let sandbox = ProjectSandbox::new(&dir).expect("test sandbox");
963 (wd, Arc::new(sandbox))
964 }
965
966 fn setup_lua(sandbox: Arc<dyn SandboxPolicy>) -> Lua {
967 let lua = Lua::new();
968 register_base_orcs_functions(&lua, sandbox).expect("should register base functions");
969 lua
970 }
971
972 #[test]
975 fn registry_new_has_8_builtins() {
976 let registry = IntentRegistry::new();
977 assert_eq!(registry.len(), 8, "should have 8 builtin intents");
978 }
979
980 #[test]
981 fn registry_get_existing() {
982 let registry = IntentRegistry::new();
983 let def = registry.get("read").expect("'read' should exist");
984 assert_eq!(def.name, "read");
985 assert_eq!(def.resolver, IntentResolver::Internal);
986 }
987
988 #[test]
989 fn registry_get_nonexistent() {
990 let registry = IntentRegistry::new();
991 assert!(registry.get("nonexistent").is_none());
992 }
993
994 #[test]
995 fn registry_register_new_intent() {
996 let mut registry = IntentRegistry::new();
997 let def = IntentDef {
998 name: "custom_tool".into(),
999 description: "A custom tool".into(),
1000 parameters: serde_json::json!({"type": "object", "properties": {}}),
1001 resolver: IntentResolver::Component {
1002 component_fqn: "lua::my_comp".into(),
1003 operation: "execute".into(),
1004 timeout_ms: None,
1005 },
1006 };
1007 registry
1008 .register(def)
1009 .expect("should register successfully");
1010 assert_eq!(registry.len(), 9);
1011 assert!(registry.get("custom_tool").is_some());
1012 }
1013
1014 #[test]
1015 fn registry_register_duplicate_fails() {
1016 let mut registry = IntentRegistry::new();
1017 let def = IntentDef {
1018 name: "read".into(),
1019 description: "duplicate".into(),
1020 parameters: serde_json::json!({}),
1021 resolver: IntentResolver::Internal,
1022 };
1023 let err = registry.register(def).expect_err("should reject duplicate");
1024 assert!(
1025 err.contains("already registered"),
1026 "error should mention duplicate, got: {err}"
1027 );
1028 }
1029
1030 #[test]
1031 fn registry_all_intent_defs_have_json_schema() {
1032 let registry = IntentRegistry::new();
1033 for def in registry.all() {
1034 assert_eq!(
1035 def.parameters.get("type").and_then(|v| v.as_str()),
1036 Some("object"),
1037 "intent '{}' should have JSON Schema with type=object",
1038 def.name
1039 );
1040 assert!(
1041 def.parameters.get("properties").is_some(),
1042 "intent '{}' should have properties",
1043 def.name
1044 );
1045 }
1046 }
1047
1048 #[test]
1049 fn builtin_intent_names_match_expected() {
1050 let registry = IntentRegistry::new();
1051 let names: Vec<&str> = registry.all().iter().map(|d| d.name.as_str()).collect();
1052 assert_eq!(
1053 names,
1054 vec!["read", "write", "grep", "glob", "mkdir", "remove", "mv", "exec"]
1055 );
1056 }
1057
1058 #[test]
1061 fn dispatch_read() {
1062 let (wd, sandbox) = test_sandbox();
1063 fs::write(wd.path().join("test.txt"), "hello dispatch").expect("should write test file");
1064
1065 let lua = setup_lua(sandbox);
1066 let result: Table = lua
1067 .load(format!(
1068 r#"return orcs.dispatch("read", {{path="{}"}})"#,
1069 wd.path().join("test.txt").display()
1070 ))
1071 .eval()
1072 .expect("dispatch read should succeed");
1073
1074 assert!(result.get::<bool>("ok").expect("should have ok field"));
1075 assert_eq!(
1076 result
1077 .get::<String>("content")
1078 .expect("should have content"),
1079 "hello dispatch"
1080 );
1081 }
1082
1083 #[test]
1084 fn dispatch_write_and_read() {
1085 let (wd, sandbox) = test_sandbox();
1086 let path = wd.path().join("written.txt");
1087
1088 let lua = setup_lua(sandbox);
1089 let code = format!(
1090 r#"
1091 local w = orcs.dispatch("write", {{path="{p}", content="via dispatch"}})
1092 local r = orcs.dispatch("read", {{path="{p}"}})
1093 return r
1094 "#,
1095 p = path.display()
1096 );
1097 let result: Table = lua
1098 .load(&code)
1099 .eval()
1100 .expect("dispatch write+read should succeed");
1101 assert!(result.get::<bool>("ok").expect("should have ok field"));
1102 assert_eq!(
1103 result
1104 .get::<String>("content")
1105 .expect("should have content"),
1106 "via dispatch"
1107 );
1108 }
1109
1110 #[test]
1111 fn dispatch_grep() {
1112 let (wd, sandbox) = test_sandbox();
1113 fs::write(wd.path().join("search.txt"), "line one\nline two\nthird")
1114 .expect("should write search file");
1115
1116 let lua = setup_lua(sandbox);
1117 let result: Table = lua
1118 .load(format!(
1119 r#"return orcs.dispatch("grep", {{pattern="line", path="{}"}})"#,
1120 wd.path().join("search.txt").display()
1121 ))
1122 .eval()
1123 .expect("dispatch grep should succeed");
1124
1125 assert!(result.get::<bool>("ok").expect("should have ok field"));
1126 assert_eq!(result.get::<usize>("count").expect("should have count"), 2);
1127 }
1128
1129 #[test]
1130 fn dispatch_glob() {
1131 let (wd, sandbox) = test_sandbox();
1132 fs::write(wd.path().join("a.rs"), "").expect("write a.rs");
1133 fs::write(wd.path().join("b.rs"), "").expect("write b.rs");
1134 fs::write(wd.path().join("c.txt"), "").expect("write c.txt");
1135
1136 let lua = setup_lua(sandbox);
1137 let result: Table = lua
1138 .load(format!(
1139 r#"return orcs.dispatch("glob", {{pattern="*.rs", dir="{}"}})"#,
1140 wd.path().display()
1141 ))
1142 .eval()
1143 .expect("dispatch glob should succeed");
1144
1145 assert!(result.get::<bool>("ok").expect("should have ok field"));
1146 assert_eq!(result.get::<usize>("count").expect("should have count"), 2);
1147 }
1148
1149 #[test]
1150 fn dispatch_mkdir_remove() {
1151 let (wd, sandbox) = test_sandbox();
1152 let dir_path = wd.path().join("sub/deep");
1153
1154 let lua = setup_lua(sandbox);
1155 let code = format!(
1156 r#"
1157 local m = orcs.dispatch("mkdir", {{path="{p}"}})
1158 local r = orcs.dispatch("remove", {{path="{p}"}})
1159 return {{mkdir=m, remove=r}}
1160 "#,
1161 p = dir_path.display()
1162 );
1163 let result: Table = lua
1164 .load(&code)
1165 .eval()
1166 .expect("dispatch mkdir+remove should succeed");
1167 let mkdir: Table = result.get("mkdir").expect("should have mkdir");
1168 let remove: Table = result.get("remove").expect("should have remove");
1169 assert!(mkdir.get::<bool>("ok").expect("mkdir ok"));
1170 assert!(remove.get::<bool>("ok").expect("remove ok"));
1171 }
1172
1173 #[test]
1174 fn dispatch_mv() {
1175 let (wd, sandbox) = test_sandbox();
1176 let src = wd.path().join("src.txt");
1177 let dst = wd.path().join("dst.txt");
1178 fs::write(&src, "move me").expect("write src");
1179
1180 let lua = setup_lua(sandbox);
1181 let result: Table = lua
1182 .load(format!(
1183 r#"return orcs.dispatch("mv", {{src="{}", dst="{}"}})"#,
1184 src.display(),
1185 dst.display()
1186 ))
1187 .eval()
1188 .expect("dispatch mv should succeed");
1189
1190 assert!(result.get::<bool>("ok").expect("should have ok field"));
1191 assert!(dst.exists());
1192 assert!(!src.exists());
1193 }
1194
1195 #[test]
1196 fn dispatch_unknown_tool() {
1197 let (_wd, sandbox) = test_sandbox();
1198 let lua = setup_lua(sandbox);
1199
1200 let result: Table = lua
1201 .load(r#"return orcs.dispatch("nonexistent", {arg="val"})"#)
1202 .eval()
1203 .expect("dispatch unknown should return error table");
1204
1205 assert!(!result.get::<bool>("ok").expect("should have ok field"));
1206 assert!(result
1207 .get::<String>("error")
1208 .expect("should have error")
1209 .contains("unknown intent"));
1210 }
1211
1212 #[test]
1213 fn dispatch_missing_required_arg() {
1214 let (_wd, sandbox) = test_sandbox();
1215 let lua = setup_lua(sandbox);
1216
1217 let result: Table = lua
1221 .load(r#"return orcs.dispatch("read", {})"#)
1222 .eval()
1223 .expect("dispatch should return result table, not throw");
1224
1225 let ok: bool = result.get("ok").expect("should have 'ok' field");
1226 assert!(!ok, "dispatch with missing arg should return ok=false");
1227 let err: String = result.get("error").expect("should have 'error' field");
1228 assert!(
1229 err.contains("missing required argument"),
1230 "error should mention missing arg, got: {err}"
1231 );
1232 }
1233
1234 #[test]
1237 fn tool_schemas_returns_all() {
1238 let (_wd, sandbox) = test_sandbox();
1239 let lua = setup_lua(sandbox);
1240
1241 let schemas: Table = lua
1242 .load("return orcs.tool_schemas()")
1243 .eval()
1244 .expect("tool_schemas should return table");
1245
1246 let count = schemas.len().expect("should have length") as usize;
1247 assert_eq!(count, 8, "should return 8 builtin tools");
1248
1249 let first: Table = schemas.get(1).expect("should have first entry");
1251 assert_eq!(
1252 first.get::<String>("name").expect("should have name"),
1253 "read"
1254 );
1255 assert!(!first
1256 .get::<String>("description")
1257 .expect("should have description")
1258 .is_empty());
1259
1260 let args: Table = first.get("args").expect("should have args");
1261 let first_arg: Table = args.get(1).expect("should have first arg");
1262 assert_eq!(first_arg.get::<String>("name").expect("arg name"), "path");
1263 assert_eq!(first_arg.get::<String>("type").expect("arg type"), "string");
1264 assert!(first_arg.get::<bool>("required").expect("arg required"));
1265 }
1266
1267 #[test]
1270 fn descriptions_include_all_tools() {
1271 let lua = Lua::new();
1272 lua.set_app_data(IntentRegistry::new());
1273 let desc = generate_descriptions(&lua);
1274 let expected_tools = [
1275 "read", "write", "grep", "glob", "mkdir", "remove", "mv", "exec",
1276 ];
1277 for tool in expected_tools {
1278 assert!(desc.contains(tool), "missing tool in descriptions: {tool}");
1279 }
1280 }
1281
1282 #[test]
1285 fn dispatch_exec_uses_registered_exec() {
1286 let (_wd, sandbox) = test_sandbox();
1287 let lua = setup_lua(sandbox);
1288
1289 let result: Table = lua
1291 .load(r#"return orcs.dispatch("exec", {cmd="echo hi"})"#)
1292 .eval()
1293 .expect("dispatch exec should return table");
1294
1295 assert!(!result.get::<bool>("ok").expect("should have ok field"));
1297 }
1298
1299 #[test]
1302 fn intent_defs_returns_all() {
1303 let (_wd, sandbox) = test_sandbox();
1304 let lua = setup_lua(sandbox);
1305
1306 let defs: Table = lua
1307 .load("return orcs.intent_defs()")
1308 .eval()
1309 .expect("intent_defs should return table");
1310
1311 let count = defs.len().expect("should have length") as usize;
1312 assert_eq!(count, 8, "should return 8 builtin intents");
1313
1314 let first: Table = defs.get(1).expect("should have first entry");
1315 assert_eq!(
1316 first.get::<String>("name").expect("should have name"),
1317 "read"
1318 );
1319 assert!(!first
1320 .get::<String>("description")
1321 .expect("should have description")
1322 .is_empty());
1323
1324 assert!(
1326 first.get::<mlua::Value>("parameters").is_ok(),
1327 "should have parameters"
1328 );
1329 }
1330
1331 #[test]
1334 fn register_intent_adds_to_registry() {
1335 let (_wd, sandbox) = test_sandbox();
1336 let lua = setup_lua(sandbox);
1337
1338 let result: Table = lua
1339 .load(
1340 r#"
1341 return orcs.register_intent({
1342 name = "custom_action",
1343 description = "A custom action",
1344 component = "lua::my_component",
1345 operation = "do_stuff",
1346 params = {
1347 input = { type = "string", description = "Input data", required = true },
1348 },
1349 })
1350 "#,
1351 )
1352 .eval()
1353 .expect("register_intent should return table");
1354
1355 assert!(
1356 result.get::<bool>("ok").expect("should have ok field"),
1357 "registration should succeed"
1358 );
1359
1360 let schemas: Table = lua
1362 .load("return orcs.tool_schemas()")
1363 .eval()
1364 .expect("tool_schemas after register");
1365 let count = schemas.len().expect("should have length") as usize;
1366 assert_eq!(count, 9, "should now have 9 intents (8 builtin + 1 custom)");
1367 }
1368
1369 #[test]
1370 fn register_intent_duplicate_fails() {
1371 let (_wd, sandbox) = test_sandbox();
1372 let lua = setup_lua(sandbox);
1373
1374 let result: Table = lua
1375 .load(
1376 r#"
1377 return orcs.register_intent({
1378 name = "read",
1379 description = "duplicate",
1380 component = "lua::x",
1381 })
1382 "#,
1383 )
1384 .eval()
1385 .expect("register_intent should return table");
1386
1387 assert!(
1388 !result.get::<bool>("ok").expect("should have ok field"),
1389 "duplicate registration should fail"
1390 );
1391 assert!(result
1392 .get::<String>("error")
1393 .expect("should have error")
1394 .contains("already registered"));
1395 }
1396
1397 #[test]
1400 fn dispatch_component_no_request_fn_returns_error() {
1401 let (_wd, sandbox) = test_sandbox();
1402 let lua = setup_lua(sandbox);
1403
1404 lua.load(
1406 r#"
1407 orcs.register_intent({
1408 name = "comp_action",
1409 description = "component action",
1410 component = "lua::test_comp",
1411 operation = "do_stuff",
1412 })
1413 "#,
1414 )
1415 .exec()
1416 .expect("register should succeed");
1417
1418 let result: Table = lua
1419 .load(r#"return orcs.dispatch("comp_action", {input="hello"})"#)
1420 .eval()
1421 .expect("should return error table");
1422
1423 assert!(
1424 !result.get::<bool>("ok").expect("should have ok"),
1425 "should fail without orcs.request"
1426 );
1427 let error: String = result.get("error").expect("should have error");
1428 assert!(
1429 error.contains("no execution context"),
1430 "error should mention missing context, got: {error}"
1431 );
1432 }
1433
1434 #[test]
1435 fn dispatch_component_success_normalized() {
1436 let (_wd, sandbox) = test_sandbox();
1437 let lua = setup_lua(sandbox);
1438
1439 lua.load(
1441 r#"
1442 orcs.register_intent({
1443 name = "mock_comp",
1444 description = "mock component",
1445 component = "lua::mock",
1446 operation = "echo",
1447 })
1448 "#,
1449 )
1450 .exec()
1451 .expect("register should succeed");
1452
1453 lua.load(
1455 r#"
1456 orcs.request = function(target, operation, payload)
1457 return { success = true, data = { echo = payload.input, target = target, op = operation } }
1458 end
1459 "#,
1460 )
1461 .exec()
1462 .expect("mock should succeed");
1463
1464 let result: Table = lua
1465 .load(r#"return orcs.dispatch("mock_comp", {input="hello"})"#)
1466 .eval()
1467 .expect("dispatch should return table");
1468
1469 assert!(
1471 result.get::<bool>("ok").expect("should have ok"),
1472 "should succeed"
1473 );
1474
1475 let duration: u64 = result.get("duration_ms").expect("should have duration_ms");
1477 assert!(
1478 duration < 1000,
1479 "local mock should be fast, got: {duration}ms"
1480 );
1481
1482 let data: Table = result.get("data").expect("should have data");
1484 assert_eq!(
1485 data.get::<String>("echo").expect("should have echo"),
1486 "hello"
1487 );
1488 assert_eq!(
1489 data.get::<String>("target").expect("should have target"),
1490 "lua::mock"
1491 );
1492 assert_eq!(data.get::<String>("op").expect("should have op"), "echo");
1493 }
1494
1495 #[test]
1496 fn dispatch_component_failure_normalized() {
1497 let (_wd, sandbox) = test_sandbox();
1498 let lua = setup_lua(sandbox);
1499
1500 lua.load(
1502 r#"
1503 orcs.register_intent({
1504 name = "fail_comp",
1505 description = "failing component",
1506 component = "lua::fail",
1507 operation = "explode",
1508 })
1509 orcs.request = function(target, operation, payload)
1510 return { success = false, error = "component exploded" }
1511 end
1512 "#,
1513 )
1514 .exec()
1515 .expect("setup should succeed");
1516
1517 let result: Table = lua
1518 .load(r#"return orcs.dispatch("fail_comp", {})"#)
1519 .eval()
1520 .expect("dispatch should return table");
1521
1522 assert!(
1523 !result.get::<bool>("ok").expect("should have ok"),
1524 "should report failure"
1525 );
1526 let error: String = result.get("error").expect("should have error");
1527 assert_eq!(error, "component exploded");
1528
1529 assert!(result.get::<u64>("duration_ms").is_ok());
1531 }
1532
1533 #[test]
1534 fn dispatch_component_forwards_all_args() {
1535 let (_wd, sandbox) = test_sandbox();
1536 let lua = setup_lua(sandbox);
1537
1538 lua.load(
1539 r#"
1540 orcs.register_intent({
1541 name = "args_comp",
1542 description = "args test",
1543 component = "lua::args_test",
1544 operation = "check_args",
1545 })
1546 -- Mock that captures and returns the payload
1547 orcs.request = function(target, operation, payload)
1548 return { success = true, data = payload }
1549 end
1550 "#,
1551 )
1552 .exec()
1553 .expect("setup should succeed");
1554
1555 let result: Table = lua
1556 .load(r#"return orcs.dispatch("args_comp", {a="1", b="2", c="3"})"#)
1557 .eval()
1558 .expect("dispatch should return table");
1559
1560 assert!(result.get::<bool>("ok").expect("should have ok"));
1561 let data: Table = result.get("data").expect("should have data");
1562 assert_eq!(data.get::<String>("a").expect("arg a"), "1");
1563 assert_eq!(data.get::<String>("b").expect("arg b"), "2");
1564 assert_eq!(data.get::<String>("c").expect("arg c"), "3");
1565 }
1566
1567 #[test]
1570 fn register_intent_missing_name_errors() {
1571 let (_wd, sandbox) = test_sandbox();
1572 let lua = setup_lua(sandbox);
1573
1574 let result = lua
1575 .load(
1576 r#"
1577 return orcs.register_intent({
1578 description = "no name",
1579 component = "lua::x",
1580 })
1581 "#,
1582 )
1583 .eval::<Table>();
1584
1585 assert!(result.is_err(), "missing 'name' should cause a Lua error");
1586 let err = result.expect_err("should error").to_string();
1587 assert!(
1588 err.contains("name"),
1589 "error should mention 'name', got: {err}"
1590 );
1591 }
1592
1593 #[test]
1594 fn register_intent_missing_description_errors() {
1595 let (_wd, sandbox) = test_sandbox();
1596 let lua = setup_lua(sandbox);
1597
1598 let result = lua
1599 .load(
1600 r#"
1601 return orcs.register_intent({
1602 name = "no_desc",
1603 component = "lua::x",
1604 })
1605 "#,
1606 )
1607 .eval::<Table>();
1608
1609 assert!(
1610 result.is_err(),
1611 "missing 'description' should cause a Lua error"
1612 );
1613 let err = result.expect_err("should error").to_string();
1614 assert!(
1615 err.contains("description"),
1616 "error should mention 'description', got: {err}"
1617 );
1618 }
1619
1620 #[test]
1621 fn register_intent_missing_component_errors() {
1622 let (_wd, sandbox) = test_sandbox();
1623 let lua = setup_lua(sandbox);
1624
1625 let result = lua
1626 .load(
1627 r#"
1628 return orcs.register_intent({
1629 name = "no_comp",
1630 description = "missing component",
1631 })
1632 "#,
1633 )
1634 .eval::<Table>();
1635
1636 assert!(
1637 result.is_err(),
1638 "missing 'component' should cause a Lua error"
1639 );
1640 let err = result.expect_err("should error").to_string();
1641 assert!(
1642 err.contains("component"),
1643 "error should mention 'component', got: {err}"
1644 );
1645 }
1646
1647 #[test]
1648 fn register_intent_defaults_operation_to_execute() {
1649 let (_wd, sandbox) = test_sandbox();
1650 let lua = setup_lua(sandbox);
1651
1652 let result: Table = lua
1654 .load(
1655 r#"
1656 return orcs.register_intent({
1657 name = "default_op",
1658 description = "test default operation",
1659 component = "lua::test_comp",
1660 })
1661 "#,
1662 )
1663 .eval()
1664 .expect("register_intent should return table");
1665
1666 assert!(
1667 result.get::<bool>("ok").expect("should have ok"),
1668 "registration should succeed"
1669 );
1670
1671 lua.load(
1674 r#"
1675 orcs.request = function(target, operation, payload)
1676 return { success = true, data = { captured_op = operation } }
1677 end
1678 "#,
1679 )
1680 .exec()
1681 .expect("mock should succeed");
1682
1683 let dispatch_result: Table = lua
1684 .load(r#"return orcs.dispatch("default_op", {})"#)
1685 .eval()
1686 .expect("dispatch should return table");
1687
1688 assert!(dispatch_result.get::<bool>("ok").expect("should have ok"));
1689 let data: Table = dispatch_result.get("data").expect("should have data");
1690 assert_eq!(
1691 data.get::<String>("captured_op").expect("captured_op"),
1692 "execute",
1693 "operation should default to 'execute'"
1694 );
1695 }
1696
1697 #[test]
1700 fn register_tool_adds_intent_and_tool() {
1701 use orcs_component::tool::{RustTool, ToolContext, ToolError};
1702 use orcs_component::Capability;
1703
1704 struct PingTool;
1705
1706 impl RustTool for PingTool {
1707 fn name(&self) -> &str {
1708 "ping"
1709 }
1710 fn description(&self) -> &str {
1711 "Returns pong"
1712 }
1713 fn parameters_schema(&self) -> serde_json::Value {
1714 serde_json::json!({"type": "object", "properties": {}})
1715 }
1716 fn required_capability(&self) -> Capability {
1717 Capability::READ
1718 }
1719 fn is_read_only(&self) -> bool {
1720 true
1721 }
1722 fn execute(
1723 &self,
1724 _args: serde_json::Value,
1725 _ctx: &ToolContext<'_>,
1726 ) -> Result<serde_json::Value, ToolError> {
1727 Ok(serde_json::json!({"reply": "pong"}))
1728 }
1729 }
1730
1731 let mut registry = IntentRegistry::new();
1732 assert_eq!(registry.len(), 8, "starts with 8 builtins");
1733
1734 registry
1735 .register_tool(Arc::new(PingTool))
1736 .expect("should register ping tool");
1737
1738 assert_eq!(registry.len(), 9, "should now have 9");
1739 assert!(registry.get("ping").is_some(), "IntentDef should exist");
1740 assert!(registry.get_tool("ping").is_some(), "RustTool should exist");
1741 assert_eq!(
1742 registry.get("ping").expect("ping def").resolver,
1743 IntentResolver::Internal,
1744 "resolver should be Internal"
1745 );
1746 }
1747
1748 #[test]
1749 fn register_tool_duplicate_fails() {
1750 use orcs_component::tool::{RustTool, ToolContext, ToolError};
1751 use orcs_component::Capability;
1752
1753 struct DupTool;
1754
1755 impl RustTool for DupTool {
1756 fn name(&self) -> &str {
1757 "read"
1758 }
1759 fn description(&self) -> &str {
1760 "duplicate of builtin"
1761 }
1762 fn parameters_schema(&self) -> serde_json::Value {
1763 serde_json::json!({"type": "object", "properties": {}})
1764 }
1765 fn required_capability(&self) -> Capability {
1766 Capability::READ
1767 }
1768 fn is_read_only(&self) -> bool {
1769 true
1770 }
1771 fn execute(
1772 &self,
1773 _args: serde_json::Value,
1774 _ctx: &ToolContext<'_>,
1775 ) -> Result<serde_json::Value, ToolError> {
1776 Ok(serde_json::json!({}))
1777 }
1778 }
1779
1780 let mut registry = IntentRegistry::new();
1781 let err = registry
1782 .register_tool(Arc::new(DupTool))
1783 .expect_err("should reject duplicate");
1784 assert!(
1785 err.contains("already registered"),
1786 "error should mention duplicate, got: {err}"
1787 );
1788 }
1789
1790 #[test]
1793 fn grep_dispatch_preserves_integer_line_number() {
1794 let (wd, sandbox) = test_sandbox();
1795 fs::write(wd.path().join("nums.txt"), "alpha\nbeta\nalpha again\n")
1796 .expect("should write test file");
1797
1798 let lua = setup_lua(sandbox);
1799 let result: Table = lua
1800 .load(format!(
1801 r#"return orcs.dispatch("grep", {{pattern="alpha", path="{}"}})"#,
1802 wd.path().join("nums.txt").display()
1803 ))
1804 .eval()
1805 .expect("dispatch grep should succeed");
1806
1807 assert!(result.get::<bool>("ok").expect("should have ok"));
1808
1809 let matches: Table = result.get("matches").expect("should have matches");
1810 let first: Table = matches.get(1).expect("should have first match");
1811
1812 let line_num: i64 = first
1815 .get("line_number")
1816 .expect("line_number should be accessible as i64");
1817 assert_eq!(line_num, 1, "first match should be line 1");
1818
1819 let count: i64 = result
1821 .get("count")
1822 .expect("count should be accessible as i64");
1823 assert_eq!(count, 2, "should find 2 matches");
1824 }
1825
1826 #[derive(Debug, Clone)]
1830 struct CapTestContext {
1831 caps: orcs_component::Capability,
1832 }
1833
1834 impl orcs_component::ChildContext for CapTestContext {
1835 fn parent_id(&self) -> &str {
1836 "cap-test"
1837 }
1838 fn emit_output(&self, _msg: &str) {}
1839 fn emit_output_with_level(&self, _msg: &str, _level: &str) {}
1840 fn child_count(&self) -> usize {
1841 0
1842 }
1843 fn max_children(&self) -> usize {
1844 0
1845 }
1846 fn spawn_child(
1847 &self,
1848 _config: orcs_component::ChildConfig,
1849 ) -> Result<Box<dyn orcs_component::ChildHandle>, orcs_component::SpawnError> {
1850 Err(orcs_component::SpawnError::Internal("stub".into()))
1851 }
1852 fn send_to_child(
1853 &self,
1854 _id: &str,
1855 _input: serde_json::Value,
1856 ) -> Result<orcs_component::ChildResult, orcs_component::RunError> {
1857 Err(orcs_component::RunError::NotFound("stub".into()))
1858 }
1859 fn capabilities(&self) -> orcs_component::Capability {
1860 self.caps
1861 }
1862 fn check_command_permission(&self, _cmd: &str) -> orcs_component::CommandPermission {
1863 orcs_component::CommandPermission::Denied("stub".into())
1864 }
1865 fn can_execute_command(&self, _cmd: &str) -> bool {
1866 false
1867 }
1868 fn can_spawn_child_auth(&self) -> bool {
1869 false
1870 }
1871 fn can_spawn_runner_auth(&self) -> bool {
1872 false
1873 }
1874 fn grant_command(&self, _pattern: &str) {}
1875 fn spawn_runner_from_script(
1876 &self,
1877 _script: &str,
1878 _id: Option<&str>,
1879 _globals: Option<&serde_json::Map<String, serde_json::Value>>,
1880 ) -> Result<(orcs_types::ChannelId, String), orcs_component::SpawnError> {
1881 Err(orcs_component::SpawnError::Internal("stub".into()))
1882 }
1883 fn clone_box(&self) -> Box<dyn orcs_component::ChildContext> {
1884 Box::new(self.clone())
1885 }
1886 }
1887
1888 #[test]
1889 fn dispatch_rust_tool_denies_without_capability() {
1890 let (wd, sandbox) = test_sandbox();
1891 fs::write(wd.path().join("secret.txt"), "classified").expect("should write test file");
1892
1893 let lua = setup_lua(sandbox);
1894
1895 use crate::context_wrapper::ContextWrapper;
1897 use parking_lot::Mutex;
1898 let ctx: Box<dyn orcs_component::ChildContext> = Box::new(CapTestContext {
1899 caps: orcs_component::Capability::empty(),
1900 });
1901 lua.set_app_data(ContextWrapper(Arc::new(Mutex::new(ctx))));
1902
1903 let result: Table = lua
1905 .load(format!(
1906 r#"return orcs.dispatch("read", {{path="{}"}})"#,
1907 wd.path().join("secret.txt").display()
1908 ))
1909 .eval()
1910 .expect("dispatch should return result table, not throw");
1911
1912 let ok: bool = result.get("ok").expect("should have ok");
1913 assert!(!ok, "should be denied");
1914 let err: String = result.get("error").expect("should have error");
1915 assert!(
1916 err.contains("permission denied"),
1917 "error should mention permission denied, got: {err}"
1918 );
1919 }
1920
1921 #[test]
1922 fn dispatch_rust_tool_allows_with_capability() {
1923 let (wd, sandbox) = test_sandbox();
1924 fs::write(wd.path().join("allowed.txt"), "public data").expect("should write test file");
1925
1926 let lua = setup_lua(sandbox);
1927
1928 use crate::context_wrapper::ContextWrapper;
1930 use parking_lot::Mutex;
1931 let ctx: Box<dyn orcs_component::ChildContext> = Box::new(CapTestContext {
1932 caps: orcs_component::Capability::READ,
1933 });
1934 lua.set_app_data(ContextWrapper(Arc::new(Mutex::new(ctx))));
1935
1936 let result: Table = lua
1937 .load(format!(
1938 r#"return orcs.dispatch("read", {{path="{}"}})"#,
1939 wd.path().join("allowed.txt").display()
1940 ))
1941 .eval()
1942 .expect("dispatch should return result table");
1943
1944 let ok: bool = result.get("ok").expect("should have ok");
1945 assert!(ok, "should succeed with READ capability");
1946 let content: String = result.get("content").expect("should have content");
1947 assert_eq!(content, "public data");
1948 }
1949
1950 #[test]
1951 fn positional_write_denied_with_read_only_cap() {
1952 let (wd, sandbox) = test_sandbox();
1953
1954 let lua = setup_lua(sandbox);
1955
1956 use crate::context_wrapper::ContextWrapper;
1960 use parking_lot::Mutex;
1961 let ctx: Box<dyn orcs_component::ChildContext> = Box::new(CapTestContext {
1962 caps: orcs_component::Capability::READ,
1963 });
1964 lua.set_app_data(ContextWrapper(Arc::new(Mutex::new(ctx))));
1965
1966 let result: Table = lua
1967 .load(format!(
1968 r#"return orcs.write("{}", "hacked")"#,
1969 wd.path().join("nope.txt").display()
1970 ))
1971 .eval()
1972 .expect("positional write should return result table");
1973
1974 let ok: bool = result.get("ok").expect("should have ok");
1975 assert!(!ok, "write should be denied with READ-only cap");
1976 let err: String = result.get("error").expect("should have error");
1977 assert!(
1978 err.contains("permission denied"),
1979 "error should mention permission denied, got: {err}"
1980 );
1981 }
1982
1983 #[test]
1986 fn positional_read_denied_without_capability() {
1987 let (wd, sandbox) = test_sandbox();
1988 fs::write(wd.path().join("pos_secret.txt"), "restricted").expect("should write test file");
1989
1990 let lua = setup_lua(sandbox);
1991
1992 use crate::context_wrapper::ContextWrapper;
1993 use parking_lot::Mutex;
1994 let ctx: Box<dyn orcs_component::ChildContext> = Box::new(CapTestContext {
1995 caps: orcs_component::Capability::empty(),
1996 });
1997 lua.set_app_data(ContextWrapper(Arc::new(Mutex::new(ctx))));
1998
1999 let result: Table = lua
2001 .load(format!(
2002 r#"return orcs.read("{}")"#,
2003 wd.path().join("pos_secret.txt").display()
2004 ))
2005 .eval()
2006 .expect("positional read should return result table");
2007
2008 let ok: bool = result.get("ok").expect("should have ok");
2009 assert!(!ok, "positional read should be denied");
2010 let err: String = result.get("error").expect("should have error");
2011 assert!(
2012 err.contains("permission denied"),
2013 "error should mention permission denied, got: {err}"
2014 );
2015 }
2016
2017 #[test]
2020 fn mcp_servers_returns_ok_without_manager() {
2021 let (_wd, sandbox) = test_sandbox();
2022 let lua = setup_lua(sandbox);
2023
2024 let result: Table = lua
2025 .load("return orcs.mcp_servers()")
2026 .eval()
2027 .expect("mcp_servers should return table");
2028
2029 assert!(
2030 result.get::<bool>("ok").expect("should have ok"),
2031 "mcp_servers without manager should return ok=true"
2032 );
2033 let servers: Table = result.get("servers").expect("should have servers");
2034 assert_eq!(
2035 servers.len().expect("servers length"),
2036 0,
2037 "servers list should be empty when no manager is set"
2038 );
2039 }
2040
2041 #[test]
2042 fn mcp_tools_returns_empty_without_mcp_intents() {
2043 let (_wd, sandbox) = test_sandbox();
2044 let lua = setup_lua(sandbox);
2045
2046 let result: Table = lua
2047 .load("return orcs.mcp_tools()")
2048 .eval()
2049 .expect("mcp_tools should return table");
2050
2051 assert!(
2052 result.get::<bool>("ok").expect("should have ok"),
2053 "mcp_tools should return ok=true"
2054 );
2055 let tools: Table = result.get("tools").expect("should have tools");
2056 assert_eq!(
2057 tools.len().expect("tools length"),
2058 0,
2059 "tools list should be empty when no MCP intents registered"
2060 );
2061 }
2062
2063 #[test]
2064 fn mcp_tools_filters_by_server() {
2065 let (_wd, sandbox) = test_sandbox();
2066 let lua = setup_lua(sandbox);
2067
2068 {
2070 let mut registry = lua
2071 .remove_app_data::<IntentRegistry>()
2072 .expect("registry should exist");
2073 let def_a = IntentDef {
2074 name: "mcp:srv_a:tool1".into(),
2075 description: "[MCP:srv_a] tool1 desc".into(),
2076 parameters: serde_json::json!({"type": "object", "properties": {}}),
2077 resolver: IntentResolver::Mcp {
2078 server_name: "srv_a".into(),
2079 tool_name: "tool1".into(),
2080 },
2081 };
2082 let def_b = IntentDef {
2083 name: "mcp:srv_b:tool2".into(),
2084 description: "[MCP:srv_b] tool2 desc".into(),
2085 parameters: serde_json::json!({"type": "object", "properties": {}}),
2086 resolver: IntentResolver::Mcp {
2087 server_name: "srv_b".into(),
2088 tool_name: "tool2".into(),
2089 },
2090 };
2091 registry.register(def_a).expect("register def_a");
2092 registry.register(def_b).expect("register def_b");
2093 lua.set_app_data(registry);
2094 }
2095
2096 let all: Table = lua
2098 .load("return orcs.mcp_tools()")
2099 .eval()
2100 .expect("mcp_tools() should succeed");
2101 assert!(all.get::<bool>("ok").expect("should have ok"));
2102 let all_tools: Table = all.get("tools").expect("should have tools");
2103 assert_eq!(
2104 all_tools.len().expect("all tools length"),
2105 2,
2106 "should list both MCP tools"
2107 );
2108
2109 let filtered: Table = lua
2111 .load(r#"return orcs.mcp_tools("srv_a")"#)
2112 .eval()
2113 .expect("mcp_tools('srv_a') should succeed");
2114 assert!(filtered.get::<bool>("ok").expect("should have ok"));
2115 let filtered_tools: Table = filtered.get("tools").expect("should have tools");
2116 assert_eq!(
2117 filtered_tools.len().expect("filtered tools length"),
2118 1,
2119 "should list only srv_a tools"
2120 );
2121 let first: Table = filtered_tools.get(1).expect("first tool entry");
2122 assert_eq!(
2123 first.get::<String>("server").expect("should have server"),
2124 "srv_a"
2125 );
2126 assert_eq!(
2127 first.get::<String>("tool").expect("should have tool"),
2128 "tool1"
2129 );
2130 }
2131
2132 #[test]
2133 fn mcp_call_without_manager_returns_error() {
2134 let (_wd, sandbox) = test_sandbox();
2135 let lua = setup_lua(sandbox);
2136
2137 let result: Table = lua
2138 .load(r#"return orcs.mcp_call("srv", "tool", {})"#)
2139 .eval()
2140 .expect("mcp_call should return error table");
2141
2142 assert!(
2143 !result.get::<bool>("ok").expect("should have ok"),
2144 "mcp_call without manager should return ok=false"
2145 );
2146 let err: String = result.get("error").expect("should have error");
2147 assert!(
2148 err.contains("MCP client not initialized"),
2149 "error should mention not initialized, got: {err}"
2150 );
2151 }
2152}