Skip to main content

vtcode_core/tools/registry/
executors.rs

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
114/// Generate an executor that delegates to a cloned tool instance from the inventory.
115macro_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
124/// Generate an executor that delegates to an async method on `self`.
125macro_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        // Default to workspace root when path is omitted for list/grep actions to reduce friction
508        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    // ============================================================
767    // SPECIALIZED EXECUTORS (Hidden from LLM, used by unified tools)
768    // ============================================================
769
770    // File operation executors -- delegate to the file_ops_tool from inventory
771    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    // Self-delegating executors -- forward to async methods on ToolRegistry
775    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    // PTY executors -- distinct signatures, kept explicit
789    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    // ============================================================
814    // INTERNAL IMPLEMENTATIONS
815    // ============================================================
816}
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)); // case insensitive
992    }
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;