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