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
126macro_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
136macro_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 #[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 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 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 self.edited_file_monitor_ref().source_inspection().check_shell(&args, cwd)?;
517 let language = code_language_from_args(&args);
518
519 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 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 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 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 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 #[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 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 }
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)); }
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 assert_eq!(response["next_continue_args"], json!({ "session_id": "run-abc" }));
1118 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 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 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 assert!(preview.starts_with("line 0\n"));
1298 assert!(preview.trim_end().ends_with("line 999"));
1299 assert!(preview.contains("bytes omitted"));
1300 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 #[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}