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        if let Some(permission_checker) = &self.permission_checker {
304            if let Some(contexts) =
305                check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
306            {
307                // "Always ask" rules (configured patterns + built-in dangerous
308                // commands) force a confirmation even under bypass. Everything
309                // else is skipped when this session is in "bypass permissions"
310                // mode (scoped per-session via its runtime state).
311                let force_ask = permission_checker.requires_forced_confirmation(&tool_name, &args);
312                for context in contexts {
313                    if ctx.bypass_permissions && !force_ask {
314                        continue;
315                    }
316                    let resource = context.resource.clone();
317                    // Forced confirmations route through `check_or_request_forced`
318                    // so the active mode/bypass cannot suppress the prompt.
319                    let decision = if force_ask {
320                        permission_checker.check_or_request_forced(context).await
321                    } else {
322                        permission_checker.check_or_request(context).await
323                    };
324                    match decision {
325                        Ok(true) => {}
326                        Ok(false) => {
327                            return Err(ToolError::Execution(format!(
328                                "Permission denied for: {}",
329                                resource
330                            )));
331                        }
332                        Err(PermissionError::ConfirmationRequired {
333                            permission_type,
334                            resource: _,
335                        }) => {
336                            // Phase 2 (cross-process): a subagent worker installs a
337                            // task-local `ApprovalProxy` for the duration of its run.
338                            // When present, forward the decision to the worker's host
339                            // (parent) and block this tool inline for the reply —
340                            // approve proceeds (treated like a granted context), deny
341                            // fails closed. Checked BEFORE the interactive sink below
342                            // so a worker (which also has an `event_tx`) proxies to its
343                            // parent instead of trying to prompt a human itself. The
344                            // proxy is unset on every non-worker path, so the behavior
345                            // there is unchanged.
346                            if let Some(proxy) = crate::approval::current_approval_proxy() {
347                                let approved = proxy
348                                    .request_approval(crate::approval::ApprovalAsk {
349                                        tool_name: tool_name.clone(),
350                                        permission: permission_type.description().to_string(),
351                                        resource: resource.clone(),
352                                    })
353                                    .await;
354                                if approved {
355                                    // Treat as a granted context: check any remaining
356                                    // contexts, then fall through to execution.
357                                    continue;
358                                }
359                                return Err(ToolError::Execution(format!(
360                                    "Permission denied by host for: {}",
361                                    resource
362                                )));
363                            }
364
365                            // Interactive sessions pause for approval by reusing the
366                            // same pending-question pipeline as `request_permissions`:
367                            // synthesize an "awaiting_permission_approval" result that
368                            // the engine recognizes (via display_preference) and turns
369                            // into a NeedClarification pause. On approval the respond
370                            // handler records a session grant so the re-attempt passes.
371                            if let Some(tx) = ctx.event_tx {
372                                // Keep emitting the structured approval event for observers.
373                                let _ = tx
374                                    .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
375                                        tool_call_id: call.id.clone(),
376                                        tool_name: tool_name.clone(),
377                                        parameters: args.clone(),
378                                    })
379                                    .await;
380
381                                let question = format!(
382                                    "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
383                                    tool_name,
384                                    permission_type.description(),
385                                    resource
386                                );
387                                let payload = serde_json::json!({
388                                    "status": "awaiting_permission_approval",
389                                    "question": question,
390                                    "permission_type": permission_type,
391                                    "resource": resource,
392                                    "options": ["Approve", "Deny"],
393                                    "allow_custom": false,
394                                });
395                                // Permission-gate synthesized question stays on
396                                // the Completed→sniff path for now (a later Phase B
397                                // step converts this to ToolOutcome::NeedsHuman).
398                                return Ok(ToolOutcome::Completed(ToolResult {
399                                    success: true,
400                                    result: payload.to_string(),
401                                    display_preference: Some("request_permissions".to_string()),
402                                    images: Vec::new(),
403                                }));
404                            }
405
406                            // Non-interactive (no event sink to surface the prompt):
407                            // fail closed rather than silently proceeding.
408                            return Err(ToolError::Execution(format!(
409                                "Permission approval required for: {}",
410                                resource
411                            )));
412                        }
413                        Err(other) => {
414                            return Err(permission_error_to_tool_error(other));
415                        }
416                    }
417                }
418            }
419        }
420
421        // Rewritten dispatch: build the owned `ToolCtx` at this concrete seam and
422        // call the tool's single `invoke`. Unwrap the `ToolOutcome` back to a
423        // `ToolResult` so the surrounding dispatch/loop is unchanged for now:
424        // `Completed` is the result; `Running`'s synthetic ack IS a `ToolResult`
425        // (preserving background Bash's current behavior); `NeedsHuman` cannot yet
426        // be produced (no tool returns it in this phase). Phase B makes the outcome
427        // authoritative and removes this unwrap.
428        let tool_ctx = ctx.to_tool_ctx();
429        tool.invoke(args, tool_ctx).await
430    }
431
432    fn list_tools(&self) -> Vec<ToolSchema> {
433        self.registry.list_tools()
434    }
435
436    fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
437        self.registry
438            .get(tool_name)
439            .map(|tool| tool.classify(&serde_json::Value::Null).mutability)
440            .unwrap_or_else(|| crate::classify_tool(tool_name))
441    }
442
443    fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
444        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
445        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
446        self.registry
447            .get(&canonical)
448            .map(|tool| tool.classify(&args).mutability)
449            .unwrap_or_else(|| self.tool_mutability(&canonical))
450    }
451
452    fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
453        let canonical = resolve_registered_tool_name(&self.registry, tool_name);
454        self.registry
455            .get(&canonical)
456            .map(|tool| tool.classify(&serde_json::Value::Null).parallel_safe)
457            .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
458    }
459
460    fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
461        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
462        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
463        self.registry
464            .get(&canonical)
465            .map(|tool| tool.classify(&args).parallel_safe)
466            .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
467    }
468
469    fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
470        // One args-aware `classify` returns the (mutability, parallel_safe) pair
471        // with a single arg parse — the collapse of the former
472        // `call_mutability`/`call_concurrency_safe` pair.
473        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
474        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
475        match self.registry.get(&canonical) {
476            Some(tool) => {
477                let class = tool.classify(&args);
478                (class.mutability, class.parallel_safe)
479            }
480            None => (
481                self.tool_mutability(&canonical),
482                self.tool_concurrency_safe(&canonical),
483            ),
484        }
485    }
486}
487
488/// Builder for constructing a BuiltinToolExecutor with custom tool configurations
489pub struct BuiltinToolExecutorBuilder {
490    registry: ToolRegistry,
491    permission_checker: Option<Arc<dyn PermissionChecker>>,
492}
493
494impl BuiltinToolExecutorBuilder {
495    /// Creates a new builder with no tools registered
496    pub fn new() -> Self {
497        Self {
498            registry: ToolRegistry::new(),
499            permission_checker: None,
500        }
501    }
502
503    /// Registers all default built-in tools
504    pub fn with_default_tools(self) -> Self {
505        BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
506        self
507    }
508
509    /// Registers a specific filesystem tool by name
510    pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
511        match name {
512            "Read" => self.registry.register(ReadTool::new()),
513            "Write" => self.registry.register(WriteTool::new()),
514            // apply_patch is now an alias for Edit
515            "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
516            "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
517            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
518        }
519        .map_err(|e| ToolError::Execution(e.to_string()))?;
520        Ok(self)
521    }
522
523    /// Registers a specific command tool by name
524    pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
525        match name {
526            "Bash" => self.registry.register(BashTool::new()),
527            "BashOutput" => self.registry.register(BashOutputTool::new()),
528            "KillShell" => self.registry.register(KillShellTool::new()),
529            "Task" => self.registry.register(TaskTool::new()),
530            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
531        }
532        .map_err(|e| ToolError::Execution(e.to_string()))?;
533        Ok(self)
534    }
535
536    /// Registers a custom tool
537    pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
538        self.registry
539            .register(tool)
540            .map_err(|e| ToolError::Execution(e.to_string()))?;
541        Ok(self)
542    }
543
544    /// Sets a permission checker for this executor
545    pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
546        self.permission_checker = Some(checker);
547        self
548    }
549
550    /// Builds the executor
551    pub fn build(self) -> BuiltinToolExecutor {
552        BuiltinToolExecutor {
553            registry: self.registry,
554            permission_checker: self.permission_checker,
555        }
556    }
557}
558
559impl Default for BuiltinToolExecutorBuilder {
560    fn default() -> Self {
561        Self::new()
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use bamboo_agent_core::AgentEvent;
569    use bamboo_agent_core::FunctionCall;
570    use bamboo_agent_core::ToolCtx;
571    use bamboo_agent_core::ToolExecutionContext;
572    use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
573    use serde_json::json;
574    use std::sync::Arc;
575    use tokio::fs;
576    use tokio::sync::mpsc;
577
578    use crate::tools::WriteTool;
579
580    fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
581        ToolCall {
582            id: "call_1".to_string(),
583            tool_type: "function".to_string(),
584            function: FunctionCall {
585                name: name.to_string(),
586                arguments: args.to_string(),
587            },
588        }
589    }
590
591    fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
592        ToolCall {
593            id: "call_1".to_string(),
594            tool_type: "function".to_string(),
595            function: FunctionCall {
596                name: name.to_string(),
597                arguments: raw_args.to_string(),
598            },
599        }
600    }
601
602    fn make_executor(
603        permission_checker: Option<Arc<dyn PermissionChecker>>,
604    ) -> BuiltinToolExecutor {
605        let builder = BuiltinToolExecutorBuilder::new()
606            .with_tool(WriteTool::new())
607            .expect("register Write tool");
608
609        let builder = match permission_checker {
610            Some(checker) => builder.with_permission_checker(checker),
611            None => builder,
612        };
613
614        builder.build()
615    }
616
617    #[test]
618    fn test_normalize_tool_ref_accepts_claude_style_names() {
619        assert_eq!(
620            normalize_tool_ref("default::Bash"),
621            Some("Bash".to_string())
622        );
623    }
624
625    #[test]
626    fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
627        assert_eq!(
628            normalize_tool_ref("default::fileExists"),
629            Some("FileExists".to_string())
630        );
631        assert_eq!(
632            normalize_tool_ref("default::getCurrentDir"),
633            Some("GetCurrentDir".to_string())
634        );
635        assert_eq!(
636            normalize_tool_ref("default::getFileInfo"),
637            Some("GetFileInfo".to_string())
638        );
639        assert_eq!(
640            normalize_tool_ref("default::setWorkspace"),
641            Some("SetWorkspace".to_string())
642        );
643        assert_eq!(
644            normalize_tool_ref("default::sleep"),
645            Some("Sleep".to_string())
646        );
647    }
648
649    #[test]
650    fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
651        assert_eq!(
652            normalize_tool_ref("default::execute_command"),
653            Some("Bash".to_string())
654        );
655        assert_eq!(
656            normalize_tool_ref("default::file_exists"),
657            Some("FileExists".to_string())
658        );
659        assert_eq!(
660            normalize_tool_ref("default::get_current_dir"),
661            Some("GetCurrentDir".to_string())
662        );
663        assert_eq!(
664            normalize_tool_ref("default::get_file_info"),
665            Some("GetFileInfo".to_string())
666        );
667        assert_eq!(
668            normalize_tool_ref("default::list_directory"),
669            Some("Glob".to_string())
670        );
671        assert_eq!(
672            normalize_tool_ref("default::memory_note"),
673            Some("memory_note".to_string())
674        );
675        assert_eq!(
676            normalize_tool_ref("default::read_file"),
677            Some("Read".to_string())
678        );
679        assert_eq!(
680            normalize_tool_ref("default::set_workspace"),
681            Some("SetWorkspace".to_string())
682        );
683        assert_eq!(
684            normalize_tool_ref("default::write_file"),
685            Some("Write".to_string())
686        );
687    }
688
689    #[test]
690    fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
691        for alias in [
692            "default::spawn_session",
693            "default::sub_session",
694            "default::sub_task",
695            "default::team_agent",
696            "default::child_session",
697        ] {
698            assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
699        }
700    }
701
702    #[test]
703    fn test_normalize_tool_ref_accepts_server_overlay_tools() {
704        assert_eq!(normalize_tool_ref("compress_context"), None);
705        assert_eq!(
706            normalize_tool_ref("default::read_skill_resource"),
707            Some("read_skill_resource".to_string())
708        );
709    }
710
711    #[tokio::test]
712    async fn test_executor_accepts_legacy_read_file_path_argument() {
713        let dir = tempfile::tempdir().unwrap();
714        let file_path = dir.path().join("legacy-read.txt");
715        fs::write(&file_path, "legacy read content").await.unwrap();
716
717        let executor = BuiltinToolExecutor::new();
718        let call = make_tool_call("read_file", json!({"path": file_path}));
719
720        let result = executor.execute(&call).await.unwrap();
721        assert!(result.success);
722        assert!(result.result.contains("legacy read content"));
723    }
724
725    #[tokio::test]
726    async fn test_executor_accepts_legacy_list_directory_without_pattern() {
727        let dir = tempfile::tempdir().unwrap();
728        let file_path = dir.path().join("legacy-list.txt");
729        fs::write(&file_path, "legacy list content").await.unwrap();
730
731        let executor = BuiltinToolExecutor::new();
732        let call = make_tool_call("list_directory", json!({"path": dir.path()}));
733
734        let result = executor.execute(&call).await.unwrap();
735        assert!(result.success);
736        assert!(result.result.contains("legacy-list.txt"));
737    }
738
739    #[tokio::test]
740    async fn test_executor_accepts_canonical_read_with_path_argument() {
741        let dir = tempfile::tempdir().unwrap();
742        let file_path = dir.path().join("canonical-read.txt");
743        fs::write(&file_path, "canonical read content")
744            .await
745            .unwrap();
746
747        let executor = BuiltinToolExecutor::new();
748        let call = make_tool_call("Read", json!({"path": file_path}));
749
750        let result = executor.execute(&call).await.unwrap();
751        assert!(result.success);
752        assert!(result.result.contains("canonical read content"));
753    }
754
755    #[tokio::test]
756    async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
757        let dir = tempfile::tempdir().unwrap();
758        let file_path = dir.path().join("canonical-list.txt");
759        fs::write(&file_path, "canonical list content")
760            .await
761            .unwrap();
762
763        let executor = BuiltinToolExecutor::new();
764        let call = make_tool_call("Glob", json!({"path": dir.path()}));
765
766        let result = executor.execute(&call).await.unwrap();
767        assert!(result.success);
768        assert!(result.result.contains("canonical-list.txt"));
769    }
770
771    #[test]
772    fn test_executor_workspace_mutability_depends_on_path_argument() {
773        let executor = BuiltinToolExecutor::new();
774        let get_call = make_tool_call("Workspace", json!({}));
775        let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
776
777        assert_eq!(
778            executor.call_mutability(&get_call),
779            crate::ToolMutability::ReadOnly
780        );
781        assert!(executor.call_concurrency_safe(&get_call));
782
783        assert_eq!(
784            executor.call_mutability(&set_call),
785            crate::ToolMutability::Mutating
786        );
787        assert!(!executor.call_concurrency_safe(&set_call));
788    }
789
790    #[test]
791    fn call_parallel_classification_matches_individual_methods() {
792        // Regression guard for the issue #17 perf refactor: the combined
793        // `call_parallel_classification` (which parses args once) must return the
794        // exact same (mutability, concurrency_safe) pair as calling
795        // `call_mutability` and `call_concurrency_safe` separately (which each
796        // parse args). Covers a read-only tool, mutating tools, and an
797        // args-aware tool (Workspace get vs set) so every branch of the
798        // single-parse override is exercised.
799        let executor = BuiltinToolExecutor::new();
800        let cases: &[(&str, serde_json::Value)] = &[
801            ("Read", json!({})),
802            ("Grep", json!({"pattern": "x"})),
803            (
804                "Write",
805                json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
806            ),
807            ("Bash", json!({"command": "echo hi"})),
808            ("Workspace", json!({})),
809            ("Workspace", json!({"path": "/tmp"})),
810        ];
811
812        for (name, args) in cases {
813            let call = make_tool_call(name, args.clone());
814            let expected_mutability = executor.call_mutability(&call);
815            let expected_concurrency = executor.call_concurrency_safe(&call);
816            let (mutability, concurrency) = executor.call_parallel_classification(&call);
817            assert_eq!(
818                mutability, expected_mutability,
819                "mutability mismatch for {name} ({args})"
820            );
821            assert_eq!(
822                concurrency, expected_concurrency,
823                "concurrency mismatch for {name} ({args})"
824            );
825        }
826    }
827
828    #[test]
829    fn list_tools_snapshot_is_stable_across_calls() {
830        // The per-round schema cache (issue #17 Part A) assumes the executor's
831        // `list_tools()` is stable within a round: a snapshot taken once must
832        // equal a fresh call. Guards that invariant so caching the set for the
833        // duration of a round can't serve a stale or filtered view.
834        let executor = BuiltinToolExecutor::new();
835        let first: Vec<String> = executor
836            .list_tools()
837            .into_iter()
838            .map(|s| s.function.name)
839            .collect();
840        let second: Vec<String> = executor
841            .list_tools()
842            .into_iter()
843            .map(|s| s.function.name)
844            .collect();
845        assert!(!first.is_empty(), "builtin executor should expose tools");
846        assert_eq!(
847            first, second,
848            "list_tools() must be deterministic per round"
849        );
850    }
851
852    #[tokio::test]
853    async fn test_executor_recovers_truncated_json_arguments() {
854        let dir = tempfile::tempdir().unwrap();
855        let path = dir.path().join("recovered-write.txt");
856
857        // Missing closing brace simulates EOF while parsing an object.
858        let malformed_args = format!(
859            r#"{{"file_path":"{}","content":"recovered content""#,
860            path.display()
861        );
862
863        let executor = BuiltinToolExecutor::new();
864        let call = make_tool_call_with_raw_args("Write", &malformed_args);
865
866        let result = executor
867            .execute(&call)
868            .await
869            .expect("truncated JSON should be auto-repaired");
870        assert!(result.success);
871
872        let written = fs::read_to_string(&path)
873            .await
874            .expect("file should be written");
875        assert_eq!(written, "recovered content");
876    }
877
878    #[test]
879    fn test_normalize_tool_ref_rejects_unknown_tool() {
880        assert_eq!(normalize_tool_ref("default::search"), None);
881    }
882
883    #[test]
884    fn test_executor_does_not_expose_legacy_tools() {
885        let executor = BuiltinToolExecutor::new();
886        let tool_names: Vec<String> = executor
887            .list_tools()
888            .into_iter()
889            .map(|schema| schema.function.name)
890            .collect();
891
892        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
893            assert!(!tool_names.iter().any(|name| name == legacy));
894        }
895    }
896
897    #[test]
898    fn test_critical_tool_schemas_match_claude_shapes() {
899        let executor = BuiltinToolExecutor::new();
900        let tools = executor.list_tools();
901
902        let get_params = |name: &str| {
903            tools
904                .iter()
905                .find(|tool| tool.function.name == name)
906                .unwrap()
907                .function
908                .parameters
909                .clone()
910        };
911
912        let grep = get_params("Grep");
913        assert_eq!(grep["required"], json!(["pattern"]));
914        assert_eq!(
915            grep["properties"]["output_mode"]["enum"],
916            json!(["content", "files_with_matches", "count"])
917        );
918        assert!(grep["properties"]["-A"].is_object());
919        assert!(grep["properties"]["-B"].is_object());
920        assert!(grep["properties"]["-C"].is_object());
921        assert!(grep["properties"]["-n"].is_object());
922        assert!(grep["properties"]["-i"].is_object());
923
924        let edit = get_params("Edit");
925        assert_eq!(edit["required"], json!(["file_path"]));
926        assert_eq!(edit["properties"]["old_string"]["type"], "string");
927        assert_eq!(edit["properties"]["new_string"]["type"], "string");
928        assert_eq!(edit["properties"]["patch"]["type"], "string");
929        assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
930        assert!(edit.get("oneOf").is_none());
931
932        // apply_patch is now an alias for Edit – its schema is the Edit
933        // schema, so we just verify that Edit includes the patch property.
934        assert_eq!(edit["properties"]["patch"]["type"], "string");
935        assert_eq!(edit["properties"]["line_number"]["type"], "integer");
936
937        let bash = get_params("Bash");
938        assert_eq!(bash["required"], json!(["command"]));
939        assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
940        assert_eq!(bash["properties"]["workdir"]["type"], "string");
941
942        let bash_output = get_params("BashOutput");
943        assert_eq!(bash_output["required"], json!(["bash_id"]));
944        assert_eq!(bash_output["properties"]["filter"]["type"], "string");
945    }
946
947    #[test]
948    fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
949        let executor = BuiltinToolExecutor::new();
950        let tools = executor.list_tools();
951        let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
952
953        for tool in tools {
954            let params = &tool.function.parameters;
955            assert_eq!(
956                params["type"], "object",
957                "tool '{}' parameters must be a top-level object schema",
958                tool.function.name
959            );
960            for key in forbidden {
961                assert!(
962                    params.get(key).is_none(),
963                    "tool '{}' parameters contains forbidden top-level keyword '{}'",
964                    tool.function.name,
965                    key
966                );
967            }
968        }
969    }
970
971    #[test]
972    fn test_executor_has_all_builtin_tools() {
973        let executor = BuiltinToolExecutor::new();
974        let tools = executor.list_tools();
975
976        assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
977
978        let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
979        for tool_name in BUILTIN_TOOL_NAMES {
980            assert!(tool_names.contains(&tool_name.to_string()));
981        }
982    }
983
984    #[test]
985    fn test_executor_builds_enhanced_prompt() {
986        let executor = BuiltinToolExecutor::new();
987        let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
988        assert!(prompt.contains("## Tool Usage Guidelines"));
989        assert!(prompt.contains("**Read**"));
990    }
991
992    #[test]
993    fn test_executor_builder_empty() {
994        let executor = BuiltinToolExecutorBuilder::new().build();
995        assert!(executor.list_tools().is_empty());
996    }
997
998    #[test]
999    fn test_executor_builder_with_default_tools() {
1000        let executor = BuiltinToolExecutorBuilder::new()
1001            .with_default_tools()
1002            .build();
1003        assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
1004    }
1005
1006    #[test]
1007    fn test_executor_builder_with_specific_tool() {
1008        let executor = BuiltinToolExecutorBuilder::new()
1009            .with_filesystem_tool("Read")
1010            .unwrap()
1011            .build();
1012
1013        let tools = executor.list_tools();
1014        assert_eq!(tools.len(), 1);
1015        assert_eq!(tools[0].function.name, "Read");
1016    }
1017
1018    #[tokio::test]
1019    async fn test_executor_skips_permission_checks_without_checker() {
1020        let executor = make_executor(None);
1021        let path = "/tmp/executor_permission_none.txt";
1022        let _ = fs::remove_file(path).await;
1023
1024        let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
1025        let result = executor.execute(&call).await.expect("execute tool");
1026
1027        assert!(result.success);
1028        let _ = fs::remove_file(path).await;
1029    }
1030
1031    #[tokio::test]
1032    async fn test_executor_with_permission_checker_enforces_checks() {
1033        let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1034        let executor = make_executor(Some(checker));
1035        let path = "/tmp/executor_permission_denied.txt";
1036        let _ = fs::remove_file(path).await;
1037
1038        let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
1039        let result = executor.execute(&call).await;
1040
1041        assert!(matches!(result, Err(ToolError::Execution(_))));
1042        assert!(fs::metadata(path).await.is_err());
1043    }
1044
1045    #[tokio::test]
1046    async fn test_bypass_permissions_skips_checker() {
1047        // Even with a deny-all checker, a context flagged `bypass_permissions`
1048        // must skip the permission check entirely and let the write through.
1049        let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1050        let executor = make_executor(Some(checker));
1051        let dir = tempfile::tempdir().unwrap();
1052        let path = dir.path().join("bypass_allows_write.txt");
1053        let path_str = path.to_str().unwrap();
1054
1055        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1056        let ctx = ToolExecutionContext {
1057            session_id: Some("s-bypass"),
1058            tool_call_id: &call.id,
1059            event_tx: None,
1060            available_tool_schemas: None,
1061            bypass_permissions: true,
1062            can_async_resume: false,
1063            bash_completion_sink: None,
1064            pre_parsed_args: None,
1065        };
1066        let result = executor.execute_with_context(&call, ctx).await;
1067
1068        assert!(result.is_ok(), "bypass should allow the write: {result:?}");
1069        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1070    }
1071
1072    #[tokio::test]
1073    async fn test_forced_ask_rule_overrides_bypass() {
1074        // A configured "always ask" rule must force a confirmation even when the
1075        // session is in bypass mode. With no event sink the executor fails closed
1076        // (approval required) rather than silently writing.
1077        let config = Arc::new(crate::permission::PermissionConfig::new());
1078        config.set_ask_rules(["Write(/etc/**)".to_string()]);
1079        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1080        let executor = make_executor(Some(checker));
1081
1082        let call = make_tool_call(
1083            "Write",
1084            json!({"file_path": "/etc/forced.conf", "content": "x"}),
1085        );
1086        let ctx = ToolExecutionContext {
1087            session_id: Some("s-forced"),
1088            tool_call_id: &call.id,
1089            event_tx: None,
1090            available_tool_schemas: None,
1091            bypass_permissions: true,
1092            can_async_resume: false,
1093            bash_completion_sink: None,
1094            pre_parsed_args: None,
1095        };
1096        let result = executor.execute_with_context(&call, ctx).await;
1097
1098        assert!(
1099            matches!(result, Err(ToolError::Execution(ref m)) if m.contains("approval required")),
1100            "forced ask rule should block under bypass: {result:?}"
1101        );
1102        assert!(fs::metadata("/etc/forced.conf").await.is_err());
1103    }
1104
1105    // ---- Phase 2: cross-process approval proxy ----------------------------
1106
1107    struct HostStub {
1108        approve: bool,
1109    }
1110
1111    #[async_trait]
1112    impl crate::approval::ApprovalProxy for HostStub {
1113        async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
1114            self.approve
1115        }
1116    }
1117
1118    #[tokio::test]
1119    async fn approval_proxy_grant_lets_gated_tool_proceed() {
1120        // A subagent worker installs an ApprovalProxy for its run. A forced-ask
1121        // rule with NO event sink would otherwise fail closed; with the host
1122        // proxy granting, the executor treats the context as approved and the
1123        // tool proceeds inline (no suspend, no synthetic pause).
1124        let dir = tempfile::tempdir().unwrap();
1125        let path = dir.path().join("approved.txt");
1126        let path_str = path.to_str().unwrap().to_string();
1127        let config = Arc::new(crate::permission::PermissionConfig::new());
1128        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1129        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1130        let executor = make_executor(Some(checker));
1131
1132        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1133        let ctx = ToolExecutionContext {
1134            session_id: Some("s-worker"),
1135            tool_call_id: &call.id,
1136            event_tx: None,
1137            available_tool_schemas: None,
1138            bypass_permissions: false,
1139            can_async_resume: false,
1140            bash_completion_sink: None,
1141            pre_parsed_args: None,
1142        };
1143
1144        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
1145        let result = crate::approval::with_approval_proxy(
1146            Some(proxy),
1147            executor.execute_with_context(&call, ctx),
1148        )
1149        .await;
1150
1151        assert!(
1152            result.is_ok(),
1153            "host grant should let the write through: {result:?}"
1154        );
1155        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1156    }
1157
1158    #[tokio::test]
1159    async fn approval_proxy_deny_fails_gated_tool_closed() {
1160        // With the host proxy denying, the gated tool fails closed and the side
1161        // effect never happens.
1162        let dir = tempfile::tempdir().unwrap();
1163        let path = dir.path().join("denied.txt");
1164        let path_str = path.to_str().unwrap().to_string();
1165        let config = Arc::new(crate::permission::PermissionConfig::new());
1166        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1167        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1168        let executor = make_executor(Some(checker));
1169
1170        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
1171        let ctx = ToolExecutionContext {
1172            session_id: Some("s-worker"),
1173            tool_call_id: &call.id,
1174            event_tx: None,
1175            available_tool_schemas: None,
1176            bypass_permissions: false,
1177            can_async_resume: false,
1178            bash_completion_sink: None,
1179            pre_parsed_args: None,
1180        };
1181
1182        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
1183        let result = crate::approval::with_approval_proxy(
1184            Some(proxy),
1185            executor.execute_with_context(&call, ctx),
1186        )
1187        .await;
1188
1189        assert!(
1190            matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
1191            "host deny should fail the tool closed: {result:?}"
1192        );
1193        assert!(fs::metadata(&path).await.is_err());
1194    }
1195
1196    #[tokio::test]
1197    async fn tool_can_stream_events_via_execute_with_context() {
1198        struct StreamingTool;
1199
1200        #[async_trait]
1201        impl Tool for StreamingTool {
1202            fn name(&self) -> &str {
1203                "streaming_tool"
1204            }
1205
1206            fn description(&self) -> &str {
1207                "streams one token"
1208            }
1209
1210            fn parameters_schema(&self) -> serde_json::Value {
1211                json!({"type":"object","properties":{}})
1212            }
1213
1214            async fn invoke(
1215                &self,
1216                _args: serde_json::Value,
1217                ctx: ToolCtx,
1218            ) -> Result<ToolOutcome, ToolError> {
1219                ctx.emit(AgentEvent::Token {
1220                    content: "stream".to_string(),
1221                })
1222                .await;
1223                Ok(ToolOutcome::Completed(ToolResult {
1224                    success: true,
1225                    result: "ok".to_string(),
1226                    display_preference: None,
1227                    images: Vec::new(),
1228                }))
1229            }
1230        }
1231
1232        let executor = BuiltinToolExecutor::new();
1233        executor
1234            .register_tool(StreamingTool)
1235            .expect("register streaming tool");
1236
1237        let (tx, mut rx) = mpsc::channel(8);
1238        let call = make_tool_call("streaming_tool", json!({}));
1239
1240        let result = executor
1241            .execute_with_context(
1242                &call,
1243                ToolExecutionContext {
1244                    session_id: Some("s1"),
1245                    tool_call_id: &call.id,
1246                    event_tx: Some(&tx),
1247                    available_tool_schemas: None,
1248                    bypass_permissions: false,
1249                    can_async_resume: false,
1250                    bash_completion_sink: None,
1251                    pre_parsed_args: None,
1252                },
1253            )
1254            .await
1255            .expect("execute tool");
1256
1257        assert!(result.success);
1258        assert_eq!(result.result, "ok");
1259
1260        let ev = rx.recv().await.expect("expected streamed event");
1261        assert!(
1262            matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
1263        );
1264    }
1265
1266    #[tokio::test]
1267    async fn removed_legacy_tools_return_not_found() {
1268        let executor = BuiltinToolExecutor::new();
1269
1270        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1271            let call = make_tool_call(legacy, json!({}));
1272            let result = executor.execute(&call).await;
1273            assert!(matches!(result, Err(ToolError::NotFound(_))));
1274        }
1275    }
1276
1277    #[tokio::test]
1278    async fn executor_prefers_exact_tool_name_before_builtin_alias() {
1279        struct CustomSpawnSessionTool;
1280
1281        #[async_trait]
1282        impl Tool for CustomSpawnSessionTool {
1283            fn name(&self) -> &str {
1284                "spawn_session"
1285            }
1286
1287            fn description(&self) -> &str {
1288                "custom tool for regression coverage"
1289            }
1290
1291            fn parameters_schema(&self) -> serde_json::Value {
1292                json!({"type":"object","properties":{}})
1293            }
1294
1295            async fn invoke(
1296                &self,
1297                _args: serde_json::Value,
1298                _ctx: ToolCtx,
1299            ) -> Result<ToolOutcome, ToolError> {
1300                Ok(ToolOutcome::Completed(ToolResult {
1301                    success: true,
1302                    result: "custom-spawn-session".to_string(),
1303                    display_preference: None,
1304                    images: Vec::new(),
1305                }))
1306            }
1307        }
1308
1309        let executor = BuiltinToolExecutorBuilder::new()
1310            .with_tool(CustomSpawnSessionTool)
1311            .expect("register custom spawn_session tool")
1312            .build();
1313
1314        let call = make_tool_call("spawn_session", json!({}));
1315        let result = executor.execute(&call).await.expect("execute custom tool");
1316        assert!(result.success);
1317        assert_eq!(result.result, "custom-spawn-session");
1318    }
1319
1320    // ---- issue #106: parse tool args once on the execute path -------------
1321
1322    /// A tool that echoes back the `v` field of the args it was invoked with, so
1323    /// a test can observe *which* parsed value reached the tool.
1324    struct EchoArgsTool;
1325
1326    #[async_trait]
1327    impl Tool for EchoArgsTool {
1328        fn name(&self) -> &str {
1329            "echo_args"
1330        }
1331        fn description(&self) -> &str {
1332            "echoes the `v` arg"
1333        }
1334        fn parameters_schema(&self) -> serde_json::Value {
1335            json!({"type":"object","properties":{"v":{"type":"string"}}})
1336        }
1337        async fn invoke(
1338            &self,
1339            args: serde_json::Value,
1340            _ctx: ToolCtx,
1341        ) -> Result<ToolOutcome, ToolError> {
1342            let v = args
1343                .get("v")
1344                .and_then(serde_json::Value::as_str)
1345                .unwrap_or("<none>")
1346                .to_string();
1347            Ok(ToolOutcome::Completed(ToolResult {
1348                success: true,
1349                result: v,
1350                display_preference: None,
1351                images: Vec::new(),
1352            }))
1353        }
1354    }
1355
1356    fn ctx_with_pre_parsed<'a>(
1357        call_id: &'a str,
1358        pre_parsed: Option<&'a serde_json::Value>,
1359    ) -> ToolExecutionContext<'a> {
1360        ToolExecutionContext {
1361            session_id: Some("s-106"),
1362            tool_call_id: call_id,
1363            event_tx: None,
1364            available_tool_schemas: None,
1365            bypass_permissions: false,
1366            can_async_resume: false,
1367            bash_completion_sink: None,
1368            pre_parsed_args: pre_parsed,
1369        }
1370    }
1371
1372    #[tokio::test]
1373    async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
1374        // The raw `arguments` string and the threaded `pre_parsed_args` Value
1375        // deliberately disagree. If the executor honored the contract (parse
1376        // once at the dispatch site, reuse downstream), the tool sees the
1377        // pre-parsed value; if it re-parsed the raw string it would see "raw".
1378        // This is the load-bearing proof that the second parse was eliminated.
1379        let executor = BuiltinToolExecutor::new();
1380        executor.register_tool(EchoArgsTool).expect("register echo");
1381
1382        let call = make_tool_call("echo_args", json!({"v": "raw"}));
1383        let pre_parsed = json!({"v": "preparsed"});
1384        let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
1385
1386        let result = executor
1387            .execute_with_context(&call, ctx)
1388            .await
1389            .expect("execute echo tool");
1390        assert_eq!(
1391            result.result, "preparsed",
1392            "executor must reuse pre_parsed_args, not re-parse the raw string"
1393        );
1394    }
1395
1396    #[tokio::test]
1397    async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
1398        // Without a threaded value (the `execute` entry point / tests / a loop
1399        // that parsed with a different parser), the executor falls back to
1400        // parsing the raw string exactly as before — behavior preserved.
1401        let executor = BuiltinToolExecutor::new();
1402        executor.register_tool(EchoArgsTool).expect("register echo");
1403
1404        let call = make_tool_call("echo_args", json!({"v": "raw"}));
1405        let ctx = ctx_with_pre_parsed(&call.id, None);
1406
1407        let result = executor
1408            .execute_with_context(&call, ctx)
1409            .await
1410            .expect("execute echo tool");
1411        assert_eq!(
1412            result.result, "raw",
1413            "without pre_parsed_args the executor parses the raw string as before"
1414        );
1415    }
1416
1417    #[tokio::test]
1418    async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
1419        // Malformed (truncated) JSON must still be auto-repaired by the
1420        // fallback parse when no pre-parsed value is threaded — the existing
1421        // error/leniency behavior is untouched by the dedup.
1422        let dir = tempfile::tempdir().unwrap();
1423        let path = dir.path().join("recovered-no-preparsed.txt");
1424        let malformed_args = format!(
1425            r#"{{"file_path":"{}","content":"recovered content""#,
1426            path.display()
1427        );
1428
1429        let executor = BuiltinToolExecutor::new();
1430        let call = make_tool_call_with_raw_args("Write", &malformed_args);
1431        let ctx = ctx_with_pre_parsed(&call.id, None);
1432
1433        let result = executor
1434            .execute_with_context(&call, ctx)
1435            .await
1436            .expect("truncated JSON should be auto-repaired");
1437        assert!(result.success);
1438        let written = fs::read_to_string(&path).await.expect("file written");
1439        assert_eq!(written, "recovered content");
1440    }
1441}