Files
agent-manager/tests/ai_test.rs
T
bruno 313645eea2
release / release (push) Successful in 13m12s
release / macos (push) Canceled after 0s
feat: am ai — langage naturel vers action shell securisee via AIChat (issues #96 #97)
- Commande am ai (alias CLI: am shell) : conversationnel par defaut
  (aichat -f <ctx> "<prompt>"), mode --exec avec generation aichat --dry-run
- Securite (issue #97) : classification safe/risky + certainite affichee,
  politique dry-run par defaut (settings.shell_ai.default_safety:
  dry-run | confirm | auto), confirmation y/N pour les commandes risky,
  --yes pour executer, --dry-run force la simulation
- Flags : --exec/-e, --files/-f (defaut: dossier courant), --provider,
  --model (registre providers -> env aichat + modele, token via keyring)
- Execution via le shell utilisateur (default_shell > SHELL > COMSPEC)
- Journalisation : EventKind::ShellAi (mode, risk, certainty, executed)
- Module src/shell_ai.rs (classification, politique, generation, execution)
- Câblage complet : cli, dispatch, help, REPL (parseur+completer+banner),
  tip, man pages (am-ai.1), i18n EN, config.yaml (bloc shell_ai)
- Tests : 7 unitaires (classification, certainty, extraction, decision)
  + 4 integration (shim aichat.cmd : dry-run par defaut, --yes, mode
  conversationnel, mode confirm) — 339 tests verts
- v1.0.5 ; ROADMAP axe 13.3 coche (6 lignes), epic #93 clôturee

closes #93
closes #96
closes #97
2026-08-20 12:07:38 -04:00

157 lines
5.5 KiB
Rust

//! 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"),
"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"),
"declined confirmation must abort, log: {log}"
);
assert!(!log.contains("exécution:"), "log: {log}");
assert!(events.contains("executed=false"), "events: {events}");
}