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