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