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
94pub struct BuiltinToolExecutor {
96 registry: ToolRegistry,
97 permission_checker: Option<Arc<dyn PermissionChecker>>,
98}
99
100impl BuiltinToolExecutor {
101 pub fn new() -> Self {
103 let registry = ToolRegistry::new();
104 Self::register_builtin_tools(®istry, None);
105 Self {
106 registry,
107 permission_checker: None,
108 }
109 }
110
111 pub fn new_with_permissions(permission_checker: Arc<dyn PermissionChecker>) -> Self {
113 let registry = ToolRegistry::new();
114 Self::register_builtin_tools(®istry, None);
115 Self {
116 registry,
117 permission_checker: Some(permission_checker),
118 }
119 }
120
121 pub fn new_with_config(config: Arc<RwLock<Config>>) -> Self {
126 let registry = ToolRegistry::new();
127 Self::register_builtin_tools(®istry, Some(config));
128 Self {
129 registry,
130 permission_checker: None,
131 }
132 }
133
134 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(®istry, Some(config));
141 Self {
142 registry,
143 permission_checker: Some(permission_checker),
144 }
145 }
146
147 pub fn with_registry(registry: ToolRegistry) -> Self {
149 Self {
150 registry,
151 permission_checker: None,
152 }
153 }
154
155 pub fn registry(&self) -> &ToolRegistry {
157 &self.registry
158 }
159
160 fn register_builtin_tools(registry: &ToolRegistry, config: Option<Arc<RwLock<Config>>>) {
162 let _ = config;
163 let _ = registry.register(ConclusionWithOptionsTool::new());
165 let _ = registry.register(BashTool::new());
166 let _ = registry.register(BashInputTool::new());
167 let _ = registry.register(BashOutputTool::new());
168 let _ = registry.register(EditTool::new());
169 let _ = registry.register(EnterPlanModeTool::new());
170 let _ = registry.register(ExitPlanModeTool::new());
171 let _ = registry.register(GetFileInfoTool::new());
173 let _ = registry.register(GlobTool::new());
174 let _ = registry.register(GrepTool::new());
175 let _ = registry.register(UpdateGoalTool::new());
176 let _ = registry.register(JsReplTool::new());
177 let _ = registry.register(KillShellTool::new());
178 let _ = registry.register(SessionNoteTool::new());
179 let _ = registry.register(NotebookEditTool::new());
180 let _ = registry.register(ReadTool::new());
181 let _ = registry.register(RequestPermissionsTool::new());
182 let _ = registry.register(SleepTool::new());
183 let _ = registry.register(TaskTool::new());
184 let _ = registry.register(WebFetchTool::new());
185 let _ = registry.register(WebSearchTool::new());
186 let _ = registry.register(WorkspaceTool::new());
188 let _ = registry.register(WriteTool::new());
189 }
190
191 pub fn tool_schemas() -> Vec<ToolSchema> {
193 let registry = ToolRegistry::new();
194 Self::register_builtin_tools(®istry, None);
195 registry.list_tools()
196 }
197
198 pub fn register_tool<T: Tool + 'static>(&self, tool: T) -> Result<(), ToolError> {
200 self.registry
201 .register(tool)
202 .map_err(|e| ToolError::Execution(e.to_string()))
203 }
204
205 pub fn register_tool_with_guide<T, G>(&self, tool: T, guide: G) -> Result<(), ToolError>
207 where
208 T: Tool + 'static,
209 G: ToolGuide + 'static,
210 {
211 self.registry
212 .register_with_guide(tool, guide)
213 .map_err(|e| ToolError::Execution(e.to_string()))
214 }
215
216 pub fn get_guide(&self, tool_name: &str) -> Option<Arc<dyn ToolGuide>> {
218 self.registry.get_guide(tool_name)
219 }
220
221 pub fn build_enhanced_prompt(&self, context: GuideBuildContext) -> String {
223 EnhancedPromptBuilder::build(Some(&self.registry), &self.registry.list_tools(), &context)
224 }
225}
226
227fn permission_error_to_tool_error(error: PermissionError) -> ToolError {
228 match error {
229 PermissionError::CheckFailed(_) => ToolError::InvalidArguments(error.to_string()),
230 _ => ToolError::Execution(error.to_string()),
231 }
232}
233
234impl Default for BuiltinToolExecutor {
235 fn default() -> Self {
236 Self::new()
237 }
238}
239
240#[async_trait]
241impl ToolExecutor for BuiltinToolExecutor {
242 async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
243 self.execute_with_context(call, ToolExecutionContext::none(&call.id))
244 .await
245 }
246
247 async fn execute_with_context(
248 &self,
249 call: &ToolCall,
250 ctx: ToolExecutionContext<'_>,
251 ) -> Result<ToolResult, ToolError> {
252 self.execute_with_context_outcome(call, ctx)
253 .await
254 .map(ToolOutcome::into_tool_result)
255 }
256
257 async fn execute_with_context_outcome(
258 &self,
259 call: &ToolCall,
260 ctx: ToolExecutionContext<'_>,
261 ) -> Result<ToolOutcome, ToolError> {
262 let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
272 pre_parsed.clone()
273 } else {
274 let args_raw = call.function.arguments.trim();
275 let (parsed, parse_warning) = parse_tool_args_best_effort(&call.function.arguments);
276 if let Some(warning) = parse_warning {
277 tracing::warn!(
278 "Builtin tool argument parsing fallback applied: session_id={:?}, tool_call_id={}, tool_name={}, args_len={}, args_preview=\"{}\", warning={}",
279 ctx.session_id,
280 call.id,
281 call.function.name,
282 args_raw.len(),
283 preview_for_log(args_raw, 180),
284 warning
285 );
286 }
287 parsed
288 };
289
290 let raw_tool_name = normalize_tool_name(&call.function.name);
291 if let Some(args_obj) = args.as_object_mut() {
292 normalize_legacy_builtin_args(raw_tool_name, args_obj);
293 }
294
295 let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
296
297 let tool = self
299 .registry
300 .get(&tool_name)
301 .ok_or_else(|| ToolError::NotFound(format!("Tool '{}' not found", tool_name)))?;
302
303 if let Some(outcome) = self.check_permissions_for(call, &ctx).await? {
310 return Ok(outcome);
311 }
312
313 let tool_ctx = ctx.to_tool_ctx();
321 tool.invoke(args, tool_ctx).await
322 }
323
324 async fn check_permissions_for(
348 &self,
349 call: &ToolCall,
350 ctx: &ToolExecutionContext<'_>,
351 ) -> Result<Option<ToolOutcome>, ToolError> {
352 let Some(permission_checker) = &self.permission_checker else {
353 return Ok(None);
354 };
355
356 let mut args = if let Some(pre_parsed) = ctx.pre_parsed_args {
361 pre_parsed.clone()
362 } else {
363 parse_tool_args_best_effort(&call.function.arguments).0
364 };
365 let raw_tool_name = normalize_tool_name(&call.function.name);
366 if let Some(args_obj) = args.as_object_mut() {
367 normalize_legacy_builtin_args(raw_tool_name, args_obj);
368 }
369 let tool_name = resolve_registered_tool_name(&self.registry, raw_tool_name);
370
371 if let Some(contexts) =
372 check_permissions(&tool_name, &args).map_err(permission_error_to_tool_error)?
373 {
374 let force_ask = permission_checker.requires_forced_confirmation(&tool_name, &args);
379 for context in contexts {
380 if permission_checker
383 .permission_config()
384 .is_some_and(|config| {
385 config.is_whitelist_allowed(context.permission_type, &context.resource)
386 == Some(false)
387 })
388 {
389 return Err(ToolError::Execution(format!(
390 "Permission denied by explicit policy for: {}",
391 context.resource
392 )));
393 }
394 if ctx.session_id.is_some_and(|session_id| {
395 permission_checker.consume_once(
396 session_id,
397 &call.id,
398 context.permission_type,
399 &context.resource,
400 )
401 }) {
402 continue;
403 }
404 if ctx.bypass_permissions && !force_ask {
405 continue;
406 }
407 let resource = context.resource.clone();
408 let operation_summary = context.operation_description.clone();
409 let risk_level = context.risk_level();
410 let decision = if force_ask {
413 permission_checker.check_or_request_forced(context).await
414 } else if let Some(session_id) = ctx.session_id {
415 permission_checker
416 .check_or_request_for_session(session_id, context)
417 .await
418 } else {
419 permission_checker.check_or_request(context).await
420 };
421 match decision {
422 Ok(true) => {}
423 Ok(false) => {
424 return Err(ToolError::Execution(format!(
425 "Permission denied for: {}",
426 resource
427 )));
428 }
429 Err(PermissionError::ConfirmationRequired {
430 permission_type,
431 resource: _,
432 }) => {
433 if let Some(proxy) = crate::approval::current_approval_proxy() {
444 let approved = proxy
445 .request_approval(crate::approval::ApprovalAsk {
446 tool_name: tool_name.clone(),
447 permission: permission_type.description().to_string(),
448 resource: resource.clone(),
449 })
450 .await;
451 if approved {
452 continue;
455 }
456 return Err(ToolError::Execution(format!(
457 "Permission denied by host for: {}",
458 resource
459 )));
460 }
461
462 if let Some(tx) = ctx.event_tx {
469 let _ = tx
471 .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
472 tool_call_id: call.id.clone(),
473 tool_name: tool_name.clone(),
474 parameters: args.clone(),
475 })
476 .await;
477
478 let question = format!(
479 "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
480 tool_name,
481 permission_type.description(),
482 resource
483 );
484 let effective_mode = permission_checker
485 .permission_config()
486 .map(|config| config.mode())
487 .unwrap_or(bamboo_config::settings::PermissionMode::Default);
488 let permission_request = crate::permission::PermissionRequest {
489 request_id: call.id.clone(),
490 session_id: ctx.session_id.unwrap_or_default().to_string(),
491 workspace_path: None,
492 tool_name: tool_name.clone(),
493 permission_type,
494 resource: resource.clone(),
495 operation_summary,
496 risk_level,
497 reason_code: if permission_checker.permission_config().is_some_and(
498 |config| config.is_hard_dangerous(&tool_name, &args),
499 ) {
500 crate::permission::PermissionReasonCode::HardDangerous
501 } else if force_ask {
502 crate::permission::PermissionReasonCode::ConfiguredAlwaysAsk
503 } else {
504 crate::permission::PermissionReasonCode::RiskThreshold
505 },
506 effective_mode,
507 bypass_requested: ctx.bypass_permissions,
508 policy_revision: 0,
509 allowed_decisions:
510 crate::permission::PermissionRequest::migration_decisions(),
511 suggested_matchers: vec![crate::permission::PermissionMatcher {
512 id: "exact_resource".to_string(),
513 kind: crate::permission::PermissionMatcherKind::ExactResource,
514 value: resource.clone(),
515 }],
516 };
517 let payload = serde_json::json!({
518 "status": "awaiting_permission_approval",
519 "question": question,
520 "permission_type": permission_type,
521 "resource": resource,
522 "options": ["Approve", "Deny"],
523 "allow_custom": false,
524 "permission_request": permission_request,
525 });
526 return Ok(Some(ToolOutcome::Completed(ToolResult {
530 success: true,
531 result: payload.to_string(),
532 display_preference: Some("request_permissions".to_string()),
533 images: Vec::new(),
534 })));
535 }
536
537 return Err(ToolError::Execution(format!(
540 "Permission approval required for: {}",
541 resource
542 )));
543 }
544 Err(other) => {
545 return Err(permission_error_to_tool_error(other));
546 }
547 }
548 }
549 }
550
551 Ok(None)
552 }
553
554 fn list_tools(&self) -> Vec<ToolSchema> {
555 self.registry.list_tools()
556 }
557
558 fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
559 self.registry
560 .get(tool_name)
561 .map(|tool| tool.classify(&serde_json::Value::Null).mutability)
562 .unwrap_or_else(|| crate::classify_tool(tool_name))
563 }
564
565 fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
566 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
567 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
568 self.registry
569 .get(&canonical)
570 .map(|tool| tool.classify(&args).mutability)
571 .unwrap_or_else(|| self.tool_mutability(&canonical))
572 }
573
574 fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
575 let canonical = resolve_registered_tool_name(&self.registry, tool_name);
576 self.registry
577 .get(&canonical)
578 .map(|tool| tool.classify(&serde_json::Value::Null).parallel_safe)
579 .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
580 }
581
582 fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
583 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
584 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
585 self.registry
586 .get(&canonical)
587 .map(|tool| tool.classify(&args).parallel_safe)
588 .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
589 }
590
591 fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
592 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
596 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
597 match self.registry.get(&canonical) {
598 Some(tool) => {
599 let class = tool.classify(&args);
600 (class.mutability, class.parallel_safe)
601 }
602 None => (
603 self.tool_mutability(&canonical),
604 self.tool_concurrency_safe(&canonical),
605 ),
606 }
607 }
608}
609
610pub struct BuiltinToolExecutorBuilder {
612 registry: ToolRegistry,
613 permission_checker: Option<Arc<dyn PermissionChecker>>,
614}
615
616impl BuiltinToolExecutorBuilder {
617 pub fn new() -> Self {
619 Self {
620 registry: ToolRegistry::new(),
621 permission_checker: None,
622 }
623 }
624
625 pub fn with_default_tools(self) -> Self {
627 BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
628 self
629 }
630
631 pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
633 match name {
634 "Read" => self.registry.register(ReadTool::new()),
635 "Write" => self.registry.register(WriteTool::new()),
636 "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
638 "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
639 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
640 }
641 .map_err(|e| ToolError::Execution(e.to_string()))?;
642 Ok(self)
643 }
644
645 pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
647 match name {
648 "Bash" => self.registry.register(BashTool::new()),
649 "BashOutput" => self.registry.register(BashOutputTool::new()),
650 "KillShell" => self.registry.register(KillShellTool::new()),
651 "Task" => self.registry.register(TaskTool::new()),
652 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
653 }
654 .map_err(|e| ToolError::Execution(e.to_string()))?;
655 Ok(self)
656 }
657
658 pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
660 self.registry
661 .register(tool)
662 .map_err(|e| ToolError::Execution(e.to_string()))?;
663 Ok(self)
664 }
665
666 pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
668 self.permission_checker = Some(checker);
669 self
670 }
671
672 pub fn build(self) -> BuiltinToolExecutor {
674 BuiltinToolExecutor {
675 registry: self.registry,
676 permission_checker: self.permission_checker,
677 }
678 }
679}
680
681impl Default for BuiltinToolExecutorBuilder {
682 fn default() -> Self {
683 Self::new()
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use bamboo_agent_core::AgentEvent;
691 use bamboo_agent_core::FunctionCall;
692 use bamboo_agent_core::ToolCtx;
693 use bamboo_agent_core::ToolExecutionContext;
694 use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
695 use serde_json::json;
696 use std::sync::Arc;
697 use tokio::fs;
698 use tokio::sync::mpsc;
699
700 use crate::tools::WriteTool;
701
702 fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
703 ToolCall {
704 id: "call_1".to_string(),
705 tool_type: "function".to_string(),
706 function: FunctionCall {
707 name: name.to_string(),
708 arguments: args.to_string(),
709 },
710 }
711 }
712
713 fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
714 ToolCall {
715 id: "call_1".to_string(),
716 tool_type: "function".to_string(),
717 function: FunctionCall {
718 name: name.to_string(),
719 arguments: raw_args.to_string(),
720 },
721 }
722 }
723
724 fn make_executor(
725 permission_checker: Option<Arc<dyn PermissionChecker>>,
726 ) -> BuiltinToolExecutor {
727 let builder = BuiltinToolExecutorBuilder::new()
728 .with_tool(WriteTool::new())
729 .expect("register Write tool");
730
731 let builder = match permission_checker {
732 Some(checker) => builder.with_permission_checker(checker),
733 None => builder,
734 };
735
736 builder.build()
737 }
738
739 #[test]
740 fn test_normalize_tool_ref_accepts_claude_style_names() {
741 assert_eq!(
742 normalize_tool_ref("default::Bash"),
743 Some("Bash".to_string())
744 );
745 }
746
747 #[test]
748 fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
749 assert_eq!(
750 normalize_tool_ref("default::fileExists"),
751 Some("FileExists".to_string())
752 );
753 assert_eq!(
754 normalize_tool_ref("default::getCurrentDir"),
755 Some("GetCurrentDir".to_string())
756 );
757 assert_eq!(
758 normalize_tool_ref("default::getFileInfo"),
759 Some("GetFileInfo".to_string())
760 );
761 assert_eq!(
762 normalize_tool_ref("default::setWorkspace"),
763 Some("SetWorkspace".to_string())
764 );
765 assert_eq!(
766 normalize_tool_ref("default::sleep"),
767 Some("Sleep".to_string())
768 );
769 }
770
771 #[test]
772 fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
773 assert_eq!(
774 normalize_tool_ref("default::execute_command"),
775 Some("Bash".to_string())
776 );
777 assert_eq!(
778 normalize_tool_ref("default::file_exists"),
779 Some("FileExists".to_string())
780 );
781 assert_eq!(
782 normalize_tool_ref("default::get_current_dir"),
783 Some("GetCurrentDir".to_string())
784 );
785 assert_eq!(
786 normalize_tool_ref("default::get_file_info"),
787 Some("GetFileInfo".to_string())
788 );
789 assert_eq!(
790 normalize_tool_ref("default::list_directory"),
791 Some("Glob".to_string())
792 );
793 assert_eq!(
794 normalize_tool_ref("default::memory_note"),
795 Some("memory_note".to_string())
796 );
797 assert_eq!(
798 normalize_tool_ref("default::read_file"),
799 Some("Read".to_string())
800 );
801 assert_eq!(
802 normalize_tool_ref("default::set_workspace"),
803 Some("SetWorkspace".to_string())
804 );
805 assert_eq!(
806 normalize_tool_ref("default::write_file"),
807 Some("Write".to_string())
808 );
809 }
810
811 #[test]
812 fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
813 for alias in [
814 "default::spawn_session",
815 "default::sub_session",
816 "default::sub_task",
817 "default::team_agent",
818 "default::child_session",
819 ] {
820 assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
821 }
822 }
823
824 #[test]
825 fn test_normalize_tool_ref_accepts_server_overlay_tools() {
826 assert_eq!(normalize_tool_ref("compress_context"), None);
827 assert_eq!(
828 normalize_tool_ref("default::read_skill_resource"),
829 Some("read_skill_resource".to_string())
830 );
831 }
832
833 #[tokio::test]
834 async fn test_executor_accepts_legacy_read_file_path_argument() {
835 let dir = tempfile::tempdir().unwrap();
836 let file_path = dir.path().join("legacy-read.txt");
837 fs::write(&file_path, "legacy read content").await.unwrap();
838
839 let executor = BuiltinToolExecutor::new();
840 let call = make_tool_call("read_file", json!({"path": file_path}));
841
842 let result = executor.execute(&call).await.unwrap();
843 assert!(result.success);
844 assert!(result.result.contains("legacy read content"));
845 }
846
847 #[tokio::test]
848 async fn test_executor_accepts_legacy_list_directory_without_pattern() {
849 let dir = tempfile::tempdir().unwrap();
850 let file_path = dir.path().join("legacy-list.txt");
851 fs::write(&file_path, "legacy list content").await.unwrap();
852
853 let executor = BuiltinToolExecutor::new();
854 let call = make_tool_call("list_directory", json!({"path": dir.path()}));
855
856 let result = executor.execute(&call).await.unwrap();
857 assert!(result.success);
858 assert!(result.result.contains("legacy-list.txt"));
859 }
860
861 #[tokio::test]
862 async fn test_executor_accepts_canonical_read_with_path_argument() {
863 let dir = tempfile::tempdir().unwrap();
864 let file_path = dir.path().join("canonical-read.txt");
865 fs::write(&file_path, "canonical read content")
866 .await
867 .unwrap();
868
869 let executor = BuiltinToolExecutor::new();
870 let call = make_tool_call("Read", json!({"path": file_path}));
871
872 let result = executor.execute(&call).await.unwrap();
873 assert!(result.success);
874 assert!(result.result.contains("canonical read content"));
875 }
876
877 #[tokio::test]
878 async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
879 let dir = tempfile::tempdir().unwrap();
880 let file_path = dir.path().join("canonical-list.txt");
881 fs::write(&file_path, "canonical list content")
882 .await
883 .unwrap();
884
885 let executor = BuiltinToolExecutor::new();
886 let call = make_tool_call("Glob", json!({"path": dir.path()}));
887
888 let result = executor.execute(&call).await.unwrap();
889 assert!(result.success);
890 assert!(result.result.contains("canonical-list.txt"));
891 }
892
893 #[test]
894 fn test_executor_workspace_mutability_depends_on_path_argument() {
895 let executor = BuiltinToolExecutor::new();
896 let get_call = make_tool_call("Workspace", json!({}));
897 let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
898
899 assert_eq!(
900 executor.call_mutability(&get_call),
901 crate::ToolMutability::ReadOnly
902 );
903 assert!(executor.call_concurrency_safe(&get_call));
904
905 assert_eq!(
906 executor.call_mutability(&set_call),
907 crate::ToolMutability::Mutating
908 );
909 assert!(!executor.call_concurrency_safe(&set_call));
910 }
911
912 #[test]
913 fn call_parallel_classification_matches_individual_methods() {
914 let executor = BuiltinToolExecutor::new();
922 let cases: &[(&str, serde_json::Value)] = &[
923 ("Read", json!({})),
924 ("Grep", json!({"pattern": "x"})),
925 (
926 "Write",
927 json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
928 ),
929 ("Bash", json!({"command": "echo hi"})),
930 ("Workspace", json!({})),
931 ("Workspace", json!({"path": "/tmp"})),
932 ];
933
934 for (name, args) in cases {
935 let call = make_tool_call(name, args.clone());
936 let expected_mutability = executor.call_mutability(&call);
937 let expected_concurrency = executor.call_concurrency_safe(&call);
938 let (mutability, concurrency) = executor.call_parallel_classification(&call);
939 assert_eq!(
940 mutability, expected_mutability,
941 "mutability mismatch for {name} ({args})"
942 );
943 assert_eq!(
944 concurrency, expected_concurrency,
945 "concurrency mismatch for {name} ({args})"
946 );
947 }
948 }
949
950 #[test]
951 fn list_tools_snapshot_is_stable_across_calls() {
952 let executor = BuiltinToolExecutor::new();
957 let first: Vec<String> = executor
958 .list_tools()
959 .into_iter()
960 .map(|s| s.function.name)
961 .collect();
962 let second: Vec<String> = executor
963 .list_tools()
964 .into_iter()
965 .map(|s| s.function.name)
966 .collect();
967 assert!(!first.is_empty(), "builtin executor should expose tools");
968 assert_eq!(
969 first, second,
970 "list_tools() must be deterministic per round"
971 );
972 }
973
974 #[tokio::test]
975 async fn test_executor_recovers_truncated_json_arguments() {
976 let dir = tempfile::tempdir().unwrap();
977 let path = dir.path().join("recovered-write.txt");
978
979 let malformed_args = format!(
981 r#"{{"file_path":"{}","content":"recovered content""#,
982 path.display()
983 );
984
985 let executor = BuiltinToolExecutor::new();
986 let call = make_tool_call_with_raw_args("Write", &malformed_args);
987
988 let result = executor
989 .execute(&call)
990 .await
991 .expect("truncated JSON should be auto-repaired");
992 assert!(result.success);
993
994 let written = fs::read_to_string(&path)
995 .await
996 .expect("file should be written");
997 assert_eq!(written, "recovered content");
998 }
999
1000 #[test]
1001 fn test_normalize_tool_ref_rejects_unknown_tool() {
1002 assert_eq!(normalize_tool_ref("default::search"), None);
1003 }
1004
1005 #[test]
1006 fn test_executor_does_not_expose_legacy_tools() {
1007 let executor = BuiltinToolExecutor::new();
1008 let tool_names: Vec<String> = executor
1009 .list_tools()
1010 .into_iter()
1011 .map(|schema| schema.function.name)
1012 .collect();
1013
1014 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1015 assert!(!tool_names.iter().any(|name| name == legacy));
1016 }
1017 }
1018
1019 #[test]
1020 fn test_critical_tool_schemas_match_claude_shapes() {
1021 let executor = BuiltinToolExecutor::new();
1022 let tools = executor.list_tools();
1023
1024 let get_params = |name: &str| {
1025 tools
1026 .iter()
1027 .find(|tool| tool.function.name == name)
1028 .unwrap()
1029 .function
1030 .parameters
1031 .clone()
1032 };
1033
1034 let grep = get_params("Grep");
1035 assert_eq!(grep["required"], json!(["pattern"]));
1036 assert_eq!(
1037 grep["properties"]["output_mode"]["enum"],
1038 json!(["content", "files_with_matches", "count"])
1039 );
1040 assert!(grep["properties"]["-A"].is_object());
1041 assert!(grep["properties"]["-B"].is_object());
1042 assert!(grep["properties"]["-C"].is_object());
1043 assert!(grep["properties"]["-n"].is_object());
1044 assert!(grep["properties"]["-i"].is_object());
1045
1046 let edit = get_params("Edit");
1047 assert_eq!(edit["required"], json!(["file_path"]));
1048 assert_eq!(edit["properties"]["old_string"]["type"], "string");
1049 assert_eq!(edit["properties"]["new_string"]["type"], "string");
1050 assert_eq!(edit["properties"]["patch"]["type"], "string");
1051 assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
1052 assert!(edit.get("oneOf").is_none());
1053
1054 assert_eq!(edit["properties"]["patch"]["type"], "string");
1057 assert_eq!(edit["properties"]["line_number"]["type"], "integer");
1058
1059 let bash = get_params("Bash");
1060 assert_eq!(bash["required"], json!(["command"]));
1061 assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
1062 assert_eq!(bash["properties"]["workdir"]["type"], "string");
1063
1064 let bash_output = get_params("BashOutput");
1065 assert_eq!(bash_output["required"], json!(["bash_id"]));
1066 assert_eq!(bash_output["properties"]["filter"]["type"], "string");
1067 }
1068
1069 #[test]
1070 fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
1071 let executor = BuiltinToolExecutor::new();
1072 let tools = executor.list_tools();
1073 let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
1074
1075 for tool in tools {
1076 let params = &tool.function.parameters;
1077 assert_eq!(
1078 params["type"], "object",
1079 "tool '{}' parameters must be a top-level object schema",
1080 tool.function.name
1081 );
1082 for key in forbidden {
1083 assert!(
1084 params.get(key).is_none(),
1085 "tool '{}' parameters contains forbidden top-level keyword '{}'",
1086 tool.function.name,
1087 key
1088 );
1089 }
1090 }
1091 }
1092
1093 #[test]
1094 fn test_executor_has_all_builtin_tools() {
1095 let executor = BuiltinToolExecutor::new();
1096 let tools = executor.list_tools();
1097
1098 assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
1099
1100 let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
1101 for tool_name in BUILTIN_TOOL_NAMES {
1102 assert!(tool_names.contains(&tool_name.to_string()));
1103 }
1104 }
1105
1106 #[test]
1107 fn test_executor_builds_enhanced_prompt() {
1108 let executor = BuiltinToolExecutor::new();
1109 let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
1110 assert!(prompt.contains("## Tool Usage Guidelines"));
1111 assert!(prompt.contains("**Read**"));
1112 }
1113
1114 #[test]
1115 fn test_executor_builder_empty() {
1116 let executor = BuiltinToolExecutorBuilder::new().build();
1117 assert!(executor.list_tools().is_empty());
1118 }
1119
1120 #[test]
1121 fn test_executor_builder_with_default_tools() {
1122 let executor = BuiltinToolExecutorBuilder::new()
1123 .with_default_tools()
1124 .build();
1125 assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
1126 }
1127
1128 #[test]
1129 fn test_executor_builder_with_specific_tool() {
1130 let executor = BuiltinToolExecutorBuilder::new()
1131 .with_filesystem_tool("Read")
1132 .unwrap()
1133 .build();
1134
1135 let tools = executor.list_tools();
1136 assert_eq!(tools.len(), 1);
1137 assert_eq!(tools[0].function.name, "Read");
1138 }
1139
1140 #[tokio::test]
1141 async fn test_executor_skips_permission_checks_without_checker() {
1142 let executor = make_executor(None);
1143 let path = "/tmp/executor_permission_none.txt";
1144 let _ = fs::remove_file(path).await;
1145
1146 let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
1147 let result = executor.execute(&call).await.expect("execute tool");
1148
1149 assert!(result.success);
1150 let _ = fs::remove_file(path).await;
1151 }
1152
1153 #[tokio::test]
1154 async fn test_executor_with_permission_checker_enforces_checks() {
1155 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1156 let executor = make_executor(Some(checker));
1157 let path = "/tmp/executor_permission_denied.txt";
1158 let _ = fs::remove_file(path).await;
1159
1160 let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
1161 let result = executor.execute(&call).await;
1162
1163 assert!(matches!(result, Err(ToolError::Execution(_))));
1164 assert!(fs::metadata(path).await.is_err());
1165 }
1166
1167 #[tokio::test]
1168 async fn test_bypass_permissions_skips_checker() {
1169 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1172 let executor = make_executor(Some(checker));
1173 let dir = tempfile::tempdir().unwrap();
1174 let path = dir.path().join("bypass_allows_write.txt");
1175 let path_str = path.to_str().unwrap();
1176
1177 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1178 let ctx = ToolExecutionContext {
1179 session_id: Some("s-bypass"),
1180 tool_call_id: &call.id,
1181 event_tx: None,
1182 available_tool_schemas: None,
1183 bypass_permissions: true,
1184 can_async_resume: false,
1185 bash_completion_sink: None,
1186 pre_parsed_args: None,
1187 };
1188 let result = executor.execute_with_context(&call, ctx).await;
1189
1190 assert!(result.is_ok(), "bypass should allow the write: {result:?}");
1191 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1192 }
1193
1194 #[tokio::test]
1195 async fn test_forced_ask_rule_overrides_bypass() {
1196 let config = Arc::new(crate::permission::PermissionConfig::new());
1200 config.set_ask_rules(["Write(/etc/**)".to_string()]);
1201 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1202 let executor = make_executor(Some(checker));
1203
1204 let call = make_tool_call(
1205 "Write",
1206 json!({"file_path": "/etc/forced.conf", "content": "x"}),
1207 );
1208 let ctx = ToolExecutionContext {
1209 session_id: Some("s-forced"),
1210 tool_call_id: &call.id,
1211 event_tx: None,
1212 available_tool_schemas: None,
1213 bypass_permissions: true,
1214 can_async_resume: false,
1215 bash_completion_sink: None,
1216 pre_parsed_args: None,
1217 };
1218 let result = executor.execute_with_context(&call, ctx).await;
1219
1220 assert!(
1221 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("approval required")),
1222 "forced ask rule should block under bypass: {result:?}"
1223 );
1224 assert!(fs::metadata("/etc/forced.conf").await.is_err());
1225 }
1226
1227 #[tokio::test]
1228 async fn test_explicit_deny_overrides_bypass() {
1229 let dir = tempfile::tempdir().unwrap();
1230 let path = dir.path().join("explicit-deny.txt");
1231 let path_str = path.to_str().unwrap();
1232 let config = Arc::new(crate::permission::PermissionConfig::new());
1233 config.add_rule(crate::permission::PermissionRule::new(
1234 crate::permission::PermissionType::WriteFile,
1235 path_str,
1236 false,
1237 ));
1238 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1239 let executor = make_executor(Some(checker));
1240 let call = make_tool_call(
1241 "Write",
1242 json!({"file_path": path_str, "content": "blocked"}),
1243 );
1244 let ctx = ToolExecutionContext {
1245 session_id: Some("s-explicit-deny"),
1246 tool_call_id: &call.id,
1247 event_tx: None,
1248 available_tool_schemas: None,
1249 bypass_permissions: true,
1250 can_async_resume: false,
1251 bash_completion_sink: None,
1252 pre_parsed_args: None,
1253 };
1254
1255 let result = executor.execute_with_context(&call, ctx).await;
1256 assert!(
1257 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1258 "explicit deny must beat bypass: {result:?}"
1259 );
1260 assert!(!path.exists());
1261 }
1262
1263 #[tokio::test]
1264 async fn test_explicit_delete_deny_overrides_bypass() {
1265 let config = Arc::new(crate::permission::PermissionConfig::new());
1266 config.add_rule(crate::permission::PermissionRule::new(
1267 crate::permission::PermissionType::DeleteOperation,
1268 "rm child-to-preserve",
1269 false,
1270 ));
1271 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1272 let executor = BuiltinToolExecutorBuilder::new()
1273 .with_tool(BashTool::new())
1274 .expect("register Bash tool")
1275 .with_permission_checker(checker)
1276 .build();
1277 let call = make_tool_call("Bash", json!({"command": "rm child-to-preserve"}));
1278 let ctx = ToolExecutionContext {
1279 session_id: Some("s-explicit-delete-deny"),
1280 tool_call_id: &call.id,
1281 event_tx: None,
1282 available_tool_schemas: None,
1283 bypass_permissions: true,
1284 can_async_resume: false,
1285 bash_completion_sink: None,
1286 pre_parsed_args: None,
1287 };
1288
1289 let result = executor.execute_with_context(&call, ctx).await;
1290 assert!(
1291 matches!(result, Err(ToolError::Execution(ref message)) if message.contains("explicit policy")),
1292 "explicit delete deny must beat bypass: {result:?}"
1293 );
1294 }
1295
1296 #[tokio::test]
1297 async fn interactive_gate_returns_synthesized_approval_pause() {
1298 let config = Arc::new(crate::permission::PermissionConfig::new());
1306 config.set_ask_rules(["Write(/etc/**)".to_string()]);
1307 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1308 let executor = make_executor(Some(checker));
1309
1310 let (tx, mut rx) = mpsc::channel(8);
1311 let call = make_tool_call(
1312 "Write",
1313 json!({"file_path": "/etc/gated.conf", "content": "x"}),
1314 );
1315 let ctx = ToolExecutionContext {
1316 session_id: Some("s-interactive"),
1317 tool_call_id: &call.id,
1318 event_tx: Some(&tx),
1319 available_tool_schemas: None,
1320 bypass_permissions: false,
1321 can_async_resume: false,
1322 bash_completion_sink: None,
1323 pre_parsed_args: None,
1324 };
1325
1326 let result = executor
1327 .execute_with_context(&call, ctx)
1328 .await
1329 .expect("interactive gate should pause (Ok), not error");
1330
1331 assert_eq!(
1332 result.display_preference.as_deref(),
1333 Some("request_permissions"),
1334 "interactive gate must return the request_permissions pause result"
1335 );
1336 assert!(result.result.contains("awaiting_permission_approval"));
1337 let payload: serde_json::Value = serde_json::from_str(&result.result).expect("payload");
1338 let request = &payload["permission_request"];
1339 assert_eq!(request["request_id"], call.id);
1340 assert_eq!(request["session_id"], "s-interactive");
1341 assert_eq!(request["reason_code"], "configured_always_ask");
1342 assert_eq!(
1343 request["allowed_decisions"],
1344 json!(["allow_once", "deny_once"])
1345 );
1346 assert_eq!(payload["options"], json!(["Approve", "Deny"]));
1347 assert!(fs::metadata("/etc/gated.conf").await.is_err());
1348
1349 let ev = rx.recv().await.expect("approval event should be emitted");
1350 assert!(
1351 matches!(ev, AgentEvent::ToolApprovalRequested { tool_name, .. } if tool_name == "Write")
1352 );
1353 }
1354
1355 #[tokio::test]
1356 async fn check_permissions_for_returns_none_when_permitted() {
1357 let executor = make_executor(None);
1360 let call = make_tool_call("Read", json!({"file_path": "/tmp/whatever"}));
1361 let ctx = ToolExecutionContext::none(&call.id);
1362 let decision = executor
1363 .check_permissions_for(&call, &ctx)
1364 .await
1365 .expect("no checker means no gate");
1366 assert!(decision.is_none(), "no checker must yield Ok(None)");
1367 }
1368
1369 struct HostStub {
1372 approve: bool,
1373 }
1374
1375 #[async_trait]
1376 impl crate::approval::ApprovalProxy for HostStub {
1377 async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
1378 self.approve
1379 }
1380 }
1381
1382 #[tokio::test]
1383 async fn approval_proxy_grant_lets_gated_tool_proceed() {
1384 let dir = tempfile::tempdir().unwrap();
1389 let path = dir.path().join("approved.txt");
1390 let path_str = path.to_str().unwrap().to_string();
1391 let config = Arc::new(crate::permission::PermissionConfig::new());
1392 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1393 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1394 let executor = make_executor(Some(checker));
1395
1396 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1397 let ctx = ToolExecutionContext {
1398 session_id: Some("s-worker"),
1399 tool_call_id: &call.id,
1400 event_tx: None,
1401 available_tool_schemas: None,
1402 bypass_permissions: false,
1403 can_async_resume: false,
1404 bash_completion_sink: None,
1405 pre_parsed_args: None,
1406 };
1407
1408 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
1409 let result = crate::approval::with_approval_proxy(
1410 Some(proxy),
1411 executor.execute_with_context(&call, ctx),
1412 )
1413 .await;
1414
1415 assert!(
1416 result.is_ok(),
1417 "host grant should let the write through: {result:?}"
1418 );
1419 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1420 }
1421
1422 #[tokio::test]
1423 async fn approval_proxy_deny_fails_gated_tool_closed() {
1424 let dir = tempfile::tempdir().unwrap();
1427 let path = dir.path().join("denied.txt");
1428 let path_str = path.to_str().unwrap().to_string();
1429 let config = Arc::new(crate::permission::PermissionConfig::new());
1430 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1431 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1432 let executor = make_executor(Some(checker));
1433
1434 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
1435 let ctx = ToolExecutionContext {
1436 session_id: Some("s-worker"),
1437 tool_call_id: &call.id,
1438 event_tx: None,
1439 available_tool_schemas: None,
1440 bypass_permissions: false,
1441 can_async_resume: false,
1442 bash_completion_sink: None,
1443 pre_parsed_args: None,
1444 };
1445
1446 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
1447 let result = crate::approval::with_approval_proxy(
1448 Some(proxy),
1449 executor.execute_with_context(&call, ctx),
1450 )
1451 .await;
1452
1453 assert!(
1454 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
1455 "host deny should fail the tool closed: {result:?}"
1456 );
1457 assert!(fs::metadata(&path).await.is_err());
1458 }
1459
1460 #[tokio::test]
1461 async fn tool_can_stream_events_via_execute_with_context() {
1462 struct StreamingTool;
1463
1464 #[async_trait]
1465 impl Tool for StreamingTool {
1466 fn name(&self) -> &str {
1467 "streaming_tool"
1468 }
1469
1470 fn description(&self) -> &str {
1471 "streams one token"
1472 }
1473
1474 fn parameters_schema(&self) -> serde_json::Value {
1475 json!({"type":"object","properties":{}})
1476 }
1477
1478 async fn invoke(
1479 &self,
1480 _args: serde_json::Value,
1481 ctx: ToolCtx,
1482 ) -> Result<ToolOutcome, ToolError> {
1483 ctx.emit(AgentEvent::Token {
1484 content: "stream".to_string(),
1485 })
1486 .await;
1487 Ok(ToolOutcome::Completed(ToolResult {
1488 success: true,
1489 result: "ok".to_string(),
1490 display_preference: None,
1491 images: Vec::new(),
1492 }))
1493 }
1494 }
1495
1496 let executor = BuiltinToolExecutor::new();
1497 executor
1498 .register_tool(StreamingTool)
1499 .expect("register streaming tool");
1500
1501 let (tx, mut rx) = mpsc::channel(8);
1502 let call = make_tool_call("streaming_tool", json!({}));
1503
1504 let result = executor
1505 .execute_with_context(
1506 &call,
1507 ToolExecutionContext {
1508 session_id: Some("s1"),
1509 tool_call_id: &call.id,
1510 event_tx: Some(&tx),
1511 available_tool_schemas: None,
1512 bypass_permissions: false,
1513 can_async_resume: false,
1514 bash_completion_sink: None,
1515 pre_parsed_args: None,
1516 },
1517 )
1518 .await
1519 .expect("execute tool");
1520
1521 assert!(result.success);
1522 assert_eq!(result.result, "ok");
1523
1524 let ev = rx.recv().await.expect("expected streamed event");
1525 assert!(
1526 matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
1527 );
1528 }
1529
1530 #[tokio::test]
1531 async fn removed_legacy_tools_return_not_found() {
1532 let executor = BuiltinToolExecutor::new();
1533
1534 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1535 let call = make_tool_call(legacy, json!({}));
1536 let result = executor.execute(&call).await;
1537 assert!(matches!(result, Err(ToolError::NotFound(_))));
1538 }
1539 }
1540
1541 #[tokio::test]
1542 async fn executor_prefers_exact_tool_name_before_builtin_alias() {
1543 struct CustomSpawnSessionTool;
1544
1545 #[async_trait]
1546 impl Tool for CustomSpawnSessionTool {
1547 fn name(&self) -> &str {
1548 "spawn_session"
1549 }
1550
1551 fn description(&self) -> &str {
1552 "custom tool for regression coverage"
1553 }
1554
1555 fn parameters_schema(&self) -> serde_json::Value {
1556 json!({"type":"object","properties":{}})
1557 }
1558
1559 async fn invoke(
1560 &self,
1561 _args: serde_json::Value,
1562 _ctx: ToolCtx,
1563 ) -> Result<ToolOutcome, ToolError> {
1564 Ok(ToolOutcome::Completed(ToolResult {
1565 success: true,
1566 result: "custom-spawn-session".to_string(),
1567 display_preference: None,
1568 images: Vec::new(),
1569 }))
1570 }
1571 }
1572
1573 let executor = BuiltinToolExecutorBuilder::new()
1574 .with_tool(CustomSpawnSessionTool)
1575 .expect("register custom spawn_session tool")
1576 .build();
1577
1578 let call = make_tool_call("spawn_session", json!({}));
1579 let result = executor.execute(&call).await.expect("execute custom tool");
1580 assert!(result.success);
1581 assert_eq!(result.result, "custom-spawn-session");
1582 }
1583
1584 struct EchoArgsTool;
1589
1590 #[async_trait]
1591 impl Tool for EchoArgsTool {
1592 fn name(&self) -> &str {
1593 "echo_args"
1594 }
1595 fn description(&self) -> &str {
1596 "echoes the `v` arg"
1597 }
1598 fn parameters_schema(&self) -> serde_json::Value {
1599 json!({"type":"object","properties":{"v":{"type":"string"}}})
1600 }
1601 async fn invoke(
1602 &self,
1603 args: serde_json::Value,
1604 _ctx: ToolCtx,
1605 ) -> Result<ToolOutcome, ToolError> {
1606 let v = args
1607 .get("v")
1608 .and_then(serde_json::Value::as_str)
1609 .unwrap_or("<none>")
1610 .to_string();
1611 Ok(ToolOutcome::Completed(ToolResult {
1612 success: true,
1613 result: v,
1614 display_preference: None,
1615 images: Vec::new(),
1616 }))
1617 }
1618 }
1619
1620 fn ctx_with_pre_parsed<'a>(
1621 call_id: &'a str,
1622 pre_parsed: Option<&'a serde_json::Value>,
1623 ) -> ToolExecutionContext<'a> {
1624 ToolExecutionContext {
1625 session_id: Some("s-106"),
1626 tool_call_id: call_id,
1627 event_tx: None,
1628 available_tool_schemas: None,
1629 bypass_permissions: false,
1630 can_async_resume: false,
1631 bash_completion_sink: None,
1632 pre_parsed_args: pre_parsed,
1633 }
1634 }
1635
1636 #[tokio::test]
1637 async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
1638 let executor = BuiltinToolExecutor::new();
1644 executor.register_tool(EchoArgsTool).expect("register echo");
1645
1646 let call = make_tool_call("echo_args", json!({"v": "raw"}));
1647 let pre_parsed = json!({"v": "preparsed"});
1648 let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
1649
1650 let result = executor
1651 .execute_with_context(&call, ctx)
1652 .await
1653 .expect("execute echo tool");
1654 assert_eq!(
1655 result.result, "preparsed",
1656 "executor must reuse pre_parsed_args, not re-parse the raw string"
1657 );
1658 }
1659
1660 #[tokio::test]
1661 async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
1662 let executor = BuiltinToolExecutor::new();
1666 executor.register_tool(EchoArgsTool).expect("register echo");
1667
1668 let call = make_tool_call("echo_args", json!({"v": "raw"}));
1669 let ctx = ctx_with_pre_parsed(&call.id, None);
1670
1671 let result = executor
1672 .execute_with_context(&call, ctx)
1673 .await
1674 .expect("execute echo tool");
1675 assert_eq!(
1676 result.result, "raw",
1677 "without pre_parsed_args the executor parses the raw string as before"
1678 );
1679 }
1680
1681 #[tokio::test]
1682 async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
1683 let dir = tempfile::tempdir().unwrap();
1687 let path = dir.path().join("recovered-no-preparsed.txt");
1688 let malformed_args = format!(
1689 r#"{{"file_path":"{}","content":"recovered content""#,
1690 path.display()
1691 );
1692
1693 let executor = BuiltinToolExecutor::new();
1694 let call = make_tool_call_with_raw_args("Write", &malformed_args);
1695 let ctx = ctx_with_pre_parsed(&call.id, None);
1696
1697 let result = executor
1698 .execute_with_context(&call, ctx)
1699 .await
1700 .expect("truncated JSON should be auto-repaired");
1701 assert!(result.success);
1702 let written = fs::read_to_string(&path).await.expect("file written");
1703 assert_eq!(written, "recovered content");
1704 }
1705}