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