Le runner de tâches de fond détectait la fin d'un process via l'EOF du drain de sortie PTY, couplant le cycle de vie du process au flux d'output. Un output encore ouvert (ou drainé ailleurs) pouvait masquer ou retarder la détection de fin. Ajoute wait/try_wait au port figé PtyPort : - wait : bloquant, rend un ExitStatus idempotent ; - try_wait : non bloquant, rend Option<ExitStatus> ; - exit status mémorisé pour garder wait/try_wait/kill cohérents. Implémentation dans PortablePtyAdapter (état d'exit mémorisé). Le CommandBackgroundRunner détecte désormais la fin via pty.wait (select sur cancel/deadline) au lieu de l'EOF du drain. Tous les fakes PtyPort du workspace sont complétés en conséquence. Le tee live UI reste hors périmètre (traité en #58). Aucun breaking IPC/front. Tests : infrastructure/tests/background_task_runner.rs (nouveau) + pty_adapter.rs verts, non-régression application/app-tauri OK (hors échecs réseau du sandbox). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3820 lines
134 KiB
Rust
3820 lines
134 KiB
Rust
//! L6 tests for the agent lifecycle use cases (`CreateAgentFromScratch`,
|
|
//! `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`,
|
|
//! `LaunchAgent`).
|
|
//!
|
|
//! Every port is faked in-memory so the use cases run without real I/O:
|
|
//! - [`FakeContexts`] — an [`AgentContextStore`] holding the manifest + a
|
|
//! `md_path → content` map,
|
|
//! - [`FakeProfiles`] — a [`ProfileStore`] returning a fixed profile list,
|
|
//! - [`FakeRuntime`] — an [`AgentRuntime`] whose `prepare_invocation` records the
|
|
//! call into a shared **trace** and returns a configurable injection plan,
|
|
//! - [`FakeFs`] — a [`FileSystem`] recording writes into the same trace,
|
|
//! - [`FakePty`] — a [`PtyPort`] recording `spawn` into the trace,
|
|
//! - [`SpyBus`], [`SeqIds`] — event recorder and deterministic id generator.
|
|
//!
|
|
//! The shared trace lets us assert the **call ordering** contract of
|
|
//! `LaunchAgent`: `prepare_invocation` → injection (fs write) → `pty.spawn`.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
|
use domain::events::DomainEvent;
|
|
use domain::ids::{AgentId, ProfileId, ProjectId};
|
|
use domain::markdown::MarkdownDoc;
|
|
use domain::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<Mutex<Vec<String>>>;
|
|
|
|
/// A recorded list of `(target, bytes)` writes, keyed by whatever addresses the
|
|
/// target (a path for the fs, a [`SessionId`] for the pty).
|
|
type WriteLog<K> = Arc<Mutex<Vec<(K, Vec<u8>)>>>;
|
|
|
|
fn trace() -> Trace {
|
|
Arc::new(Mutex::new(Vec::new()))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeContexts (AgentContextStore)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Default)]
|
|
struct ContextsInner {
|
|
manifest: AgentManifest,
|
|
contents: HashMap<String, String>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct FakeContexts(Arc<Mutex<ContextsInner>>);
|
|
|
|
impl FakeContexts {
|
|
fn new() -> Self {
|
|
Self(Arc::new(Mutex::new(ContextsInner {
|
|
manifest: AgentManifest {
|
|
version: 1,
|
|
entries: Vec::new(),
|
|
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<String> {
|
|
self.0.lock().unwrap().contents.get(md_path).cloned()
|
|
}
|
|
fn md_path_of(&self, agent: &AgentId) -> Option<String> {
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.manifest
|
|
.entries
|
|
.iter()
|
|
.find(|e| &e.agent_id == agent)
|
|
.map(|e| e.md_path.clone())
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentContextStore for FakeContexts {
|
|
async fn read_context(
|
|
&self,
|
|
_project: &Project,
|
|
agent: &AgentId,
|
|
) -> Result<MarkdownDoc, StoreError> {
|
|
let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
|
self.content(&md_path)
|
|
.map(MarkdownDoc::new)
|
|
.ok_or(StoreError::NotFound)
|
|
}
|
|
async fn write_context(
|
|
&self,
|
|
_project: &Project,
|
|
agent: &AgentId,
|
|
md: &MarkdownDoc,
|
|
) -> Result<(), StoreError> {
|
|
let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.contents
|
|
.insert(md_path, md.as_str().to_owned());
|
|
Ok(())
|
|
}
|
|
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
|
Ok(self.manifest())
|
|
}
|
|
async fn save_manifest(
|
|
&self,
|
|
_project: &Project,
|
|
manifest: &AgentManifest,
|
|
) -> Result<(), StoreError> {
|
|
self.0.lock().unwrap().manifest = manifest.clone();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeProfiles (ProfileStore)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone)]
|
|
struct FakeProfiles(Arc<Vec<AgentProfile>>);
|
|
|
|
impl FakeProfiles {
|
|
fn new(profiles: Vec<AgentProfile>) -> Self {
|
|
Self(Arc::new(profiles))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ProfileStore for FakeProfiles {
|
|
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
|
Ok((*self.0).clone())
|
|
}
|
|
async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
async fn is_configured(&self) -> Result<bool, StoreError> {
|
|
Ok(true)
|
|
}
|
|
async fn mark_configured(&self) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeSkills (SkillStore) — an in-memory store seeded with a few skills
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone, Default)]
|
|
struct FakeSkills(Arc<Vec<Skill>>);
|
|
|
|
impl FakeSkills {
|
|
fn with(skills: Vec<Skill>) -> Self {
|
|
Self(Arc::new(skills))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SkillStore for FakeSkills {
|
|
async fn list(&self, scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
|
|
Ok(self
|
|
.0
|
|
.iter()
|
|
.filter(|s| s.scope == scope)
|
|
.cloned()
|
|
.collect())
|
|
}
|
|
async fn get(
|
|
&self,
|
|
scope: SkillScope,
|
|
_root: &ProjectPath,
|
|
id: SkillId,
|
|
) -> Result<Skill, StoreError> {
|
|
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<Vec<MemoryIndexEntry>>,
|
|
fail: bool,
|
|
}
|
|
|
|
impl FakeRecall {
|
|
fn returning(entries: Vec<MemoryIndexEntry>) -> 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<Vec<MemoryIndexEntry>, 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<ContextInjectionPlan>,
|
|
/// 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<Mutex<Option<SessionPlan>>>,
|
|
}
|
|
|
|
impl FakeRuntime {
|
|
fn new(trace: Trace, plan: Option<ContextInjectionPlan>) -> 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<Mutex<Option<SessionPlan>>> {
|
|
Arc::clone(&self.last_session)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentRuntime for FakeRuntime {
|
|
async fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
|
Ok(true)
|
|
}
|
|
fn prepare_invocation(
|
|
&self,
|
|
profile: &AgentProfile,
|
|
_ctx: &PreparedContext,
|
|
cwd: &ProjectPath,
|
|
session: &SessionPlan,
|
|
) -> Result<SpawnSpec, RuntimeError> {
|
|
*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<String>,
|
|
created_dirs: Arc<Mutex<Vec<String>>>,
|
|
project_context: Arc<Mutex<Option<String>>>,
|
|
/// 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<Mutex<Vec<String>>>,
|
|
/// 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<Mutex<Vec<String>>>,
|
|
/// 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<Mutex<HashMap<String, String>>>,
|
|
}
|
|
|
|
impl FakeFs {
|
|
fn new(trace: Trace) -> Self {
|
|
Self {
|
|
trace,
|
|
writes: Arc::new(Mutex::new(Vec::new())),
|
|
created_dirs: Arc::new(Mutex::new(Vec::new())),
|
|
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<u8>)> {
|
|
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<u8>)> {
|
|
self.writes.lock().unwrap().clone()
|
|
}
|
|
fn created_dirs(&self) -> Vec<String> {
|
|
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<u8>)> {
|
|
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<u8>)> {
|
|
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<String> {
|
|
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<Vec<u8>, 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<bool, FsError> {
|
|
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<Vec<DirEntry>, FsError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakePty (PtyPort) — records spawn into the trace
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone)]
|
|
struct FakePty {
|
|
trace: Trace,
|
|
next_id: SessionId,
|
|
spawns: Arc<Mutex<Vec<SpawnSpec>>>,
|
|
writes: WriteLog<SessionId>,
|
|
}
|
|
|
|
impl FakePty {
|
|
fn new(trace: Trace, next_id: SessionId) -> Self {
|
|
Self {
|
|
trace,
|
|
next_id,
|
|
spawns: Arc::new(Mutex::new(Vec::new())),
|
|
writes: Arc::new(Mutex::new(Vec::new())),
|
|
}
|
|
}
|
|
fn spawns(&self) -> Vec<SpawnSpec> {
|
|
self.spawns.lock().unwrap().clone()
|
|
}
|
|
fn writes(&self) -> Vec<(SessionId, Vec<u8>)> {
|
|
self.writes.lock().unwrap().clone()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl PtyPort for FakePty {
|
|
async fn spawn(&self, spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
|
|
self.trace.lock().unwrap().push("spawn".to_owned());
|
|
self.spawns.lock().unwrap().push(spec);
|
|
Ok(PtyHandle {
|
|
session_id: self.next_id,
|
|
})
|
|
}
|
|
fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> {
|
|
self.writes
|
|
.lock()
|
|
.unwrap()
|
|
.push((handle.session_id, data.to_vec()));
|
|
Ok(())
|
|
}
|
|
fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
|
|
Ok(())
|
|
}
|
|
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
|
Ok(Box::new(std::iter::empty()))
|
|
}
|
|
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn wait(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
|
Ok(ExitStatus { code: Some(0) })
|
|
}
|
|
fn try_wait(&self, _handle: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
|
|
Ok(Some(ExitStatus { code: Some(0) }))
|
|
}
|
|
async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
|
Ok(ExitStatus { code: Some(0) })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeStructuredFactory (AgentSessionFactory) — records structured starts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone)]
|
|
struct FakeSession {
|
|
id: SessionId,
|
|
conversation_id: Option<String>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentSession for FakeSession {
|
|
fn id(&self) -> SessionId {
|
|
self.id
|
|
}
|
|
|
|
fn conversation_id(&self) -> Option<String> {
|
|
self.conversation_id.clone()
|
|
}
|
|
|
|
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
|
Ok(Box::new(std::iter::empty()))
|
|
}
|
|
|
|
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct FakeStructuredFactory {
|
|
trace: Trace,
|
|
starts: Arc<Mutex<Vec<ProfileId>>>,
|
|
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<ProfileId> {
|
|
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<Arc<dyn AgentSession>, 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<Mutex<Vec<DomainEvent>>>);
|
|
impl SpyBus {
|
|
fn events(&self) -> Vec<DomainEvent> {
|
|
self.0.lock().unwrap().clone()
|
|
}
|
|
}
|
|
impl EventBus for SpyBus {
|
|
fn publish(&self, event: DomainEvent) {
|
|
self.0.lock().unwrap().push(event);
|
|
}
|
|
fn subscribe(&self) -> EventStream {
|
|
Box::new(std::iter::empty())
|
|
}
|
|
}
|
|
|
|
struct SeqIds(Mutex<u128>);
|
|
impl SeqIds {
|
|
fn new() -> Self {
|
|
Self(Mutex::new(1))
|
|
}
|
|
}
|
|
impl IdGenerator for SeqIds {
|
|
fn new_uuid(&self) -> Uuid {
|
|
let mut n = self.0.lock().unwrap();
|
|
let id = Uuid::from_u128(*n);
|
|
*n += 1;
|
|
id
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn pid(n: u128) -> ProfileId {
|
|
ProfileId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn aid(n: u128) -> AgentId {
|
|
AgentId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn sid(n: u128) -> SessionId {
|
|
SessionId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
|
|
fn project() -> Project {
|
|
Project::new(
|
|
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
|
"demo",
|
|
ProjectPath::new("/home/me/proj").unwrap(),
|
|
RemoteRef::local(),
|
|
1_700_000_000_000,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn profile(id: ProfileId, injection: ContextInjection) -> AgentProfile {
|
|
AgentProfile::new(
|
|
id,
|
|
"Claude Code",
|
|
"claude",
|
|
Vec::new(),
|
|
injection,
|
|
Some("claude --version".to_owned()),
|
|
"{agentRunDir}",
|
|
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<TerminalSessions>,
|
|
Trace,
|
|
Arc<Mutex<Option<SessionPlan>>>,
|
|
);
|
|
|
|
/// Wires a LaunchAgent over fakes for a given injection strategy/plan.
|
|
fn launch_fixture(
|
|
injection: ContextInjection,
|
|
plan: Option<ContextInjectionPlan>,
|
|
) -> 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<ContextInjectionPlan>,
|
|
) -> 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<ContextInjectionPlan>,
|
|
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/<agent-id>/CLAUDE.md`) — NOT at the project root. Its content
|
|
// is the *composed* document: an absolute project-root header followed by the
|
|
// agent persona `.md`.
|
|
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
|
let writes = fs.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<dyn AgentSessionFactory> = 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/<agent-id>/` — and each writes its convention file
|
|
/// inside its own run dir, never colliding at the project root.
|
|
#[tokio::test]
|
|
async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
|
|
let injection = ContextInjection::convention_file("CLAUDE.md").unwrap();
|
|
let plan = Some(ContextInjectionPlan::File {
|
|
target: "CLAUDE.md".to_owned(),
|
|
});
|
|
|
|
// Two agents, same profile (pid(9)), same project root.
|
|
let agent_a = scratch_agent(aid(1), "Alpha", "agents/alpha.md", pid(9));
|
|
let agent_b = scratch_agent(aid(2), "Bravo", "agents/bravo.md", pid(9));
|
|
|
|
let contexts = FakeContexts::with_agent(&agent_a, "# alpha");
|
|
{
|
|
let mut inner = contexts.0.lock().unwrap();
|
|
inner
|
|
.manifest
|
|
.entries
|
|
.push(ManifestEntry::from_agent(&agent_b));
|
|
inner
|
|
.contents
|
|
.insert(agent_b.context_path.clone(), "# bravo".to_owned());
|
|
}
|
|
let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]);
|
|
|
|
let tr = trace();
|
|
let fs = FakeFs::new(Arc::clone(&tr));
|
|
let pty = FakePty::new(Arc::clone(&tr), sid(777));
|
|
let sessions = Arc::new(TerminalSessions::new());
|
|
let launch = LaunchAgent::new(
|
|
Arc::new(contexts),
|
|
Arc::new(profiles),
|
|
Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)),
|
|
Arc::new(fs.clone()),
|
|
Arc::new(pty.clone()),
|
|
Arc::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<u8>)> {
|
|
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
|
|
// <run_dir>/.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 <run_dir>/.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, <run_dir>).
|
|
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<String> = 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<Mutex<HashMap<ConversationId, Handoff>>>);
|
|
|
|
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<Option<Handoff>, 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<dyn HandoffStore>);
|
|
|
|
impl HandoffProvider for FakeHandoffProvider {
|
|
fn handoff_store_for(&self, _root: &ProjectPath) -> Option<Arc<dyn HandoffStore>> {
|
|
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<Arc<dyn HandoffProvider>>) -> (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<dyn HandoffProvider>;
|
|
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<dyn HandoffProvider>;
|
|
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<dyn HandoffProvider>;
|
|
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<dyn HandoffProvider>;
|
|
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<dyn HandoffProvider>;
|
|
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<dyn HandoffProvider>;
|
|
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<dyn HandoffProvider>;
|
|
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<dyn HandoffProvider>;
|
|
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<ProjectPermissions, StoreError> {
|
|
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<String> {
|
|
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<String> {
|
|
Vec::new()
|
|
}
|
|
}
|
|
|
|
/// A registry carrying both faithful projector doubles.
|
|
fn full_registry() -> Arc<PermissionProjectorRegistry> {
|
|
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<ContextInjectionPlan>,
|
|
registry: Option<Arc<PermissionProjectorRegistry>>,
|
|
perm_doc: Option<ProjectPermissions>,
|
|
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
|
|
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<ContextInjectionPlan> {
|
|
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<domain::live_state::LiveState>,
|
|
}
|
|
#[async_trait]
|
|
impl domain::ports::LiveStateStore for SeededLiveStore {
|
|
async fn load(&self) -> Result<domain::live_state::LiveState, domain::ports::StoreError> {
|
|
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<application::GetLiveStateLean>,
|
|
}
|
|
impl application::LiveStateLeanProvider for SeededLeanProvider {
|
|
fn live_state_lean_for(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
) -> Option<Arc<application::GetLiveStateLean>> {
|
|
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<dyn domain::ports::LiveStateStore>,
|
|
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<dyn application::LiveStateLeanProvider>);
|
|
|
|
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}");
|
|
}
|