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    /// Creates a new executor from an existing registry and permission checker.
156    ///
157    /// This is the dependency-injection counterpart to
158    /// [`new_with_permissions`](Self::new_with_permissions): callers that
159    /// intentionally expose a selected/custom registry can keep the canonical
160    /// permission gate instead of silently dropping it.
161    pub fn with_registry_and_permissions(
162        registry: ToolRegistry,
163        permission_checker: Arc<dyn PermissionChecker>,
164    ) -> Self {
165        Self {
166            registry,
167            permission_checker: Some(permission_checker),
168        }
169    }
170
171    /// Returns a reference to the internal registry
172    pub fn registry(&self) -> &ToolRegistry {
173        &self.registry
174    }
175
176    /// Registers all built-in tools to the given registry
177    fn register_builtin_tools(registry: &ToolRegistry, config: Option<Arc<RwLock<Config>>>) {
178        let _ = config;
179        // NOTE: apply_patch is now an alias for Edit – no separate registration.
180        let _ = registry.register(ConclusionWithOptionsTool::new());
181        let _ = registry.register(BashTool::new());
182        let _ = registry.register(BashInputTool::new());
183        let _ = registry.register(BashOutputTool::new());
184        let _ = registry.register(EditTool::new());
185        let _ = registry.register(EnterPlanModeTool::new());
186        let _ = registry.register(ExitPlanModeTool::new());
187        // NOTE: FileExists is now an alias for GetFileInfo – no separate registration.
188        let _ = registry.register(GetFileInfoTool::new());
189        let _ = registry.register(GlobTool::new());
190        let _ = registry.register(GrepTool::new());
191        let _ = registry.register(UpdateGoalTool::new());
192        let _ = registry.register(JsReplTool::new());
193        let _ = registry.register(KillShellTool::new());
194        let _ = registry.register(SessionNoteTool::new());
195        let _ = registry.register(NotebookEditTool::new());
196        let _ = registry.register(ReadTool::new());
197        let _ = registry.register(RequestPermissionsTool::new());
198        let _ = registry.register(SleepTool::new());
199        let _ = registry.register(TaskTool::new());
200        let _ = registry.register(WebFetchTool::new());
201        let _ = registry.register(WebSearchTool::new());
202        // NOTE: GetCurrentDir + SetWorkspace are now aliases for Workspace.
203        let _ = registry.register(WorkspaceTool::new());
204        let _ = registry.register(WriteTool::new());
205    }
206
207    /// Returns all built-in tool schemas
208    pub fn tool_schemas() -> Vec<ToolSchema> {
209        let registry = ToolRegistry::new();
210        Self::register_builtin_tools(&registry, None);
211        registry.list_tools()
212    }
213
214    /// Registers a custom tool to this executor
215    pub fn register_tool<T: Tool + 'static>(&self, tool: T) -> Result<(), ToolError> {
216        self.registry
217            .register(tool)
218            .map_err(|e| ToolError::Execution(e.to_string()))
219    }
220
221    /// Register a tool with its guide
222    pub fn register_tool_with_guide<T, G>(&self, tool: T, guide: G) -> Result<(), ToolError>
223    where
224        T: Tool + 'static,
225        G: ToolGuide + 'static,
226    {
227        self.registry
228            .register_with_guide(tool, guide)
229            .map_err(|e| ToolError::Execution(e.to_string()))
230    }
231
232    /// Get guide for a tool
233    pub fn get_guide(&self, tool_name: &str) -> Option<Arc<dyn ToolGuide>> {
234        self.registry.get_guide(tool_name)
235    }
236
237    /// Build enhanced prompt for all registered tools
238    pub fn build_enhanced_prompt(&self, context: GuideBuildContext) -> String {
239        EnhancedPromptBuilder::build(Some(&self.registry), &self.registry.list_tools(), &context)
240    }
241}
242
243fn permission_error_to_tool_error(error: PermissionError) -> ToolError {
244    match error {
245        PermissionError::CheckFailed(_) => ToolError::InvalidArguments(error.to_string()),
246        _ => ToolError::Execution(error.to_string()),
247    }
248}
249
250impl Default for BuiltinToolExecutor {
251    fn default() -> Self {
252        Self::new()
253    }
254}
255
256#[async_trait]
257impl ToolExecutor for BuiltinToolExecutor {
258    async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
259        self.execute_with_context(call, ToolExecutionContext::none(&call.id))
260            .await
261    }
262
263    async fn execute_with_context(
264        &self,
265        call: &ToolCall,
266        ctx: ToolExecutionContext<'_>,
267    ) -> Result<ToolResult, ToolError> {
268        self.execute_with_context_outcome(call, ctx)
269            .await
270            .map(ToolOutcome::into_tool_result)
271    }
272
273    async fn execute_with_context_outcome(
274        &self,
275        call: &ToolCall,
276        ctx: ToolExecutionContext<'_>,
277    ) -> Result<ToolOutcome, ToolError> {
278        // Reuse the args the dispatching agent loop already parsed (for the
279        // `ToolStart` event) when it threaded them through the context, instead
280        // of re-parsing the raw JSON string here (issue #106, deferred B1 from
281        // #17). The pre-parsed value is the exact output of
282        // `parse_tool_args_best_effort` on the same input, and that loop already
283        // logged any fallback warning at parse time, so skipping the re-parse is
284        // behavior-preserving. When absent (the `execute` entry point, tests, or
285        // a loop that parsed with a different/stricter parser), fall back to
286        // parsing here exactly as before — including the fallback-warning log.
287        let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
288            pre_parsed.clone()
289        } else {
290            let args_raw = call.function.arguments.trim();
291            let (parsed, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
292            if let Some(warning) = parse_warning {
293                tracing::warn!(
294                    "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
295                    ctx.session_id,
296                    call.id,
297                    call.function.name,
298                    args_raw.len(),
299                    preview_for_log(args_raw, 180),
300                    warning
301                );
302            }
303            parsed
304        };
305
306        let raw_tool_name = normalize_tool_name(&call.function.name);
307        if let Some(args_obj) = args.as_object_mut() {
308            normalize_legacy_builtin_args(raw_tool_name, args_obj);
309        }
310
311        let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
312
313        // Look up the tool in the registry
314        let tool = self
315            .registry
316            .get(&tool_name)
317            .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", tool_name)))?;
318
319        // Permission gate. Factored onto the `ToolExecutor` trait
320        // (`check_permissions_for`) so overlay/wrapping executors can run the
321        // exact same check before invoking their own tools (issue #341). Kept
322        // AFTER the registry lookup so a `NotFound` still takes precedence,
323        // exactly as before. `Some(outcome)` is the interactive approval pause
324        // synthesized for a human sink; `Err` is deny / fail-closed.
325        if let Some(outcome) = self.check_permissions_for(call, &ctx).await? {
326            return Ok(outcome);
327        }
328
329        // Rewritten dispatch: build the owned `ToolCtx` at this concrete seam and
330        // call the tool's single `invoke`. Unwrap the `ToolOutcome` back to a
331        // `ToolResult` so the surrounding dispatch/loop is unchanged for now:
332        // `Completed` is the result; `Running`'s synthetic ack IS a `ToolResult`
333        // (preserving background Bash's current behavior); `NeedsHuman` cannot yet
334        // be produced (no tool returns it in this phase). Phase B makes the outcome
335        // authoritative and removes this unwrap.
336        let tool_ctx = ctx.to_tool_ctx();
337        tool.invoke(args, tool_ctx).await
338    }
339
340    /// The real permission gate for built-in tools, extracted from the execute
341    /// path so it is reusable by wrapping executors (issue #341). The behavior is
342    /// byte-for-byte the same block that used to run inline in
343    /// `execute_with_context_outcome`:
344    ///
345    /// - resolves the SAME `tool_name` + `args` the execute path runs with (so
346    ///   the check sees exactly what the tool will run with);
347    /// - "always ask" rules (`requires_forced_confirmation`) force a confirmation
348    ///   even under bypass; everything else is skipped when the session is in
349    ///   bypass-permissions mode;
350    /// - forced confirmations route through `check_or_request_forced` so the
351    ///   active mode/bypass can't suppress the prompt;
352    /// - a `ConfirmationRequired` first tries the cross-process `ApprovalProxy`
353    ///   (a subagent worker forwarding to its host), then the interactive human
354    ///   sink (returning the synthesized approval pause as `Ok(Some(..))`), then
355    ///   fails closed;
356    /// - deny fails closed.
357    ///
358    /// The only mechanical difference from the old inline block: the interactive
359    /// pause is returned as `Ok(Some(outcome))` and a clean pass returns
360    /// `Ok(None)`, so the caller decides whether to run the tool. The fallback
361    /// arg-parse warning is intentionally NOT re-logged here — the execute path
362    /// already logs it once for this call.
363    async fn check_permissions_for(
364        &self,
365        call: &ToolCall,
366        ctx: &ToolExecutionContext<'_>,
367    ) -> Result<Option<ToolOutcome>, ToolError> {
368        let raw_tool_name = normalize_tool_name(&call.function.name);
369        let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
370        if ctx.auto_approve_permissions && tool_name.eq_ignore_ascii_case("request_permissions") {
371            return Err(ToolError::Execution(
372                "Auto mode cannot request expanded permissions; operate within existing hard boundaries"
373                    .to_string(),
374            ));
375        }
376        if ctx.plan_read_only && !crate::orchestrator::plan_mode_allows_tool(&tool_name) {
377            return Err(ToolError::Execution(format!(
378                "Plan mode: {tool_name} operation blocked"
379            )));
380        }
381        let Some(permission_checker) = &self.permission_checker else {
382            return Ok(None);
383        };
384        let hook_permission_override = crate::current_hook_permission_override(&call.id);
385
386        // Mirror the head of `execute_with_context_outcome`: reuse the pre-parsed
387        // args when threaded, apply the legacy-arg normalization, then resolve the
388        // registered/alias tool name. This is what makes the gate see the exact
389        // `tool_name`/`args` the tool will actually run with.
390        let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
391            pre_parsed.clone()
392        } else {
393            parse_tool_args_best_effort(&call.function.arguments).0
394        };
395        if let Some(args_obj) = args.as_object_mut() {
396            normalize_legacy_builtin_args(raw_tool_name, args_obj);
397        }
398
399        if let Some(contexts) =
400            check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
401        {
402            for context in contexts {
403                let resource = context.resource.clone();
404                let operation_summary = context.operation_description.clone();
405                let risk_level = context.risk_level();
406                let permission_type = context.permission_type;
407                let platform_hard_deny = permission_checker.hard_deny_reason(&context);
408                let config = permission_checker.permission_config();
409                let proxy = crate::approval::current_approval_proxy();
410                let request = if let Some(config) = config.as_ref() {
411                    // A boolean approval relay can only honor one-shot choices.
412                    // Interactive local sessions support all typed scopes; the
413                    // evaluator omits workspace when no stable identity is known.
414                    let supported_decisions = if proxy.is_some() {
415                        crate::permission::PermissionRequest::forced_decisions()
416                    } else {
417                        crate::permission::PermissionRequest::ordinary_decisions(true)
418                    };
419                    let workspace_path = args
420                        .get("workspace_path")
421                        .or_else(|| args.get("cwd"))
422                        .and_then(|value| value.as_str())
423                        .map(ToOwned::to_owned)
424                        .or_else(|| {
425                            ctx.session_id
426                                .and_then(|session_id| config.session_workspace(session_id))
427                        });
428                    match config.evaluate(crate::permission::PermissionEvaluation {
429                        request_id: call.id.clone(),
430                        session_id: ctx.session_id.unwrap_or_default().to_string(),
431                        workspace_path,
432                        tool_name: tool_name.clone(),
433                        tool_args: args.clone(),
434                        permission_type,
435                        resource: resource.clone(),
436                        operation_summary: operation_summary.clone(),
437                        risk_level,
438                        bypass_requested: ctx.bypass_permissions,
439                        auto_approve_requested: ctx.auto_approve_permissions,
440                        platform_hard_deny,
441                        consume_once: true,
442                        supported_decisions,
443                    }) {
444                        crate::permission::PermissionOutcome::Allow { .. } => continue,
445                        crate::permission::PermissionOutcome::Deny { reason, .. } => {
446                            return Err(ToolError::Execution(reason.message));
447                        }
448                        crate::permission::PermissionOutcome::Ask(request)
449                            if matches!(
450                                hook_permission_override,
451                                Some(crate::HookPermissionOverride::Allow)
452                            ) && request.reason_code
453                                != crate::permission::PermissionReasonCode::HardDangerous =>
454                        {
455                            continue;
456                        }
457                        crate::permission::PermissionOutcome::Ask(request) => request,
458                    }
459                } else {
460                    // Compatibility path for custom checkers that do not expose a
461                    // typed config. It remains one-shot only and fail-closed.
462                    if let Some(reason) = platform_hard_deny {
463                        return Err(ToolError::Execution(reason));
464                    }
465                    let force_ask =
466                        permission_checker.requires_forced_confirmation(&tool_name, &args);
467                    let hook_allows = matches!(
468                        hook_permission_override,
469                        Some(crate::HookPermissionOverride::Allow)
470                    );
471                    if ctx.auto_approve_permissions
472                        || ((ctx.bypass_permissions || hook_allows) && !force_ask)
473                    {
474                        continue;
475                    }
476                    let decision = if force_ask {
477                        permission_checker.check_or_request_forced(context).await
478                    } else if let Some(session_id) = ctx.session_id {
479                        permission_checker
480                            .check_or_request_for_session(session_id, context)
481                            .await
482                    } else {
483                        permission_checker.check_or_request(context).await
484                    };
485                    match decision {
486                        Ok(true) => continue,
487                        Ok(false) => {
488                            return Err(ToolError::Execution(format!(
489                                "Permission denied for: {}",
490                                resource
491                            )));
492                        }
493                        Err(PermissionError::ConfirmationRequired { .. }) => {
494                            crate::permission::PermissionRequest {
495                                request_id: call.id.clone(),
496                                session_id: ctx.session_id.unwrap_or_default().to_string(),
497                                workspace_path: None,
498                                tool_name: tool_name.clone(),
499                                permission_type,
500                                resource: resource.clone(),
501                                operation_summary: operation_summary.clone(),
502                                risk_level,
503                                reason_code: if force_ask {
504                                    crate::permission::PermissionReasonCode::ConfiguredAlwaysAsk
505                                } else {
506                                    crate::permission::PermissionReasonCode::RiskThreshold
507                                },
508                                effective_mode: bamboo_config::settings::PermissionMode::Default,
509                                bypass_requested: ctx.bypass_permissions,
510                                auto_approve_requested: ctx.auto_approve_permissions,
511                                policy_revision: 0,
512                                matched_rule: None,
513                                allowed_decisions:
514                                    crate::permission::PermissionRequest::forced_decisions(),
515                                suggested_matchers: crate::permission::conservative_matchers(
516                                    permission_type,
517                                    &resource,
518                                ),
519                            }
520                        }
521                        Err(other) => return Err(permission_error_to_tool_error(other)),
522                    }
523                };
524
525                // A worker/external relay gets the same typed request but only
526                // one-shot decisions are advertised until its protocol supports
527                // a stronger scope. No boolean downgrade can create a grant.
528                if let Some(proxy) = proxy {
529                    let approved = proxy
530                        .request_approval(crate::approval::ApprovalAsk {
531                            tool_name: tool_name.clone(),
532                            permission: permission_type.description().to_string(),
533                            resource: resource.clone(),
534                            permission_request: Some(request.clone()),
535                        })
536                        .await;
537                    if approved {
538                        continue;
539                    }
540                    return Err(ToolError::Execution(format!(
541                        "Permission denied by host for: {}",
542                        resource
543                    )));
544                }
545
546                // Interactive sessions pause through the legacy question shape
547                // while carrying the complete typed request alongside it.
548                if let Some(tx) = ctx.event_tx {
549                    let _ = tx
550                        .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
551                            tool_call_id: call.id.clone(),
552                            tool_name: tool_name.clone(),
553                            parameters: args.clone(),
554                        })
555                        .await;
556
557                    let question = format!(
558                        "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
559                        tool_name,
560                        permission_type.description(),
561                        resource
562                    );
563                    if let Some(config) = config {
564                        config.register_pending_request(request.clone());
565                    }
566                    let payload = serde_json::json!({
567                        "status": "awaiting_permission_approval",
568                        "question": question,
569                        "permission_type": permission_type,
570                        "resource": resource,
571                        "options": ["Approve", "Deny"],
572                        "allow_custom": false,
573                        "permission_request": request,
574                    });
575                    return Ok(Some(ToolOutcome::Completed(ToolResult {
576                        success: true,
577                        result: payload.to_string(),
578                        display_preference: Some("request_permissions".to_string()),
579                        images: Vec::new(),
580                    })));
581                }
582
583                return Err(ToolError::Execution(format!(
584                    "Permission approval required for: {}",
585                    resource
586                )));
587            }
588        }
589
590        Ok(None)
591    }
592
593    fn list_tools(&self) -> Vec<ToolSchema> {
594        self.registry.list_tools()
595    }
596
597    fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
598        self.registry
599            .get(tool_name)
600            .map(|tool| tool.classify(&serde_json::Value::Null).mutability)
601            .unwrap_or_else(|| crate::classify_tool(tool_name))
602    }
603
604    fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
605        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
606        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
607        self.registry
608            .get(&canonical)
609            .map(|tool| tool.classify(&args).mutability)
610            .unwrap_or_else(|| self.tool_mutability(&canonical))
611    }
612
613    fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
614        let canonical = resolve_registered_tool_name(&self.registry, tool_name);
615        self.registry
616            .get(&canonical)
617            .map(|tool| tool.classify(&serde_json::Value::Null).parallel_safe)
618            .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
619    }
620
621    fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
622        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
623        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
624        self.registry
625            .get(&canonical)
626            .map(|tool| tool.classify(&args).parallel_safe)
627            .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
628    }
629
630    fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
631        // One args-aware `classify` returns the (mutability, parallel_safe) pair
632        // with a single arg parse — the collapse of the former
633        // `call_mutability`/`call_concurrency_safe` pair.
634        let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
635        let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
636        match self.registry.get(&canonical) {
637            Some(tool) => {
638                let class = tool.classify(&args);
639                (class.mutability, class.parallel_safe)
640            }
641            None => (
642                self.tool_mutability(&canonical),
643                self.tool_concurrency_safe(&canonical),
644            ),
645        }
646    }
647}
648
649/// Builder for constructing a BuiltinToolExecutor with custom tool configurations
650pub struct BuiltinToolExecutorBuilder {
651    registry: ToolRegistry,
652    permission_checker: Option<Arc<dyn PermissionChecker>>,
653}
654
655impl BuiltinToolExecutorBuilder {
656    /// Creates a new builder with no tools registered
657    pub fn new() -> Self {
658        Self {
659            registry: ToolRegistry::new(),
660            permission_checker: None,
661        }
662    }
663
664    /// Registers all default built-in tools
665    pub fn with_default_tools(self) -> Self {
666        BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
667        self
668    }
669
670    /// Registers a specific filesystem tool by name
671    pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
672        match name {
673            "Read" => self.registry.register(ReadTool::new()),
674            "Write" => self.registry.register(WriteTool::new()),
675            // apply_patch is now an alias for Edit
676            "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
677            "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
678            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
679        }
680        .map_err(|e| ToolError::Execution(e.to_string()))?;
681        Ok(self)
682    }
683
684    /// Registers a specific command tool by name
685    pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
686        match name {
687            "Bash" => self.registry.register(BashTool::new()),
688            "BashOutput" => self.registry.register(BashOutputTool::new()),
689            "KillShell" => self.registry.register(KillShellTool::new()),
690            "Task" => self.registry.register(TaskTool::new()),
691            _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
692        }
693        .map_err(|e| ToolError::Execution(e.to_string()))?;
694        Ok(self)
695    }
696
697    /// Registers a custom tool
698    pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
699        self.registry
700            .register(tool)
701            .map_err(|e| ToolError::Execution(e.to_string()))?;
702        Ok(self)
703    }
704
705    /// Sets a permission checker for this executor
706    pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
707        self.permission_checker = Some(checker);
708        self
709    }
710
711    /// Builds the executor
712    pub fn build(self) -> BuiltinToolExecutor {
713        BuiltinToolExecutor {
714            registry: self.registry,
715            permission_checker: self.permission_checker,
716        }
717    }
718}
719
720impl Default for BuiltinToolExecutorBuilder {
721    fn default() -> Self {
722        Self::new()
723    }
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729    use bamboo_agent_core::AgentEvent;
730    use bamboo_agent_core::FunctionCall;
731    use bamboo_agent_core::ToolCtx;
732    use bamboo_agent_core::ToolExecutionContext;
733    use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
734    use serde_json::json;
735    use std::sync::atomic::{AtomicUsize, Ordering};
736    use std::sync::Arc;
737    use tokio::fs;
738    use tokio::sync::mpsc;
739
740    use crate::tools::WriteTool;
741
742    fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
743        ToolCall {
744            id: "call_1".to_string(),
745            tool_type: "function".to_string(),
746            function: FunctionCall {
747                name: name.to_string(),
748                arguments: args.to_string(),
749            },
750        }
751    }
752
753    fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
754        ToolCall {
755            id: "call_1".to_string(),
756            tool_type: "function".to_string(),
757            function: FunctionCall {
758                name: name.to_string(),
759                arguments: raw_args.to_string(),
760            },
761        }
762    }
763
764    fn make_executor(
765        permission_checker: Option<Arc<dyn PermissionChecker>>,
766    ) -> BuiltinToolExecutor {
767        let builder = BuiltinToolExecutorBuilder::new()
768            .with_tool(WriteTool::new())
769            .expect("register Write tool");
770
771        let builder = match permission_checker {
772            Some(checker) => builder.with_permission_checker(checker),
773            None => builder,
774        };
775
776        builder.build()
777    }
778
779    struct RecordingApprovalProxy {
780        requests: Arc<AtomicUsize>,
781        approve: bool,
782    }
783
784    #[async_trait]
785    impl crate::approval::ApprovalProxy for RecordingApprovalProxy {
786        async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
787            self.requests.fetch_add(1, Ordering::SeqCst);
788            self.approve
789        }
790    }
791
792    #[test]
793    fn test_normalize_tool_ref_accepts_claude_style_names() {
794        assert_eq!(
795            normalize_tool_ref("default::Bash"),
796            Some("Bash".to_string())
797        );
798    }
799
800    #[test]
801    fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
802        assert_eq!(
803            normalize_tool_ref("default::fileExists"),
804            Some("FileExists".to_string())
805        );
806        assert_eq!(
807            normalize_tool_ref("default::getCurrentDir"),
808            Some("GetCurrentDir".to_string())
809        );
810        assert_eq!(
811            normalize_tool_ref("default::getFileInfo"),
812            Some("GetFileInfo".to_string())
813        );
814        assert_eq!(
815            normalize_tool_ref("default::setWorkspace"),
816            Some("SetWorkspace".to_string())
817        );
818        assert_eq!(
819            normalize_tool_ref("default::sleep"),
820            Some("Sleep".to_string())
821        );
822    }
823
824    #[test]
825    fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
826        assert_eq!(
827            normalize_tool_ref("default::execute_command"),
828            Some("Bash".to_string())
829        );
830        assert_eq!(
831            normalize_tool_ref("default::file_exists"),
832            Some("FileExists".to_string())
833        );
834        assert_eq!(
835            normalize_tool_ref("default::get_current_dir"),
836            Some("GetCurrentDir".to_string())
837        );
838        assert_eq!(
839            normalize_tool_ref("default::get_file_info"),
840            Some("GetFileInfo".to_string())
841        );
842        assert_eq!(
843            normalize_tool_ref("default::list_directory"),
844            Some("Glob".to_string())
845        );
846        assert_eq!(
847            normalize_tool_ref("default::memory_note"),
848            Some("memory_note".to_string())
849        );
850        assert_eq!(
851            normalize_tool_ref("default::read_file"),
852            Some("Read".to_string())
853        );
854        assert_eq!(
855            normalize_tool_ref("default::set_workspace"),
856            Some("SetWorkspace".to_string())
857        );
858        assert_eq!(
859            normalize_tool_ref("default::write_file"),
860            Some("Write".to_string())
861        );
862    }
863
864    #[test]
865    fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
866        for alias in [
867            "default::spawn_session",
868            "default::sub_session",
869            "default::sub_task",
870            "default::team_agent",
871            "default::child_session",
872        ] {
873            assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
874        }
875    }
876
877    #[test]
878    fn test_normalize_tool_ref_accepts_server_overlay_tools() {
879        assert_eq!(normalize_tool_ref("compress_context"), None);
880        assert_eq!(
881            normalize_tool_ref("default::read_skill_resource"),
882            Some("read_skill_resource".to_string())
883        );
884    }
885
886    #[tokio::test]
887    async fn test_executor_accepts_legacy_read_file_path_argument() {
888        let dir = tempfile::tempdir().unwrap();
889        let file_path = dir.path().join("legacy-read.txt");
890        fs::write(&file_path, "legacy read content").await.unwrap();
891
892        let executor = BuiltinToolExecutor::new();
893        let call = make_tool_call("read_file", json!({"path": file_path}));
894
895        let result = executor.execute(&call).await.unwrap();
896        assert!(result.success);
897        assert!(result.result.contains("legacy read content"));
898    }
899
900    #[tokio::test]
901    async fn test_executor_accepts_legacy_list_directory_without_pattern() {
902        let dir = tempfile::tempdir().unwrap();
903        let file_path = dir.path().join("legacy-list.txt");
904        fs::write(&file_path, "legacy list content").await.unwrap();
905
906        let executor = BuiltinToolExecutor::new();
907        let call = make_tool_call("list_directory", json!({"path": dir.path()}));
908
909        let result = executor.execute(&call).await.unwrap();
910        assert!(result.success);
911        assert!(result.result.contains("legacy-list.txt"));
912    }
913
914    #[tokio::test]
915    async fn test_executor_accepts_canonical_read_with_path_argument() {
916        let dir = tempfile::tempdir().unwrap();
917        let file_path = dir.path().join("canonical-read.txt");
918        fs::write(&file_path, "canonical read content")
919            .await
920            .unwrap();
921
922        let executor = BuiltinToolExecutor::new();
923        let call = make_tool_call("Read", json!({"path": file_path}));
924
925        let result = executor.execute(&call).await.unwrap();
926        assert!(result.success);
927        assert!(result.result.contains("canonical read content"));
928    }
929
930    #[tokio::test]
931    async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
932        let dir = tempfile::tempdir().unwrap();
933        let file_path = dir.path().join("canonical-list.txt");
934        fs::write(&file_path, "canonical list content")
935            .await
936            .unwrap();
937
938        let executor = BuiltinToolExecutor::new();
939        let call = make_tool_call("Glob", json!({"path": dir.path()}));
940
941        let result = executor.execute(&call).await.unwrap();
942        assert!(result.success);
943        assert!(result.result.contains("canonical-list.txt"));
944    }
945
946    #[test]
947    fn test_executor_workspace_mutability_depends_on_path_argument() {
948        let executor = BuiltinToolExecutor::new();
949        let get_call = make_tool_call("Workspace", json!({}));
950        let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
951
952        assert_eq!(
953            executor.call_mutability(&get_call),
954            crate::ToolMutability::ReadOnly
955        );
956        assert!(executor.call_concurrency_safe(&get_call));
957
958        assert_eq!(
959            executor.call_mutability(&set_call),
960            crate::ToolMutability::Mutating
961        );
962        assert!(!executor.call_concurrency_safe(&set_call));
963    }
964
965    #[test]
966    fn call_parallel_classification_matches_individual_methods() {
967        // Regression guard for the issue #17 perf refactor: the combined
968        // `call_parallel_classification` (which parses args once) must return the
969        // exact same (mutability, concurrency_safe) pair as calling
970        // `call_mutability` and `call_concurrency_safe` separately (which each
971        // parse args). Covers a read-only tool, mutating tools, and an
972        // args-aware tool (Workspace get vs set) so every branch of the
973        // single-parse override is exercised.
974        let executor = BuiltinToolExecutor::new();
975        let cases: &[(&str, serde_json::Value)] = &[
976            ("Read", json!({})),
977            ("Grep", json!({"pattern": "x"})),
978            (
979                "Write",
980                json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
981            ),
982            ("Bash", json!({"command": "echo hi"})),
983            ("Workspace", json!({})),
984            ("Workspace", json!({"path": "/tmp"})),
985        ];
986
987        for (name, args) in cases {
988            let call = make_tool_call(name, args.clone());
989            let expected_mutability = executor.call_mutability(&call);
990            let expected_concurrency = executor.call_concurrency_safe(&call);
991            let (mutability, concurrency) = executor.call_parallel_classification(&call);
992            assert_eq!(
993                mutability, expected_mutability,
994                "mutability mismatch for {name} ({args})"
995            );
996            assert_eq!(
997                concurrency, expected_concurrency,
998                "concurrency mismatch for {name} ({args})"
999            );
1000        }
1001    }
1002
1003    #[test]
1004    fn list_tools_snapshot_is_stable_across_calls() {
1005        // The per-round schema cache (issue #17 Part A) assumes the executor's
1006        // `list_tools()` is stable within a round: a snapshot taken once must
1007        // equal a fresh call. Guards that invariant so caching the set for the
1008        // duration of a round can't serve a stale or filtered view.
1009        let executor = BuiltinToolExecutor::new();
1010        let first: Vec<String> = executor
1011            .list_tools()
1012            .into_iter()
1013            .map(|s| s.function.name)
1014            .collect();
1015        let second: Vec<String> = executor
1016            .list_tools()
1017            .into_iter()
1018            .map(|s| s.function.name)
1019            .collect();
1020        assert!(!first.is_empty(), "builtin executor should expose tools");
1021        assert_eq!(
1022            first, second,
1023            "list_tools() must be deterministic per round"
1024        );
1025    }
1026
1027    #[tokio::test]
1028    async fn test_executor_recovers_truncated_json_arguments() {
1029        let dir = tempfile::tempdir().unwrap();
1030        let path = dir.path().join("recovered-write.txt");
1031
1032        // Missing closing brace simulates EOF while parsing an object.
1033        let malformed_args = format!(
1034            r#"{{"file_path":"{}","content":"recovered content""#,
1035            path.display()
1036        );
1037
1038        let executor = BuiltinToolExecutor::new();
1039        let call = make_tool_call_with_raw_args("Write", &malformed_args);
1040
1041        let result = executor
1042            .execute(&call)
1043            .await
1044            .expect("truncated JSON should be auto-repaired");
1045        assert!(result.success);
1046
1047        let written = fs::read_to_string(&path)
1048            .await
1049            .expect("file should be written");
1050        assert_eq!(written, "recovered content");
1051    }
1052
1053    #[test]
1054    fn test_normalize_tool_ref_rejects_unknown_tool() {
1055        assert_eq!(normalize_tool_ref("default::search"), None);
1056    }
1057
1058    #[test]
1059    fn test_executor_does_not_expose_legacy_tools() {
1060        let executor = BuiltinToolExecutor::new();
1061        let tool_names: Vec<String> = executor
1062            .list_tools()
1063            .into_iter()
1064            .map(|schema| schema.function.name)
1065            .collect();
1066
1067        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1068            assert!(!tool_names.iter().any(|name| name == legacy));
1069        }
1070    }
1071
1072    #[test]
1073    fn test_critical_tool_schemas_match_claude_shapes() {
1074        let executor = BuiltinToolExecutor::new();
1075        let tools = executor.list_tools();
1076
1077        let get_params = |name: &str| {
1078            tools
1079                .iter()
1080                .find(|tool| tool.function.name == name)
1081                .unwrap()
1082                .function
1083                .parameters
1084                .clone()
1085        };
1086
1087        let grep = get_params("Grep");
1088        assert_eq!(grep["required"], json!(["pattern"]));
1089        assert_eq!(
1090            grep["properties"]["output_mode"]["enum"],
1091            json!(["content", "files_with_matches", "count"])
1092        );
1093        assert!(grep["properties"]["-A"].is_object());
1094        assert!(grep["properties"]["-B"].is_object());
1095        assert!(grep["properties"]["-C"].is_object());
1096        assert!(grep["properties"]["-n"].is_object());
1097        assert!(grep["properties"]["-i"].is_object());
1098
1099        let edit = get_params("Edit");
1100        assert_eq!(edit["required"], json!(["file_path"]));
1101        assert_eq!(edit["properties"]["old_string"]["type"], "string");
1102        assert_eq!(edit["properties"]["new_string"]["type"], "string");
1103        assert_eq!(edit["properties"]["patch"]["type"], "string");
1104        assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
1105        assert!(edit.get("oneOf").is_none());
1106
1107        // apply_patch is now an alias for Edit – its schema is the Edit
1108        // schema, so we just verify that Edit includes the patch property.
1109        assert_eq!(edit["properties"]["patch"]["type"], "string");
1110        assert_eq!(edit["properties"]["line_number"]["type"], "integer");
1111
1112        let bash = get_params("Bash");
1113        assert_eq!(bash["required"], json!(["command"]));
1114        assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
1115        assert_eq!(bash["properties"]["workdir"]["type"], "string");
1116
1117        let bash_output = get_params("BashOutput");
1118        assert_eq!(bash_output["required"], json!(["bash_id"]));
1119        assert_eq!(bash_output["properties"]["filter"]["type"], "string");
1120    }
1121
1122    #[test]
1123    fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
1124        let executor = BuiltinToolExecutor::new();
1125        let tools = executor.list_tools();
1126        let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
1127
1128        for tool in tools {
1129            let params = &tool.function.parameters;
1130            assert_eq!(
1131                params["type"], "object",
1132                "tool '{}' parameters must be a top-level object schema",
1133                tool.function.name
1134            );
1135            for key in forbidden {
1136                assert!(
1137                    params.get(key).is_none(),
1138                    "tool '{}' parameters contains forbidden top-level keyword '{}'",
1139                    tool.function.name,
1140                    key
1141                );
1142            }
1143        }
1144    }
1145
1146    #[test]
1147    fn test_executor_has_all_builtin_tools() {
1148        let executor = BuiltinToolExecutor::new();
1149        let tools = executor.list_tools();
1150
1151        assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
1152
1153        let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
1154        for tool_name in BUILTIN_TOOL_NAMES {
1155            assert!(tool_names.contains(&tool_name.to_string()));
1156        }
1157    }
1158
1159    #[test]
1160    fn test_executor_builds_enhanced_prompt() {
1161        let executor = BuiltinToolExecutor::new();
1162        let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
1163        assert!(prompt.contains("## Tool Usage Guidelines"));
1164        assert!(prompt.contains("**Read**"));
1165    }
1166
1167    #[test]
1168    fn test_executor_builder_empty() {
1169        let executor = BuiltinToolExecutorBuilder::new().build();
1170        assert!(executor.list_tools().is_empty());
1171    }
1172
1173    #[test]
1174    fn test_executor_builder_with_default_tools() {
1175        let executor = BuiltinToolExecutorBuilder::new()
1176            .with_default_tools()
1177            .build();
1178        assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
1179    }
1180
1181    #[test]
1182    fn test_executor_builder_with_specific_tool() {
1183        let executor = BuiltinToolExecutorBuilder::new()
1184            .with_filesystem_tool("Read")
1185            .unwrap()
1186            .build();
1187
1188        let tools = executor.list_tools();
1189        assert_eq!(tools.len(), 1);
1190        assert_eq!(tools[0].function.name, "Read");
1191    }
1192
1193    #[tokio::test]
1194    async fn test_executor_skips_permission_checks_without_checker() {
1195        let executor = make_executor(None);
1196        let path = "/tmp/executor_permission_none.txt";
1197        let _ = fs::remove_file(path).await;
1198
1199        let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
1200        let result = executor.execute(&call).await.expect("execute tool");
1201
1202        assert!(result.success);
1203        let _ = fs::remove_file(path).await;
1204    }
1205
1206    #[tokio::test]
1207    async fn test_executor_with_permission_checker_enforces_checks() {
1208        let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1209        let executor = make_executor(Some(checker));
1210        let path = "/tmp/executor_permission_denied.txt";
1211        let _ = fs::remove_file(path).await;
1212
1213        let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
1214        let result = executor.execute(&call).await;
1215
1216        assert!(matches!(result, Err(ToolError::Execution(_))));
1217        assert!(fs::metadata(path).await.is_err());
1218    }
1219
1220    #[tokio::test]
1221    async fn test_bypass_permissions_skips_checker() {
1222        // Model the worker side of a child whose parent bypass flag was inherited:
1223        // a production Bash tool under the production config evaluator must
1224        // execute an ordinary command directly. Even though both a parent
1225        // approval proxy and a human-event sink are installed, neither path may
1226        // be touched.
1227        let config = Arc::new(crate::permission::PermissionConfig::new());
1228        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1229        let executor = BuiltinToolExecutorBuilder::new()
1230            .with_tool(BashTool::new())
1231            .expect("register Bash tool")
1232            .with_permission_checker(checker)
1233            .build();
1234        let dir = tempfile::tempdir().unwrap();
1235        let path = dir.path().join("bypass_allows_bash.txt");
1236        let command = format!("printf ordinary > {}", path.display());
1237        let approval_requests = Arc::new(AtomicUsize::new(0));
1238        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1239            requests: approval_requests.clone(),
1240            approve: true,
1241        });
1242        let (event_tx, mut event_rx) = mpsc::channel(8);
1243
1244        let call = make_tool_call("Bash", json!({"command": command}));
1245        let ctx = ToolExecutionContext {
1246            session_id: Some("s-bypass"),
1247            tool_call_id: &call.id,
1248            event_tx: Some(&event_tx),
1249            available_tool_schemas: None,
1250            bypass_permissions: true,
1251            auto_approve_permissions: false,
1252            plan_read_only: false,
1253            can_async_resume: false,
1254            bash_completion_sink: None,
1255            pre_parsed_args: None,
1256        };
1257        let result = crate::approval::with_approval_proxy(
1258            Some(proxy),
1259            executor.execute_with_context(&call, ctx),
1260        )
1261        .await;
1262
1263        assert!(result.is_ok(), "bypass should allow the write: {result:?}");
1264        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ordinary");
1265        assert_eq!(
1266            approval_requests.load(Ordering::SeqCst),
1267            0,
1268            "ordinary bypassed child command must not invoke the parent reviewer"
1269        );
1270        assert!(
1271            event_rx.try_recv().is_err(),
1272            "ordinary bypassed child command must not emit a human approval event"
1273        );
1274    }
1275
1276    #[tokio::test]
1277    async fn hook_allow_skips_configured_ask_for_exact_call() {
1278        let dir = tempfile::tempdir().unwrap();
1279        let path = dir.path().join("hook-allowed.txt");
1280        let path_str = path.to_str().unwrap().to_string();
1281        let config = Arc::new(crate::permission::PermissionConfig::new());
1282        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1283        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1284        let executor = make_executor(Some(checker));
1285        let call = make_tool_call(
1286            "Write",
1287            json!({"file_path": path_str, "content": "allowed by hook"}),
1288        );
1289        let ctx = ToolExecutionContext {
1290            session_id: Some("s-hook-allow"),
1291            tool_call_id: &call.id,
1292            event_tx: None,
1293            available_tool_schemas: None,
1294            bypass_permissions: false,
1295            auto_approve_permissions: false,
1296            plan_read_only: false,
1297            can_async_resume: false,
1298            bash_completion_sink: None,
1299            pre_parsed_args: None,
1300        };
1301
1302        let result = crate::with_hook_permission_override(
1303            Some(crate::HookPermissionOverride::Allow),
1304            &call.id,
1305            executor.execute_with_context(&call, ctx),
1306        )
1307        .await;
1308
1309        assert!(
1310            result.is_ok(),
1311            "hook allow should skip ordinary ask: {result:?}"
1312        );
1313        assert_eq!(fs::read_to_string(path).await.unwrap(), "allowed by hook");
1314        assert_eq!(
1315            crate::current_hook_permission_override(&call.id),
1316            None,
1317            "the one-call override must not leak"
1318        );
1319    }
1320
1321    #[tokio::test]
1322    async fn hook_allow_cannot_skip_hard_dangerous_parent_review() {
1323        let config = Arc::new(crate::permission::PermissionConfig::new());
1324        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1325        let executor = BuiltinToolExecutorBuilder::new()
1326            .with_tool(BashTool::new())
1327            .expect("register Bash tool")
1328            .with_permission_checker(checker)
1329            .build();
1330        let dir = tempfile::tempdir().unwrap();
1331        let path = dir.path().join("hard-dangerous-must-not-run.txt");
1332        let command = format!("eval 'printf denied > {}'", path.display());
1333        let requests = Arc::new(AtomicUsize::new(0));
1334        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1335            requests: requests.clone(),
1336            approve: false,
1337        });
1338        let call = make_tool_call("Bash", json!({"command": command}));
1339        let ctx = ToolExecutionContext {
1340            session_id: Some("s-hook-hard-dangerous"),
1341            tool_call_id: &call.id,
1342            event_tx: None,
1343            available_tool_schemas: None,
1344            bypass_permissions: true,
1345            auto_approve_permissions: false,
1346            plan_read_only: false,
1347            can_async_resume: false,
1348            bash_completion_sink: None,
1349            pre_parsed_args: None,
1350        };
1351
1352        let result = crate::with_hook_permission_override(
1353            Some(crate::HookPermissionOverride::Allow),
1354            &call.id,
1355            crate::approval::with_approval_proxy(
1356                Some(proxy),
1357                executor.execute_with_context(&call, ctx),
1358            ),
1359        )
1360        .await;
1361
1362        assert!(
1363            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
1364            "hard-dangerous review must remain authoritative: {result:?}"
1365        );
1366        assert_eq!(requests.load(Ordering::SeqCst), 1);
1367        assert!(!path.exists());
1368    }
1369
1370    #[tokio::test]
1371    async fn hook_allow_cannot_skip_explicit_deny() {
1372        let dir = tempfile::tempdir().unwrap();
1373        let path = dir.path().join("explicit-deny.txt");
1374        let path_str = path.to_str().unwrap().to_string();
1375        let config = Arc::new(crate::permission::PermissionConfig::new());
1376        config.deny_scoped_session_permission(
1377            "s-hook-explicit-deny",
1378            crate::permission::PermissionType::WriteFile,
1379            path_str.clone(),
1380        );
1381        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1382        let executor = make_executor(Some(checker));
1383        let call = make_tool_call(
1384            "Write",
1385            json!({"file_path": path_str, "content": "must not be written"}),
1386        );
1387        let ctx = ToolExecutionContext {
1388            session_id: Some("s-hook-explicit-deny"),
1389            tool_call_id: &call.id,
1390            event_tx: None,
1391            available_tool_schemas: None,
1392            bypass_permissions: false,
1393            auto_approve_permissions: false,
1394            plan_read_only: false,
1395            can_async_resume: false,
1396            bash_completion_sink: None,
1397            pre_parsed_args: None,
1398        };
1399
1400        let result = crate::with_hook_permission_override(
1401            Some(crate::HookPermissionOverride::Allow),
1402            &call.id,
1403            executor.execute_with_context(&call, ctx),
1404        )
1405        .await;
1406
1407        assert!(
1408            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("remembered session decision")),
1409            "explicit deny must remain authoritative: {result:?}"
1410        );
1411        assert!(!path.exists());
1412    }
1413
1414    #[tokio::test]
1415    async fn test_forced_ask_rule_overrides_bypass() {
1416        // A hard-dangerous Bash command must still traverse the worker's parent
1417        // approval proxy under bypass. The returned verdict is authoritative:
1418        // deny prevents execution, while approve lets the exact command run.
1419        let config = Arc::new(crate::permission::PermissionConfig::new());
1420        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1421        let executor = BuiltinToolExecutorBuilder::new()
1422            .with_tool(BashTool::new())
1423            .expect("register Bash tool")
1424            .with_permission_checker(checker)
1425            .build();
1426        let dir = tempfile::tempdir().unwrap();
1427        let denied_path = dir.path().join("forced-denied.txt");
1428        let denied_command = format!("eval 'printf denied > {}'", denied_path.display());
1429        let denied_requests = Arc::new(AtomicUsize::new(0));
1430        let deny_proxy: Arc<dyn crate::approval::ApprovalProxy> =
1431            Arc::new(RecordingApprovalProxy {
1432                requests: denied_requests.clone(),
1433                approve: false,
1434            });
1435
1436        let denied_call = make_tool_call("Bash", json!({"command": denied_command}));
1437        let denied_ctx = ToolExecutionContext {
1438            session_id: Some("s-forced"),
1439            tool_call_id: &denied_call.id,
1440            event_tx: None,
1441            available_tool_schemas: None,
1442            bypass_permissions: true,
1443            auto_approve_permissions: false,
1444            plan_read_only: false,
1445            can_async_resume: false,
1446            bash_completion_sink: None,
1447            pre_parsed_args: None,
1448        };
1449        let denied = crate::approval::with_approval_proxy(
1450            Some(deny_proxy),
1451            executor.execute_with_context(&denied_call, denied_ctx),
1452        )
1453        .await;
1454
1455        assert!(
1456            matches!(denied, Err(ToolError::Execution(ref message)) if message.contains("denied by host")),
1457            "parent denial must block forced-ask execution under bypass: {denied:?}"
1458        );
1459        assert_eq!(denied_requests.load(Ordering::SeqCst), 1);
1460        assert!(!denied_path.exists(), "denied command must not execute");
1461
1462        let approved_path = dir.path().join("forced-approved.txt");
1463        let approved_command = format!("eval 'printf approved > {}'", approved_path.display());
1464        let approved_requests = Arc::new(AtomicUsize::new(0));
1465        let approve_proxy: Arc<dyn crate::approval::ApprovalProxy> =
1466            Arc::new(RecordingApprovalProxy {
1467                requests: approved_requests.clone(),
1468                approve: true,
1469            });
1470        let approved_call = make_tool_call("Bash", json!({"command": approved_command}));
1471        let approved_ctx = ToolExecutionContext {
1472            session_id: Some("s-forced"),
1473            tool_call_id: &approved_call.id,
1474            event_tx: None,
1475            available_tool_schemas: None,
1476            bypass_permissions: true,
1477            auto_approve_permissions: false,
1478            plan_read_only: false,
1479            can_async_resume: false,
1480            bash_completion_sink: None,
1481            pre_parsed_args: None,
1482        };
1483        let approved = crate::approval::with_approval_proxy(
1484            Some(approve_proxy),
1485            executor.execute_with_context(&approved_call, approved_ctx),
1486        )
1487        .await;
1488
1489        assert!(
1490            approved.is_ok(),
1491            "parent approval must allow forced-ask execution under bypass: {approved:?}"
1492        );
1493        assert_eq!(approved_requests.load(Ordering::SeqCst), 1);
1494        assert_eq!(fs::read_to_string(approved_path).await.unwrap(), "approved");
1495    }
1496
1497    #[tokio::test]
1498    async fn auto_executes_forced_ask_without_proxy_or_human_event() {
1499        let config = Arc::new(crate::permission::PermissionConfig::new());
1500        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1501        let executor = BuiltinToolExecutorBuilder::new()
1502            .with_tool(BashTool::new())
1503            .expect("register Bash tool")
1504            .with_permission_checker(checker)
1505            .build();
1506        let dir = tempfile::tempdir().unwrap();
1507        let path = dir.path().join("auto-forced.txt");
1508        let command = format!("eval 'printf auto > {}'", path.display());
1509        let approval_requests = Arc::new(AtomicUsize::new(0));
1510        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(RecordingApprovalProxy {
1511            requests: approval_requests.clone(),
1512            approve: false,
1513        });
1514        let (event_tx, mut event_rx) = mpsc::channel(8);
1515        let call = make_tool_call("Bash", json!({"command": command}));
1516        let ctx = ToolExecutionContext {
1517            session_id: Some("s-auto"),
1518            tool_call_id: &call.id,
1519            event_tx: Some(&event_tx),
1520            available_tool_schemas: None,
1521            bypass_permissions: false,
1522            auto_approve_permissions: true,
1523            plan_read_only: false,
1524            can_async_resume: false,
1525            bash_completion_sink: None,
1526            pre_parsed_args: None,
1527        };
1528
1529        let result = crate::approval::with_approval_proxy(
1530            Some(proxy),
1531            executor.execute_with_context(&call, ctx),
1532        )
1533        .await;
1534
1535        assert!(result.is_ok(), "Auto should execute directly: {result:?}");
1536        assert_eq!(fs::read_to_string(path).await.unwrap(), "auto");
1537        assert_eq!(approval_requests.load(Ordering::SeqCst), 0);
1538        assert!(
1539            event_rx.try_recv().is_err(),
1540            "Auto must not emit an interactive approval request"
1541        );
1542    }
1543
1544    #[tokio::test]
1545    async fn auto_never_overrides_guardian_read_only_hard_deny() {
1546        let config = Arc::new(crate::permission::PermissionConfig::new());
1547        let base: Arc<dyn crate::permission::PermissionChecker> = Arc::new(
1548            crate::permission::ConfigPermissionChecker::new(config.clone()),
1549        );
1550        let checker = Arc::new(crate::permission::GuardianReadOnlyChecker::new(base));
1551        let executor = BuiltinToolExecutorBuilder::new()
1552            .with_tool(BashTool::new())
1553            .expect("register Bash tool")
1554            .with_permission_checker(checker)
1555            .build();
1556        let dir = tempfile::tempdir().unwrap();
1557        let path = dir.path().join("guardian-mutation.txt");
1558        let command = format!("printf blocked > {}", path.display());
1559        let call = make_tool_call("Bash", json!({"command": command}));
1560        let ctx = ToolExecutionContext {
1561            session_id: Some("guardian-auto"),
1562            tool_call_id: &call.id,
1563            event_tx: None,
1564            available_tool_schemas: None,
1565            bypass_permissions: false,
1566            auto_approve_permissions: true,
1567            plan_read_only: false,
1568            can_async_resume: false,
1569            bash_completion_sink: None,
1570            pre_parsed_args: None,
1571        };
1572
1573        let error = executor
1574            .execute_with_context(&call, ctx)
1575            .await
1576            .expect_err("Auto must retain Guardian read-only authority");
1577
1578        assert!(error.to_string().contains("Guardian reviewer is read-only"));
1579        assert!(!path.exists());
1580    }
1581
1582    #[tokio::test]
1583    async fn test_explicit_deny_overrides_bypass() {
1584        let dir = tempfile::tempdir().unwrap();
1585        let path = dir.path().join("explicit-deny.txt");
1586        let path_str = path.to_str().unwrap();
1587        let config = Arc::new(crate::permission::PermissionConfig::new());
1588        config.add_rule(crate::permission::PermissionRule::new(
1589            crate::permission::PermissionType::WriteFile,
1590            path_str,
1591            false,
1592        ));
1593        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1594        let executor = make_executor(Some(checker));
1595        let call = make_tool_call(
1596            "Write",
1597            json!({"file_path": path_str, "content": "blocked"}),
1598        );
1599        let ctx = ToolExecutionContext {
1600            session_id: Some("s-explicit-deny"),
1601            tool_call_id: &call.id,
1602            event_tx: None,
1603            available_tool_schemas: None,
1604            bypass_permissions: true,
1605            auto_approve_permissions: false,
1606            plan_read_only: false,
1607            can_async_resume: false,
1608            bash_completion_sink: None,
1609            pre_parsed_args: None,
1610        };
1611
1612        let result = executor.execute_with_context(&call, ctx).await;
1613        assert!(
1614            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1615            "explicit deny must beat bypass: {result:?}"
1616        );
1617        assert!(!path.exists());
1618    }
1619
1620    #[tokio::test]
1621    async fn test_explicit_delete_deny_overrides_bypass() {
1622        let config = Arc::new(crate::permission::PermissionConfig::new());
1623        config.add_rule(crate::permission::PermissionRule::new(
1624            crate::permission::PermissionType::DeleteOperation,
1625            "rm child-to-preserve",
1626            false,
1627        ));
1628        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1629        let executor = BuiltinToolExecutorBuilder::new()
1630            .with_tool(BashTool::new())
1631            .expect("register Bash tool")
1632            .with_permission_checker(checker)
1633            .build();
1634        let call = make_tool_call("Bash", json!({"command": "rm child-to-preserve"}));
1635        let ctx = ToolExecutionContext {
1636            session_id: Some("s-explicit-delete-deny"),
1637            tool_call_id: &call.id,
1638            event_tx: None,
1639            available_tool_schemas: None,
1640            bypass_permissions: true,
1641            auto_approve_permissions: false,
1642            plan_read_only: false,
1643            can_async_resume: false,
1644            bash_completion_sink: None,
1645            pre_parsed_args: None,
1646        };
1647
1648        let result = executor.execute_with_context(&call, ctx).await;
1649        assert!(
1650            matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1651            "explicit delete deny must beat bypass: {result:?}"
1652        );
1653    }
1654
1655    #[tokio::test]
1656    async fn plan_auto_denies_mutation_but_allows_read_without_a_checker() {
1657        let executor = BuiltinToolExecutor::new();
1658        let dir = tempfile::tempdir().unwrap();
1659        let path = dir.path().join("plan-auto.txt");
1660        let write = make_tool_call(
1661            "Write",
1662            json!({"file_path": path, "content": "must not run"}),
1663        );
1664        let write_ctx = ToolExecutionContext {
1665            session_id: Some("plan-auto"),
1666            tool_call_id: &write.id,
1667            event_tx: None,
1668            available_tool_schemas: None,
1669            bypass_permissions: false,
1670            auto_approve_permissions: true,
1671            plan_read_only: true,
1672            can_async_resume: false,
1673            bash_completion_sink: None,
1674            pre_parsed_args: None,
1675        };
1676        let denied = executor.execute_with_context(&write, write_ctx).await;
1677        assert!(matches!(
1678            denied,
1679            Err(ToolError::Execution(ref message)) if message.contains("Plan mode")
1680        ));
1681        assert!(tokio::fs::metadata(&path).await.is_err());
1682
1683        tokio::fs::write(&path, "readable").await.unwrap();
1684        let read = make_tool_call("Read", json!({"file_path": path}));
1685        let read_ctx = ToolExecutionContext {
1686            session_id: Some("plan-auto"),
1687            tool_call_id: &read.id,
1688            event_tx: None,
1689            available_tool_schemas: None,
1690            bypass_permissions: false,
1691            auto_approve_permissions: true,
1692            plan_read_only: true,
1693            can_async_resume: false,
1694            bash_completion_sink: None,
1695            pre_parsed_args: None,
1696        };
1697        let allowed = executor
1698            .execute_with_context(&read, read_ctx)
1699            .await
1700            .unwrap();
1701        assert!(allowed.success);
1702    }
1703
1704    #[tokio::test]
1705    async fn auto_request_permissions_fails_without_creating_a_pause() {
1706        let executor = BuiltinToolExecutor::new();
1707        let (event_tx, mut event_rx) = mpsc::channel(4);
1708        let call = make_tool_call("request_permissions", json!({}));
1709        let ctx = ToolExecutionContext {
1710            session_id: Some("auto-no-prompt"),
1711            tool_call_id: &call.id,
1712            event_tx: Some(&event_tx),
1713            available_tool_schemas: None,
1714            bypass_permissions: false,
1715            auto_approve_permissions: true,
1716            plan_read_only: false,
1717            can_async_resume: false,
1718            bash_completion_sink: None,
1719            pre_parsed_args: None,
1720        };
1721
1722        let result = executor.execute_with_context_outcome(&call, ctx).await;
1723        assert!(matches!(
1724            result,
1725            Err(ToolError::Execution(ref message)) if message.contains("cannot request expanded permissions")
1726        ));
1727        assert!(event_rx.try_recv().is_err());
1728    }
1729
1730    #[tokio::test]
1731    async fn interactive_gate_returns_synthesized_approval_pause() {
1732        // With an event sink present, a forced-ask rule that yields
1733        // `ConfirmationRequired` must resolve to the synthesized "awaiting
1734        // approval" PAUSE result (a `Completed` result tagged
1735        // `display_preference = "request_permissions"`) — NOT an error — so the
1736        // engine turns it into a clarification pause. This locks in the
1737        // interactive-sink path that the `check_permissions_for` extraction must
1738        // preserve as `Ok(Some(outcome))` rather than collapse to an `Err`.
1739        let config = Arc::new(crate::permission::PermissionConfig::new());
1740        config.set_ask_rules(["Write(/etc/**)".to_string()]);
1741        config.register_session_workspace("s-interactive", "/workspace/project");
1742        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1743        let executor = make_executor(Some(checker));
1744
1745        let (tx, mut rx) = mpsc::channel(8);
1746        let call = make_tool_call(
1747            "Write",
1748            json!({"file_path": "/etc/gated.conf", "content": "x"}),
1749        );
1750        let ctx = ToolExecutionContext {
1751            session_id: Some("s-interactive"),
1752            tool_call_id: &call.id,
1753            event_tx: Some(&tx),
1754            available_tool_schemas: None,
1755            bypass_permissions: false,
1756            auto_approve_permissions: false,
1757            plan_read_only: false,
1758            can_async_resume: false,
1759            bash_completion_sink: None,
1760            pre_parsed_args: None,
1761        };
1762
1763        let result = executor
1764            .execute_with_context(&call, ctx)
1765            .await
1766            .expect("interactive gate should pause (Ok), not error");
1767
1768        assert_eq!(
1769            result.display_preference.as_deref(),
1770            Some("request_permissions"),
1771            "interactive gate must return the request_permissions pause result"
1772        );
1773        assert!(result.result.contains("awaiting_permission_approval"));
1774        let payload: serde_json::Value = serde_json::from_str(&result.result).expect("payload");
1775        let request = &payload["permission_request"];
1776        assert_eq!(request["request_id"], call.id);
1777        assert_eq!(request["session_id"], "s-interactive");
1778        assert_eq!(request["workspace_path"], "/workspace/project");
1779        assert_eq!(request["reason_code"], "configured_always_ask");
1780        assert_eq!(
1781            request["allowed_decisions"],
1782            json!(["allow_once", "deny_once"])
1783        );
1784        assert_eq!(payload["options"], json!(["Approve", "Deny"]));
1785        assert!(fs::metadata("/etc/gated.conf").await.is_err());
1786
1787        let ev = rx.recv().await.expect("approval event should be emitted");
1788        assert!(
1789            matches!(ev, AgentEvent::ToolApprovalRequested { tool_name, .. } if tool_name == "Write")
1790        );
1791    }
1792
1793    #[tokio::test]
1794    async fn check_permissions_for_returns_none_when_permitted() {
1795        // A tool with no matching gate (Read, no checker rule) passes the gate:
1796        // `check_permissions_for` returns `Ok(None)` so the caller runs the tool.
1797        let executor = make_executor(None);
1798        let call = make_tool_call("Read", json!({"file_path": "/tmp/whatever"}));
1799        let ctx = ToolExecutionContext::none(&call.id);
1800        let decision = executor
1801            .check_permissions_for(&call, &ctx)
1802            .await
1803            .expect("no checker means no gate");
1804        assert!(decision.is_none(), "no checker must yield Ok(None)");
1805    }
1806
1807    // ---- Phase 2: cross-process approval proxy ----------------------------
1808
1809    struct HostStub {
1810        approve: bool,
1811    }
1812
1813    #[async_trait]
1814    impl crate::approval::ApprovalProxy for HostStub {
1815        async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
1816            self.approve
1817        }
1818    }
1819
1820    #[tokio::test]
1821    async fn approval_proxy_grant_lets_gated_tool_proceed() {
1822        // A subagent worker installs an ApprovalProxy for its run. A forced-ask
1823        // rule with NO event sink would otherwise fail closed; with the host
1824        // proxy granting, the executor treats the context as approved and the
1825        // tool proceeds inline (no suspend, no synthetic pause).
1826        let dir = tempfile::tempdir().unwrap();
1827        let path = dir.path().join("approved.txt");
1828        let path_str = path.to_str().unwrap().to_string();
1829        let config = Arc::new(crate::permission::PermissionConfig::new());
1830        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1831        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1832        let executor = make_executor(Some(checker));
1833
1834        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1835        let ctx = ToolExecutionContext {
1836            session_id: Some("s-worker"),
1837            tool_call_id: &call.id,
1838            event_tx: None,
1839            available_tool_schemas: None,
1840            bypass_permissions: false,
1841            auto_approve_permissions: false,
1842            plan_read_only: false,
1843            can_async_resume: false,
1844            bash_completion_sink: None,
1845            pre_parsed_args: None,
1846        };
1847
1848        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
1849        let result = crate::approval::with_approval_proxy(
1850            Some(proxy),
1851            executor.execute_with_context(&call, ctx),
1852        )
1853        .await;
1854
1855        assert!(
1856            result.is_ok(),
1857            "host grant should let the write through: {result:?}"
1858        );
1859        assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1860    }
1861
1862    #[tokio::test]
1863    async fn approval_proxy_deny_fails_gated_tool_closed() {
1864        // With the host proxy denying, the gated tool fails closed and the side
1865        // effect never happens.
1866        let dir = tempfile::tempdir().unwrap();
1867        let path = dir.path().join("denied.txt");
1868        let path_str = path.to_str().unwrap().to_string();
1869        let config = Arc::new(crate::permission::PermissionConfig::new());
1870        config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1871        let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1872        let executor = make_executor(Some(checker));
1873
1874        let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
1875        let ctx = ToolExecutionContext {
1876            session_id: Some("s-worker"),
1877            tool_call_id: &call.id,
1878            event_tx: None,
1879            available_tool_schemas: None,
1880            bypass_permissions: false,
1881            auto_approve_permissions: false,
1882            plan_read_only: false,
1883            can_async_resume: false,
1884            bash_completion_sink: None,
1885            pre_parsed_args: None,
1886        };
1887
1888        let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
1889        let result = crate::approval::with_approval_proxy(
1890            Some(proxy),
1891            executor.execute_with_context(&call, ctx),
1892        )
1893        .await;
1894
1895        assert!(
1896            matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
1897            "host deny should fail the tool closed: {result:?}"
1898        );
1899        assert!(fs::metadata(&path).await.is_err());
1900    }
1901
1902    #[tokio::test]
1903    async fn tool_can_stream_events_via_execute_with_context() {
1904        struct StreamingTool;
1905
1906        #[async_trait]
1907        impl Tool for StreamingTool {
1908            fn name(&self) -> &str {
1909                "streaming_tool"
1910            }
1911
1912            fn description(&self) -> &str {
1913                "streams one token"
1914            }
1915
1916            fn parameters_schema(&self) -> serde_json::Value {
1917                json!({"type":"object","properties":{}})
1918            }
1919
1920            async fn invoke(
1921                &self,
1922                _args: serde_json::Value,
1923                ctx: ToolCtx,
1924            ) -> Result<ToolOutcome, ToolError> {
1925                ctx.emit(AgentEvent::Token {
1926                    content: "stream".to_string(),
1927                })
1928                .await;
1929                Ok(ToolOutcome::Completed(ToolResult {
1930                    success: true,
1931                    result: "ok".to_string(),
1932                    display_preference: None,
1933                    images: Vec::new(),
1934                }))
1935            }
1936        }
1937
1938        let executor = BuiltinToolExecutor::new();
1939        executor
1940            .register_tool(StreamingTool)
1941            .expect("register streaming tool");
1942
1943        let (tx, mut rx) = mpsc::channel(8);
1944        let call = make_tool_call("streaming_tool", json!({}));
1945
1946        let result = executor
1947            .execute_with_context(
1948                &call,
1949                ToolExecutionContext {
1950                    session_id: Some("s1"),
1951                    tool_call_id: &call.id,
1952                    event_tx: Some(&tx),
1953                    available_tool_schemas: None,
1954                    bypass_permissions: false,
1955                    auto_approve_permissions: false,
1956                    plan_read_only: false,
1957                    can_async_resume: false,
1958                    bash_completion_sink: None,
1959                    pre_parsed_args: None,
1960                },
1961            )
1962            .await
1963            .expect("execute tool");
1964
1965        assert!(result.success);
1966        assert_eq!(result.result, "ok");
1967
1968        let ev = rx.recv().await.expect("expected streamed event");
1969        assert!(
1970            matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
1971        );
1972    }
1973
1974    #[tokio::test]
1975    async fn removed_legacy_tools_return_not_found() {
1976        let executor = BuiltinToolExecutor::new();
1977
1978        for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1979            let call = make_tool_call(legacy, json!({}));
1980            let result = executor.execute(&call).await;
1981            assert!(matches!(result, Err(ToolError::NotFound(_))));
1982        }
1983    }
1984
1985    #[tokio::test]
1986    async fn executor_prefers_exact_tool_name_before_builtin_alias() {
1987        struct CustomSpawnSessionTool;
1988
1989        #[async_trait]
1990        impl Tool for CustomSpawnSessionTool {
1991            fn name(&self) -> &str {
1992                "spawn_session"
1993            }
1994
1995            fn description(&self) -> &str {
1996                "custom tool for regression coverage"
1997            }
1998
1999            fn parameters_schema(&self) -> serde_json::Value {
2000                json!({"type":"object","properties":{}})
2001            }
2002
2003            async fn invoke(
2004                &self,
2005                _args: serde_json::Value,
2006                _ctx: ToolCtx,
2007            ) -> Result<ToolOutcome, ToolError> {
2008                Ok(ToolOutcome::Completed(ToolResult {
2009                    success: true,
2010                    result: "custom-spawn-session".to_string(),
2011                    display_preference: None,
2012                    images: Vec::new(),
2013                }))
2014            }
2015        }
2016
2017        let executor = BuiltinToolExecutorBuilder::new()
2018            .with_tool(CustomSpawnSessionTool)
2019            .expect("register custom spawn_session tool")
2020            .build();
2021
2022        let call = make_tool_call("spawn_session", json!({}));
2023        let result = executor.execute(&call).await.expect("execute custom tool");
2024        assert!(result.success);
2025        assert_eq!(result.result, "custom-spawn-session");
2026    }
2027
2028    // ---- issue #106: parse tool args once on the execute path -------------
2029
2030    /// A tool that echoes back the `v` field of the args it was invoked with, so
2031    /// a test can observe *which* parsed value reached the tool.
2032    struct EchoArgsTool;
2033
2034    #[async_trait]
2035    impl Tool for EchoArgsTool {
2036        fn name(&self) -> &str {
2037            "echo_args"
2038        }
2039        fn description(&self) -> &str {
2040            "echoes the `v` arg"
2041        }
2042        fn parameters_schema(&self) -> serde_json::Value {
2043            json!({"type":"object","properties":{"v":{"type":"string"}}})
2044        }
2045        async fn invoke(
2046            &self,
2047            args: serde_json::Value,
2048            _ctx: ToolCtx,
2049        ) -> Result<ToolOutcome, ToolError> {
2050            let v = args
2051                .get("v")
2052                .and_then(serde_json::Value::as_str)
2053                .unwrap_or("<none>")
2054                .to_string();
2055            Ok(ToolOutcome::Completed(ToolResult {
2056                success: true,
2057                result: v,
2058                display_preference: None,
2059                images: Vec::new(),
2060            }))
2061        }
2062    }
2063
2064    fn ctx_with_pre_parsed<'a>(
2065        call_id: &'a str,
2066        pre_parsed: Option<&'a serde_json::Value>,
2067    ) -> ToolExecutionContext<'a> {
2068        ToolExecutionContext {
2069            session_id: Some("s-106"),
2070            tool_call_id: call_id,
2071            event_tx: None,
2072            available_tool_schemas: None,
2073            bypass_permissions: false,
2074            auto_approve_permissions: false,
2075            plan_read_only: false,
2076            can_async_resume: false,
2077            bash_completion_sink: None,
2078            pre_parsed_args: pre_parsed,
2079        }
2080    }
2081
2082    #[tokio::test]
2083    async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
2084        // The raw `arguments` string and the threaded `pre_parsed_args` Value
2085        // deliberately disagree. If the executor honored the contract (parse
2086        // once at the dispatch site, reuse downstream), the tool sees the
2087        // pre-parsed value; if it re-parsed the raw string it would see "raw".
2088        // This is the load-bearing proof that the second parse was eliminated.
2089        let executor = BuiltinToolExecutor::new();
2090        executor.register_tool(EchoArgsTool).expect("register echo");
2091
2092        let call = make_tool_call("echo_args", json!({"v": "raw"}));
2093        let pre_parsed = json!({"v": "preparsed"});
2094        let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
2095
2096        let result = executor
2097            .execute_with_context(&call, ctx)
2098            .await
2099            .expect("execute echo tool");
2100        assert_eq!(
2101            result.result, "preparsed",
2102            "executor must reuse pre_parsed_args, not re-parse the raw string"
2103        );
2104    }
2105
2106    #[tokio::test]
2107    async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
2108        // Without a threaded value (the `execute` entry point / tests / a loop
2109        // that parsed with a different parser), the executor falls back to
2110        // parsing the raw string exactly as before — behavior preserved.
2111        let executor = BuiltinToolExecutor::new();
2112        executor.register_tool(EchoArgsTool).expect("register echo");
2113
2114        let call = make_tool_call("echo_args", json!({"v": "raw"}));
2115        let ctx = ctx_with_pre_parsed(&call.id, None);
2116
2117        let result = executor
2118            .execute_with_context(&call, ctx)
2119            .await
2120            .expect("execute echo tool");
2121        assert_eq!(
2122            result.result, "raw",
2123            "without pre_parsed_args the executor parses the raw string as before"
2124        );
2125    }
2126
2127    #[tokio::test]
2128    async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
2129        // Malformed (truncated) JSON must still be auto-repaired by the
2130        // fallback parse when no pre-parsed value is threaded — the existing
2131        // error/leniency behavior is untouched by the dedup.
2132        let dir = tempfile::tempdir().unwrap();
2133        let path = dir.path().join("recovered-no-preparsed.txt");
2134        let malformed_args = format!(
2135            r#"{{"file_path":"{}","content":"recovered content""#,
2136            path.display()
2137        );
2138
2139        let executor = BuiltinToolExecutor::new();
2140        let call = make_tool_call_with_raw_args("Write", &malformed_args);
2141        let ctx = ctx_with_pre_parsed(&call.id, None);
2142
2143        let result = executor
2144            .execute_with_context(&call, ctx)
2145            .await
2146            .expect("truncated JSON should be auto-repaired");
2147        assert!(result.success);
2148        let written = fs::read_to_string(&path).await.expect("file written");
2149        assert_eq!(written, "recovered content");
2150    }
2151}