Files
IdeA/crates/infrastructure/tests/agent_runtime.rs
Blomios 33edbad713 feat(agent): isolate agent cwd in .ideai/run/<id> to kill convention-file collisions
ARCHITECTURE §14.1: an agent's PTY cwd is now its own
`<project_root>/.ideai/run/<agent-id>/` directory, never the project root, so
N agents of the same profile no longer collide on a single conventional file
(CLAUDE.md/AGENTS.md/...).

- profile: cwd_template is now "{agentRunDir}" (built-in catalogue + docs).
- runtime: resolve_cwd substitutes {agentRunDir} (legacy {projectRoot} kept).
- LaunchAgent: computes + creates the run dir via FileSystem::create_dir_all,
  passes it as the cwd base to the pure prepare_invocation. Contract chosen:
  pass run_dir as the `cwd` argument (no PreparedContext change) — keeps
  prepare_invocation pure, I/O stays in the use case.
- convention file is generated by IdeA inside the run dir via a pure
  compose_convention_file(project_root, agent_md): absolute project-root header
  + agent persona (extensible for skills, §14.2).
- .gitignore: ignore .ideai/run/.
- run-dir cleanup left as a TODO (FileSystem port exposes no delete).

Tests: anti-collision (2 agents -> 2 distinct cwd, 2 distinct convention files,
none at root), run-dir creation order, composed convention file; pure unit
tests for agent_run_dir + compose_convention_file; runtime {agentRunDir}
substitution. cargo test --workspace + clippy -D warnings green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:18:14 +02:00

330 lines
11 KiB
Rust

//! 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::ports::{
AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError,
ProcessSpawner, RuntimeError, SpawnSpec,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::ProjectPath;
use domain::ids::ProfileId;
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,
)
.unwrap()
}
fn ctx() -> PreparedContext {
PreparedContext {
content: MarkdownDoc::new("# hi"),
relative_path: ".ideai/agent.md".to_owned(),
}
}
/// A [`ProcessSpawner`] that returns a fixed outcome regardless of the spec.
struct FixedSpawner(Result<Output, ProcessError>);
#[async_trait]
impl ProcessSpawner for FixedSpawner {
async fn run(&self, _spec: SpawnSpec) -> Result<Output, ProcessError> {
self.0.clone()
}
}
fn runtime_with(outcome: Result<Output, ProcessError>) -> 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<Option<SpawnSpec>>);
#[async_trait]
impl ProcessSpawner for RecordingSpawner {
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError> {
*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).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).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).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).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).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).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).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).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}",
)
.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"]);
}