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 obj
261 .get("content_type")
262 .and_then(Value::as_str)
263 .is_some_and(|content_type| content_type == "exec_inspect")
264 {
265 return true;
266 }
267
268 [
269 "command",
270 "id",
271 "session_id",
272 "process_id",
273 "is_exited",
274 "exit_code",
275 ]
276 .iter()
277 .any(|key| obj.contains_key(*key))
278}
279
280pub fn is_command_run_tool_call(tool_name: &str, args: &Value) -> bool {
281 match tool_name {
282 tools::RUN_PTY_CMD | tools::CREATE_PTY_SESSION | tools::SHELL | "bash" => true,
283 tools::UNIFIED_EXEC
284 | tools::EXEC_PTY_CMD
285 | tools::EXEC_COMMAND
286 | "exec"
287 | "container.exec" => unified_exec_action_is(args, "run"),
288 _ => false,
289 }
290}
291
292pub fn remap_unified_file_command_args_to_unified_exec(args: &Value) -> Option<Value> {
293 let obj = args.as_object()?;
294 let command = obj
295 .get("command")
296 .or_else(|| obj.get("cmd"))
297 .or_else(|| obj.get("raw_command"))
298 .and_then(Value::as_str)
299 .map(str::trim)
300 .filter(|value| !value.is_empty())?;
301 let action = obj.get("action").and_then(Value::as_str).map(str::trim);
302 if let Some(action) = action
303 && !action.is_empty()
304 && !action_matches_any(Some(action), &["run", "exec", "execute", "shell"])
305 {
306 return None;
307 }
308
309 let mut mapped = serde_json::Map::new();
310 mapped.insert("action".to_string(), Value::String("run".to_string()));
311 mapped.insert("command".to_string(), Value::String(command.to_string()));
312
313 for key in [
314 "args",
315 "cwd",
316 "workdir",
317 "env",
318 "timeout_ms",
319 "yield_time_ms",
320 "login",
321 "shell",
322 "tty",
323 "sandbox_permissions",
324 "justification",
325 "prefix_rule",
326 ] {
327 if let Some(value) = obj.get(key) {
328 mapped.insert(key.to_string(), value.clone());
329 }
330 }
331
332 Some(Value::Object(mapped))
333}
334
335fn unified_file_intent(args: &Value) -> ToolIntent {
336 if unified_file_action_is(args, "read") {
337 ToolIntent::read_only_unified_action()
338 } else {
339 ToolIntent::mutating()
340 }
341}
342
343fn unified_exec_intent(args: &Value) -> ToolIntent {
344 let has_exec_input = unified_exec_has_input(args);
345 let readonly_unified_action = if unified_exec_action_is(args, "run") {
346 is_readonly_unified_exec_command(args)
347 } else {
348 unified_exec_action_in(args, &["poll", "list", "inspect"])
349 || (unified_exec_action_is(args, "continue") && !has_exec_input)
350 };
351
352 if readonly_unified_action {
353 ToolIntent::read_only_unified_action()
354 } else {
355 ToolIntent::mutating()
356 }
357}
358
359fn memory_tool_intent(args: &Value) -> ToolIntent {
360 let command = args
361 .get("command")
362 .and_then(Value::as_str)
363 .map(str::trim)
364 .unwrap_or_default();
365 if command.eq_ignore_ascii_case("view") {
366 ToolIntent::read_only()
367 } else {
368 ToolIntent::mutating()
369 }
370}
371
372fn is_readonly_unified_exec_command(args: &Value) -> bool {
373 let Ok(Some(parts)) = crate::tools::command_args::command_words(args) else {
374 return false;
375 };
376
377 if parts.iter().any(|part| part == "--dry-run") {
378 return true;
379 }
380
381 let Some(command) = parts.first().map(String::as_str) else {
382 return false;
383 };
384
385 match command {
386 "rg" | "ls" | "cat" => true,
387 "git" => matches!(parts.get(1).map(String::as_str), Some("status")),
388 "cargo" => matches!(parts.get(1).map(String::as_str), Some("check" | "test")),
389 "npm" | "pnpm" | "yarn" => match parts.get(1).map(String::as_str) {
390 Some("test") => true,
391 Some("run") => matches!(parts.get(2).map(String::as_str), Some("test")),
392 _ => false,
393 },
394 _ => false,
395 }
396}
397
398pub fn unified_file_action(args: &Value) -> Option<&str> {
401 fn looks_like_patch_text(text: &str) -> bool {
402 let trimmed = text.trim_start();
403 trimmed.starts_with("*** Begin Patch")
404 || trimmed.starts_with("*** Update File:")
405 || trimmed.starts_with("*** Add File:")
406 || trimmed.starts_with("*** Delete File:")
407 }
408
409 args.get("action").and_then(|v| v.as_str()).or_else(|| {
410 let has_read_path = args.get("path").is_some()
411 || args.get("file_path").is_some()
412 || args.get("filepath").is_some()
413 || args.get("target_path").is_some()
414 || args.get("file").is_some()
415 || args.get("p").is_some();
416 let patch_in_input = args
417 .get("input")
418 .and_then(|v| v.as_str())
419 .is_some_and(looks_like_patch_text);
420 let raw_patch = args.as_str().is_some_and(looks_like_patch_text);
421
422 if args.get("old_str").is_some() {
423 Some("edit")
424 } else if args.get("patch").is_some() || patch_in_input || raw_patch {
425 Some("patch")
426 } else if args.get("content").is_some() {
427 Some("write")
428 } else if args.get("destination").is_some() {
429 Some("move")
430 } else if has_read_path {
431 Some("read")
432 } else {
433 None
434 }
435 })
436}
437
438pub fn unified_exec_action(args: &Value) -> Option<&str> {
441 args.get("action").and_then(|v| v.as_str()).or_else(|| {
442 if args.get("command").is_some()
444 || args.get("cmd").is_some()
445 || args.get("raw_command").is_some()
446 || crate::tools::command_args::has_indexed_command_parts(args)
447 {
448 Some("run")
449 } else if args.get("code").is_some() {
450 Some("code")
451 } else if args.get("input").is_some()
452 || args.get("chars").is_some()
453 || args.get("text").is_some()
454 {
455 Some("write")
456 } else if args.get("spool_path").is_some()
457 || args.get("query").is_some()
458 || args.get("head_lines").is_some()
459 || args.get("tail_lines").is_some()
460 || args.get("max_matches").is_some()
461 || args.get("literal").is_some()
462 {
463 Some("inspect")
464 } else if args.get("session_id").is_some() || args.get("s").is_some() {
465 Some("poll")
466 } else {
467 None
468 }
469 })
470}
471
472fn action_matches(action: Option<&str>, expected: &str) -> bool {
473 action.is_some_and(|candidate| candidate.eq_ignore_ascii_case(expected))
474}
475
476fn action_matches_any(action: Option<&str>, expected: &[&str]) -> bool {
477 action.is_some_and(|candidate| {
478 expected
479 .iter()
480 .any(|expected_action| candidate.eq_ignore_ascii_case(expected_action))
481 })
482}
483
484pub fn unified_file_action_is(args: &Value, expected: &str) -> bool {
485 action_matches(unified_file_action(args), expected)
486}
487
488pub fn unified_file_action_in(args: &Value, expected: &[&str]) -> bool {
489 action_matches_any(unified_file_action(args), expected)
490}
491
492pub fn unified_exec_action_is(args: &Value, expected: &str) -> bool {
493 action_matches(unified_exec_action(args), expected)
494}
495
496pub fn unified_exec_action_in(args: &Value, expected: &[&str]) -> bool {
497 action_matches_any(unified_exec_action(args), expected)
498}
499
500pub fn unified_search_action_is(args: &Value, expected: &str) -> bool {
501 action_matches(unified_search_action(args), expected)
502}
503
504pub fn unified_search_action_in(args: &Value, expected: &[&str]) -> bool {
505 action_matches_any(unified_search_action(args), expected)
506}
507
508fn unified_exec_has_input(args: &Value) -> bool {
509 interactive_input_text(args).is_some()
510}
511
512fn get_field_case_insensitive<'a>(
513 args: &'a serde_json::Map<String, Value>,
514 key: &str,
515) -> Option<&'a Value> {
516 args.get(key).or_else(|| {
517 args.iter()
518 .find(|(name, _)| name.eq_ignore_ascii_case(key))
519 .map(|(_, value)| value)
520 })
521}
522
523fn has_meaningful_search_field(args: &serde_json::Map<String, Value>, key: &str) -> bool {
524 match get_field_case_insensitive(args, key) {
525 Some(Value::Null) | None => false,
526 Some(Value::String(text)) => !text.trim().is_empty(),
527 Some(Value::Array(values)) => !values.is_empty(),
528 Some(_) => true,
529 }
530}
531
532fn looks_like_list_glob_pattern(args: &serde_json::Map<String, Value>) -> bool {
533 let pattern = get_field_case_insensitive(args, "pattern")
534 .or_else(|| get_field_case_insensitive(args, "query"))
535 .and_then(Value::as_str)
536 .map(str::trim)
537 .filter(|pattern| !pattern.is_empty());
538 let Some(pattern) = pattern else {
539 return false;
540 };
541
542 let has_glob_wildcards =
543 pattern.contains('*') || pattern.contains('?') || pattern.contains('[');
544 if !has_glob_wildcards {
545 return false;
546 }
547
548 pattern.contains('/')
549 || pattern.contains('\\')
550 || pattern.starts_with("*.")
551 || pattern.contains("*.")
552}
553
554fn unified_search_action_from_object(args: &serde_json::Map<String, Value>) -> Option<&str> {
555 get_field_case_insensitive(args, "action")
556 .and_then(|value| value.as_str())
557 .or_else(|| {
558 let has_structural_workflow = get_field_case_insensitive(args, "workflow")
560 .and_then(Value::as_str)
561 .map(str::trim)
562 .is_some_and(|workflow| !workflow.is_empty());
563 let has_pattern = has_meaningful_search_field(args, "pattern")
564 || has_meaningful_search_field(args, "query");
565 let has_structural_hint = has_structural_workflow
566 || has_meaningful_search_field(args, "lang")
567 || has_meaningful_search_field(args, "selector")
568 || has_meaningful_search_field(args, "strictness")
569 || has_meaningful_search_field(args, "debug_query")
570 || has_meaningful_search_field(args, "globs")
571 || has_meaningful_search_field(args, "config_path")
572 || has_meaningful_search_field(args, "filter")
573 || has_meaningful_search_field(args, "skip_snapshot_tests");
574 let has_path = has_meaningful_search_field(args, "path");
575
576 if has_structural_workflow || (has_pattern && has_structural_hint) {
577 Some("structural")
578 } else if has_pattern && has_path && looks_like_list_glob_pattern(args) {
579 Some("list")
580 } else if has_pattern {
581 Some("grep")
582 } else if get_field_case_insensitive(args, "keyword").is_some() {
583 Some("tools")
584 } else if get_field_case_insensitive(args, "url").is_some() {
585 Some("web")
586 } else if get_field_case_insensitive(args, "sub_action").is_some()
587 || get_field_case_insensitive(args, "name").is_some()
588 {
589 Some("skill")
590 } else if get_field_case_insensitive(args, "scope").is_some() {
591 Some("errors")
592 } else if get_field_case_insensitive(args, "path").is_some() {
593 Some("list")
594 } else {
595 None
596 }
597 })
598}
599
600fn is_unified_search_arg_key(key: &str) -> bool {
601 matches!(
602 key.to_ascii_lowercase().as_str(),
603 "action"
604 | "pattern"
605 | "query"
606 | "path"
607 | "lang"
608 | "selector"
609 | "strictness"
610 | "debug_query"
611 | "workflow"
612 | "config_path"
613 | "filter"
614 | "globs"
615 | "context_lines"
616 | "max_results"
617 | "skip_snapshot_tests"
618 | "keyword"
619 | "url"
620 | "scope"
621 | "sub_action"
622 | "name"
623 )
624}
625
626fn object_has_unified_search_signal(args: &serde_json::Map<String, Value>) -> bool {
627 args.keys().any(|key| is_unified_search_arg_key(key))
628}
629
630fn parse_object_json_string(payload: &str) -> Option<serde_json::Map<String, Value>> {
631 let parsed = serde_json::from_str::<Value>(payload).ok()?;
632 parsed.as_object().cloned()
633}
634
635fn extract_unified_search_args_object(args: &Value) -> Option<serde_json::Map<String, Value>> {
636 match args {
637 Value::Object(args_obj) => {
638 if object_has_unified_search_signal(args_obj) {
639 return Some(args_obj.clone());
640 }
641
642 for wrapper in ["arguments", "args"] {
643 let Some(candidate) = get_field_case_insensitive(args_obj, wrapper) else {
644 continue;
645 };
646 match candidate {
647 Value::Object(inner_obj) => return Some(inner_obj.clone()),
648 Value::String(inner_str) => {
649 if let Some(parsed_obj) = parse_object_json_string(inner_str) {
650 return Some(parsed_obj);
651 }
652 }
653 _ => {}
654 }
655 }
656
657 Some(args_obj.clone())
658 }
659 Value::String(raw) => parse_object_json_string(raw),
660 _ => None,
661 }
662}
663
664pub fn unified_search_action(args: &Value) -> Option<&str> {
667 let args_obj = args.as_object()?;
668 unified_search_action_from_object(args_obj)
669}
670
671pub fn normalize_unified_search_args(args: &Value) -> Value {
673 let Some(args_obj) = extract_unified_search_args_object(args) else {
674 return args.clone();
675 };
676
677 let mut normalized = serde_json::Map::with_capacity(args_obj.len() + 1);
678 for (key, value) in &args_obj {
679 let canonical = if key.eq_ignore_ascii_case("action") {
680 "action"
681 } else if key.eq_ignore_ascii_case("pattern") {
682 "pattern"
683 } else if key.eq_ignore_ascii_case("query") {
684 "query"
685 } else if key.eq_ignore_ascii_case("path") {
686 "path"
687 } else if key.eq_ignore_ascii_case("lang") {
688 "lang"
689 } else if key.eq_ignore_ascii_case("selector") {
690 "selector"
691 } else if key.eq_ignore_ascii_case("strictness") {
692 "strictness"
693 } else if key.eq_ignore_ascii_case("debug_query") || key.eq_ignore_ascii_case("debug-query")
694 {
695 "debug_query"
696 } else if key.eq_ignore_ascii_case("workflow") {
697 "workflow"
698 } else if key.eq_ignore_ascii_case("config_path") || key.eq_ignore_ascii_case("config-path")
699 {
700 "config_path"
701 } else if key.eq_ignore_ascii_case("filter") {
702 "filter"
703 } else if key.eq_ignore_ascii_case("globs") {
704 "globs"
705 } else if key.eq_ignore_ascii_case("context_lines")
706 || key.eq_ignore_ascii_case("context-lines")
707 {
708 "context_lines"
709 } else if key.eq_ignore_ascii_case("max_results") || key.eq_ignore_ascii_case("max-results")
710 {
711 "max_results"
712 } else if key.eq_ignore_ascii_case("skip_snapshot_tests")
713 || key.eq_ignore_ascii_case("skip-snapshot-tests")
714 {
715 "skip_snapshot_tests"
716 } else if key.eq_ignore_ascii_case("keyword") {
717 "keyword"
718 } else if key.eq_ignore_ascii_case("url") {
719 "url"
720 } else if key.eq_ignore_ascii_case("scope") {
721 "scope"
722 } else if key.eq_ignore_ascii_case("sub_action") {
723 "sub_action"
724 } else if key.eq_ignore_ascii_case("name") {
725 "name"
726 } else if key.eq_ignore_ascii_case("name_pattern")
727 || key.eq_ignore_ascii_case("name-pattern")
728 {
729 "name_pattern"
730 } else {
731 key
732 };
733 normalized
734 .entry(canonical.to_string())
735 .or_insert_with(|| value.clone());
736 }
737
738 let inferred_action = unified_search_action_from_object(&normalized).map(|a| a.to_string());
739 if let Some(action) = inferred_action {
740 normalized
741 .entry("action".to_string())
742 .or_insert_with(|| Value::String(action));
743 }
744
745 let action = normalized
746 .get("action")
747 .and_then(Value::as_str)
748 .unwrap_or_default()
749 .to_string();
750 let keyword_alias = normalized
751 .get("keyword")
752 .and_then(Value::as_str)
753 .map(str::trim)
754 .filter(|value| !value.is_empty())
755 .map(str::to_string);
756 let pattern_alias = normalized
757 .get("pattern")
758 .and_then(Value::as_str)
759 .map(str::trim)
760 .filter(|value| !value.is_empty())
761 .map(str::to_string)
762 .or_else(|| {
763 normalized
764 .get("query")
765 .and_then(Value::as_str)
766 .map(str::trim)
767 .filter(|value| !value.is_empty())
768 .map(str::to_string)
769 })
770 .or_else(|| keyword_alias.clone());
771
772 if action.eq_ignore_ascii_case("grep")
773 && let Some(pattern) = pattern_alias.clone()
774 {
775 normalized
776 .entry("pattern".to_string())
777 .or_insert_with(|| Value::String(pattern));
778 }
779
780 if action.eq_ignore_ascii_case("list")
781 && !normalized.contains_key("pattern")
782 && !normalized.contains_key("name_pattern")
783 {
784 if let Some(keyword) = keyword_alias {
785 normalized
786 .entry("name_pattern".to_string())
787 .or_insert_with(|| Value::String(keyword));
788 } else if let Some(pattern) = pattern_alias {
789 normalized
790 .entry("pattern".to_string())
791 .or_insert_with(|| Value::String(pattern));
792 }
793 }
794
795 Value::Object(normalized)
796}
797
798#[cfg(test)]
799mod tests {
800 use super::{
801 canonical_unified_exec_tool_name, classify_tool_intent, is_command_run_tool_call,
802 is_edited_file_conflict_guarded_call, is_parallel_safe_call, normalize_unified_search_args,
803 remap_unified_file_command_args_to_unified_exec, should_use_spool_reference_only,
804 unified_file_action,
805 };
806 use crate::config::constants::tools;
807 use serde_json::json;
808
809 #[test]
810 fn unified_file_read_is_retry_safe() {
811 let intent = classify_tool_intent(
812 tools::UNIFIED_FILE,
813 &json!({"action": "read", "path": "README.md"}),
814 );
815 assert!(!intent.mutating);
816 assert!(intent.readonly_unified_action);
817 assert!(intent.retry_safe);
818 }
819
820 #[test]
821 fn unified_exec_poll_is_retry_safe() {
822 let intent = classify_tool_intent(
823 tools::UNIFIED_EXEC,
824 &json!({"action": "poll", "session_id": 1}),
825 );
826 assert!(!intent.mutating);
827 assert!(intent.readonly_unified_action);
828 assert!(intent.retry_safe);
829 }
830
831 #[test]
832 fn unified_exec_inspect_is_retry_safe() {
833 let intent = classify_tool_intent(
834 tools::UNIFIED_EXEC,
835 &json!({"action": "inspect", "spool_path": ".vtcode/context/tool_outputs/run-1.txt"}),
836 );
837 assert!(!intent.mutating);
838 assert!(intent.readonly_unified_action);
839 assert!(intent.retry_safe);
840 }
841
842 #[test]
843 fn unified_exec_continue_without_input_is_retry_safe() {
844 let intent = classify_tool_intent(
845 tools::UNIFIED_EXEC,
846 &json!({"action": "continue", "session_id": "run-1"}),
847 );
848 assert!(!intent.mutating);
849 assert!(intent.readonly_unified_action);
850 assert!(intent.retry_safe);
851 }
852
853 #[test]
854 fn unified_exec_continue_with_input_is_mutating_and_destructive() {
855 let intent = classify_tool_intent(
856 tools::UNIFIED_EXEC,
857 &json!({"action": "continue", "session_id": "run-1", "input": "q"}),
858 );
859 assert!(intent.mutating);
860 assert!(intent.destructive);
861 assert!(!intent.readonly_unified_action);
862 assert!(!intent.retry_safe);
863 }
864
865 #[test]
866 fn unified_exec_continue_with_empty_input_stays_retry_safe() {
867 let intent = classify_tool_intent(
868 tools::UNIFIED_EXEC,
869 &json!({"action": "continue", "session_id": "run-1", "input": ""}),
870 );
871 assert!(!intent.mutating);
872 assert!(intent.readonly_unified_action);
873 assert!(intent.retry_safe);
874 }
875
876 #[test]
877 fn unified_exec_run_is_mutating_and_destructive() {
878 let intent = classify_tool_intent(
879 tools::UNIFIED_EXEC,
880 &json!({"action": "run", "command": "echo hi"}),
881 );
882 assert!(intent.mutating);
883 assert!(intent.destructive);
884 assert!(!intent.retry_safe);
885 }
886
887 #[test]
888 fn unified_exec_run_allowlisted_is_read_only() {
889 let intent = classify_tool_intent(
890 tools::UNIFIED_EXEC,
891 &json!({"action": "run", "command": "rg planning_active src"}),
892 );
893 assert!(!intent.mutating);
894 assert!(intent.readonly_unified_action);
895 assert!(intent.retry_safe);
896 }
897
898 #[test]
899 fn unified_exec_run_dry_run_is_read_only() {
900 let intent = classify_tool_intent(
901 tools::UNIFIED_EXEC,
902 &json!({"action": "run", "command": "npm install --dry-run"}),
903 );
904 assert!(!intent.mutating);
905 assert!(intent.readonly_unified_action);
906 assert!(intent.retry_safe);
907 }
908
909 #[test]
910 fn parallel_safe_calls_reject_control_and_exec_paths() {
911 assert!(is_parallel_safe_call(
912 tools::READ_FILE,
913 &json!({"path": "README.md"})
914 ));
915 assert!(!is_parallel_safe_call(tools::LIST_PTY_SESSIONS, &json!({})));
916 assert!(!is_parallel_safe_call(
917 tools::REQUEST_USER_INPUT,
918 &json!({"questions": []})
919 ));
920 assert!(!is_parallel_safe_call(
921 tools::UNIFIED_EXEC,
922 &json!({"action": "inspect", "session_id": "run-1"})
923 ));
924 }
925
926 #[test]
927 fn unified_exec_cmd_alias_infers_run() {
928 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"cmd": "echo hi"}));
929 assert!(intent.mutating);
930 assert!(intent.destructive);
931 assert!(!intent.readonly_unified_action);
932 }
933
934 #[test]
935 fn unified_exec_chars_alias_infers_write() {
936 let intent = classify_tool_intent(
937 tools::UNIFIED_EXEC,
938 &json!({"session_id": "abc123", "chars": "status\n"}),
939 );
940 assert!(intent.mutating);
941 assert!(intent.destructive);
942 assert!(!intent.readonly_unified_action);
943 }
944
945 #[test]
946 fn unified_exec_text_alias_infers_write() {
947 let intent = classify_tool_intent(
948 tools::UNIFIED_EXEC,
949 &json!({"session_id": "abc123", "text": "status\n"}),
950 );
951 assert!(intent.mutating);
952 assert!(intent.destructive);
953 assert!(!intent.readonly_unified_action);
954 }
955
956 #[test]
957 fn unified_exec_spool_path_alias_infers_inspect() {
958 let intent = classify_tool_intent(
959 tools::UNIFIED_EXEC,
960 &json!({"spool_path": ".vtcode/context/tool_outputs/run-1.txt"}),
961 );
962 assert!(!intent.mutating);
963 assert!(!intent.destructive);
964 assert!(intent.readonly_unified_action);
965 }
966
967 #[test]
968 fn unified_exec_compact_session_alias_infers_poll() {
969 let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"s": "run-1"}));
970 assert!(!intent.mutating);
971 assert!(!intent.destructive);
972 assert!(intent.readonly_unified_action);
973 }
974
975 #[test]
976 fn unified_file_input_patch_infers_patch() {
977 let args = json!({
978 "input": "*** Begin Patch\n*** End Patch\n"
979 });
980 let action = unified_file_action(&args);
981 assert_eq!(action, Some("patch"));
982 }
983
984 #[test]
985 fn unified_file_raw_patch_infers_patch() {
986 let args = json!("*** Begin Patch\n*** Update File: src/main.rs\n*** End Patch\n");
987 let action = unified_file_action(&args);
988 assert_eq!(action, Some("patch"));
989 }
990
991 #[test]
992 fn unified_file_unknown_args_require_action() {
993 let args = json!({
994 "unexpected": true
995 });
996 let action = unified_file_action(&args);
997 assert_eq!(action, None);
998 }
999
1000 #[test]
1001 fn unified_file_compact_path_alias_infers_read() {
1002 let args = json!({
1003 "p": "README.md"
1004 });
1005 let action = unified_file_action(&args);
1006 assert_eq!(action, Some("read"));
1007 }
1008
1009 #[test]
1010 fn remap_unified_file_command_args_maps_command_payload_to_unified_exec() {
1011 let remapped = remap_unified_file_command_args_to_unified_exec(&json!({
1012 "command": "cargo check",
1013 "cwd": ".",
1014 "timeout_ms": 1000
1015 }))
1016 .expect("command payload should remap");
1017
1018 assert_eq!(remapped["action"], "run");
1019 assert_eq!(remapped["command"], "cargo check");
1020 assert_eq!(remapped["cwd"], ".");
1021 assert_eq!(remapped["timeout_ms"], 1000);
1022 }
1023
1024 #[test]
1025 fn remap_unified_file_command_args_accepts_exec_action_aliases() {
1026 let remapped = remap_unified_file_command_args_to_unified_exec(&json!({
1027 "action": "shell",
1028 "cmd": "echo ok"
1029 }))
1030 .expect("shell action alias should remap");
1031
1032 assert_eq!(remapped["action"], "run");
1033 assert_eq!(remapped["command"], "echo ok");
1034 }
1035
1036 #[test]
1037 fn remap_unified_file_command_args_rejects_non_command_actions() {
1038 let remapped = remap_unified_file_command_args_to_unified_exec(&json!({
1039 "action": "read",
1040 "command": "echo ok"
1041 }));
1042
1043 assert_eq!(remapped, None);
1044 }
1045
1046 #[test]
1047 fn edited_file_conflict_guard_accepts_supported_mutations() {
1048 assert!(is_edited_file_conflict_guarded_call(
1049 tools::WRITE_FILE,
1050 &json!({"path": "README.md", "content": "agent"})
1051 ));
1052 assert!(is_edited_file_conflict_guarded_call(
1053 tools::CREATE_FILE,
1054 &json!({"path": "README.md", "content": "agent"})
1055 ));
1056 assert!(is_edited_file_conflict_guarded_call(
1057 tools::EDIT_FILE,
1058 &json!({"path": "README.md", "old_str": "a", "new_str": "b"})
1059 ));
1060 assert!(is_edited_file_conflict_guarded_call(
1061 tools::APPLY_PATCH,
1062 &json!({"patch": "*** Begin Patch\n*** End Patch\n"})
1063 ));
1064 assert!(is_edited_file_conflict_guarded_call(
1065 tools::UNIFIED_FILE,
1066 &json!({"action": "write", "path": "README.md", "content": "agent"})
1067 ));
1068 assert!(is_edited_file_conflict_guarded_call(
1069 tools::UNIFIED_FILE,
1070 &json!({"action": "create", "path": "README.md", "content": "agent"})
1071 ));
1072 assert!(is_edited_file_conflict_guarded_call(
1073 tools::UNIFIED_FILE,
1074 &json!({"patch": "*** Begin Patch\n*** End Patch\n"})
1075 ));
1076 }
1077
1078 #[test]
1079 fn edited_file_conflict_guard_rejects_non_guarded_calls() {
1080 assert!(!is_edited_file_conflict_guarded_call(
1081 tools::READ_FILE,
1082 &json!({"path": "README.md"})
1083 ));
1084 assert!(!is_edited_file_conflict_guarded_call(
1085 tools::GREP_FILE,
1086 &json!({"pattern": "needle", "path": "."})
1087 ));
1088 assert!(!is_edited_file_conflict_guarded_call(
1089 tools::LIST_FILES,
1090 &json!({"path": "."})
1091 ));
1092 assert!(!is_edited_file_conflict_guarded_call(
1093 tools::UNIFIED_FILE,
1094 &json!({"action": "read", "path": "README.md"})
1095 ));
1096 assert!(!is_edited_file_conflict_guarded_call(
1097 tools::UNIFIED_FILE,
1098 &json!({"action": "delete", "path": "README.md"})
1099 ));
1100 assert!(!is_edited_file_conflict_guarded_call(
1101 tools::UNIFIED_EXEC,
1102 &json!({"action": "run", "command": "git status"})
1103 ));
1104 }
1105
1106 #[test]
1107 fn normalize_unified_search_args_canonicalizes_case_and_infers_action() {
1108 let normalized = normalize_unified_search_args(&json!({
1109 "Pattern": "needle",
1110 "Path": "."
1111 }));
1112
1113 assert_eq!(normalized["pattern"], "needle");
1114 assert_eq!(normalized["path"], ".");
1115 assert_eq!(normalized["action"], "grep");
1116 }
1117
1118 #[test]
1119 fn normalize_unified_search_args_infers_list_for_glob_patterns() {
1120 let normalized = normalize_unified_search_args(&json!({
1121 "Pattern": "**/*.rs",
1122 "Path": "src"
1123 }));
1124
1125 assert_eq!(normalized["pattern"], "**/*.rs");
1126 assert_eq!(normalized["path"], "src");
1127 assert_eq!(normalized["action"], "list");
1128 }
1129
1130 #[test]
1131 fn normalize_unified_search_args_unwraps_arguments_object() {
1132 let normalized = normalize_unified_search_args(&json!({
1133 "arguments": {
1134 "Pattern": "needle",
1135 "Path": "."
1136 }
1137 }));
1138
1139 assert_eq!(normalized["pattern"], "needle");
1140 assert_eq!(normalized["path"], ".");
1141 assert_eq!(normalized["action"], "grep");
1142 }
1143
1144 #[test]
1145 fn normalize_unified_search_args_parses_arguments_json_string() {
1146 let normalized = normalize_unified_search_args(&json!({
1147 "args": "{\"Pattern\":\"needle\",\"Path\":\".\"}"
1148 }));
1149
1150 assert_eq!(normalized["pattern"], "needle");
1151 assert_eq!(normalized["path"], ".");
1152 assert_eq!(normalized["action"], "grep");
1153 }
1154
1155 #[test]
1156 fn normalize_unified_search_args_keeps_structural_action_explicit() {
1157 let normalized = normalize_unified_search_args(&json!({
1158 "Action": "structural",
1159 "Pattern": "fn $NAME() {}",
1160 "Lang": "rust",
1161 "Debug-Query": "ast",
1162 "Max-Results": 5
1163 }));
1164
1165 assert_eq!(normalized["action"], "structural");
1166 assert_eq!(normalized["pattern"], "fn $NAME() {}");
1167 assert_eq!(normalized["lang"], "rust");
1168 assert_eq!(normalized["debug_query"], "ast");
1169 assert_eq!(normalized["max_results"], 5);
1170 }
1171
1172 #[test]
1173 fn normalize_unified_search_args_infers_structural_from_pattern_and_lang() {
1174 let normalized = normalize_unified_search_args(&json!({
1175 "Pattern": "fn $NAME($$$ARGS) { $$$BODY }",
1176 "Lang": "rust",
1177 "Path": "."
1178 }));
1179
1180 assert_eq!(normalized["action"], "structural");
1181 assert_eq!(normalized["lang"], "rust");
1182 assert_eq!(normalized["path"], ".");
1183 }
1184
1185 #[test]
1186 fn normalize_unified_search_args_canonicalizes_structural_workflow_fields() {
1187 let normalized = normalize_unified_search_args(&json!({
1188 "Workflow": "scan",
1189 "Config-Path": "config/sgconfig.yml",
1190 "Filter": "rust/no-iterator-for-each",
1191 "Skip-Snapshot-Tests": true
1192 }));
1193
1194 assert_eq!(normalized["workflow"], "scan");
1195 assert_eq!(normalized["config_path"], "config/sgconfig.yml");
1196 assert_eq!(normalized["filter"], "rust/no-iterator-for-each");
1197 assert_eq!(normalized["skip_snapshot_tests"], true);
1198 assert_eq!(normalized["action"], "structural");
1199 }
1200
1201 #[test]
1202 fn normalize_unified_search_args_maps_keyword_to_pattern_for_grep() {
1203 let normalized = normalize_unified_search_args(&json!({
1204 "action": "grep",
1205 "keyword": "system prompt",
1206 "path": "src"
1207 }));
1208
1209 assert_eq!(normalized["action"], "grep");
1210 assert_eq!(normalized["pattern"], "system prompt");
1211 assert_eq!(normalized["path"], "src");
1212 }
1213
1214 #[test]
1215 fn normalize_unified_search_args_maps_keyword_to_name_pattern_for_list() {
1216 let normalized = normalize_unified_search_args(&json!({
1217 "action": "list",
1218 "keyword": "agent",
1219 "path": "vtcode-core/src",
1220 "mode": "file"
1221 }));
1222
1223 assert_eq!(normalized["action"], "list");
1224 assert_eq!(normalized["name_pattern"], "agent");
1225 assert_eq!(normalized["path"], "vtcode-core/src");
1226 assert_eq!(normalized["mode"], "file");
1227 }
1228
1229 #[test]
1230 fn normalize_unified_search_args_maps_query_to_pattern_for_grep() {
1231 let normalized = normalize_unified_search_args(&json!({
1232 "action": "grep",
1233 "query": "Result<",
1234 "path": "vtcode-core/src"
1235 }));
1236
1237 assert_eq!(normalized["action"], "grep");
1238 assert_eq!(normalized["pattern"], "Result<");
1239 assert_eq!(normalized["path"], "vtcode-core/src");
1240 }
1241
1242 #[test]
1243 fn legacy_search_aliases_are_readonly() {
1244 let grep_intent =
1245 classify_tool_intent(tools::GREP_FILE, &json!({"pattern": "needle", "path": "."}));
1246 assert!(!grep_intent.mutating);
1247 assert!(!grep_intent.destructive);
1248
1249 let list_intent = classify_tool_intent(tools::LIST_FILES, &json!({"path": "."}));
1250 assert!(!list_intent.mutating);
1251 assert!(!list_intent.destructive);
1252 }
1253
1254 #[test]
1255 fn canonical_unified_exec_tool_name_normalizes_exec_aliases() {
1256 for alias in [
1257 tools::UNIFIED_EXEC,
1258 tools::RUN_PTY_CMD,
1259 tools::SEND_PTY_INPUT,
1260 tools::READ_PTY_SESSION,
1261 tools::LIST_PTY_SESSIONS,
1262 tools::CLOSE_PTY_SESSION,
1263 tools::EXECUTE_CODE,
1264 tools::EXEC_PTY_CMD,
1265 tools::EXEC_COMMAND,
1266 tools::WRITE_STDIN,
1267 tools::SHELL,
1268 "bash",
1269 "exec",
1270 "container.exec",
1271 ] {
1272 assert_eq!(
1273 canonical_unified_exec_tool_name(alias),
1274 Some(tools::UNIFIED_EXEC)
1275 );
1276 }
1277 }
1278
1279 #[test]
1280 fn spool_reference_only_detects_exec_aliases() {
1281 assert!(should_use_spool_reference_only(
1282 Some(tools::RUN_PTY_CMD),
1283 &json!({"spool_path": ".vtcode/context/tool_outputs/run-1.txt"})
1284 ));
1285 }
1286
1287 #[test]
1288 fn spool_reference_only_detects_exec_payload_without_tool_name() {
1289 assert!(should_use_spool_reference_only(
1290 None,
1291 &json!({
1292 "command": "cargo check",
1293 "spool_path": ".vtcode/context/tool_outputs/run-1.txt",
1294 "exit_code": 0
1295 })
1296 ));
1297 }
1298
1299 #[test]
1300 fn spool_reference_only_skips_loop_recovery_payloads() {
1301 assert!(!should_use_spool_reference_only(
1302 Some(tools::UNIFIED_EXEC),
1303 &json!({
1304 "spool_path": ".vtcode/context/tool_outputs/run-1.txt",
1305 "exit_code": 0,
1306 "loop_detected": true
1307 })
1308 ));
1309 }
1310
1311 #[test]
1312 fn is_command_run_tool_call_only_accepts_run_actions() {
1313 assert!(is_command_run_tool_call(
1314 tools::RUN_PTY_CMD,
1315 &json!({"command": "cargo check"})
1316 ));
1317 assert!(is_command_run_tool_call(
1318 tools::UNIFIED_EXEC,
1319 &json!({"action": "run", "command": "cargo check"})
1320 ));
1321 assert!(is_command_run_tool_call(
1322 tools::EXEC_COMMAND,
1323 &json!({"cmd": "cargo check"})
1324 ));
1325 assert!(!is_command_run_tool_call(
1326 tools::UNIFIED_EXEC,
1327 &json!({"action": "poll", "session_id": "run-1"})
1328 ));
1329 assert!(!is_command_run_tool_call(
1330 tools::WRITE_STDIN,
1331 &json!({"session_id": "run-1", "chars": "q"})
1332 ));
1333 }
1334}