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 let manager = self.inventory.grep_file_manager();
525 manager
526 .perform_search(serde_json::from_value(args)?)
527 .await
528 .map(|r| json!(r))
529 }
530 UnifiedSearchAction::List => {
531 let tool = self.inventory.file_ops_tool().clone();
532 tool.execute(args).await
533 }
534 UnifiedSearchAction::Structural => {
535 crate::tools::structural_search::execute_structural_search(
536 self.workspace_root(),
537 args,
538 )
539 .await
540 }
541 UnifiedSearchAction::Intelligence => Ok(
542 serde_json::json!({"error": "Action 'intelligence' is deprecated. Use action='grep' or action='list'."}),
543 ),
544 UnifiedSearchAction::Tools => self.execute_search_tools(args).await,
545 UnifiedSearchAction::Errors => self.execute_get_errors(args).await,
546 UnifiedSearchAction::Agent => self.execute_agent_info().await,
547 UnifiedSearchAction::Web => self.execute_web_fetch(args).await,
548 UnifiedSearchAction::Skill => self.execute_skill(args).await,
549 }
550 }
551
552 pub(super) async fn execute_code(&self, args: Value) -> Result<Value> {
553 let code = args
554 .get("command")
555 .or_else(|| args.get("code"))
556 .and_then(|v| v.as_str())
557 .ok_or_else(|| anyhow!("Missing code/command in execute_code"))?;
558
559 let language = code_language_from_args(&args);
560
561 let track_files = args
562 .get("track_files")
563 .and_then(|v| v.as_bool())
564 .unwrap_or(false);
565
566 let mcp_client = self
567 .mcp_client()
568 .ok_or_else(|| anyhow!("MCP client not available"))?;
569
570 let workspace_root = self.workspace_root_owned();
571 let executor = crate::exec::code_executor::CodeExecutor::new(
572 language,
573 mcp_client.clone(),
574 workspace_root.clone(),
575 );
576 let execution_start = SystemTime::now();
577
578 let result = executor.execute(code).await?;
579
580 let mut response = json!(result);
581
582 if track_files {
583 let tracker = FileTracker::new(workspace_root);
584 if let Ok(changes) = tracker.detect_new_files(execution_start).await {
585 response["generated_files"] = json!({
586 "count": changes.len(),
587 "files": changes,
588 "summary": tracker.generate_file_summary(&changes),
589 });
590 }
591 }
592
593 Ok(response)
594 }
595
596 pub(super) async fn execute_web_fetch(&self, args: Value) -> Result<Value> {
597 acquire_executor_rate_limit("unified_search:web", 1.0)?;
598
599 let url = args
600 .get("url")
601 .and_then(|v| v.as_str())
602 .ok_or_else(|| anyhow!("Missing url in web_fetch"))?;
603
604 let client = reqwest::Client::builder()
605 .timeout(Duration::from_secs(30))
606 .user_agent("VT Code/1.0")
607 .build()?;
608
609 let response = client.get(url).send().await?;
610 let status = response.status();
611
612 if !status.is_success() {
613 return Err(anyhow!("Web fetch failed with status: {}", status));
614 }
615
616 let body = response.text().await?;
617 Ok(json!({ "success": true, "content": body, "url": url }))
618 }
619
620 pub(super) async fn execute_skill(&self, args: Value) -> Result<Value> {
621 let sub_action = args
622 .get("sub_action")
623 .and_then(|v| v.as_str())
624 .or_else(|| {
625 if args.get("name").is_some() {
626 Some("load")
627 } else {
628 None
629 }
630 })
631 .ok_or_else(|| anyhow!("Missing sub_action in skill"))?;
632
633 let skill_manager = self.inventory.skill_manager();
634
635 match sub_action {
636 "save" => {
637 let name = args
638 .get("name")
639 .and_then(|v| v.as_str())
640 .ok_or_else(|| anyhow!("Missing name in skill save"))?;
641 let code = args
642 .get("code")
643 .and_then(|v| v.as_str())
644 .ok_or_else(|| anyhow!("Missing code in skill save"))?;
645 let description = args
646 .get("description")
647 .and_then(|v| v.as_str())
648 .unwrap_or("");
649 let language = args
650 .get("language")
651 .and_then(|v| v.as_str())
652 .unwrap_or("python3");
653
654 let metadata = SkillMetadata {
655 name: name.to_string(),
656 description: description.to_string(),
657 language: language.to_string(),
658 inputs: vec![],
659 output: "".to_string(),
660 examples: vec![],
661 tags: vec![],
662 created_at: chrono::Utc::now().to_rfc3339(),
663 modified_at: chrono::Utc::now().to_rfc3339(),
664 tool_dependencies: vec![],
665 };
666
667 let skill = Skill {
668 metadata,
669 code: code.to_string(),
670 };
671
672 skill_manager.save_skill(skill).await?;
673 Ok(json!({ "success": true, "name": name }))
674 }
675 "load" => {
676 let name = args
677 .get("name")
678 .and_then(|v| v.as_str())
679 .ok_or_else(|| anyhow!("Missing name in skill load"))?;
680 let skill = skill_manager.load_skill(name).await?;
681 Ok(json!({
682 "success": true,
683 "name": skill.metadata.name,
684 "code": skill.code,
685 "language": skill.metadata.language
686 }))
687 }
688 "list" => {
689 let skills = skill_manager.list_skills().await?;
690 Ok(json!({ "success": true, "skills": skills }))
691 }
692 _ => Err(anyhow!("Unknown skill sub_action: {}", sub_action)),
693 }
694 }
695
696 pub(super) async fn execute_apply_patch(&self, args: Value) -> Result<Value> {
697 let (patch_args, patch_input_bytes, patch_base64) = self.prepare_apply_patch_args(args)?;
698 let context = self.harness_context_snapshot();
699 tracing::debug!(
700 tool = tools::UNIFIED_FILE,
701 action = "patch",
702 payload_bytes = serialized_payload_size_bytes(&patch_args),
703 patch_input_bytes,
704 patch_base64,
705 patch_decoded_bytes = patch_args
706 .get("input")
707 .and_then(|v| v.as_str())
708 .map(|s| s.len())
709 .unwrap_or(0),
710 session_id = %context.session_id,
711 task_id = %context.task_id.as_deref().unwrap_or(""),
712 "Prepared patch payload for apply_patch"
713 );
714
715 self.execute_apply_patch_internal(patch_args).await
716 }
717
718 fn prepare_apply_patch_args(&self, args: Value) -> Result<(Value, usize, bool)> {
719 let patch_input = crate::tools::apply_patch::decode_apply_patch_input(&args)?
720 .ok_or_else(|| anyhow!("Missing patch input"))?;
721 let patch_input_bytes = patch_input.source_bytes;
722 let patch_base64 = patch_input.was_base64;
723
724 let mut patch_args = args;
725 patch_args["input"] = json!(patch_input.text);
726 Ok((patch_args, patch_input_bytes, patch_base64))
727 }
728
729 fn log_unified_file_payload_diagnostics(&self, action: &str, args: &Value) {
730 let context = self.harness_context_snapshot();
731 let (patch_source_bytes, patch_base64) =
732 crate::tools::apply_patch::patch_source_from_args(args)
733 .map(|source| (source.len(), source.starts_with("base64:")))
734 .unwrap_or((0, false));
735
736 tracing::trace!(
737 tool = tools::UNIFIED_FILE,
738 action,
739 payload_bytes = serialized_payload_size_bytes(args),
740 patch_source_bytes,
741 patch_base64,
742 session_id = %context.session_id,
743 task_id = %context.task_id.as_deref().unwrap_or(""),
744 "Captured unified_file payload diagnostics"
745 );
746 }
747
748 async fn resolve_exec_sandbox_request(
749 &self,
750 payload: &serde_json::Map<String, Value>,
751 ) -> Result<ResolvedExecSandboxRequest> {
752 let working_dir_path = self
753 .pty_manager()
754 .resolve_working_dir(shell_working_dir_value(payload))
755 .await?;
756 let (sandbox_permissions, additional_permissions) =
757 parse_requested_sandbox_permissions(payload, &working_dir_path)?;
758
759 Ok(ResolvedExecSandboxRequest {
760 working_dir_path,
761 sandbox_permissions,
762 additional_permissions,
763 })
764 }
765
766 delegate_to_tool!(read_file_executor, file_ops_tool, read_file);
772 delegate_to_tool!(write_file_executor, file_ops_tool, write_file);
773
774 delegate_to_self!(list_files_executor, list_files);
776 delegate_to_self!(edit_file_executor, edit_file);
777 delegate_to_self!(get_errors_executor, execute_get_errors);
778 delegate_to_self!(mcp_search_tools_executor, execute_mcp_search_tools);
779 delegate_to_self!(mcp_get_tool_details_executor, execute_mcp_get_tool_details);
780 delegate_to_self!(mcp_list_servers_executor, execute_mcp_list_servers);
781 delegate_to_self!(mcp_connect_server_executor, execute_mcp_connect_server);
782 delegate_to_self!(
783 mcp_disconnect_server_executor,
784 execute_mcp_disconnect_server
785 );
786 delegate_to_self!(apply_patch_executor, execute_apply_patch);
787
788 pub(super) fn run_pty_cmd_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
790 self.dispatch_unified_exec_run_alias(args, true)
791 }
792
793 pub(super) fn send_pty_input_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
794 self.dispatch_unified_exec_action_alias(args, "write")
795 }
796
797 pub(super) fn read_pty_session_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
798 self.dispatch_unified_exec_action_alias(args, "poll")
799 }
800
801 pub(super) fn create_pty_session_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
802 self.dispatch_unified_exec_run_alias(args, true)
803 }
804
805 pub(super) fn list_pty_sessions_executor(&self, _args: Value) -> BoxFuture<'_, Result<Value>> {
806 self.dispatch_unified_exec_alias(json!({"action": "list"}))
807 }
808
809 pub(super) fn close_pty_session_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
810 self.dispatch_unified_exec_action_alias(args, "close")
811 }
812
813 }
817
818#[cfg(test)]
819mod execute_code_tests {
820 use super::code_language_from_args;
821 use crate::exec::code_executor::Language;
822 use serde_json::json;
823
824 #[test]
825 fn code_language_uses_language_field_instead_of_action() {
826 assert_eq!(
827 code_language_from_args(&json!({
828 "action": "code",
829 "language": "javascript",
830 })),
831 Language::JavaScript
832 );
833 assert_eq!(
834 code_language_from_args(&json!({
835 "action": "code",
836 "lang": "js",
837 })),
838 Language::JavaScript
839 );
840 assert_eq!(
841 code_language_from_args(&json!({
842 "action": "code",
843 })),
844 Language::Python3
845 );
846 }
847}
848
849#[cfg(test)]
850mod subagent_tool_output_tests {
851 use super::sanitize_subagent_tool_output_paths;
852 use serde_json::json;
853 use tempfile::TempDir;
854
855 #[test]
856 fn strips_transcript_paths_outside_workspace() {
857 let temp = TempDir::new().expect("tempdir");
858 let mut value = json!({
859 "completed": true,
860 "entry": {
861 "id": "agent-1",
862 "transcript_path": "/Users/example/.vtcode/sessions/agent-1.json",
863 }
864 });
865
866 sanitize_subagent_tool_output_paths(temp.path(), &mut value);
867
868 assert!(value["entry"].get("transcript_path").is_none());
869 }
870
871 #[test]
872 fn keeps_transcript_paths_inside_workspace() {
873 let temp = TempDir::new().expect("tempdir");
874 let transcript_path = temp.path().join(".vtcode/context/subagents/agent-1.json");
875 let mut value = json!({
876 "id": "agent-1",
877 "transcript_path": transcript_path,
878 });
879
880 sanitize_subagent_tool_output_paths(temp.path(), &mut value);
881
882 assert_eq!(value["transcript_path"].as_str(), transcript_path.to_str());
883 }
884}
885
886#[cfg(test)]
887mod shell_preference_tests {
888 use super::{resolve_shell_preference, resolve_shell_preference_with_zsh_fork};
889 use crate::config::PtyConfig;
890 use crate::tools::shell::resolve_fallback_shell;
891
892 #[test]
893 fn explicit_shell_overrides_config_preference() {
894 let config = PtyConfig {
895 preferred_shell: Some("/bin/bash".to_string()),
896 ..Default::default()
897 };
898
899 let resolved = resolve_shell_preference(Some(" /bin/zsh "), &config);
900 assert_eq!(resolved, "/bin/zsh");
901 }
902
903 #[test]
904 fn config_preferred_shell_used_when_explicit_missing() {
905 let config = PtyConfig {
906 preferred_shell: Some("zsh".to_string()),
907 ..Default::default()
908 };
909
910 let resolved = resolve_shell_preference(None, &config);
911 assert_eq!(resolved, "zsh");
912 }
913
914 #[test]
915 fn blank_explicit_shell_falls_back_to_config_preference() {
916 let config = PtyConfig {
917 preferred_shell: Some("bash".to_string()),
918 ..Default::default()
919 };
920
921 let resolved = resolve_shell_preference(Some(" "), &config);
922 assert_eq!(resolved, "bash");
923 }
924
925 #[test]
926 fn blank_config_shell_falls_back_to_default_resolver() {
927 let config = PtyConfig {
928 preferred_shell: Some(" ".to_string()),
929 ..Default::default()
930 };
931
932 let resolved = resolve_shell_preference(None, &config);
933 assert_eq!(resolved, resolve_fallback_shell());
934 }
935
936 #[test]
937 fn missing_preferences_fall_back_to_default_resolver() {
938 let config = PtyConfig::default();
939 let resolved = resolve_shell_preference(None, &config);
940 assert_eq!(resolved, resolve_fallback_shell());
941 }
942
943 #[test]
944 fn zsh_fork_disabled_uses_standard_shell_resolution() -> anyhow::Result<()> {
945 let config = PtyConfig {
946 preferred_shell: Some("/bin/bash".to_string()),
947 ..Default::default()
948 };
949 let resolved = resolve_shell_preference_with_zsh_fork(None, &config)?;
950 assert_eq!(resolved, "/bin/bash");
951 Ok(())
952 }
953
954 #[test]
955 fn zsh_fork_missing_path_returns_error() {
956 let config = PtyConfig {
957 shell_zsh_fork: true,
958 zsh_path: None,
959 ..PtyConfig::default()
960 };
961 resolve_shell_preference_with_zsh_fork(Some("/bin/bash"), &config).unwrap_err();
962 }
963
964 #[cfg(unix)]
965 #[test]
966 fn zsh_fork_ignores_explicit_shell_and_uses_configured_path() -> anyhow::Result<()> {
967 let zsh = tempfile::NamedTempFile::new()?;
968 let expected = zsh.path().to_string_lossy().to_string();
969 let config = PtyConfig {
970 shell_zsh_fork: true,
971 zsh_path: Some(expected.clone()),
972 ..PtyConfig::default()
973 };
974 let resolved = resolve_shell_preference_with_zsh_fork(Some("/bin/bash"), &config)?;
975 assert_eq!(resolved, expected);
976 Ok(())
977 }
978}
979
980#[cfg(test)]
981mod token_efficiency_tests {
982 use super::*;
983
984 #[test]
985 fn test_suggests_limit_for_cat() {
986 assert_eq!(suggest_max_tokens_for_command("cat file.txt"), Some(250));
987 assert_eq!(
988 suggest_max_tokens_for_command("cat /path/to/file.rs"),
989 Some(250)
990 );
991 assert_eq!(suggest_max_tokens_for_command("CAT file.txt"), Some(250)); }
993
994 #[test]
995 fn test_suggests_limit_for_bat() {
996 assert_eq!(suggest_max_tokens_for_command("bat file.rs"), Some(250));
997 }
998
999 #[test]
1000 fn test_no_limit_when_already_limited() {
1001 assert_eq!(suggest_max_tokens_for_command("cat file.txt | head"), None);
1002 assert_eq!(suggest_max_tokens_for_command("head -n 50 file.txt"), None);
1003 assert_eq!(suggest_max_tokens_for_command("tail -n 20 file.txt"), None);
1004 }
1005
1006 #[test]
1007 fn test_no_limit_for_other_commands() {
1008 assert_eq!(suggest_max_tokens_for_command("ls -la"), None);
1009 assert_eq!(suggest_max_tokens_for_command("grep pattern file"), None);
1010 assert_eq!(suggest_max_tokens_for_command("echo hello"), None);
1011 }
1012}
1013
1014#[cfg(test)]
1015mod pty_output_filter_tests {
1016 use super::filter_pty_output;
1017
1018 #[test]
1019 fn normalizes_crlf_sequences() {
1020 let raw = "a\r\nb\rc\n";
1021 assert_eq!(filter_pty_output(raw), "a\nb\nc\n");
1022 }
1023}
1024
1025#[cfg(test)]
1026mod pty_context_tests {
1027 use super::{
1028 ExecOutputPreview, PtyEphemeralCapture, attach_exec_response_context,
1029 attach_pty_continuation, build_exec_response, build_exec_session_command_display,
1030 };
1031 use crate::tools::types::VTCodeExecSession;
1032 use serde_json::json;
1033
1034 #[test]
1035 fn build_exec_session_command_display_unwraps_shell_c_argument() {
1036 let session = VTCodeExecSession {
1037 id: "run-123".to_string().into(),
1038 backend: "pty".to_string(),
1039 command: "zsh".to_string(),
1040 args: vec![
1041 "-l".to_string(),
1042 "-c".to_string(),
1043 "cargo check".to_string(),
1044 ],
1045 working_dir: Some(".".to_string()),
1046 rows: Some(24),
1047 cols: Some(80),
1048 child_pid: None,
1049 started_at: None,
1050 lifecycle_state: None,
1051 exit_code: None,
1052 };
1053
1054 assert_eq!(build_exec_session_command_display(&session), "cargo check");
1055 }
1056
1057 #[test]
1058 fn attach_exec_response_context_sets_expected_keys() {
1059 let mut response = json!({ "output": "ok" });
1060 let session = VTCodeExecSession {
1061 id: "run-123".to_string().into(),
1062 backend: "pty".to_string(),
1063 command: "zsh".to_string(),
1064 args: vec![
1065 "-l".to_string(),
1066 "-c".to_string(),
1067 "cargo check".to_string(),
1068 ],
1069 working_dir: Some(".".to_string()),
1070 rows: Some(30),
1071 cols: Some(120),
1072 child_pid: None,
1073 started_at: None,
1074 lifecycle_state: None,
1075 exit_code: None,
1076 };
1077
1078 attach_exec_response_context(&mut response, &session, "cargo check", false);
1079
1080 assert_eq!(response["session_id"], "run-123");
1081 assert_eq!(response["command"], "cargo check");
1082 assert_eq!(response["working_directory"], ".");
1083 assert_eq!(response["backend"], "pty");
1084 assert_eq!(response["rows"], 30);
1085 assert_eq!(response["cols"], 120);
1086 assert_eq!(response["is_exited"], false);
1087 }
1088
1089 #[test]
1090 fn attach_pty_continuation_compacts_next_continue_args() {
1091 let mut response = json!({ "output": "ok" });
1092 attach_pty_continuation(&mut response, "run-123");
1093
1094 assert!(response.get("follow_up_prompt").is_none());
1095 assert!(response.get("next_poll_args").is_none());
1096 assert_eq!(
1097 response["next_continue_args"],
1098 json!({ "session_id": "run-123" })
1099 );
1100 assert!(response.get("preferred_next_action").is_none());
1101 }
1102
1103 #[test]
1104 fn attach_pty_continuation_keeps_payload_compact() {
1105 let mut response = json!({ "output": "ok" });
1106 attach_pty_continuation(&mut response, "run-123");
1107
1108 assert!(response.get("follow_up_prompt").is_none());
1109 assert!(response.get("next_poll_args").is_none());
1110 assert_eq!(
1111 response["next_continue_args"],
1112 json!({ "session_id": "run-123" })
1113 );
1114 }
1115
1116 #[test]
1117 fn build_exec_response_skips_continuation_after_exit() {
1118 let session = VTCodeExecSession {
1119 id: "run-123".to_string().into(),
1120 backend: "pipe".to_string(),
1121 command: "cargo".to_string(),
1122 args: vec!["check".to_string()],
1123 working_dir: Some(".".to_string()),
1124 rows: None,
1125 cols: None,
1126 child_pid: None,
1127 started_at: None,
1128 lifecycle_state: None,
1129 exit_code: None,
1130 };
1131 let capture = PtyEphemeralCapture {
1132 output: "first\nsecond\n".to_string(),
1133 exit_code: Some(0),
1134 duration: std::time::Duration::from_millis(25),
1135 };
1136
1137 let response = build_exec_response(
1138 &session,
1139 "cargo check",
1140 &capture,
1141 ExecOutputPreview {
1142 raw_output: "first\nsecond\n".to_string(),
1143 output: "first\n[Output truncated]".to_string(),
1144 truncated: true,
1145 },
1146 None,
1147 false,
1148 None,
1149 );
1150
1151 assert_eq!(response["exit_code"], 0);
1152 assert!(response.get("next_continue_args").is_none());
1153 }
1154}
1155
1156#[cfg(test)]
1157mod git_diff_tests {
1158 use super::is_git_diff_command;
1159
1160 #[test]
1161 fn detects_git_diff() {
1162 let cmd = vec!["git".to_string(), "diff".to_string()];
1163 assert!(is_git_diff_command(&cmd));
1164 }
1165
1166 #[test]
1167 fn detects_git_diff_with_flags() {
1168 let cmd = vec![
1169 "git".to_string(),
1170 "-c".to_string(),
1171 "color.ui=always".to_string(),
1172 "diff".to_string(),
1173 "--stat".to_string(),
1174 ];
1175 assert!(is_git_diff_command(&cmd));
1176 }
1177
1178 #[test]
1179 fn detects_git_diff_with_path() {
1180 let cmd = vec!["/usr/bin/git".to_string(), "diff".to_string()];
1181 assert!(is_git_diff_command(&cmd));
1182 }
1183
1184 #[test]
1185 fn ignores_other_git_commands() {
1186 let cmd = vec!["git".to_string(), "status".to_string()];
1187 assert!(!is_git_diff_command(&cmd));
1188 }
1189}
1190
1191#[cfg(test)]
1192mod unified_action_error_tests {
1193 use super::{
1194 CargoTestCommandKind, ExecOutputPreview, PtyEphemeralCapture,
1195 attach_exec_recovery_guidance, attach_failure_diagnostics_metadata,
1196 build_exec_output_preview, build_exec_response, build_head_tail_preview,
1197 cargo_selector_error_diagnostics, cargo_test_failure_diagnostics, cargo_test_rerun_hint,
1198 clamp_inspect_lines, clamp_max_matches, extract_run_session_id_from_read_file_error,
1199 extract_run_session_id_from_tool_output_path, filter_lines,
1200 missing_unified_exec_action_error, missing_unified_search_action_error,
1201 resolve_exec_run_session_id, summarized_arg_keys,
1202 };
1203 use crate::tools::types::VTCodeExecSession;
1204 use serde_json::json;
1205 use std::time::Duration;
1206
1207 #[test]
1208 fn summarized_arg_keys_reports_shape_for_non_object_payloads() {
1209 assert_eq!(summarized_arg_keys(&json!(null)), "<null>");
1210 assert_eq!(summarized_arg_keys(&json!(["a", "b"])), "<array>");
1211 assert_eq!(summarized_arg_keys(&json!("x")), "<string>");
1212 }
1213
1214 #[test]
1215 fn unified_exec_missing_action_error_includes_received_keys() {
1216 let err = missing_unified_exec_action_error(&json!({
1217 "foo": "bar",
1218 "session_id": "123"
1219 }));
1220 let text = err.to_string();
1221 assert!(text.contains("Missing unified_exec action"));
1222 assert!(text.contains("foo"));
1223 assert!(text.contains("session_id"));
1224 }
1225
1226 #[test]
1227 fn unified_search_missing_action_error_includes_received_keys() {
1228 let err = missing_unified_search_action_error(&json!({
1229 "unexpected": true
1230 }));
1231 let text = err.to_string();
1232 assert!(text.contains("Missing unified_search action"));
1233 assert!(text.contains("unexpected"));
1234 }
1235
1236 #[test]
1237 fn extracts_run_session_id_from_tool_output_path() {
1238 assert_eq!(
1239 extract_run_session_id_from_tool_output_path(
1240 ".vtcode/context/tool_outputs/run-abc123.txt"
1241 ),
1242 Some("run-abc123".to_string())
1243 );
1244 assert_eq!(
1245 extract_run_session_id_from_tool_output_path(
1246 ".vtcode/context/tool_outputs/not-a-session.txt"
1247 ),
1248 None
1249 );
1250 }
1251
1252 #[test]
1253 fn extracts_run_session_id_from_read_file_error() {
1254 let error = "Use unified_exec with session_id=\"run-zz9\" instead of read_file.";
1255 assert_eq!(
1256 extract_run_session_id_from_read_file_error(error),
1257 Some("run-zz9".to_string())
1258 );
1259 assert_eq!(
1260 extract_run_session_id_from_read_file_error("no session"),
1261 None
1262 );
1263 }
1264
1265 #[test]
1266 fn resolve_exec_run_session_id_prefers_requested_session_id() {
1267 let payload = json!({ "session_id": " check_sh " });
1268 let payload = payload.as_object().expect("object");
1269
1270 assert_eq!(
1271 resolve_exec_run_session_id(payload).expect("requested session id"),
1272 "check_sh"
1273 );
1274 }
1275
1276 #[test]
1277 fn resolve_exec_run_session_id_generates_default_when_missing() {
1278 let payload = json!({});
1279 let payload = payload.as_object().expect("object");
1280 let session_id = resolve_exec_run_session_id(payload).expect("generated session id");
1281
1282 assert!(session_id.starts_with("run-"));
1283 }
1284
1285 #[test]
1286 fn resolve_exec_run_session_id_rejects_invalid_values() {
1287 let payload = json!({ "session_id": "bad id" });
1288 let payload = payload.as_object().expect("object");
1289 let err = resolve_exec_run_session_id(payload).expect_err("invalid session id");
1290
1291 assert!(err.to_string().contains("Invalid session_id"));
1292 }
1293
1294 #[test]
1295 fn inspect_helpers_clamp_limits() {
1296 assert_eq!(clamp_inspect_lines(Some(0), 30), 0);
1297 assert_eq!(clamp_inspect_lines(Some(9_999), 30), 5_000);
1298 assert_eq!(clamp_max_matches(None), 200);
1299 assert_eq!(clamp_max_matches(Some(0)), 1);
1300 assert_eq!(clamp_max_matches(Some(50_000)), 10_000);
1301 }
1302
1303 #[test]
1304 fn inspect_helpers_build_head_tail_preview() {
1305 let content = "l1\nl2\nl3\nl4\nl5\nl6";
1306 let (preview, truncated) = build_head_tail_preview(content, 2, 2);
1307 assert!(truncated);
1308 assert!(preview.contains("l1"));
1309 assert!(preview.contains("l2"));
1310 assert!(preview.contains("l5"));
1311 assert!(preview.contains("l6"));
1312 }
1313
1314 #[test]
1315 fn inspect_helpers_filter_lines_literal() {
1316 let (output, matched, truncated) =
1317 filter_lines("alpha\nbeta\nalpha2", "alpha", true, 1).expect("filter");
1318 assert_eq!(matched, 2);
1319 assert!(truncated);
1320 assert!(output.contains("1: alpha"));
1321 }
1322
1323 #[test]
1324 fn exec_output_preview_truncates_on_utf8_boundaries() {
1325 let preview = build_exec_output_preview("a🙂b".to_string(), 1);
1326
1327 assert!(preview.truncated);
1328 assert_eq!(preview.raw_output, "a🙂b");
1329 assert_eq!(preview.output, "a\n[Output truncated]");
1330 std::str::from_utf8(preview.output.as_bytes()).unwrap();
1331 }
1332
1333 #[test]
1334 fn exec_recovery_guidance_sets_command_not_found_metadata() {
1335 let session = VTCodeExecSession {
1336 id: "run-123".to_string().into(),
1337 backend: "pipe".to_string(),
1338 command: "zsh".to_string(),
1339 args: vec!["-c".to_string(), "pip install pymupdf".to_string()],
1340 working_dir: Some(".".to_string()),
1341 rows: None,
1342 cols: None,
1343 child_pid: None,
1344 started_at: None,
1345 lifecycle_state: None,
1346 exit_code: None,
1347 };
1348 let capture = PtyEphemeralCapture {
1349 output: String::new(),
1350 exit_code: Some(127),
1351 duration: Duration::from_millis(42),
1352 };
1353
1354 let response = build_exec_response(
1355 &session,
1356 "pip install pymupdf",
1357 &capture,
1358 ExecOutputPreview {
1359 raw_output: "bash: pip: command not found".to_string(),
1360 output: "bash: pip: command not found".to_string(),
1361 truncated: false,
1362 },
1363 None,
1364 false,
1365 None,
1366 );
1367
1368 assert_eq!(response["output"], "bash: pip: command not found");
1369 assert_eq!(response["exit_code"], 127);
1370 assert_eq!(response["session_id"], "run-123");
1371 assert_eq!(response["command"], "pip install pymupdf");
1372 assert_eq!(
1373 response["critical_note"],
1374 "Command `pip` was not found in PATH."
1375 );
1376 assert_eq!(
1377 response["next_action"],
1378 "Check the command name or install the missing binary, then rerun the command."
1379 );
1380 }
1381
1382 #[test]
1383 fn exec_recovery_guidance_ignores_non_command_not_found_exit_codes() {
1384 let mut response = json!({});
1385 attach_exec_recovery_guidance(&mut response, "cargo test", Some(1));
1386 assert!(response.get("critical_note").is_none());
1387 assert!(response.get("next_action").is_none());
1388 }
1389
1390 #[test]
1391 fn cargo_selector_error_diagnostics_classifies_missing_test_target() {
1392 let output = "error: no test target named `exec_only_policy_skips_when_full_auto_is_disabled` in `vtcode-core` package\n";
1393
1394 let diagnostics = cargo_selector_error_diagnostics(
1395 CargoTestCommandKind::Nextest,
1396 "cargo nextest run --test exec_only_policy_skips_when_full_auto_is_disabled -p vtcode-core --no-capture",
1397 output,
1398 )
1399 .expect("selector diagnostics");
1400
1401 assert_eq!(diagnostics["kind"], "cargo_test_selector_error");
1402 assert_eq!(diagnostics["package"], "vtcode-core");
1403 assert_eq!(
1404 diagnostics["requested_test_target"],
1405 "exec_only_policy_skips_when_full_auto_is_disabled"
1406 );
1407 assert_eq!(diagnostics["selector_error"], true);
1408 assert_eq!(
1409 diagnostics["validation_hint"],
1410 "cargo test -p vtcode-core --lib -- --list | rg 'exec_only_policy_skips_when_full_auto_is_disabled'"
1411 );
1412 assert_eq!(
1413 diagnostics["rerun_hint"],
1414 "cargo nextest run -p vtcode-core exec_only_policy_skips_when_full_auto_is_disabled"
1415 );
1416 }
1417
1418 #[test]
1419 fn cargo_test_failure_diagnostics_extracts_unit_test_failure_details() {
1420 let output = r#"────────────
1421 Nextest run ID 18fffe01-0ef9-4113-9a81-2344a7cc3c16 with nextest profile: default
1422 FAIL [ 0.216s] ( 363/2669) vtcode-core core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled
1423 stderr ───
1424 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:
1425 task result: Invalid request: QueuedProvider has no queued responses
1426"#;
1427
1428 let diagnostics =
1429 cargo_test_failure_diagnostics("cargo nextest run -p vtcode-core", output, Some(100))
1430 .expect("failure diagnostics");
1431
1432 assert_eq!(diagnostics["kind"], "cargo_test_failure");
1433 assert_eq!(diagnostics["package"], "vtcode-core");
1434 assert_eq!(diagnostics["binary_kind"], "unit");
1435 assert_eq!(
1436 diagnostics["test_fqname"],
1437 "core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled"
1438 );
1439 assert_eq!(
1440 diagnostics["panic"],
1441 "task result: Invalid request: QueuedProvider has no queued responses"
1442 );
1443 assert_eq!(
1444 diagnostics["source_file"],
1445 "vtcode-core/src/core/agent/runner/tests.rs"
1446 );
1447 assert_eq!(diagnostics["source_line"], 692);
1448 assert_eq!(
1449 diagnostics["rerun_hint"],
1450 cargo_test_rerun_hint(
1451 CargoTestCommandKind::Nextest,
1452 "vtcode-core",
1453 "unit",
1454 "core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled",
1455 )
1456 );
1457 }
1458
1459 #[test]
1460 fn build_exec_response_attaches_cargo_failure_diagnostics() {
1461 let session = VTCodeExecSession {
1462 id: "run-123".to_string().into(),
1463 backend: "pipe".to_string(),
1464 command: "cargo".to_string(),
1465 args: vec![
1466 "nextest".to_string(),
1467 "run".to_string(),
1468 "-p".to_string(),
1469 "vtcode-core".to_string(),
1470 ],
1471 working_dir: Some(".".to_string()),
1472 rows: None,
1473 cols: None,
1474 child_pid: None,
1475 started_at: None,
1476 lifecycle_state: None,
1477 exit_code: None,
1478 };
1479 let raw_output = r#"
1480 FAIL [ 0.216s] ( 363/2669) vtcode-core core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled
1481 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:
1482 task result: Invalid request: QueuedProvider has no queued responses
1483"#;
1484 let capture = PtyEphemeralCapture {
1485 output: raw_output.to_string(),
1486 exit_code: Some(100),
1487 duration: Duration::from_millis(42),
1488 };
1489
1490 let response = build_exec_response(
1491 &session,
1492 "cargo nextest run -p vtcode-core",
1493 &capture,
1494 ExecOutputPreview {
1495 raw_output: raw_output.to_string(),
1496 output: raw_output.to_string(),
1497 truncated: false,
1498 },
1499 None,
1500 false,
1501 None,
1502 );
1503
1504 assert_eq!(
1505 response["failure_diagnostics"]["test_fqname"],
1506 "core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled"
1507 );
1508 assert_eq!(response["package"], "vtcode-core");
1509 assert_eq!(response["binary_kind"], "unit");
1510 assert_eq!(
1511 response["source_file"],
1512 "vtcode-core/src/core/agent/runner/tests.rs"
1513 );
1514 assert_eq!(response["source_line"], 692);
1515 assert_eq!(
1516 response["rerun_hint"],
1517 "cargo nextest run -p vtcode-core core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled"
1518 );
1519 assert_eq!(
1520 response["next_action"],
1521 "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"
1522 );
1523 }
1524
1525 #[test]
1526 fn attach_failure_diagnostics_metadata_promotes_selector_hints() {
1527 let mut response = json!({
1528 "success": true,
1529 "command": "cargo nextest run --test bad -p vtcode-core"
1530 });
1531 let diagnostics = json!({
1532 "kind": "cargo_test_selector_error",
1533 "package": "vtcode-core",
1534 "binary_kind": "test_target_selector",
1535 "requested_test_target": "bad",
1536 "selector_error": true,
1537 "validation_hint": "cargo test -p vtcode-core --lib -- --list | rg 'bad'",
1538 "rerun_hint": "cargo nextest run -p vtcode-core bad",
1539 "critical_note": "selector mismatch",
1540 "next_action": "validate first"
1541 });
1542
1543 attach_failure_diagnostics_metadata(&mut response, &diagnostics);
1544
1545 assert_eq!(response["package"], "vtcode-core");
1546 assert_eq!(response["binary_kind"], "test_target_selector");
1547 assert_eq!(response["selector_error"], true);
1548 assert_eq!(
1549 response["validation_hint"],
1550 "cargo test -p vtcode-core --lib -- --list | rg 'bad'"
1551 );
1552 assert_eq!(
1553 response["rerun_hint"],
1554 "cargo nextest run -p vtcode-core bad"
1555 );
1556 assert_eq!(response["critical_note"], "selector mismatch");
1557 assert_eq!(response["next_action"], "validate first");
1558 assert_eq!(
1559 response["failure_diagnostics"]["kind"],
1560 "cargo_test_selector_error"
1561 );
1562 }
1563}
1564
1565#[cfg(test)]
1566#[path = "executors/sandbox_runtime_tests.rs"]
1567mod sandbox_runtime_tests;