Skip to main content

orcs_lua/
tool_registry.rs

1//! Intent registry: structured intent definitions with unified dispatch.
2//!
3//! Provides a single source of truth for intent metadata (name, description,
4//! parameters as JSON Schema, resolver) and a unified `orcs.dispatch(name, args)`
5//! function that validates and routes to the appropriate resolver.
6//!
7//! # Design
8//!
9//! ```text
10//! IntentRegistry (dynamic, stored in Lua app_data)
11//!   ├── IntentDef { name, description, parameters (JSON Schema), resolver }
12//!   │     resolver = Internal       → dispatch_internal() (8 builtin tools)
13//!   │     resolver = Component{..}  → Component RPC via EventBus
14//!   │
15//!   └── Lua API:
16//!         orcs.dispatch(name, args)   → resolve + execute
17//!         orcs.tool_schemas()         → legacy Lua table format (backward compat)
18//!         orcs.intent_defs()          → JSON Schema format (for LLM tools param)
19//!         orcs.register_intent(def)   → dynamic addition (Component tools)
20//! ```
21
22use 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
32// ── IntentRegistry ───────────────────────────────────────────────────
33
34/// Registry of named intents. Stored in Lua app_data.
35///
36/// Initialized with 8 builtin Internal tools. Components can register
37/// additional intents at runtime via `orcs.register_intent()`.
38///
39/// Uses `Vec` for ordered iteration and `HashMap<String, usize>` index
40/// for O(1) name lookup.
41///
42/// For `IntentResolver::Internal` tools, the registry also stores
43/// `Arc<dyn RustTool>` instances for direct Rust dispatch (no Lua roundtrip).
44pub struct IntentRegistry {
45    defs: Vec<IntentDef>,
46    /// Maps intent name → index in `defs` for O(1) lookup.
47    index: HashMap<String, usize>,
48    /// Maps intent name → RustTool for Internal dispatch.
49    /// Not all Internal intents have a RustTool (e.g., `exec`
50    /// retains its Lua dispatch path for per-command HIL approval).
51    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    /// Create a new registry pre-populated with the 8 builtin tools.
62    pub fn new() -> Self {
63        let builtin_tools = crate::builtin_tools::builtin_rust_tools();
64
65        // Collect IntentDefs from RustTool instances (7 file tools)
66        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        // Add exec IntentDef (no RustTool — retains Lua dispatch for HIL)
73        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    /// Look up an intent definition by name. O(1).
85    pub fn get(&self, name: &str) -> Option<&IntentDef> {
86        self.index.get(name).map(|&i| &self.defs[i])
87    }
88
89    /// Look up a RustTool by name. O(1).
90    pub fn get_tool(&self, name: &str) -> Option<&Arc<dyn RustTool>> {
91        self.tools.get(name)
92    }
93
94    /// Register a new intent definition. Returns error if name is already taken.
95    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    /// Register a RustTool (creates IntentDef automatically).
106    /// Returns error if name is already taken.
107    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    /// All registered intent definitions (insertion order).
116    pub fn all(&self) -> &[IntentDef] {
117        &self.defs
118    }
119
120    /// Number of registered intents.
121    pub fn len(&self) -> usize {
122        self.defs.len()
123    }
124
125    /// Whether the registry is empty.
126    pub fn is_empty(&self) -> bool {
127        self.defs.is_empty()
128    }
129}
130
131// ── Exec IntentDef (not a RustTool — per-command HIL) ────────────────
132
133/// The `exec` tool IntentDef.
134///
135/// `exec` is NOT a RustTool because its per-command approval flow
136/// requires `ChildContext` / `ComponentError::Suspended`, which is
137/// tightly coupled to the Lua runtime. It retains the Lua dispatch path.
138fn 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
156// ── Dispatch ─────────────────────────────────────────────────────────
157
158/// Dispatches a tool call by name. Routes through IntentRegistry.
159///
160/// 1. Look up intent in registry
161/// 2. Route by resolver: Internal → dispatch_internal, Component → (future)
162/// 3. Unknown name → error
163fn 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
201/// Checks if a mutating intent requires HIL approval before execution.
202///
203/// Uses [`ChildContext::is_command_granted`] with a synthetic `"intent:<name>"`
204/// command. This intentionally bypasses session elevation so that mutating
205/// intents always require explicit approval on first use:
206///
207/// - **Previously granted** → auto-allowed (`GrantPolicy` stores `"intent:<name>"`)
208/// - **Not yet granted** → `Suspended` (triggers HIL approval flow)
209///
210/// After the user approves, `GrantPolicy::grant("intent:<name>")` is persisted by
211/// the ChannelRunner, so subsequent calls for the same intent are auto-allowed.
212///
213/// # Caller responsibility
214///
215/// The caller (`dispatch_internal`) must filter out exempt intents (exec,
216/// read-only tools) BEFORE calling this function. This avoids a redundant
217/// registry lookup — the caller already holds the tool reference.
218fn check_mutation_approval(lua: &Lua, name: &str, args: &Table) -> mlua::Result<()> {
219    // Access ChildContext from app_data. If not set (e.g., tests without
220    // Component context), fall through to permissive mode.
221    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    // Check grants directly — elevation is intentionally ignored so that
228    // mutating intents always require explicit approval on first use.
229    let intent_cmd = format!("intent:{name}");
230    if ctx.is_command_granted(&intent_cmd) {
231        return Ok(());
232    }
233
234    // Not yet granted → suspend for HIL approval
235    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
257/// Builds a human-readable description for an intent approval request.
258fn 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
275/// Dispatches an Internal intent.
276///
277/// For tools with a [`RustTool`] implementation (7 file tools), dispatches
278/// directly in Rust — no Lua roundtrip. Falls back to Lua dispatch for
279/// `exec` (per-command HIL approval) and any future Internal-only intents.
280///
281/// Mutating intents (write, remove, mv, mkdir) are subject to HIL approval
282/// via [`check_mutation_approval`]. Read-only intents and `exec` (which has
283/// its own per-command approval flow) skip this check.
284fn dispatch_internal(lua: &Lua, name: &str, args: &Table) -> mlua::Result<Table> {
285    // Single registry lookup — used for both approval check and dispatch.
286    let tool = {
287        let registry = ensure_registry(lua)?;
288        registry.get_tool(name).cloned()
289    };
290
291    // HIL approval for mutating intents (write, remove, mv, mkdir).
292    // Exempt: exec (own per-command approval), read-only tools.
293    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    // Fallback to Lua dispatch (exec, future Internal-only intents).
305    dispatch_internal_lua(lua, name, args)
306}
307
308/// Dispatches a RustTool: capability check → execute → convert to Lua table.
309///
310/// Used by `dispatch_internal` (for `orcs.dispatch` calls) and by
311/// positional wrapper functions (`orcs.read(path)` etc.) in `tools.rs`.
312///
313/// # Lock scope
314///
315/// When a [`ContextWrapper`] is present in `app_data`, the inner
316/// `Mutex<Box<dyn ChildContext>>` is held for the entire duration of
317/// `tool.execute()`. This means:
318///
319/// - **Tool authors must NOT access `ContextWrapper` from within
320///   `execute()`** — doing so would deadlock (`parking_lot::Mutex`
321///   is non-reentrant).
322/// - The `ToolContext` passed to `execute()` already provides
323///   `child_ctx()` for any runtime interaction the tool may need.
324/// - For the 7 builtin file tools (synchronous I/O), this lock
325///   duration is negligible. Custom tools with long-running I/O
326///   should consider whether holding the lock is acceptable.
327pub(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    // Prepare sandbox and args before locking ContextWrapper.
336    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    // Single lock scope: capability check + execute.
346    // When ContextWrapper is absent (tests without Component context),
347    // runs in permissive mode — no lock needed.
348    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            // Merge result fields into the table.
374            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
390/// Lua-based dispatch fallback (exec and future Internal-only intents).
391fn 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            // Internal resolver for unknown name — should not happen if
402            // registry is consistent, but handle defensively.
403            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
413/// Dispatches a Component intent via RPC.
414///
415/// Calls `orcs.request(component_fqn, operation, args)` which is
416/// registered by emitter_fns.rs (Component) or child.rs (ChildContext).
417///
418/// The RPC returns `{ success: bool, data?, error? }`. This function
419/// normalizes the response to `{ ok: bool, data?, error?, duration_ms }`,
420/// matching the Internal dispatch contract.
421fn 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    // Build RPC payload (shallow copy of args table)
444    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    // Build optional opts table (timeout override for long-running RPCs)
451    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    // Execute with timing
461    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    // Normalize { success, data, error } → { ok, data, error, duration_ms }
470    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        // Forward data if present
477        if let Ok(data) = rpc_result.get::<mlua::Value>("data") {
478            result.set("data", data)?;
479        }
480    } else {
481        // Forward error message
482        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
491/// Wrapper for `Arc<McpClientManager>` stored in Lua `app_data`.
492///
493/// Each Lua VM holds a clone of the shared manager. Set during
494/// [`install_child_context`](crate::component) via the ChildContext
495/// extension mechanism.
496pub(crate) struct SharedMcpManager(pub Arc<orcs_mcp::McpClientManager>);
497
498/// Deferred MCP IntentDefs, stored when IntentRegistry doesn't exist yet.
499///
500/// Consumed by [`ensure_registry`] on first registry access.
501///
502/// # Invariant
503///
504/// `ensure_registry` is called by every dispatch and query path
505/// (`dispatch_tool`, `generate_descriptions`, `register_dispatch_functions`),
506/// so pending defs are guaranteed to be drained before any tool operation.
507/// If a new code path accesses `IntentRegistry` directly without calling
508/// `ensure_registry`, these defs would be silently dropped.
509pub(crate) struct PendingMcpDefs(pub Vec<orcs_types::intent::IntentDef>);
510
511/// Dispatches an MCP tool invocation.
512///
513/// Bridges async `McpClientManager::call_tool` into the sync Lua call
514/// context via `tokio::task::block_in_place`. Converts `CallToolResult`
515/// content to a Lua table matching the dispatch contract:
516/// `{ ok: bool, content?: string, error?: string }`.
517///
518/// # Runtime requirement
519///
520/// Requires a **multi-thread** tokio runtime (`block_in_place` panics on
521/// `current_thread`). The caller must not hold any `RwLock` on
522/// `McpClientManager` — `call_tool` acquires `tool_routes` and `servers`
523/// read locks internally.
524fn 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    // Retrieve McpClientManager from app_data
532    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    // Convert Lua args → JSON for MCP call
545    let json_args = crate::types::lua_to_json(mlua::Value::Table(args.clone()), lua)?;
546
547    // Bridge async → sync via tokio runtime handle
548    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    // Convert CallToolResult → Lua table
556    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            // Extract text from Content items
563            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
580// ── Registry Helpers ─────────────────────────────────────────────────
581
582/// Ensure IntentRegistry exists in app_data. Returns a reference.
583pub(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    // Register any deferred MCP IntentDefs
589    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
604/// Generates formatted tool descriptions from the registry.
605pub 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        // Extract argument names from JSON Schema
615        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
626/// Extract argument names from a JSON Schema `properties` + `required` for display.
627fn 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
652// ── Arg extraction helpers ───────────────────────────────────────────
653
654/// Extracts a required string argument from the args table.
655fn 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
660/// Sets error fields on a result table.
661fn set_error(result: &Table, msg: &str) -> mlua::Result<()> {
662    result.set("ok", false)?;
663    result.set("error", msg.to_string())?;
664    Ok(())
665}
666
667// ── Lua API Registration ─────────────────────────────────────────────
668
669/// Registers intent-based Lua APIs in the runtime.
670///
671/// - `orcs.dispatch(name, args)` — unified intent dispatcher
672/// - `orcs.tool_schemas()` — legacy Lua table format (backward compat)
673/// - `orcs.intent_defs()` — JSON Schema format (for LLM tools param)
674/// - `orcs.register_intent(def)` — dynamic intent registration
675/// - `orcs.tool_descriptions()` — formatted text descriptions
676pub fn register_dispatch_functions(lua: &Lua) -> Result<(), LuaError> {
677    // Ensure registry is initialized
678    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    // orcs.dispatch(name, args) -> result table
685    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    // orcs.tool_schemas() -> legacy Lua table format (backward compat)
690    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            // Convert JSON Schema properties to legacy args format
700            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    // orcs.intent_defs() -> JSON Schema format for LLM tools parameter
736    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            // parameters as native Lua table (JSON Schema → Lua via serde_json_to_lua)
746            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    // orcs.register_intent(def) -> register a new intent definition
757    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        // Component resolver fields
766        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        // Parameters: accept a table or default to empty schema
774        let parameters = match def_table.get::<Table>("params") {
775            Ok(params_table) => {
776                // Convert Lua table to JSON Schema
777                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        // Optional RPC timeout override (ms)
812        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        // Mutate registry
826        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    // orcs.tool_descriptions() -> formatted text (dynamic: re-reads registry each call)
850    let tool_desc_fn = lua.create_function(|lua, ()| Ok(generate_descriptions(lua)))?;
851    orcs_table.set("tool_descriptions", tool_desc_fn)?;
852
853    // ── MCP convenience APIs ────────────────────────────────────────
854
855    // orcs.mcp_servers() -> { ok, servers: [{name}] }
856    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    // orcs.mcp_tools(server_name?) -> { ok, tools: [{name, description, server, parameters}] }
888    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                // Apply optional server name filter
910                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    // orcs.mcp_call(server, tool, args) -> { ok, content?, error? }
937    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    // --- IntentRegistry unit tests ---
973
974    #[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    // --- dispatch tests (unchanged behavior) ---
1059
1060    #[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        // Missing required arg returns {ok: false, error: "..."} (not a Lua error).
1218        // Validation is handled by RustTool::execute, which returns ToolError
1219        // translated to a result table — consistent with other tool errors.
1220        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    // --- tool_schemas tests (backward compat) ---
1235
1236    #[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        // Verify first schema structure (backward compat format)
1250        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    // --- generate_descriptions tests ---
1268
1269    #[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    // --- exec dispatch delegates to registered function ---
1283
1284    #[test]
1285    fn dispatch_exec_uses_registered_exec() {
1286        let (_wd, sandbox) = test_sandbox();
1287        let lua = setup_lua(sandbox);
1288
1289        // Default exec is deny-stub
1290        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        // Should return the deny-stub result (not error, just ok=false)
1296        assert!(!result.get::<bool>("ok").expect("should have ok field"));
1297    }
1298
1299    // --- intent_defs tests ---
1300
1301    #[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        // parameters should be present (as string or table)
1325        assert!(
1326            first.get::<mlua::Value>("parameters").is_ok(),
1327            "should have parameters"
1328        );
1329    }
1330
1331    // --- register_intent tests ---
1332
1333    #[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        // Verify it appears in tool_schemas
1361        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    // --- Component dispatch tests ---
1398
1399    #[test]
1400    fn dispatch_component_no_request_fn_returns_error() {
1401        let (_wd, sandbox) = test_sandbox();
1402        let lua = setup_lua(sandbox);
1403
1404        // Register a Component intent without providing orcs.request
1405        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        // Register a Component intent
1440        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        // Mock orcs.request to return { success=true, data={echo="hi"} }
1454        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        // Normalized response: ok (not success)
1470        assert!(
1471            result.get::<bool>("ok").expect("should have ok"),
1472            "should succeed"
1473        );
1474
1475        // duration_ms should be present
1476        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        // data should be forwarded
1483        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        // Register + mock failing request
1501        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        // duration_ms still present
1530        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    // --- register_intent validation tests ---
1568
1569    #[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        // Register without specifying operation
1653        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        // Dispatch it to verify operation defaults to "execute"
1672        // Mock orcs.request to capture the operation argument
1673        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    // --- register_tool tests ---
1698
1699    #[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    // --- dispatch_rust_tool: integer type preservation ---
1791
1792    #[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        // Verify line_number is Lua integer (not float).
1813        // mlua's get::<i64> succeeds only for Lua integers.
1814        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        // Also verify count is integer.
1820        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    // --- dispatch_rust_tool: capability check ---
1827
1828    /// Minimal mock ChildContext for testing capability gating.
1829    #[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        // Set ContextWrapper with NO capabilities.
1896        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        // read requires READ capability — should be denied.
1904        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        // Set ContextWrapper with READ capability.
1929        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        // Set ContextWrapper with READ only — WRITE not granted.
1957        // Uses positional orcs.write() which goes directly to dispatch_rust_tool
1958        // (bypassing HIL approval in dispatch_internal).
1959        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    // --- dispatch_rust_tool: positional wrapper with capability ---
1984
1985    #[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        // orcs.read() positional wrapper should also check capability.
2000        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    // --- MCP Lua API tests ---
2018
2019    #[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        // Manually register MCP intents to test filtering
2069        {
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        // Without filter: both tools
2097        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        // With filter: only srv_a
2110        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}