Scelle la cohérence corrigée en P8a : - infra (5) : resolve stable à travers 2 instances de registre (restart), resolve.id == for_pair == uuid agent, commutativité, distinction des paires - application (2) : round-trip réel « handoff sauvé sous la clé resolve ↔ rechargé au relancement de la cellule portant cette clé » + contre-épreuve (handoff d'une autre paire ⇒ pas de reprise) Clarifie : une cellule neuve (conversation_id=None) ne charge pas de handoff ; la reprise opère à la réouverture. domain/infra/application verts, zéro régression. Code de prod non modifié. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2410 lines
85 KiB
Rust
2410 lines
85 KiB
Rust
//! L6 tests for the agent lifecycle use cases (`CreateAgentFromScratch`,
|
|
//! `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`,
|
|
//! `LaunchAgent`).
|
|
//!
|
|
//! Every port is faked in-memory so the use cases run without real I/O:
|
|
//! - [`FakeContexts`] — an [`AgentContextStore`] holding the manifest + a
|
|
//! `md_path → content` map,
|
|
//! - [`FakeProfiles`] — a [`ProfileStore`] returning a fixed profile list,
|
|
//! - [`FakeRuntime`] — an [`AgentRuntime`] whose `prepare_invocation` records the
|
|
//! call into a shared **trace** and returns a configurable injection plan,
|
|
//! - [`FakeFs`] — a [`FileSystem`] recording writes into the same trace,
|
|
//! - [`FakePty`] — a [`PtyPort`] recording `spawn` into the trace,
|
|
//! - [`SpyBus`], [`SeqIds`] — event recorder and deterministic id generator.
|
|
//!
|
|
//! The shared trace lets us assert the **call ordering** contract of
|
|
//! `LaunchAgent`: `prepare_invocation` → injection (fs write) → `pty.spawn`.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
|
use domain::events::DomainEvent;
|
|
use domain::ids::{AgentId, ProfileId, ProjectId};
|
|
use domain::markdown::MarkdownDoc;
|
|
use domain::ports::{
|
|
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
|
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
|
|
OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath,
|
|
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
|
};
|
|
use domain::profile::{
|
|
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, SessionStrategy,
|
|
};
|
|
use domain::project::{Project, ProjectPath};
|
|
use domain::remote::RemoteRef;
|
|
use domain::skill::{Skill, SkillScope};
|
|
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
|
|
use domain::{PtySize, SessionId, SkillId, SkillRef};
|
|
use uuid::Uuid;
|
|
|
|
use application::{
|
|
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
|
|
LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput,
|
|
TerminalSessions, UpdateAgentContext, UpdateAgentContextInput,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared trace (ordering)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Trace = Arc<Mutex<Vec<String>>>;
|
|
|
|
/// A recorded list of `(target, bytes)` writes, keyed by whatever addresses the
|
|
/// target (a path for the fs, a [`SessionId`] for the pty).
|
|
type WriteLog<K> = Arc<Mutex<Vec<(K, Vec<u8>)>>>;
|
|
|
|
fn trace() -> Trace {
|
|
Arc::new(Mutex::new(Vec::new()))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeContexts (AgentContextStore)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Default)]
|
|
struct ContextsInner {
|
|
manifest: AgentManifest,
|
|
contents: HashMap<String, String>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct FakeContexts(Arc<Mutex<ContextsInner>>);
|
|
|
|
impl FakeContexts {
|
|
fn new() -> Self {
|
|
Self(Arc::new(Mutex::new(ContextsInner {
|
|
manifest: AgentManifest {
|
|
version: 1,
|
|
entries: Vec::new(),
|
|
},
|
|
contents: HashMap::new(),
|
|
})))
|
|
}
|
|
fn with_agent(agent: &Agent, content: &str) -> Self {
|
|
let me = Self::new();
|
|
{
|
|
let mut inner = me.0.lock().unwrap();
|
|
inner
|
|
.manifest
|
|
.entries
|
|
.push(ManifestEntry::from_agent(agent));
|
|
inner
|
|
.contents
|
|
.insert(agent.context_path.clone(), content.to_owned());
|
|
}
|
|
me
|
|
}
|
|
fn manifest(&self) -> AgentManifest {
|
|
self.0.lock().unwrap().manifest.clone()
|
|
}
|
|
fn content(&self, md_path: &str) -> Option<String> {
|
|
self.0.lock().unwrap().contents.get(md_path).cloned()
|
|
}
|
|
fn md_path_of(&self, agent: &AgentId) -> Option<String> {
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.manifest
|
|
.entries
|
|
.iter()
|
|
.find(|e| &e.agent_id == agent)
|
|
.map(|e| e.md_path.clone())
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentContextStore for FakeContexts {
|
|
async fn read_context(
|
|
&self,
|
|
_project: &Project,
|
|
agent: &AgentId,
|
|
) -> Result<MarkdownDoc, StoreError> {
|
|
let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
|
self.content(&md_path)
|
|
.map(MarkdownDoc::new)
|
|
.ok_or(StoreError::NotFound)
|
|
}
|
|
async fn write_context(
|
|
&self,
|
|
_project: &Project,
|
|
agent: &AgentId,
|
|
md: &MarkdownDoc,
|
|
) -> Result<(), StoreError> {
|
|
let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.contents
|
|
.insert(md_path, md.as_str().to_owned());
|
|
Ok(())
|
|
}
|
|
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
|
Ok(self.manifest())
|
|
}
|
|
async fn save_manifest(
|
|
&self,
|
|
_project: &Project,
|
|
manifest: &AgentManifest,
|
|
) -> Result<(), StoreError> {
|
|
self.0.lock().unwrap().manifest = manifest.clone();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FakeProfiles (ProfileStore)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone)]
|
|
struct FakeProfiles(Arc<Vec<AgentProfile>>);
|
|
|
|
impl FakeProfiles {
|
|
fn new(profiles: Vec<AgentProfile>) -> Self {
|
|
Self(Arc::new(profiles))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ProfileStore for FakeProfiles {
|
|
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
|
Ok((*self.0).clone())
|
|
}
|
|
async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
async fn is_configured(&self) -> Result<bool, StoreError> {
|
|
Ok(true)
|
|
}
|
|
async fn mark_configured(&self) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
impl AgentRuntime for FakeRuntime {
|
|
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(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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>>>,
|
|
}
|
|
|
|
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())),
|
|
}
|
|
}
|
|
fn set_project_context(&self, content: &str) {
|
|
*self.project_context.lock().unwrap() = Some(content.to_owned());
|
|
}
|
|
/// 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> {
|
|
if path.as_str().ends_with("/.ideai/CONTEXT.md") {
|
|
return self
|
|
.project_context
|
|
.lock()
|
|
.unwrap()
|
|
.clone()
|
|
.map(|s| s.into_bytes())
|
|
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()));
|
|
}
|
|
Err(FsError::NotFound(path.as_str().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 kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
|
Ok(ExitStatus { code: Some(0) })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SpyBus + SeqIds
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Default, Clone)]
|
|
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
|
impl SpyBus {
|
|
fn events(&self) -> Vec<DomainEvent> {
|
|
self.0.lock().unwrap().clone()
|
|
}
|
|
}
|
|
impl EventBus for SpyBus {
|
|
fn publish(&self, event: DomainEvent) {
|
|
self.0.lock().unwrap().push(event);
|
|
}
|
|
fn subscribe(&self) -> EventStream {
|
|
Box::new(std::iter::empty())
|
|
}
|
|
}
|
|
|
|
struct SeqIds(Mutex<u128>);
|
|
impl SeqIds {
|
|
fn new() -> Self {
|
|
Self(Mutex::new(1))
|
|
}
|
|
}
|
|
impl IdGenerator for SeqIds {
|
|
fn new_uuid(&self) -> Uuid {
|
|
let mut n = self.0.lock().unwrap();
|
|
let id = Uuid::from_u128(*n);
|
|
*n += 1;
|
|
id
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn pid(n: u128) -> ProfileId {
|
|
ProfileId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn aid(n: u128) -> AgentId {
|
|
AgentId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn sid(n: u128) -> SessionId {
|
|
SessionId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
|
|
fn project() -> Project {
|
|
Project::new(
|
|
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
|
"demo",
|
|
ProjectPath::new("/home/me/proj").unwrap(),
|
|
RemoteRef::local(),
|
|
1_700_000_000_000,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn profile(id: ProfileId, injection: ContextInjection) -> AgentProfile {
|
|
AgentProfile::new(
|
|
id,
|
|
"Claude Code",
|
|
"claude",
|
|
Vec::new(),
|
|
injection,
|
|
Some("claude --version".to_owned()),
|
|
"{agentRunDir}",
|
|
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,
|
|
}
|
|
}
|
|
|
|
#[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. The Claude permission seed is written first (right after
|
|
// the run dir is created), then prepare → injection (convention file) → spawn.
|
|
assert_eq!(
|
|
*tr.lock().unwrap(),
|
|
vec![
|
|
"fs.write".to_owned(),
|
|
"prepare".to_owned(),
|
|
"fs.write".to_owned(),
|
|
"spawn".to_owned()
|
|
],
|
|
"seed → 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}"
|
|
);
|
|
|
|
// Bug #5: a Claude permission seed was written into the run dir so the agent
|
|
// runs with the project's autonomy instead of prompting per command.
|
|
let seeds = fs.seed_writes();
|
|
assert_eq!(seeds.len(), 1);
|
|
assert_eq!(seeds[0].0, format!("{run_dir}/.claude/settings.local.json"));
|
|
let seed = String::from_utf8(seeds[0].1.clone()).unwrap();
|
|
assert!(
|
|
seed.contains("bypassPermissions"),
|
|
"seed grants autonomy: {seed}"
|
|
);
|
|
assert!(
|
|
seed.contains("/home/me/proj"),
|
|
"seed grants the project root"
|
|
);
|
|
|
|
// The run directory (and the seed's `.claude` subdir) were created before spawn.
|
|
assert_eq!(fs.created_run_dirs(), vec![run_dir.clone()]);
|
|
assert!(fs.created_dirs().contains(&format!("{run_dir}/.claude")));
|
|
|
|
// 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 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))
|
|
}
|
|
|
|
/// 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_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_is_non_clobbering() {
|
|
// M5d-5 — a pre-existing .mcp.json is NOT overwritten, even when a real runtime
|
|
// is injected (unchanged non-clobbering contract from M1).
|
|
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"));
|
|
|
|
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(),
|
|
"an existing .mcp.json must not be clobbered even with an injected runtime"
|
|
);
|
|
}
|
|
|
|
#[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_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}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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}"
|
|
);
|
|
}
|