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