//! Integration tests for `am ai` (issues #96 #97): the command resolves //! the `aichat` catalog agent, so a fake `aichat.cmd` shim is placed on //! PATH and emits a canned command. The safety pipeline (dry-run default, //! classification, confirmation, --yes) is exercised end to end through //! the real command dispatch. mod common; use agent_manager::cli::Cli; use clap::Parser; use std::path::PathBuf; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Mutex; /// Serialize PATH mutation and process spawning between parallel tests. static ENV_LOCK: Mutex<()> = Mutex::new(()); static COUNTER: AtomicU32 = AtomicU32::new(0); const AICHAT_DEF: &str = r#" agents: - name: aichat display_name: AIChat description: fake shim for tests category: shell-ai install: type: binary repo: sigoden/aichat binary: aichat run: aichat installable: false "#; fn fresh_dir(tag: &str) -> PathBuf { let id = COUNTER.fetch_add(1, Ordering::SeqCst); let dir = std::env::temp_dir().join(format!("am-ai-{tag}-{}-{id}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); dir } /// Write a fake `aichat.cmd` into `dir` and prepend `dir` to PATH. fn fake_aichat(dir: &PathBuf, body: &str) { std::fs::write(dir.join("aichat.cmd"), body).unwrap(); let mut paths = vec![dir.clone()]; if let Some(existing) = std::env::var_os("PATH") { paths.extend(std::env::split_paths(&existing)); } std::env::set_var("PATH", std::env::join_paths(paths).unwrap()); } /// Build the App for an `am ai` invocation with the aichat shim on PATH. fn app_for(shim_body: &str, settings: &str, args: &[&str]) -> (agent_manager::app::App, PathBuf) { let dir = fresh_dir("app"); fake_aichat(&dir, shim_body); let cfg = common::write_config(&dir, &format!("{settings}{AICHAT_DEF}")); let mut full = vec!["am".to_string(), "--config".to_string(), cfg.display().to_string()]; full.extend(args.iter().map(|s| s.to_string())); let cli = Cli::parse_from(full); let mut app = agent_manager::app::App::from_cli(cli).expect("app should build"); common::isolate(&mut app, &dir); (app, dir) } fn run(args: &[&str], shim_body: &str, settings: &str) -> (String, String, i32) { let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let (app, dir) = app_for(shim_body, settings, args); let cmd = app.cli.command.as_ref().expect("a command was parsed"); let code = agent_manager::commands::execute_command(&app, cmd).unwrap_or_else(|e| { eprintln!("ERR: {e:#}"); 1 }); let log = std::fs::read_to_string(app.paths.log_dir.join("agent-manager.log")) .unwrap_or_default(); // Events journal lives next to state.json (isolated dir). let mut events = String::new(); if let Ok(rd) = std::fs::read_dir(&dir) { for e in rd.flatten() { let name = e.file_name().to_string_lossy().to_string(); if name.starts_with("events-") && name.ends_with(".jsonl") { events.push_str(&std::fs::read_to_string(e.path()).unwrap_or_default()); } } } (log, events, code) } #[test] fn ai_exec_dry_run_is_the_default_and_never_executes() { let (log, events, code) = run( &["ai", "--exec", "supprime tout"], "@echo off\r\necho rm -rf /tmp/nonexistent-xyz\r\n", "", ); assert_eq!(code, 0); assert!( log.contains("non exécutée") || log.contains("not executed"), "dry-run policy must abort, log: {log}" ); assert!(!log.contains("exécution:"), "nothing must run, log: {log}"); assert!( events.contains("shell_ai") && events.contains("executed=false"), "journal must record the aborted exec, events: {events}" ); } #[test] fn ai_exec_with_yes_executes_through_the_shell() { // Shim emits a harmless command; --yes skips the policy gate. let (log, events, code) = run( &["ai", "--exec", "dis bonjour", "--yes"], "@echo off\r\necho echo hello-from-shim\r\n", "", ); assert_eq!(code, 0); assert!( log.contains("exécution: echo hello-from-shim"), "the generated command must run, log: {log}" ); assert!( events.contains("executed=true"), "journal must record the execution, events: {events}" ); } #[test] fn ai_conversational_passes_prompt_and_context_to_aichat() { // Shim echoes its arguments; conversational mode passes -f . + prompt. let (log, events, code) = run( &["ai", "liste les fichiers"], "@echo off\r\necho %*\r\n", "", ); assert_eq!(code, 0); assert!( log.contains("shell-ai:") || events.contains("mode=chat"), "conversational mode must be journalized, log: {log} events: {events}" ); assert!(events.contains("mode=chat"), "events: {events}"); } #[test] fn ai_exec_respects_configured_confirm_mode() { // default_safety: confirm + risky command => the confirmation prompt is // reached; without stdin input the prompt is declined (empty answer). let (log, events, code) = run( &["ai", "--exec", "supprime"], "@echo off\r\necho rm -rf /tmp/nonexistent-xyz\r\n", " shell_ai:\n default_safety: confirm\n", ); assert_eq!(code, 0); assert!( log.contains("annulé") || log.contains("cancelled") || log.contains("non exécutée") || log.contains("not executed"), "declined confirmation must abort, log: {log}" ); assert!(!log.contains("exécution:"), "log: {log}"); assert!(events.contains("executed=false"), "events: {events}"); }