1use crate::config::ConfigManager;
2use crate::config::constants::tools;
3use crate::exec::skill_manager::{Skill, SkillMetadata};
4use crate::tools::file_tracker::FileTracker;
5use crate::tools::native_memory;
6use crate::tools::registry::unified_actions::{
7 UnifiedExecAction, UnifiedFileAction, UnifiedSearchAction,
8};
9use crate::tools::tool_intent;
10use crate::tools::traits::Tool;
11
12use anyhow::{Context, Result, anyhow, bail};
13use chrono;
14use futures::future::BoxFuture;
15use hashbrown::HashMap;
16use serde::de::DeserializeOwned;
17use serde_json::{Value, json};
18use std::{
19 path::PathBuf,
20 time::{Duration, SystemTime},
21};
22
23use super::{ExecSettlementMode, ToolRegistry};
24use exec_support::*;
25use sandbox_runtime::*;
26
27#[cfg(test)]
28use cargo_failure_diagnostics::{
29 CargoTestCommandKind, attach_exec_recovery_guidance, attach_failure_diagnostics_metadata,
30 cargo_selector_error_diagnostics, cargo_test_failure_diagnostics, cargo_test_rerun_hint,
31};
32
33mod cargo_failure_diagnostics;
34mod exec_sessions;
35mod exec_support;
36mod patch_pipeline;
37mod sandbox_runtime;
38mod search_introspection;
39mod subagents;
40
41#[derive(Clone, Copy)]
42enum ExecRunBackendKind {
43 Pty,
44 Pipe,
45}
46
47struct PreparedExecRunRequest {
48 prepared_command: PreparedExecCommand,
49 working_dir_path: PathBuf,
50 output_config: ExecRunOutputConfig,
51 yield_duration: Duration,
52 session_id: String,
53 shell_program: String,
54 env_overrides: HashMap<String, String>,
55 is_git_diff: bool,
56 confirm: bool,
57 rows: Option<u16>,
58 cols: Option<u16>,
59}
60
61struct ResolvedExecSandboxRequest {
62 working_dir_path: PathBuf,
63 sandbox_permissions: crate::sandboxing::SandboxPermissions,
64 additional_permissions: Option<crate::sandboxing::AdditionalPermissions>,
65}
66
67fn set_payload_default(payload: &mut serde_json::Map<String, Value>, key: &str, value: Value) {
68 payload.entry(key.to_string()).or_insert(value);
69}
70
71fn normalize_unified_exec_run_alias_args(args: &Value, tty: bool) -> Result<Value> {
72 let mut args =
73 crate::tools::command_args::normalize_shell_args(args).map_err(|error| anyhow!(error))?;
74 if let Some(payload) = args.as_object_mut() {
75 set_payload_default(payload, "action", json!("run"));
76 if tty {
77 set_payload_default(payload, "tty", json!(true));
78 }
79 }
80 Ok(args)
81}
82
83fn with_unified_exec_action_default(mut args: Value, action: &'static str) -> Value {
84 if let Some(payload) = args.as_object_mut() {
85 set_payload_default(payload, "action", json!(action));
86 }
87 args
88}
89
90fn annotate_exec_run_response(response: &mut Value, is_git_diff: bool) {
91 if is_git_diff {
92 response["no_spool"] = json!(true);
93 response["content_type"] = json!("git_diff");
94 }
95}
96
97fn acquire_executor_rate_limit(bucket: &str, multiplier: f64) -> Result<()> {
98 let mut guard = crate::tools::rate_limiter::PER_TOOL_RATE_LIMITER
99 .lock()
100 .map_err(|err| anyhow!("per-tool rate limiter poisoned: {}", err))?;
101 guard
102 .try_acquire_for_scaled(bucket, multiplier)
103 .map_err(|_| anyhow!("tool rate limit exceeded for {}", bucket))
104}
105
106fn parse_action<T>(action_str: &str) -> Result<T>
107where
108 T: DeserializeOwned,
109{
110 serde_json::from_value(json!(action_str))
111 .with_context(|| format!("Invalid action: {}", action_str))
112}
113
114macro_rules! delegate_to_tool {
116 ($name:ident, $tool_accessor:ident, $method:ident) => {
117 pub(super) fn $name(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
118 let tool = self.inventory.$tool_accessor().clone();
119 Box::pin(async move { tool.$method(args).await })
120 }
121 };
122}
123
124macro_rules! delegate_to_self {
126 ($name:ident, $method:ident) => {
127 pub(super) fn $name(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
128 Box::pin(async move { self.$method(args).await })
129 }
130 };
131}
132
133impl ToolRegistry {
134 pub(super) fn cron_create_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
135 Box::pin(async move {
136 let prompt = args
137 .get("prompt")
138 .and_then(Value::as_str)
139 .map(str::trim)
140 .filter(|value| !value.is_empty())
141 .ok_or_else(|| anyhow!("cron_create requires a non-empty prompt"))?
142 .to_string();
143 let name = args
144 .get("name")
145 .and_then(Value::as_str)
146 .map(ToOwned::to_owned);
147 let cron = args.get("cron").and_then(Value::as_str);
148 let delay_minutes = args.get("delay_minutes").and_then(Value::as_u64);
149 let run_at = args.get("run_at").and_then(Value::as_str);
150
151 let schedule = match (cron, delay_minutes, run_at) {
152 (Some(expression), None, None) => {
153 crate::scheduler::ScheduleSpec::cron5(expression)?
154 }
155 (None, Some(minutes), None) => {
156 crate::scheduler::ScheduleSpec::fixed_interval(Duration::from_secs(
157 minutes
158 .checked_mul(60)
159 .ok_or_else(|| anyhow!("delay_minutes is too large"))?,
160 ))?
161 }
162 (None, None, Some(raw)) => crate::scheduler::ScheduleSpec::one_shot(
163 crate::scheduler::parse_local_datetime(raw, chrono::Local::now())?,
164 ),
165 _ => bail!("Choose exactly one of cron, delay_minutes, or run_at"),
166 };
167
168 let summary = self
169 .create_session_prompt_task(name, prompt, schedule, chrono::Utc::now())
170 .await?;
171 serde_json::to_value(summary).context("Failed to serialize cron_create response")
172 })
173 }
174
175 pub(super) fn cron_list_executor(&self, _args: Value) -> BoxFuture<'_, Result<Value>> {
176 Box::pin(async move {
177 Ok(json!({
178 "tasks": self.list_session_tasks().await,
179 }))
180 })
181 }
182
183 pub(super) fn cron_delete_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
184 Box::pin(async move {
185 let id = args
186 .get("id")
187 .and_then(Value::as_str)
188 .map(str::trim)
189 .filter(|value| !value.is_empty())
190 .ok_or_else(|| anyhow!("cron_delete requires id"))?;
191 let deleted = self.delete_session_task(id).await;
192 Ok(json!({
193 "deleted": deleted.is_some(),
194 "task": deleted,
195 }))
196 })
197 }
198
199 pub(super) fn memory_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
200 Box::pin(async move {
201 let workspace_root = self.workspace_root_owned();
202 let config = ConfigManager::load_from_workspace(&workspace_root)
203 .map(|manager| manager.config().clone())
204 .unwrap_or_default();
205 native_memory::execute_with_vt_config(&workspace_root, &config, args).await
206 })
207 }
208
209 pub async fn shell_run_approval_reason(
210 &self,
211 tool_name: &str,
212 tool_args: Option<&Value>,
213 ) -> Result<Option<String>> {
214 let resolved_tool_name = self
215 .resolve_public_tool_name_sync(tool_name)
216 .unwrap_or_else(|_| tool_name.to_string());
217 let Some(payload) = shell_run_payload(&resolved_tool_name, tool_args) else {
218 return Ok(None);
219 };
220
221 let (requested_command, _) = parse_command_parts(
222 payload,
223 "shell run request requires a command",
224 "shell run request command cannot be empty",
225 )?;
226 let sandbox_request = self.resolve_exec_sandbox_request(payload).await?;
227 let sandbox_config = self.sandbox_config();
228 let plan = build_shell_execution_plan(
229 &sandbox_config,
230 self.workspace_root(),
231 &requested_command,
232 sandbox_request.sandbox_permissions,
233 sandbox_request.additional_permissions.as_ref(),
234 )?;
235
236 Ok(plan.approval_reason)
237 }
238
239 pub(super) fn unified_exec_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
240 Box::pin(async move { self.execute_unified_exec(args).await })
241 }
242
243 pub(super) fn unified_file_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
244 Box::pin(async move { self.execute_unified_file(args).await })
245 }
246
247 pub(super) fn unified_search_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
248 Box::pin(async move { self.execute_unified_search(args).await })
249 }
250
251 async fn prepare_exec_run_request(
252 &self,
253 args: &Value,
254 backend: ExecRunBackendKind,
255 missing_error: &str,
256 empty_error: &str,
257 ) -> Result<PreparedExecRunRequest> {
258 acquire_executor_rate_limit("unified_exec:run", 2.0)?;
259
260 let payload = args
261 .as_object()
262 .ok_or_else(|| anyhow!("command execution requires a JSON object"))?;
263
264 let (command, auto_raw_command) = parse_command_parts(payload, missing_error, empty_error)?;
265 let shell_program = match backend {
266 ExecRunBackendKind::Pty => resolve_shell_preference_with_zsh_fork(
267 payload.get("shell").and_then(|value| value.as_str()),
268 self.pty_config(),
269 )?,
270 ExecRunBackendKind::Pipe => resolve_shell_preference(
271 payload.get("shell").and_then(|value| value.as_str()),
272 self.pty_config(),
273 ),
274 };
275 let login_shell = payload
276 .get("login")
277 .and_then(|value| value.as_bool())
278 .unwrap_or(false);
279 let confirm = payload
280 .get("confirm")
281 .and_then(|value| value.as_bool())
282 .unwrap_or(false);
283
284 let mut prepared_command = prepare_exec_command(
285 payload,
286 &shell_program,
287 login_shell,
288 command,
289 auto_raw_command,
290 );
291 let is_git_diff = is_git_diff_command(&prepared_command.requested_command);
292
293 let sandbox_request = self.resolve_exec_sandbox_request(payload).await?;
294 let output_config = exec_run_output_config(payload, &prepared_command.display_command);
295
296 enforce_pty_command_policy(&prepared_command.display_command, confirm)?;
297 let sandbox_config = self.sandbox_config();
298 prepared_command.command = apply_runtime_sandbox_to_command(
299 prepared_command.command,
300 &prepared_command.requested_command,
301 &sandbox_config,
302 self.workspace_root(),
303 &sandbox_request.working_dir_path,
304 sandbox_request.sandbox_permissions,
305 sandbox_request.additional_permissions.as_ref(),
306 )?;
307
308 let rows = match backend {
309 ExecRunBackendKind::Pty => Some(parse_pty_dimension(
310 "rows",
311 payload.get("rows"),
312 self.pty_config().default_rows,
313 )?),
314 ExecRunBackendKind::Pipe => None,
315 };
316 let cols = match backend {
317 ExecRunBackendKind::Pty => Some(parse_pty_dimension(
318 "cols",
319 payload.get("cols"),
320 self.pty_config().default_cols,
321 )?),
322 ExecRunBackendKind::Pipe => None,
323 };
324
325 Ok(PreparedExecRunRequest {
326 prepared_command,
327 working_dir_path: sandbox_request.working_dir_path,
328 output_config,
329 yield_duration: Duration::from_millis(clamp_exec_yield_ms(
330 payload.get("yield_time_ms").and_then(Value::as_u64),
331 10_000,
332 )),
333 session_id: resolve_exec_run_session_id(payload)?,
334 shell_program,
335 env_overrides: parse_exec_env_overrides(payload)?,
336 is_git_diff,
337 confirm,
338 rows,
339 cols,
340 })
341 }
342
343 pub(super) async fn execute_unified_exec(&self, args: Value) -> Result<Value> {
344 self.execute_unified_exec_internal(args, ExecSettlementMode::Manual)
345 .await
346 }
347
348 pub(super) async fn execute_harness_unified_exec_terminal_run_raw(
349 &self,
350 args: Value,
351 ) -> Result<Value> {
352 let args = normalize_unified_exec_run_alias_args(&args, true)?;
353 self.execute_command_session_run_pty(args, true).await
354 }
355
356 fn dispatch_unified_exec_alias(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
357 Box::pin(async move {
358 self.execute_unified_exec(args)
359 .await
360 .map(super::normalize_tool_output)
361 })
362 }
363
364 fn dispatch_unified_exec_run_alias(
365 &self,
366 args: Value,
367 tty: bool,
368 ) -> BoxFuture<'_, Result<Value>> {
369 Box::pin(async move {
370 let args = normalize_unified_exec_run_alias_args(&args, tty)?;
371 self.execute_unified_exec(args)
372 .await
373 .map(super::normalize_tool_output)
374 })
375 }
376
377 fn dispatch_unified_exec_action_alias(
378 &self,
379 args: Value,
380 action: &'static str,
381 ) -> BoxFuture<'_, Result<Value>> {
382 self.dispatch_unified_exec_alias(with_unified_exec_action_default(args, action))
383 }
384
385 pub(super) async fn execute_unified_exec_internal(
386 &self,
387 args: Value,
388 exec_settlement_mode: ExecSettlementMode,
389 ) -> Result<Value> {
390 let args = crate::tools::command_args::normalize_shell_args(&args)
391 .map_err(|error| anyhow!(error))?;
392
393 let action_str = tool_intent::unified_exec_action(&args)
394 .ok_or_else(|| missing_unified_exec_action_error(&args))?;
395 let action: UnifiedExecAction = parse_action(action_str)?;
396
397 match action {
398 UnifiedExecAction::Run => {
399 self.execute_command_session_run_internal(args, exec_settlement_mode)
400 .await
401 }
402 UnifiedExecAction::Write => self.execute_command_session_write(args).await,
403 UnifiedExecAction::Poll => {
404 self.execute_command_session_poll_internal(args, exec_settlement_mode)
405 .await
406 }
407 UnifiedExecAction::Continue => {
408 self.execute_command_session_continue_internal(args, exec_settlement_mode)
409 .await
410 }
411 UnifiedExecAction::Inspect => self.execute_command_session_inspect(args).await,
412 UnifiedExecAction::List => self.execute_command_session_list().await,
413 UnifiedExecAction::Close => self.execute_command_session_close(args).await,
414 UnifiedExecAction::Code => self.execute_code(args).await,
415 }
416 }
417
418 async fn execute_command_session_run_internal(
419 &self,
420 args: Value,
421 exec_settlement_mode: ExecSettlementMode,
422 ) -> Result<Value> {
423 let tty = args.get("tty").and_then(Value::as_bool).unwrap_or(false);
424 if tty {
425 self.execute_command_session_run_pty(args, false).await
426 } else {
427 self.execute_run_pipe_cmd(args, exec_settlement_mode).await
428 }
429 }
430
431 pub(super) async fn execute_unified_file(&self, args: Value) -> Result<Value> {
432 let action_str = tool_intent::unified_file_action(&args)
433 .ok_or_else(|| missing_unified_file_action_error(&args))?;
434
435 let action: UnifiedFileAction = parse_action(action_str)?;
436 self.log_unified_file_payload_diagnostics(action_str, &args);
437 let tool = self.inventory.file_ops_tool().clone();
438
439 match action {
440 UnifiedFileAction::Read => {
441 self.execute_unified_file_read_with_recovery(&tool, args)
442 .await
443 }
444 UnifiedFileAction::Write => tool.write_file(args).await,
445 UnifiedFileAction::Edit => self.edit_file(args).await,
446 UnifiedFileAction::Patch => self.execute_apply_patch(args).await,
447 UnifiedFileAction::Delete => tool.delete_file(args).await,
448 UnifiedFileAction::Move => tool.move_file(args).await,
449 UnifiedFileAction::Copy => tool.copy_file(args).await,
450 }
451 }
452
453 async fn execute_unified_file_read_with_recovery(
454 &self,
455 tool: &crate::tools::file_ops::FileOpsTool,
456 args: Value,
457 ) -> Result<Value> {
458 match tool.read_file(args.clone()).await {
459 Ok(response) => Ok(response),
460 Err(read_err) => {
461 let read_err_text = read_err.to_string();
462 if let Some(fallback_args) = build_read_pty_fallback_args(&args, &read_err_text) {
463 let session_id = fallback_args
464 .get("session_id")
465 .and_then(Value::as_str)
466 .unwrap_or_default()
467 .to_string();
468 tracing::info!(
469 session_id = %session_id,
470 "Auto-recovering unified_file read via unified_exec poll"
471 );
472 match self.execute_command_session_poll(fallback_args).await {
473 Ok(mut recovered) => {
474 if let Some(obj) = recovered.as_object_mut() {
475 obj.insert("auto_recovered".to_string(), json!(true));
476 obj.insert("recovery_tool".to_string(), json!(tools::UNIFIED_EXEC));
477 obj.insert("recovery_action".to_string(), json!("poll"));
478 obj.insert(
479 "recovery_reason".to_string(),
480 json!("missing_pty_spool_file"),
481 );
482 }
483 return Ok(recovered);
484 }
485 Err(recovery_err) => {
486 tracing::warn!(
487 session_id = %session_id,
488 error = %recovery_err,
489 "Failed auto-recovery via unified_exec poll"
490 );
491 }
492 }
493 }
494 Err(read_err)
495 }
496 }
497 }
498
499 pub(super) async fn execute_unified_search(&self, args: Value) -> Result<Value> {
500 let mut args = tool_intent::normalize_unified_search_args(&args);
501
502 let action_str = tool_intent::unified_search_action(&args)
503 .ok_or_else(|| missing_unified_search_action_error(&args))?;
504
505 let action: UnifiedSearchAction = parse_action(action_str)?;
506
507 if matches!(
509 action,
510 UnifiedSearchAction::Grep | UnifiedSearchAction::List
511 ) {
512 let has_path = args
513 .get("path")
514 .and_then(|v| v.as_str())
515 .map(|p| !p.trim().is_empty())
516 .unwrap_or(false);
517 if !has_path {
518 args["path"] = json!(".");
519 }
520 }
521
522 match action {
523 UnifiedSearchAction::Grep => {
524 ensure_unified_search_grep_pattern(&args)?;
525 let manager = self.inventory.grep_file_manager();
526 manager
527 .perform_search(serde_json::from_value(args)?)
528 .await
529 .map(|r| json!(r))
530 }
531 UnifiedSearchAction::List => {
532 let tool = self.inventory.file_ops_tool().clone();
533 tool.execute(args).await
534 }
535 UnifiedSearchAction::Structural => {
536 crate::tools::structural_search::execute_structural_search(
537 self.workspace_root(),
538 args,
539 )
540 .await
541 }
542 UnifiedSearchAction::Intelligence => Ok(
543 serde_json::json!({"error": "Action 'intelligence' is deprecated. Use action='grep' or action='list'."}),
544 ),
545 UnifiedSearchAction::Tools => self.execute_search_tools(args).await,
546 UnifiedSearchAction::Errors => self.execute_get_errors(args).await,
547 UnifiedSearchAction::Agent => self.execute_agent_info().await,
548 UnifiedSearchAction::Web => self.execute_web_fetch(args).await,
549 UnifiedSearchAction::Skill => self.execute_skill(args).await,
550 }
551 }
552
553 pub(super) async fn execute_code(&self, args: Value) -> Result<Value> {
554 let code = args
555 .get("command")
556 .or_else(|| args.get("code"))
557 .and_then(|v| v.as_str())
558 .ok_or_else(|| anyhow!("Missing code/command in execute_code"))?;
559
560 let language = code_language_from_args(&args);
561
562 let track_files = args
563 .get("track_files")
564 .and_then(|v| v.as_bool())
565 .unwrap_or(false);
566
567 let mcp_client = self
568 .mcp_client()
569 .ok_or_else(|| anyhow!("MCP client not available"))?;
570
571 let workspace_root = self.workspace_root_owned();
572 let executor = crate::exec::code_executor::CodeExecutor::new(
573 language,
574 mcp_client.clone(),
575 workspace_root.clone(),
576 );
577 let execution_start = SystemTime::now();
578
579 let result = executor.execute(code).await?;
580
581 let mut response = json!(result);
582
583 if track_files {
584 let tracker = FileTracker::new(workspace_root);
585 if let Ok(changes) = tracker.detect_new_files(execution_start).await {
586 response["generated_files"] = json!({
587 "count": changes.len(),
588 "files": changes,
589 "summary": tracker.generate_file_summary(&changes),
590 });
591 }
592 }
593
594 Ok(response)
595 }
596
597 pub(super) async fn execute_web_fetch(&self, args: Value) -> Result<Value> {
598 acquire_executor_rate_limit("unified_search:web", 1.0)?;
599
600 let url = args
601 .get("url")
602 .and_then(|v| v.as_str())
603 .ok_or_else(|| anyhow!("Missing url in web_fetch"))?;
604
605 let client = reqwest::Client::builder()
606 .timeout(Duration::from_secs(30))
607 .user_agent("VT Code/1.0")
608 .build()?;
609
610 let response = client.get(url).send().await?;
611 let status = response.status();
612
613 if !status.is_success() {
614 return Err(anyhow!("Web fetch failed with status: {}", status));
615 }
616
617 let body = response.text().await?;
618 Ok(json!({ "success": true, "content": body, "url": url }))
619 }
620
621 pub(super) async fn execute_skill(&self, args: Value) -> Result<Value> {
622 let sub_action = args
623 .get("sub_action")
624 .and_then(|v| v.as_str())
625 .or_else(|| {
626 if args.get("name").is_some() {
627 Some("load")
628 } else {
629 None
630 }
631 })
632 .ok_or_else(|| anyhow!("Missing sub_action in skill"))?;
633
634 let skill_manager = self.inventory.skill_manager();
635
636 match sub_action {
637 "save" => {
638 let name = args
639 .get("name")
640 .and_then(|v| v.as_str())
641 .ok_or_else(|| anyhow!("Missing name in skill save"))?;
642 let code = args
643 .get("code")
644 .and_then(|v| v.as_str())
645 .ok_or_else(|| anyhow!("Missing code in skill save"))?;
646 let description = args
647 .get("description")
648 .and_then(|v| v.as_str())
649 .unwrap_or("");
650 let language = args
651 .get("language")
652 .and_then(|v| v.as_str())
653 .unwrap_or("python3");
654
655 let metadata = SkillMetadata {
656 name: name.to_string(),
657 description: description.to_string(),
658 language: language.to_string(),
659 inputs: vec![],
660 output: "".to_string(),
661 examples: vec![],
662 tags: vec![],
663 created_at: chrono::Utc::now().to_rfc3339(),
664 modified_at: chrono::Utc::now().to_rfc3339(),
665 tool_dependencies: vec![],
666 };
667
668 let skill = Skill {
669 metadata,
670 code: code.to_string(),
671 };
672
673 skill_manager.save_skill(skill).await?;
674 Ok(json!({ "success": true, "name": name }))
675 }
676 "load" => {
677 let name = args
678 .get("name")
679 .and_then(|v| v.as_str())
680 .ok_or_else(|| anyhow!("Missing name in skill load"))?;
681 let skill = skill_manager.load_skill(name).await?;
682 Ok(json!({
683 "success": true,
684 "name": skill.metadata.name,
685 "code": skill.code,
686 "language": skill.metadata.language
687 }))
688 }
689 "list" => {
690 let skills = skill_manager.list_skills().await?;
691 Ok(json!({ "success": true, "skills": skills }))
692 }
693 _ => Err(anyhow!("Unknown skill sub_action: {}", sub_action)),
694 }
695 }
696
697 pub(super) async fn execute_apply_patch(&self, args: Value) -> Result<Value> {
698 let (patch_args, patch_input_bytes, patch_base64) = self.prepare_apply_patch_args(args)?;
699 let context = self.harness_context_snapshot();
700 tracing::debug!(
701 tool = tools::UNIFIED_FILE,
702 action = "patch",
703 payload_bytes = serialized_payload_size_bytes(&patch_args),
704 patch_input_bytes,
705 patch_base64,
706 patch_decoded_bytes = patch_args
707 .get("input")
708 .and_then(|v| v.as_str())
709 .map(|s| s.len())
710 .unwrap_or(0),
711 session_id = %context.session_id,
712 task_id = %context.task_id.as_deref().unwrap_or(""),
713 "Prepared patch payload for apply_patch"
714 );
715
716 self.execute_apply_patch_internal(patch_args).await
717 }
718
719 fn prepare_apply_patch_args(&self, args: Value) -> Result<(Value, usize, bool)> {
720 let patch_input = crate::tools::apply_patch::decode_apply_patch_input(&args)?
721 .ok_or_else(|| anyhow!("Missing patch input"))?;
722 let patch_input_bytes = patch_input.source_bytes;
723 let patch_base64 = patch_input.was_base64;
724
725 let mut patch_args = args;
726 patch_args["input"] = json!(patch_input.text);
727 Ok((patch_args, patch_input_bytes, patch_base64))
728 }
729
730 fn log_unified_file_payload_diagnostics(&self, action: &str, args: &Value) {
731 let context = self.harness_context_snapshot();
732 let (patch_source_bytes, patch_base64) =
733 crate::tools::apply_patch::patch_source_from_args(args)
734 .map(|source| (source.len(), source.starts_with("base64:")))
735 .unwrap_or((0, false));
736
737 tracing::trace!(
738 tool = tools::UNIFIED_FILE,
739 action,
740 payload_bytes = serialized_payload_size_bytes(args),
741 patch_source_bytes,
742 patch_base64,
743 session_id = %context.session_id,
744 task_id = %context.task_id.as_deref().unwrap_or(""),
745 "Captured unified_file payload diagnostics"
746 );
747 }
748
749 async fn resolve_exec_sandbox_request(
750 &self,
751 payload: &serde_json::Map<String, Value>,
752 ) -> Result<ResolvedExecSandboxRequest> {
753 let working_dir_path = self
754 .pty_manager()
755 .resolve_working_dir(shell_working_dir_value(payload))
756 .await?;
757 let (sandbox_permissions, additional_permissions) =
758 parse_requested_sandbox_permissions(payload, &working_dir_path)?;
759
760 Ok(ResolvedExecSandboxRequest {
761 working_dir_path,
762 sandbox_permissions,
763 additional_permissions,
764 })
765 }
766
767 delegate_to_tool!(read_file_executor, file_ops_tool, read_file);
773 delegate_to_tool!(write_file_executor, file_ops_tool, write_file);
774
775 delegate_to_self!(list_files_executor, list_files);
777 delegate_to_self!(edit_file_executor, edit_file);
778 delegate_to_self!(get_errors_executor, execute_get_errors);
779 delegate_to_self!(mcp_search_tools_executor, execute_mcp_search_tools);
780 delegate_to_self!(mcp_get_tool_details_executor, execute_mcp_get_tool_details);
781 delegate_to_self!(mcp_list_servers_executor, execute_mcp_list_servers);
782 delegate_to_self!(mcp_connect_server_executor, execute_mcp_connect_server);
783 delegate_to_self!(
784 mcp_disconnect_server_executor,
785 execute_mcp_disconnect_server
786 );
787 delegate_to_self!(apply_patch_executor, execute_apply_patch);
788
789 pub(super) fn run_pty_cmd_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
791 self.dispatch_unified_exec_run_alias(args, true)
792 }
793
794 pub(super) fn send_pty_input_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
795 self.dispatch_unified_exec_action_alias(args, "write")
796 }
797
798 pub(super) fn read_pty_session_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
799 self.dispatch_unified_exec_action_alias(args, "poll")
800 }
801
802 pub(super) fn create_pty_session_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
803 self.dispatch_unified_exec_run_alias(args, true)
804 }
805
806 pub(super) fn list_pty_sessions_executor(&self, _args: Value) -> BoxFuture<'_, Result<Value>> {
807 self.dispatch_unified_exec_alias(json!({"action": "list"}))
808 }
809
810 pub(super) fn close_pty_session_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
811 self.dispatch_unified_exec_action_alias(args, "close")
812 }
813
814 }
818
819fn ensure_unified_search_grep_pattern(args: &Value) -> Result<()> {
820 let pattern = args
821 .get("pattern")
822 .and_then(Value::as_str)
823 .map(str::trim)
824 .unwrap_or_default();
825
826 if pattern.is_empty() {
827 bail!(
828 "unified_search action='grep' requires a non-empty pattern. Use unified_file action='read' to read file contents, or provide specific text/regex to search for."
829 );
830 }
831
832 Ok(())
833}
834
835#[cfg(test)]
836mod execute_code_tests {
837 use super::code_language_from_args;
838 use crate::exec::code_executor::Language;
839 use serde_json::json;
840
841 #[test]
842 fn code_language_uses_language_field_instead_of_action() {
843 assert_eq!(
844 code_language_from_args(&json!({
845 "action": "code",
846 "language": "javascript",
847 })),
848 Language::JavaScript
849 );
850 assert_eq!(
851 code_language_from_args(&json!({
852 "action": "code",
853 "lang": "js",
854 })),
855 Language::JavaScript
856 );
857 assert_eq!(
858 code_language_from_args(&json!({
859 "action": "code",
860 })),
861 Language::Python3
862 );
863 }
864}
865
866#[cfg(test)]
867mod subagent_tool_output_tests {
868 use super::sanitize_subagent_tool_output_paths;
869 use serde_json::json;
870 use tempfile::TempDir;
871
872 #[test]
873 fn strips_transcript_paths_outside_workspace() {
874 let temp = TempDir::new().expect("tempdir");
875 let mut value = json!({
876 "completed": true,
877 "entry": {
878 "id": "agent-1",
879 "transcript_path": "/Users/example/.vtcode/sessions/agent-1.json",
880 }
881 });
882
883 sanitize_subagent_tool_output_paths(temp.path(), &mut value);
884
885 assert!(value["entry"].get("transcript_path").is_none());
886 }
887
888 #[test]
889 fn keeps_transcript_paths_inside_workspace() {
890 let temp = TempDir::new().expect("tempdir");
891 let transcript_path = temp.path().join(".vtcode/context/subagents/agent-1.json");
892 let mut value = json!({
893 "id": "agent-1",
894 "transcript_path": transcript_path,
895 });
896
897 sanitize_subagent_tool_output_paths(temp.path(), &mut value);
898
899 assert_eq!(value["transcript_path"].as_str(), transcript_path.to_str());
900 }
901}
902
903#[cfg(test)]
904mod shell_preference_tests {
905 use super::{resolve_shell_preference, resolve_shell_preference_with_zsh_fork};
906 use crate::config::PtyConfig;
907 use crate::tools::shell::resolve_fallback_shell;
908
909 #[test]
910 fn explicit_shell_overrides_config_preference() {
911 let config = PtyConfig {
912 preferred_shell: Some("/bin/bash".to_string()),
913 ..Default::default()
914 };
915
916 let resolved = resolve_shell_preference(Some(" /bin/zsh "), &config);
917 assert_eq!(resolved, "/bin/zsh");
918 }
919
920 #[test]
921 fn config_preferred_shell_used_when_explicit_missing() {
922 let config = PtyConfig {
923 preferred_shell: Some("zsh".to_string()),
924 ..Default::default()
925 };
926
927 let resolved = resolve_shell_preference(None, &config);
928 assert_eq!(resolved, "zsh");
929 }
930
931 #[test]
932 fn blank_explicit_shell_falls_back_to_config_preference() {
933 let config = PtyConfig {
934 preferred_shell: Some("bash".to_string()),
935 ..Default::default()
936 };
937
938 let resolved = resolve_shell_preference(Some(" "), &config);
939 assert_eq!(resolved, "bash");
940 }
941
942 #[test]
943 fn blank_config_shell_falls_back_to_default_resolver() {
944 let config = PtyConfig {
945 preferred_shell: Some(" ".to_string()),
946 ..Default::default()
947 };
948
949 let resolved = resolve_shell_preference(None, &config);
950 assert_eq!(resolved, resolve_fallback_shell());
951 }
952
953 #[test]
954 fn missing_preferences_fall_back_to_default_resolver() {
955 let config = PtyConfig::default();
956 let resolved = resolve_shell_preference(None, &config);
957 assert_eq!(resolved, resolve_fallback_shell());
958 }
959
960 #[test]
961 fn zsh_fork_disabled_uses_standard_shell_resolution() -> anyhow::Result<()> {
962 let config = PtyConfig {
963 preferred_shell: Some("/bin/bash".to_string()),
964 ..Default::default()
965 };
966 let resolved = resolve_shell_preference_with_zsh_fork(None, &config)?;
967 assert_eq!(resolved, "/bin/bash");
968 Ok(())
969 }
970
971 #[test]
972 fn zsh_fork_missing_path_returns_error() {
973 let config = PtyConfig {
974 shell_zsh_fork: true,
975 zsh_path: None,
976 ..PtyConfig::default()
977 };
978 resolve_shell_preference_with_zsh_fork(Some("/bin/bash"), &config).unwrap_err();
979 }
980
981 #[cfg(unix)]
982 #[test]
983 fn zsh_fork_ignores_explicit_shell_and_uses_configured_path() -> anyhow::Result<()> {
984 let zsh = tempfile::NamedTempFile::new()?;
985 let expected = zsh.path().to_string_lossy().to_string();
986 let config = PtyConfig {
987 shell_zsh_fork: true,
988 zsh_path: Some(expected.clone()),
989 ..PtyConfig::default()
990 };
991 let resolved = resolve_shell_preference_with_zsh_fork(Some("/bin/bash"), &config)?;
992 assert_eq!(resolved, expected);
993 Ok(())
994 }
995}
996
997#[cfg(test)]
998mod token_efficiency_tests {
999 use super::*;
1000
1001 #[test]
1002 fn test_suggests_limit_for_cat() {
1003 assert_eq!(suggest_max_tokens_for_command("cat file.txt"), Some(250));
1004 assert_eq!(
1005 suggest_max_tokens_for_command("cat /path/to/file.rs"),
1006 Some(250)
1007 );
1008 assert_eq!(suggest_max_tokens_for_command("CAT file.txt"), Some(250)); }
1010
1011 #[test]
1012 fn test_suggests_limit_for_bat() {
1013 assert_eq!(suggest_max_tokens_for_command("bat file.rs"), Some(250));
1014 }
1015
1016 #[test]
1017 fn test_no_limit_when_already_limited() {
1018 assert_eq!(suggest_max_tokens_for_command("cat file.txt | head"), None);
1019 assert_eq!(suggest_max_tokens_for_command("head -n 50 file.txt"), None);
1020 assert_eq!(suggest_max_tokens_for_command("tail -n 20 file.txt"), None);
1021 }
1022
1023 #[test]
1024 fn test_no_limit_for_other_commands() {
1025 assert_eq!(suggest_max_tokens_for_command("ls -la"), None);
1026 assert_eq!(suggest_max_tokens_for_command("grep pattern file"), None);
1027 assert_eq!(suggest_max_tokens_for_command("echo hello"), None);
1028 }
1029}
1030
1031#[cfg(test)]
1032mod pty_output_filter_tests {
1033 use super::filter_pty_output;
1034
1035 #[test]
1036 fn normalizes_crlf_sequences() {
1037 let raw = "a\r\nb\rc\n";
1038 assert_eq!(filter_pty_output(raw), "a\nb\nc\n");
1039 }
1040}
1041
1042#[cfg(test)]
1043mod pty_context_tests {
1044 use super::{
1045 ExecOutputPreview, PtyEphemeralCapture, attach_exec_response_context,
1046 attach_pty_continuation, build_exec_response, build_exec_session_command_display,
1047 };
1048 use crate::tools::types::VTCodeExecSession;
1049 use serde_json::json;
1050
1051 #[test]
1052 fn build_exec_session_command_display_unwraps_shell_c_argument() {
1053 let session = VTCodeExecSession {
1054 id: "run-123".to_string().into(),
1055 backend: "pty".to_string(),
1056 command: "zsh".to_string(),
1057 args: vec![
1058 "-l".to_string(),
1059 "-c".to_string(),
1060 "cargo check".to_string(),
1061 ],
1062 working_dir: Some(".".to_string()),
1063 rows: Some(24),
1064 cols: Some(80),
1065 child_pid: None,
1066 started_at: None,
1067 lifecycle_state: None,
1068 exit_code: None,
1069 };
1070
1071 assert_eq!(build_exec_session_command_display(&session), "cargo check");
1072 }
1073
1074 #[test]
1075 fn attach_exec_response_context_sets_expected_keys() {
1076 let mut response = json!({ "output": "ok" });
1077 let session = VTCodeExecSession {
1078 id: "run-123".to_string().into(),
1079 backend: "pty".to_string(),
1080 command: "zsh".to_string(),
1081 args: vec![
1082 "-l".to_string(),
1083 "-c".to_string(),
1084 "cargo check".to_string(),
1085 ],
1086 working_dir: Some(".".to_string()),
1087 rows: Some(30),
1088 cols: Some(120),
1089 child_pid: None,
1090 started_at: None,
1091 lifecycle_state: None,
1092 exit_code: None,
1093 };
1094
1095 attach_exec_response_context(&mut response, &session, "cargo check", false);
1096
1097 assert_eq!(response["session_id"], "run-123");
1098 assert_eq!(response["command"], "cargo check");
1099 assert_eq!(response["working_directory"], ".");
1100 assert_eq!(response["backend"], "pty");
1101 assert_eq!(response["rows"], 30);
1102 assert_eq!(response["cols"], 120);
1103 assert_eq!(response["is_exited"], false);
1104 }
1105
1106 #[test]
1107 fn attach_pty_continuation_compacts_next_continue_args() {
1108 let mut response = json!({ "output": "ok" });
1109 attach_pty_continuation(&mut response, "run-123");
1110
1111 assert!(response.get("follow_up_prompt").is_none());
1112 assert!(response.get("next_poll_args").is_none());
1113 assert_eq!(
1114 response["next_continue_args"],
1115 json!({ "session_id": "run-123" })
1116 );
1117 assert!(response.get("preferred_next_action").is_none());
1118 }
1119
1120 #[test]
1121 fn attach_pty_continuation_keeps_payload_compact() {
1122 let mut response = json!({ "output": "ok" });
1123 attach_pty_continuation(&mut response, "run-123");
1124
1125 assert!(response.get("follow_up_prompt").is_none());
1126 assert!(response.get("next_poll_args").is_none());
1127 assert_eq!(
1128 response["next_continue_args"],
1129 json!({ "session_id": "run-123" })
1130 );
1131 }
1132
1133 #[test]
1134 fn build_exec_response_skips_continuation_after_exit() {
1135 let session = VTCodeExecSession {
1136 id: "run-123".to_string().into(),
1137 backend: "pipe".to_string(),
1138 command: "cargo".to_string(),
1139 args: vec!["check".to_string()],
1140 working_dir: Some(".".to_string()),
1141 rows: None,
1142 cols: None,
1143 child_pid: None,
1144 started_at: None,
1145 lifecycle_state: None,
1146 exit_code: None,
1147 };
1148 let capture = PtyEphemeralCapture {
1149 output: "first\nsecond\n".to_string(),
1150 exit_code: Some(0),
1151 duration: std::time::Duration::from_millis(25),
1152 };
1153
1154 let response = build_exec_response(
1155 &session,
1156 "cargo check",
1157 &capture,
1158 ExecOutputPreview {
1159 raw_output: "first\nsecond\n".to_string(),
1160 output: "first\n[Output truncated]".to_string(),
1161 truncated: true,
1162 },
1163 None,
1164 false,
1165 None,
1166 );
1167
1168 assert_eq!(response["exit_code"], 0);
1169 assert!(response.get("next_continue_args").is_none());
1170 }
1171}
1172
1173#[cfg(test)]
1174mod git_diff_tests {
1175 use super::is_git_diff_command;
1176
1177 #[test]
1178 fn detects_git_diff() {
1179 let cmd = vec!["git".to_string(), "diff".to_string()];
1180 assert!(is_git_diff_command(&cmd));
1181 }
1182
1183 #[test]
1184 fn detects_git_diff_with_flags() {
1185 let cmd = vec![
1186 "git".to_string(),
1187 "-c".to_string(),
1188 "color.ui=always".to_string(),
1189 "diff".to_string(),
1190 "--stat".to_string(),
1191 ];
1192 assert!(is_git_diff_command(&cmd));
1193 }
1194
1195 #[test]
1196 fn detects_git_diff_with_path() {
1197 let cmd = vec!["/usr/bin/git".to_string(), "diff".to_string()];
1198 assert!(is_git_diff_command(&cmd));
1199 }
1200
1201 #[test]
1202 fn ignores_other_git_commands() {
1203 let cmd = vec!["git".to_string(), "status".to_string()];
1204 assert!(!is_git_diff_command(&cmd));
1205 }
1206}
1207
1208#[cfg(test)]
1209mod unified_action_error_tests {
1210 use super::{
1211 CargoTestCommandKind, ExecOutputPreview, PtyEphemeralCapture,
1212 attach_exec_recovery_guidance, attach_failure_diagnostics_metadata,
1213 build_exec_output_preview, build_exec_response, build_head_tail_preview,
1214 cargo_selector_error_diagnostics, cargo_test_failure_diagnostics, cargo_test_rerun_hint,
1215 clamp_inspect_lines, clamp_max_matches, ensure_unified_search_grep_pattern,
1216 extract_run_session_id_from_read_file_error, extract_run_session_id_from_tool_output_path,
1217 filter_lines, missing_unified_exec_action_error, missing_unified_search_action_error,
1218 resolve_exec_run_session_id, summarized_arg_keys,
1219 };
1220 use crate::tools::types::VTCodeExecSession;
1221 use serde_json::json;
1222 use std::time::Duration;
1223
1224 #[test]
1225 fn summarized_arg_keys_reports_shape_for_non_object_payloads() {
1226 assert_eq!(summarized_arg_keys(&json!(null)), "<null>");
1227 assert_eq!(summarized_arg_keys(&json!(["a", "b"])), "<array>");
1228 assert_eq!(summarized_arg_keys(&json!("x")), "<string>");
1229 }
1230
1231 #[test]
1232 fn unified_exec_missing_action_error_includes_received_keys() {
1233 let err = missing_unified_exec_action_error(&json!({
1234 "foo": "bar",
1235 "session_id": "123"
1236 }));
1237 let text = err.to_string();
1238 assert!(text.contains("Missing unified_exec action"));
1239 assert!(text.contains("foo"));
1240 assert!(text.contains("session_id"));
1241 }
1242
1243 #[test]
1244 fn unified_search_missing_action_error_includes_received_keys() {
1245 let err = missing_unified_search_action_error(&json!({
1246 "unexpected": true
1247 }));
1248 let text = err.to_string();
1249 assert!(text.contains("Missing unified_search action"));
1250 assert!(text.contains("unexpected"));
1251 }
1252
1253 #[test]
1254 fn unified_search_grep_rejects_empty_patterns() {
1255 let err = ensure_unified_search_grep_pattern(&json!({
1256 "action": "grep",
1257 "pattern": " ",
1258 "path": "README.md"
1259 }))
1260 .expect_err("empty grep pattern should be rejected");
1261
1262 let text = err.to_string();
1263 assert!(text.contains("requires a non-empty pattern"));
1264 assert!(text.contains("unified_file"));
1265 }
1266
1267 #[test]
1268 fn unified_search_grep_accepts_non_empty_patterns() {
1269 ensure_unified_search_grep_pattern(&json!({
1270 "action": "grep",
1271 "pattern": "VT Code",
1272 "path": "README.md"
1273 }))
1274 .expect("specific grep pattern should be accepted");
1275 }
1276
1277 #[test]
1278 fn extracts_run_session_id_from_tool_output_path() {
1279 assert_eq!(
1280 extract_run_session_id_from_tool_output_path(
1281 ".vtcode/context/tool_outputs/run-abc123.txt"
1282 ),
1283 Some("run-abc123".to_string())
1284 );
1285 assert_eq!(
1286 extract_run_session_id_from_tool_output_path(
1287 ".vtcode/context/tool_outputs/not-a-session.txt"
1288 ),
1289 None
1290 );
1291 }
1292
1293 #[test]
1294 fn extracts_run_session_id_from_read_file_error() {
1295 let error = "Use unified_exec with session_id=\"run-zz9\" instead of read_file.";
1296 assert_eq!(
1297 extract_run_session_id_from_read_file_error(error),
1298 Some("run-zz9".to_string())
1299 );
1300 assert_eq!(
1301 extract_run_session_id_from_read_file_error("no session"),
1302 None
1303 );
1304 }
1305
1306 #[test]
1307 fn resolve_exec_run_session_id_prefers_requested_session_id() {
1308 let payload = json!({ "session_id": " check_sh " });
1309 let payload = payload.as_object().expect("object");
1310
1311 assert_eq!(
1312 resolve_exec_run_session_id(payload).expect("requested session id"),
1313 "check_sh"
1314 );
1315 }
1316
1317 #[test]
1318 fn resolve_exec_run_session_id_generates_default_when_missing() {
1319 let payload = json!({});
1320 let payload = payload.as_object().expect("object");
1321 let session_id = resolve_exec_run_session_id(payload).expect("generated session id");
1322
1323 assert!(session_id.starts_with("run-"));
1324 }
1325
1326 #[test]
1327 fn resolve_exec_run_session_id_rejects_invalid_values() {
1328 let payload = json!({ "session_id": "bad id" });
1329 let payload = payload.as_object().expect("object");
1330 let err = resolve_exec_run_session_id(payload).expect_err("invalid session id");
1331
1332 assert!(err.to_string().contains("Invalid session_id"));
1333 }
1334
1335 #[test]
1336 fn inspect_helpers_clamp_limits() {
1337 assert_eq!(clamp_inspect_lines(Some(0), 30), 0);
1338 assert_eq!(clamp_inspect_lines(Some(9_999), 30), 5_000);
1339 assert_eq!(clamp_max_matches(None), 200);
1340 assert_eq!(clamp_max_matches(Some(0)), 1);
1341 assert_eq!(clamp_max_matches(Some(50_000)), 10_000);
1342 }
1343
1344 #[test]
1345 fn inspect_helpers_build_head_tail_preview() {
1346 let content = "l1\nl2\nl3\nl4\nl5\nl6";
1347 let (preview, truncated) = build_head_tail_preview(content, 2, 2);
1348 assert!(truncated);
1349 assert!(preview.contains("l1"));
1350 assert!(preview.contains("l2"));
1351 assert!(preview.contains("l5"));
1352 assert!(preview.contains("l6"));
1353 }
1354
1355 #[test]
1356 fn inspect_helpers_filter_lines_literal() {
1357 let (output, matched, truncated) =
1358 filter_lines("alpha\nbeta\nalpha2", "alpha", true, 1).expect("filter");
1359 assert_eq!(matched, 2);
1360 assert!(truncated);
1361 assert!(output.contains("1: alpha"));
1362 }
1363
1364 #[test]
1365 fn exec_output_preview_truncates_on_utf8_boundaries() {
1366 let preview = build_exec_output_preview("a🙂b".to_string(), 1);
1367
1368 assert!(preview.truncated);
1369 assert_eq!(preview.raw_output, "a🙂b");
1370 assert_eq!(preview.output, "a\n[Output truncated]");
1371 std::str::from_utf8(preview.output.as_bytes()).unwrap();
1372 }
1373
1374 #[test]
1375 fn exec_recovery_guidance_sets_command_not_found_metadata() {
1376 let session = VTCodeExecSession {
1377 id: "run-123".to_string().into(),
1378 backend: "pipe".to_string(),
1379 command: "zsh".to_string(),
1380 args: vec!["-c".to_string(), "pip install pymupdf".to_string()],
1381 working_dir: Some(".".to_string()),
1382 rows: None,
1383 cols: None,
1384 child_pid: None,
1385 started_at: None,
1386 lifecycle_state: None,
1387 exit_code: None,
1388 };
1389 let capture = PtyEphemeralCapture {
1390 output: String::new(),
1391 exit_code: Some(127),
1392 duration: Duration::from_millis(42),
1393 };
1394
1395 let response = build_exec_response(
1396 &session,
1397 "pip install pymupdf",
1398 &capture,
1399 ExecOutputPreview {
1400 raw_output: "bash: pip: command not found".to_string(),
1401 output: "bash: pip: command not found".to_string(),
1402 truncated: false,
1403 },
1404 None,
1405 false,
1406 None,
1407 );
1408
1409 assert_eq!(response["output"], "bash: pip: command not found");
1410 assert_eq!(response["exit_code"], 127);
1411 assert_eq!(response["session_id"], "run-123");
1412 assert_eq!(response["command"], "pip install pymupdf");
1413 assert_eq!(
1414 response["critical_note"],
1415 "Command `pip` was not found in PATH."
1416 );
1417 assert_eq!(
1418 response["next_action"],
1419 "Check the command name or install the missing binary, then rerun the command."
1420 );
1421 }
1422
1423 #[test]
1424 fn exec_recovery_guidance_ignores_non_command_not_found_exit_codes() {
1425 let mut response = json!({});
1426 attach_exec_recovery_guidance(&mut response, "cargo test", Some(1));
1427 assert!(response.get("critical_note").is_none());
1428 assert!(response.get("next_action").is_none());
1429 }
1430
1431 #[test]
1432 fn cargo_selector_error_diagnostics_classifies_missing_test_target() {
1433 let output = "error: no test target named `exec_only_policy_skips_when_full_auto_is_disabled` in `vtcode-core` package\n";
1434
1435 let diagnostics = cargo_selector_error_diagnostics(
1436 CargoTestCommandKind::Nextest,
1437 "cargo nextest run --test exec_only_policy_skips_when_full_auto_is_disabled -p vtcode-core --no-capture",
1438 output,
1439 )
1440 .expect("selector diagnostics");
1441
1442 assert_eq!(diagnostics["kind"], "cargo_test_selector_error");
1443 assert_eq!(diagnostics["package"], "vtcode-core");
1444 assert_eq!(
1445 diagnostics["requested_test_target"],
1446 "exec_only_policy_skips_when_full_auto_is_disabled"
1447 );
1448 assert_eq!(diagnostics["selector_error"], true);
1449 assert_eq!(
1450 diagnostics["validation_hint"],
1451 "cargo test -p vtcode-core --lib -- --list | rg 'exec_only_policy_skips_when_full_auto_is_disabled'"
1452 );
1453 assert_eq!(
1454 diagnostics["rerun_hint"],
1455 "cargo nextest run -p vtcode-core exec_only_policy_skips_when_full_auto_is_disabled"
1456 );
1457 }
1458
1459 #[test]
1460 fn cargo_test_failure_diagnostics_extracts_unit_test_failure_details() {
1461 let output = r#"────────────
1462 Nextest run ID 18fffe01-0ef9-4113-9a81-2344a7cc3c16 with nextest profile: default
1463 FAIL [ 0.216s] ( 363/2669) vtcode-core core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled
1464 stderr ───
1465 thread 'core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled' (382951) panicked at vtcode-core/src/core/agent/runner/tests.rs:692:10:
1466 task result: Invalid request: QueuedProvider has no queued responses
1467"#;
1468
1469 let diagnostics =
1470 cargo_test_failure_diagnostics("cargo nextest run -p vtcode-core", output, Some(100))
1471 .expect("failure diagnostics");
1472
1473 assert_eq!(diagnostics["kind"], "cargo_test_failure");
1474 assert_eq!(diagnostics["package"], "vtcode-core");
1475 assert_eq!(diagnostics["binary_kind"], "unit");
1476 assert_eq!(
1477 diagnostics["test_fqname"],
1478 "core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled"
1479 );
1480 assert_eq!(
1481 diagnostics["panic"],
1482 "task result: Invalid request: QueuedProvider has no queued responses"
1483 );
1484 assert_eq!(
1485 diagnostics["source_file"],
1486 "vtcode-core/src/core/agent/runner/tests.rs"
1487 );
1488 assert_eq!(diagnostics["source_line"], 692);
1489 assert_eq!(
1490 diagnostics["rerun_hint"],
1491 cargo_test_rerun_hint(
1492 CargoTestCommandKind::Nextest,
1493 "vtcode-core",
1494 "unit",
1495 "core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled",
1496 )
1497 );
1498 }
1499
1500 #[test]
1501 fn build_exec_response_attaches_cargo_failure_diagnostics() {
1502 let session = VTCodeExecSession {
1503 id: "run-123".to_string().into(),
1504 backend: "pipe".to_string(),
1505 command: "cargo".to_string(),
1506 args: vec![
1507 "nextest".to_string(),
1508 "run".to_string(),
1509 "-p".to_string(),
1510 "vtcode-core".to_string(),
1511 ],
1512 working_dir: Some(".".to_string()),
1513 rows: None,
1514 cols: None,
1515 child_pid: None,
1516 started_at: None,
1517 lifecycle_state: None,
1518 exit_code: None,
1519 };
1520 let raw_output = r#"
1521 FAIL [ 0.216s] ( 363/2669) vtcode-core core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled
1522 thread 'core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled' (382951) panicked at vtcode-core/src/core/agent/runner/tests.rs:692:10:
1523 task result: Invalid request: QueuedProvider has no queued responses
1524"#;
1525 let capture = PtyEphemeralCapture {
1526 output: raw_output.to_string(),
1527 exit_code: Some(100),
1528 duration: Duration::from_millis(42),
1529 };
1530
1531 let response = build_exec_response(
1532 &session,
1533 "cargo nextest run -p vtcode-core",
1534 &capture,
1535 ExecOutputPreview {
1536 raw_output: raw_output.to_string(),
1537 output: raw_output.to_string(),
1538 truncated: false,
1539 },
1540 None,
1541 false,
1542 None,
1543 );
1544
1545 assert_eq!(
1546 response["failure_diagnostics"]["test_fqname"],
1547 "core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled"
1548 );
1549 assert_eq!(response["package"], "vtcode-core");
1550 assert_eq!(response["binary_kind"], "unit");
1551 assert_eq!(
1552 response["source_file"],
1553 "vtcode-core/src/core/agent/runner/tests.rs"
1554 );
1555 assert_eq!(response["source_line"], 692);
1556 assert_eq!(
1557 response["rerun_hint"],
1558 "cargo nextest run -p vtcode-core core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled"
1559 );
1560 assert_eq!(
1561 response["next_action"],
1562 "Rerun the failing test directly with: cargo nextest run -p vtcode-core core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled"
1563 );
1564 }
1565
1566 #[test]
1567 fn attach_failure_diagnostics_metadata_promotes_selector_hints() {
1568 let mut response = json!({
1569 "success": true,
1570 "command": "cargo nextest run --test bad -p vtcode-core"
1571 });
1572 let diagnostics = json!({
1573 "kind": "cargo_test_selector_error",
1574 "package": "vtcode-core",
1575 "binary_kind": "test_target_selector",
1576 "requested_test_target": "bad",
1577 "selector_error": true,
1578 "validation_hint": "cargo test -p vtcode-core --lib -- --list | rg 'bad'",
1579 "rerun_hint": "cargo nextest run -p vtcode-core bad",
1580 "critical_note": "selector mismatch",
1581 "next_action": "validate first"
1582 });
1583
1584 attach_failure_diagnostics_metadata(&mut response, &diagnostics);
1585
1586 assert_eq!(response["package"], "vtcode-core");
1587 assert_eq!(response["binary_kind"], "test_target_selector");
1588 assert_eq!(response["selector_error"], true);
1589 assert_eq!(
1590 response["validation_hint"],
1591 "cargo test -p vtcode-core --lib -- --list | rg 'bad'"
1592 );
1593 assert_eq!(
1594 response["rerun_hint"],
1595 "cargo nextest run -p vtcode-core bad"
1596 );
1597 assert_eq!(response["critical_note"], "selector mismatch");
1598 assert_eq!(response["next_action"], "validate first");
1599 assert_eq!(
1600 response["failure_diagnostics"]["kind"],
1601 "cargo_test_selector_error"
1602 );
1603 }
1604}
1605
1606#[cfg(test)]
1607#[path = "executors/sandbox_runtime_tests.rs"]
1608mod sandbox_runtime_tests;