Skip to main content

vtcode_core/tools/registry/
execution_kernel.rs

1use anyhow::{Result, anyhow};
2use serde_json::Map;
3use serde_json::Value;
4use serde_json::json;
5
6use crate::config::constants::tools as tool_names;
7use crate::tools::apply_patch::{UNIFIED_FILE_MAX_PAYLOAD_BYTES_ENV, effective_max_payload_bytes};
8use crate::tools::error_messages::agent_execution;
9use crate::tools::names::canonical_tool_name;
10use crate::tools::validation::{commands, paths};
11
12use super::ToolRegistry;
13
14const DESCRIPTION_FIELD: &str = "description";
15const DETAILS_ALIAS_FIELD: &str = "details";
16
17#[derive(Debug, Clone)]
18pub struct ToolPreflightOutcome {
19    pub normalized_tool_name: String,
20    pub readonly_classification: bool,
21    pub parallel_safe_after_preflight: bool,
22    pub effective_args: Value,
23}
24
25fn required_args_for_tool(tool_name: &str) -> &'static [&'static str] {
26    match tool_name {
27        tool_names::READ_FILE => &["path"],
28        tool_names::WRITE_FILE => &["path", "content"],
29        tool_names::EDIT_FILE => &["path", "old_str", "new_str"],
30        tool_names::RUN_PTY_CMD | tool_names::CREATE_PTY_SESSION => &["command"],
31        tool_names::APPLY_PATCH => &["patch"],
32        _ => &[],
33    }
34}
35
36fn is_missing_arg_value(args: &Value, key: &str) -> bool {
37    match args.get(key) {
38        Some(v) => v.is_null() || (v.is_string() && v.as_str().is_none_or(|s| s.trim().is_empty())),
39        None => true,
40    }
41}
42
43fn is_missing_apply_patch_payload(args: &Value) -> bool {
44    if args.is_string() {
45        return false;
46    }
47
48    let has_object_payload = |key: &str| args.get(key).is_some_and(|value| !value.is_null());
49    !(has_object_payload("patch") || has_object_payload("input"))
50}
51
52fn is_missing_required_arg(tool_name: &str, args: &Value, key: &str) -> bool {
53    if tool_name == tool_names::READ_FILE && key == "path" {
54        return ["path", "file_path", "filepath", "target_path", "file"]
55            .iter()
56            .all(|candidate| is_missing_arg_value(args, candidate));
57    }
58    if tool_name == tool_names::EDIT_FILE {
59        return match key {
60            "old_str" => {
61                is_missing_arg_value(args, "old_str") && is_missing_arg_value(args, "old_string")
62            }
63            "new_str" => {
64                is_missing_arg_value(args, "new_str") && is_missing_arg_value(args, "new_string")
65            }
66            _ => is_missing_arg_value(args, key),
67        };
68    }
69    if tool_name == tool_names::APPLY_PATCH && key == "patch" {
70        return is_missing_apply_patch_payload(args);
71    }
72    is_missing_arg_value(args, key)
73}
74
75#[cfg(test)]
76fn parse_unified_file_max_payload_bytes(raw: Option<&str>) -> Option<usize> {
77    raw.and_then(|value| value.trim().parse::<usize>().ok())
78        .filter(|value| *value >= 1024)
79}
80
81fn configured_unified_file_max_payload_bytes() -> usize {
82    // Single source of truth for the cap: both preflight and the post-decode
83    // size check in `apply_patch` resolve the env-var override the same way
84    // (including the 1 KiB safety floor), so the two stages always agree.
85    effective_max_payload_bytes()
86}
87
88fn schema_uses_description_alias(schema_properties: &Map<String, Value>) -> bool {
89    schema_properties.contains_key(DESCRIPTION_FIELD)
90        && !schema_properties.contains_key(DETAILS_ALIAS_FIELD)
91}
92
93fn normalize_description_alias(
94    object: &mut Map<String, Value>,
95    schema_properties: &Map<String, Value>,
96) -> bool {
97    if !schema_uses_description_alias(schema_properties) || object.contains_key(DESCRIPTION_FIELD) {
98        return false;
99    }
100
101    let Some(details) = object.remove(DETAILS_ALIAS_FIELD) else {
102        return false;
103    };
104    object.insert(DESCRIPTION_FIELD.to_string(), details);
105    true
106}
107
108fn normalize_schema_aliases_in_place(value: &mut Value, schema: &Value) -> bool {
109    let Some(schema_object) = schema.as_object() else {
110        return false;
111    };
112
113    let mut changed = false;
114
115    if let Value::Object(object) = value
116        && let Some(properties) = schema_object.get("properties").and_then(Value::as_object)
117    {
118        changed |= normalize_description_alias(object, properties);
119        for (property_name, property_schema) in properties {
120            if let Some(property_value) = object.get_mut(property_name) {
121                changed |= normalize_schema_aliases_in_place(property_value, property_schema);
122            }
123        }
124    }
125
126    if let Value::Array(items) = value
127        && let Some(items_schema) = schema_object.get("items")
128    {
129        for item in items {
130            changed |= normalize_schema_aliases_in_place(item, items_schema);
131        }
132    }
133
134    for keyword in ["allOf", "anyOf", "oneOf"] {
135        if let Some(branches) = schema_object.get(keyword).and_then(Value::as_array) {
136            for branch in branches {
137                changed |= normalize_schema_aliases_in_place(value, branch);
138            }
139        }
140    }
141    for keyword in ["if", "then", "else"] {
142        if let Some(branch) = schema_object.get(keyword) {
143            changed |= normalize_schema_aliases_in_place(value, branch);
144        }
145    }
146
147    changed
148}
149
150fn normalize_details_aliases(args: &Value, parameter_schema: Option<&Value>) -> Option<Value> {
151    let schema = parameter_schema?;
152    let mut normalized = args.clone();
153    normalize_schema_aliases_in_place(&mut normalized, schema).then_some(normalized)
154}
155
156fn serialized_payload_size_bytes(args: &Value) -> usize {
157    serde_json::to_vec(args)
158        .map(|bytes| bytes.len())
159        .unwrap_or_else(|_| args.to_string().len())
160}
161
162fn unified_file_action_for_limit(normalized_tool_name: &str, args: &Value) -> Option<String> {
163    if normalized_tool_name == tool_names::UNIFIED_FILE {
164        return crate::tools::tool_intent::unified_file_action(args)
165            .map(|a| a.to_ascii_lowercase());
166    }
167    if normalized_tool_name == tool_names::APPLY_PATCH {
168        return Some("patch".to_string());
169    }
170    if normalized_tool_name == tool_names::EDIT_FILE {
171        return Some("edit".to_string());
172    }
173    None
174}
175
176pub(super) fn remap_public_unified_file_alias_args(
177    requested_name: &str,
178    normalized_tool_name: &str,
179    args: &Value,
180) -> Option<Value> {
181    if normalized_tool_name != tool_names::UNIFIED_FILE {
182        return None;
183    }
184
185    let obj = args.as_object()?;
186    if obj.contains_key("action") {
187        return None;
188    }
189
190    let action = super::assembly::public_tool_name_candidates(requested_name)
191        .into_iter()
192        .find_map(|candidate| match candidate.as_str() {
193            tool_names::READ_FILE => Some("read"),
194            tool_names::WRITE_FILE => Some("write"),
195            tool_names::EDIT_FILE => Some("edit"),
196            tool_names::DELETE_FILE => Some("delete"),
197            tool_names::MOVE_FILE => Some("move"),
198            tool_names::COPY_FILE => Some("copy"),
199            tool_names::CREATE_FILE => Some("write"),
200            _ => None,
201        })?;
202
203    let mut mapped = obj.clone();
204    mapped.insert("action".to_string(), Value::String(action.to_string()));
205    Some(Value::Object(mapped))
206}
207
208fn enforce_unified_file_payload_limit(
209    normalized_tool_name: &str,
210    args: &Value,
211    max_payload_bytes: usize,
212    failures: &mut Vec<String>,
213) {
214    let Some(action) = unified_file_action_for_limit(normalized_tool_name, args) else {
215        return;
216    };
217    if action != "patch" && action != "edit" {
218        return;
219    }
220
221    let payload_bytes = serialized_payload_size_bytes(args);
222    if payload_bytes <= max_payload_bytes {
223        return;
224    }
225
226    tracing::warn!(
227        tool = %normalized_tool_name,
228        action = %action,
229        payload_bytes,
230        max_payload_bytes,
231        "Rejected oversized patch/edit payload during preflight"
232    );
233
234    failures.push(format!(
235        "Patch/edit payload too large for '{}': action='{}', payload={} bytes exceeds {} bytes. \
236         Split the change into smaller patch/edit calls, or raise {} for intentional large edits.",
237        normalized_tool_name,
238        action,
239        payload_bytes,
240        max_payload_bytes,
241        UNIFIED_FILE_MAX_PAYLOAD_BYTES_ENV
242    ));
243}
244
245pub(super) fn normalize_tool_args<'a>(
246    normalized_tool_name: &str,
247    args: &'a Value,
248    parameter_schema: Option<&Value>,
249) -> Result<std::borrow::Cow<'a, Value>> {
250    let mut normalized = std::borrow::Cow::Borrowed(args);
251
252    if normalized_tool_name == tool_names::APPLY_PATCH
253        && let Some(raw_patch) = normalized.as_ref().as_str()
254    {
255        normalized = std::borrow::Cow::Owned(json!({ "input": raw_patch }));
256    }
257
258    if matches!(
259        normalized_tool_name,
260        tool_names::RUN_PTY_CMD
261            | tool_names::CREATE_PTY_SESSION
262            | tool_names::UNIFIED_EXEC
263            | tool_names::SHELL
264    ) {
265        let shell_args = crate::tools::command_args::normalize_shell_args(normalized.as_ref())
266            .map_err(|error| anyhow!(error))?;
267        if shell_args != *normalized.as_ref() {
268            normalized = std::borrow::Cow::Owned(shell_args);
269        }
270    }
271
272    if normalized_tool_name == tool_names::UNIFIED_SEARCH {
273        let search_args =
274            crate::tools::tool_intent::normalize_unified_search_args(normalized.as_ref());
275        if search_args != *normalized.as_ref() {
276            normalized = std::borrow::Cow::Owned(search_args);
277        }
278    }
279
280    if let Some(alias_args) = normalize_details_aliases(normalized.as_ref(), parameter_schema) {
281        normalized = std::borrow::Cow::Owned(alias_args);
282    }
283
284    Ok(normalized)
285}
286
287pub(super) fn preflight_validate_call(
288    registry: &ToolRegistry,
289    name: &str,
290    args: &Value,
291) -> Result<ToolPreflightOutcome> {
292    let normalized_tool_name = registry
293        .resolve_public_tool(name)
294        .map(|resolution| resolution.registration_name().to_string())
295        .map_err(|_| anyhow!("Unknown tool: {}", canonical_tool_name(name)))?;
296
297    if let Some(remapped_args) =
298        remap_public_unified_file_alias_args(name, &normalized_tool_name, args)
299    {
300        preflight_validate_resolved_call(registry, &normalized_tool_name, &remapped_args)
301    } else {
302        preflight_validate_resolved_call(registry, &normalized_tool_name, args)
303    }
304}
305
306pub(super) fn preflight_validate_resolved_call(
307    registry: &ToolRegistry,
308    normalized_tool_name: &str,
309    args: &Value,
310) -> Result<ToolPreflightOutcome> {
311    let mut effective_tool_name = normalized_tool_name.to_string();
312    let parameter_schema = registry
313        .inventory
314        .registration_for(normalized_tool_name)
315        .and_then(|registration| registration.parameter_schema().cloned());
316    let mut validation_args =
317        normalize_tool_args(normalized_tool_name, args, parameter_schema.as_ref())?;
318    if normalized_tool_name == tool_names::UNIFIED_FILE
319        && let Some(remapped_args) =
320            crate::tools::tool_intent::remap_unified_file_command_args_to_unified_exec(
321                validation_args.as_ref(),
322            )
323    {
324        effective_tool_name = tool_names::UNIFIED_EXEC.to_string();
325        let exec_schema = registry
326            .inventory
327            .registration_for(&effective_tool_name)
328            .and_then(|registration| registration.parameter_schema().cloned());
329        validation_args = std::borrow::Cow::Owned(
330            normalize_tool_args(&effective_tool_name, &remapped_args, exec_schema.as_ref())?
331                .into_owned(),
332        );
333    }
334
335    let required = required_args_for_tool(&effective_tool_name);
336    let mut failures = Vec::new();
337    for key in required {
338        if is_missing_required_arg(&effective_tool_name, validation_args.as_ref(), key) {
339            failures.push(format!("Missing required argument: {}", key));
340        }
341    }
342    if effective_tool_name == tool_names::UNIFIED_EXEC {
343        failures.extend(
344            crate::tools::command_args::unified_exec_missing_required_args(
345                validation_args.as_ref(),
346            )
347            .into_iter()
348            .map(|key| format!("Missing required argument: {}", key)),
349        );
350    }
351
352    if let Some(path) = validation_args
353        .as_ref()
354        .get("path")
355        .and_then(|v| v.as_str())
356        .or_else(|| {
357            validation_args
358                .as_ref()
359                .get("file_path")
360                .and_then(|v| v.as_str())
361        })
362        .or_else(|| {
363            validation_args
364                .as_ref()
365                .get("filepath")
366                .and_then(|v| v.as_str())
367        })
368        .or_else(|| {
369            validation_args
370                .as_ref()
371                .get("target_path")
372                .and_then(|v| v.as_str())
373        })
374        .or_else(|| {
375            validation_args
376                .as_ref()
377                .get("file")
378                .and_then(|v| v.as_str())
379        })
380        && let Err(err) = paths::validate_path_safety(path)
381    {
382        failures.push(format!("Path security check failed: {}", err));
383    }
384
385    let should_validate_command = matches!(
386        effective_tool_name.as_str(),
387        tool_names::RUN_PTY_CMD | tool_names::CREATE_PTY_SESSION | tool_names::SHELL
388    ) || (effective_tool_name == tool_names::UNIFIED_EXEC
389        && crate::tools::command_args::unified_exec_requires_command_safety(
390            validation_args.as_ref(),
391        ));
392    if should_validate_command
393        && let Some(command) = crate::tools::command_args::command_text(validation_args.as_ref())
394            .ok()
395            .flatten()
396        && let Err(err) = commands::validate_command_safety(&command)
397    {
398        failures.push(format!("Command security check failed: {}", err));
399    }
400    enforce_unified_file_payload_limit(
401        &effective_tool_name,
402        validation_args.as_ref(),
403        configured_unified_file_max_payload_bytes(),
404        &mut failures,
405    );
406
407    if !failures.is_empty() {
408        return Err(anyhow!(
409            "Tool preflight validation failed for '{}': {}",
410            effective_tool_name,
411            failures.join("; ")
412        ));
413    }
414
415    if effective_tool_name == tool_names::UNIFIED_EXEC
416        && crate::tools::tool_intent::unified_exec_action(validation_args.as_ref()).is_none()
417    {
418        return Err(anyhow!(
419            "Invalid arguments for tool '{}': missing action; provide `action` or inferable exec arguments",
420            effective_tool_name
421        ));
422    }
423    if effective_tool_name == tool_names::UNIFIED_SEARCH
424        && crate::tools::tool_intent::unified_search_action(validation_args.as_ref()).is_none()
425    {
426        return Err(anyhow!(
427            "Invalid arguments for tool '{}': missing action; provide `action` or inferable search arguments",
428            effective_tool_name
429        ));
430    }
431    let effective_parameter_schema = registry
432        .inventory
433        .registration_for(&effective_tool_name)
434        .and_then(|registration| registration.parameter_schema().cloned());
435    if let Some(schema) = effective_parameter_schema.as_ref()
436        && let Err(errors) = jsonschema::validate(schema, validation_args.as_ref())
437    {
438        return Err(anyhow!(
439            "Invalid arguments for tool '{}': {}",
440            effective_tool_name,
441            errors
442        ));
443    }
444
445    let intent = crate::tools::tool_intent::classify_tool_intent(
446        &effective_tool_name,
447        validation_args.as_ref(),
448    );
449    let readonly_classification = !intent.mutating;
450    if registry.is_plan_mode()
451        && !registry.is_plan_mode_allowed(&effective_tool_name, validation_args.as_ref())
452    {
453        let msg = agent_execution::plan_mode_denial_message(&effective_tool_name);
454        return Err(anyhow!(msg).context(agent_execution::PLAN_MODE_DENIED_CONTEXT));
455    }
456
457    Ok(ToolPreflightOutcome {
458        normalized_tool_name: effective_tool_name.clone(),
459        readonly_classification,
460        parallel_safe_after_preflight: crate::tools::tool_intent::is_parallel_safe_call(
461            &effective_tool_name,
462            validation_args.as_ref(),
463        ),
464        effective_args: validation_args.into_owned(),
465    })
466}
467
468#[cfg(test)]
469mod tests {
470    use super::super::assembly::public_tool_name_candidates;
471    use super::{
472        ToolRegistry, configured_unified_file_max_payload_bytes,
473        enforce_unified_file_payload_limit, is_missing_required_arg, normalize_tool_args,
474        parse_unified_file_max_payload_bytes, preflight_validate_resolved_call,
475    };
476    use crate::config::constants::tools as tool_names;
477    use crate::tools::command_args::parse_indexed_command_parts;
478    use crate::tools::request_user_input::RequestUserInputTool;
479    use crate::tools::traits::Tool;
480    use anyhow::Result;
481    use serde_json::{Value, json};
482
483    async fn new_test_registry() -> (tempfile::TempDir, ToolRegistry) {
484        let temp = tempfile::tempdir().expect("temp workspace");
485        let registry = ToolRegistry::new(temp.path().to_path_buf()).await;
486        (temp, registry)
487    }
488
489    #[test]
490    fn patch_action_within_limit_is_allowed() {
491        let mut failures = Vec::new();
492        let args = json!({
493            "action": "patch",
494            "patch": "*** Begin Patch\n*** End Patch\n"
495        });
496
497        enforce_unified_file_payload_limit(tool_names::UNIFIED_FILE, &args, 1024, &mut failures);
498        assert!(failures.is_empty());
499    }
500
501    #[test]
502    fn patch_action_over_limit_is_rejected() {
503        let mut failures = Vec::new();
504        let args = json!({
505            "action": "patch",
506            "patch": "x".repeat(512)
507        });
508
509        enforce_unified_file_payload_limit(tool_names::UNIFIED_FILE, &args, 128, &mut failures);
510        assert_eq!(failures.len(), 1);
511        assert!(failures[0].contains("payload too large"));
512        assert!(failures[0].contains("Split the change"));
513    }
514
515    #[test]
516    fn edit_tool_over_limit_is_rejected() {
517        let mut failures = Vec::new();
518        let args = json!({
519            "path": "file.txt",
520            "old_str": "old",
521            "new_str": "x".repeat(512)
522        });
523
524        enforce_unified_file_payload_limit(tool_names::EDIT_FILE, &args, 128, &mut failures);
525        assert_eq!(failures.len(), 1);
526        assert!(failures[0].contains("action='edit'"));
527    }
528
529    #[test]
530    fn read_action_is_not_limited() {
531        let mut failures = Vec::new();
532        let args = json!({
533            "action": "read",
534            "path": "README.md"
535        });
536
537        enforce_unified_file_payload_limit(tool_names::UNIFIED_FILE, &args, 1, &mut failures);
538        assert!(failures.is_empty());
539    }
540
541    #[test]
542    fn edit_file_required_args_accept_legacy_key_names() {
543        let args = json!({
544            "path": "file.txt",
545            "old_string": "old",
546            "new_string": "new"
547        });
548
549        assert!(!is_missing_required_arg(
550            tool_names::EDIT_FILE,
551            &args,
552            "path"
553        ));
554        assert!(!is_missing_required_arg(
555            tool_names::EDIT_FILE,
556            &args,
557            "old_str"
558        ));
559        assert!(!is_missing_required_arg(
560            tool_names::EDIT_FILE,
561            &args,
562            "new_str"
563        ));
564    }
565
566    #[test]
567    fn parse_payload_limit_accepts_safe_override() {
568        let parsed = parse_unified_file_max_payload_bytes(Some("2048"));
569        assert_eq!(parsed, Some(2048));
570    }
571
572    #[test]
573    fn parse_payload_limit_rejects_too_small_values() {
574        let parsed = parse_unified_file_max_payload_bytes(Some("512"));
575        assert_eq!(parsed, None);
576    }
577
578    #[test]
579    fn parse_payload_limit_rejects_invalid_values() {
580        let parsed = parse_unified_file_max_payload_bytes(Some("not-a-number"));
581        assert_eq!(parsed, None);
582    }
583
584    #[test]
585    fn configured_payload_limit_is_always_safe() {
586        let configured = configured_unified_file_max_payload_bytes();
587        assert!(configured >= 1024);
588    }
589
590    #[test]
591    fn apply_patch_required_arg_accepts_input_alias() {
592        assert!(!is_missing_required_arg(
593            tool_names::APPLY_PATCH,
594            &json!({"input": ""}),
595            "patch"
596        ));
597    }
598
599    #[test]
600    fn apply_patch_required_arg_accepts_raw_string_payload() {
601        assert!(!is_missing_required_arg(
602            tool_names::APPLY_PATCH,
603            &json!(""),
604            "patch"
605        ));
606    }
607
608    #[test]
609    fn run_pty_cmd_required_arg_accepts_zero_based_indexed_command() -> Result<()> {
610        let input = json!({
611            "command.0": "ls",
612            "command.1": "-a"
613        });
614        let args = normalize_tool_args(tool_names::RUN_PTY_CMD, &input, None)?;
615
616        assert!(!is_missing_required_arg(
617            tool_names::RUN_PTY_CMD,
618            args.as_ref(),
619            "command"
620        ));
621        assert_eq!(
622            args.get("command").and_then(|value| value.as_str()),
623            Some("ls -a")
624        );
625        Ok(())
626    }
627
628    #[test]
629    fn run_pty_cmd_required_arg_accepts_one_based_indexed_command() -> Result<()> {
630        let input = json!({
631            "command.1": "ls",
632            "command.2": "-a"
633        });
634        let args = normalize_tool_args(tool_names::RUN_PTY_CMD, &input, None)?;
635
636        assert!(!is_missing_required_arg(
637            tool_names::RUN_PTY_CMD,
638            args.as_ref(),
639            "command"
640        ));
641        assert_eq!(
642            args.get("command").and_then(|value| value.as_str()),
643            Some("ls -a")
644        );
645        Ok(())
646    }
647
648    #[test]
649    fn indexed_command_parts_require_zero_or_one_based_sequences() {
650        assert_eq!(
651            parse_indexed_command_parts(
652                json!({
653                    "command.0": "ls",
654                    "command.1": "-a"
655                })
656                .as_object()
657                .expect("object"),
658            )
659            .expect("valid indexed args"),
660            Some(vec!["ls".to_string(), "-a".to_string()])
661        );
662        assert_eq!(
663            parse_indexed_command_parts(
664                json!({
665                    "command.1": "ls",
666                    "command.2": "-a"
667                })
668                .as_object()
669                .expect("object"),
670            )
671            .expect("valid indexed args"),
672            Some(vec!["ls".to_string(), "-a".to_string()])
673        );
674        assert_eq!(
675            parse_indexed_command_parts(json!({"command.2": "ls"}).as_object().expect("object"))
676                .expect("valid indexed args"),
677            None
678        );
679    }
680
681    #[test]
682    fn tool_name_candidates_extract_channel_suffix_alias() {
683        let candidates = public_tool_name_candidates("assistant<|channel|>apply_patch");
684        assert!(candidates.iter().any(|c| c == "apply_patch"));
685    }
686
687    #[test]
688    fn tool_name_candidates_normalize_humanized_name() {
689        let candidates = public_tool_name_candidates("Read file");
690        assert!(candidates.iter().any(|c| c == "read_file"));
691    }
692
693    #[test]
694    fn unified_search_schema_args_infers_action_from_pattern() -> Result<()> {
695        let args = json!({
696            "pattern": "LLMStreamEvent::",
697            "path": "."
698        });
699
700        let normalized = normalize_tool_args(tool_names::UNIFIED_SEARCH, &args, None)?;
701        assert_eq!(
702            normalized.get("action").and_then(|v| v.as_str()),
703            Some("grep")
704        );
705        Ok(())
706    }
707
708    #[test]
709    fn unified_search_schema_args_infers_list_action_from_glob_pattern() -> Result<()> {
710        let args = json!({
711            "pattern": "**/*.rs",
712            "path": "src"
713        });
714
715        let normalized = normalize_tool_args(tool_names::UNIFIED_SEARCH, &args, None)?;
716        assert_eq!(
717            normalized.get("action").and_then(|v| v.as_str()),
718            Some("list")
719        );
720        Ok(())
721    }
722
723    #[test]
724    fn unified_search_schema_args_preserves_non_inferable_payload() -> Result<()> {
725        let args = json!({
726            "max_results": 10
727        });
728
729        let normalized = normalize_tool_args(tool_names::UNIFIED_SEARCH, &args, None)?;
730        assert!(normalized.get("action").is_none());
731        Ok(())
732    }
733
734    #[test]
735    fn unified_search_schema_args_normalizes_case_variants() -> Result<()> {
736        let args = json!({
737            "Pattern": "ReasoningStage",
738            "Path": "."
739        });
740
741        let normalized = normalize_tool_args(tool_names::UNIFIED_SEARCH, &args, None)?;
742        assert_eq!(
743            normalized.get("pattern").and_then(|v| v.as_str()),
744            Some("ReasoningStage")
745        );
746        assert_eq!(normalized.get("path").and_then(|v| v.as_str()), Some("."));
747        assert_eq!(
748            normalized.get("action").and_then(|v| v.as_str()),
749            Some("grep")
750        );
751        Ok(())
752    }
753
754    #[test]
755    fn request_user_input_args_accept_details_alias() -> Result<()> {
756        let schema = RequestUserInputTool
757            .parameter_schema()
758            .expect("request_user_input schema");
759        let args = json!({
760            "questions": [{
761                "id": "scope",
762                "header": "Scope",
763                "question": "Which direction should we take?",
764                "options": [
765                    {
766                        "label": "Minimal",
767                        "details": "Ship the smallest viable slice."
768                    },
769                    {
770                        "label": "Full",
771                        "details": "Ship the full implementation."
772                    }
773                ]
774            }]
775        });
776
777        let normalized = normalize_tool_args(tool_names::REQUEST_USER_INPUT, &args, Some(&schema))?;
778        let option = &normalized["questions"][0]["options"][0];
779        assert_eq!(
780            option.get("description").and_then(Value::as_str),
781            Some("Ship the smallest viable slice.")
782        );
783        assert!(option.get("details").is_none());
784        Ok(())
785    }
786
787    #[test]
788    fn task_tracker_args_accept_details_alias() -> Result<()> {
789        let schema = json!({
790            "type": "object",
791            "properties": {
792                "action": { "type": "string" },
793                "description": { "type": "string" }
794            }
795        });
796        let args = json!({
797            "action": "add",
798            "details": "Add regression coverage"
799        });
800
801        let normalized = normalize_tool_args(tool_names::TASK_TRACKER, &args, Some(&schema))?;
802        assert_eq!(
803            normalized.get("description").and_then(Value::as_str),
804            Some("Add regression coverage")
805        );
806        assert!(normalized.get("details").is_none());
807        Ok(())
808    }
809
810    #[test]
811    fn details_alias_does_not_shadow_real_details_field() -> Result<()> {
812        let schema = json!({
813            "type": "object",
814            "properties": {
815                "description": { "type": "string" },
816                "details": { "type": "string" }
817            }
818        });
819        let args = json!({
820            "details": "Keep the real details field."
821        });
822
823        let normalized = normalize_tool_args(tool_names::TASK_TRACKER, &args, Some(&schema))?;
824        assert!(normalized.get("description").is_none());
825        assert_eq!(
826            normalized.get("details").and_then(Value::as_str),
827            Some("Keep the real details field.")
828        );
829        Ok(())
830    }
831
832    #[tokio::test]
833    async fn unified_exec_preflight_rejects_run_without_command() {
834        let (_temp, registry) = new_test_registry().await;
835
836        let err = preflight_validate_resolved_call(
837            &registry,
838            tool_names::UNIFIED_EXEC,
839            &json!({"action": "run"}),
840        )
841        .expect_err("missing command should fail preflight");
842
843        assert!(
844            err.to_string()
845                .contains("Missing required argument: command")
846        );
847    }
848
849    #[tokio::test]
850    async fn unified_exec_preflight_rejects_missing_action_without_inferable_args() {
851        let (_temp, registry) = new_test_registry().await;
852
853        let err = preflight_validate_resolved_call(&registry, tool_names::UNIFIED_EXEC, &json!({}))
854            .expect_err("missing action should fail preflight");
855
856        assert!(
857            err.to_string().contains(
858                "Invalid arguments for tool 'unified_exec': missing action; provide `action` or inferable exec arguments"
859            )
860        );
861    }
862
863    #[tokio::test]
864    async fn unified_exec_preflight_rejects_write_without_input() {
865        let (_temp, registry) = new_test_registry().await;
866
867        let err = preflight_validate_resolved_call(
868            &registry,
869            tool_names::UNIFIED_EXEC,
870            &json!({"action": "write", "session_id": "run-1"}),
871        )
872        .expect_err("missing input should fail preflight");
873
874        assert!(
875            err.to_string()
876                .contains("Missing required argument: input or chars or text")
877        );
878    }
879
880    #[tokio::test]
881    async fn unified_exec_preflight_rejects_poll_without_session_id() {
882        let (_temp, registry) = new_test_registry().await;
883
884        let err = preflight_validate_resolved_call(
885            &registry,
886            tool_names::UNIFIED_EXEC,
887            &json!({"action": "poll"}),
888        )
889        .expect_err("missing session_id should fail preflight");
890
891        assert!(
892            err.to_string()
893                .contains("Missing required argument: session_id")
894        );
895    }
896
897    #[tokio::test]
898    async fn unified_exec_preflight_accepts_list_without_extra_args() -> Result<()> {
899        let (_temp, registry) = new_test_registry().await;
900
901        let result = preflight_validate_resolved_call(
902            &registry,
903            tool_names::UNIFIED_EXEC,
904            &json!({"action": "list"}),
905        )?;
906
907        assert_eq!(result.normalized_tool_name, tool_names::UNIFIED_EXEC);
908        Ok(())
909    }
910
911    #[tokio::test]
912    async fn unified_exec_preflight_accepts_inspect_with_spool_path() -> Result<()> {
913        let (_temp, registry) = new_test_registry().await;
914
915        let result = preflight_validate_resolved_call(
916            &registry,
917            tool_names::UNIFIED_EXEC,
918            &json!({"action": "inspect", "spool_path": ".vtcode/context/tool_outputs/out.log"}),
919        )?;
920
921        assert_eq!(result.normalized_tool_name, tool_names::UNIFIED_EXEC);
922        Ok(())
923    }
924
925    #[tokio::test]
926    async fn unified_file_command_payload_preflight_remaps_to_unified_exec() -> Result<()> {
927        let (_temp, registry) = new_test_registry().await;
928
929        let result = preflight_validate_resolved_call(
930            &registry,
931            tool_names::UNIFIED_FILE,
932            &json!({
933                "command": "echo vtcode",
934                "cwd": ".",
935            }),
936        )?;
937
938        assert_eq!(result.normalized_tool_name, tool_names::UNIFIED_EXEC);
939        assert_eq!(result.effective_args["action"], "run");
940        assert_eq!(result.effective_args["command"], "echo vtcode");
941        assert_eq!(result.effective_args["cwd"], ".");
942        Ok(())
943    }
944}