1use serde_json::Value;
2
3use crate::config::constants::tools;
4use crate::tools::command_args::interactive_input_text;
5use crate::tools::names::canonical_tool_name;
6
7pub type ToolIntentClassifier = fn(&Value) -> ToolIntent;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ToolSurfaceKind {
11 Function,
12 ApplyPatch,
13}
14
15#[derive(Debug, Clone, Copy)]
16pub enum ToolMutationModel {
17 ReadOnly,
18 Mutating,
19 ByArgs(ToolIntentClassifier),
20}
21
22impl ToolMutationModel {
23 pub fn classify(self, args: &Value) -> ToolIntent {
24 match self {
25 Self::ReadOnly => ToolIntent::read_only(),
26 Self::Mutating => ToolIntent::mutating(),
27 Self::ByArgs(classifier) => classifier(args),
28 }
29 }
30}
31
32#[derive(Debug, Clone, Copy)]
33pub struct ToolBehavior {
34 pub surface_kind: ToolSurfaceKind,
35 pub mutation_model: ToolMutationModel,
36 pub supports_parallel_calls: bool,
37 pub safe_mode_prompt: bool,
38}
39
40impl ToolBehavior {
41 pub const fn function(
42 mutation_model: ToolMutationModel,
43 supports_parallel_calls: bool,
44 safe_mode_prompt: bool,
45 ) -> Self {
46 Self {
47 surface_kind: ToolSurfaceKind::Function,
48 mutation_model,
49 supports_parallel_calls,
50 safe_mode_prompt,
51 }
52 }
53
54 pub const fn apply_patch(
55 mutation_model: ToolMutationModel,
56 supports_parallel_calls: bool,
57 safe_mode_prompt: bool,
58 ) -> Self {
59 Self {
60 surface_kind: ToolSurfaceKind::ApplyPatch,
61 mutation_model,
62 supports_parallel_calls,
63 safe_mode_prompt,
64 }
65 }
66
67 pub fn classify(self, args: &Value) -> ToolIntent {
68 self.mutation_model.classify(args)
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub struct ToolIntent {
74 pub mutating: bool,
75 pub destructive: bool,
76 pub readonly_unified_action: bool,
77 pub retry_safe: bool,
78}
79
80impl ToolIntent {
81 pub const fn read_only() -> Self {
82 Self {
83 mutating: false,
84 destructive: false,
85 readonly_unified_action: false,
86 retry_safe: true,
87 }
88 }
89
90 pub const fn read_only_unified_action() -> Self {
91 Self {
92 mutating: false,
93 destructive: false,
94 readonly_unified_action: true,
95 retry_safe: true,
96 }
97 }
98
99 pub const fn mutating() -> Self {
100 Self {
101 mutating: true,
102 destructive: true,
103 readonly_unified_action: false,
104 retry_safe: false,
105 }
106 }
107}
108
109pub fn builtin_tool_behavior(tool_name: &str) -> Option<ToolBehavior> {
110 let canonical = canonical_tool_name(tool_name);
111 builtin_tool_behavior_canonical(canonical)
112}
113
114fn builtin_tool_behavior_canonical(tool: &str) -> Option<ToolBehavior> {
115 match tool {
116 tools::UNIFIED_SEARCH => Some(ToolBehavior::function(
117 ToolMutationModel::ReadOnly,
118 true,
119 false,
120 )),
121 tools::UNIFIED_EXEC => Some(ToolBehavior::function(
122 ToolMutationModel::ByArgs(unified_exec_intent),
123 false,
124 true,
125 )),
126 tools::UNIFIED_FILE => Some(ToolBehavior::function(
127 ToolMutationModel::ByArgs(unified_file_intent),
128 false,
129 false,
130 )),
131 tools::APPLY_PATCH => Some(ToolBehavior::apply_patch(
132 ToolMutationModel::Mutating,
133 false,
134 true,
135 )),
136 tools::REQUEST_USER_INPUT
137 | tools::MEMORY
138 | tools::START_PLANNING
139 | tools::FINISH_PLANNING
140 | tools::LIST_SKILLS
141 | tools::LOAD_SKILL
142 | tools::LOAD_SKILL_RESOURCE
143 | tools::TASK_TRACKER
144 | tools::GET_ERRORS
145 | tools::SEARCH_TOOLS
146 | tools::MCP_SEARCH_TOOLS
147 | tools::MCP_GET_TOOL_DETAILS
148 | tools::MCP_LIST_SERVERS
149 | tools::THINK => Some(ToolBehavior::function(
150 if tool == tools::MEMORY {
151 ToolMutationModel::ByArgs(memory_tool_intent)
152 } else {
153 ToolMutationModel::ReadOnly
154 },
155 false,
156 false,
157 )),
158 tools::READ_FILE | tools::GREP_FILE | tools::LIST_FILES => Some(ToolBehavior::function(
159 ToolMutationModel::ReadOnly,
160 true,
161 false,
162 )),
163 tools::WRITE_FILE | tools::EDIT_FILE | tools::DELETE_FILE | tools::CREATE_FILE => Some(
164 ToolBehavior::function(ToolMutationModel::Mutating, false, true),
165 ),
166 tools::MCP_CONNECT_SERVER | tools::MCP_DISCONNECT_SERVER => Some(ToolBehavior::function(
167 ToolMutationModel::Mutating,
168 false,
169 false,
170 )),
171 tools::RUN_PTY_CMD
172 | tools::SEND_PTY_INPUT
173 | tools::CREATE_PTY_SESSION
174 | tools::READ_PTY_SESSION
175 | tools::LIST_PTY_SESSIONS
176 | tools::CLOSE_PTY_SESSION
177 | tools::EXECUTE_CODE
178 | tools::SHELL => Some(ToolBehavior::function(
179 ToolMutationModel::Mutating,
180 false,
181 true,
182 )),
183 _ => None,
184 }
185}
186
187pub fn is_parallel_safe_call(tool_name: &str, args: &Value) -> bool {
188 let canonical = canonical_tool_name(tool_name);
189 if let Some(behavior) = builtin_tool_behavior_canonical(canonical) {
190 return behavior.supports_parallel_calls && !behavior.classify(args).mutating;
191 }
192
193 !classify_tool_intent(canonical, args).mutating
194}
195
196pub fn classify_tool_intent(tool_name: &str, args: &Value) -> ToolIntent {
197 let canonical = canonical_tool_name(tool_name);
198 builtin_tool_behavior_canonical(canonical)
199 .map(|behavior| behavior.classify(args))
200 .unwrap_or_else(ToolIntent::mutating)
201}
202
203pub fn is_edited_file_conflict_guarded_call(tool_name: &str, args: &Value) -> bool {
204 let canonical = canonical_tool_name(tool_name);
205 match canonical {
206 tools::WRITE_FILE | tools::CREATE_FILE | tools::EDIT_FILE | tools::APPLY_PATCH => true,
207 tools::UNIFIED_FILE => unified_file_action(args)
208 .map(is_edited_file_conflict_guarded_unified_file_action)
209 .unwrap_or(false),
210 _ => false,
211 }
212}
213
214fn is_edited_file_conflict_guarded_unified_file_action(action: &str) -> bool {
215 action_matches_any(Some(action), &["write", "create", "edit", "patch"])
216}
217
218pub fn canonical_unified_exec_tool_name(tool_name: &str) -> Option<&'static str> {
219 match tool_name {
220 tools::UNIFIED_EXEC
221 | tools::RUN_PTY_CMD
222 | tools::SEND_PTY_INPUT
223 | tools::CREATE_PTY_SESSION
224 | tools::READ_PTY_SESSION
225 | tools::LIST_PTY_SESSIONS
226 | tools::CLOSE_PTY_SESSION
227 | tools::EXECUTE_CODE
228 | tools::EXEC_PTY_CMD
229 | tools::EXEC_COMMAND
230 | tools::WRITE_STDIN
231 | tools::SHELL
232 | "bash"
233 | "exec"
234 | "container.exec" => Some(tools::UNIFIED_EXEC),
235 _ => None,
236 }
237}
238
239pub fn should_use_spool_reference_only(tool_name: Option<&str>, output: &Value) -> bool {
240 let Some(obj) = output.as_object() else {
241 return false;
242 };
243
244 let has_spool_path = obj
245 .get("spool_path")
246 .and_then(Value::as_str)
247 .is_some_and(|path| !path.trim().is_empty());
248 if !has_spool_path {
249 return false;
250 }
251
252 if obj.get("loop_detected").and_then(Value::as_bool) == Some(true) {
253 return false;
254 }
255
256 if tool_name.is_some_and(|name| canonical_unified_exec_tool_name(name).is_some()) {
257 return true;
258 }
259
260 if tool_name.is_some_and(|name| canonical_tool_name(name) == tools::UNIFIED_SEARCH) {
261 return true;
262 }
263
264 if looks_like_unified_search_output(obj) {
265 return true;
266 }
267
268 if obj
269 .get("content_type")
270 .and_then(Value::as_str)
271 .is_some_and(|content_type| content_type == "exec_inspect")
272 {
273 return true;
274 }
275
276 [
277 "command",
278 "id",
279 "session_id",
280 "process_id",
281 "is_exited",
282 "exit_code",
283 ]
284 .iter()
285 .any(|key| obj.contains_key(*key))
286}
287
288fn looks_like_unified_search_output(obj: &serde_json::Map<String, Value>) -> bool {
289 ["matches", "results", "files", "entries"]
290 .iter()
291 .any(|key| obj.contains_key(*key))
292 || obj
293 .get("tool")
294 .or_else(|| obj.get("tool_name"))
295 .or_else(|| obj.get("name"))
296 .and_then(Value::as_str)
297 .is_some_and(|name| canonical_tool_name(name) == tools::UNIFIED_SEARCH)
298}
299
300pub fn is_command_run_tool_call(tool_name: &str, args: &Value) -> bool {
301 match tool_name {
302 tools::RUN_PTY_CMD | tools::CREATE_PTY_SESSION | tools::SHELL | "bash" => true,
303 tools::UNIFIED_EXEC
304 | tools::EXEC_PTY_CMD
305 | tools::EXEC_COMMAND
306 | "exec"
307 | "container.exec" => unified_exec_action_is(args, "run"),
308 _ => false,
309 }
310}
311
312pub fn remap_unified_file_command_args_to_unified_exec(args: &Value) -> Option<Value> {
313 let obj = args.as_object()?;
314 let command = obj
315 .get("command")
316 .or_else(|| obj.get("cmd"))
317 .or_else(|| obj.get("raw_command"))
318 .and_then(Value::as_str)
319 .map(str::trim)
320 .filter(|value| !value.is_empty())?;
321 let action = obj.get("action").and_then(Value::as_str).map(str::trim);
322 if let Some(action) = action
323 && !action.is_empty()
324 && !action_matches_any(Some(action), &["run", "exec", "execute", "shell"])
325 {
326 return None;
327 }
328
329 let mut mapped = serde_json::Map::new();
330 mapped.insert("action".to_string(), Value::String("run".to_string()));
331 mapped.insert("command".to_string(), Value::String(command.to_string()));
332
333 for key in [
334 "args",
335 "cwd",
336 "workdir",
337 "env",
338 "timeout_ms",
339 "yield_time_ms",
340 "login",
341 "shell",
342 "tty",
343 "sandbox_permissions",
344 "justification",
345 "prefix_rule",
346 ] {
347 if let Some(value) = obj.get(key) {
348 mapped.insert(key.to_string(), value.clone());
349 }
350 }
351
352 Some(Value::Object(mapped))
353}
354
355fn unified_file_intent(args: &Value) -> ToolIntent {
356 if unified_file_action_is(args, "read") {
357 ToolIntent::read_only_unified_action()
358 } else {
359 ToolIntent::mutating()
360 }
361}
362
363fn unified_exec_intent(args: &Value) -> ToolIntent {
364 let has_exec_input = unified_exec_has_input(args);
365 let readonly_unified_action = if unified_exec_action_is(args, "run") {
366 is_readonly_unified_exec_command(args)
367 } else {
368 unified_exec_action_in(args, &["poll", "list", "inspect"])
369 || (unified_exec_action_is(args, "continue") && !has_exec_input)
370 };
371
372 if readonly_unified_action {
373 ToolIntent::read_only_unified_action()
374 } else {
375 ToolIntent::mutating()
376 }
377}
378
379fn memory_tool_intent(args: &Value) -> ToolIntent {
380 let command = args
381 .get("command")
382 .and_then(Value::as_str)
383 .map(str::trim)
384 .unwrap_or_default();
385 if command.eq_ignore_ascii_case("view") {
386 ToolIntent::read_only()
387 } else {
388 ToolIntent::mutating()
389 }
390}
391
392fn is_readonly_unified_exec_command(args: &Value) -> bool {
393 let Ok(Some(parts)) = crate::tools::command_args::command_words(args) else {
394 return false;
395 };
396
397 if parts.iter().any(|part| part == "--dry-run") {
398 return true;
399 }
400
401 let Some(command) = parts.first().map(String::as_str) else {
402 return false;
403 };
404
405 match command {
406 "rg" | "ls" | "cat" => true,
407 "git" => matches!(parts.get(1).map(String::as_str), Some("status")),
408 "cargo" => matches!(parts.get(1).map(String::as_str), Some("check" | "test")),
409 "npm" | "pnpm" | "yarn" => match parts.get(1).map(String::as_str) {
410 Some("test") => true,
411 Some("run") => matches!(parts.get(2).map(String::as_str), Some("test")),
412 _ => false,
413 },
414 _ => false,
415 }
416}
417
418pub fn unified_file_action(args: &Value) -> Option<&str> {
421 fn looks_like_patch_text(text: &str) -> bool {
422 let trimmed = text.trim_start();
423 trimmed.starts_with("*** Begin Patch")
424 || trimmed.starts_with("*** Update File:")
425 || trimmed.starts_with("*** Add File:")
426 || trimmed.starts_with("*** Delete File:")
427 }
428
429 args.get("action").and_then(|v| v.as_str()).or_else(|| {
430 let has_read_path = args.get("path").is_some()
431 || args.get("file_path").is_some()
432 || args.get("filepath").is_some()
433 || args.get("target_path").is_some()
434 || args.get("file").is_some()
435 || args.get("p").is_some();
436 let patch_in_input = args
437 .get("input")
438 .and_then(|v| v.as_str())
439 .is_some_and(looks_like_patch_text);
440 let raw_patch = args.as_str().is_some_and(looks_like_patch_text);
441
442 if args.get("old_str").is_some() {
443 Some("edit")
444 } else if args.get("patch").is_some() || patch_in_input || raw_patch {
445 Some("patch")
446 } else if args.get("content").is_some() {
447 Some("write")
448 } else if args.get("destination").is_some() {
449 Some("move")
450 } else if has_read_path {
451 Some("read")
452 } else {
453 None
454 }
455 })
456}
457
458pub fn unified_exec_action(args: &Value) -> Option<&str> {
461 args.get("action").and_then(|v| v.as_str()).or_else(|| {
462 if args.get("command").is_some()
464 || args.get("cmd").is_some()
465 || args.get("raw_command").is_some()
466 || crate::tools::command_args::has_indexed_command_parts(args)
467 {
468 Some("run")
469 } else if args.get("code").is_some() {
470 Some("code")
471 } else if args.get("input").is_some()
472 || args.get("chars").is_some()
473 || args.get("text").is_some()
474 {
475 Some("write")
476 } else if args.get("spool_path").is_some()
477 || args.get("query").is_some()
478 || args.get("head_lines").is_some()
479 || args.get("tail_lines").is_some()
480 || args.get("max_matches").is_some()
481 || args.get("literal").is_some()
482 {
483 Some("inspect")
484 } else if args.get("session_id").is_some() || args.get("s").is_some() {
485 Some("poll")
486 } else {
487 None
488 }
489 })
490}
491
492fn action_matches(action: Option<&str>, expected: &str) -> bool {
493 action.is_some_and(|candidate| candidate.eq_ignore_ascii_case(expected))
494}
495
496fn action_matches_any(action: Option<&str>, expected: &[&str]) -> bool {
497 action.is_some_and(|candidate| {
498 expected
499 .iter()
500 .any(|expected_action| candidate.eq_ignore_ascii_case(expected_action))
501 })
502}
503
504pub fn unified_file_action_is(args: &Value, expected: &str) -> bool {
505 action_matches(unified_file_action(args), expected)
506}
507
508pub fn unified_file_action_in(args: &Value, expected: &[&str]) -> bool {
509 action_matches_any(unified_file_action(args), expected)
510}
511
512pub fn unified_exec_action_is(args: &Value, expected: &str) -> bool {
513 action_matches(unified_exec_action(args), expected)
514}
515
516pub fn unified_exec_action_in(args: &Value, expected: &[&str]) -> bool {
517 action_matches_any(unified_exec_action(args), expected)
518}
519
520pub fn unified_search_action_is(args: &Value, expected: &str) -> bool {
521 action_matches(unified_search_action(args), expected)
522}
523
524pub fn unified_search_action_in(args: &Value, expected: &[&str]) -> bool {
525 action_matches_any(unified_search_action(args), expected)
526}
527
528fn unified_exec_has_input(args: &Value) -> bool {
529 interactive_input_text(args).is_some()
530}
531
532fn get_field_case_insensitive<'a>(
533 args: &'a serde_json::Map<String, Value>,
534 key: &str,
535) -> Option<&'a Value> {
536 args.get(key).or_else(|| {
537 args.iter()
538 .find(|(name, _)| name.eq_ignore_ascii_case(key))
539 .map(|(_, value)| value)
540 })
541}
542
543fn has_meaningful_search_field(args: &serde_json::Map<String, Value>, key: &str) -> bool {
544 match get_field_case_insensitive(args, key) {
545 Some(Value::Null) | None => false,
546 Some(Value::String(text)) => !text.trim().is_empty(),
547 Some(Value::Array(values)) => !values.is_empty(),
548 Some(_) => true,
549 }
550}
551
552fn looks_like_list_glob_pattern(args: &serde_json::Map<String, Value>) -> bool {
553 let pattern = get_field_case_insensitive(args, "pattern")
554 .or_else(|| get_field_case_insensitive(args, "query"))
555 .and_then(Value::as_str)
556 .map(str::trim)
557 .filter(|pattern| !pattern.is_empty());
558 let Some(pattern) = pattern else {
559 return false;
560 };
561
562 let has_glob_wildcards =
563 pattern.contains('*') || pattern.contains('?') || pattern.contains('[');
564 if !has_glob_wildcards {
565 return false;
566 }
567
568 pattern.contains('/')
569 || pattern.contains('\\')
570 || pattern.starts_with("*.")
571 || pattern.contains("*.")
572}
573
574fn unified_search_action_from_object(args: &serde_json::Map<String, Value>) -> Option<&str> {
575 get_field_case_insensitive(args, "action")
576 .and_then(|value| value.as_str())
577 .or_else(|| {
578 let has_structural_workflow = get_field_case_insensitive(args, "workflow")
580 .and_then(Value::as_str)
581 .map(str::trim)
582 .is_some_and(|workflow| !workflow.is_empty());
583 let has_pattern = has_meaningful_search_field(args, "pattern")
584 || has_meaningful_search_field(args, "query");
585 let has_structural_hint = has_structural_workflow
586 || has_meaningful_search_field(args, "lang")
587 || has_meaningful_search_field(args, "selector")
588 || has_meaningful_search_field(args, "strictness")
589 || has_meaningful_search_field(args, "debug_query")
590 || has_meaningful_search_field(args, "globs")
591 || has_meaningful_search_field(args, "config_path")
592 || has_meaningful_search_field(args, "filter")
593 || has_meaningful_search_field(args, "skip_snapshot_tests");
594 let has_path = has_meaningful_search_field(args, "path");
595
596 if has_structural_workflow || (has_pattern && has_structural_hint) {
597 Some("structural")
598 } else if has_pattern && has_path && looks_like_list_glob_pattern(args) {
599 Some("list")
600 } else if has_pattern {
601 Some("grep")
602 } else if get_field_case_insensitive(args, "keyword").is_some() {
603 Some("tools")
604 } else if get_field_case_insensitive(args, "url").is_some() {
605 Some("web")
606 } else if get_field_case_insensitive(args, "sub_action").is_some()
607 || get_field_case_insensitive(args, "name").is_some()
608 {
609 Some("skill")
610 } else if get_field_case_insensitive(args, "scope").is_some() {
611 Some("errors")
612 } else if get_field_case_insensitive(args, "path").is_some() {
613 Some("list")
614 } else {
615 None
616 }
617 })
618}
619
620fn is_unified_search_arg_key(key: &str) -> bool {
621 matches!(
622 key.to_ascii_lowercase().as_str(),
623 "action"
624 | "pattern"
625 | "query"
626 | "path"
627 | "lang"
628 | "selector"
629 | "strictness"
630 | "debug_query"
631 | "workflow"
632 | "config_path"
633 | "filter"
634 | "globs"
635 | "context_lines"
636 | "max_results"
637 | "skip_snapshot_tests"
638 | "keyword"
639 | "url"
640 | "scope"
641 | "sub_action"
642 | "name"
643 )
644}
645
646fn object_has_unified_search_signal(args: &serde_json::Map<String, Value>) -> bool {
647 args.keys().any(|key| is_unified_search_arg_key(key))
648}
649
650fn parse_object_json_string(payload: &str) -> Option<serde_json::Map<String, Value>> {
651 let parsed = serde_json::from_str::<Value>(payload).ok()?;
652 parsed.as_object().cloned()
653}
654
655fn extract_unified_search_args_object(args: &Value) -> Option<serde_json::Map<String, Value>> {
656 match args {
657 Value::Object(args_obj) => {
658 if object_has_unified_search_signal(args_obj) {
659 return Some(args_obj.clone());
660 }
661
662 for wrapper in ["arguments", "args"] {
663 let Some(candidate) = get_field_case_insensitive(args_obj, wrapper) else {
664 continue;
665 };
666 match candidate {
667 Value::Object(inner_obj) => return Some(inner_obj.clone()),
668 Value::String(inner_str) => {
669 if let Some(parsed_obj) = parse_object_json_string(inner_str) {
670 return Some(parsed_obj);
671 }
672 }
673 _ => {}
674 }
675 }
676
677 Some(args_obj.clone())
678 }
679 Value::String(raw) => parse_object_json_string(raw),
680 _ => None,
681 }
682}
683
684pub fn unified_search_action(args: &Value) -> Option<&str> {
687 let args_obj = args.as_object()?;
688 unified_search_action_from_object(args_obj)
689}
690
691pub fn normalize_unified_search_args(args: &Value) -> Value {
693 let Some(args_obj) = extract_unified_search_args_object(args) else {
694 return args.clone();
695 };
696
697 let mut normalized = serde_json::Map::with_capacity(args_obj.len() + 1);
698 for (key, value) in &args_obj {
699 let canonical = if key.eq_ignore_ascii_case("action") {
700 "action"
701 } else if key.eq_ignore_ascii_case("pattern") {
702 "pattern"
703 } else if key.eq_ignore_ascii_case("query") {
704 "query"
705 } else if key.eq_ignore_ascii_case("path") {
706 "path"
707 } else if key.eq_ignore_ascii_case("lang") {
708 "lang"
709 } else if key.eq_ignore_ascii_case("selector") {
710 "selector"
711 } else if key.eq_ignore_ascii_case("strictness") {
712 "strictness"
713 } else if key.eq_ignore_ascii_case("debug_query") || key.eq_ignore_ascii_case("debug-query")
714 {
715 "debug_query"
716 } else if key.eq_ignore_ascii_case("workflow") {
717 "workflow"
718 } else if key.eq_ignore_ascii_case("config_path") || key.eq_ignore_ascii_case("config-path")
719 {
720 "config_path"
721 } else if key.eq_ignore_ascii_case("filter") {
722 "filter"
723 } else if key.eq_ignore_ascii_case("globs") {
724 "globs"
725 } else if key.eq_ignore_ascii_case("context_lines")
726 || key.eq_ignore_ascii_case("context-lines")
727 {
728 "context_lines"
729 } else if key.eq_ignore_ascii_case("max_results") || key.eq_ignore_ascii_case("max-results")
730 {
731 "max_results"
732 } else if key.eq_ignore_ascii_case("skip_snapshot_tests")
733 || key.eq_ignore_ascii_case("skip-snapshot-tests")
734 {
735 "skip_snapshot_tests"
736 } else if key.eq_ignore_ascii_case("keyword") {
737 "keyword"
738 } else if key.eq_ignore_ascii_case("url") {
739 "url"
740 } else if key.eq_ignore_ascii_case("scope") {
741 "scope"
742 } else if key.eq_ignore_ascii_case("sub_action") {
743 "sub_action"
744 } else if key.eq_ignore_ascii_case("name") {
745 "name"
746 } else if key.eq_ignore_ascii_case("name_pattern")
747 || key.eq_ignore_ascii_case("name-pattern")
748 {
749 "name_pattern"
750 } else {
751 key
752 };
753 normalized
754 .entry(canonical.to_string())
755 .or_insert_with(|| value.clone());
756 }
757
758 let inferred_action = unified_search_action_from_object(&normalized).map(|a| a.to_string());
759 if let Some(action) = inferred_action {
760 normalized
761 .entry("action".to_string())
762 .or_insert_with(|| Value::String(action));
763 }
764
765 let action = normalized
766 .get("action")
767 .and_then(Value::as_str)
768 .unwrap_or_default()
769 .to_string();
770 let keyword_alias = normalized
771 .get("keyword")
772 .and_then(Value::as_str)
773 .map(str::trim)
774 .filter(|value| !value.is_empty())
775 .map(str::to_string);
776 let pattern_alias = normalized
777 .get("pattern")
778 .and_then(Value::as_str)
779 .map(str::trim)
780 .filter(|value| !value.is_empty())
781 .map(str::to_string)
782 .or_else(|| {
783 normalized
784 .get("query")
785 .and_then(Value::as_str)
786 .map(str::trim)
787 .filter(|value| !value.is_empty())
788 .map(str::to_string)
789 })
790 .or_else(|| keyword_alias.clone());
791
792 if action.eq_ignore_ascii_case("grep")
793 && let Some(pattern) = pattern_alias.clone()
794 {
795 normalized
796 .entry("pattern".to_string())
797 .or_insert_with(|| Value::String(pattern));
798 }
799
800 if action.eq_ignore_ascii_case("list")
801 && !normalized.contains_key("pattern")
802 && !normalized.contains_key("name_pattern")
803 {
804 if let Some(keyword) = keyword_alias {
805 normalized
806 .entry("name_pattern".to_string())
807 .or_insert_with(|| Value::String(keyword));
808 } else if let Some(pattern) = pattern_alias {
809 normalized
810 .entry("pattern".to_string())
811 .or_insert_with(|| Value::String(pattern));
812 }
813 }
814
815 Value::Object(normalized)
816}
817
818#[cfg(test)]
819mod tests {
820 use super::{
821 canonical_unified_exec_tool_name, classify_tool_intent, is_command_run_tool_call,
822 is_edited_file_conflict_guarded_call, is_parallel_safe_call, normalize_unified_search_args,
823 remap_unified_file_command_args_to_unified_exec, should_use_spool_reference_only,
824 unified_file_action,
825 };
826 use crate::config::constants::tools;
827 use serde_json::json;
828
829 #[test]
830 fn unified_file_read_is_retry_safe() {
831 let intent = classify_tool_intent(
832 tools::UNIFIED_FILE,
833 &json!({"action": "read", "path": "README.md"}),
834 );
835 assert!(!intent.mutating);
836 assert!(intent.readonly_unified_action);
837 assert!(intent.retry_safe);
838 }
839
840 #[test]
841 fn unified_exec_poll_is_retry_safe() {
842 let intent = classify_tool_intent(
843 tools::UNIFIED_EXEC,
844 &json!({"action": "poll", "session_id": 1}),
845 );
846 assert!(!intent.mutating);
847 assert!(intent.readonly_unified_action);
848 assert!(intent.retry_safe);
849 }
850
851 #[test]
852 fn unified_exec_inspect_is_retry_safe() {
853 let intent = classify_tool_intent(
854 tools::UNIFIED_EXEC,
855 &json!({"action": "inspect", "spool_path": ".vtcode/context/tool_outputs/run-1.txt"}),
856 );
857 assert!(!intent.mutating);
858 assert!(intent.readonly_unified_action);
859 assert!(intent.retry_safe);
860 }
861
862 #[test]
863 fn unified_exec_continue_without_input_is_retry_safe() {
864 let intent = classify_tool_intent(
865 tools::UNIFIED_EXEC,
866 &json!({"action": "continue", "session_id": "run-1"}),
867 );
868 assert!(!intent.mutating);
869 assert!(intent.readonly_unified_action);
870 assert!(intent.retry_safe);
871 }
872
873 #[test]
874 fn unified_exec_continue_with_input_is_mutating_and_destructive() {
875 let intent = classify_tool_intent(
876 tools::UNIFIED_EXEC,
877 &json!({"action": "continue", "session_id": "run-1", "input": "q"}),
878 );
879 assert!(intent.mutating);
880 assert!(intent.destructive);
881 assert!(!intent.readonly_unified_action);
882 assert!(!intent.retry_safe);
883 }
884
885 #[test]
886 fn unified_exec_continue_with_empty_input_stays_retry_safe() {
887 let intent = classify_tool_intent(
888 tools::UNIFIED_EXEC,
889 &json!({"action": "continue", "session_id": "run-1", "input": ""}),
890 );
891 assert!(!intent.mutating);
892 assert!(intent.readonly_unified_action);
893 assert!(intent.retry_safe);
894 }
895
896 #[test]
897 fn unified_exec_run_is_mutating_and_destructive() {
898 let intent = classify_tool_intent(
899 tools::UNIFIED_EXEC,
900 &json!({"action": "run", "command": "echo hi"}),
901 );
902 assert!(intent.mutating);
903 assert!(intent.destructive);
904 assert!(!intent.retry_safe);
905 }
906
907 #[test]
908 fn unified_exec_run_allowlisted_is_read_only() {
909 let intent = classify_tool_intent(
910 tools::UNIFIED_EXEC,
911 &json!({"action": "run", "command": "rg planning_active src"}),
912 );
913 assert!(!intent.mutating);
914 assert!(intent.readonly_unified_action);
915 assert!(intent.retry_safe);
916 }
917
918 #[test]
919 fn unified_exec_run_dry_run_is_read_only() {
920 let intent = classify_tool_intent(
921 tools::UNIFIED_EXEC,
922 &json!({"action": "run", "command": "npm install --dry-run"}),
923 );
924 assert!(!intent.mutating);
925 assert!(intent.readonly_unified_action);
926 assert!(intent.retry_safe);
927 }
928
929 #[test]
930 fn parallel_safe_calls_reject_control_and_exec_paths() {
931 assert!(is_parallel_safe_call(
932 tools::READ_FILE,
933 &json!({"path": "README.md"})
934 ));
935 assert!(!is_parallel_safe_call(tools::LIST_PTY_SESSIONS, &json!({})));
936 assert!(!is_parallel_safe_call(
937 tools::REQUEST_USER_INPUT,
938 &json!({"questions": []})
939 ));
940 assert!(!is_parallel_safe_call(
941 tools::UNIFIED_EXEC,
942 &json!({"action": "inspect", "session_id": "run-1"})
943 ));
944 }
945
946 #[test]
947 fn unified_exec_cmd_alias_infers_run() {
948 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"cmd": "echo hi"}));
949 assert!(intent.mutating);
950 assert!(intent.destructive);
951 assert!(!intent.readonly_unified_action);
952 }
953
954 #[test]
955 fn unified_exec_chars_alias_infers_write() {
956 let intent = classify_tool_intent(
957 tools::UNIFIED_EXEC,
958 &json!({"session_id": "abc123", "chars": "status\n"}),
959 );
960 assert!(intent.mutating);
961 assert!(intent.destructive);
962 assert!(!intent.readonly_unified_action);
963 }
964
965 #[test]
966 fn unified_exec_text_alias_infers_write() {
967 let intent = classify_tool_intent(
968 tools::UNIFIED_EXEC,
969 &json!({"session_id": "abc123", "text": "status\n"}),
970 );
971 assert!(intent.mutating);
972 assert!(intent.destructive);
973 assert!(!intent.readonly_unified_action);
974 }
975
976 #[test]
977 fn unified_exec_spool_path_alias_infers_inspect() {
978 let intent = classify_tool_intent(
979 tools::UNIFIED_EXEC,
980 &json!({"spool_path": ".vtcode/context/tool_outputs/run-1.txt"}),
981 );
982 assert!(!intent.mutating);
983 assert!(!intent.destructive);
984 assert!(intent.readonly_unified_action);
985 }
986
987 #[test]
988 fn unified_exec_compact_session_alias_infers_poll() {
989 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"s": "run-1"}));
990 assert!(!intent.mutating);
991 assert!(!intent.destructive);
992 assert!(intent.readonly_unified_action);
993 }
994
995 #[test]
996 fn unified_file_input_patch_infers_patch() {
997 let args = json!({
998 "input": "*** Begin Patch\n*** End Patch\n"
999 });
1000 let action = unified_file_action(&args);
1001 assert_eq!(action, Some("patch"));
1002 }
1003
1004 #[test]
1005 fn unified_file_raw_patch_infers_patch() {
1006 let args = json!("*** Begin Patch\n*** Update File: src/main.rs\n*** End Patch\n");
1007 let action = unified_file_action(&args);
1008 assert_eq!(action, Some("patch"));
1009 }
1010
1011 #[test]
1012 fn unified_file_unknown_args_require_action() {
1013 let args = json!({
1014 "unexpected": true
1015 });
1016 let action = unified_file_action(&args);
1017 assert_eq!(action, None);
1018 }
1019
1020 #[test]
1021 fn unified_file_compact_path_alias_infers_read() {
1022 let args = json!({
1023 "p": "README.md"
1024 });
1025 let action = unified_file_action(&args);
1026 assert_eq!(action, Some("read"));
1027 }
1028
1029 #[test]
1030 fn remap_unified_file_command_args_maps_command_payload_to_unified_exec() {
1031 let remapped = remap_unified_file_command_args_to_unified_exec(&json!({
1032 "command": "cargo check",
1033 "cwd": ".",
1034 "timeout_ms": 1000
1035 }))
1036 .expect("command payload should remap");
1037
1038 assert_eq!(remapped["action"], "run");
1039 assert_eq!(remapped["command"], "cargo check");
1040 assert_eq!(remapped["cwd"], ".");
1041 assert_eq!(remapped["timeout_ms"], 1000);
1042 }
1043
1044 #[test]
1045 fn remap_unified_file_command_args_accepts_exec_action_aliases() {
1046 let remapped = remap_unified_file_command_args_to_unified_exec(&json!({
1047 "action": "shell",
1048 "cmd": "echo ok"
1049 }))
1050 .expect("shell action alias should remap");
1051
1052 assert_eq!(remapped["action"], "run");
1053 assert_eq!(remapped["command"], "echo ok");
1054 }
1055
1056 #[test]
1057 fn remap_unified_file_command_args_rejects_non_command_actions() {
1058 let remapped = remap_unified_file_command_args_to_unified_exec(&json!({
1059 "action": "read",
1060 "command": "echo ok"
1061 }));
1062
1063 assert_eq!(remapped, None);
1064 }
1065
1066 #[test]
1067 fn edited_file_conflict_guard_accepts_supported_mutations() {
1068 assert!(is_edited_file_conflict_guarded_call(
1069 tools::WRITE_FILE,
1070 &json!({"path": "README.md", "content": "agent"})
1071 ));
1072 assert!(is_edited_file_conflict_guarded_call(
1073 tools::CREATE_FILE,
1074 &json!({"path": "README.md", "content": "agent"})
1075 ));
1076 assert!(is_edited_file_conflict_guarded_call(
1077 tools::EDIT_FILE,
1078 &json!({"path": "README.md", "old_str": "a", "new_str": "b"})
1079 ));
1080 assert!(is_edited_file_conflict_guarded_call(
1081 tools::APPLY_PATCH,
1082 &json!({"patch": "*** Begin Patch\n*** End Patch\n"})
1083 ));
1084 assert!(is_edited_file_conflict_guarded_call(
1085 tools::UNIFIED_FILE,
1086 &json!({"action": "write", "path": "README.md", "content": "agent"})
1087 ));
1088 assert!(is_edited_file_conflict_guarded_call(
1089 tools::UNIFIED_FILE,
1090 &json!({"action": "create", "path": "README.md", "content": "agent"})
1091 ));
1092 assert!(is_edited_file_conflict_guarded_call(
1093 tools::UNIFIED_FILE,
1094 &json!({"patch": "*** Begin Patch\n*** End Patch\n"})
1095 ));
1096 }
1097
1098 #[test]
1099 fn edited_file_conflict_guard_rejects_non_guarded_calls() {
1100 assert!(!is_edited_file_conflict_guarded_call(
1101 tools::READ_FILE,
1102 &json!({"path": "README.md"})
1103 ));
1104 assert!(!is_edited_file_conflict_guarded_call(
1105 tools::GREP_FILE,
1106 &json!({"pattern": "needle", "path": "."})
1107 ));
1108 assert!(!is_edited_file_conflict_guarded_call(
1109 tools::LIST_FILES,
1110 &json!({"path": "."})
1111 ));
1112 assert!(!is_edited_file_conflict_guarded_call(
1113 tools::UNIFIED_FILE,
1114 &json!({"action": "read", "path": "README.md"})
1115 ));
1116 assert!(!is_edited_file_conflict_guarded_call(
1117 tools::UNIFIED_FILE,
1118 &json!({"action": "delete", "path": "README.md"})
1119 ));
1120 assert!(!is_edited_file_conflict_guarded_call(
1121 tools::UNIFIED_EXEC,
1122 &json!({"action": "run", "command": "git status"})
1123 ));
1124 }
1125
1126 #[test]
1127 fn normalize_unified_search_args_canonicalizes_case_and_infers_action() {
1128 let normalized = normalize_unified_search_args(&json!({
1129 "Pattern": "needle",
1130 "Path": "."
1131 }));
1132
1133 assert_eq!(normalized["pattern"], "needle");
1134 assert_eq!(normalized["path"], ".");
1135 assert_eq!(normalized["action"], "grep");
1136 }
1137
1138 #[test]
1139 fn normalize_unified_search_args_infers_list_for_glob_patterns() {
1140 let normalized = normalize_unified_search_args(&json!({
1141 "Pattern": "**/*.rs",
1142 "Path": "src"
1143 }));
1144
1145 assert_eq!(normalized["pattern"], "**/*.rs");
1146 assert_eq!(normalized["path"], "src");
1147 assert_eq!(normalized["action"], "list");
1148 }
1149
1150 #[test]
1151 fn normalize_unified_search_args_unwraps_arguments_object() {
1152 let normalized = normalize_unified_search_args(&json!({
1153 "arguments": {
1154 "Pattern": "needle",
1155 "Path": "."
1156 }
1157 }));
1158
1159 assert_eq!(normalized["pattern"], "needle");
1160 assert_eq!(normalized["path"], ".");
1161 assert_eq!(normalized["action"], "grep");
1162 }
1163
1164 #[test]
1165 fn normalize_unified_search_args_parses_arguments_json_string() {
1166 let normalized = normalize_unified_search_args(&json!({
1167 "args": "{\"Pattern\":\"needle\",\"Path\":\".\"}"
1168 }));
1169
1170 assert_eq!(normalized["pattern"], "needle");
1171 assert_eq!(normalized["path"], ".");
1172 assert_eq!(normalized["action"], "grep");
1173 }
1174
1175 #[test]
1176 fn normalize_unified_search_args_keeps_structural_action_explicit() {
1177 let normalized = normalize_unified_search_args(&json!({
1178 "Action": "structural",
1179 "Pattern": "fn $NAME() {}",
1180 "Lang": "rust",
1181 "Debug-Query": "ast",
1182 "Max-Results": 5
1183 }));
1184
1185 assert_eq!(normalized["action"], "structural");
1186 assert_eq!(normalized["pattern"], "fn $NAME() {}");
1187 assert_eq!(normalized["lang"], "rust");
1188 assert_eq!(normalized["debug_query"], "ast");
1189 assert_eq!(normalized["max_results"], 5);
1190 }
1191
1192 #[test]
1193 fn normalize_unified_search_args_infers_structural_from_pattern_and_lang() {
1194 let normalized = normalize_unified_search_args(&json!({
1195 "Pattern": "fn $NAME($$$ARGS) { $$$BODY }",
1196 "Lang": "rust",
1197 "Path": "."
1198 }));
1199
1200 assert_eq!(normalized["action"], "structural");
1201 assert_eq!(normalized["lang"], "rust");
1202 assert_eq!(normalized["path"], ".");
1203 }
1204
1205 #[test]
1206 fn normalize_unified_search_args_canonicalizes_structural_workflow_fields() {
1207 let normalized = normalize_unified_search_args(&json!({
1208 "Workflow": "scan",
1209 "Config-Path": "config/sgconfig.yml",
1210 "Filter": "rust/no-iterator-for-each",
1211 "Skip-Snapshot-Tests": true
1212 }));
1213
1214 assert_eq!(normalized["workflow"], "scan");
1215 assert_eq!(normalized["config_path"], "config/sgconfig.yml");
1216 assert_eq!(normalized["filter"], "rust/no-iterator-for-each");
1217 assert_eq!(normalized["skip_snapshot_tests"], true);
1218 assert_eq!(normalized["action"], "structural");
1219 }
1220
1221 #[test]
1222 fn normalize_unified_search_args_maps_keyword_to_pattern_for_grep() {
1223 let normalized = normalize_unified_search_args(&json!({
1224 "action": "grep",
1225 "keyword": "system prompt",
1226 "path": "src"
1227 }));
1228
1229 assert_eq!(normalized["action"], "grep");
1230 assert_eq!(normalized["pattern"], "system prompt");
1231 assert_eq!(normalized["path"], "src");
1232 }
1233
1234 #[test]
1235 fn normalize_unified_search_args_maps_keyword_to_name_pattern_for_list() {
1236 let normalized = normalize_unified_search_args(&json!({
1237 "action": "list",
1238 "keyword": "agent",
1239 "path": "vtcode-core/src",
1240 "mode": "file"
1241 }));
1242
1243 assert_eq!(normalized["action"], "list");
1244 assert_eq!(normalized["name_pattern"], "agent");
1245 assert_eq!(normalized["path"], "vtcode-core/src");
1246 assert_eq!(normalized["mode"], "file");
1247 }
1248
1249 #[test]
1250 fn normalize_unified_search_args_maps_query_to_pattern_for_grep() {
1251 let normalized = normalize_unified_search_args(&json!({
1252 "action": "grep",
1253 "query": "Result<",
1254 "path": "vtcode-core/src"
1255 }));
1256
1257 assert_eq!(normalized["action"], "grep");
1258 assert_eq!(normalized["pattern"], "Result<");
1259 assert_eq!(normalized["path"], "vtcode-core/src");
1260 }
1261
1262 #[test]
1263 fn legacy_search_aliases_are_readonly() {
1264 let grep_intent =
1265 classify_tool_intent(tools::GREP_FILE, &json!({"pattern": "needle", "path": "."}));
1266 assert!(!grep_intent.mutating);
1267 assert!(!grep_intent.destructive);
1268
1269 let list_intent = classify_tool_intent(tools::LIST_FILES, &json!({"path": "."}));
1270 assert!(!list_intent.mutating);
1271 assert!(!list_intent.destructive);
1272 }
1273
1274 #[test]
1275 fn canonical_unified_exec_tool_name_normalizes_exec_aliases() {
1276 for alias in [
1277 tools::UNIFIED_EXEC,
1278 tools::RUN_PTY_CMD,
1279 tools::SEND_PTY_INPUT,
1280 tools::READ_PTY_SESSION,
1281 tools::LIST_PTY_SESSIONS,
1282 tools::CLOSE_PTY_SESSION,
1283 tools::EXECUTE_CODE,
1284 tools::EXEC_PTY_CMD,
1285 tools::EXEC_COMMAND,
1286 tools::WRITE_STDIN,
1287 tools::SHELL,
1288 "bash",
1289 "exec",
1290 "container.exec",
1291 ] {
1292 assert_eq!(
1293 canonical_unified_exec_tool_name(alias),
1294 Some(tools::UNIFIED_EXEC)
1295 );
1296 }
1297 }
1298
1299 #[test]
1300 fn spool_reference_only_detects_exec_aliases() {
1301 assert!(should_use_spool_reference_only(
1302 Some(tools::RUN_PTY_CMD),
1303 &json!({"spool_path": ".vtcode/context/tool_outputs/run-1.txt"})
1304 ));
1305 }
1306
1307 #[test]
1308 fn spool_reference_only_detects_unified_search_payloads() {
1309 assert!(should_use_spool_reference_only(
1310 Some(tools::UNIFIED_SEARCH),
1311 &json!({
1312 "spool_path": ".vtcode/context/tool_outputs/unified_search_1.txt",
1313 "matches": []
1314 })
1315 ));
1316 }
1317
1318 #[test]
1319 fn spool_reference_only_detects_unified_search_payload_without_tool_name() {
1320 assert!(should_use_spool_reference_only(
1321 None,
1322 &json!({
1323 "spool_path": ".vtcode/context/tool_outputs/unified_search_1.txt",
1324 "matches": []
1325 })
1326 ));
1327 }
1328
1329 #[test]
1330 fn spool_reference_only_detects_exec_payload_without_tool_name() {
1331 assert!(should_use_spool_reference_only(
1332 None,
1333 &json!({
1334 "command": "cargo check",
1335 "spool_path": ".vtcode/context/tool_outputs/run-1.txt",
1336 "exit_code": 0
1337 })
1338 ));
1339 }
1340
1341 #[test]
1342 fn spool_reference_only_skips_loop_recovery_payloads() {
1343 assert!(!should_use_spool_reference_only(
1344 Some(tools::UNIFIED_EXEC),
1345 &json!({
1346 "spool_path": ".vtcode/context/tool_outputs/run-1.txt",
1347 "exit_code": 0,
1348 "loop_detected": true
1349 })
1350 ));
1351 }
1352
1353 #[test]
1354 fn is_command_run_tool_call_only_accepts_run_actions() {
1355 assert!(is_command_run_tool_call(
1356 tools::RUN_PTY_CMD,
1357 &json!({"command": "cargo check"})
1358 ));
1359 assert!(is_command_run_tool_call(
1360 tools::UNIFIED_EXEC,
1361 &json!({"action": "run", "command": "cargo check"})
1362 ));
1363 assert!(is_command_run_tool_call(
1364 tools::EXEC_COMMAND,
1365 &json!({"cmd": "cargo check"})
1366 ));
1367 assert!(!is_command_run_tool_call(
1368 tools::UNIFIED_EXEC,
1369 &json!({"action": "poll", "session_id": "run-1"})
1370 ));
1371 assert!(!is_command_run_tool_call(
1372 tools::WRITE_STDIN,
1373 &json!({"session_id": "run-1", "chars": "q"})
1374 ));
1375 }
1376}