Skip to main content

vtcode_core/tools/registry/
executors.rs

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