Skip to main content

bamboo_tools/
executor.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use bamboo_agent_core::{
5    normalize_tool_name, parse_tool_args_best_effort, Tool, ToolCall, ToolError,
6    ToolExecutionContext, ToolExecutor, ToolOutcome, ToolResult, ToolSchema,
7};
8use bamboo_domain::tool_names::{normalize_builtin_alias, resolve_alias};
9
10use crate::guide::{context::GuideBuildContext, EnhancedPromptBuilder, ToolGuide};
11use crate::permission::{check_permissions, PermissionChecker, PermissionError};
12use crate::tools::{
13    BashInputTool, BashOutputTool, BashTool, ConclusionWithOptionsTool, EditTool,
14    EnterPlanModeTool, ExitPlanModeTool, GetFileInfoTool, GlobTool, GrepTool, JsReplTool,
15    KillShellTool, NotebookEditTool, ReadTool, RequestPermissionsTool, SessionNoteTool, SleepTool,
16    TaskTool, ToolRegistry, UpdateGoalTool, WebFetchTool, WebSearchTool, WorkspaceTool, WriteTool,
17};
18use bamboo_llm::Config;
19use tokio::sync::RwLock;
20
21fn preview_for_log(value: &str, max_chars: usize) -> String {
22    let mut iter = value.chars();
23    let mut preview = String::new();
24    for _ in 0..max_chars {
25        match iter.next() {
26            Some(ch) => preview.push(ch),
27            None => break,
28        }
29    }
30    if iter.next().is_some() {
31        preview.push_str("...");
32    }
33    preview.replace('\n', "\\n").replace('\r', "\\r")
34}
35
36fn copy_legacy_arg_if_missing(
37    args: &mut serde_json::Map<String, serde_json::Value>,
38    from: &str,
39    to: &str,
40) {
41    if args.contains_key(to) {
42        return;
43    }
44    if let Some(value) = args.get(from).cloned() {
45        args.insert(to.to_string(), value);
46    }
47}
48
49fn normalize_legacy_builtin_args(
50    raw_tool_name: &str,
51    args: &mut serde_json::Map<String, serde_json::Value>,
52) {
53    match raw_tool_name {
54        "read_file" | "write_file" | "Read" | "Write" | "apply_patch" => {
55            copy_legacy_arg_if_missing(args, "path", "file_path");
56        }
57        "execute_command" | "Bash" => {
58            copy_legacy_arg_if_missing(args, "cmd", "command");
59        }
60        "list_directory" | "Glob" => {
61            let should_default_pattern = raw_tool_name == "list_directory"
62                || args.contains_key("path")
63                || args.contains_key("recursive");
64            if should_default_pattern && !args.contains_key("pattern") {
65                let recursive = args
66                    .get("recursive")
67                    .and_then(serde_json::Value::as_bool)
68                    .unwrap_or(false);
69                let pattern = if recursive { "**/*" } else { "*" };
70                args.insert(
71                    "pattern".to_string(),
72                    serde_json::Value::String(pattern.to_string()),
73                );
74            }
75            args.remove("recursive");
76        }
77        _ => {}
78    }
79}
80
81fn resolve_registered_tool_name(registry: &ToolRegistry, raw_tool_name: &str) -> String {
82    if registry.get(raw_tool_name).is_some() {
83        return raw_tool_name.to_string();
84    }
85
86    let aliased = normalize_builtin_alias(raw_tool_name);
87    if registry.get(aliased).is_some() {
88        return aliased.to_string();
89    }
90
91    resolve_alias(aliased).unwrap_or(aliased).to_string()
92}
93
94/// Built-in tool executor that uses ToolRegistry for dynamic dispatch
95pub struct BuiltinToolExecutor {
96    registry: ToolRegistry,
97    permission_checker: Option<Arc<dyn PermissionChecker>>,
98}
99
100impl BuiltinToolExecutor {
101    /// Creates a new executor with all built-in tools registered
102    pub fn new() -> Self {
103        let registry = ToolRegistry::new();
104        Self::register_builtin_tools(&registry, None);
105        Self {
106            registry,
107            permission_checker: None,
108        }
109    }
110
111    /// Creates a new executor with a permission checker
112    pub fn new_with_permissions(permission_checker: Arc<dyn PermissionChecker>) -> Self {
113        let registry = ToolRegistry::new();
114        Self::register_builtin_tools(&registry, None);
115        Self {
116            registry,
117            permission_checker: Some(permission_checker),
118        }
119    }
120
121    /// Creates a new executor that can read the shared, hot-reloadable config.
122    ///
123    /// Use this when running inside the Bamboo server so tools (notably
124    /// `http_request`) honor proxy settings from `config.json`.
125    pub fn new_with_config(config: Arc<RwLock<Config>>) -> Self {
126        let registry = ToolRegistry::new();
127        Self::register_builtin_tools(&registry, Some(config));
128        Self {
129            registry,
130            permission_checker: None,
131        }
132    }
133
134    /// Creates a new executor with both shared config and a permission checker.
135    pub fn new_with_config_and_permissions(
136        config: Arc<RwLock<Config>>,
137        permission_checker: Arc<dyn PermissionChecker>,
138    ) -> Self {
139        let registry = ToolRegistry::new();
140        Self::register_builtin_tools(&registry, Some(config));
141        Self {
142            registry,
143            permission_checker: Some(permission_checker),
144        }
145    }
146
147    /// Creates a new executor from an existing registry
148    pub fn with_registry(registry: ToolRegistry) -> Self {
149        Self {
150            registry,
151            permission_checker: None,
152        }
153    }
154
155    /// Returns a reference to the internal registry
156    pub fn registry(&self) -> &ToolRegistry {
157        &self.registry
158    }
159
160    /// Registers all built-in tools to the given registry
161    fn register_builtin_tools(registry: &ToolRegistry, config: Option<Arc<RwLock<Config>>>) {
162        let _ = config;
163        // NOTE: apply_patch is now an alias for Edit – no separate registration.
164        let _ = registry.register(ConclusionWithOptionsTool::new());
165        let _ = registry.register(BashTool::new());
166        let _ = registry.register(BashInputTool::new());
167        let _ = registry.register(BashOutputTool::new());
168        let _ = registry.register(EditTool::new());
169        let _ = registry.register(EnterPlanModeTool::new());
170        let _ = registry.register(ExitPlanModeTool::new());
171        // NOTE: FileExists is now an alias for GetFileInfo – no separate registration.
172        let _ = registry.register(GetFileInfoTool::new());
173        let _ = registry.register(GlobTool::new());
174        let _ = registry.register(GrepTool::new());
175        let _ = registry.register(UpdateGoalTool::new());
176        let _ = registry.register(JsReplTool::new());
177        let _ = registry.register(KillShellTool::new());
178        let _ = registry.register(SessionNoteTool::new());
179        let _ = registry.register(NotebookEditTool::new());
180        let _ = registry.register(ReadTool::new());
181        let _ = registry.register(RequestPermissionsTool::new());
182        let _ = registry.register(SleepTool::new());
183        let _ = registry.register(TaskTool::new());
184        let _ = registry.register(WebFetchTool::new());
185        let _ = registry.register(WebSearchTool::new());
186        // NOTE: GetCurrentDir + SetWorkspace are now aliases for Workspace.
187        let _ = registry.register(WorkspaceTool::new());
188        let _ = registry.register(WriteTool::new());
189    }
190
191    /// Returns all built-in tool schemas
192    pub fn tool_schemas() -> Vec<ToolSchema> {
193        let registry = ToolRegistry::new();
194        Self::register_builtin_tools(&registry, None);
195        registry.list_tools()
196    }
197
198    /// Registers a custom tool to this executor
199    pub fn register_tool<T: Tool + 'static>(&self, tool: T) -> Result<(), ToolError> {
200        self.registry
201            .register(tool)
202            .map_err(|e| ToolError::Execution(e.to_string()))
203    }
204
205    /// Register a tool with its guide
206    pub fn register_tool_with_guide<T, G>(&self, tool: T, guide: G) -> Result<(), ToolError>
207    where
208        T: Tool + 'static,
209        G: ToolGuide + 'static,
210    {
211        self.registry
212            .register_with_guide(tool, guide)
213            .map_err(|e| ToolError::Execution(e.to_string()))
214    }
215
216    /// Get guide for a tool
217    pub fn get_guide(&self, tool_name: &str) -> Option<Arc<dyn ToolGuide>> {
218        self.registry.get_guide(tool_name)
219    }
220
221    /// Build enhanced prompt for all registered tools
222    pub fn build_enhanced_prompt(&self, context: GuideBuildContext) -> String {
223        EnhancedPromptBuilder::build(Some(&self.registry), &self.registry.list_tools(), &context)
224    }
225}
226
227fn permission_error_to_tool_error(error: PermissionError) -> ToolError {
228    match error {
229        PermissionError::CheckFailed(_) => ToolError::InvalidArguments(error.to_string()),
230        _ => ToolError::Execution(error.to_string()),
231    }
232}
233
234impl Default for BuiltinToolExecutor {
235    fn default() -> Self {
236        Self::new()
237    }
238}
239
240#[async_trait]
241impl ToolExecutor for BuiltinToolExecutor {
242    async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
243        self.execute_with_context(call, ToolExecutionContext::none(&call.id))
244            .await
245    }
246
247    async fn execute_with_context(
248        &self,
249        call: &ToolCall,
250        ctx: ToolExecutionContext<'_>,
251    ) -> Result<ToolResult, ToolError> {
252        self.execute_with_context_outcome(call, ctx)
253            .await
254            .map(ToolOutcome::into_tool_result)
255    }
256
257    async fn execute_with_context_outcome(
258        &self,
259        call: &ToolCall,
260        ctx: ToolExecutionContext<'_>,
261    ) -> Result<ToolOutcome, ToolError> {
262        // Reuse the args the dispatching agent loop already parsed (for the
263        // `ToolStart` event) when it threaded them through the context, instead
264        // of re-parsing the raw JSON string here (issue #106, deferred B1 from
265        // #17). The pre-parsed value is the exact output of
266        // `parse_tool_args_best_effort` on the same input, and that loop already
267        // logged any fallback warning at parse time, so skipping the re-parse is
268        // behavior-preserving. When absent (the `execute` entry point, tests, or
269        // a loop that parsed with a different/stricter parser), fall back to
270        // parsing here exactly as before — including the fallback-warning log.
271        let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
272            pre_parsed.clone()
273        } else {
274            let args_raw = call.function.arguments.trim();
275            let (parsed, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
276            if let Some(warning) = parse_warning {
277                tracing::warn!(
278                    "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
279                    ctx.session_id,
280                    call.id,
281                    call.function.name,
282                    args_raw.len(),
283                    preview_for_log(args_raw, 180),
284                    warning
285                );
286            }
287            parsed
288        };
289
290        let raw_tool_name = normalize_tool_name(&call.function.name);
291        if let Some(args_obj) = args.as_object_mut() {
292            normalize_legacy_builtin_args(raw_tool_name, args_obj);
293        }
294
295        let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
296
297        // Look up the tool in the registry
298        let tool = self
299            .registry
300            .get(&tool_name)
301            .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", tool_name)))?;
302
303        // Permission gate. Factored onto the `ToolExecutor` trait
304        // (`check_permissions_for`) so overlay/wrapping executors can run the
305        // exact same check before invoking their own tools (issue #341). Kept
306        // AFTER the registry lookup so a `NotFound` still takes precedence,
307        // exactly as before. `Some(outcome)` is the interactive approval pause
308        // synthesized for a human sink; `Err` is deny / fail-closed.
309        if let Some(outcome) = self.check_permissions_for(call, &ctx).await? {
310            return Ok(outcome);
311        }
312
313        // Rewritten dispatch: build the owned `ToolCtx` at this concrete seam and
314        // call the tool's single `invoke`. Unwrap the `ToolOutcome` back to a
315        // `ToolResult` so the surrounding dispatch/loop is unchanged for now:
316        // `Completed` is the result; `Running`'s synthetic ack IS a `ToolResult`
317        // (preserving background Bash's current behavior); `NeedsHuman` cannot yet
318        // be produced (no tool returns it in this phase). Phase B makes the outcome
319        // authoritative and removes this unwrap.
320        let tool_ctx = ctx.to_tool_ctx();
321        tool.invoke(args, tool_ctx).await
322    }
323
324    /// The real permission gate for built-in tools, extracted from the execute
325    /// path so it is reusable by wrapping executors (issue #341). The behavior is
326    /// byte-for-byte the same block that used to run inline in
327    /// `execute_with_context_outcome`:
328    ///
329    /// - resolves the SAME `tool_name` + `args` the execute path runs with (so
330    ///   the check sees exactly what the tool will run with);
331    /// - "always ask" rules (`requires_forced_confirmation`) force a confirmation
332    ///   even under bypass; everything else is skipped when the session is in
333    ///   bypass-permissions mode;
334    /// - forced confirmations route through `check_or_request_forced` so the
335    ///   active mode/bypass can't suppress the prompt;
336    /// - a `ConfirmationRequired` first tries the cross-process `ApprovalProxy`
337    ///   (a subagent worker forwarding to its host), then the interactive human
338    ///   sink (returning the synthesized approval pause as `Ok(Some(..))`), then
339    ///   fails closed;
340    /// - deny fails closed.
341    ///
342    /// The only mechanical difference from the old inline block: the interactive
343    /// pause is returned as `Ok(Some(outcome))` and a clean pass returns
344    /// `Ok(None)`, so the caller decides whether to run the tool. The fallback
345    /// arg-parse warning is intentionally NOT re-logged here — the execute path
346    /// already logs it once for this call.
347    async fn check_permissions_for(
348        &self,
349        call: &ToolCall,
350        ctx: &ToolExecutionContext<'_>,
351    ) -> Result<Option<ToolOutcome>, ToolError> {
352        let Some(permission_checker) = &self.permission_checker else {
353            return Ok(None);
354        };
355
356        // Mirror the head of `execute_with_context_outcome`: reuse the pre-parsed
357        // args when threaded, apply the legacy-arg normalization, then resolve the
358        // registered/alias tool name. This is what makes the gate see the exact
359        // `tool_name`/`args` the tool will actually run with.
360        let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
361            pre_parsed.clone()
362        } else {
363            parse_tool_args_best_effort(&call.function.arguments).0
364        };
365        let raw_tool_name = normalize_tool_name(&call.function.name);
366        if let Some(args_obj) = args.as_object_mut() {
367            normalize_legacy_builtin_args(raw_tool_name, args_obj);
368        }
369        let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
370
371        if let Some(contexts) =
372            check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
373        {
374            // "Always ask" rules (configured patterns + built-in dangerous
375            // commands) force a confirmation even under bypass. Everything
376            // else is skipped when this session is in "bypass permissions"
377            // mode (scoped per-session via its runtime state).
378            let force_ask = permission_checker.requires_forced_confirmation(&tool_name, &args);
379            for context in contexts {
380                if ctx.bypass_permissions && !force_ask {
381                    continue;
382                }
383                let resource = context.resource.clone();
384                // Forced confirmations route through `check_or_request_forced`
385                // so the active mode/bypass cannot suppress the prompt.
386                let decision = if force_ask {
387                    permission_checker.check_or_request_forced(context).await
388                } else {
389                    permission_checker.check_or_request(context).await
390                };
391                match decision {
392                    Ok(true) => {}
393                    Ok(false) => {
394                        return Err(ToolError::Execution(format!(
395                            "Permission denied for: {}",
396                            resource
397                        )));
398                    }
399                    Err(PermissionError::ConfirmationRequired {
400                        permission_type,
401                        resource: _,
402                    }) => {
403                        // Phase 2 (cross-process): a subagent worker installs a
404                        // task-local `ApprovalProxy` for the duration of its run.
405                        // When present, forward the decision to the worker's host
406                        // (parent) and block this tool inline for the reply —
407                        // approve proceeds (treated like a granted context), deny
408                        // fails closed. Checked BEFORE the interactive sink below
409                        // so a worker (which also has an `event_tx`) proxies to its
410                        // parent instead of trying to prompt a human itself. The
411                        // proxy is unset on every non-worker path, so the behavior
412                        // there is unchanged.
413                        if let Some(proxy) = crate::approval::current_approval_proxy() {
414                            let approved = proxy
415                                .request_approval(crate::approval::ApprovalAsk {
416                                    tool_name: tool_name.clone(),
417                                    permission: permission_type.description().to_string(),
418                                    resource: resource.clone(),
419                                })
420                                .await;
421                            if approved {
422                                // Treat as a granted context: check any remaining
423                                // contexts, then fall through to execution.
424                                continue;
425                            }
426                            return Err(ToolError::Execution(format!(
427                                "Permission denied by host for: {}",
428                                resource
429                            )));
430                        }
431
432                        // Interactive sessions pause for approval by reusing the
433                        // same pending-question pipeline as `request_permissions`:
434                        // synthesize an "awaiting_permission_approval" result that
435                        // the engine recognizes (via display_preference) and turns
436                        // into a NeedClarification pause. On approval the respond
437                        // handler records a session grant so the re-attempt passes.
438                        if let Some(tx) = ctx.event_tx {
439                            // Keep emitting the structured approval event for observers.
440                            let _ = tx
441                                .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
442                                    tool_call_id: call.id.clone(),
443                                    tool_name: tool_name.clone(),
444                                    parameters: args.clone(),
445                                })
446                                .await;
447
448                            let question = format!(
449                                "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
450                                tool_name,
451                                permission_type.description(),
452                                resource
453                            );
454                            let payload = serde_json::json!({
455                                "status": "awaiting_permission_approval",
456                                "question": question,
457                                "permission_type": permission_type,
458                                "resource": resource,
459                                "options": ["Approve", "Deny"],
460                                "allow_custom": false,
461                            });
462                            // Permission-gate synthesized question stays on
463                            // the Completed→sniff path for now (a later Phase B
464                            // step converts this to ToolOutcome::NeedsHuman).
465                            return Ok(Some(ToolOutcome::Completed(ToolResult {
466                                success: true,
467                                result: payload.to_string(),
468                                display_preference: Some("request_permissions".to_string()),
469                                images: Vec::new(),
470                            })));
471                        }
472
473                        // Non-interactive (no event sink to surface the prompt):
474                        // fail closed rather than silently proceeding.
475                        return Err(ToolError::Execution(format!(
476                            "Permission approval required for: {}",
477                            resource
478                        )));
479                    }
480                    Err(other) => {
481                        return Err(permission_error_to_tool_error(other));
482                    }
483                }
484            }
485        }
486
487        Ok(None)
488    }
489
490    fn list_tools(&self) -> Vec<ToolSchema> {
491        self.registry.list_tools()
492    }
493
494    fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
495        self.registry
496            .get(tool_name)
497            .map(|tool| tool.classify(&serde_json::Value::Null).mutability)
498            .unwrap_or_else(|| crate::classify_tool(tool_name))
499    }
500
501    fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
502        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
503        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
504        self.registry
505            .get(&canonical)
506            .map(|tool| tool.classify(&args).mutability)
507            .unwrap_or_else(|| self.tool_mutability(&canonical))
508    }
509
510    fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
511        let canonical = resolve_registered_tool_name(&self.registry, tool_name);
512        self.registry
513            .get(&canonical)
514            .map(|tool| tool.classify(&serde_json::Value::Null).parallel_safe)
515            .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
516    }
517
518    fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
519        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
520        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
521        self.registry
522            .get(&canonical)
523            .map(|tool| tool.classify(&args).parallel_safe)
524            .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
525    }
526
527    fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
528        // One args-aware `classify` returns the (mutability, parallel_safe) pair
529        // with a single arg parse — the collapse of the former
530        // `call_mutability`/`call_concurrency_safe` pair.
531        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
532        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
533        match self.registry.get(&canonical) {
534            Some(tool) => {
535                let class = tool.classify(&args);
536                (class.mutability, class.parallel_safe)
537            }
538            None => (
539                self.tool_mutability(&canonical),
540                self.tool_concurrency_safe(&canonical),
541            ),
542        }
543    }
544}
545
546/// Builder for constructing a BuiltinToolExecutor with custom tool configurations
547pub struct BuiltinToolExecutorBuilder {
548    registry: ToolRegistry,
549    permission_checker: Option<Arc<dyn PermissionChecker>>,
550}
551
552impl BuiltinToolExecutorBuilder {
553    /// Creates a new builder with no tools registered
554    pub fn new() -> Self {
555        Self {
556            registry: ToolRegistry::new(),
557            permission_checker: None,
558        }
559    }
560
561    /// Registers all default built-in tools
562    pub fn with_default_tools(self) -> Self {
563        BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
564        self
565    }
566
567    /// Registers a specific filesystem tool by name
568    pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
569        match name {
570            "Read" => self.registry.register(ReadTool::new()),
571            "Write" => self.registry.register(WriteTool::new()),
572            // apply_patch is now an alias for Edit
573            "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
574            "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
575            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
576        }
577        .map_err(|e| ToolError::Execution(e.to_string()))?;
578        Ok(self)
579    }
580
581    /// Registers a specific command tool by name
582    pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
583        match name {
584            "Bash" => self.registry.register(BashTool::new()),
585            "BashOutput" => self.registry.register(BashOutputTool::new()),
586            "KillShell" => self.registry.register(KillShellTool::new()),
587            "Task" => self.registry.register(TaskTool::new()),
588            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
589        }
590        .map_err(|e| ToolError::Execution(e.to_string()))?;
591        Ok(self)
592    }
593
594    /// Registers a custom tool
595    pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
596        self.registry
597            .register(tool)
598            .map_err(|e| ToolError::Execution(e.to_string()))?;
599        Ok(self)
600    }
601
602    /// Sets a permission checker for this executor
603    pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
604        self.permission_checker = Some(checker);
605        self
606    }
607
608    /// Builds the executor
609    pub fn build(self) -> BuiltinToolExecutor {
610        BuiltinToolExecutor {
611            registry: self.registry,
612            permission_checker: self.permission_checker,
613        }
614    }
615}
616
617impl Default for BuiltinToolExecutorBuilder {
618    fn default() -> Self {
619        Self::new()
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use bamboo_agent_core::AgentEvent;
627    use bamboo_agent_core::FunctionCall;
628    use bamboo_agent_core::ToolCtx;
629    use bamboo_agent_core::ToolExecutionContext;
630    use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
631    use serde_json::json;
632    use std::sync::Arc;
633    use tokio::fs;
634    use tokio::sync::mpsc;
635
636    use crate::tools::WriteTool;
637
638    fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
639        ToolCall {
640            id: "call_1".to_string(),
641            tool_type: "function".to_string(),
642            function: FunctionCall {
643                name: name.to_string(),
644                arguments: args.to_string(),
645            },
646        }
647    }
648
649    fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
650        ToolCall {
651            id: "call_1".to_string(),
652            tool_type: "function".to_string(),
653            function: FunctionCall {
654                name: name.to_string(),
655                arguments: raw_args.to_string(),
656            },
657        }
658    }
659
660    fn make_executor(
661        permission_checker: Option<Arc<dyn PermissionChecker>>,
662    ) -> BuiltinToolExecutor {
663        let builder = BuiltinToolExecutorBuilder::new()
664            .with_tool(WriteTool::new())
665            .expect("register Write tool");
666
667        let builder = match permission_checker {
668            Some(checker) => builder.with_permission_checker(checker),
669            None => builder,
670        };
671
672        builder.build()
673    }
674
675    #[test]
676    fn test_normalize_tool_ref_accepts_claude_style_names() {
677        assert_eq!(
678            normalize_tool_ref("default::Bash"),
679            Some("Bash".to_string())
680        );
681    }
682
683    #[test]
684    fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
685        assert_eq!(
686            normalize_tool_ref("default::fileExists"),
687            Some("FileExists".to_string())
688        );
689        assert_eq!(
690            normalize_tool_ref("default::getCurrentDir"),
691            Some("GetCurrentDir".to_string())
692        );
693        assert_eq!(
694            normalize_tool_ref("default::getFileInfo"),
695            Some("GetFileInfo".to_string())
696        );
697        assert_eq!(
698            normalize_tool_ref("default::setWorkspace"),
699            Some("SetWorkspace".to_string())
700        );
701        assert_eq!(
702            normalize_tool_ref("default::sleep"),
703            Some("Sleep".to_string())
704        );
705    }
706
707    #[test]
708    fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
709        assert_eq!(
710            normalize_tool_ref("default::execute_command"),
711            Some("Bash".to_string())
712        );
713        assert_eq!(
714            normalize_tool_ref("default::file_exists"),
715            Some("FileExists".to_string())
716        );
717        assert_eq!(
718            normalize_tool_ref("default::get_current_dir"),
719            Some("GetCurrentDir".to_string())
720        );
721        assert_eq!(
722            normalize_tool_ref("default::get_file_info"),
723            Some("GetFileInfo".to_string())
724        );
725        assert_eq!(
726            normalize_tool_ref("default::list_directory"),
727            Some("Glob".to_string())
728        );
729        assert_eq!(
730            normalize_tool_ref("default::memory_note"),
731            Some("memory_note".to_string())
732        );
733        assert_eq!(
734            normalize_tool_ref("default::read_file"),
735            Some("Read".to_string())
736        );
737        assert_eq!(
738            normalize_tool_ref("default::set_workspace"),
739            Some("SetWorkspace".to_string())
740        );
741        assert_eq!(
742            normalize_tool_ref("default::write_file"),
743            Some("Write".to_string())
744        );
745    }
746
747    #[test]
748    fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
749        for alias in [
750            "default::spawn_session",
751            "default::sub_session",
752            "default::sub_task",
753            "default::team_agent",
754            "default::child_session",
755        ] {
756            assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
757        }
758    }
759
760    #[test]
761    fn test_normalize_tool_ref_accepts_server_overlay_tools() {
762        assert_eq!(normalize_tool_ref("compress_context"), None);
763        assert_eq!(
764            normalize_tool_ref("default::read_skill_resource"),
765            Some("read_skill_resource".to_string())
766        );
767    }
768
769    #[tokio::test]
770    async fn test_executor_accepts_legacy_read_file_path_argument() {
771        let dir = tempfile::tempdir().unwrap();
772        let file_path = dir.path().join("legacy-read.txt");
773        fs::write(&file_path, "legacy read content").await.unwrap();
774
775        let executor = BuiltinToolExecutor::new();
776        let call = make_tool_call("read_file", json!({"path": file_path}));
777
778        let result = executor.execute(&call).await.unwrap();
779        assert!(result.success);
780        assert!(result.result.contains("legacy read content"));
781    }
782
783    #[tokio::test]
784    async fn test_executor_accepts_legacy_list_directory_without_pattern() {
785        let dir = tempfile::tempdir().unwrap();
786        let file_path = dir.path().join("legacy-list.txt");
787        fs::write(&file_path, "legacy list content").await.unwrap();
788
789        let executor = BuiltinToolExecutor::new();
790        let call = make_tool_call("list_directory", json!({"path": dir.path()}));
791
792        let result = executor.execute(&call).await.unwrap();
793        assert!(result.success);
794        assert!(result.result.contains("legacy-list.txt"));
795    }
796
797    #[tokio::test]
798    async fn test_executor_accepts_canonical_read_with_path_argument() {
799        let dir = tempfile::tempdir().unwrap();
800        let file_path = dir.path().join("canonical-read.txt");
801        fs::write(&file_path, "canonical read content")
802            .await
803            .unwrap();
804
805        let executor = BuiltinToolExecutor::new();
806        let call = make_tool_call("Read", json!({"path": file_path}));
807
808        let result = executor.execute(&call).await.unwrap();
809        assert!(result.success);
810        assert!(result.result.contains("canonical read content"));
811    }
812
813    #[tokio::test]
814    async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
815        let dir = tempfile::tempdir().unwrap();
816        let file_path = dir.path().join("canonical-list.txt");
817        fs::write(&file_path, "canonical list content")
818            .await
819            .unwrap();
820
821        let executor = BuiltinToolExecutor::new();
822        let call = make_tool_call("Glob", json!({"path": dir.path()}));
823
824        let result = executor.execute(&call).await.unwrap();
825        assert!(result.success);
826        assert!(result.result.contains("canonical-list.txt"));
827    }
828
829    #[test]
830    fn test_executor_workspace_mutability_depends_on_path_argument() {
831        let executor = BuiltinToolExecutor::new();
832        let get_call = make_tool_call("Workspace", json!({}));
833        let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
834
835        assert_eq!(
836            executor.call_mutability(&get_call),
837            crate::ToolMutability::ReadOnly
838        );
839        assert!(executor.call_concurrency_safe(&get_call));
840
841        assert_eq!(
842            executor.call_mutability(&set_call),
843            crate::ToolMutability::Mutating
844        );
845        assert!(!executor.call_concurrency_safe(&set_call));
846    }
847
848    #[test]
849    fn call_parallel_classification_matches_individual_methods() {
850        // Regression guard for the issue #17 perf refactor: the combined
851        // `call_parallel_classification` (which parses args once) must return the
852        // exact same (mutability, concurrency_safe) pair as calling
853        // `call_mutability` and `call_concurrency_safe` separately (which each
854        // parse args). Covers a read-only tool, mutating tools, and an
855        // args-aware tool (Workspace get vs set) so every branch of the
856        // single-parse override is exercised.
857        let executor = BuiltinToolExecutor::new();
858        let cases: &[(&str, serde_json::Value)] = &[
859            ("Read", json!({})),
860            ("Grep", json!({"pattern": "x"})),
861            (
862                "Write",
863                json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
864            ),
865            ("Bash", json!({"command": "echo hi"})),
866            ("Workspace", json!({})),
867            ("Workspace", json!({"path": "/tmp"})),
868        ];
869
870        for (name, args) in cases {
871            let call = make_tool_call(name, args.clone());
872            let expected_mutability = executor.call_mutability(&call);
873            let expected_concurrency = executor.call_concurrency_safe(&call);
874            let (mutability, concurrency) = executor.call_parallel_classification(&call);
875            assert_eq!(
876                mutability, expected_mutability,
877                "mutability mismatch for {name} ({args})"
878            );
879            assert_eq!(
880                concurrency, expected_concurrency,
881                "concurrency mismatch for {name} ({args})"
882            );
883        }
884    }
885
886    #[test]
887    fn list_tools_snapshot_is_stable_across_calls() {
888        // The per-round schema cache (issue #17 Part A) assumes the executor's
889        // `list_tools()` is stable within a round: a snapshot taken once must
890        // equal a fresh call. Guards that invariant so caching the set for the
891        // duration of a round can't serve a stale or filtered view.
892        let executor = BuiltinToolExecutor::new();
893        let first: Vec<String> = executor
894            .list_tools()
895            .into_iter()
896            .map(|s| s.function.name)
897            .collect();
898        let second: Vec<String> = executor
899            .list_tools()
900            .into_iter()
901            .map(|s| s.function.name)
902            .collect();
903        assert!(!first.is_empty(), "builtin executor should expose tools");
904        assert_eq!(
905            first, second,
906            "list_tools() must be deterministic per round"
907        );
908    }
909
910    #[tokio::test]
911    async fn test_executor_recovers_truncated_json_arguments() {
912        let dir = tempfile::tempdir().unwrap();
913        let path = dir.path().join("recovered-write.txt");
914
915        // Missing closing brace simulates EOF while parsing an object.
916        let malformed_args = format!(
917            r#"{{"file_path":"{}","content":"recovered content""#,
918            path.display()
919        );
920
921        let executor = BuiltinToolExecutor::new();
922        let call = make_tool_call_with_raw_args("Write", &malformed_args);
923
924        let result = executor
925            .execute(&call)
926            .await
927            .expect("truncated JSON should be auto-repaired");
928        assert!(result.success);
929
930        let written = fs::read_to_string(&path)
931            .await
932            .expect("file should be written");
933        assert_eq!(written, "recovered content");
934    }
935
936    #[test]
937    fn test_normalize_tool_ref_rejects_unknown_tool() {
938        assert_eq!(normalize_tool_ref("default::search"), None);
939    }
940
941    #[test]
942    fn test_executor_does_not_expose_legacy_tools() {
943        let executor = BuiltinToolExecutor::new();
944        let tool_names: Vec<String> = executor
945            .list_tools()
946            .into_iter()
947            .map(|schema| schema.function.name)
948            .collect();
949
950        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
951            assert!(!tool_names.iter().any(|name| name == legacy));
952        }
953    }
954
955    #[test]
956    fn test_critical_tool_schemas_match_claude_shapes() {
957        let executor = BuiltinToolExecutor::new();
958        let tools = executor.list_tools();
959
960        let get_params = |name: &str| {
961            tools
962                .iter()
963                .find(|tool| tool.function.name == name)
964                .unwrap()
965                .function
966                .parameters
967                .clone()
968        };
969
970        let grep = get_params("Grep");
971        assert_eq!(grep["required"], json!(["pattern"]));
972        assert_eq!(
973            grep["properties"]["output_mode"]["enum"],
974            json!(["content", "files_with_matches", "count"])
975        );
976        assert!(grep["properties"]["-A"].is_object());
977        assert!(grep["properties"]["-B"].is_object());
978        assert!(grep["properties"]["-C"].is_object());
979        assert!(grep["properties"]["-n"].is_object());
980        assert!(grep["properties"]["-i"].is_object());
981
982        let edit = get_params("Edit");
983        assert_eq!(edit["required"], json!(["file_path"]));
984        assert_eq!(edit["properties"]["old_string"]["type"], "string");
985        assert_eq!(edit["properties"]["new_string"]["type"], "string");
986        assert_eq!(edit["properties"]["patch"]["type"], "string");
987        assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
988        assert!(edit.get("oneOf").is_none());
989
990        // apply_patch is now an alias for Edit – its schema is the Edit
991        // schema, so we just verify that Edit includes the patch property.
992        assert_eq!(edit["properties"]["patch"]["type"], "string");
993        assert_eq!(edit["properties"]["line_number"]["type"], "integer");
994
995        let bash = get_params("Bash");
996        assert_eq!(bash["required"], json!(["command"]));
997        assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
998        assert_eq!(bash["properties"]["workdir"]["type"], "string");
999
1000        let bash_output = get_params("BashOutput");
1001        assert_eq!(bash_output["required"], json!(["bash_id"]));
1002        assert_eq!(bash_output["properties"]["filter"]["type"], "string");
1003    }
1004
1005    #[test]
1006    fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
1007        let executor = BuiltinToolExecutor::new();
1008        let tools = executor.list_tools();
1009        let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
1010
1011        for tool in tools {
1012            let params = &tool.function.parameters;
1013            assert_eq!(
1014                params["type"], "object",
1015                "tool '{}' parameters must be a top-level object schema",
1016                tool.function.name
1017            );
1018            for key in forbidden {
1019                assert!(
1020                    params.get(key).is_none(),
1021                    "tool '{}' parameters contains forbidden top-level keyword '{}'",
1022                    tool.function.name,
1023                    key
1024                );
1025            }
1026        }
1027    }
1028
1029    #[test]
1030    fn test_executor_has_all_builtin_tools() {
1031        let executor = BuiltinToolExecutor::new();
1032        let tools = executor.list_tools();
1033
1034        assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
1035
1036        let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
1037        for tool_name in BUILTIN_TOOL_NAMES {
1038            assert!(tool_names.contains(&tool_name.to_string()));
1039        }
1040    }
1041
1042    #[test]
1043    fn test_executor_builds_enhanced_prompt() {
1044        let executor = BuiltinToolExecutor::new();
1045        let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
1046        assert!(prompt.contains("## Tool Usage Guidelines"));
1047        assert!(prompt.contains("**Read**"));
1048    }
1049
1050    #[test]
1051    fn test_executor_builder_empty() {
1052        let executor = BuiltinToolExecutorBuilder::new().build();
1053        assert!(executor.list_tools().is_empty());
1054    }
1055
1056    #[test]
1057    fn test_executor_builder_with_default_tools() {
1058        let executor = BuiltinToolExecutorBuilder::new()
1059            .with_default_tools()
1060            .build();
1061        assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
1062    }
1063
1064    #[test]
1065    fn test_executor_builder_with_specific_tool() {
1066        let executor = BuiltinToolExecutorBuilder::new()
1067            .with_filesystem_tool("Read")
1068            .unwrap()
1069            .build();
1070
1071        let tools = executor.list_tools();
1072        assert_eq!(tools.len(), 1);
1073        assert_eq!(tools[0].function.name, "Read");
1074    }
1075
1076    #[tokio::test]
1077    async fn test_executor_skips_permission_checks_without_checker() {
1078        let executor = make_executor(None);
1079        let path = "/tmp/executor_permission_none.txt";
1080        let _ = fs::remove_file(path).await;
1081
1082        let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
1083        let result = executor.execute(&call).await.expect("execute tool");
1084
1085        assert!(result.success);
1086        let _ = fs::remove_file(path).await;
1087    }
1088
1089    #[tokio::test]
1090    async fn test_executor_with_permission_checker_enforces_checks() {
1091        let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1092        let executor = make_executor(Some(checker));
1093        let path = "/tmp/executor_permission_denied.txt";
1094        let _ = fs::remove_file(path).await;
1095
1096        let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
1097        let result = executor.execute(&call).await;
1098
1099        assert!(matches!(result, Err(ToolError::Execution(_))));
1100        assert!(fs::metadata(path).await.is_err());
1101    }
1102
1103    #[tokio::test]
1104    async fn test_bypass_permissions_skips_checker() {
1105        // Even with a deny-all checker, a context flagged `bypass_permissions`
1106        // must skip the permission check entirely and let the write through.
1107        let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1108        let executor = make_executor(Some(checker));
1109        let dir = tempfile::tempdir().unwrap();
1110        let path = dir.path().join("bypass_allows_write.txt");
1111        let path_str = path.to_str().unwrap();
1112
1113        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1114        let ctx = ToolExecutionContext {
1115            session_id: Some("s-bypass"),
1116            tool_call_id: &call.id,
1117            event_tx: None,
1118            available_tool_schemas: None,
1119            bypass_permissions: true,
1120            can_async_resume: false,
1121            bash_completion_sink: None,
1122            pre_parsed_args: None,
1123        };
1124        let result = executor.execute_with_context(&call, ctx).await;
1125
1126        assert!(result.is_ok(), "bypass should allow the write: {result:?}");
1127        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1128    }
1129
1130    #[tokio::test]
1131    async fn test_forced_ask_rule_overrides_bypass() {
1132        // A configured "always ask" rule must force a confirmation even when the
1133        // session is in bypass mode. With no event sink the executor fails closed
1134        // (approval required) rather than silently writing.
1135        let config = Arc::new(crate::permission::PermissionConfig::new());
1136        config.set_ask_rules(["Write(/etc/**)".to_string()]);
1137        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1138        let executor = make_executor(Some(checker));
1139
1140        let call = make_tool_call(
1141            "Write",
1142            json!({"file_path": "/etc/forced.conf", "content": "x"}),
1143        );
1144        let ctx = ToolExecutionContext {
1145            session_id: Some("s-forced"),
1146            tool_call_id: &call.id,
1147            event_tx: None,
1148            available_tool_schemas: None,
1149            bypass_permissions: true,
1150            can_async_resume: false,
1151            bash_completion_sink: None,
1152            pre_parsed_args: None,
1153        };
1154        let result = executor.execute_with_context(&call, ctx).await;
1155
1156        assert!(
1157            matches!(result, Err(ToolError::Execution(ref m)) if m.contains("approval required")),
1158            "forced ask rule should block under bypass: {result:?}"
1159        );
1160        assert!(fs::metadata("/etc/forced.conf").await.is_err());
1161    }
1162
1163    #[tokio::test]
1164    async fn interactive_gate_returns_synthesized_approval_pause() {
1165        // With an event sink present, a forced-ask rule that yields
1166        // `ConfirmationRequired` must resolve to the synthesized "awaiting
1167        // approval" PAUSE result (a `Completed` result tagged
1168        // `display_preference = "request_permissions"`) — NOT an error — so the
1169        // engine turns it into a clarification pause. This locks in the
1170        // interactive-sink path that the `check_permissions_for` extraction must
1171        // preserve as `Ok(Some(outcome))` rather than collapse to an `Err`.
1172        let config = Arc::new(crate::permission::PermissionConfig::new());
1173        config.set_ask_rules(["Write(/etc/**)".to_string()]);
1174        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1175        let executor = make_executor(Some(checker));
1176
1177        let (tx, mut rx) = mpsc::channel(8);
1178        let call = make_tool_call(
1179            "Write",
1180            json!({"file_path": "/etc/gated.conf", "content": "x"}),
1181        );
1182        let ctx = ToolExecutionContext {
1183            session_id: Some("s-interactive"),
1184            tool_call_id: &call.id,
1185            event_tx: Some(&tx),
1186            available_tool_schemas: None,
1187            bypass_permissions: false,
1188            can_async_resume: false,
1189            bash_completion_sink: None,
1190            pre_parsed_args: None,
1191        };
1192
1193        let result = executor
1194            .execute_with_context(&call, ctx)
1195            .await
1196            .expect("interactive gate should pause (Ok), not error");
1197
1198        assert_eq!(
1199            result.display_preference.as_deref(),
1200            Some("request_permissions"),
1201            "interactive gate must return the request_permissions pause result"
1202        );
1203        assert!(result.result.contains("awaiting_permission_approval"));
1204        assert!(fs::metadata("/etc/gated.conf").await.is_err());
1205
1206        let ev = rx.recv().await.expect("approval event should be emitted");
1207        assert!(
1208            matches!(ev, AgentEvent::ToolApprovalRequested { tool_name, .. } if tool_name == "Write")
1209        );
1210    }
1211
1212    #[tokio::test]
1213    async fn check_permissions_for_returns_none_when_permitted() {
1214        // A tool with no matching gate (Read, no checker rule) passes the gate:
1215        // `check_permissions_for` returns `Ok(None)` so the caller runs the tool.
1216        let executor = make_executor(None);
1217        let call = make_tool_call("Read", json!({"file_path": "/tmp/whatever"}));
1218        let ctx = ToolExecutionContext::none(&call.id);
1219        let decision = executor
1220            .check_permissions_for(&call, &ctx)
1221            .await
1222            .expect("no checker means no gate");
1223        assert!(decision.is_none(), "no checker must yield Ok(None)");
1224    }
1225
1226    // ---- Phase 2: cross-process approval proxy ----------------------------
1227
1228    struct HostStub {
1229        approve: bool,
1230    }
1231
1232    #[async_trait]
1233    impl crate::approval::ApprovalProxy for HostStub {
1234        async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
1235            self.approve
1236        }
1237    }
1238
1239    #[tokio::test]
1240    async fn approval_proxy_grant_lets_gated_tool_proceed() {
1241        // A subagent worker installs an ApprovalProxy for its run. A forced-ask
1242        // rule with NO event sink would otherwise fail closed; with the host
1243        // proxy granting, the executor treats the context as approved and the
1244        // tool proceeds inline (no suspend, no synthetic pause).
1245        let dir = tempfile::tempdir().unwrap();
1246        let path = dir.path().join("approved.txt");
1247        let path_str = path.to_str().unwrap().to_string();
1248        let config = Arc::new(crate::permission::PermissionConfig::new());
1249        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1250        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1251        let executor = make_executor(Some(checker));
1252
1253        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1254        let ctx = ToolExecutionContext {
1255            session_id: Some("s-worker"),
1256            tool_call_id: &call.id,
1257            event_tx: None,
1258            available_tool_schemas: None,
1259            bypass_permissions: false,
1260            can_async_resume: false,
1261            bash_completion_sink: None,
1262            pre_parsed_args: None,
1263        };
1264
1265        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
1266        let result = crate::approval::with_approval_proxy(
1267            Some(proxy),
1268            executor.execute_with_context(&call, ctx),
1269        )
1270        .await;
1271
1272        assert!(
1273            result.is_ok(),
1274            "host grant should let the write through: {result:?}"
1275        );
1276        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1277    }
1278
1279    #[tokio::test]
1280    async fn approval_proxy_deny_fails_gated_tool_closed() {
1281        // With the host proxy denying, the gated tool fails closed and the side
1282        // effect never happens.
1283        let dir = tempfile::tempdir().unwrap();
1284        let path = dir.path().join("denied.txt");
1285        let path_str = path.to_str().unwrap().to_string();
1286        let config = Arc::new(crate::permission::PermissionConfig::new());
1287        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1288        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1289        let executor = make_executor(Some(checker));
1290
1291        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
1292        let ctx = ToolExecutionContext {
1293            session_id: Some("s-worker"),
1294            tool_call_id: &call.id,
1295            event_tx: None,
1296            available_tool_schemas: None,
1297            bypass_permissions: false,
1298            can_async_resume: false,
1299            bash_completion_sink: None,
1300            pre_parsed_args: None,
1301        };
1302
1303        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
1304        let result = crate::approval::with_approval_proxy(
1305            Some(proxy),
1306            executor.execute_with_context(&call, ctx),
1307        )
1308        .await;
1309
1310        assert!(
1311            matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
1312            "host deny should fail the tool closed: {result:?}"
1313        );
1314        assert!(fs::metadata(&path).await.is_err());
1315    }
1316
1317    #[tokio::test]
1318    async fn tool_can_stream_events_via_execute_with_context() {
1319        struct StreamingTool;
1320
1321        #[async_trait]
1322        impl Tool for StreamingTool {
1323            fn name(&self) -> &str {
1324                "streaming_tool"
1325            }
1326
1327            fn description(&self) -> &str {
1328                "streams one token"
1329            }
1330
1331            fn parameters_schema(&self) -> serde_json::Value {
1332                json!({"type":"object","properties":{}})
1333            }
1334
1335            async fn invoke(
1336                &self,
1337                _args: serde_json::Value,
1338                ctx: ToolCtx,
1339            ) -> Result<ToolOutcome, ToolError> {
1340                ctx.emit(AgentEvent::Token {
1341                    content: "stream".to_string(),
1342                })
1343                .await;
1344                Ok(ToolOutcome::Completed(ToolResult {
1345                    success: true,
1346                    result: "ok".to_string(),
1347                    display_preference: None,
1348                    images: Vec::new(),
1349                }))
1350            }
1351        }
1352
1353        let executor = BuiltinToolExecutor::new();
1354        executor
1355            .register_tool(StreamingTool)
1356            .expect("register streaming tool");
1357
1358        let (tx, mut rx) = mpsc::channel(8);
1359        let call = make_tool_call("streaming_tool", json!({}));
1360
1361        let result = executor
1362            .execute_with_context(
1363                &call,
1364                ToolExecutionContext {
1365                    session_id: Some("s1"),
1366                    tool_call_id: &call.id,
1367                    event_tx: Some(&tx),
1368                    available_tool_schemas: None,
1369                    bypass_permissions: false,
1370                    can_async_resume: false,
1371                    bash_completion_sink: None,
1372                    pre_parsed_args: None,
1373                },
1374            )
1375            .await
1376            .expect("execute tool");
1377
1378        assert!(result.success);
1379        assert_eq!(result.result, "ok");
1380
1381        let ev = rx.recv().await.expect("expected streamed event");
1382        assert!(
1383            matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
1384        );
1385    }
1386
1387    #[tokio::test]
1388    async fn removed_legacy_tools_return_not_found() {
1389        let executor = BuiltinToolExecutor::new();
1390
1391        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1392            let call = make_tool_call(legacy, json!({}));
1393            let result = executor.execute(&call).await;
1394            assert!(matches!(result, Err(ToolError::NotFound(_))));
1395        }
1396    }
1397
1398    #[tokio::test]
1399    async fn executor_prefers_exact_tool_name_before_builtin_alias() {
1400        struct CustomSpawnSessionTool;
1401
1402        #[async_trait]
1403        impl Tool for CustomSpawnSessionTool {
1404            fn name(&self) -> &str {
1405                "spawn_session"
1406            }
1407
1408            fn description(&self) -> &str {
1409                "custom tool for regression coverage"
1410            }
1411
1412            fn parameters_schema(&self) -> serde_json::Value {
1413                json!({"type":"object","properties":{}})
1414            }
1415
1416            async fn invoke(
1417                &self,
1418                _args: serde_json::Value,
1419                _ctx: ToolCtx,
1420            ) -> Result<ToolOutcome, ToolError> {
1421                Ok(ToolOutcome::Completed(ToolResult {
1422                    success: true,
1423                    result: "custom-spawn-session".to_string(),
1424                    display_preference: None,
1425                    images: Vec::new(),
1426                }))
1427            }
1428        }
1429
1430        let executor = BuiltinToolExecutorBuilder::new()
1431            .with_tool(CustomSpawnSessionTool)
1432            .expect("register custom spawn_session tool")
1433            .build();
1434
1435        let call = make_tool_call("spawn_session", json!({}));
1436        let result = executor.execute(&call).await.expect("execute custom tool");
1437        assert!(result.success);
1438        assert_eq!(result.result, "custom-spawn-session");
1439    }
1440
1441    // ---- issue #106: parse tool args once on the execute path -------------
1442
1443    /// A tool that echoes back the `v` field of the args it was invoked with, so
1444    /// a test can observe *which* parsed value reached the tool.
1445    struct EchoArgsTool;
1446
1447    #[async_trait]
1448    impl Tool for EchoArgsTool {
1449        fn name(&self) -> &str {
1450            "echo_args"
1451        }
1452        fn description(&self) -> &str {
1453            "echoes the `v` arg"
1454        }
1455        fn parameters_schema(&self) -> serde_json::Value {
1456            json!({"type":"object","properties":{"v":{"type":"string"}}})
1457        }
1458        async fn invoke(
1459            &self,
1460            args: serde_json::Value,
1461            _ctx: ToolCtx,
1462        ) -> Result<ToolOutcome, ToolError> {
1463            let v = args
1464                .get("v")
1465                .and_then(serde_json::Value::as_str)
1466                .unwrap_or("<none>")
1467                .to_string();
1468            Ok(ToolOutcome::Completed(ToolResult {
1469                success: true,
1470                result: v,
1471                display_preference: None,
1472                images: Vec::new(),
1473            }))
1474        }
1475    }
1476
1477    fn ctx_with_pre_parsed<'a>(
1478        call_id: &'a str,
1479        pre_parsed: Option<&'a serde_json::Value>,
1480    ) -> ToolExecutionContext<'a> {
1481        ToolExecutionContext {
1482            session_id: Some("s-106"),
1483            tool_call_id: call_id,
1484            event_tx: None,
1485            available_tool_schemas: None,
1486            bypass_permissions: false,
1487            can_async_resume: false,
1488            bash_completion_sink: None,
1489            pre_parsed_args: pre_parsed,
1490        }
1491    }
1492
1493    #[tokio::test]
1494    async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
1495        // The raw `arguments` string and the threaded `pre_parsed_args` Value
1496        // deliberately disagree. If the executor honored the contract (parse
1497        // once at the dispatch site, reuse downstream), the tool sees the
1498        // pre-parsed value; if it re-parsed the raw string it would see "raw".
1499        // This is the load-bearing proof that the second parse was eliminated.
1500        let executor = BuiltinToolExecutor::new();
1501        executor.register_tool(EchoArgsTool).expect("register echo");
1502
1503        let call = make_tool_call("echo_args", json!({"v": "raw"}));
1504        let pre_parsed = json!({"v": "preparsed"});
1505        let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
1506
1507        let result = executor
1508            .execute_with_context(&call, ctx)
1509            .await
1510            .expect("execute echo tool");
1511        assert_eq!(
1512            result.result, "preparsed",
1513            "executor must reuse pre_parsed_args, not re-parse the raw string"
1514        );
1515    }
1516
1517    #[tokio::test]
1518    async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
1519        // Without a threaded value (the `execute` entry point / tests / a loop
1520        // that parsed with a different parser), the executor falls back to
1521        // parsing the raw string exactly as before — behavior preserved.
1522        let executor = BuiltinToolExecutor::new();
1523        executor.register_tool(EchoArgsTool).expect("register echo");
1524
1525        let call = make_tool_call("echo_args", json!({"v": "raw"}));
1526        let ctx = ctx_with_pre_parsed(&call.id, None);
1527
1528        let result = executor
1529            .execute_with_context(&call, ctx)
1530            .await
1531            .expect("execute echo tool");
1532        assert_eq!(
1533            result.result, "raw",
1534            "without pre_parsed_args the executor parses the raw string as before"
1535        );
1536    }
1537
1538    #[tokio::test]
1539    async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
1540        // Malformed (truncated) JSON must still be auto-repaired by the
1541        // fallback parse when no pre-parsed value is threaded — the existing
1542        // error/leniency behavior is untouched by the dedup.
1543        let dir = tempfile::tempdir().unwrap();
1544        let path = dir.path().join("recovered-no-preparsed.txt");
1545        let malformed_args = format!(
1546            r#"{{"file_path":"{}","content":"recovered content""#,
1547            path.display()
1548        );
1549
1550        let executor = BuiltinToolExecutor::new();
1551        let call = make_tool_call_with_raw_args("Write", &malformed_args);
1552        let ctx = ctx_with_pre_parsed(&call.id, None);
1553
1554        let result = executor
1555            .execute_with_context(&call, ctx)
1556            .await
1557            .expect("truncated JSON should be auto-repaired");
1558        assert!(result.success);
1559        let written = fs::read_to_string(&path).await.expect("file written");
1560        assert_eq!(written, "recovered content");
1561    }
1562}