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 ctx.bypass_permissions && !force_ask {
381 continue;
382 }
383 let resource = context.resource.clone();
384 let decision = if force_ask {
387 permission_checker.check_or_request_forced(context).await
388 } else {
389 permission_checker.check_or_request(context).await
390 };
391 match decision {
392 Ok(true) => {}
393 Ok(false) => {
394 return Err(ToolError::Execution(format!(
395 "Permission denied for: {}",
396 resource
397 )));
398 }
399 Err(PermissionError::ConfirmationRequired {
400 permission_type,
401 resource: _,
402 }) => {
403 if let Some(proxy) = crate::approval::current_approval_proxy() {
414 let approved = proxy
415 .request_approval(crate::approval::ApprovalAsk {
416 tool_name: tool_name.clone(),
417 permission: permission_type.description().to_string(),
418 resource: resource.clone(),
419 })
420 .await;
421 if approved {
422 continue;
425 }
426 return Err(ToolError::Execution(format!(
427 "Permission denied by host for: {}",
428 resource
429 )));
430 }
431
432 if let Some(tx) = ctx.event_tx {
439 let _ = tx
441 .send(bamboo_agent_core::AgentEvent::ToolApprovalRequested {
442 tool_call_id: call.id.clone(),
443 tool_name: tool_name.clone(),
444 parameters: args.clone(),
445 })
446 .await;
447
448 let question = format!(
449 "**Permission required**\n\nThe `{}` tool needs approval to {} on:\n\n`{}`",
450 tool_name,
451 permission_type.description(),
452 resource
453 );
454 let payload = serde_json::json!({
455 "status": "awaiting_permission_approval",
456 "question": question,
457 "permission_type": permission_type,
458 "resource": resource,
459 "options": ["Approve", "Deny"],
460 "allow_custom": false,
461 });
462 return Ok(Some(ToolOutcome::Completed(ToolResult {
466 success: true,
467 result: payload.to_string(),
468 display_preference: Some("request_permissions".to_string()),
469 images: Vec::new(),
470 })));
471 }
472
473 return Err(ToolError::Execution(format!(
476 "Permission approval required for: {}",
477 resource
478 )));
479 }
480 Err(other) => {
481 return Err(permission_error_to_tool_error(other));
482 }
483 }
484 }
485 }
486
487 Ok(None)
488 }
489
490 fn list_tools(&self) -> Vec<ToolSchema> {
491 self.registry.list_tools()
492 }
493
494 fn tool_mutability(&self, tool_name: &str) -> crate::ToolMutability {
495 self.registry
496 .get(tool_name)
497 .map(|tool| tool.classify(&serde_json::Value::Null).mutability)
498 .unwrap_or_else(|| crate::classify_tool(tool_name))
499 }
500
501 fn call_mutability(&self, call: &ToolCall) -> crate::ToolMutability {
502 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
503 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
504 self.registry
505 .get(&canonical)
506 .map(|tool| tool.classify(&args).mutability)
507 .unwrap_or_else(|| self.tool_mutability(&canonical))
508 }
509
510 fn tool_concurrency_safe(&self, tool_name: &str) -> bool {
511 let canonical = resolve_registered_tool_name(&self.registry, tool_name);
512 self.registry
513 .get(&canonical)
514 .map(|tool| tool.classify(&serde_json::Value::Null).parallel_safe)
515 .unwrap_or_else(|| self.tool_mutability(&canonical) == crate::ToolMutability::ReadOnly)
516 }
517
518 fn call_concurrency_safe(&self, call: &ToolCall) -> bool {
519 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
520 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
521 self.registry
522 .get(&canonical)
523 .map(|tool| tool.classify(&args).parallel_safe)
524 .unwrap_or_else(|| self.tool_concurrency_safe(&canonical))
525 }
526
527 fn call_parallel_classification(&self, call: &ToolCall) -> (crate::ToolMutability, bool) {
528 let canonical = resolve_registered_tool_name(&self.registry, call.function.name.trim());
532 let args = bamboo_agent_core::parse_tool_args_best_effort(&call.function.arguments).0;
533 match self.registry.get(&canonical) {
534 Some(tool) => {
535 let class = tool.classify(&args);
536 (class.mutability, class.parallel_safe)
537 }
538 None => (
539 self.tool_mutability(&canonical),
540 self.tool_concurrency_safe(&canonical),
541 ),
542 }
543 }
544}
545
546pub struct BuiltinToolExecutorBuilder {
548 registry: ToolRegistry,
549 permission_checker: Option<Arc<dyn PermissionChecker>>,
550}
551
552impl BuiltinToolExecutorBuilder {
553 pub fn new() -> Self {
555 Self {
556 registry: ToolRegistry::new(),
557 permission_checker: None,
558 }
559 }
560
561 pub fn with_default_tools(self) -> Self {
563 BuiltinToolExecutor::register_builtin_tools(&self.registry, None);
564 self
565 }
566
567 pub fn with_filesystem_tool(self, name: &str) -> Result<Self, ToolError> {
569 match name {
570 "Read" => self.registry.register(ReadTool::new()),
571 "Write" => self.registry.register(WriteTool::new()),
572 "Edit" | "apply_patch" => self.registry.register(EditTool::new()),
574 "NotebookEdit" => self.registry.register(NotebookEditTool::new()),
575 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
576 }
577 .map_err(|e| ToolError::Execution(e.to_string()))?;
578 Ok(self)
579 }
580
581 pub fn with_command_tool(self, name: &str) -> Result<Self, ToolError> {
583 match name {
584 "Bash" => self.registry.register(BashTool::new()),
585 "BashOutput" => self.registry.register(BashOutputTool::new()),
586 "KillShell" => self.registry.register(KillShellTool::new()),
587 "Task" => self.registry.register(TaskTool::new()),
588 _ => return Err(ToolError::NotFound(format!("Unknown tool: {}", name))),
589 }
590 .map_err(|e| ToolError::Execution(e.to_string()))?;
591 Ok(self)
592 }
593
594 pub fn with_tool<T: Tool + 'static>(self, tool: T) -> Result<Self, ToolError> {
596 self.registry
597 .register(tool)
598 .map_err(|e| ToolError::Execution(e.to_string()))?;
599 Ok(self)
600 }
601
602 pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
604 self.permission_checker = Some(checker);
605 self
606 }
607
608 pub fn build(self) -> BuiltinToolExecutor {
610 BuiltinToolExecutor {
611 registry: self.registry,
612 permission_checker: self.permission_checker,
613 }
614 }
615}
616
617impl Default for BuiltinToolExecutorBuilder {
618 fn default() -> Self {
619 Self::new()
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626 use bamboo_agent_core::AgentEvent;
627 use bamboo_agent_core::FunctionCall;
628 use bamboo_agent_core::ToolCtx;
629 use bamboo_agent_core::ToolExecutionContext;
630 use bamboo_domain::tool_names::{normalize_tool_ref, BUILTIN_TOOL_NAMES};
631 use serde_json::json;
632 use std::sync::Arc;
633 use tokio::fs;
634 use tokio::sync::mpsc;
635
636 use crate::tools::WriteTool;
637
638 fn make_tool_call(name: &str, args: serde_json::Value) -> ToolCall {
639 ToolCall {
640 id: "call_1".to_string(),
641 tool_type: "function".to_string(),
642 function: FunctionCall {
643 name: name.to_string(),
644 arguments: args.to_string(),
645 },
646 }
647 }
648
649 fn make_tool_call_with_raw_args(name: &str, raw_args: &str) -> ToolCall {
650 ToolCall {
651 id: "call_1".to_string(),
652 tool_type: "function".to_string(),
653 function: FunctionCall {
654 name: name.to_string(),
655 arguments: raw_args.to_string(),
656 },
657 }
658 }
659
660 fn make_executor(
661 permission_checker: Option<Arc<dyn PermissionChecker>>,
662 ) -> BuiltinToolExecutor {
663 let builder = BuiltinToolExecutorBuilder::new()
664 .with_tool(WriteTool::new())
665 .expect("register Write tool");
666
667 let builder = match permission_checker {
668 Some(checker) => builder.with_permission_checker(checker),
669 None => builder,
670 };
671
672 builder.build()
673 }
674
675 #[test]
676 fn test_normalize_tool_ref_accepts_claude_style_names() {
677 assert_eq!(
678 normalize_tool_ref("default::Bash"),
679 Some("Bash".to_string())
680 );
681 }
682
683 #[test]
684 fn test_normalize_tool_ref_accepts_legacy_camel_aliases() {
685 assert_eq!(
686 normalize_tool_ref("default::fileExists"),
687 Some("FileExists".to_string())
688 );
689 assert_eq!(
690 normalize_tool_ref("default::getCurrentDir"),
691 Some("GetCurrentDir".to_string())
692 );
693 assert_eq!(
694 normalize_tool_ref("default::getFileInfo"),
695 Some("GetFileInfo".to_string())
696 );
697 assert_eq!(
698 normalize_tool_ref("default::setWorkspace"),
699 Some("SetWorkspace".to_string())
700 );
701 assert_eq!(
702 normalize_tool_ref("default::sleep"),
703 Some("Sleep".to_string())
704 );
705 }
706
707 #[test]
708 fn test_normalize_tool_ref_accepts_legacy_snake_case_aliases() {
709 assert_eq!(
710 normalize_tool_ref("default::execute_command"),
711 Some("Bash".to_string())
712 );
713 assert_eq!(
714 normalize_tool_ref("default::file_exists"),
715 Some("FileExists".to_string())
716 );
717 assert_eq!(
718 normalize_tool_ref("default::get_current_dir"),
719 Some("GetCurrentDir".to_string())
720 );
721 assert_eq!(
722 normalize_tool_ref("default::get_file_info"),
723 Some("GetFileInfo".to_string())
724 );
725 assert_eq!(
726 normalize_tool_ref("default::list_directory"),
727 Some("Glob".to_string())
728 );
729 assert_eq!(
730 normalize_tool_ref("default::memory_note"),
731 Some("memory_note".to_string())
732 );
733 assert_eq!(
734 normalize_tool_ref("default::read_file"),
735 Some("Read".to_string())
736 );
737 assert_eq!(
738 normalize_tool_ref("default::set_workspace"),
739 Some("SetWorkspace".to_string())
740 );
741 assert_eq!(
742 normalize_tool_ref("default::write_file"),
743 Some("Write".to_string())
744 );
745 }
746
747 #[test]
748 fn test_normalize_tool_ref_accepts_spawn_task_aliases() {
749 for alias in [
750 "default::spawn_session",
751 "default::sub_session",
752 "default::sub_task",
753 "default::team_agent",
754 "default::child_session",
755 ] {
756 assert_eq!(normalize_tool_ref(alias), Some("SubAgent".to_string()));
757 }
758 }
759
760 #[test]
761 fn test_normalize_tool_ref_accepts_server_overlay_tools() {
762 assert_eq!(normalize_tool_ref("compress_context"), None);
763 assert_eq!(
764 normalize_tool_ref("default::read_skill_resource"),
765 Some("read_skill_resource".to_string())
766 );
767 }
768
769 #[tokio::test]
770 async fn test_executor_accepts_legacy_read_file_path_argument() {
771 let dir = tempfile::tempdir().unwrap();
772 let file_path = dir.path().join("legacy-read.txt");
773 fs::write(&file_path, "legacy read content").await.unwrap();
774
775 let executor = BuiltinToolExecutor::new();
776 let call = make_tool_call("read_file", json!({"path": file_path}));
777
778 let result = executor.execute(&call).await.unwrap();
779 assert!(result.success);
780 assert!(result.result.contains("legacy read content"));
781 }
782
783 #[tokio::test]
784 async fn test_executor_accepts_legacy_list_directory_without_pattern() {
785 let dir = tempfile::tempdir().unwrap();
786 let file_path = dir.path().join("legacy-list.txt");
787 fs::write(&file_path, "legacy list content").await.unwrap();
788
789 let executor = BuiltinToolExecutor::new();
790 let call = make_tool_call("list_directory", json!({"path": dir.path()}));
791
792 let result = executor.execute(&call).await.unwrap();
793 assert!(result.success);
794 assert!(result.result.contains("legacy-list.txt"));
795 }
796
797 #[tokio::test]
798 async fn test_executor_accepts_canonical_read_with_path_argument() {
799 let dir = tempfile::tempdir().unwrap();
800 let file_path = dir.path().join("canonical-read.txt");
801 fs::write(&file_path, "canonical read content")
802 .await
803 .unwrap();
804
805 let executor = BuiltinToolExecutor::new();
806 let call = make_tool_call("Read", json!({"path": file_path}));
807
808 let result = executor.execute(&call).await.unwrap();
809 assert!(result.success);
810 assert!(result.result.contains("canonical read content"));
811 }
812
813 #[tokio::test]
814 async fn test_executor_accepts_canonical_glob_without_pattern_when_path_present() {
815 let dir = tempfile::tempdir().unwrap();
816 let file_path = dir.path().join("canonical-list.txt");
817 fs::write(&file_path, "canonical list content")
818 .await
819 .unwrap();
820
821 let executor = BuiltinToolExecutor::new();
822 let call = make_tool_call("Glob", json!({"path": dir.path()}));
823
824 let result = executor.execute(&call).await.unwrap();
825 assert!(result.success);
826 assert!(result.result.contains("canonical-list.txt"));
827 }
828
829 #[test]
830 fn test_executor_workspace_mutability_depends_on_path_argument() {
831 let executor = BuiltinToolExecutor::new();
832 let get_call = make_tool_call("Workspace", json!({}));
833 let set_call = make_tool_call("Workspace", json!({"path": "/tmp"}));
834
835 assert_eq!(
836 executor.call_mutability(&get_call),
837 crate::ToolMutability::ReadOnly
838 );
839 assert!(executor.call_concurrency_safe(&get_call));
840
841 assert_eq!(
842 executor.call_mutability(&set_call),
843 crate::ToolMutability::Mutating
844 );
845 assert!(!executor.call_concurrency_safe(&set_call));
846 }
847
848 #[test]
849 fn call_parallel_classification_matches_individual_methods() {
850 let executor = BuiltinToolExecutor::new();
858 let cases: &[(&str, serde_json::Value)] = &[
859 ("Read", json!({})),
860 ("Grep", json!({"pattern": "x"})),
861 (
862 "Write",
863 json!({"file_path": "/tmp/par_cls.txt", "content": "y"}),
864 ),
865 ("Bash", json!({"command": "echo hi"})),
866 ("Workspace", json!({})),
867 ("Workspace", json!({"path": "/tmp"})),
868 ];
869
870 for (name, args) in cases {
871 let call = make_tool_call(name, args.clone());
872 let expected_mutability = executor.call_mutability(&call);
873 let expected_concurrency = executor.call_concurrency_safe(&call);
874 let (mutability, concurrency) = executor.call_parallel_classification(&call);
875 assert_eq!(
876 mutability, expected_mutability,
877 "mutability mismatch for {name} ({args})"
878 );
879 assert_eq!(
880 concurrency, expected_concurrency,
881 "concurrency mismatch for {name} ({args})"
882 );
883 }
884 }
885
886 #[test]
887 fn list_tools_snapshot_is_stable_across_calls() {
888 let executor = BuiltinToolExecutor::new();
893 let first: Vec<String> = executor
894 .list_tools()
895 .into_iter()
896 .map(|s| s.function.name)
897 .collect();
898 let second: Vec<String> = executor
899 .list_tools()
900 .into_iter()
901 .map(|s| s.function.name)
902 .collect();
903 assert!(!first.is_empty(), "builtin executor should expose tools");
904 assert_eq!(
905 first, second,
906 "list_tools() must be deterministic per round"
907 );
908 }
909
910 #[tokio::test]
911 async fn test_executor_recovers_truncated_json_arguments() {
912 let dir = tempfile::tempdir().unwrap();
913 let path = dir.path().join("recovered-write.txt");
914
915 let malformed_args = format!(
917 r#"{{"file_path":"{}","content":"recovered content""#,
918 path.display()
919 );
920
921 let executor = BuiltinToolExecutor::new();
922 let call = make_tool_call_with_raw_args("Write", &malformed_args);
923
924 let result = executor
925 .execute(&call)
926 .await
927 .expect("truncated JSON should be auto-repaired");
928 assert!(result.success);
929
930 let written = fs::read_to_string(&path)
931 .await
932 .expect("file should be written");
933 assert_eq!(written, "recovered content");
934 }
935
936 #[test]
937 fn test_normalize_tool_ref_rejects_unknown_tool() {
938 assert_eq!(normalize_tool_ref("default::search"), None);
939 }
940
941 #[test]
942 fn test_executor_does_not_expose_legacy_tools() {
943 let executor = BuiltinToolExecutor::new();
944 let tool_names: Vec<String> = executor
945 .list_tools()
946 .into_iter()
947 .map(|schema| schema.function.name)
948 .collect();
949
950 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
951 assert!(!tool_names.iter().any(|name| name == legacy));
952 }
953 }
954
955 #[test]
956 fn test_critical_tool_schemas_match_claude_shapes() {
957 let executor = BuiltinToolExecutor::new();
958 let tools = executor.list_tools();
959
960 let get_params = |name: &str| {
961 tools
962 .iter()
963 .find(|tool| tool.function.name == name)
964 .unwrap()
965 .function
966 .parameters
967 .clone()
968 };
969
970 let grep = get_params("Grep");
971 assert_eq!(grep["required"], json!(["pattern"]));
972 assert_eq!(
973 grep["properties"]["output_mode"]["enum"],
974 json!(["content", "files_with_matches", "count"])
975 );
976 assert!(grep["properties"]["-A"].is_object());
977 assert!(grep["properties"]["-B"].is_object());
978 assert!(grep["properties"]["-C"].is_object());
979 assert!(grep["properties"]["-n"].is_object());
980 assert!(grep["properties"]["-i"].is_object());
981
982 let edit = get_params("Edit");
983 assert_eq!(edit["required"], json!(["file_path"]));
984 assert_eq!(edit["properties"]["old_string"]["type"], "string");
985 assert_eq!(edit["properties"]["new_string"]["type"], "string");
986 assert_eq!(edit["properties"]["patch"]["type"], "string");
987 assert_eq!(edit["properties"]["replace_all"]["type"], "boolean");
988 assert!(edit.get("oneOf").is_none());
989
990 assert_eq!(edit["properties"]["patch"]["type"], "string");
993 assert_eq!(edit["properties"]["line_number"]["type"], "integer");
994
995 let bash = get_params("Bash");
996 assert_eq!(bash["required"], json!(["command"]));
997 assert_eq!(bash["properties"]["run_in_background"]["type"], "boolean");
998 assert_eq!(bash["properties"]["workdir"]["type"], "string");
999
1000 let bash_output = get_params("BashOutput");
1001 assert_eq!(bash_output["required"], json!(["bash_id"]));
1002 assert_eq!(bash_output["properties"]["filter"]["type"], "string");
1003 }
1004
1005 #[test]
1006 fn test_tool_schemas_avoid_openai_forbidden_top_level_keywords() {
1007 let executor = BuiltinToolExecutor::new();
1008 let tools = executor.list_tools();
1009 let forbidden = ["oneOf", "anyOf", "allOf", "not", "enum"];
1010
1011 for tool in tools {
1012 let params = &tool.function.parameters;
1013 assert_eq!(
1014 params["type"], "object",
1015 "tool '{}' parameters must be a top-level object schema",
1016 tool.function.name
1017 );
1018 for key in forbidden {
1019 assert!(
1020 params.get(key).is_none(),
1021 "tool '{}' parameters contains forbidden top-level keyword '{}'",
1022 tool.function.name,
1023 key
1024 );
1025 }
1026 }
1027 }
1028
1029 #[test]
1030 fn test_executor_has_all_builtin_tools() {
1031 let executor = BuiltinToolExecutor::new();
1032 let tools = executor.list_tools();
1033
1034 assert_eq!(tools.len(), BUILTIN_TOOL_NAMES.len());
1035
1036 let tool_names: Vec<String> = tools.iter().map(|t| t.function.name.clone()).collect();
1037 for tool_name in BUILTIN_TOOL_NAMES {
1038 assert!(tool_names.contains(&tool_name.to_string()));
1039 }
1040 }
1041
1042 #[test]
1043 fn test_executor_builds_enhanced_prompt() {
1044 let executor = BuiltinToolExecutor::new();
1045 let prompt = executor.build_enhanced_prompt(GuideBuildContext::default());
1046 assert!(prompt.contains("## Tool Usage Guidelines"));
1047 assert!(prompt.contains("**Read**"));
1048 }
1049
1050 #[test]
1051 fn test_executor_builder_empty() {
1052 let executor = BuiltinToolExecutorBuilder::new().build();
1053 assert!(executor.list_tools().is_empty());
1054 }
1055
1056 #[test]
1057 fn test_executor_builder_with_default_tools() {
1058 let executor = BuiltinToolExecutorBuilder::new()
1059 .with_default_tools()
1060 .build();
1061 assert_eq!(executor.list_tools().len(), BUILTIN_TOOL_NAMES.len());
1062 }
1063
1064 #[test]
1065 fn test_executor_builder_with_specific_tool() {
1066 let executor = BuiltinToolExecutorBuilder::new()
1067 .with_filesystem_tool("Read")
1068 .unwrap()
1069 .build();
1070
1071 let tools = executor.list_tools();
1072 assert_eq!(tools.len(), 1);
1073 assert_eq!(tools[0].function.name, "Read");
1074 }
1075
1076 #[tokio::test]
1077 async fn test_executor_skips_permission_checks_without_checker() {
1078 let executor = make_executor(None);
1079 let path = "/tmp/executor_permission_none.txt";
1080 let _ = fs::remove_file(path).await;
1081
1082 let call = make_tool_call("Write", json!({"file_path": path, "content": "ok"}));
1083 let result = executor.execute(&call).await.expect("execute tool");
1084
1085 assert!(result.success);
1086 let _ = fs::remove_file(path).await;
1087 }
1088
1089 #[tokio::test]
1090 async fn test_executor_with_permission_checker_enforces_checks() {
1091 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1092 let executor = make_executor(Some(checker));
1093 let path = "/tmp/executor_permission_denied.txt";
1094 let _ = fs::remove_file(path).await;
1095
1096 let call = make_tool_call("Write", json!({"file_path": path, "content": "nope"}));
1097 let result = executor.execute(&call).await;
1098
1099 assert!(matches!(result, Err(ToolError::Execution(_))));
1100 assert!(fs::metadata(path).await.is_err());
1101 }
1102
1103 #[tokio::test]
1104 async fn test_bypass_permissions_skips_checker() {
1105 let checker = Arc::new(crate::permission::DenyDangerousPermissionChecker);
1108 let executor = make_executor(Some(checker));
1109 let dir = tempfile::tempdir().unwrap();
1110 let path = dir.path().join("bypass_allows_write.txt");
1111 let path_str = path.to_str().unwrap();
1112
1113 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1114 let ctx = ToolExecutionContext {
1115 session_id: Some("s-bypass"),
1116 tool_call_id: &call.id,
1117 event_tx: None,
1118 available_tool_schemas: None,
1119 bypass_permissions: true,
1120 can_async_resume: false,
1121 bash_completion_sink: None,
1122 pre_parsed_args: None,
1123 };
1124 let result = executor.execute_with_context(&call, ctx).await;
1125
1126 assert!(result.is_ok(), "bypass should allow the write: {result:?}");
1127 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1128 }
1129
1130 #[tokio::test]
1131 async fn test_forced_ask_rule_overrides_bypass() {
1132 let config = Arc::new(crate::permission::PermissionConfig::new());
1136 config.set_ask_rules(["Write(/etc/**)".to_string()]);
1137 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1138 let executor = make_executor(Some(checker));
1139
1140 let call = make_tool_call(
1141 "Write",
1142 json!({"file_path": "/etc/forced.conf", "content": "x"}),
1143 );
1144 let ctx = ToolExecutionContext {
1145 session_id: Some("s-forced"),
1146 tool_call_id: &call.id,
1147 event_tx: None,
1148 available_tool_schemas: None,
1149 bypass_permissions: true,
1150 can_async_resume: false,
1151 bash_completion_sink: None,
1152 pre_parsed_args: None,
1153 };
1154 let result = executor.execute_with_context(&call, ctx).await;
1155
1156 assert!(
1157 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("approval required")),
1158 "forced ask rule should block under bypass: {result:?}"
1159 );
1160 assert!(fs::metadata("/etc/forced.conf").await.is_err());
1161 }
1162
1163 #[tokio::test]
1164 async fn interactive_gate_returns_synthesized_approval_pause() {
1165 let config = Arc::new(crate::permission::PermissionConfig::new());
1173 config.set_ask_rules(["Write(/etc/**)".to_string()]);
1174 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1175 let executor = make_executor(Some(checker));
1176
1177 let (tx, mut rx) = mpsc::channel(8);
1178 let call = make_tool_call(
1179 "Write",
1180 json!({"file_path": "/etc/gated.conf", "content": "x"}),
1181 );
1182 let ctx = ToolExecutionContext {
1183 session_id: Some("s-interactive"),
1184 tool_call_id: &call.id,
1185 event_tx: Some(&tx),
1186 available_tool_schemas: None,
1187 bypass_permissions: false,
1188 can_async_resume: false,
1189 bash_completion_sink: None,
1190 pre_parsed_args: None,
1191 };
1192
1193 let result = executor
1194 .execute_with_context(&call, ctx)
1195 .await
1196 .expect("interactive gate should pause (Ok), not error");
1197
1198 assert_eq!(
1199 result.display_preference.as_deref(),
1200 Some("request_permissions"),
1201 "interactive gate must return the request_permissions pause result"
1202 );
1203 assert!(result.result.contains("awaiting_permission_approval"));
1204 assert!(fs::metadata("/etc/gated.conf").await.is_err());
1205
1206 let ev = rx.recv().await.expect("approval event should be emitted");
1207 assert!(
1208 matches!(ev, AgentEvent::ToolApprovalRequested { tool_name, .. } if tool_name == "Write")
1209 );
1210 }
1211
1212 #[tokio::test]
1213 async fn check_permissions_for_returns_none_when_permitted() {
1214 let executor = make_executor(None);
1217 let call = make_tool_call("Read", json!({"file_path": "/tmp/whatever"}));
1218 let ctx = ToolExecutionContext::none(&call.id);
1219 let decision = executor
1220 .check_permissions_for(&call, &ctx)
1221 .await
1222 .expect("no checker means no gate");
1223 assert!(decision.is_none(), "no checker must yield Ok(None)");
1224 }
1225
1226 struct HostStub {
1229 approve: bool,
1230 }
1231
1232 #[async_trait]
1233 impl crate::approval::ApprovalProxy for HostStub {
1234 async fn request_approval(&self, _ask: crate::approval::ApprovalAsk) -> bool {
1235 self.approve
1236 }
1237 }
1238
1239 #[tokio::test]
1240 async fn approval_proxy_grant_lets_gated_tool_proceed() {
1241 let dir = tempfile::tempdir().unwrap();
1246 let path = dir.path().join("approved.txt");
1247 let path_str = path.to_str().unwrap().to_string();
1248 let config = Arc::new(crate::permission::PermissionConfig::new());
1249 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1250 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1251 let executor = make_executor(Some(checker));
1252
1253 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "ok"}));
1254 let ctx = ToolExecutionContext {
1255 session_id: Some("s-worker"),
1256 tool_call_id: &call.id,
1257 event_tx: None,
1258 available_tool_schemas: None,
1259 bypass_permissions: false,
1260 can_async_resume: false,
1261 bash_completion_sink: None,
1262 pre_parsed_args: None,
1263 };
1264
1265 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: true });
1266 let result = crate::approval::with_approval_proxy(
1267 Some(proxy),
1268 executor.execute_with_context(&call, ctx),
1269 )
1270 .await;
1271
1272 assert!(
1273 result.is_ok(),
1274 "host grant should let the write through: {result:?}"
1275 );
1276 assert_eq!(fs::read_to_string(&path).await.unwrap(), "ok");
1277 }
1278
1279 #[tokio::test]
1280 async fn approval_proxy_deny_fails_gated_tool_closed() {
1281 let dir = tempfile::tempdir().unwrap();
1284 let path = dir.path().join("denied.txt");
1285 let path_str = path.to_str().unwrap().to_string();
1286 let config = Arc::new(crate::permission::PermissionConfig::new());
1287 config.set_ask_rules([format!("Write({}/**)", dir.path().to_str().unwrap())]);
1288 let checker = Arc::new(crate::permission::ConfigPermissionChecker::new(config));
1289 let executor = make_executor(Some(checker));
1290
1291 let call = make_tool_call("Write", json!({"file_path": path_str, "content": "nope"}));
1292 let ctx = ToolExecutionContext {
1293 session_id: Some("s-worker"),
1294 tool_call_id: &call.id,
1295 event_tx: None,
1296 available_tool_schemas: None,
1297 bypass_permissions: false,
1298 can_async_resume: false,
1299 bash_completion_sink: None,
1300 pre_parsed_args: None,
1301 };
1302
1303 let proxy: Arc<dyn crate::approval::ApprovalProxy> = Arc::new(HostStub { approve: false });
1304 let result = crate::approval::with_approval_proxy(
1305 Some(proxy),
1306 executor.execute_with_context(&call, ctx),
1307 )
1308 .await;
1309
1310 assert!(
1311 matches!(result, Err(ToolError::Execution(ref m)) if m.contains("denied by host")),
1312 "host deny should fail the tool closed: {result:?}"
1313 );
1314 assert!(fs::metadata(&path).await.is_err());
1315 }
1316
1317 #[tokio::test]
1318 async fn tool_can_stream_events_via_execute_with_context() {
1319 struct StreamingTool;
1320
1321 #[async_trait]
1322 impl Tool for StreamingTool {
1323 fn name(&self) -> &str {
1324 "streaming_tool"
1325 }
1326
1327 fn description(&self) -> &str {
1328 "streams one token"
1329 }
1330
1331 fn parameters_schema(&self) -> serde_json::Value {
1332 json!({"type":"object","properties":{}})
1333 }
1334
1335 async fn invoke(
1336 &self,
1337 _args: serde_json::Value,
1338 ctx: ToolCtx,
1339 ) -> Result<ToolOutcome, ToolError> {
1340 ctx.emit(AgentEvent::Token {
1341 content: "stream".to_string(),
1342 })
1343 .await;
1344 Ok(ToolOutcome::Completed(ToolResult {
1345 success: true,
1346 result: "ok".to_string(),
1347 display_preference: None,
1348 images: Vec::new(),
1349 }))
1350 }
1351 }
1352
1353 let executor = BuiltinToolExecutor::new();
1354 executor
1355 .register_tool(StreamingTool)
1356 .expect("register streaming tool");
1357
1358 let (tx, mut rx) = mpsc::channel(8);
1359 let call = make_tool_call("streaming_tool", json!({}));
1360
1361 let result = executor
1362 .execute_with_context(
1363 &call,
1364 ToolExecutionContext {
1365 session_id: Some("s1"),
1366 tool_call_id: &call.id,
1367 event_tx: Some(&tx),
1368 available_tool_schemas: None,
1369 bypass_permissions: false,
1370 can_async_resume: false,
1371 bash_completion_sink: None,
1372 pre_parsed_args: None,
1373 },
1374 )
1375 .await
1376 .expect("execute tool");
1377
1378 assert!(result.success);
1379 assert_eq!(result.result, "ok");
1380
1381 let ev = rx.recv().await.expect("expected streamed event");
1382 assert!(
1383 matches!(ev, AgentEvent::ToolToken { tool_call_id, content } if tool_call_id == "call_1" && content == "stream")
1384 );
1385 }
1386
1387 #[tokio::test]
1388 async fn removed_legacy_tools_return_not_found() {
1389 let executor = BuiltinToolExecutor::new();
1390
1391 for legacy in ["claude_code", "search_in_file", "search_in_project"] {
1392 let call = make_tool_call(legacy, json!({}));
1393 let result = executor.execute(&call).await;
1394 assert!(matches!(result, Err(ToolError::NotFound(_))));
1395 }
1396 }
1397
1398 #[tokio::test]
1399 async fn executor_prefers_exact_tool_name_before_builtin_alias() {
1400 struct CustomSpawnSessionTool;
1401
1402 #[async_trait]
1403 impl Tool for CustomSpawnSessionTool {
1404 fn name(&self) -> &str {
1405 "spawn_session"
1406 }
1407
1408 fn description(&self) -> &str {
1409 "custom tool for regression coverage"
1410 }
1411
1412 fn parameters_schema(&self) -> serde_json::Value {
1413 json!({"type":"object","properties":{}})
1414 }
1415
1416 async fn invoke(
1417 &self,
1418 _args: serde_json::Value,
1419 _ctx: ToolCtx,
1420 ) -> Result<ToolOutcome, ToolError> {
1421 Ok(ToolOutcome::Completed(ToolResult {
1422 success: true,
1423 result: "custom-spawn-session".to_string(),
1424 display_preference: None,
1425 images: Vec::new(),
1426 }))
1427 }
1428 }
1429
1430 let executor = BuiltinToolExecutorBuilder::new()
1431 .with_tool(CustomSpawnSessionTool)
1432 .expect("register custom spawn_session tool")
1433 .build();
1434
1435 let call = make_tool_call("spawn_session", json!({}));
1436 let result = executor.execute(&call).await.expect("execute custom tool");
1437 assert!(result.success);
1438 assert_eq!(result.result, "custom-spawn-session");
1439 }
1440
1441 struct EchoArgsTool;
1446
1447 #[async_trait]
1448 impl Tool for EchoArgsTool {
1449 fn name(&self) -> &str {
1450 "echo_args"
1451 }
1452 fn description(&self) -> &str {
1453 "echoes the `v` arg"
1454 }
1455 fn parameters_schema(&self) -> serde_json::Value {
1456 json!({"type":"object","properties":{"v":{"type":"string"}}})
1457 }
1458 async fn invoke(
1459 &self,
1460 args: serde_json::Value,
1461 _ctx: ToolCtx,
1462 ) -> Result<ToolOutcome, ToolError> {
1463 let v = args
1464 .get("v")
1465 .and_then(serde_json::Value::as_str)
1466 .unwrap_or("<none>")
1467 .to_string();
1468 Ok(ToolOutcome::Completed(ToolResult {
1469 success: true,
1470 result: v,
1471 display_preference: None,
1472 images: Vec::new(),
1473 }))
1474 }
1475 }
1476
1477 fn ctx_with_pre_parsed<'a>(
1478 call_id: &'a str,
1479 pre_parsed: Option<&'a serde_json::Value>,
1480 ) -> ToolExecutionContext<'a> {
1481 ToolExecutionContext {
1482 session_id: Some("s-106"),
1483 tool_call_id: call_id,
1484 event_tx: None,
1485 available_tool_schemas: None,
1486 bypass_permissions: false,
1487 can_async_resume: false,
1488 bash_completion_sink: None,
1489 pre_parsed_args: pre_parsed,
1490 }
1491 }
1492
1493 #[tokio::test]
1494 async fn execute_with_context_reuses_pre_parsed_args_without_reparsing() {
1495 let executor = BuiltinToolExecutor::new();
1501 executor.register_tool(EchoArgsTool).expect("register echo");
1502
1503 let call = make_tool_call("echo_args", json!({"v": "raw"}));
1504 let pre_parsed = json!({"v": "preparsed"});
1505 let ctx = ctx_with_pre_parsed(&call.id, Some(&pre_parsed));
1506
1507 let result = executor
1508 .execute_with_context(&call, ctx)
1509 .await
1510 .expect("execute echo tool");
1511 assert_eq!(
1512 result.result, "preparsed",
1513 "executor must reuse pre_parsed_args, not re-parse the raw string"
1514 );
1515 }
1516
1517 #[tokio::test]
1518 async fn execute_with_context_parses_raw_when_no_pre_parsed_args() {
1519 let executor = BuiltinToolExecutor::new();
1523 executor.register_tool(EchoArgsTool).expect("register echo");
1524
1525 let call = make_tool_call("echo_args", json!({"v": "raw"}));
1526 let ctx = ctx_with_pre_parsed(&call.id, None);
1527
1528 let result = executor
1529 .execute_with_context(&call, ctx)
1530 .await
1531 .expect("execute echo tool");
1532 assert_eq!(
1533 result.result, "raw",
1534 "without pre_parsed_args the executor parses the raw string as before"
1535 );
1536 }
1537
1538 #[tokio::test]
1539 async fn execute_with_context_malformed_args_repair_unchanged_without_pre_parsed() {
1540 let dir = tempfile::tempdir().unwrap();
1544 let path = dir.path().join("recovered-no-preparsed.txt");
1545 let malformed_args = format!(
1546 r#"{{"file_path":"{}","content":"recovered content""#,
1547 path.display()
1548 );
1549
1550 let executor = BuiltinToolExecutor::new();
1551 let call = make_tool_call_with_raw_args("Write", &malformed_args);
1552 let ctx = ctx_with_pre_parsed(&call.id, None);
1553
1554 let result = executor
1555 .execute_with_context(&call, ctx)
1556 .await
1557 .expect("truncated JSON should be auto-repaired");
1558 assert!(result.success);
1559 let written = fs::read_to_string(&path).await.expect("file written");
1560 assert_eq!(written, "recovered content");
1561 }
1562}