//! L5 tests for [`CliAgentRuntime`]. //! //! Covers: //! - `prepare_invocation` (the **pure** core): for every [`ContextInjection`] //! strategy, the produced [`SpawnSpec`] (command, args order, resolved cwd, //! `context_plan`) is asserted. //! - `detection_spec` (pure): custom `detect` tokenisation vs `--version` //! fallback. //! - `detect` driven by a **mocked** [`ProcessSpawner`]: exit 0 ⇒ `true`, //! non-zero ⇒ `false`, spawner error ⇒ propagated as `RuntimeError`. use std::sync::Arc; use async_trait::async_trait; use domain::ids::ProfileId; use domain::ports::{ AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError, ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec, }; use domain::profile::{AgentProfile, ContextInjection, SessionStrategy}; use domain::project::ProjectPath; use domain::MarkdownDoc; use infrastructure::CliAgentRuntime; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn profile(injection: ContextInjection, cwd_template: &str) -> AgentProfile { AgentProfile::new( ProfileId::from_uuid(uuid::Uuid::from_u128(1)), "Test", "mycli", vec!["--static".to_owned(), "arg".to_owned()], injection, Some("mycli probe --json".to_owned()), cwd_template, None, ) .unwrap() } fn ctx() -> PreparedContext { PreparedContext { content: MarkdownDoc::new("# hi"), relative_path: ".ideai/agent.md".to_owned(), project_root: "/repo".to_owned(), } } /// A [`ProcessSpawner`] that returns a fixed outcome regardless of the spec. struct FixedSpawner(Result); #[async_trait] impl ProcessSpawner for FixedSpawner { async fn run(&self, _spec: SpawnSpec) -> Result { self.0.clone() } } fn runtime_with(outcome: Result) -> CliAgentRuntime { CliAgentRuntime::new(Arc::new(FixedSpawner(outcome))) } /// A spawner that just records the spec it was handed (for detect-spec assertions). struct RecordingSpawner(std::sync::Mutex>); #[async_trait] impl ProcessSpawner for RecordingSpawner { async fn run(&self, spec: SpawnSpec) -> Result { *self.0.lock().unwrap() = Some(spec); Ok(Output { status: ExitStatus { code: Some(0) }, stdout: Vec::new(), stderr: Vec::new(), }) } } fn pure_runtime() -> CliAgentRuntime { runtime_with(Ok(Output { status: ExitStatus { code: Some(0) }, stdout: Vec::new(), stderr: Vec::new(), })) } // --------------------------------------------------------------------------- // prepare_invocation — ConventionFile // --------------------------------------------------------------------------- #[test] fn prepare_convention_file_keeps_args_and_plans_file() { let rt = pure_runtime(); let p = profile( ContextInjection::convention_file("CLAUDE.md").unwrap(), "{projectRoot}", ); let root = ProjectPath::new("/home/me/proj").unwrap(); let spec = rt .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None) .unwrap(); assert_eq!(spec.command, "mycli"); assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged"); assert_eq!(spec.cwd.as_str(), "/home/me/proj"); assert_eq!( spec.context_plan, Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned() }) ); } // --------------------------------------------------------------------------- // prepare_invocation — Flag with {path} // --------------------------------------------------------------------------- #[test] fn prepare_flag_with_path_substitutes_and_splits() { let rt = pure_runtime(); let p = profile( ContextInjection::flag("--context-file {path}").unwrap(), "{projectRoot}", ); let root = ProjectPath::new("/p").unwrap(); let spec = rt .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None) .unwrap(); // static args first, then the substituted+split flag args. assert_eq!( spec.args, vec!["--static", "arg", "--context-file", ".ideai/agent.md"] ); assert_eq!( spec.context_plan, Some(ContextInjectionPlan::Args { args: vec!["--context-file".to_owned(), ".ideai/agent.md".to_owned()] }) ); } // --------------------------------------------------------------------------- // prepare_invocation — Flag without {path} (switch + path) // --------------------------------------------------------------------------- #[test] fn prepare_flag_without_path_is_switch_then_path() { let rt = pure_runtime(); let p = profile(ContextInjection::flag("-f").unwrap(), "{projectRoot}"); let root = ProjectPath::new("/p").unwrap(); let spec = rt .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None) .unwrap(); assert_eq!(spec.args, vec!["--static", "arg", "-f", ".ideai/agent.md"]); assert_eq!( spec.context_plan, Some(ContextInjectionPlan::Args { args: vec!["-f".to_owned(), ".ideai/agent.md".to_owned()] }) ); } // --------------------------------------------------------------------------- // prepare_invocation — Stdin // --------------------------------------------------------------------------- #[test] fn prepare_stdin_keeps_args_and_plans_stdin() { let rt = pure_runtime(); let p = profile(ContextInjection::stdin(), "{projectRoot}"); let root = ProjectPath::new("/p").unwrap(); let spec = rt .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None) .unwrap(); assert_eq!( spec.args, vec!["--static", "arg"], "args unchanged for stdin" ); assert_eq!(spec.context_plan, Some(ContextInjectionPlan::Stdin)); } // --------------------------------------------------------------------------- // prepare_invocation — Env // --------------------------------------------------------------------------- #[test] fn prepare_env_keeps_args_and_plans_env() { let rt = pure_runtime(); let p = profile( ContextInjection::env("AGENT_CONTEXT").unwrap(), "{projectRoot}", ); let root = ProjectPath::new("/p").unwrap(); let spec = rt .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None) .unwrap(); assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for env"); assert_eq!( spec.context_plan, Some(ContextInjectionPlan::Env { var: "AGENT_CONTEXT".to_owned() }) ); } // --------------------------------------------------------------------------- // prepare_invocation — cwd template substitution // --------------------------------------------------------------------------- #[test] fn prepare_substitutes_project_root_in_cwd_template() { let rt = pure_runtime(); let p = profile(ContextInjection::stdin(), "{projectRoot}/subdir"); let root = ProjectPath::new("/home/me/proj").unwrap(); let spec = rt .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None) .unwrap(); assert_eq!(spec.cwd.as_str(), "/home/me/proj/subdir"); } #[test] fn prepare_empty_cwd_template_defaults_to_base() { let rt = pure_runtime(); let p = profile(ContextInjection::stdin(), ""); let base = ProjectPath::new("/home/me/proj").unwrap(); let spec = rt .prepare_invocation(&p, &ctx(), &base, &SessionPlan::None) .unwrap(); assert_eq!(spec.cwd.as_str(), "/home/me/proj"); } #[test] fn prepare_substitutes_agent_run_dir_in_cwd_template() { // The canonical template (ARCHITECTURE §14.1): `{agentRunDir}` resolves to the // base cwd the launcher passes — the agent's isolated run directory. let rt = pure_runtime(); let p = profile( ContextInjection::convention_file("CLAUDE.md").unwrap(), "{agentRunDir}", ); let run_dir = ProjectPath::new("/home/me/proj/.ideai/run/agent-1").unwrap(); let spec = rt .prepare_invocation(&p, &ctx(), &run_dir, &SessionPlan::None) .unwrap(); assert_eq!(spec.cwd.as_str(), "/home/me/proj/.ideai/run/agent-1"); } // --------------------------------------------------------------------------- // detection_spec (pure) // --------------------------------------------------------------------------- #[test] fn detection_spec_uses_custom_detect_tokenised() { let p = profile(ContextInjection::stdin(), "{projectRoot}"); let spec = CliAgentRuntime::detection_spec(&p).unwrap(); assert_eq!(spec.command, "mycli"); assert_eq!(spec.args, vec!["probe", "--json"]); assert!(spec.context_plan.is_none()); assert!(spec.env.is_empty()); } #[test] fn detection_spec_falls_back_to_command_version() { let p = AgentProfile::new( ProfileId::from_uuid(uuid::Uuid::from_u128(2)), "NoDetect", "somecli", Vec::new(), ContextInjection::stdin(), None, "{projectRoot}", None, ) .unwrap(); let spec = CliAgentRuntime::detection_spec(&p).unwrap(); assert_eq!(spec.command, "somecli"); assert_eq!(spec.args, vec!["--version"]); } // --------------------------------------------------------------------------- // detect (mocked spawner) // --------------------------------------------------------------------------- #[test] fn detect_true_on_exit_zero() { let rt = runtime_with(Ok(Output { status: ExitStatus { code: Some(0) }, stdout: Vec::new(), stderr: Vec::new(), })); let p = profile(ContextInjection::stdin(), "{projectRoot}"); assert!(rt.detect(&p).unwrap()); } #[test] fn detect_false_on_nonzero_exit() { let rt = runtime_with(Ok(Output { status: ExitStatus { code: Some(127) }, stdout: Vec::new(), stderr: Vec::new(), })); let p = profile(ContextInjection::stdin(), "{projectRoot}"); assert!(!rt.detect(&p).unwrap()); } #[test] fn detect_false_on_signal_terminated() { let rt = runtime_with(Ok(Output { status: ExitStatus { code: None }, stdout: Vec::new(), stderr: Vec::new(), })); let p = profile(ContextInjection::stdin(), "{projectRoot}"); assert!(!rt.detect(&p).unwrap()); } #[test] fn detect_propagates_spawner_error() { let rt = runtime_with(Err(ProcessError::Spawn("no such file".to_owned()))); let p = profile(ContextInjection::stdin(), "{projectRoot}"); let err = rt.detect(&p).expect_err("spawner error surfaces"); assert!(matches!(err, RuntimeError::Detection(_)), "got {err:?}"); } #[test] fn detect_runs_the_detection_spec_command() { let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None))); let rt = CliAgentRuntime::new(recorder.clone()); let p = profile(ContextInjection::stdin(), "{projectRoot}"); rt.detect(&p).unwrap(); let spec = recorder.0.lock().unwrap().clone().expect("spec recorded"); assert_eq!(spec.command, "mycli"); assert_eq!(spec.args, vec!["probe", "--json"]); } // --------------------------------------------------------------------------- // prepare_invocation — session args (T3 truth table) // --------------------------------------------------------------------------- /// Like [`profile`] but carries a [`SessionStrategy`]. `Stdin` injection keeps /// the context out of the args so session args are the *only* trailing tokens. fn profile_with_session(session: Option) -> AgentProfile { AgentProfile::new( ProfileId::from_uuid(uuid::Uuid::from_u128(7)), "Test", "mycli", vec!["--static".to_owned(), "arg".to_owned()], ContextInjection::stdin(), Some("mycli probe --json".to_owned()), "{agentRunDir}", session, ) .unwrap() } fn root() -> ProjectPath { ProjectPath::new("/p").unwrap() } // Row 1: profile.session == None ⇒ no args added, whatever the plan. #[test] fn session_none_profile_adds_nothing_for_any_plan() { let rt = pure_runtime(); let p = profile_with_session(None); for plan in [ SessionPlan::None, SessionPlan::Assign { conversation_id: "id-1".to_owned(), }, SessionPlan::Resume { conversation_id: "id-1".to_owned(), }, ] { let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap(); assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}"); } } // Row 2: Some{assign_flag: Some(f)} + Assign{id} ⇒ [f, id]. #[test] fn session_assign_with_flag_emits_flag_and_id() { let rt = pure_runtime(); let p = profile_with_session(Some( SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap(), )); let spec = rt .prepare_invocation( &p, &ctx(), &root(), &SessionPlan::Assign { conversation_id: "abc".to_owned(), }, ) .unwrap(); assert_eq!(spec.args, vec!["--static", "arg", "--session-id", "abc"]); } // Row 3: Some{resume_flag: r, assign_flag: Some} + Resume{id} ⇒ [r, id]. #[test] fn session_resume_with_flag_emits_resume_and_id() { let rt = pure_runtime(); let p = profile_with_session(Some( SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap(), )); let spec = rt .prepare_invocation( &p, &ctx(), &root(), &SessionPlan::Resume { conversation_id: "abc".to_owned(), }, ) .unwrap(); assert_eq!(spec.args, vec!["--static", "arg", "--resume", "abc"]); } // Row 4: Some{assign_flag: None, resume_flag: r} + Resume{id} ⇒ [r] (degraded). #[test] fn session_resume_without_assign_flag_emits_resume_only() { let rt = pure_runtime(); let p = profile_with_session(Some(SessionStrategy::new(None, "--continue").unwrap())); let spec = rt .prepare_invocation( &p, &ctx(), &root(), &SessionPlan::Resume { conversation_id: "abc".to_owned(), }, ) .unwrap(); assert_eq!(spec.args, vec!["--static", "arg", "--continue"]); } // Row 5: Some{..} + SessionPlan::None ⇒ nothing added. #[test] fn session_plan_none_with_strategy_adds_nothing() { let rt = pure_runtime(); let p = profile_with_session(Some( SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap(), )); let spec = rt .prepare_invocation(&p, &ctx(), &root(), &SessionPlan::None) .unwrap(); assert_eq!(spec.args, vec!["--static", "arg"]); } // Row 6: Some{assign_flag: None} + Assign{id} ⇒ nothing (no assign possible). #[test] fn session_assign_without_flag_adds_nothing() { let rt = pure_runtime(); let p = profile_with_session(Some(SessionStrategy::new(None, "--continue").unwrap())); let spec = rt .prepare_invocation( &p, &ctx(), &root(), &SessionPlan::Assign { conversation_id: "abc".to_owned(), }, ) .unwrap(); assert_eq!(spec.args, vec!["--static", "arg"]); } // Non-regression: a profile WITHOUT session + SessionPlan::None yields the exact // same args as before T3 (the existing strategy tests already assert these; here // we pin it against an Assign/Resume plan too — still nothing added). #[test] fn no_session_profile_is_unaffected_by_any_plan() { let rt = pure_runtime(); let p = profile(ContextInjection::stdin(), "{agentRunDir}"); for plan in [ SessionPlan::None, SessionPlan::Assign { conversation_id: "x".to_owned(), }, SessionPlan::Resume { conversation_id: "x".to_owned(), }, ] { let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap(); assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}"); } } // Ordering: session args land *after* the context-injection (Flag) args. #[test] fn session_args_come_after_context_injection_args() { let rt = pure_runtime(); let p = AgentProfile::new( ProfileId::from_uuid(uuid::Uuid::from_u128(8)), "Test", "mycli", vec!["--static".to_owned()], ContextInjection::flag("--context-file {path}").unwrap(), Some("mycli probe".to_owned()), "{agentRunDir}", Some(SessionStrategy::new(Some("--session-id".to_owned()), "--resume").unwrap()), ) .unwrap(); let spec = rt .prepare_invocation( &p, &ctx(), &root(), &SessionPlan::Assign { conversation_id: "abc".to_owned(), }, ) .unwrap(); // static arg, then context-injection args, then session args — in that order. assert_eq!( spec.args, vec![ "--static", "--context-file", ".ideai/agent.md", "--session-id", "abc" ] ); }