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 inspection_cwd = self
359            .pty_manager()
360            .working_dir_candidate(crate::tools::command_args::working_dir_text(args))?;
361        self.edited_file_monitor_ref()
362            .source_inspection()
363            .check_shell(args, &inspection_cwd)?;
364        let sandbox_request = self.resolve_exec_sandbox_request(payload).await?;
365        let output_config = exec_run_output_config(payload, &prepared_command.display_command);
366
367        enforce_pty_command_policy(&prepared_command.display_command, confirm)?;
368        let sandbox_config = self.sandbox_config();
369        let sandbox_plan = build_shell_execution_plan(
370            &sandbox_config,
371            self.workspace_root(),
372            &prepared_command.requested_command,
373            sandbox_request.sandbox_permissions,
374            sandbox_request.additional_permissions.as_ref(),
375        )?;
376        let sandbox_active = sandbox_plan.sandbox_policy.is_some();
377        prepared_command.command = apply_runtime_sandbox_to_command(
378            prepared_command.command,
379            &prepared_command.requested_command,
380            &sandbox_config,
381            self.workspace_root(),
382            &sandbox_request.working_dir_path,
383            sandbox_request.sandbox_permissions,
384            sandbox_request.additional_permissions.as_ref(),
385        )?;
386
387        let rows = match backend {
388            ExecRunBackendKind::Pty => {
389                Some(parse_pty_dimension("rows", payload.get("rows"), self.pty_config().default_rows)?)
390            }
391            ExecRunBackendKind::Pipe => None,
392        };
393        let cols = match backend {
394            ExecRunBackendKind::Pty => {
395                Some(parse_pty_dimension("cols", payload.get("cols"), self.pty_config().default_cols)?)
396            }
397            ExecRunBackendKind::Pipe => None,
398        };
399
400        Ok(PreparedExecRunRequest {
401            prepared_command,
402            working_dir_path: sandbox_request.working_dir_path,
403            output_config,
404            yield_duration: Duration::from_millis(
405                clamp_exec_yield_ms(
406                    payload.get("yield_time_ms").and_then(Value::as_u64),
407                    if background { 250 } else { 10_000 },
408                )
409                .min(if background { 1_000 } else { 30_000 }),
410            ),
411            session_id: resolve_exec_run_session_id(payload)?,
412            shell_program,
413            env_overrides: parse_exec_env_overrides(payload)?,
414            is_git_diff,
415            confirm,
416            rows,
417            cols,
418            sandbox_active,
419            background,
420            stdin,
421        })
422    }
423
424    pub(super) async fn execute_command_session(&self, args: Value) -> Result<Value> {
425        self.execute_command_session_internal(args, ExecSettlementMode::Manual).await
426    }
427
428    pub(super) async fn execute_harness_command_session_terminal_run_raw(&self, args: Value) -> Result<Value> {
429        let args = normalize_command_session_run_alias_args(&args, true)?;
430        self.execute_command_session_run_pty(args, true).await
431    }
432
433    fn dispatch_command_session_alias(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
434        Box::pin(async move { self.execute_command_session(args).await.map(super::normalize_tool_output) })
435    }
436
437    fn dispatch_command_session_run_alias(&self, args: Value, tty: bool) -> BoxFuture<'_, Result<Value>> {
438        Box::pin(async move {
439            let args = normalize_command_session_run_alias_args(&args, tty)?;
440            self.execute_command_session(args).await.map(super::normalize_tool_output)
441        })
442    }
443
444    fn dispatch_command_session_action_alias(&self, args: Value, action: &'static str) -> BoxFuture<'_, Result<Value>> {
445        self.dispatch_command_session_alias(with_command_session_action_default(args, action))
446    }
447
448    pub(super) async fn execute_command_session_internal(
449        &self,
450        args: Value,
451        exec_settlement_mode: ExecSettlementMode,
452    ) -> Result<Value> {
453        let launch = self.begin_completion_verifier(&args);
454        let result = self.execute_command_session_observed(args.clone(), exec_settlement_mode).await;
455        self.observe_completion_verifier(launch, &args, &result);
456        result
457    }
458
459    async fn execute_command_session_observed(
460        &self,
461        args: Value,
462        exec_settlement_mode: ExecSettlementMode,
463    ) -> Result<Value> {
464        let args = crate::tools::command_args::normalize_shell_args(&args).map_err(|error| anyhow!(error))?;
465
466        let action_str =
467            tool_intent::command_session_action(&args).ok_or_else(|| missing_command_session_action_error(&args))?;
468        let action: CommandSessionAction = parse_action(action_str)?;
469
470        match action {
471            CommandSessionAction::Run => self.execute_command_session_run_internal(args, exec_settlement_mode).await,
472            CommandSessionAction::Write => self.execute_command_session_write(args).await,
473            CommandSessionAction::Poll => self.execute_command_session_poll_internal(args, exec_settlement_mode).await,
474            CommandSessionAction::Wait => self.execute_command_session_wait(args).await,
475            CommandSessionAction::Continue => {
476                self.execute_command_session_continue_internal(args, exec_settlement_mode).await
477            }
478            CommandSessionAction::Inspect => self.execute_command_session_inspect(args).await,
479            CommandSessionAction::List => self.execute_command_session_list().await,
480            CommandSessionAction::Close => self.execute_command_session_close(args).await,
481            CommandSessionAction::Code => self.execute_code(args).await,
482        }
483    }
484
485    async fn execute_command_session_run_internal(
486        &self,
487        args: Value,
488        exec_settlement_mode: ExecSettlementMode,
489    ) -> Result<Value> {
490        let tty = args.get("tty").and_then(Value::as_bool).unwrap_or(false);
491        if tty {
492            self.execute_command_session_run_pty(args, false).await
493        } else {
494            self.execute_run_pipe_cmd(args, exec_settlement_mode).await
495        }
496    }
497
498    async fn execute_code_search(&self, args: Value) -> Result<Value> {
499        let request = serde_json::from_value(args).context("invalid code_search request")?;
500        let response = crate::tools::code_search::execute(self.workspace_root(), request).await?;
501        serde_json::to_value(response).context("failed to serialise code_search response")
502    }
503
504    async fn execute_code(&self, args: Value) -> Result<Value> {
505        acquire_executor_rate_limit("unified_exec:code", 2.0)?;
506
507        let code = args
508            .get("command")
509            .or_else(|| args.get("code"))
510            .and_then(|v| v.as_str())
511            .ok_or_else(|| anyhow!("Missing code/command in execute_code"))?;
512
513        let cwd = self.workspace_root();
514        // Arbitrary interpreter code has the same file-writing authority as a
515        // shell script. It cannot be a route around guarded file mutations.
516        self.edited_file_monitor_ref().source_inspection().check_shell(&args, cwd)?;
517        let language = code_language_from_args(&args);
518
519        // The `code` action spawns the interpreter directly, so it must clear
520        // the same command policy as `run`: a policy that excludes
521        // `python3`/`node` must not be bypassed through code mode.
522        let interpreter = language.interpreter().to_string();
523        if !self.inventory.command_policy_allows(std::slice::from_ref(&interpreter)) {
524            return Err(anyhow!("code execution via '{interpreter}' is not permitted by the execution policy"));
525        }
526
527        let track_files = args.get("track_files").and_then(|v| v.as_bool()).unwrap_or(false);
528
529        let mcp_client = self.mcp_client().ok_or_else(|| anyhow!("MCP client not available"))?;
530
531        let workspace_root = self.workspace_root_owned();
532        // Expose built-in tools to the snippet as callable library functions
533        // (curated, non-recursive subset) when a weak self-reference was
534        // installed at session bootstrap.
535        let builtin_executor = self.builtin_executor_for_code();
536        let executor =
537            crate::exec::code_executor::CodeExecutor::new(language, mcp_client.clone(), workspace_root.clone())
538                .with_builtin_executor(builtin_executor);
539        let execution_start = SystemTime::now();
540
541        let result = executor.execute(code).await?;
542
543        let mut response = json!(result);
544
545        if track_files {
546            let tracker = FileTracker::new(workspace_root);
547            match tracker.detect_new_files(execution_start).await {
548                Ok(changes) => {
549                    response["generated_files"] = json!({
550                        "count": changes.len(),
551                        "files": changes,
552                        "summary": tracker.generate_file_summary(&changes),
553                    });
554                }
555                Err(e) => {
556                    tracing::warn!(
557                        error = %e,
558                        "FileTracker failed to detect new files after code execution"
559                    );
560                }
561            }
562        }
563
564        Ok(response)
565    }
566
567    async fn execute_apply_patch(&self, args: Value) -> Result<Value> {
568        let (patch_args, patch_input_bytes, patch_base64) = self.prepare_apply_patch_args(args)?;
569        let context = self.harness_context_snapshot();
570        tracing::debug!(
571            tool = tools::APPLY_PATCH,
572            payload_bytes = serialized_payload_size_bytes(&patch_args),
573            patch_input_bytes,
574            patch_base64,
575            patch_decoded_bytes = patch_args
576                .get("input")
577                .and_then(|v| v.as_str())
578                .map(|s| s.len())
579                .unwrap_or(0),
580            session_id = %context.session_id,
581            task_id = %context.task_id.as_deref().unwrap_or(""),
582            "Prepared apply_patch payload"
583        );
584
585        self.execute_apply_patch_internal(patch_args).await
586    }
587
588    fn prepare_apply_patch_args(&self, args: Value) -> Result<(Value, usize, bool)> {
589        let patch_input = crate::tools::apply_patch::decode_apply_patch_input(&args)?
590            .ok_or_else(|| anyhow!("Missing patch input {}", crate::tools::error_helpers::PATCH_PARAMETER_HINT))?;
591        let patch_input_bytes = patch_input.source_bytes;
592        let patch_base64 = patch_input.was_base64;
593
594        // Guard against a bare-string `args` (some callers pass the patch text
595        // directly as a JSON string rather than an object). Indexing a
596        // `Value::String` with `["input"]` panics in serde_json, so normalize
597        // to an object first.
598        let mut patch_args = if args.is_object() { args } else { json!({}) };
599        patch_args["input"] = json!(patch_input.text);
600        Ok((patch_args, patch_input_bytes, patch_base64))
601    }
602
603    async fn resolve_exec_sandbox_request(
604        &self,
605        payload: &serde_json::Map<String, Value>,
606    ) -> Result<ResolvedExecSandboxRequest> {
607        let working_dir_path = self.pty_manager().resolve_working_dir(shell_working_dir_value(payload)).await?;
608        let sandbox_config = self.sandbox_config();
609        let (sandbox_permissions, additional_permissions) =
610            parse_requested_sandbox_permissions(payload, self.workspace_root(), &working_dir_path, &sandbox_config)
611                .await?;
612
613        Ok(ResolvedExecSandboxRequest {
614            working_dir_path,
615            sandbox_permissions,
616            additional_permissions,
617        })
618    }
619
620    // ============================================================
621    // SPECIALIZED EXECUTORS (Hidden from LLM, used by unified tools)
622    // ============================================================
623
624    // File operation executors -- delegate to the file_ops_tool from inventory
625    delegate_to_tool!(read_file_executor, file_ops_tool, read_file);
626    delegate_to_tool!(write_file_executor, file_ops_tool, write_file);
627
628    // Self-delegating executors -- forward to async methods on ToolRegistry
629    delegate_to_self!(list_files_executor, list_files);
630    delegate_to_self!(edit_file_executor, edit_file);
631    delegate_to_self!(get_errors_executor, execute_get_errors);
632    delegate_to_self!(search_tools_executor, execute_search_tools);
633    delegate_to_self!(mcp_search_tools_executor, execute_mcp_search_tools);
634    delegate_to_self!(mcp_get_tool_details_executor, execute_mcp_get_tool_details);
635    delegate_to_self!(mcp_list_servers_executor, execute_mcp_list_servers);
636    delegate_to_self!(mcp_connect_server_executor, execute_mcp_connect_server);
637    delegate_to_self!(mcp_disconnect_server_executor, execute_mcp_disconnect_server);
638    delegate_to_self!(apply_patch_executor, execute_apply_patch);
639
640    /// Unified `mcp` executor: dispatches on `action`
641    /// (search_tools | get_tool_details | list_servers | connect | disconnect).
642    /// For legacy alias calls that omit `action`, the action is inferred from
643    /// the argument shape: `query` implies search_tools, `name` implies
644    /// get_tool_details, otherwise list_servers. `connect`/`disconnect` require
645    /// an explicit `action` since the schema marks it required, and are
646    /// evaluated under the action-qualified policy keys `mcp:connect` /
647    /// `mcp:disconnect` so they keep their Prompt confirmation even though
648    /// `mcp` itself is `ToolPolicy::Allow`.
649    #[cfg_attr(
650        not(test),
651        allow(
652            dead_code,
653            reason = "Legacy executor aliases remain available for compatibility tests."
654        )
655    )]
656    pub(super) fn mcp_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
657        Box::pin(async move {
658            let action = args
659                .get("action")
660                .and_then(Value::as_str)
661                .map(str::to_ascii_lowercase)
662                .unwrap_or_else(|| {
663                    if args.get("query").is_some() {
664                        "search_tools".to_string()
665                    } else if args.get("name").is_some() {
666                        "get_tool_details".to_string()
667                    } else {
668                        "list_servers".to_string()
669                    }
670                });
671            match action.as_str() {
672                "search_tools" => self.mcp_search_tools_executor(args).await,
673                "get_tool_details" => self.mcp_get_tool_details_executor(args).await,
674                "list_servers" => self.mcp_list_servers_executor(args).await,
675                "connect" => self.mcp_connect_server_executor(args).await,
676                "disconnect" => self.mcp_disconnect_server_executor(args).await,
677                other => Err(anyhow!(
678                    "mcp: unknown action '{other}'. Use action='search_tools' (query), 'get_tool_details' (name), 'list_servers', 'connect' (name), or 'disconnect' (name)."
679                )),
680            }
681        })
682    }
683
684    // PTY executors -- distinct signatures, kept explicit.
685    // `run_pty_cmd` and `create_pty_session` are intentional aliases: both
686    // create a PTY session and run a command. The separate names exist for
687    // backward compatibility with existing tool registrations.
688    pub(super) fn run_pty_cmd_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
689        self.dispatch_command_session_run_alias(args, true)
690    }
691
692    pub(super) fn exec_command_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
693        self.dispatch_command_session_run_alias(args, false)
694    }
695
696    pub(super) fn write_stdin_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
697        Box::pin(self.execute_write_stdin(args, ExecSettlementMode::Manual))
698    }
699
700    pub(super) async fn execute_write_stdin(&self, args: Value, settlement_mode: ExecSettlementMode) -> Result<Value> {
701        let (args, dispatch) = normalize_write_stdin_args(&args)?;
702
703        let observed_args = args.clone();
704        let result = async {
705            let response = match dispatch {
706                crate::tools::command_args::WriteStdinDispatch::Write => {
707                    self.execute_command_session_write_for_tool(args, tools::WRITE_STDIN).await
708                }
709                crate::tools::command_args::WriteStdinDispatch::Poll => {
710                    self.execute_command_session_poll_for_tool(args, settlement_mode, tools::WRITE_STDIN)
711                        .await
712                }
713                crate::tools::command_args::WriteStdinDispatch::Wait => self.execute_command_session_wait(args).await,
714                crate::tools::command_args::WriteStdinDispatch::Inspect => {
715                    self.execute_command_session_inspect(args).await
716                }
717                crate::tools::command_args::WriteStdinDispatch::Terminate => {
718                    self.execute_command_session_terminate(args).await
719                }
720                crate::tools::command_args::WriteStdinDispatch::Close => self.execute_command_session_close(args).await,
721            }?;
722            Ok(response)
723        }
724        .await;
725        self.observe_completion_verifier(None, &observed_args, &result);
726        result
727    }
728
729    pub(super) fn send_pty_input_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
730        self.dispatch_command_session_action_alias(args, "write")
731    }
732
733    pub(super) fn read_pty_session_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
734        self.dispatch_command_session_action_alias(args, "poll")
735    }
736
737    pub(super) fn create_pty_session_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
738        self.dispatch_command_session_run_alias(args, true)
739    }
740
741    pub(super) fn list_pty_sessions_executor(&self, _args: Value) -> BoxFuture<'_, Result<Value>> {
742        self.dispatch_command_session_alias(json!({"action": "list"}))
743    }
744
745    pub(super) fn close_pty_session_executor(&self, args: Value) -> BoxFuture<'_, Result<Value>> {
746        self.dispatch_command_session_action_alias(args, "close")
747    }
748
749    // ============================================================
750    // INTERNAL IMPLEMENTATIONS
751    // ============================================================
752}
753
754#[cfg(test)]
755mod execute_code_tests {
756    use serde_json::json;
757
758    use super::code_language_from_args;
759    use crate::exec::code_executor::Language;
760
761    #[test]
762    fn code_language_uses_language_field_instead_of_action() {
763        assert_eq!(
764            code_language_from_args(&json!({
765                "action": "code",
766                "language": "javascript",
767            })),
768            Language::JavaScript
769        );
770        assert_eq!(
771            code_language_from_args(&json!({
772                "action": "code",
773                "lang": "js",
774            })),
775            Language::JavaScript
776        );
777        assert_eq!(
778            code_language_from_args(&json!({
779                "action": "code",
780            })),
781            Language::Python3
782        );
783    }
784}
785
786#[cfg(test)]
787mod subagent_tool_output_tests {
788    use serde_json::json;
789    use tempfile::TempDir;
790
791    use super::sanitize_subagent_tool_output_paths;
792
793    #[test]
794    fn strips_transcript_paths_outside_workspace() {
795        let temp = TempDir::new().expect("tempdir");
796        let mut value = json!({
797            "completed": true,
798            "entry": {
799                "id": "agent-1",
800                "transcript_path": "/Users/example/.vtcode/sessions/agent-1.json",
801            }
802        });
803
804        sanitize_subagent_tool_output_paths(temp.path(), &mut value);
805
806        assert!(value["entry"].get("transcript_path").is_none());
807    }
808
809    #[test]
810    fn keeps_transcript_paths_inside_workspace() {
811        let temp = TempDir::new().expect("tempdir");
812        let transcript_path = temp.path().join(".vtcode/context/subagents/agent-1.json");
813        let mut value = json!({
814            "id": "agent-1",
815            "transcript_path": transcript_path,
816        });
817
818        sanitize_subagent_tool_output_paths(temp.path(), &mut value);
819
820        assert_eq!(value["transcript_path"].as_str(), transcript_path.to_str());
821    }
822}
823
824#[cfg(test)]
825mod shell_preference_tests {
826    use super::{resolve_shell_preference, resolve_shell_preference_with_zsh_fork};
827    use crate::config::PtyConfig;
828    use crate::tools::shell::resolve_fallback_shell;
829
830    #[test]
831    fn explicit_shell_overrides_config_preference() {
832        let config = PtyConfig {
833            preferred_shell: Some("/bin/bash".to_string()),
834            ..Default::default()
835        };
836
837        let resolved = resolve_shell_preference(Some(" /bin/zsh "), &config);
838        assert_eq!(resolved, "/bin/zsh");
839    }
840
841    #[test]
842    fn config_preferred_shell_used_when_explicit_missing() {
843        let config = PtyConfig {
844            preferred_shell: Some("zsh".to_string()),
845            ..Default::default()
846        };
847
848        let resolved = resolve_shell_preference(None, &config);
849        assert_eq!(resolved, "zsh");
850    }
851
852    #[test]
853    fn blank_explicit_shell_falls_back_to_config_preference() {
854        let config = PtyConfig {
855            preferred_shell: Some("bash".to_string()),
856            ..Default::default()
857        };
858
859        let resolved = resolve_shell_preference(Some("   "), &config);
860        assert_eq!(resolved, "bash");
861    }
862
863    #[test]
864    fn blank_config_shell_falls_back_to_default_resolver() {
865        let config = PtyConfig {
866            preferred_shell: Some("   ".to_string()),
867            ..Default::default()
868        };
869
870        let resolved = resolve_shell_preference(None, &config);
871        assert_eq!(resolved, resolve_fallback_shell());
872    }
873
874    #[test]
875    fn missing_preferences_fall_back_to_default_resolver() {
876        let config = PtyConfig::default();
877        let resolved = resolve_shell_preference(None, &config);
878        assert_eq!(resolved, resolve_fallback_shell());
879    }
880
881    #[test]
882    fn zsh_fork_disabled_uses_standard_shell_resolution() -> anyhow::Result<()> {
883        let config = PtyConfig {
884            preferred_shell: Some("/bin/bash".to_string()),
885            ..Default::default()
886        };
887        let resolved = resolve_shell_preference_with_zsh_fork(None, &config)?;
888        assert_eq!(resolved, "/bin/bash");
889        Ok(())
890    }
891
892    #[test]
893    fn zsh_fork_missing_path_returns_error() {
894        let config = PtyConfig {
895            shell_zsh_fork: true,
896            zsh_path: None,
897            ..PtyConfig::default()
898        };
899        resolve_shell_preference_with_zsh_fork(Some("/bin/bash"), &config).unwrap_err();
900    }
901
902    #[cfg(unix)]
903    #[test]
904    fn zsh_fork_ignores_explicit_shell_and_uses_configured_path() -> anyhow::Result<()> {
905        let zsh = tempfile::NamedTempFile::new()?;
906        let expected = zsh.path().to_string_lossy().to_string();
907        let config = PtyConfig {
908            shell_zsh_fork: true,
909            zsh_path: Some(expected.clone()),
910            ..PtyConfig::default()
911        };
912        let resolved = resolve_shell_preference_with_zsh_fork(Some("/bin/bash"), &config)?;
913        assert_eq!(resolved, expected);
914        Ok(())
915    }
916}
917
918#[cfg(test)]
919mod token_efficiency_tests {
920    use super::*;
921
922    #[test]
923    fn test_suggests_limit_for_cat() {
924        assert_eq!(suggest_max_tokens_for_command("cat file.txt"), Some(250));
925        assert_eq!(suggest_max_tokens_for_command("cat /path/to/file.rs"), Some(250));
926        assert_eq!(suggest_max_tokens_for_command("CAT file.txt"), Some(250)); // case insensitive
927    }
928
929    #[test]
930    fn test_suggests_limit_for_bat() {
931        assert_eq!(suggest_max_tokens_for_command("bat file.rs"), Some(250));
932    }
933
934    #[test]
935    fn test_no_limit_when_already_limited() {
936        assert_eq!(suggest_max_tokens_for_command("cat file.txt | head"), None);
937        assert_eq!(suggest_max_tokens_for_command("head -n 50 file.txt"), None);
938        assert_eq!(suggest_max_tokens_for_command("tail -n 20 file.txt"), None);
939    }
940
941    #[test]
942    fn test_no_limit_for_other_commands() {
943        assert_eq!(suggest_max_tokens_for_command("ls -la"), None);
944        assert_eq!(suggest_max_tokens_for_command("grep pattern file"), None);
945        assert_eq!(suggest_max_tokens_for_command("echo hello"), None);
946    }
947}
948
949#[cfg(test)]
950mod pty_output_filter_tests {
951    use super::filter_pty_output;
952
953    #[test]
954    fn normalizes_crlf_sequences() {
955        let raw = "a\r\nb\rc\n";
956        assert_eq!(filter_pty_output(raw), "a\nb\nc\n");
957    }
958}
959
960#[cfg(test)]
961mod pty_context_tests {
962    use serde_json::json;
963
964    use super::{
965        ExecOutputPreview, PtyEphemeralCapture, attach_exec_response_context, attach_pty_continuation,
966        build_exec_response, build_exec_session_command_display,
967    };
968    use crate::tools::types::VTCodeExecSession;
969
970    #[test]
971    fn build_exec_session_command_display_unwraps_shell_c_argument() {
972        let session = VTCodeExecSession {
973            id: "run-123".to_string().into(),
974            backend: "pty".to_string(),
975            command: "zsh".to_string(),
976            args: vec!["-l".to_string(), "-c".to_string(), "cargo check".to_string()],
977            working_dir: Some(".".to_string()),
978            background: false,
979            rows: Some(24),
980            cols: Some(80),
981            child_pid: None,
982            started_at: None,
983            lifecycle_state: None,
984            exit_code: None,
985        };
986
987        assert_eq!(build_exec_session_command_display(&session), "cargo check");
988    }
989
990    #[test]
991    fn attach_exec_response_context_sets_expected_keys() {
992        let mut response = json!({ "output": "ok" });
993        let session = VTCodeExecSession {
994            id: "run-123".to_string().into(),
995            backend: "pty".to_string(),
996            command: "zsh".to_string(),
997            args: vec!["-l".to_string(), "-c".to_string(), "cargo check".to_string()],
998            working_dir: Some(".".to_string()),
999            background: false,
1000            rows: Some(30),
1001            cols: Some(120),
1002            child_pid: None,
1003            started_at: None,
1004            lifecycle_state: None,
1005            exit_code: None,
1006        };
1007
1008        attach_exec_response_context(&mut response, &session, "cargo check", false);
1009
1010        assert_eq!(response["session_id"], "run-123");
1011        assert_eq!(response["command"], "cargo check");
1012        assert_eq!(response["working_directory"], ".");
1013        assert_eq!(response["backend"], "pty");
1014        assert_eq!(response["rows"], 30);
1015        assert_eq!(response["cols"], 120);
1016        assert_eq!(response["is_exited"], false);
1017    }
1018
1019    #[test]
1020    fn attach_pty_continuation_compacts_next_continue_args() {
1021        let mut response = json!({ "output": "ok" });
1022        attach_pty_continuation(&mut response, "run-123");
1023
1024        assert!(response.get("follow_up_prompt").is_none());
1025        assert!(response.get("next_poll_args").is_none());
1026        assert_eq!(response["next_continue_args"], json!({ "session_id": "run-123" }));
1027        assert!(response.get("preferred_next_action").is_none());
1028    }
1029
1030    #[test]
1031    fn attach_pty_continuation_keeps_payload_compact() {
1032        let mut response = json!({ "output": "ok" });
1033        attach_pty_continuation(&mut response, "run-123");
1034
1035        assert!(response.get("follow_up_prompt").is_none());
1036        assert!(response.get("next_poll_args").is_none());
1037        assert_eq!(response["next_continue_args"], json!({ "session_id": "run-123" }));
1038    }
1039
1040    #[test]
1041    fn build_exec_response_skips_continuation_after_exit() {
1042        let session = VTCodeExecSession {
1043            id: "run-123".to_string().into(),
1044            backend: "pipe".to_string(),
1045            command: "cargo".to_string(),
1046            args: vec!["check".to_string()],
1047            working_dir: Some(".".to_string()),
1048            background: false,
1049            rows: None,
1050            cols: None,
1051            child_pid: None,
1052            started_at: None,
1053            lifecycle_state: None,
1054            exit_code: None,
1055        };
1056        let capture = PtyEphemeralCapture {
1057            output: "first\nsecond\n".to_string(),
1058            exit_code: Some(0),
1059            duration: std::time::Duration::from_millis(25),
1060        };
1061
1062        let response = build_exec_response(
1063            &session,
1064            "cargo check",
1065            &capture,
1066            ExecOutputPreview {
1067                raw_output: "first\nsecond\n".to_string(),
1068                output: "first\n[Output truncated]".to_string(),
1069                truncated: true,
1070            },
1071            None,
1072            false,
1073            None,
1074        );
1075
1076        assert_eq!(response["exit_code"], 0);
1077        assert!(response.get("next_continue_args").is_none());
1078    }
1079
1080    #[test]
1081    fn build_exec_response_steers_still_running_to_wait_action() {
1082        let session = VTCodeExecSession {
1083            id: "run-abc".to_string().into(),
1084            backend: "pipe".to_string(),
1085            command: "cargo".to_string(),
1086            args: vec!["build".to_string()],
1087            working_dir: Some(".".to_string()),
1088            background: false,
1089            rows: None,
1090            cols: None,
1091            child_pid: None,
1092            started_at: None,
1093            lifecycle_state: None,
1094            exit_code: None,
1095        };
1096        let capture = PtyEphemeralCapture {
1097            output: "   Compiling vtcode-core\n".to_string(),
1098            exit_code: None,
1099            duration: std::time::Duration::from_secs(10),
1100        };
1101
1102        let response = build_exec_response(
1103            &session,
1104            "cargo build",
1105            &capture,
1106            ExecOutputPreview {
1107                raw_output: "   Compiling vtcode-core\n".to_string(),
1108                output: "   Compiling vtcode-core\n".to_string(),
1109                truncated: false,
1110            },
1111            None,
1112            false,
1113            Some("run-abc"),
1114        );
1115
1116        // The poll-oriented continuation is still attached for incremental peeks.
1117        assert_eq!(response["next_continue_args"], json!({ "session_id": "run-abc" }));
1118        // The no-burn wait action is pre-filled and ready to reuse.
1119        assert_eq!(response["next_wait_args"]["session_id"], "run-abc");
1120        assert_eq!(response["next_wait_args"]["action"], "wait");
1121        assert_eq!(response["next_wait_args"]["wait_timeout_seconds"], 600);
1122        // The hint ranks wait ahead of polling and names the tool to call.
1123        let hint = response["next_action_hint"].as_str().expect("hint present");
1124        assert!(hint.contains("write_stdin"));
1125        assert!(hint.contains("next_wait_args"));
1126        assert!(hint.contains("no model round-trips"));
1127        assert!(hint.contains("exempt from the per-turn tool-call budget"));
1128        assert!(hint.contains("next_continue_args"));
1129        assert_eq!(response["is_exited"], false);
1130        assert_eq!(response["process_id"], "run-abc");
1131    }
1132}
1133
1134#[cfg(test)]
1135mod git_diff_tests {
1136    use super::is_git_diff_command;
1137
1138    #[test]
1139    fn detects_git_diff() {
1140        let cmd = vec!["git".to_string(), "diff".to_string()];
1141        assert!(is_git_diff_command(&cmd));
1142    }
1143
1144    #[test]
1145    fn detects_git_diff_with_flags() {
1146        let cmd = vec![
1147            "git".to_string(),
1148            "-c".to_string(),
1149            "color.ui=always".to_string(),
1150            "diff".to_string(),
1151            "--stat".to_string(),
1152        ];
1153        assert!(is_git_diff_command(&cmd));
1154    }
1155
1156    #[test]
1157    fn detects_git_diff_with_path() {
1158        let cmd = vec!["/usr/bin/git".to_string(), "diff".to_string()];
1159        assert!(is_git_diff_command(&cmd));
1160    }
1161
1162    #[test]
1163    fn ignores_other_git_commands() {
1164        let cmd = vec!["git".to_string(), "status".to_string()];
1165        assert!(!is_git_diff_command(&cmd));
1166    }
1167}
1168
1169#[cfg(test)]
1170mod unified_action_error_tests {
1171    use std::time::Duration;
1172
1173    use serde_json::json;
1174
1175    use super::{
1176        CargoTestCommandKind, ExecOutputPreview, PtyEphemeralCapture, attach_exec_recovery_guidance,
1177        attach_failure_diagnostics_metadata, build_exec_output_preview, build_exec_response, build_head_tail_preview,
1178        cargo_selector_error_diagnostics, cargo_test_failure_diagnostics, cargo_test_rerun_hint, clamp_inspect_lines,
1179        clamp_max_matches, extract_run_session_id_from_read_file_error, extract_run_session_id_from_tool_output_path,
1180        filter_lines, missing_command_session_action_error, resolve_exec_run_session_id, summarized_arg_keys,
1181    };
1182    use crate::tools::types::VTCodeExecSession;
1183
1184    #[test]
1185    fn summarized_arg_keys_reports_shape_for_non_object_payloads() {
1186        assert_eq!(summarized_arg_keys(&json!(null)), "<null>");
1187        assert_eq!(summarized_arg_keys(&json!(["a", "b"])), "<array>");
1188        assert_eq!(summarized_arg_keys(&json!("x")), "<string>");
1189    }
1190
1191    #[test]
1192    fn exec_command_missing_action_error_includes_received_keys() {
1193        let err = missing_command_session_action_error(&json!({
1194            "foo": "bar",
1195            "session_id": "123"
1196        }));
1197        let text = err.to_string();
1198        assert!(text.contains("Missing command session action"));
1199        assert!(text.contains("foo"));
1200        assert!(text.contains("session_id"));
1201    }
1202
1203    #[test]
1204    fn extracts_run_session_id_from_tool_output_path() {
1205        assert_eq!(
1206            extract_run_session_id_from_tool_output_path(".vtcode/context/tool_outputs/run-abc123.txt"),
1207            Some("run-abc123".to_string())
1208        );
1209        assert_eq!(
1210            extract_run_session_id_from_tool_output_path(".vtcode/context/tool_outputs/not-a-session.txt"),
1211            None
1212        );
1213    }
1214
1215    #[test]
1216    fn extracts_run_session_id_from_read_file_error() {
1217        let error = "Use exec_command with session_id=\"run-zz9\" instead of read_file.";
1218        assert_eq!(extract_run_session_id_from_read_file_error(error), Some("run-zz9".to_string()));
1219        assert_eq!(extract_run_session_id_from_read_file_error("no session"), None);
1220    }
1221
1222    #[test]
1223    fn resolve_exec_run_session_id_prefers_requested_session_id() {
1224        let payload = json!({ "session_id": " check_sh " });
1225        let payload = payload.as_object().expect("object");
1226
1227        assert_eq!(resolve_exec_run_session_id(payload).expect("requested session id"), "check_sh");
1228    }
1229
1230    #[test]
1231    fn resolve_exec_run_session_id_generates_default_when_missing() {
1232        let payload = json!({});
1233        let payload = payload.as_object().expect("object");
1234        let session_id = resolve_exec_run_session_id(payload).expect("generated session id");
1235
1236        assert!(session_id.starts_with("run-"));
1237    }
1238
1239    #[test]
1240    fn resolve_exec_run_session_id_rejects_invalid_values() {
1241        let payload = json!({ "session_id": "bad id" });
1242        let payload = payload.as_object().expect("object");
1243        let err = resolve_exec_run_session_id(payload).expect_err("invalid session id");
1244
1245        assert!(err.to_string().contains("Invalid session_id"));
1246    }
1247
1248    #[test]
1249    fn inspect_helpers_clamp_limits() {
1250        assert_eq!(clamp_inspect_lines(Some(0), 30), 0);
1251        assert_eq!(clamp_inspect_lines(Some(9_999), 30), 5_000);
1252        assert_eq!(clamp_max_matches(None), 200);
1253        assert_eq!(clamp_max_matches(Some(0)), 1);
1254        assert_eq!(clamp_max_matches(Some(50_000)), 10_000);
1255    }
1256
1257    #[test]
1258    fn inspect_helpers_build_head_tail_preview() {
1259        let content = "l1\nl2\nl3\nl4\nl5\nl6";
1260        let (preview, truncated) = build_head_tail_preview(content, 2, 2);
1261        assert!(truncated);
1262        assert!(preview.contains("l1"));
1263        assert!(preview.contains("l2"));
1264        assert!(preview.contains("l5"));
1265        assert!(preview.contains("l6"));
1266    }
1267
1268    #[test]
1269    fn inspect_helpers_filter_lines_literal() {
1270        let (output, matched, truncated) = filter_lines("alpha\nbeta\nalpha2", "alpha", true, 1).expect("filter");
1271        assert_eq!(matched, 2);
1272        assert!(truncated);
1273        assert!(output.contains("1: alpha"));
1274    }
1275
1276    #[test]
1277    fn exec_output_preview_truncates_on_utf8_boundaries() {
1278        let (preview, truncated) = build_exec_output_preview("a🙂b", 1);
1279
1280        assert!(truncated);
1281        // The byte window is head+tail, so the last output content survives
1282        // the cut (the part that carries build/test summaries).
1283        assert!(preview.starts_with('a'));
1284        assert!(preview.ends_with('b'));
1285        assert!(preview.contains("bytes omitted"));
1286        std::str::from_utf8(preview.as_bytes()).unwrap();
1287    }
1288
1289    #[test]
1290    fn exec_output_preview_keeps_tail_of_long_output_within_budget() {
1291        let lines: String = (0..1000).map(|idx| format!("line {idx}\n")).collect();
1292        let (preview, truncated) = build_exec_output_preview(&lines, 1000);
1293
1294        assert!(truncated);
1295        // Head shows the first line, tail shows the last — a head-only cut
1296        // lost the tail where build/test summaries live.
1297        assert!(preview.starts_with("line 0\n"));
1298        assert!(preview.trim_end().ends_with("line 999"));
1299        assert!(preview.contains("bytes omitted"));
1300        // The inline budget stays essentially intact (marker bytes aside).
1301        assert!(preview.len() <= 1000 * 4 + 64, "preview {} must stay near the 4000-byte budget", preview.len());
1302    }
1303
1304    #[test]
1305    fn exec_recovery_guidance_sets_command_not_found_metadata() {
1306        let session = VTCodeExecSession {
1307            id: "run-123".to_string().into(),
1308            backend: "pipe".to_string(),
1309            command: "zsh".to_string(),
1310            args: vec!["-c".to_string(), "pip install pymupdf".to_string()],
1311            working_dir: Some(".".to_string()),
1312            background: false,
1313            rows: None,
1314            cols: None,
1315            child_pid: None,
1316            started_at: None,
1317            lifecycle_state: None,
1318            exit_code: None,
1319        };
1320        let capture = PtyEphemeralCapture {
1321            output: String::new(),
1322            exit_code: Some(127),
1323            duration: Duration::from_millis(42),
1324        };
1325
1326        let response = build_exec_response(
1327            &session,
1328            "pip install pymupdf",
1329            &capture,
1330            ExecOutputPreview {
1331                raw_output: "bash: pip: command not found".to_string(),
1332                output: "bash: pip: command not found".to_string(),
1333                truncated: false,
1334            },
1335            None,
1336            false,
1337            None,
1338        );
1339
1340        assert_eq!(response["output"], "bash: pip: command not found");
1341        assert_eq!(response["exit_code"], 127);
1342        assert_eq!(response["session_id"], "run-123");
1343        assert_eq!(response["command"], "pip install pymupdf");
1344        assert_eq!(response["critical_note"], "Command `pip` was not found in PATH.");
1345        assert_eq!(
1346            response["next_action"],
1347            "Check the command name or install the missing binary, then rerun the command."
1348        );
1349    }
1350
1351    #[test]
1352    fn exec_recovery_guidance_ignores_non_command_not_found_exit_codes() {
1353        let mut response = json!({});
1354        attach_exec_recovery_guidance(&mut response, "cargo test", Some(1));
1355        assert!(response.get("critical_note").is_none());
1356        assert!(response.get("next_action").is_none());
1357    }
1358
1359    #[test]
1360    fn exec_recovery_guidance_redirects_apply_patch_shell_collision_to_tool() {
1361        let mut response = json!({});
1362        attach_exec_recovery_guidance(&mut response, "apply_patch", Some(127));
1363        assert_eq!(
1364            response["critical_note"],
1365            "Command `apply_patch` is not a shell binary — use the `apply_patch` tool instead."
1366        );
1367        assert!(
1368            response["next_action"]
1369                .as_str()
1370                .expect("next_action present")
1371                .contains("Call the `apply_patch` tool")
1372        );
1373
1374        let mut aliased = json!({});
1375        attach_exec_recovery_guidance(&mut aliased, "applypatch", Some(127));
1376        assert_eq!(aliased["critical_note"], response["critical_note"]);
1377
1378        let mut with_args = json!({});
1379        attach_exec_recovery_guidance(&mut with_args, "/usr/bin/apply_patch --help", Some(127));
1380        assert_eq!(with_args["critical_note"], response["critical_note"]);
1381
1382        let mut sudo_prefixed = json!({});
1383        attach_exec_recovery_guidance(&mut sudo_prefixed, "sudo apply_patch", Some(127));
1384        assert_eq!(sudo_prefixed["critical_note"], "Command `sudo` was not found in PATH.");
1385    }
1386
1387    #[test]
1388    fn cargo_selector_error_diagnostics_classifies_missing_test_target() {
1389        let output = "error: no test target named `exec_only_policy_skips_when_full_auto_is_disabled` in `vtcode-core` package\n";
1390
1391        let diagnostics = cargo_selector_error_diagnostics(
1392            CargoTestCommandKind::Nextest,
1393            "cargo nextest run --test exec_only_policy_skips_when_full_auto_is_disabled -p vtcode-core --no-capture",
1394            output,
1395        )
1396        .expect("selector diagnostics");
1397
1398        assert_eq!(diagnostics["kind"], "cargo_test_selector_error");
1399        assert_eq!(diagnostics["package"], "vtcode-core");
1400        assert_eq!(diagnostics["requested_test_target"], "exec_only_policy_skips_when_full_auto_is_disabled");
1401        assert_eq!(diagnostics["selector_error"], true);
1402        assert_eq!(
1403            diagnostics["validation_hint"],
1404            "cargo test -p vtcode-core --lib -- --list | rg 'exec_only_policy_skips_when_full_auto_is_disabled'"
1405        );
1406        assert_eq!(
1407            diagnostics["rerun_hint"],
1408            "cargo nextest run -p vtcode-core exec_only_policy_skips_when_full_auto_is_disabled"
1409        );
1410    }
1411
1412    #[test]
1413    fn cargo_test_failure_diagnostics_extracts_unit_test_failure_details() {
1414        let output = r#"────────────
1415    Nextest run ID 18fffe01-0ef9-4113-9a81-2344a7cc3c16 with nextest profile: default
1416        FAIL [   0.216s] ( 363/2669) vtcode-core core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled
1417    stderr ───
1418    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:
1419    task result: Invalid request: QueuedProvider has no queued responses
1420"#;
1421
1422        let diagnostics = cargo_test_failure_diagnostics("cargo nextest run -p vtcode-core", output, Some(100))
1423            .expect("failure diagnostics");
1424
1425        assert_eq!(diagnostics["kind"], "cargo_test_failure");
1426        assert_eq!(diagnostics["package"], "vtcode-core");
1427        assert_eq!(diagnostics["binary_kind"], "unit");
1428        assert_eq!(
1429            diagnostics["test_fqname"],
1430            "core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled"
1431        );
1432        assert_eq!(diagnostics["panic"], "task result: Invalid request: QueuedProvider has no queued responses");
1433        assert_eq!(diagnostics["source_file"], "crates/codegen/vtcode-core/src/core/agent/runner/tests.rs");
1434        assert_eq!(diagnostics["source_line"], 692);
1435        assert_eq!(
1436            diagnostics["rerun_hint"],
1437            cargo_test_rerun_hint(
1438                CargoTestCommandKind::Nextest,
1439                "vtcode-core",
1440                "unit",
1441                "core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled",
1442            )
1443        );
1444    }
1445
1446    #[test]
1447    fn build_exec_response_attaches_cargo_failure_diagnostics() {
1448        let session = VTCodeExecSession {
1449            id: "run-123".to_string().into(),
1450            backend: "pipe".to_string(),
1451            command: "cargo".to_string(),
1452            args: vec![
1453                "nextest".to_string(),
1454                "run".to_string(),
1455                "-p".to_string(),
1456                "vtcode-core".to_string(),
1457            ],
1458            working_dir: Some(".".to_string()),
1459            background: false,
1460            rows: None,
1461            cols: None,
1462            child_pid: None,
1463            started_at: None,
1464            lifecycle_state: None,
1465            exit_code: None,
1466        };
1467        let raw_output = r#"
1468        FAIL [   0.216s] ( 363/2669) vtcode-core core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled
1469    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:
1470    task result: Invalid request: QueuedProvider has no queued responses
1471"#;
1472        let capture = PtyEphemeralCapture {
1473            output: raw_output.to_string(),
1474            exit_code: Some(100),
1475            duration: Duration::from_millis(42),
1476        };
1477
1478        let response = build_exec_response(
1479            &session,
1480            "cargo nextest run -p vtcode-core",
1481            &capture,
1482            ExecOutputPreview {
1483                raw_output: raw_output.to_string(),
1484                output: raw_output.to_string(),
1485                truncated: false,
1486            },
1487            None,
1488            false,
1489            None,
1490        );
1491
1492        assert_eq!(
1493            response["failure_diagnostics"]["test_fqname"],
1494            "core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled"
1495        );
1496        assert_eq!(response["package"], "vtcode-core");
1497        assert_eq!(response["binary_kind"], "unit");
1498        assert_eq!(response["source_file"], "crates/codegen/vtcode-core/src/core/agent/runner/tests.rs");
1499        assert_eq!(response["source_line"], 692);
1500        assert_eq!(
1501            response["rerun_hint"],
1502            "cargo nextest run -p vtcode-core core::agent::runner::tests::exec_only_policy_skips_when_full_auto_is_disabled"
1503        );
1504        assert_eq!(
1505            response["next_action"],
1506            "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"
1507        );
1508    }
1509
1510    #[test]
1511    fn attach_failure_diagnostics_metadata_promotes_selector_hints() {
1512        let mut response = json!({
1513            "success": true,
1514            "command": "cargo nextest run --test bad -p vtcode-core"
1515        });
1516        let diagnostics = json!({
1517            "kind": "cargo_test_selector_error",
1518            "package": "vtcode-core",
1519            "binary_kind": "test_target_selector",
1520            "requested_test_target": "bad",
1521            "selector_error": true,
1522            "validation_hint": "cargo test -p vtcode-core --lib -- --list | rg 'bad'",
1523            "rerun_hint": "cargo nextest run -p vtcode-core bad",
1524            "critical_note": "selector mismatch",
1525            "next_action": "validate first"
1526        });
1527
1528        attach_failure_diagnostics_metadata(&mut response, &diagnostics);
1529
1530        assert_eq!(response["package"], "vtcode-core");
1531        assert_eq!(response["binary_kind"], "test_target_selector");
1532        assert_eq!(response["selector_error"], true);
1533        assert_eq!(response["validation_hint"], "cargo test -p vtcode-core --lib -- --list | rg 'bad'");
1534        assert_eq!(response["rerun_hint"], "cargo nextest run -p vtcode-core bad");
1535        assert_eq!(response["critical_note"], "selector mismatch");
1536        assert_eq!(response["next_action"], "validate first");
1537        assert_eq!(response["failure_diagnostics"]["kind"], "cargo_test_selector_error");
1538    }
1539}
1540
1541#[cfg(test)]
1542#[path = "executors/sandbox_runtime_tests.rs"]
1543mod sandbox_runtime_tests;
1544
1545#[cfg(test)]
1546mod mcp_action_dispatch_tests {
1547    use serde_json::json;
1548
1549    use super::ToolRegistry;
1550
1551    /// `mcp_executor` must dispatch `action='connect'`/`'disconnect'` to
1552    /// `mcp_connect_server_executor`/`mcp_disconnect_server_executor` rather
1553    /// than falling through to the unknown-action branch. A bare
1554    /// `ToolRegistry::new` has no MCP client configured, so both calls fail
1555    /// at the `mcp_client()` lookup inside the delegated executor -- but the
1556    /// error text proves the dispatch reached the right function.
1557    #[tokio::test]
1558    async fn mcp_executor_dispatches_connect_and_disconnect_actions() {
1559        let temp = tempfile::tempdir().expect("tempdir");
1560        let registry = ToolRegistry::new(temp.path().to_path_buf()).await;
1561
1562        let connect_err = registry
1563            .mcp_executor(json!({"action": "connect", "name": "example"}))
1564            .await
1565            .expect_err("connect without an active mcp client should fail");
1566        let connect_text = connect_err.to_string();
1567        assert!(!connect_text.contains("unknown action"));
1568        assert!(connect_text.contains("MCP client not available"));
1569
1570        let disconnect_err = registry
1571            .mcp_executor(json!({"action": "disconnect", "name": "example"}))
1572            .await
1573            .expect_err("disconnect without an active mcp client should fail");
1574        let disconnect_text = disconnect_err.to_string();
1575        assert!(!disconnect_text.contains("unknown action"));
1576        assert!(disconnect_text.contains("MCP client not available"));
1577    }
1578
1579    #[tokio::test]
1580    async fn mcp_executor_rejects_unknown_action_with_guidance() {
1581        let temp = tempfile::tempdir().expect("tempdir");
1582        let registry = ToolRegistry::new(temp.path().to_path_buf()).await;
1583
1584        let err = registry
1585            .mcp_executor(json!({"action": "bogus"}))
1586            .await
1587            .expect_err("unknown mcp action should error");
1588        let text = err.to_string();
1589        assert!(text.contains("unknown action 'bogus'"));
1590        assert!(text.contains("connect"));
1591        assert!(text.contains("disconnect"));
1592    }
1593}