//! 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::permission::{ EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile, ProjectionContext, ProjectorKey, }; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory, ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall, OutputStream, PermissionStore, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::profile::{ AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, OpenCodeConfig, SessionStrategy, StructuredAdapter, }; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; use domain::{MemoryIndexEntry, MemorySlug, MemoryType}; use domain::{PermissionSet, ProjectPermissions}; use domain::{PtySize, SessionId, SkillId, SkillRef}; use uuid::Uuid; use application::{ CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, PermissionProjectorRegistry, ReadAgentContext, ReadAgentContextInput, StructuredRoutingMode, StructuredSessions, TerminalSessions, UpdateAgentContext, UpdateAgentContextInput, }; // --------------------------------------------------------------------------- // Shared trace (ordering) // --------------------------------------------------------------------------- type Trace = Arc>>; /// 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 = Arc)>>>; fn trace() -> Trace { Arc::new(Mutex::new(Vec::new())) } // --------------------------------------------------------------------------- // FakeContexts (AgentContextStore) // --------------------------------------------------------------------------- #[derive(Default)] struct ContextsInner { manifest: AgentManifest, contents: HashMap, } #[derive(Clone)] struct FakeContexts(Arc>); impl FakeContexts { fn new() -> Self { Self(Arc::new(Mutex::new(ContextsInner { manifest: AgentManifest { version: 1, entries: Vec::new(), orchestrator: None, }, 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 { self.0.lock().unwrap().contents.get(md_path).cloned() } fn md_path_of(&self, agent: &AgentId) -> Option { 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 { 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 { 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>); impl FakeProfiles { fn new(profiles: Vec) -> Self { Self(Arc::new(profiles)) } } #[async_trait] impl ProfileStore for FakeProfiles { async fn list(&self) -> Result, 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 { Ok(true) } async fn mark_configured(&self) -> Result<(), StoreError> { Ok(()) } } // --------------------------------------------------------------------------- // FakeSkills (SkillStore) — an in-memory store seeded with a few skills // --------------------------------------------------------------------------- #[derive(Clone, Default)] struct FakeSkills(Arc>); impl FakeSkills { fn with(skills: Vec) -> Self { Self(Arc::new(skills)) } } #[async_trait] impl SkillStore for FakeSkills { async fn list(&self, scope: SkillScope, _root: &ProjectPath) -> Result, StoreError> { Ok(self .0 .iter() .filter(|s| s.scope == scope) .cloned() .collect()) } async fn get( &self, scope: SkillScope, _root: &ProjectPath, id: SkillId, ) -> Result { self.0 .iter() .find(|s| s.scope == scope && s.id == id) .cloned() .ok_or(StoreError::NotFound) } async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { Ok(()) } async fn delete( &self, _scope: SkillScope, _root: &ProjectPath, _id: SkillId, ) -> Result<(), StoreError> { Ok(()) } } // --------------------------------------------------------------------------- // FakeRecall (MemoryRecall) — returns a canned index, or fails (best-effort test) // --------------------------------------------------------------------------- /// In-memory [`MemoryRecall`] fake for the launch tests: empty by default /// (⇒ no memory section, behaviour unchanged), configurable with canned entries, /// or set to fail so the best-effort degradation at activation can be asserted. #[derive(Clone, Default)] struct FakeRecall { entries: Arc>, fail: bool, } impl FakeRecall { fn returning(entries: Vec) -> Self { Self { entries: Arc::new(entries), fail: false, } } fn failing() -> Self { Self { entries: Arc::default(), fail: true, } } } #[async_trait] impl MemoryRecall for FakeRecall { async fn recall( &self, _root: &ProjectPath, _query: &MemoryQuery, ) -> Result, MemoryError> { if self.fail { return Err(MemoryError::Io("recall boom".to_owned())); } Ok((*self.entries).clone()) } } /// Builds a memory index entry for the launch tests. fn mem_entry(slug: &str, title: &str, hook: &str, kind: MemoryType) -> MemoryIndexEntry { MemoryIndexEntry { slug: MemorySlug::new(slug).unwrap(), title: title.to_owned(), hook: hook.to_owned(), r#type: kind, } } // --------------------------------------------------------------------------- // FakeRuntime (AgentRuntime) — records prepare + returns a configured plan // --------------------------------------------------------------------------- struct FakeRuntime { trace: Trace, plan: Option, /// The last [`SessionPlan`] handed to `prepare_invocation`, captured so tests /// can assert the launch resolved the right Assign/Resume/None intention (T4). last_session: Arc>>, } impl FakeRuntime { fn new(trace: Trace, plan: Option) -> Self { Self { trace, plan, last_session: Arc::new(Mutex::new(None)), } } /// Shared handle to inspect the captured session plan after a launch. fn session_probe(&self) -> Arc>> { Arc::clone(&self.last_session) } } #[async_trait] impl AgentRuntime for FakeRuntime { async fn detect(&self, _profile: &AgentProfile) -> Result { Ok(true) } fn prepare_invocation( &self, profile: &AgentProfile, _ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, ) -> Result { *self.last_session.lock().unwrap() = Some(session.clone()); 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(), sandbox: None, }) } } // --------------------------------------------------------------------------- // FakeFs (FileSystem) — records writes into the trace // --------------------------------------------------------------------------- #[derive(Clone)] struct FakeFs { trace: Trace, writes: WriteLog, created_dirs: Arc>>, project_context: Arc>>, /// Paths reported as **already existing** by [`FileSystem::exists`] (the /// default empty set keeps the historical `exists == false` behaviour). Used /// by the MCP non-clobbering test. existing: Arc>>, /// Paths whose [`FileSystem::write`] must fail with an I/O error (nothing is /// recorded for them). Used by the MCP best-effort test to prove that an MCP /// config write failure never fails the launch. fail_writes_for: Arc>>, /// Canned `path → contents` returned by [`FileSystem::read`] when no later write /// to that path exists. Used by the `MergeToml` projection test to seed a /// pre-existing `.codex/config.toml` carrying unmanaged user keys. seeded_reads: Arc>>, } impl FakeFs { fn new(trace: Trace) -> Self { Self { trace, writes: Arc::new(Mutex::new(Vec::new())), created_dirs: Arc::new(Mutex::new(Vec::new())), project_context: Arc::new(Mutex::new(None)), existing: Arc::new(Mutex::new(Vec::new())), fail_writes_for: Arc::new(Mutex::new(Vec::new())), seeded_reads: Arc::new(Mutex::new(HashMap::new())), } } fn set_project_context(&self, content: &str) { *self.project_context.lock().unwrap() = Some(content.to_owned()); } /// Seeds a canned content for `path` so [`FileSystem::read`] returns it until a /// later [`FileSystem::write`] supersedes it (last-write-wins). fn seed_read(&self, path: &str, content: &str) { self.seeded_reads .lock() .unwrap() .insert(path.to_owned(), content.to_owned()); } /// All writes whose path ends with `suffix` (e.g. the projected Codex config). fn writes_ending_with(&self, suffix: &str) -> Vec<(String, Vec)> { self.writes() .into_iter() .filter(|(p, _)| p.ends_with(suffix)) .collect() } /// Marks a path as already existing on disk, so [`FileSystem::exists`] returns /// `true` for it (non-clobbering scenarios). fn mark_existing(&self, path: &str) { self.existing.lock().unwrap().push(path.to_owned()); } /// Makes any [`FileSystem::write`] whose path ends with `suffix` fail /// (best-effort scenarios — e.g. the MCP `.mcp.json` config file). fn fail_write_suffix(&self, suffix: &str) { self.fail_writes_for.lock().unwrap().push(suffix.to_owned()); } fn writes(&self) -> Vec<(String, Vec)> { self.writes.lock().unwrap().clone() } fn created_dirs(&self) -> Vec { self.created_dirs.lock().unwrap().clone() } /// Convention-file / context writes only (excludes the Claude permission seed), /// so injection assertions stay focused on the agent's `.md`. fn context_writes(&self) -> Vec<(String, Vec)> { self.writes() .into_iter() .filter(|(p, _)| !p.ends_with("/.claude/settings.local.json")) .collect() } /// Only the Claude permission seed writes (`.claude/settings.local.json`). fn seed_writes(&self) -> Vec<(String, Vec)> { self.writes() .into_iter() .filter(|(p, _)| p.ends_with("/.claude/settings.local.json")) .collect() } /// Created run dirs excluding the seed's `.claude` subdir. fn created_run_dirs(&self) -> Vec { self.created_dirs() .into_iter() .filter(|p| !p.ends_with("/.claude")) .collect() } } #[async_trait] impl FileSystem for FakeFs { async fn read(&self, path: &RemotePath) -> Result, FsError> { let p = path.as_str(); // Last-write-wins: a previously written file reads back its latest content // (so the MergeToml merge/idempotence test observes its own prior write). if let Some((_, bytes)) = self.writes().into_iter().rev().find(|(wp, _)| wp == p) { return Ok(bytes); } // Then any canned seed for this exact path. if let Some(content) = self.seeded_reads.lock().unwrap().get(p) { return Ok(content.clone().into_bytes()); } if p.ends_with("/.ideai/CONTEXT.md") { return self .project_context .lock() .unwrap() .clone() .map(|s| s.into_bytes()) .ok_or_else(|| FsError::NotFound(p.to_owned())); } Err(FsError::NotFound(p.to_owned())) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { let p = path.as_str(); if self .fail_writes_for .lock() .unwrap() .iter() .any(|suffix| p.ends_with(suffix.as_str())) { return Err(FsError::Io(format!("forced write failure for {p}"))); } self.trace.lock().unwrap().push("fs.write".to_owned()); self.writes .lock() .unwrap() .push((p.to_owned(), data.to_vec())); Ok(()) } async fn exists(&self, path: &RemotePath) -> Result { let p = path.as_str(); Ok(self.existing.lock().unwrap().iter().any(|e| e == p)) } 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, 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>>, writes: WriteLog, } 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 { self.spawns.lock().unwrap().clone() } fn writes(&self) -> Vec<(SessionId, Vec)> { self.writes.lock().unwrap().clone() } } #[async_trait] impl PtyPort for FakePty { async fn spawn(&self, spec: SpawnSpec, _size: PtySize) -> Result { 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 { Ok(Box::new(std::iter::empty())) } fn scrollback(&self, _handle: &PtyHandle) -> Result, PtyError> { Ok(Vec::new()) } async fn wait(&self, _handle: &PtyHandle) -> Result { Ok(ExitStatus { code: Some(0) }) } fn try_wait(&self, _handle: &PtyHandle) -> Result, PtyError> { Ok(Some(ExitStatus { code: Some(0) })) } async fn kill(&self, _handle: &PtyHandle) -> Result { Ok(ExitStatus { code: Some(0) }) } } // --------------------------------------------------------------------------- // FakeStructuredFactory (AgentSessionFactory) — records structured starts // --------------------------------------------------------------------------- #[derive(Clone)] struct FakeSession { id: SessionId, conversation_id: Option, } #[async_trait] impl AgentSession for FakeSession { fn id(&self) -> SessionId { self.id } fn conversation_id(&self) -> Option { self.conversation_id.clone() } async fn send(&self, _prompt: &str) -> Result { Ok(Box::new(std::iter::empty())) } async fn shutdown(&self) -> Result<(), AgentSessionError> { Ok(()) } } #[derive(Clone)] struct FakeStructuredFactory { trace: Trace, starts: Arc>>, next_session: SessionId, } impl FakeStructuredFactory { fn new(trace: Trace, next_session: SessionId) -> Self { Self { trace, starts: Arc::new(Mutex::new(Vec::new())), next_session, } } fn starts(&self) -> Vec { self.starts.lock().unwrap().clone() } } #[async_trait] impl AgentSessionFactory for FakeStructuredFactory { fn supports(&self, profile: &AgentProfile) -> bool { profile.structured_adapter.is_some() } async fn start( &self, profile: &AgentProfile, _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { self.trace .lock() .unwrap() .push("structured.start".to_owned()); self.starts.lock().unwrap().push(profile.id); Ok(Arc::new(FakeSession { id: self.next_session, conversation_id: Some("engine-session-1".to_owned()), })) } } // --------------------------------------------------------------------------- // SpyBus + SeqIds // --------------------------------------------------------------------------- #[derive(Default, Clone)] struct SpyBus(Arc>>); impl SpyBus { fn events(&self) -> Vec { 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); 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}", None, ) .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, Trace, Arc>>, ); /// Wires a LaunchAgent over fakes for a given injection strategy/plan. fn launch_fixture( injection: ContextInjection, plan: Option, ) -> LaunchFixture { launch_fixture_with_profile(profile(pid(9), injection), plan) } /// Like [`launch_fixture`] but takes a fully-built profile (so session-strategy /// variants can be exercised). The seeded agent uses the profile's id. fn launch_fixture_with_profile( profile: AgentProfile, plan: Option, ) -> LaunchFixture { launch_fixture_with_profile_and_recall(profile, plan, FakeRecall::default()) } /// Like [`launch_fixture_with_profile`] but also takes the [`MemoryRecall`] fake, /// so the memory-injection feature can be exercised. The default callers pass an /// empty [`FakeRecall`] ⇒ no memory section ⇒ behaviour unchanged. fn launch_fixture_with_profile_and_recall( profile: AgentProfile, plan: Option, recall: FakeRecall, ) -> LaunchFixture { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id); let contexts = FakeContexts::with_agent(&agent, "# ctx body"); let profiles = FakeProfiles::new(vec![profile]); let tr = trace(); let runtime = FakeRuntime::new(Arc::clone(&tr), plan); let session_probe = runtime.session_probe(); 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::new(FakeSkills::default()), Arc::clone(&sessions), Arc::new(bus.clone()), Arc::new(SeqIds::new()), Arc::new(recall), None, ); (launch, agent, fs, pty, bus, sessions, tr, session_probe) } fn launch_input(agent_id: AgentId) -> LaunchAgentInput { LaunchAgentInput { project: project(), agent_id, rows: 24, cols: 80, node_id: None, conversation_id: None, mcp_runtime: None, allow_structured_alongside_pty: false, } } #[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, _session) = 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 (lot LP3-3): prepare → injection (convention file) → spawn. // The permission projection no longer runs here — no projector registry is wired // in this fixture (`LaunchAgent::new` without `with_permission_projectors`), so no // extra `fs.write` precedes prepare. With a registry, the projection write would // appear *after* the injection write and before spawn (see `apply_permission_projection`). 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//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.context_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}" ); // Lot LP3-3: the Claude permission seed is no longer written unconditionally // here. It is now produced by the Claude permission projector and written only // when a projector registry is wired (`with_permission_projectors`) — absent in // this fixture — so no seed is written. (Projection coverage lives in the infra // projector tests (LP3-2) and the LP3-3 projection wiring tests, QA.) assert!( fs.seed_writes().is_empty(), "no projector registry wired ⇒ no permission seed written" ); // The run directory was created before spawn. assert_eq!(fs.created_run_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), }] ); } #[tokio::test] async fn structured_profile_with_factory_routes_to_structured_session_without_pty_spawn() { let profile = profile( pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap(), ) .with_structured_adapter(StructuredAdapter::Claude); let (launch, agent, _fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile( profile, Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888)); let factory_port: Arc = Arc::new(factory.clone()); let structured = Arc::new(StructuredSessions::new()); let launch = launch .with_structured_routing_mode(StructuredRoutingMode::RequireStructured) .with_structured(factory_port, Arc::clone(&structured)); let out = launch.execute(launch_input(agent.id)).await.unwrap(); assert_eq!(factory.starts(), vec![pid(9)]); assert!( pty.spawns().is_empty(), "structured route must not spawn PTY" ); assert!(out.structured.is_some()); assert!(structured.session(&sid(888)).is_some()); assert_eq!( *tr.lock().unwrap(), vec![ "prepare".to_owned(), "fs.write".to_owned(), "structured.start".to_owned() ] ); } #[tokio::test] async fn structured_profile_without_factory_require_structured_errors_without_pty_spawn() { let profile = profile( pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap(), ) .with_structured_adapter(StructuredAdapter::Claude); let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( profile, Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); let launch = launch.with_structured_routing_mode(StructuredRoutingMode::RequireStructured); let err = launch.execute(launch_input(agent.id)).await.unwrap_err(); assert!(err .to_string() .contains("structured profile requires structured session factory")); assert!( pty.spawns().is_empty(), "miswired structured route must not spawn PTY" ); } #[tokio::test] async fn structured_profile_without_factory_human_fallback_spawns_pty() { let profile = profile( pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap(), ) .with_structured_adapter(StructuredAdapter::Claude); let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( profile, Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); let launch = launch.with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback); let out = launch.execute(launch_input(agent.id)).await.unwrap(); assert!(out.structured.is_none()); assert_eq!(pty.spawns().len(), 1, "human fallback keeps native CLI PTY"); } #[tokio::test] async fn non_structured_profile_uses_pty_even_when_structured_is_required() { let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture( ContextInjection::convention_file("CLAUDE.md").unwrap(), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); let launch = launch.with_structured_routing_mode(StructuredRoutingMode::RequireStructured); let out = launch.execute(launch_input(agent.id)).await.unwrap(); assert!(out.structured.is_none()); assert_eq!(pty.spawns().len(), 1); } #[tokio::test] async fn launch_injects_shared_project_context_from_ideai_context_md() { let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture( ContextInjection::convention_file("CLAUDE.md").unwrap(), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); fs.set_project_context("# Shared project\n\nUse pnpm."); launch .execute(launch_input(agent.id)) .await .expect("launch"); let writes = fs.context_writes(); assert_eq!(writes.len(), 1); let doc = String::from_utf8(writes[0].1.clone()).unwrap(); assert!(doc.contains("# Contexte projet")); assert!(doc.contains("Use pnpm.")); let project_context_at = doc.find("# Contexte projet").unwrap(); let persona_at = doc.find("# ctx body").unwrap(); assert!( project_context_at < persona_at, "project context must precede the agent persona" ); } /// Inserts a live agent session into the registry, simulating an already-running /// agent pinned on `node`. Returns the session id. fn seed_live_agent_session( sessions: &TerminalSessions, agent_id: AgentId, node: domain::NodeId, session_id: SessionId, ) { let size = PtySize::new(24, 80).unwrap(); let mut session = domain::TerminalSession::starting( session_id, node, ProjectPath::new("/home/me/proj/.ideai/run/x").unwrap(), domain::SessionKind::Agent { agent_id }, size, ); session.status = domain::SessionStatus::Running; sessions.insert(PtyHandle { session_id }, session); } fn nid(n: u128) -> domain::NodeId { domain::NodeId::from_uuid(Uuid::from_u128(n)) } /// **Singleton invariant (R0a, décision « 1 agent = 1 employé »)**: a *fresh* /// launch (no `conversation_id`) targeting **another** cell of an agent already /// live in cell A is a genuine second launch ⇒ it is **refused** with /// `AppError::AgentAlreadyRunning { node_id: A, agent_id }`. The session is NOT /// silently moved, the PTY is never spawned, and the registry is unchanged. #[tokio::test] async fn launch_new_in_other_cell_refuses_when_agent_live_elsewhere() { let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture( ContextInjection::convention_file("CLAUDE.md").unwrap(), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); // The agent is already live in cell A. let host = nid(1); seed_live_agent_session(&sessions, agent.id, host, sid(42)); let before = sessions.len(); // Fresh launch (conversation_id: None) of the same running agent in a // *different* cell B ⇒ must be refused, not silently rebound. let mut input = launch_input(agent.id); let target = nid(2); input.node_id = Some(target); let err = launch .execute(input) .await .expect_err("fresh second launch elsewhere is refused"); match err { application::AppError::AgentAlreadyRunning { agent_id, node_id } => { assert_eq!(agent_id, agent.id, "reports the live agent"); assert_eq!(node_id, host, "reports the live HOST node A, not target B"); } other => panic!("expected AgentAlreadyRunning, got {other:?}"), } // No silent move, no respawn, registry untouched. assert_eq!( sessions.node_for_agent(&agent.id), Some(host), "session stays pinned on its host node" ); assert_eq!(sessions.len(), before, "registry size is unchanged"); assert!(pty.spawns().is_empty(), "no PTY spawn on a refused launch"); } /// A launch with an unspecified node (`node_id: None`) for an already-live agent /// is a background/idempotent no-op: return the existing session without /// respawning or changing its current host. #[tokio::test] async fn launch_existing_agent_with_no_node_is_background_noop() { let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture( ContextInjection::convention_file("CLAUDE.md").unwrap(), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); seed_live_agent_session(&sessions, agent.id, nid(1), sid(42)); // node_id defaults to None in launch_input. let out = launch .execute(launch_input(agent.id)) .await .expect("background relaunch is idempotent"); assert_eq!(out.session.id, sid(42)); assert_eq!(out.session.node_id, nid(1)); assert!(pty.spawns().is_empty()); } /// Idempotence on the SAME node: a double-launch of the very same cell returns /// the already-registered session without respawning, and assigns no new /// conversation id. #[tokio::test] async fn launch_same_node_is_idempotent_no_respawn() { let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture( ContextInjection::convention_file("CLAUDE.md").unwrap(), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); let host = nid(7); seed_live_agent_session(&sessions, agent.id, host, sid(42)); let before = sessions.len(); let mut input = launch_input(agent.id); input.node_id = Some(host); let out = launch.execute(input).await.expect("idempotent launch"); assert_eq!(out.session.id, sid(42), "returns the existing session"); assert!( out.assigned_conversation_id.is_none(), "nothing new assigned" ); assert_eq!(sessions.len(), before, "no new session registered"); assert!(pty.spawns().is_empty(), "no respawn on same-node relaunch"); } /// **Legitimate explicit reattach (R0a)**: launching an agent that is live in /// cell A onto a *different* cell B but carrying a `conversation_id` is an /// explicit view reattach (the cell knows the agent was running) ⇒ rebind, no /// error, no respawn, same session id. #[tokio::test] async fn launch_other_cell_with_conversation_id_rebinds_no_respawn() { let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture( ContextInjection::convention_file("CLAUDE.md").unwrap(), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); let host = nid(1); seed_live_agent_session(&sessions, agent.id, host, sid(42)); let before = sessions.len(); // Different cell B, but an explicit reattach signal (conversation_id present). let mut input = launch_input(agent.id); let target = nid(2); input.node_id = Some(target); input.conversation_id = Some("conv-live".to_owned()); let out = launch .execute(input) .await .expect("explicit reattach succeeds"); assert_eq!(out.session.id, sid(42), "returns the existing session"); assert_eq!(out.session.node_id, target, "view rebound to target cell"); assert_eq!(sessions.node_for_agent(&agent.id), Some(target)); assert_eq!(sessions.len(), before, "registry size is unchanged"); assert!(pty.spawns().is_empty(), "no PTY spawn on explicit reattach"); } /// After the live session is removed (cell closed / agent exited), a fresh launch /// succeeds — the guard only blocks while the agent is actually live. #[tokio::test] async fn launch_succeeds_after_session_removed() { let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture( ContextInjection::convention_file("CLAUDE.md").unwrap(), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); // Live, then removed (close/exit). seed_live_agent_session(&sessions, agent.id, nid(1), sid(42)); sessions.remove(&sid(42)); assert!(sessions.session_for_agent(&agent.id).is_none()); let mut input = launch_input(agent.id); input.node_id = Some(nid(2)); let out = launch.execute(input).await.expect("relaunch must succeed"); assert_eq!(out.session.id, sid(777), "spawned a fresh PTY session"); assert_eq!( pty.spawns().len(), 1, "exactly one spawn after the agent died" ); } /// **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//` — 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::new(FakeSkills::default()), Arc::clone(&sessions), Arc::new(SpyBus::default()), Arc::new(SeqIds::new()), Arc::new(FakeRecall::default()), None, ); 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 (ignoring each seed's `.claude` subdir). assert_eq!(fs.created_run_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.context_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_conventionfile_injects_assigned_skills_in_order() { // An agent with two assigned global skills; the generated convention file must // carry both skill bodies, after the persona, in assignment order (§14.2). let skill_id = |n: u128| SkillId::from_uuid(Uuid::from_u128(n)); let skill = |n: u128, name: &str, body: &str| { Skill::new( skill_id(n), name, MarkdownDoc::new(body), SkillScope::Global, ) .unwrap() }; let mut agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); agent.assign_skill(SkillRef::new(skill_id(1), SkillScope::Global)); agent.assign_skill(SkillRef::new(skill_id(2), SkillScope::Global)); let contexts = FakeContexts::with_agent(&agent, "# persona"); let profiles = FakeProfiles::new(vec![profile( pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap(), )]); let tr = trace(); let fs = FakeFs::new(Arc::clone(&tr)); let pty = FakePty::new(Arc::clone(&tr), sid(777)); let skills = FakeSkills::with(vec![ skill(1, "refactor", "REFAC_BODY"), skill(2, "review", "REVIEW_BODY"), ]); let launch = LaunchAgent::new( Arc::new(contexts), Arc::new(profiles), Arc::new(FakeRuntime::new( Arc::clone(&tr), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), )), Arc::new(fs.clone()), Arc::new(pty.clone()), Arc::new(skills), Arc::new(TerminalSessions::new()), Arc::new(SpyBus::default()), Arc::new(SeqIds::new()), Arc::new(FakeRecall::default()), None, ); launch.execute(launch_input(agent.id)).await.unwrap(); let writes = fs.context_writes(); assert_eq!(writes.len(), 1); let doc = String::from_utf8(writes[0].1.clone()).unwrap(); assert!(doc.contains("# persona"), "persona present: {doc}"); assert!(doc.contains("REFAC_BODY"), "first skill body present"); assert!(doc.contains("REVIEW_BODY"), "second skill body present"); // Deterministic order: persona before skills, skill 1 before skill 2. let persona_at = doc.find("# persona").unwrap(); let refac_at = doc.find("REFAC_BODY").unwrap(); let review_at = doc.find("REVIEW_BODY").unwrap(); assert!( persona_at < refac_at && refac_at < review_at, "ordering: {doc}" ); } #[tokio::test] async fn launch_conventionfile_injects_project_memory_in_order() { // A non-empty recall ⇒ the generated convention file must carry a // `# Mémoire projet` section with one line per entry, in the recalled order // and the exact `- [Title](slug.md) — hook (type)` format (§14.5.4). let recall = FakeRecall::returning(vec![ mem_entry( "git-optional", "Git optionnel", "git reste un simple tool", MemoryType::Project, ), mem_entry( "perm-archi", "Permissions", "sandbox OS + résumé injecté", MemoryType::Reference, ), ]); let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile_and_recall( profile( pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap(), ), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), recall, ); launch.execute(launch_input(agent.id)).await.unwrap(); let writes = fs.context_writes(); assert_eq!(writes.len(), 1); let doc = String::from_utf8(writes[0].1.clone()).unwrap(); assert!( doc.contains("# Mémoire projet"), "memory section present: {doc}" ); assert!( doc.contains( "- [Git optionnel](.ideai/memory/git-optional.md) — git reste un simple tool (project)" ), "first memory line exact format: {doc}" ); assert!( doc.contains( "- [Permissions](.ideai/memory/perm-archi.md) — sandbox OS + résumé injecté (reference)" ), "second memory line exact format: {doc}" ); // Recalled order preserved; section after the persona. let persona_at = doc.find("# ctx body").unwrap(); let section_at = doc.find("# Mémoire projet").unwrap(); let first_at = doc.find("[Git optionnel]").unwrap(); let second_at = doc.find("[Permissions]").unwrap(); assert!(persona_at < section_at, "memory after persona: {doc}"); assert!( first_at < second_at, "entries kept in recalled order: {doc}" ); } #[tokio::test] async fn launch_conventionfile_without_memory_omits_section() { // The default empty recall ⇒ no `# Mémoire projet` section (behaviour unchanged). let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture( ContextInjection::convention_file("CLAUDE.md").unwrap(), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); launch.execute(launch_input(agent.id)).await.unwrap(); let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap(); assert!( !doc.contains("# Mémoire projet"), "no memory ⇒ no section: {doc}" ); } #[tokio::test] async fn launch_recall_error_degrades_to_no_section_without_failing() { // A failing recall must NOT fail the launch (best-effort, §14.5.4): the launch // succeeds and the convention file simply carries no memory section. let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile_and_recall( profile( pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap(), ), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), FakeRecall::failing(), ); // Launch still succeeds despite the recall error. launch .execute(launch_input(agent.id)) .await .expect("launch must succeed"); let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap(); assert!( !doc.contains("# Mémoire projet"), "recall error ⇒ no section, launch unaffected: {doc}" ); } #[tokio::test] async fn launch_env_strategy_injects_no_memory_section() { // For the `env` strategy IdeA writes no convention file, so no memory section is // injected — aligned with how skills are only injected for `conventionFile`. let recall = FakeRecall::returning(vec![mem_entry("note", "Note", "a hook", MemoryType::User)]); let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile_and_recall( profile(pid(9), ContextInjection::env("IDEA_CONTEXT").unwrap()), Some(ContextInjectionPlan::Env { var: "IDEA_CONTEXT".to_owned(), }), recall, ); launch.execute(launch_input(agent.id)).await.unwrap(); // The env strategy writes no convention file at all (only the run-dir seed). assert!( fs.context_writes().is_empty(), "env strategy must not write a convention file" ); } #[tokio::test] async fn launch_skips_dangling_skill_ref_without_failing() { // The agent references a skill that no longer exists in the store: launch must // still succeed and simply omit it (no Skills section for a sole dangling ref). let mut agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); agent.assign_skill(SkillRef::new( SkillId::from_uuid(Uuid::from_u128(99)), SkillScope::Global, )); let contexts = FakeContexts::with_agent(&agent, "# persona"); let profiles = FakeProfiles::new(vec![profile( pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap(), )]); let tr = trace(); let fs = FakeFs::new(Arc::clone(&tr)); 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::File { target: "CLAUDE.md".to_owned(), }), )), Arc::new(fs.clone()), Arc::new(pty.clone()), Arc::new(FakeSkills::default()), // empty store ⇒ the ref is dangling Arc::new(TerminalSessions::new()), Arc::new(SpyBus::default()), Arc::new(SeqIds::new()), Arc::new(FakeRecall::default()), None, ); launch .execute(launch_input(agent.id)) .await .expect("launch must succeed"); let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap(); assert!( !doc.contains("# Skills"), "no Skills section for a dangling ref: {doc}" ); } #[tokio::test] async fn launch_non_claude_convention_does_not_seed_permissions() { // A CLI whose convention file is NOT CLAUDE.md (e.g. Codex → AGENTS.md) must // get its convention file but NO Claude permission seed (Bug #5 is per-CLI). let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture( ContextInjection::convention_file("AGENTS.md").unwrap(), Some(ContextInjectionPlan::File { target: "AGENTS.md".to_owned(), }), ); launch.execute(launch_input(agent.id)).await.unwrap(); // The convention file was written, but no permission seed. assert_eq!(fs.context_writes().len(), 1); assert!( fs.seed_writes().is_empty(), "no Claude seed for a non-Claude CLI" ); assert!( !fs.created_dirs().iter().any(|p| p.ends_with("/.claude")), "no .claude dir created for a non-Claude CLI" ); } #[tokio::test] async fn launch_stdin_strategy_pipes_context_after_spawn() { let (launch, agent, fs, pty, _bus, _sessions, tr, _session) = 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, _session) = 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(FakeSkills::default()), Arc::new(TerminalSessions::new()), Arc::new(SpyBus::default()), Arc::new(SeqIds::new()), Arc::new(FakeRecall::default()), None, ); 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"); } // --------------------------------------------------------------------------- // LaunchAgent — MCP config injection (M1, cadrage v3 Décision 3) // --------------------------------------------------------------------------- /// Builds a CLAUDE.md-convention profile carrying an [`McpCapability`], so the /// MCP injection path (`apply_mcp_config`) is exercised during a launch. fn profile_with_mcp(id: ProfileId, config: McpConfigStrategy) -> AgentProfile { profile(id, ContextInjection::convention_file("CLAUDE.md").unwrap()) .with_mcp(McpCapability::new(config, McpTransport::Stdio)) } fn opencode_profile(id: ProfileId) -> AgentProfile { profile(id, ContextInjection::convention_file("AGENTS.md").unwrap()) .with_structured_adapter(StructuredAdapter::OpenCode) .with_opencode( OpenCodeConfig::new( "http://localhost:8080/v1", Some("sk-no-key".to_owned()), "qwen3-coder-30b", None, None, ) .unwrap(), ) .with_mcp(McpCapability::new( McpConfigStrategy::open_code_config("opencode.json").unwrap(), McpTransport::Stdio, )) } /// Returns the writes whose path ends with the given suffix (e.g. `/.mcp.json`). fn writes_ending_with(fs: &FakeFs, suffix: &str) -> Vec<(String, Vec)> { fs.writes() .into_iter() .filter(|(p, _)| p.ends_with(suffix)) .collect() } #[tokio::test] async fn launch_without_mcp_writes_no_mcp_config_and_leaves_spec_untouched() { // Test 1 — profile.mcp = None ⇒ no MCP file written, spec.args/env unchanged. // The profile has empty args and the runtime adds no env, so a clean launch // must leave the spawned spec's args/env exactly empty (no MCP flag/env). let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) = launch_fixture( ContextInjection::convention_file("CLAUDE.md").unwrap(), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); launch.execute(launch_input(agent.id)).await.unwrap(); // No MCP-style config file anywhere. assert!( writes_ending_with(&fs, "/.mcp.json").is_empty(), "no MCP config file when profile.mcp is None" ); // Exactly the convention file write (+ the Claude seed), nothing MCP-related. assert_eq!( fs.context_writes().len(), 1, "only the convention file is written" ); // Spec args/env carry nothing MCP-related (here: empty). let spawns = pty.spawns(); assert_eq!(spawns.len(), 1); assert!(spawns[0].args.is_empty(), "no MCP flag pushed to args"); assert!(spawns[0].env.is_empty(), "no MCP var pushed to env"); } #[tokio::test] async fn launch_mcp_config_file_writes_declaration_at_run_dir_target() { // Test 2 — ConfigFile { target: ".mcp.json" } ⇒ a file is written at // /.mcp.json with a non-empty, coherent MCP server declaration. let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); launch.execute(launch_input(agent.id)).await.unwrap(); let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); let mcp = writes_ending_with(&fs, "/.mcp.json"); assert_eq!(mcp.len(), 1, "exactly one MCP config file written"); assert_eq!( mcp[0].0, format!("{run_dir}/.mcp.json"), "written at run dir" ); let body = String::from_utf8(mcp[0].1.clone()).unwrap(); assert!(!body.trim().is_empty(), "MCP declaration is non-empty"); // Coherent MCP server declaration (an `mcpServers`/`idea` server entry). assert!( body.contains("mcpServers") && body.contains("idea"), "MCP declaration must declare the IdeA MCP server, got: {body}" ); // It is valid JSON. let _: serde_json::Value = serde_json::from_str(&body).expect("MCP declaration is valid JSON"); } #[tokio::test] async fn launch_mcp_config_file_is_non_clobbering() { // Test 3 — if /.mcp.json already exists, the launch must NOT overwrite // it: no write is recorded against that path. let profile = profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()); let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( profile, Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); // Pre-existing MCP config file on disk. let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); fs.mark_existing(&format!("{run_dir}/.mcp.json")); launch.execute(launch_input(agent.id)).await.unwrap(); assert!( writes_ending_with(&fs, "/.mcp.json").is_empty(), "an existing MCP config file must not be clobbered" ); } #[tokio::test] async fn launch_mcp_config_file_write_failure_is_best_effort() { // Test 4 — a write failure on the MCP config file must NOT fail the launch. let profile = profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()); let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( profile, Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); // Force the MCP config write to fail. fs.fail_write_suffix("/.mcp.json"); // The launch still succeeds and spawns the CLI. let out = launch .execute(launch_input(agent.id)) .await .expect("MCP write failure must not fail the launch"); assert!(out.structured.is_none()); assert_eq!(pty.spawns().len(), 1, "the CLI is still spawned"); } #[tokio::test] async fn launch_opencode_writes_isolated_config_and_env() { let profile = opencode_profile(pid(9)); let (launch, agent, fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( profile, Some(ContextInjectionPlan::File { target: "AGENTS.md".to_owned(), }), ); launch.execute(launch_input(agent.id)).await.unwrap(); let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); let opencode = writes_ending_with(&fs, "/opencode.json"); assert_eq!(opencode.len(), 1, "one OpenCode config is written"); assert_eq!(opencode[0].0, format!("{run_dir}/opencode.json")); let body: serde_json::Value = serde_json::from_slice(&opencode[0].1).unwrap(); assert_eq!(body["model"], "llamacpp/qwen3-coder-30b"); assert_eq!( body["provider"]["llamacpp"]["options"]["baseURL"], "http://localhost:8080/v1" ); assert_eq!( body["provider"]["llamacpp"]["options"]["apiKey"], "sk-no-key" ); assert_eq!( body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["name"], "llamacpp/qwen3-coder-30b" ); assert_eq!( body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["tool_call"], true ); assert_eq!( body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["reasoning"], true ); assert_eq!( body["provider"]["llamacpp"]["models"]["qwen3-coder-30b"]["attachment"], false ); assert_eq!( body["disabled_providers"], serde_json::json!(["anthropic", "openai", "gemini", "ollama"]) ); assert_eq!(body["mcp"]["idea"]["type"], "local"); assert_eq!(body["mcp"]["idea"]["command"][0], "idea"); let spawn = pty .spawns() .pop() .expect("spawned via PTY fallback in this fixture"); let env: std::collections::HashMap<_, _> = spawn.env.into_iter().collect(); assert_eq!( env.get("OPENCODE_CONFIG").map(String::as_str), Some(format!("{run_dir}/opencode.json").as_str()) ); assert_eq!( env.get("HOME").map(String::as_str), Some(format!("{run_dir}/.opencode").as_str()) ); assert_eq!( env.get("XDG_CONFIG_HOME").map(String::as_str), Some(format!("{run_dir}/.opencode/config").as_str()) ); assert_eq!( env.get("XDG_DATA_HOME").map(String::as_str), Some(format!("{run_dir}/.opencode/data").as_str()) ); assert_eq!( env.get("XDG_CACHE_HOME").map(String::as_str), Some(format!("{run_dir}/.opencode/cache").as_str()) ); assert_eq!( env.get("OPENCODE_DISABLE_AUTOUPDATE").map(String::as_str), Some("1") ); } #[tokio::test] async fn launch_mcp_flag_appends_flag_and_path_to_args() { // Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed // by the run-dir config path. let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( profile_with_mcp(pid(9), McpConfigStrategy::flag("--mcp-config").unwrap()), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); launch.execute(launch_input(agent.id)).await.unwrap(); let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); let spawns = pty.spawns(); assert_eq!(spawns.len(), 1); let args = &spawns[0].args; let pos = args .iter() .position(|a| a == "--mcp-config") .expect("the MCP flag must be present in args"); assert_eq!( args.get(pos + 1), Some(&run_dir), "the MCP flag is followed by the run-dir config path" ); } #[tokio::test] async fn launch_mcp_env_appends_var_and_path_to_env() { // Test 6 — Env { var: "IDEA_MCP" } ⇒ spec.env carries (IDEA_MCP, ). let (launch, agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( profile_with_mcp(pid(9), McpConfigStrategy::env("IDEA_MCP").unwrap()), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); launch.execute(launch_input(agent.id)).await.unwrap(); let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); let spawns = pty.spawns(); assert_eq!(spawns.len(), 1); assert!( spawns[0] .env .iter() .any(|(k, v)| k == "IDEA_MCP" && v == &run_dir), "spec.env must carry (IDEA_MCP, run_dir), got: {:?}", spawns[0].env ); } // --------------------------------------------------------------------------- // LaunchAgent — MCP runtime declaration (M5d, cadrage v5 §2) // --------------------------------------------------------------------------- /// A `LaunchAgentInput` carrying an injected [`McpRuntime`], otherwise identical /// to [`launch_input`]. Lets the M5d tests drive the real declaration path while /// reusing the M1 harness (`launch_fixture_with_profile`, `FakeFs` write trace). fn launch_input_with_runtime( agent_id: AgentId, runtime: application::McpRuntime, ) -> LaunchAgentInput { LaunchAgentInput { mcp_runtime: Some(runtime), ..launch_input(agent_id) } } /// Reads the single `.mcp.json` written by a launch, parsing it as JSON. Panics /// with a clear message if zero or several were written, so an unexpected write /// shape is surfaced rather than silently picked. fn read_single_mcp_json(fs: &FakeFs) -> serde_json::Value { let mcp = writes_ending_with(fs, "/.mcp.json"); assert_eq!(mcp.len(), 1, "exactly one .mcp.json must be written"); let body = String::from_utf8(mcp[0].1.clone()).unwrap(); serde_json::from_str(&body) .unwrap_or_else(|e| panic!("written .mcp.json must be valid JSON ({e}): {body}")) } #[tokio::test] async fn launch_mcp_runtime_writes_real_declaration_with_ordered_args() { // M5d-1 — ConfigFile{".mcp.json"} + an injected McpRuntime ⇒ the written // declaration is the REAL one: command == exe, and args == the ordered // ["mcp-server","--endpoint",endpoint,"--project",project_id,"--requester", // requester]. Structure-asserted via parsed JSON (not a fragile string match). let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); let exe = "/abs/idea"; let endpoint = "/run/idea-mcp/abc.sock"; // project_id in the hyphen-free 32-hex `simple` form consumed by the M5c guard // (`as_uuid().simple()` — see the coherence note / M5e smoke e2e below). let project_id = "0123456789abcdef0123456789abcdef"; let requester = "agent-7"; let runtime = application::McpRuntime { exe: exe.to_owned(), endpoint: endpoint.to_owned(), project_id: project_id.to_owned(), requester: requester.to_owned(), }; launch .execute(launch_input_with_runtime(agent.id, runtime)) .await .unwrap(); let doc = read_single_mcp_json(&fs); let server = &doc["mcpServers"]["idea"]; assert_eq!( server["command"].as_str(), Some(exe), "command must be the injected exe, got: {doc}" ); let args: Vec<&str> = server["args"] .as_array() .expect("args must be a JSON array") .iter() .map(|v| v.as_str().expect("each arg is a JSON string")) .collect(); assert_eq!( args, vec![ "mcp-server", "--endpoint", endpoint, "--project", project_id, "--requester", requester, ], "args must carry the ordered mcp-server flags, got: {args:?}" ); } #[tokio::test] async fn launch_mcp_runtime_special_chars_stay_valid_json() { // M5d-2 — an exe/endpoint with a space and a backslash ⇒ the .mcp.json stays // valid JSON (parse OK, asserted by read_single_mcp_json) and the values are // faithfully preserved (correct escaping, no corruption). let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); let exe = r"C:\Program Files\IdeA\idea.exe"; let endpoint = r"/tmp/idea mcp/a b\c.sock"; let runtime = application::McpRuntime { exe: exe.to_owned(), endpoint: endpoint.to_owned(), project_id: "0123456789abcdef0123456789abcdef".to_owned(), requester: "agent X".to_owned(), }; launch .execute(launch_input_with_runtime(agent.id, runtime)) .await .unwrap(); // Parsing already proves the JSON is valid despite the special chars. let doc = read_single_mcp_json(&fs); let server = &doc["mcpServers"]["idea"]; assert_eq!( server["command"].as_str(), Some(exe), "exe with backslashes/spaces must round-trip faithfully" ); let args: Vec<&str> = server["args"] .as_array() .unwrap() .iter() .map(|v| v.as_str().unwrap()) .collect(); assert_eq!( args.get(2).copied(), Some(endpoint), "endpoint with a space and a backslash must round-trip faithfully, got: {args:?}" ); } #[tokio::test] async fn launch_mcp_runtime_none_writes_minimal_declaration() { // M5d-3 — mcp_runtime = None + an MCP profile ⇒ the minimal declaration is // written: command == "idea", args == ["mcp-server"]. Still valid JSON. let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); // launch_input has mcp_runtime: None. launch.execute(launch_input(agent.id)).await.unwrap(); let doc = read_single_mcp_json(&fs); let server = &doc["mcpServers"]["idea"]; assert_eq!( server["command"].as_str(), Some("idea"), "the minimal declaration must use the `idea` command, got: {doc}" ); let args: Vec<&str> = server["args"] .as_array() .expect("args must be a JSON array") .iter() .map(|v| v.as_str().unwrap()) .collect(); assert_eq!( args, vec!["mcp-server"], "the minimal declaration must carry only the mcp-server subcommand, got: {args:?}" ); } #[tokio::test] async fn launch_mcp_runtime_with_no_mcp_profile_writes_nothing() { // M5d-4 — a profile WITHOUT MCP ⇒ no .mcp.json is written, even though a // runtime is supplied (unchanged from M1: mcp == None short-circuits). let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture( ContextInjection::convention_file("CLAUDE.md").unwrap(), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); let runtime = application::McpRuntime { exe: "/abs/idea".to_owned(), endpoint: "/run/idea-mcp/abc.sock".to_owned(), project_id: "0123456789abcdef0123456789abcdef".to_owned(), requester: "agent-7".to_owned(), }; launch .execute(launch_input_with_runtime(agent.id, runtime)) .await .unwrap(); assert!( writes_ending_with(&fs, "/.mcp.json").is_empty(), "no MCP config file when the profile carries no McpCapability" ); } #[tokio::test] async fn launch_mcp_runtime_clobbers_stale_config_with_fresh_declaration() { // M5d-5 — with a real injected runtime, a pre-existing .mcp.json MUST be // regenerated and CLOBBERED on every (re)launch: its `command` (the IdeA exe // path) and endpoint drift between runs (the AppImage internal mount path // changes at each remount/reboot), so a stale declaration would point the bridge // at a dead binary/socket. `.mcp.json` is IdeA-managed, so clobbering is safe. let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); // A stale file already on disk (e.g. written by a previous run pointing at a // now-dead AppImage mount). fs.mark_existing(&format!("{run_dir}/.mcp.json")); let exe = "/abs/idea"; let endpoint = "/run/idea-mcp/abc.sock"; let runtime = application::McpRuntime { exe: exe.to_owned(), endpoint: endpoint.to_owned(), project_id: "0123456789abcdef0123456789abcdef".to_owned(), requester: "agent-7".to_owned(), }; launch .execute(launch_input_with_runtime(agent.id, runtime)) .await .unwrap(); // The fresh declaration is written despite the pre-existing file, and it carries // the current exe path / endpoint. let doc = read_single_mcp_json(&fs); let server = &doc["mcpServers"]["idea"]; assert_eq!( server["command"].as_str(), Some(exe), "a stale .mcp.json must be clobbered with the fresh exe path, got: {doc}" ); let args: Vec<&str> = server["args"] .as_array() .expect("args must be a JSON array") .iter() .map(|v| v.as_str().unwrap()) .collect(); assert_eq!( args.get(2).copied(), Some(endpoint), "the clobbered declaration must carry the fresh endpoint, got: {args:?}" ); } #[tokio::test] async fn launch_mcp_runtime_none_is_non_clobbering() { // M5d-5b — WITHOUT a runtime (orchestrator / hot-swap / tests) only the degraded // minimal declaration is available, so a pre-existing .mcp.json must NOT be // overwritten: we never replace a real declaration with the minimal one. let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); fs.mark_existing(&format!("{run_dir}/.mcp.json")); // launch_input has mcp_runtime: None. launch.execute(launch_input(agent.id)).await.unwrap(); assert!( writes_ending_with(&fs, "/.mcp.json").is_empty(), "without a runtime, an existing .mcp.json must not be clobbered with the minimal decl" ); } #[tokio::test] async fn launch_mcp_runtime_remount_clobbers_with_run_specific_facts() { // M5d-5c (QA) — direct reproduction of the AppImage-remount bug: across two // independent app sessions (= two reboots, each with a *different* mount path // and a *different* loopback socket), each launch must clobber a pre-existing // stale `.mcp.json` with the facts of THAT run. This proves the file is rebuilt // per-launch from the live runtime — never frozen on a previous run's dead // binary/socket. Two fixtures are used on purpose: relaunching the *same* live // agent would short-circuit through the reattach guard (no respawn), so it // would not exercise a second `apply_mcp_config`. let launch_once = |exe: &'static str, endpoint: &'static str| async move { let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( profile_with_mcp(pid(9), McpConfigStrategy::config_file(".mcp.json").unwrap()), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), ); let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); // A stale declaration left by the previous boot is already on disk. fs.mark_existing(&format!("{run_dir}/.mcp.json")); let runtime = application::McpRuntime { exe: exe.to_owned(), endpoint: endpoint.to_owned(), project_id: "0123456789abcdef0123456789abcdef".to_owned(), requester: "agent-7".to_owned(), }; launch .execute(launch_input_with_runtime(agent.id, runtime)) .await .unwrap(); let doc = read_single_mcp_json(&fs); let server = &doc["mcpServers"]["idea"]; let command = server["command"].as_str().unwrap().to_owned(); let args: Vec = server["args"] .as_array() .expect("args must be a JSON array") .iter() .map(|v| v.as_str().unwrap().to_owned()) .collect(); (command, args) }; // Boot #1: mount path A + socket A. let exe_a = "/tmp/.mount_IdeA_AAA/idea"; let endpoint_a = "/run/idea-mcp/boot-a.sock"; let (command_a, args_a) = launch_once(exe_a, endpoint_a).await; assert_eq!(command_a, exe_a, "boot #1 must carry mount path A"); assert_eq!(args_a.get(2).map(String::as_str), Some(endpoint_a)); // Boot #2: a *new* mount path + a *new* socket (the very drift that caused the // bug). The declaration must follow the live facts, not the previous boot's. let exe_b = "/tmp/.mount_IdeA_BBB/idea"; let endpoint_b = "/run/idea-mcp/boot-b.sock"; let (command_b, args_b) = launch_once(exe_b, endpoint_b).await; assert_eq!(command_b, exe_b, "boot #2 must carry the NEW mount path"); assert_eq!(args_b.get(2).map(String::as_str), Some(endpoint_b)); // And nothing from the stale boot #1 leaks into the boot #2 declaration. assert_ne!( command_b, exe_a, "boot #2 must not reuse boot #1's dead exe" ); assert!( !args_b.iter().any(|a| a == endpoint_a), "boot #2 must not reuse boot #1's dead endpoint, got: {args_b:?}" ); } #[test] fn mcp_runtime_project_id_uses_simple_uuid_form() { // M5d-6 (coherence, pure) — app-tauri builds the McpRuntime.project_id as // `project.id.as_uuid().simple().to_string()` (commands.rs::launch_agent), // i.e. the hyphen-free 32-hex `simple` form the M5c handshake guard // (`serve_peer`) compares against. The McpRuntime construction is inlined in // the Tauri command (not an extractable pure fn), so we cannot unit-test the // wiring here without an AppState. Instead we PIN the format contract the M5d // tests above rely on, and flag that the end-to-end injection coherence // (--endpoint == mcp_endpoint(..).as_cli_arg() and --project == this form) is // exercised by the M5e smoke e2e. let uuid = Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef); let simple = uuid.as_simple().to_string(); assert_eq!(simple, "0123456789abcdef0123456789abcdef"); assert!( !simple.contains('-') && simple.len() == 32, "the `simple` form is hyphen-free 32-hex, matching the project_id used in the M5d tests" ); } // --------------------------------------------------------------------------- // LaunchAgent — session resume (T4) // --------------------------------------------------------------------------- /// Builds a stdin profile carrying a [`SessionStrategy`] (assign + resume flags). fn profile_with_session( id: ProfileId, assign_flag: Option<&str>, resume_flag: &str, ) -> AgentProfile { let session = SessionStrategy::new(assign_flag.map(str::to_owned), resume_flag.to_owned()).unwrap(); AgentProfile::new( id, "Claude Code", "claude", Vec::new(), ContextInjection::stdin(), Some("claude --version".to_owned()), "{agentRunDir}", Some(session), ) .unwrap() } #[tokio::test] async fn launch_first_time_with_assign_flag_mints_and_exposes_conversation_id() { // Fresh cell (conversation_id = None), profile WITH an assign_flag. let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) = launch_fixture_with_profile( profile_with_session(pid(9), Some("--session-id"), "--resume"), Some(ContextInjectionPlan::Stdin), ); let out = launch .execute(launch_input(agent.id)) .await .expect("launch"); // SeqIds yields Uuid::from_u128(1) on its first call. let expected = Uuid::from_u128(1).to_string(); // The use case exposes the assigned id (caller persists it on the leaf). assert_eq!( out.assigned_conversation_id.as_deref(), Some(expected.as_str()) ); // The runtime received an Assign plan carrying exactly that id. assert_eq!( *session.lock().unwrap(), Some(SessionPlan::Assign { conversation_id: expected }), ); } #[tokio::test] async fn launch_reopen_with_existing_conversation_id_resumes_without_new_id() { // The cell already carries a conversation id ⇒ Resume, no new id minted. let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) = launch_fixture_with_profile( profile_with_session(pid(9), Some("--session-id"), "--resume"), Some(ContextInjectionPlan::Stdin), ); let mut input = launch_input(agent.id); input.conversation_id = Some("conv-existing".to_owned()); let out = launch.execute(input).await.expect("launch"); // Nothing newly assigned (the id pre-existed): caller has nothing to persist. assert_eq!(out.assigned_conversation_id, None); // Resume plan with the existing id; SeqIds was never consulted. assert_eq!( *session.lock().unwrap(), Some(SessionPlan::Resume { conversation_id: "conv-existing".to_owned() }), ); } #[tokio::test] async fn launch_profile_without_session_block_passes_none() { // Non-regression: a profile with no session block behaves exactly as before. let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) = launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin)); let out = launch .execute(launch_input(agent.id)) .await .expect("launch"); assert_eq!(out.assigned_conversation_id, None); assert_eq!(*session.lock().unwrap(), Some(SessionPlan::None)); } #[tokio::test] async fn launch_degraded_mode_without_assign_flag_first_launch_is_none() { // Profile HAS a session block but NO assign_flag (degraded): a first launch // (no cell id) must NOT mint an id and must pass SessionPlan::None. let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) = launch_fixture_with_profile( profile_with_session(pid(9), None, "--continue"), Some(ContextInjectionPlan::Stdin), ); let out = launch .execute(launch_input(agent.id)) .await .expect("launch"); assert_eq!( out.assigned_conversation_id, None, "degraded mode mints no id" ); assert_eq!(*session.lock().unwrap(), Some(SessionPlan::None)); } // --------------------------------------------------------------------------- // LaunchAgent — handoff resume injection (lot P7) // --------------------------------------------------------------------------- use application::HandoffProvider; use domain::{ConversationId, ConversationParty, Handoff, HandoffStore, TurnId}; /// In-memory [`HandoffStore`] for the P7 launch tests: holds at most one handoff /// per [`ConversationId`]. `load` returns `Ok(None)` for an unknown conversation /// (the store-without-handoff case), so the best-effort `resolve_handoff` degrades /// to "no section". #[derive(Clone, Default)] struct FakeHandoffStore(Arc>>); impl FakeHandoffStore { fn with(conv: ConversationId, handoff: Handoff) -> Self { let me = Self::default(); me.0.lock().unwrap().insert(conv, handoff); me } } #[async_trait] impl HandoffStore for FakeHandoffStore { async fn load(&self, conversation: ConversationId) -> Result, StoreError> { Ok(self.0.lock().unwrap().get(&conversation).cloned()) } async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> { self.0.lock().unwrap().insert(conversation, handoff); Ok(()) } } /// [`HandoffProvider`] that hands back the same store for any root (the launch /// tests use a single project root). #[derive(Clone)] struct FakeHandoffProvider(Arc); impl HandoffProvider for FakeHandoffProvider { fn handoff_store_for(&self, _root: &ProjectPath) -> Option> { Some(Arc::clone(&self.0)) } } /// Builds a `conventionFile` LaunchAgent wired with an **optional** handoff /// provider, returning the launcher and the recording fs (the only port the P7 /// resume assertions read). When `provider` is `None`, no handoff is wired — /// exactly the legacy launch path. fn launch_with_handoff(provider: Option>) -> (LaunchAgent, Agent, FakeFs) { let injection = ContextInjection::convention_file("CLAUDE.md").unwrap(); 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 fs = FakeFs::new(Arc::clone(&tr)); let pty = FakePty::new(Arc::clone(&tr), sid(777)); let mut launch = LaunchAgent::new( Arc::new(contexts), Arc::new(profiles), Arc::new(FakeRuntime::new( Arc::clone(&tr), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), )), Arc::new(fs.clone()), Arc::new(pty.clone()), Arc::new(FakeSkills::default()), Arc::new(TerminalSessions::new()), Arc::new(SpyBus::default()), Arc::new(SeqIds::new()), Arc::new(FakeRecall::default()), None, ); if let Some(p) = provider { launch = launch.with_handoff_provider(p); } (launch, agent, fs) } fn conv_id(n: u128) -> ConversationId { ConversationId::from_uuid(Uuid::from_u128(n)) } fn handoff_for(summary: &str, objective: Option<&str>) -> Handoff { Handoff::new( summary, TurnId::from_uuid(Uuid::from_u128(7)), objective.map(ToOwned::to_owned), ) } /// Reads the (single) convention file written by the launch. fn convention_doc(fs: &FakeFs) -> String { let writes = fs.context_writes(); assert_eq!(writes.len(), 1, "exactly one convention file"); String::from_utf8(writes[0].1.clone()).unwrap() } #[tokio::test] async fn launch_injects_handoff_resume_when_provider_and_conversation_match() { // Happy path: a wired provider, a cell conversation_id that parses to a known // ConversationId with a stored handoff ⇒ the convention file carries the // `# Reprise de la conversation` section (objective + summary). let conv = conv_id(123); let store = FakeHandoffStore::with( conv, handoff_for("On a terminé l'étape A.", Some("Finir le lot P7")), ); let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc; let (launch, agent, fs) = launch_with_handoff(Some(provider)); let mut input = launch_input(agent.id); // The cell carries the conversation id of the known pair (UUID string form). input.conversation_id = Some(Uuid::from_u128(123).to_string()); launch.execute(input).await.expect("launch succeeds"); let doc = convention_doc(&fs); assert!( doc.contains("# Reprise de la conversation"), "resume section materialised in the run dir: {doc}" ); assert!( doc.contains("**Objectif :** Finir le lot P7"), "objective injected: {doc}" ); assert!( doc.contains("On a terminé l'étape A."), "summary injected: {doc}" ); } #[tokio::test] async fn launch_without_handoff_provider_omits_resume_section() { // No provider wired (legacy path) ⇒ no resume section, even with a cell // conversation_id present. let (launch, agent, fs) = launch_with_handoff(None); let mut input = launch_input(agent.id); input.conversation_id = Some(Uuid::from_u128(123).to_string()); launch.execute(input).await.expect("launch succeeds"); let doc = convention_doc(&fs); assert!( !doc.contains("# Reprise de la conversation"), "no provider ⇒ no resume section: {doc}" ); } #[tokio::test] async fn launch_with_no_conversation_id_omits_resume_section() { // Provider wired and a handoff stored, but the cell has NO conversation_id ⇒ // nothing to resolve ⇒ no section, launch unaffected. let store = FakeHandoffStore::with(conv_id(123), handoff_for("x", Some("y"))); let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc; let (launch, agent, fs) = launch_with_handoff(Some(provider)); // launch_input leaves conversation_id = None. launch .execute(launch_input(agent.id)) .await .expect("launch succeeds"); let doc = convention_doc(&fs); assert!( !doc.contains("# Reprise de la conversation"), "no conversation_id ⇒ no resume section: {doc}" ); } #[tokio::test] async fn launch_with_invalid_uuid_conversation_id_omits_resume_section() { // A non-UUID conversation_id (legacy CLI id / corrupt data) must NOT crash the // launch and must inject no resume section (parse failure ⇒ best-effort None). let store = FakeHandoffStore::with(conv_id(123), handoff_for("x", Some("y"))); let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc; let (launch, agent, fs) = launch_with_handoff(Some(provider)); let mut input = launch_input(agent.id); input.conversation_id = Some("not-a-uuid".to_owned()); launch .execute(input) .await .expect("invalid uuid must not fail the launch"); let doc = convention_doc(&fs); assert!( !doc.contains("# Reprise de la conversation"), "invalid uuid ⇒ no resume section: {doc}" ); } #[tokio::test] async fn launch_opencode_omits_api_key_when_profile_has_none() { let profile = profile( pid(91), ContextInjection::convention_file("AGENTS.md").unwrap(), ) .with_structured_adapter(StructuredAdapter::OpenCode) .with_opencode( OpenCodeConfig::new( "http://localhost:8080/v1", None, "qwen3-coder-30b", None, None, ) .unwrap(), ) .with_mcp(McpCapability::new( McpConfigStrategy::open_code_config("opencode.json").unwrap(), McpTransport::Stdio, )); let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile( profile, Some(ContextInjectionPlan::File { target: "AGENTS.md".to_owned(), }), ); launch.execute(launch_input(agent.id)).await.unwrap(); let opencode = writes_ending_with(&fs, "/opencode.json"); assert_eq!(opencode.len(), 1, "one OpenCode config is written"); let body: serde_json::Value = serde_json::from_slice(&opencode[0].1).unwrap(); assert_eq!( body["provider"]["llamacpp"]["options"]["baseURL"], "http://localhost:8080/v1" ); assert!( body["provider"]["llamacpp"]["options"] .as_object() .expect("llamacpp options are an object") .get("apiKey") .is_none(), "apiKey must be omitted, not serialized as null" ); } #[tokio::test] async fn launch_with_store_without_handoff_omits_resume_section() { // Provider wired, valid conversation_id, but the store holds NO handoff for it // (load ⇒ Ok(None)) ⇒ no section, launch succeeds. let store = FakeHandoffStore::default(); // empty let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc; let (launch, agent, fs) = launch_with_handoff(Some(provider)); let mut input = launch_input(agent.id); input.conversation_id = Some(Uuid::from_u128(999).to_string()); launch.execute(input).await.expect("launch succeeds"); let doc = convention_doc(&fs); assert!( !doc.contains("# Reprise de la conversation"), "store without handoff ⇒ no resume section: {doc}" ); } /// LS5 — un `handoff.md` **legacy surdimensionné** (écrit avant LS5, non borné) chargé /// au lancement ⇒ la borne défensive de `resolve_handoff` ramène le `summary_md` injecté /// sous [`domain::HANDOFF_SUMMARY_MAX_CHARS`], sans migration de données ni crash. On /// vérifie sur la section `# Reprise de la conversation` réellement écrite dans le run dir. #[tokio::test] async fn launch_bounds_oversize_legacy_handoff_resume_section() { // Handoff legacy géant : 500 lignes-tours bien formées, objectif absent (⇒ la section // injectée = le seul summary, comparable directement au plafond). let mut summary = String::new(); for i in 0..500u32 { if i > 0 { summary.push('\n'); } summary.push_str(&format!( "- **Response:** tour numero {i} {}", "x".repeat(30) )); } assert!( summary.chars().count() > domain::HANDOFF_SUMMARY_MAX_CHARS, "pré-condition : le handoff legacy dépasse le plafond ({})", summary.chars().count() ); let conv = conv_id(123); let store = FakeHandoffStore::with(conv, handoff_for(&summary, None)); let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc; let (launch, agent, fs) = launch_with_handoff(Some(provider)); let mut input = launch_input(agent.id); input.conversation_id = Some(Uuid::from_u128(123).to_string()); launch.execute(input).await.expect("launch succeeds"); let doc = convention_doc(&fs); // La section de reprise est la DERNIÈRE du document (ordre composeur). Son contenu // (après l'entête) = le summary injecté, qui doit être borné. let section = doc .split("# Reprise de la conversation\n\n") .nth(1) .expect("resume section present") .trim_end(); assert!( section.chars().count() <= domain::HANDOFF_SUMMARY_MAX_CHARS, "summary legacy injecté borné, obtenu {}", section.chars().count() ); // Distillation : les lignes les plus récentes survivent, les plus anciennes droppées. assert!(section.contains("tour numero 499"), "le plus récent reste"); assert!( !section.contains("tour numero 0 "), "le plus ancien droppé : {section}" ); // Reparsable : chaque ligne conservée garde le préfixe de tour. for line in section.lines().filter(|l| !l.is_empty()) { assert!(line.starts_with("- **"), "ligne reparsable : {line}"); } } /// LS5 — zéro régression : un handoff **normal** (sous le plafond) est injecté /// **verbatim** (la borne est l'identité), objectif et summary intacts. #[tokio::test] async fn launch_normal_handoff_injects_summary_verbatim() { let summary = "**Objectif :** livrer LS5\n\n- **Prompt:** a\n- **Response:** b"; assert!(summary.chars().count() <= domain::HANDOFF_SUMMARY_MAX_CHARS); let conv = conv_id(123); let store = FakeHandoffStore::with(conv, handoff_for(summary, Some("livrer LS5"))); let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc; let (launch, agent, fs) = launch_with_handoff(Some(provider)); let mut input = launch_input(agent.id); input.conversation_id = Some(Uuid::from_u128(123).to_string()); launch.execute(input).await.expect("launch succeeds"); let doc = convention_doc(&fs); // Le summary normal est injecté tel quel (la borne ne l'a pas touché). assert!( doc.contains(summary), "handoff normal injecté verbatim (borne = identité) : {doc}" ); assert!(doc.contains("**Objectif :** livrer LS5")); } // --------------------------------------------------------------------------- // Bloc 2 — cohérence end-to-end de la clé de conversation (LE test de vérité). // // Round-trip réel « sauve sous resolve / charge au lancement » : // 1. SAUVEGARDE — côté orchestrateur, le handoff est rangé sous la clé que // `resolve_conversation(None, agent)` renvoie ; avec un registre câblé c'est // `reg.resolve(User, agent).id`, qui (scellé Bloc 1 : // `resolve_id_equals_for_pair_user_agent`) vaut `ConversationId::for_pair(User, // agent)`. On dérive donc la clé de SAUVEGARDE via ce **même** `for_pair` — // sans tirer `infrastructure` comme dépendance de la couche application (règle // hexagonale ; l'égalité registre↔for_pair est prouvée côté infra). // 2. PERSISTANCE (P8a) — au 1er lancement d'une cellule neuve, IdeA **persiste** // `for_pair(User, agent)` sur `LeafCell.conversation_id`. Donc à la réouverture // la cellule **porte** cette clé. // 3. CHARGEMENT (P7) — `resolve_handoff` charge le handoff sous // `LaunchAgentInput.conversation_id` (la clé persistée que la cellule porte). // // Ce test joue (2)+(3) fidèlement : la cellule porte la clé que P8a a persistée // (`for_pair`), et on prouve qu'elle retrouve le handoff sauvé sous la clé de (1). // C'est l'invariant que Main a rattrapé : si l'une des trois clés dérivait, la // section de reprise serait absente et ce test échouerait. // --------------------------------------------------------------------------- #[tokio::test] async fn reopened_cell_loads_handoff_saved_under_resolve_key_end_to_end() { let agent_party = ConversationParty::agent(aid(1)); // l'agent du harnais. // (1) Clé de SAUVEGARDE = resolve_conversation(None, agent) avec registre câblé // == for_pair(User, agent) (égalité scellée Bloc 1, côté infrastructure). let save_key = ConversationId::for_pair(ConversationParty::User, agent_party); // (2) Clé que P8a persiste sur la cellule neuve, donc portée à la réouverture. let persisted_on_leaf = ConversationId::for_pair(ConversationParty::User, agent_party); assert_eq!( save_key, persisted_on_leaf, "préalable end-to-end : clé sauvegarde (resolve) == clé persistée (P8a)" ); let store = FakeHandoffStore::with( save_key, handoff_for("État au dernier tour : lot P8a câblé.", Some("Sceller §19")), ); let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc; let (launch, agent, fs) = launch_with_handoff(Some(provider)); // (3) Réouverture : la cellule porte la clé que P8a avait persistée. let mut input = launch_input(agent.id); input.conversation_id = Some(persisted_on_leaf.to_string()); launch.execute(input).await.expect("launch succeeds"); let doc = convention_doc(&fs); assert!( doc.contains("# Reprise de la conversation"), "clé de chargement (P7, clé persistée par P8a) == clé de sauvegarde (resolve) : \ le handoff doit être retrouvé. doc: {doc}" ); assert!( doc.contains("**Objectif :** Sceller §19"), "objectif du handoff sauvé sous la clé de résolution injecté: {doc}" ); assert!( doc.contains("État au dernier tour : lot P8a câblé."), "résumé du handoff sauvé sous la clé de résolution injecté: {doc}" ); } /// Contre-épreuve : un handoff rangé sous une **autre** clé (un agent différent) ne /// doit PAS être chargé — garantit que la réussite du test ci-dessus tient à /// l'**égalité** des clés, pas à un chargement inconditionnel. #[tokio::test] async fn reopened_cell_does_not_load_handoff_saved_under_a_different_pair_key() { let agent_party = ConversationParty::agent(aid(1)); // agent du harnais. // La cellule porte SA clé de paire (celle que P8a aurait persistée)… let leaf_key = ConversationId::for_pair(ConversationParty::User, agent_party); // …mais le handoff est rangé sous la clé d'une AUTRE paire (autre agent). let other_key = ConversationId::for_pair(ConversationParty::User, ConversationParty::agent(aid(2))); assert_ne!( leaf_key, other_key, "préalable : deux clés de paire distinctes" ); let store = FakeHandoffStore::with(other_key, handoff_for("ne doit pas fuiter", Some("X"))); let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc; let (launch, agent, fs) = launch_with_handoff(Some(provider)); let mut input = launch_input(agent.id); input.conversation_id = Some(leaf_key.to_string()); launch.execute(input).await.expect("launch succeeds"); let doc = convention_doc(&fs); assert!( !doc.contains("# Reprise de la conversation"), "handoff d'une autre paire ⇒ aucune reprise (pas de fuite de clé): {doc}" ); } // =========================================================================== // LP3-3 — permission projection wiring (apply_permission_projection + registry // + MCP decoupling). FS-mocked, projectors are faithful test doubles (the real // translation is covered by the infra LP3-2 tests; application must stay // dependency-free of `infrastructure`). // =========================================================================== const CLAUDE_SEED_REL: &str = "/.claude/settings.local.json"; const CODEX_CONFIG_REL: &str = "/.codex/config.toml"; /// In-memory [`PermissionStore`] returning a fixed document (LP3-3 wiring tests). struct FakePermissionStore(ProjectPermissions); #[async_trait] impl PermissionStore for FakePermissionStore { async fn load_permissions(&self, _project: &Project) -> Result { Ok(self.0.clone()) } async fn save_permissions( &self, _project: &Project, _permissions: &ProjectPermissions, ) -> Result<(), StoreError> { Ok(()) } } /// Faithful Claude projector double: emits a single owned `Replace` settings file /// whose `defaultMode` mirrors the real posture→mode mapping (Allow→bypass, /// Ask→acceptEdits, Deny→plan), and embeds the project root verbatim. `eff == None` /// ⇒ empty projection. struct FakeClaudeProjector; impl PermissionProjector for FakeClaudeProjector { fn key(&self) -> ProjectorKey { ProjectorKey::Claude } fn project( &self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext, ) -> PermissionProjection { let Some(eff) = eff else { return PermissionProjection::empty(); }; let mode = match eff.fallback() { Posture::Deny => "plan", Posture::Ask => "acceptEdits", Posture::Allow => "bypassPermissions", }; let contents = format!( "{{\"permissions\":{{\"defaultMode\":\"{mode}\",\"additionalDirectories\":[\"{}\"]}}}}\n", ctx.project_root ); PermissionProjection { files: vec![ProjectedFile::Replace { rel_path: ".claude/settings.local.json".to_owned(), contents, }], args: Vec::new(), env: Vec::new(), } } fn owned_replace_paths(&self) -> Vec { vec![".claude/settings.local.json".to_owned()] } } /// Faithful Codex projector double: emits a co-owned `MergeToml` over the two /// managed keys + the matching `--sandbox`/`--ask-for-approval` args, both derived /// from the posture exactly like the real projector. Workspace-write postures also /// add the project root as a writable directory (`--add-dir`). `eff == None` ⇒ /// empty. struct FakeCodexProjector; impl FakeCodexProjector { fn modes(fallback: Posture) -> (&'static str, &'static str) { match fallback { Posture::Deny => ("read-only", "on-request"), Posture::Ask => ("workspace-write", "on-request"), Posture::Allow => ("workspace-write", "never"), } } } impl PermissionProjector for FakeCodexProjector { fn key(&self) -> ProjectorKey { ProjectorKey::Codex } fn project( &self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext, ) -> PermissionProjection { let Some(eff) = eff else { return PermissionProjection::empty(); }; let (sandbox, approval) = Self::modes(eff.fallback()); let contents = format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n"); let mut args = vec![ "--sandbox".to_owned(), sandbox.to_owned(), "--ask-for-approval".to_owned(), approval.to_owned(), ]; if sandbox == "workspace-write" { args.push("--add-dir".to_owned()); args.push(ctx.project_root.to_owned()); } PermissionProjection { files: vec![ProjectedFile::MergeToml { rel_path: ".codex/config.toml".to_owned(), managed_tables: Vec::new(), managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()], contents, }], args, env: Vec::new(), } } fn owned_replace_paths(&self) -> Vec { Vec::new() } } /// A registry carrying both faithful projector doubles. fn full_registry() -> Arc { Arc::new( PermissionProjectorRegistry::new() .with(Arc::new(FakeClaudeProjector)) .with(Arc::new(FakeCodexProjector)), ) } /// A project document whose project-level fallback is `posture` (no agent /// override) ⇒ `resolve_for` yields `Some(eff)` with that fallback. fn perm_doc(posture: Posture) -> ProjectPermissions { ProjectPermissions::new(Some(PermissionSet::new(vec![], posture)), Vec::new()) } /// Builds a `codex` profile (convention `AGENTS.md`) with the given customiser, so /// the structured-adapter / projector / mcp variants can be exercised. fn codex_profile() -> AgentProfile { AgentProfile::new( pid(9), "OpenAI Codex CLI", "codex", Vec::new(), ContextInjection::convention_file("AGENTS.md").unwrap(), Some("codex --version".to_owned()), "{agentRunDir}", None, ) .unwrap() } /// Wires a `LaunchAgent` over the fakes with an optional projector registry and an /// optional permission document. Returns the use case + the seeded agent + the /// recording fs/pty so the projection writes and folded args can be asserted. fn launch_with_projection( profile: AgentProfile, plan: Option, registry: Option>, perm_doc: Option, ) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc) { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id); let contexts = FakeContexts::with_agent(&agent, "# ctx body"); let profiles = FakeProfiles::new(vec![profile]); 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 mut 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::new(FakeSkills::default()), Arc::clone(&sessions), Arc::new(SpyBus::default()), Arc::new(SeqIds::new()), Arc::new(FakeRecall::default()), None, ); if let Some(doc) = perm_doc { launch = launch.with_permission_store(Arc::new(FakePermissionStore(doc))); } if let Some(reg) = registry { launch = launch.with_permission_projectors(reg); } (launch, agent, fs, pty, sessions) } fn convention_file_plan() -> Option { Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }) } // ---- (1) projector key selection ------------------------------------------- /// (1a) An explicit `projector = Some(Claude)` is honoured even when the heuristic /// would not fire (here the convention file is GEMINI.md): the explicit field wins. #[tokio::test] async fn projection_selects_claude_from_explicit_projector_field() { let profile = profile( pid(9), ContextInjection::convention_file("GEMINI.md").unwrap(), ) .with_projector(ProjectorKey::Claude); let (launch, agent, fs, _pty, _s) = launch_with_projection( profile, Some(ContextInjectionPlan::File { target: "GEMINI.md".to_owned(), }), Some(full_registry()), Some(perm_doc(Posture::Allow)), ); launch .execute(launch_input(agent.id)) .await .expect("launch"); let seeds = fs.writes_ending_with(CLAUDE_SEED_REL); assert_eq!(seeds.len(), 1, "the Claude projector ran (explicit key)"); assert!( fs.writes_ending_with(CODEX_CONFIG_REL).is_empty(), "the Codex projector must NOT run" ); } /// (1b) Legacy fallback: no `projector` field, but a `CLAUDE.md` convention file ⇒ /// Claude projector is selected. #[tokio::test] async fn projection_falls_back_to_claude_from_convention_file() { let profile = profile( pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap(), ); assert!( profile.projector.is_none(), "no explicit projector (legacy)" ); let (launch, agent, fs, _pty, _s) = launch_with_projection( profile, convention_file_plan(), Some(full_registry()), Some(perm_doc(Posture::Allow)), ); launch .execute(launch_input(agent.id)) .await .expect("launch"); assert_eq!( fs.writes_ending_with(CLAUDE_SEED_REL).len(), 1, "CLAUDE.md heuristic selects the Claude projector" ); } /// (1c) Legacy fallback: no `projector` field, but `StructuredAdapter::Codex` ⇒ /// Codex projector is selected. #[tokio::test] async fn projection_falls_back_to_codex_from_structured_adapter() { let profile = codex_profile().with_structured_adapter(StructuredAdapter::Codex); assert!( profile.projector.is_none(), "no explicit projector (legacy)" ); let (launch, agent, fs, pty, _s) = launch_with_projection( profile, Some(ContextInjectionPlan::File { target: "AGENTS.md".to_owned(), }), Some(full_registry()), Some(perm_doc(Posture::Allow)), ); launch .execute(launch_input(agent.id)) .await .expect("launch"); assert_eq!( fs.writes_ending_with(CODEX_CONFIG_REL).len(), 1, "Codex structured adapter selects the Codex projector" ); assert!( fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(), "the Claude projector must NOT run" ); // Args were folded into the spawned spec. assert!( pty.spawns()[0].args.contains(&"--sandbox".to_owned()), "sandbox args folded into spec" ); } /// (1d) A non-projectable profile (no projector, no CLAUDE.md, no Codex signal) ⇒ /// no projection at all, even with a full registry and a posed policy. #[tokio::test] async fn projection_noop_for_unprojectable_profile() { let profile = profile( pid(9), ContextInjection::convention_file("GEMINI.md").unwrap(), ); let (launch, agent, fs, pty, _s) = launch_with_projection( profile, Some(ContextInjectionPlan::File { target: "GEMINI.md".to_owned(), }), Some(full_registry()), Some(perm_doc(Posture::Allow)), ); launch .execute(launch_input(agent.id)) .await .expect("launch"); assert!(fs.writes_ending_with(CLAUDE_SEED_REL).is_empty()); assert!(fs.writes_ending_with(CODEX_CONFIG_REL).is_empty()); assert!( !pty.spawns()[0].args.contains(&"--sandbox".to_owned()), "no projected args either" ); } // ---- (2) Replace clobber --------------------------------------------------- /// (2) A `Replace` file is **clobbered** (rewritten) on every (re)launch: launching /// the same Claude agent twice (with the session removed in between to clear the /// singleton guard) writes the owned seed twice to the SAME path — proving the /// inversion vs. the MCP non-clobbering regime (which skips when the file exists). #[tokio::test] async fn claude_replace_seed_is_clobbered_on_relaunch() { let profile = profile( pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap(), ) .with_projector(ProjectorKey::Claude); let (launch, agent, fs, _pty, sessions) = launch_with_projection( profile, convention_file_plan(), Some(full_registry()), Some(perm_doc(Posture::Allow)), ); let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); let seed_path = format!("{run_dir}{CLAUDE_SEED_REL}"); // Pre-mark the seed as already existing: a Replace must write anyway (clobber). fs.mark_existing(&seed_path); // First launch, then simulate the agent exiting so a fresh relaunch is allowed. launch .execute(launch_input(agent.id)) .await .expect("launch 1"); sessions.remove(&sid(777)); launch .execute(launch_input(agent.id)) .await .expect("launch 2"); let seeds = fs.writes_ending_with(CLAUDE_SEED_REL); assert_eq!( seeds.len(), 2, "the owned Replace seed is rewritten on each launch (clobber), got {seeds:?}" ); assert!( seeds.iter().all(|(p, _)| p == &seed_path), "both writes target the same seed path" ); } // ---- (3) MergeToml: upsert managed keys, preserve unmanaged, idempotent ----- /// (3) Projecting onto a pre-existing `.codex/config.toml` upserts the two managed /// keys while preserving an unmanaged user key; a second projection does not /// duplicate the managed keys (idempotence). #[tokio::test] async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() { let profile = codex_profile().with_projector(ProjectorKey::Codex); let (launch, agent, fs, _pty, sessions) = launch_with_projection( profile, Some(ContextInjectionPlan::File { target: "AGENTS.md".to_owned(), }), Some(full_registry()), Some(perm_doc(Posture::Allow)), ); let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); let cfg_path = format!("{run_dir}{CODEX_CONFIG_REL}"); // Pre-existing config with an unmanaged top-level key + an unmanaged table. fs.seed_read( &cfg_path, "user_key = \"keep-me\"\n[mcp_servers.idea]\ncommand = \"idea\"\n", ); // First projection. launch .execute(launch_input(agent.id)) .await .expect("launch 1"); let first = String::from_utf8( fs.writes_ending_with(CODEX_CONFIG_REL) .last() .expect("a codex config write") .1 .clone(), ) .unwrap(); assert!( first.contains("user_key = \"keep-me\""), "unmanaged key preserved: {first}" ); assert!( first.contains("[mcp_servers.idea]"), "unmanaged table preserved: {first}" ); assert!( first.contains("sandbox_mode = \"workspace-write\""), "managed sandbox_mode upserted: {first}" ); assert!( first.contains("approval_policy = \"never\""), "managed approval_policy upserted: {first}" ); // Second projection (relaunch): managed keys are replaced in place, not dup'd. sessions.remove(&sid(777)); launch .execute(launch_input(agent.id)) .await .expect("launch 2"); let second = String::from_utf8( fs.writes_ending_with(CODEX_CONFIG_REL) .last() .unwrap() .1 .clone(), ) .unwrap(); assert_eq!( second.matches("sandbox_mode =").count(), 1, "idempotent: no duplicate sandbox_mode after a second projection: {second}" ); assert_eq!( second.matches("approval_policy =").count(), 1, "idempotent: no duplicate approval_policy: {second}" ); assert!( second.contains("user_key = \"keep-me\""), "unmanaged key still preserved" ); } // ---- (4) args/env fold into the spawned spec -------------------------------- /// (4) The projection's launch args are folded into the spec inherited by the /// (PTY) spawn, in order, alongside the projected config file. #[tokio::test] async fn codex_projection_folds_args_into_spawn_spec() { let profile = codex_profile().with_projector(ProjectorKey::Codex); let (launch, agent, _fs, pty, _s) = launch_with_projection( profile, Some(ContextInjectionPlan::File { target: "AGENTS.md".to_owned(), }), Some(full_registry()), Some(perm_doc(Posture::Ask)), ); launch .execute(launch_input(agent.id)) .await .expect("launch"); let args = &pty.spawns()[0].args; // Ask ⇒ workspace-write / on-request, in CLI order. let pos = args .windows(2) .position(|w| w == ["--sandbox".to_owned(), "workspace-write".to_owned()]); assert!( pos.is_some(), "expected --sandbox workspace-write in {args:?}" ); let pos2 = args .windows(2) .position(|w| w == ["--ask-for-approval".to_owned(), "on-request".to_owned()]); assert!( pos2.is_some(), "expected --ask-for-approval on-request in {args:?}" ); let add_dir = args .windows(2) .position(|w| w == ["--add-dir".to_owned(), "/home/me/proj".to_owned()]); assert!( add_dir.is_some(), "expected --add-dir project root in {args:?}" ); } // ---- (5) MCP decoupling — THE key case of the lot --------------------------- /// (5) A Codex profile with **no MCP capability** still gets its sandbox projected /// (config file + args). This proves the projection no longer depends on /// `apply_mcp_config`: the MCP table is absent, yet the permission plan is applied. #[tokio::test] async fn codex_sandbox_projected_without_any_mcp_capability() { let profile = codex_profile().with_projector(ProjectorKey::Codex); assert!(profile.mcp.is_none(), "precondition: no MCP capability"); let (launch, agent, fs, pty, _s) = launch_with_projection( profile, Some(ContextInjectionPlan::File { target: "AGENTS.md".to_owned(), }), Some(full_registry()), Some(perm_doc(Posture::Deny)), ); launch .execute(launch_input(agent.id)) .await .expect("launch"); // The sandbox config WAS written despite the absent MCP capability. let cfg = fs.writes_ending_with(CODEX_CONFIG_REL); assert_eq!(cfg.len(), 1, "sandbox config projected without MCP"); let toml = String::from_utf8(cfg[0].1.clone()).unwrap(); assert!( toml.contains("sandbox_mode = \"read-only\""), "Deny ⇒ read-only: {toml}" ); // And the args were folded too. assert!( pty.spawns()[0] .args .windows(2) .any(|w| w == ["--sandbox".to_owned(), "read-only".to_owned()]), "sandbox args projected without MCP: {:?}", pty.spawns()[0].args ); } // ---- (6) no-op regimes ------------------------------------------------------ /// (6a) Registry absent (builder never called) ⇒ no permission file is written, /// even with a posed policy. #[tokio::test] async fn no_registry_means_no_projection() { let profile = profile( pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap(), ) .with_projector(ProjectorKey::Claude); let (launch, agent, fs, _pty, _s) = launch_with_projection( profile, convention_file_plan(), None, // no registry wired Some(perm_doc(Posture::Deny)), ); launch .execute(launch_input(agent.id)) .await .expect("launch"); assert!( fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(), "no registry ⇒ no permission seed written (legacy behaviour)" ); } /// (6b) `eff == None` (no project/agent policy posed) ⇒ empty projection: nothing /// written, even though the registry IS wired. #[tokio::test] async fn no_policy_posed_means_empty_projection() { let profile = profile( pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap(), ) .with_projector(ProjectorKey::Claude); let (launch, agent, fs, _pty, _s) = launch_with_projection( profile, convention_file_plan(), Some(full_registry()), Some(ProjectPermissions::default()), // project_defaults = None ⇒ resolve_for == None ); launch .execute(launch_input(agent.id)) .await .expect("launch"); assert!( fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(), "eff == None ⇒ empty projection ⇒ no seed written" ); } // ---- (7) resolved eff reflected in the projected file ----------------------- /// (7) Smoke: a posed `Deny` project posture flows through `resolve_for` into the /// projector and is reflected in the produced Claude settings (`defaultMode=plan`). #[tokio::test] async fn resolved_deny_posture_reflected_as_plan_mode() { let profile = profile( pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap(), ) .with_projector(ProjectorKey::Claude); let (launch, agent, fs, _pty, _s) = launch_with_projection( profile, convention_file_plan(), Some(full_registry()), Some(perm_doc(Posture::Deny)), ); launch .execute(launch_input(agent.id)) .await .expect("launch"); let seed = String::from_utf8(fs.writes_ending_with(CLAUDE_SEED_REL)[0].1.clone()).unwrap(); let json: serde_json::Value = serde_json::from_str(&seed).expect("valid settings JSON"); assert_eq!( json["permissions"]["defaultMode"], "plan", "Deny posture ⇒ plan mode: {seed}" ); assert_eq!( json["permissions"]["additionalDirectories"][0], "/home/me/proj", "project root flowed into the projection ctx" ); } // --------------------------------------------------------------------------- // État du projet injection at launch (lot LS4) — `resolve_live_state` owns the // manifest join + self-exclusion + ordering + cap; the composer renders it. // --------------------------------------------------------------------------- /// In-memory [`LiveStateStore`] seeded with fixed rows (no I/O), to drive the /// live-state preview injected into the convention file at launch. struct SeededLiveStore { state: Mutex, } #[async_trait] impl domain::ports::LiveStateStore for SeededLiveStore { async fn load(&self) -> Result { Ok(self.state.lock().unwrap().clone()) } async fn upsert( &self, entry: domain::live_state::LiveEntry, ) -> Result<(), domain::ports::StoreError> { self.state.lock().unwrap().upsert(entry); Ok(()) } async fn prune( &self, now_ms: u64, ttl_ms: u64, max_n: usize, ) -> Result<(), domain::ports::StoreError> { self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n); Ok(()) } } /// Fixed clock so the seeded rows are never pruned for age at read time. struct InjectClock(i64); impl domain::ports::Clock for InjectClock { fn now_millis(&self) -> i64 { self.0 } } /// A [`LiveStateLeanProvider`] yielding the same [`GetLiveStateLean`] over a shared /// seeded store (root-agnostic for the test). struct SeededLeanProvider { getter: Arc, } impl application::LiveStateLeanProvider for SeededLeanProvider { fn live_state_lean_for( &self, _root: &ProjectPath, ) -> Option> { Some(Arc::clone(&self.getter)) } } #[tokio::test] async fn launch_injects_live_state_excluding_self_capped_in_manifest_order() { use domain::live_state::{LiveEntry, LiveState, WorkStatus}; let injection = ContextInjection::convention_file("CLAUDE.md").unwrap(); let plan = Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }); // The launched agent (self) — must be EXCLUDED from its own preview. let me = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); let contexts = FakeContexts::with_agent(&me, "# ctx body"); // 18 OTHER agents in the manifest, in a deterministic order (aid 2..=19). { let mut inner = contexts.0.lock().unwrap(); for n in 2..=19u128 { let other = scratch_agent( aid(n), &format!("A{n:02}"), &format!("agents/a{n}.md"), pid(9), ); inner .manifest .entries .push(ManifestEntry::from_agent(&other)); } } // Seed the live-state store: a row for self (must not surface), a row for each // of the 18 manifest peers, plus one OFF-manifest row (aid 99) that must drop. let now = 1_000_000_i64; let mut state = LiveState::default(); state.upsert( LiveEntry::new( aid(1), None, "my own work", WorkStatus::Working, None, None, now as u64, ) .unwrap(), ); for n in 2..=19u128 { state.upsert( LiveEntry::new( aid(n), None, format!("intent {n}"), WorkStatus::Working, None, None, now as u64, ) .unwrap(), ); } state.upsert( LiveEntry::new( aid(99), None, "ghost", WorkStatus::Idle, None, None, now as u64, ) .unwrap(), ); let store = Arc::new(SeededLiveStore { state: Mutex::new(state), }); let getter = Arc::new(application::GetLiveStateLean::new( store as Arc, Arc::new(InjectClock(now)), )); let provider = Arc::new(SeededLeanProvider { getter }); 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 bus = SpyBus::default(); 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::new(FakeSkills::default()), Arc::clone(&sessions), Arc::new(bus.clone()), Arc::new(SeqIds::new()), Arc::new(FakeRecall::default()), None, ) .with_live_state_lean(provider as Arc); launch.execute(launch_input(me.id)).await.expect("launch"); let writes = fs.context_writes(); assert_eq!(writes.len(), 1, "exactly one convention file written"); let doc = String::from_utf8(writes[0].1.clone()).unwrap(); // The section is present. assert!( doc.contains("# État du projet"), "live-state section present: {doc}" ); // Self is EXCLUDED: the launched agent never sees its own row. assert!( !doc.contains("- **Backend**"), "the launched agent must not see its own live row: {doc}" ); // The off-manifest row never surfaces (its name is unknown to the manifest). assert!( !doc.contains("ghost"), "off-manifest rows are dropped: {doc}" ); // Cap: exactly LIVE_STATE_INJECT_MAX (16) peer rows are injected, in manifest // order ⇒ A02..=A17 present, A18/A19 dropped by the cap. The only `- **` bullets // in this composition are the live rows (no skills/memory bullets here). assert_eq!( doc.matches("- **").count(), 16, "capped to LIVE_STATE_INJECT_MAX peer rows: {doc}" ); assert!( doc.contains("- **A02** — working · intent 2"), "first peer row: {doc}" ); assert!( doc.contains("- **A17** — working · intent 17"), "16th peer row: {doc}" ); assert!( !doc.contains("- **A18**"), "18th agent dropped by the cap: {doc}" ); assert!( !doc.contains("- **A19**"), "19th agent dropped by the cap: {doc}" ); // Manifest order preserved across the kept rows. let a02 = doc.find("**A02**").unwrap(); let a17 = doc.find("**A17**").unwrap(); assert!(a02 < a17, "rows follow manifest order: {doc}"); }