Skip to main content

vtcode_core/tools/
tool_intent.rs

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
398/// Determine the action for unified_file tool based on args.
399/// Returns the action string or a default if inference is possible.
400pub 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
438/// Determine the action for unified_exec tool based on args.
439/// Returns the action string or None if no inference is possible.
440pub fn unified_exec_action(args: &Value) -> Option<&str> {
441    args.get("action").and_then(|v| v.as_str()).or_else(|| {
442        // Check for standard command fields
443        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            // Smart action inference based on parameters
559            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
664/// Determine the action for unified_search tool based on args.
665/// Returns the action string or None if no inference is possible.
666pub 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
671/// Normalize unified_search args so case/shape variants still pass schema checks.
672pub 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}