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 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 ®istry,
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(®istry, 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 ®istry,
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 ®istry,
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 ®istry,
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 ®istry,
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 ®istry,
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}