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>
779 lines
25 KiB
Rust
779 lines
25 KiB
Rust
//! L6 tests for the agent lifecycle use cases (`CreateAgentFromScratch`,
|
|
//! `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`,
|
|
//! `LaunchAgent`).
|
|
//!
|
|
//! Every port is faked in-memory so the use cases run without real I/O:
|
|
//! - [`FakeContexts`] — an [`AgentContextStore`] holding the manifest + a
|
|
//! `md_path → content` map,
|
|
//! - [`FakeProfiles`] — a [`ProfileStore`] returning a fixed profile list,
|
|
//! - [`FakeRuntime`] — an [`AgentRuntime`] whose `prepare_invocation` records the
|
|
//! call into a shared **trace** and returns a configurable injection plan,
|
|
//! - [`FakeFs`] — a [`FileSystem`] recording writes into the same trace,
|
|
//! - [`FakePty`] — a [`PtyPort`] recording `spawn` into the trace,
|
|
//! - [`SpyBus`], [`SeqIds`] — event recorder and deterministic id generator.
|
|
//!
|
|
//! The shared trace lets us assert the **call ordering** contract of
|
|
//! `LaunchAgent`: `prepare_invocation` → injection (fs write) → `pty.spawn`.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
|
use domain::events::DomainEvent;
|
|
use domain::ids::{AgentId, ProfileId, ProjectId};
|
|
use domain::markdown::MarkdownDoc;
|
|
use domain::ports::{
|
|
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
|
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
|
|
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SpawnSpec, StoreError,
|
|
};
|
|
use domain::profile::{AgentProfile, ContextInjection};
|
|
use domain::project::{Project, ProjectPath};
|
|
use domain::remote::RemoteRef;
|
|
use domain::{PtySize, SessionId};
|
|
use uuid::Uuid;
|
|
|
|
use application::{
|
|
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
|
|
LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput,
|
|
TerminalSessions, UpdateAgentContext, UpdateAgentContextInput,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared trace (ordering)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Trace = Arc<Mutex<Vec<String>>>;
|
|
|
|
/// A recorded list of `(target, bytes)` writes, keyed by whatever addresses the
|
|
/// target (a path for the fs, a [`SessionId`] for the pty).
|
|
type WriteLog<K> = Arc<Mutex<Vec<(K, Vec<u8>)>>>;
|
|
|
|
fn trace() -> Trace {
|
|
Arc::new(Mutex::new(Vec::new()))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeContexts (AgentContextStore)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Default)]
|
|
struct ContextsInner {
|
|
manifest: AgentManifest,
|
|
contents: HashMap<String, String>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct FakeContexts(Arc<Mutex<ContextsInner>>);
|
|
|
|
impl FakeContexts {
|
|
fn new() -> Self {
|
|
Self(Arc::new(Mutex::new(ContextsInner {
|
|
manifest: AgentManifest {
|
|
version: 1,
|
|
entries: Vec::new(),
|
|
},
|
|
contents: HashMap::new(),
|
|
})))
|
|
}
|
|
fn with_agent(agent: &Agent, content: &str) -> Self {
|
|
let me = Self::new();
|
|
{
|
|
let mut inner = me.0.lock().unwrap();
|
|
inner.manifest.entries.push(ManifestEntry::from_agent(agent));
|
|
inner
|
|
.contents
|
|
.insert(agent.context_path.clone(), content.to_owned());
|
|
}
|
|
me
|
|
}
|
|
fn manifest(&self) -> AgentManifest {
|
|
self.0.lock().unwrap().manifest.clone()
|
|
}
|
|
fn content(&self, md_path: &str) -> Option<String> {
|
|
self.0.lock().unwrap().contents.get(md_path).cloned()
|
|
}
|
|
fn md_path_of(&self, agent: &AgentId) -> Option<String> {
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.manifest
|
|
.entries
|
|
.iter()
|
|
.find(|e| &e.agent_id == agent)
|
|
.map(|e| e.md_path.clone())
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentContextStore for FakeContexts {
|
|
async fn read_context(
|
|
&self,
|
|
_project: &Project,
|
|
agent: &AgentId,
|
|
) -> Result<MarkdownDoc, StoreError> {
|
|
let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
|
self.content(&md_path)
|
|
.map(MarkdownDoc::new)
|
|
.ok_or(StoreError::NotFound)
|
|
}
|
|
async fn write_context(
|
|
&self,
|
|
_project: &Project,
|
|
agent: &AgentId,
|
|
md: &MarkdownDoc,
|
|
) -> Result<(), StoreError> {
|
|
let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.contents
|
|
.insert(md_path, md.as_str().to_owned());
|
|
Ok(())
|
|
}
|
|
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
|
Ok(self.manifest())
|
|
}
|
|
async fn save_manifest(
|
|
&self,
|
|
_project: &Project,
|
|
manifest: &AgentManifest,
|
|
) -> Result<(), StoreError> {
|
|
self.0.lock().unwrap().manifest = manifest.clone();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeProfiles (ProfileStore)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone)]
|
|
struct FakeProfiles(Arc<Vec<AgentProfile>>);
|
|
|
|
impl FakeProfiles {
|
|
fn new(profiles: Vec<AgentProfile>) -> Self {
|
|
Self(Arc::new(profiles))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ProfileStore for FakeProfiles {
|
|
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
|
Ok((*self.0).clone())
|
|
}
|
|
async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
async fn is_configured(&self) -> Result<bool, StoreError> {
|
|
Ok(true)
|
|
}
|
|
async fn mark_configured(&self) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeRuntime (AgentRuntime) — records prepare + returns a configured plan
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct FakeRuntime {
|
|
trace: Trace,
|
|
plan: Option<ContextInjectionPlan>,
|
|
}
|
|
|
|
impl FakeRuntime {
|
|
fn new(trace: Trace, plan: Option<ContextInjectionPlan>) -> Self {
|
|
Self { trace, plan }
|
|
}
|
|
}
|
|
|
|
impl AgentRuntime for FakeRuntime {
|
|
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
|
Ok(true)
|
|
}
|
|
fn prepare_invocation(
|
|
&self,
|
|
profile: &AgentProfile,
|
|
_ctx: &PreparedContext,
|
|
cwd: &ProjectPath,
|
|
) -> Result<SpawnSpec, RuntimeError> {
|
|
self.trace.lock().unwrap().push("prepare".to_owned());
|
|
Ok(SpawnSpec {
|
|
command: profile.command.clone(),
|
|
args: profile.args.clone(),
|
|
cwd: cwd.clone(),
|
|
env: Vec::new(),
|
|
context_plan: self.plan.clone(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeFs (FileSystem) — records writes into the trace
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone)]
|
|
struct FakeFs {
|
|
trace: Trace,
|
|
writes: WriteLog<String>,
|
|
created_dirs: Arc<Mutex<Vec<String>>>,
|
|
}
|
|
|
|
impl FakeFs {
|
|
fn new(trace: Trace) -> Self {
|
|
Self {
|
|
trace,
|
|
writes: Arc::new(Mutex::new(Vec::new())),
|
|
created_dirs: Arc::new(Mutex::new(Vec::new())),
|
|
}
|
|
}
|
|
fn writes(&self) -> Vec<(String, Vec<u8>)> {
|
|
self.writes.lock().unwrap().clone()
|
|
}
|
|
fn created_dirs(&self) -> Vec<String> {
|
|
self.created_dirs.lock().unwrap().clone()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl FileSystem for FakeFs {
|
|
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
|
Err(FsError::NotFound(path.as_str().to_owned()))
|
|
}
|
|
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
|
self.trace.lock().unwrap().push("fs.write".to_owned());
|
|
self.writes
|
|
.lock()
|
|
.unwrap()
|
|
.push((path.as_str().to_owned(), data.to_vec()));
|
|
Ok(())
|
|
}
|
|
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
|
|
Ok(false)
|
|
}
|
|
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
|
|
self.created_dirs
|
|
.lock()
|
|
.unwrap()
|
|
.push(path.as_str().to_owned());
|
|
Ok(())
|
|
}
|
|
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakePty (PtyPort) — records spawn into the trace
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone)]
|
|
struct FakePty {
|
|
trace: Trace,
|
|
next_id: SessionId,
|
|
spawns: Arc<Mutex<Vec<SpawnSpec>>>,
|
|
writes: WriteLog<SessionId>,
|
|
}
|
|
|
|
impl FakePty {
|
|
fn new(trace: Trace, next_id: SessionId) -> Self {
|
|
Self {
|
|
trace,
|
|
next_id,
|
|
spawns: Arc::new(Mutex::new(Vec::new())),
|
|
writes: Arc::new(Mutex::new(Vec::new())),
|
|
}
|
|
}
|
|
fn spawns(&self) -> Vec<SpawnSpec> {
|
|
self.spawns.lock().unwrap().clone()
|
|
}
|
|
fn writes(&self) -> Vec<(SessionId, Vec<u8>)> {
|
|
self.writes.lock().unwrap().clone()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl PtyPort for FakePty {
|
|
async fn spawn(&self, spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
|
|
self.trace.lock().unwrap().push("spawn".to_owned());
|
|
self.spawns.lock().unwrap().push(spec);
|
|
Ok(PtyHandle {
|
|
session_id: self.next_id,
|
|
})
|
|
}
|
|
fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> {
|
|
self.writes
|
|
.lock()
|
|
.unwrap()
|
|
.push((handle.session_id, data.to_vec()));
|
|
Ok(())
|
|
}
|
|
fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
|
|
Ok(())
|
|
}
|
|
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
|
Ok(Box::new(std::iter::empty()))
|
|
}
|
|
async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
|
Ok(ExitStatus { code: Some(0) })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SpyBus + SeqIds
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Default, Clone)]
|
|
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
|
impl SpyBus {
|
|
fn events(&self) -> Vec<DomainEvent> {
|
|
self.0.lock().unwrap().clone()
|
|
}
|
|
}
|
|
impl EventBus for SpyBus {
|
|
fn publish(&self, event: DomainEvent) {
|
|
self.0.lock().unwrap().push(event);
|
|
}
|
|
fn subscribe(&self) -> EventStream {
|
|
Box::new(std::iter::empty())
|
|
}
|
|
}
|
|
|
|
struct SeqIds(Mutex<u128>);
|
|
impl SeqIds {
|
|
fn new() -> Self {
|
|
Self(Mutex::new(1))
|
|
}
|
|
}
|
|
impl IdGenerator for SeqIds {
|
|
fn new_uuid(&self) -> Uuid {
|
|
let mut n = self.0.lock().unwrap();
|
|
let id = Uuid::from_u128(*n);
|
|
*n += 1;
|
|
id
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn pid(n: u128) -> ProfileId {
|
|
ProfileId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn aid(n: u128) -> AgentId {
|
|
AgentId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn sid(n: u128) -> SessionId {
|
|
SessionId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
|
|
fn project() -> Project {
|
|
Project::new(
|
|
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
|
"demo",
|
|
ProjectPath::new("/home/me/proj").unwrap(),
|
|
RemoteRef::local(),
|
|
1_700_000_000_000,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn profile(id: ProfileId, injection: ContextInjection) -> AgentProfile {
|
|
AgentProfile::new(
|
|
id,
|
|
"Claude Code",
|
|
"claude",
|
|
Vec::new(),
|
|
injection,
|
|
Some("claude --version".to_owned()),
|
|
"{agentRunDir}",
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn scratch_agent(id: AgentId, name: &str, md: &str, profile_id: ProfileId) -> Agent {
|
|
Agent::new(id, name, md, profile_id, AgentOrigin::Scratch, false).unwrap()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CreateAgentFromScratch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn create_persists_manifest_entry_and_initial_context() {
|
|
let contexts = FakeContexts::new();
|
|
let bus = SpyBus::default();
|
|
let create = CreateAgentFromScratch::new(
|
|
Arc::new(contexts.clone()),
|
|
Arc::new(SeqIds::new()),
|
|
Arc::new(bus.clone()),
|
|
);
|
|
|
|
let out = create
|
|
.execute(CreateAgentInput {
|
|
project: project(),
|
|
name: "Backend Dev".to_owned(),
|
|
profile_id: pid(9),
|
|
initial_content: Some("# Backend".to_owned()),
|
|
})
|
|
.await
|
|
.expect("create succeeds");
|
|
|
|
// md_path is slugified from the name.
|
|
assert_eq!(out.agent.context_path, "agents/backend-dev.md");
|
|
assert_eq!(out.agent.profile_id, pid(9));
|
|
assert!(matches!(out.agent.origin, AgentOrigin::Scratch));
|
|
assert!(!out.agent.synchronized);
|
|
|
|
// Manifest has exactly one entry for this agent; context stored under md_path.
|
|
let manifest = contexts.manifest();
|
|
assert_eq!(manifest.entries.len(), 1);
|
|
assert_eq!(manifest.entries[0].agent_id, out.agent.id);
|
|
assert_eq!(
|
|
contexts.content("agents/backend-dev.md").as_deref(),
|
|
Some("# Backend")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_disambiguates_md_path_on_name_collision() {
|
|
// Seed a project that already has `agents/backend.md`.
|
|
let existing = scratch_agent(aid(50), "Backend", "agents/backend.md", pid(9));
|
|
let contexts = FakeContexts::with_agent(&existing, "old");
|
|
let create = CreateAgentFromScratch::new(
|
|
Arc::new(contexts.clone()),
|
|
Arc::new(SeqIds::new()),
|
|
Arc::new(SpyBus::default()),
|
|
);
|
|
|
|
let out = create
|
|
.execute(CreateAgentInput {
|
|
project: project(),
|
|
name: "Backend".to_owned(),
|
|
profile_id: pid(9),
|
|
initial_content: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.agent.context_path, "agents/backend-2.md");
|
|
assert_eq!(contexts.manifest().entries.len(), 2);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ListAgents / Read / Update / Delete
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn list_reconstructs_agents_from_manifest() {
|
|
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
|
let contexts = FakeContexts::with_agent(&a, "ctx");
|
|
let list = ListAgents::new(Arc::new(contexts));
|
|
|
|
let out = list
|
|
.execute(ListAgentsInput { project: project() })
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(out.agents, vec![a]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_then_update_context_roundtrips() {
|
|
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
|
let contexts = FakeContexts::with_agent(&a, "original");
|
|
let read = ReadAgentContext::new(Arc::new(contexts.clone()));
|
|
let update = UpdateAgentContext::new(Arc::new(contexts.clone()));
|
|
|
|
let before = read
|
|
.execute(ReadAgentContextInput {
|
|
project: project(),
|
|
agent_id: a.id,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(before.content.as_str(), "original");
|
|
|
|
update
|
|
.execute(UpdateAgentContextInput {
|
|
project: project(),
|
|
agent_id: a.id,
|
|
content: "edited".to_owned(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(contexts.content("agents/backend.md").as_deref(), Some("edited"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_removes_entry_then_unknown_is_not_found() {
|
|
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
|
let contexts = FakeContexts::with_agent(&a, "ctx");
|
|
let delete = DeleteAgent::new(Arc::new(contexts.clone()), Arc::new(SpyBus::default()));
|
|
|
|
delete
|
|
.execute(DeleteAgentInput {
|
|
project: project(),
|
|
agent_id: a.id,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(contexts.manifest().entries.is_empty());
|
|
|
|
// Second delete: the agent is gone → NotFound.
|
|
let err = delete
|
|
.execute(DeleteAgentInput {
|
|
project: project(),
|
|
agent_id: a.id,
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LaunchAgent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Everything a launch test needs to drive `LaunchAgent` and assert over the
|
|
/// fakes: the use case, the seeded agent, the recording fs/pty, the event spy,
|
|
/// the session registry and the shared ordering trace.
|
|
type LaunchFixture = (
|
|
LaunchAgent,
|
|
Agent,
|
|
FakeFs,
|
|
FakePty,
|
|
SpyBus,
|
|
Arc<TerminalSessions>,
|
|
Trace,
|
|
);
|
|
|
|
/// Wires a LaunchAgent over fakes for a given injection strategy/plan.
|
|
fn launch_fixture(injection: ContextInjection, plan: Option<ContextInjectionPlan>) -> LaunchFixture {
|
|
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
|
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
|
let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]);
|
|
let tr = trace();
|
|
let runtime = FakeRuntime::new(Arc::clone(&tr), plan);
|
|
let fs = FakeFs::new(Arc::clone(&tr));
|
|
let pty = FakePty::new(Arc::clone(&tr), sid(777));
|
|
let sessions = Arc::new(TerminalSessions::new());
|
|
let bus = SpyBus::default();
|
|
let launch = LaunchAgent::new(
|
|
Arc::new(contexts),
|
|
Arc::new(profiles),
|
|
Arc::new(runtime),
|
|
Arc::new(fs.clone()),
|
|
Arc::new(pty.clone()),
|
|
Arc::clone(&sessions),
|
|
Arc::new(bus.clone()),
|
|
);
|
|
(launch, agent, fs, pty, bus, sessions, tr)
|
|
}
|
|
|
|
fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
|
|
LaunchAgentInput {
|
|
project: project(),
|
|
agent_id,
|
|
rows: 24,
|
|
cols: 80,
|
|
node_id: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn launch_orders_prepare_then_injection_then_spawn() {
|
|
// conventionFile strategy → an fs.write must happen between prepare and spawn.
|
|
let (launch, agent, fs, pty, bus, sessions, tr) = launch_fixture(
|
|
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
|
Some(ContextInjectionPlan::File {
|
|
target: "CLAUDE.md".to_owned(),
|
|
}),
|
|
);
|
|
|
|
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
|
|
|
|
// Ordering contract.
|
|
assert_eq!(
|
|
*tr.lock().unwrap(),
|
|
vec!["prepare".to_owned(), "fs.write".to_owned(), "spawn".to_owned()],
|
|
"prepare → injection → spawn"
|
|
);
|
|
|
|
// The conventionFile was written inside the agent's isolated run directory
|
|
// (`.ideai/run/<agent-id>/CLAUDE.md`) — NOT at the project root. Its content
|
|
// is the *composed* document: an absolute project-root header followed by the
|
|
// agent persona `.md`.
|
|
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
|
let writes = fs.writes();
|
|
assert_eq!(writes.len(), 1);
|
|
assert_eq!(writes[0].0, format!("{run_dir}/CLAUDE.md"));
|
|
let written = String::from_utf8(writes[0].1.clone()).unwrap();
|
|
assert!(
|
|
written.contains("/home/me/proj"),
|
|
"convention file must carry the absolute project root, got: {written}"
|
|
);
|
|
assert!(
|
|
written.contains("# ctx body"),
|
|
"convention file must carry the agent persona, got: {written}"
|
|
);
|
|
|
|
// The run directory was created (via the FileSystem port) before spawn.
|
|
assert_eq!(fs.created_dirs(), vec![run_dir.clone()]);
|
|
|
|
// Spawn happened at the isolated run dir with the profile command.
|
|
let spawns = pty.spawns();
|
|
assert_eq!(spawns.len(), 1);
|
|
assert_eq!(spawns[0].command, "claude");
|
|
assert_eq!(spawns[0].cwd.as_str(), run_dir);
|
|
|
|
// The session adopts the PTY id, is Running, and is registered as an agent.
|
|
assert_eq!(out.session.id, sid(777));
|
|
assert!(matches!(
|
|
out.session.kind,
|
|
domain::SessionKind::Agent { agent_id } if agent_id == agent.id
|
|
));
|
|
assert!(sessions.session(&sid(777)).is_some());
|
|
|
|
// AgentLaunched announced.
|
|
assert_eq!(
|
|
bus.events(),
|
|
vec![DomainEvent::AgentLaunched {
|
|
agent_id: agent.id,
|
|
session_id: sid(777),
|
|
}]
|
|
);
|
|
}
|
|
|
|
/// **Anti-collision (ARCHITECTURE §14.1)**: two distinct agents of the *same*
|
|
/// profile on the *same* project root must launch into two **distinct** cwd —
|
|
/// each its own `.ideai/run/<agent-id>/` — and each writes its convention file
|
|
/// inside its own run dir, never colliding at the project root.
|
|
#[tokio::test]
|
|
async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
|
|
let injection = ContextInjection::convention_file("CLAUDE.md").unwrap();
|
|
let plan = Some(ContextInjectionPlan::File {
|
|
target: "CLAUDE.md".to_owned(),
|
|
});
|
|
|
|
// Two agents, same profile (pid(9)), same project root.
|
|
let agent_a = scratch_agent(aid(1), "Alpha", "agents/alpha.md", pid(9));
|
|
let agent_b = scratch_agent(aid(2), "Bravo", "agents/bravo.md", pid(9));
|
|
|
|
let contexts = FakeContexts::with_agent(&agent_a, "# alpha");
|
|
{
|
|
let mut inner = contexts.0.lock().unwrap();
|
|
inner
|
|
.manifest
|
|
.entries
|
|
.push(ManifestEntry::from_agent(&agent_b));
|
|
inner
|
|
.contents
|
|
.insert(agent_b.context_path.clone(), "# bravo".to_owned());
|
|
}
|
|
let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]);
|
|
|
|
let tr = trace();
|
|
let fs = FakeFs::new(Arc::clone(&tr));
|
|
let pty = FakePty::new(Arc::clone(&tr), sid(777));
|
|
let sessions = Arc::new(TerminalSessions::new());
|
|
let launch = LaunchAgent::new(
|
|
Arc::new(contexts),
|
|
Arc::new(profiles),
|
|
Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)),
|
|
Arc::new(fs.clone()),
|
|
Arc::new(pty.clone()),
|
|
Arc::clone(&sessions),
|
|
Arc::new(SpyBus::default()),
|
|
);
|
|
|
|
launch.execute(launch_input(agent_a.id)).await.unwrap();
|
|
launch.execute(launch_input(agent_b.id)).await.unwrap();
|
|
|
|
let dir_a = format!("/home/me/proj/.ideai/run/{}", agent_a.id);
|
|
let dir_b = format!("/home/me/proj/.ideai/run/{}", agent_b.id);
|
|
assert_ne!(dir_a, dir_b, "the two agents must map to different run dirs");
|
|
|
|
// Two distinct run dirs were created.
|
|
assert_eq!(fs.created_dirs(), vec![dir_a.clone(), dir_b.clone()]);
|
|
|
|
// Two spawns at two distinct cwd — the core anti-collision guarantee.
|
|
let spawns = pty.spawns();
|
|
assert_eq!(spawns.len(), 2);
|
|
assert_eq!(spawns[0].cwd.as_str(), dir_a);
|
|
assert_eq!(spawns[1].cwd.as_str(), dir_b);
|
|
assert_ne!(spawns[0].cwd, spawns[1].cwd);
|
|
|
|
// Two convention files, each inside its own run dir (no shared root file).
|
|
let writes = fs.writes();
|
|
assert_eq!(writes.len(), 2);
|
|
assert_eq!(writes[0].0, format!("{dir_a}/CLAUDE.md"));
|
|
assert_eq!(writes[1].0, format!("{dir_b}/CLAUDE.md"));
|
|
assert_ne!(writes[0].0, writes[1].0);
|
|
// Neither writes to the project root.
|
|
assert!(writes.iter().all(|(p, _)| p != "/home/me/proj/CLAUDE.md"));
|
|
|
|
// Each convention file carries its own persona.
|
|
assert!(String::from_utf8(writes[0].1.clone()).unwrap().contains("# alpha"));
|
|
assert!(String::from_utf8(writes[1].1.clone()).unwrap().contains("# bravo"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn launch_stdin_strategy_pipes_context_after_spawn() {
|
|
let (launch, agent, fs, pty, _bus, _sessions, tr) =
|
|
launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin));
|
|
|
|
launch.execute(launch_input(agent.id)).await.unwrap();
|
|
|
|
// No file written for stdin; content is piped to the PTY post-spawn.
|
|
assert!(fs.writes().is_empty(), "stdin must not write a file");
|
|
assert_eq!(*tr.lock().unwrap(), vec!["prepare".to_owned(), "spawn".to_owned()]);
|
|
let writes = pty.writes();
|
|
assert_eq!(writes.len(), 1);
|
|
assert_eq!(writes[0].0, sid(777));
|
|
assert_eq!(writes[0].1, b"# ctx body");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn launch_unknown_agent_is_not_found() {
|
|
let (launch, _agent, _fs, pty, _bus, _sessions, _tr) = launch_fixture(
|
|
ContextInjection::stdin(),
|
|
Some(ContextInjectionPlan::Stdin),
|
|
);
|
|
let err = launch.execute(launch_input(aid(404))).await.unwrap_err();
|
|
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
|
assert!(pty.spawns().is_empty(), "no spawn for unknown agent");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn launch_unknown_profile_is_not_found() {
|
|
// The agent references pid(9) but the store only knows pid(1).
|
|
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
|
let contexts = FakeContexts::with_agent(&agent, "ctx");
|
|
let profiles = FakeProfiles::new(vec![profile(pid(1), ContextInjection::stdin())]);
|
|
let tr = trace();
|
|
let pty = FakePty::new(Arc::clone(&tr), sid(777));
|
|
let launch = LaunchAgent::new(
|
|
Arc::new(contexts),
|
|
Arc::new(profiles),
|
|
Arc::new(FakeRuntime::new(Arc::clone(&tr), Some(ContextInjectionPlan::Stdin))),
|
|
Arc::new(FakeFs::new(Arc::clone(&tr))),
|
|
Arc::new(pty.clone()),
|
|
Arc::new(TerminalSessions::new()),
|
|
Arc::new(SpyBus::default()),
|
|
);
|
|
|
|
let err = launch.execute(launch_input(agent.id)).await.unwrap_err();
|
|
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
|
assert!(pty.spawns().is_empty(), "no spawn when profile unresolved");
|
|
}
|