feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé

- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs
  (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés,
  environnement local détecté, stratégies compilées). UI EmbedderSettings.
- LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la
  mémoire dépasse le budget de recall sans embedder configuré (event
  EmbedderSuggested, anti-spam 1×/session, « ne plus demander »).
- Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les
  agents/profils au lancement, avant la persona. UI ProjectContextPanel.

Tests : backend workspace vert (0 échec) ; frontend 306/306.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 09:24:51 +02:00
parent 32398827fb
commit 785e9935fd
118 changed files with 5793 additions and 866 deletions

View File

@ -192,7 +192,15 @@ fn profile(id: ProfileId) -> AgentProfile {
}
fn agent(id: AgentId, profile_id: ProfileId) -> Agent {
Agent::new(id, "Bob", "agents/bob.md", profile_id, AgentOrigin::Scratch, false).unwrap()
Agent::new(
id,
"Bob",
"agents/bob.md",
profile_id,
AgentOrigin::Scratch,
false,
)
.unwrap()
}
fn input(agent_id: AgentId) -> InspectConversationInput {
@ -228,7 +236,10 @@ async fn supporting_inspector_propagates_details() {
);
let out = uc.execute(input(aid)).await.unwrap();
assert_eq!(out.details.last_topic.as_deref(), Some("refactor the parser"));
assert_eq!(
out.details.last_topic.as_deref(),
Some("refactor the parser")
);
assert_eq!(out.details.token_count, Some(4242));
// Routed the conversation id and the agent's isolated run dir (not the root).
@ -270,8 +281,10 @@ async fn read_error_degrades_to_empty_details() {
let aid = AgentId::from_uuid(Uuid::from_u128(42));
let a = agent(aid, pid);
let (inspector, _seen) =
FakeInspector::new(true, InspectResult::Err(InspectError::Read("boom".to_owned())));
let (inspector, _seen) = FakeInspector::new(
true,
InspectResult::Err(InspectError::Read("boom".to_owned())),
);
let uc = InspectConversation::new(
Arc::new(FakeContexts::with_agent(&a)),
@ -280,7 +293,13 @@ async fn read_error_degrades_to_empty_details() {
);
let out = uc.execute(input(aid)).await.unwrap();
assert_eq!(out.details, ConversationDetails { last_topic: None, token_count: None });
assert_eq!(
out.details,
ConversationDetails {
last_topic: None,
token_count: None
}
);
}
// ---------------------------------------------------------------------------
@ -344,7 +363,10 @@ async fn unknown_agent_errors() {
let aid = AgentId::from_uuid(Uuid::from_u128(99));
let (inspector, _seen) = FakeInspector::new(
true,
InspectResult::Ok(ConversationDetails { last_topic: None, token_count: None }),
InspectResult::Ok(ConversationDetails {
last_topic: None,
token_count: None,
}),
);
let uc = InspectConversation::new(

View File

@ -29,16 +29,16 @@ use domain::ports::{
OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath,
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
use domain::profile::{AgentProfile, ContextInjection, 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::{
AppError, CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput,
TerminalSessions, UpdateAgentContext, UpdateAgentContextInput,
};
@ -84,7 +84,10 @@ impl FakeContexts {
let me = Self::new();
{
let mut inner = me.0.lock().unwrap();
inner.manifest.entries.push(ManifestEntry::from_agent(agent));
inner
.manifest
.entries
.push(ManifestEntry::from_agent(agent));
inner
.contents
.insert(agent.context_path.clone(), content.to_owned());
@ -196,7 +199,12 @@ impl FakeSkills {
#[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())
Ok(self
.0
.iter()
.filter(|s| s.scope == scope)
.cloned()
.collect())
}
async fn get(
&self,
@ -333,6 +341,7 @@ struct FakeFs {
trace: Trace,
writes: WriteLog<String>,
created_dirs: Arc<Mutex<Vec<String>>>,
project_context: Arc<Mutex<Option<String>>>,
}
impl FakeFs {
@ -341,8 +350,12 @@ impl FakeFs {
trace,
writes: Arc::new(Mutex::new(Vec::new())),
created_dirs: Arc::new(Mutex::new(Vec::new())),
project_context: Arc::new(Mutex::new(None)),
}
}
fn set_project_context(&self, content: &str) {
*self.project_context.lock().unwrap() = Some(content.to_owned());
}
fn writes(&self) -> Vec<(String, Vec<u8>)> {
self.writes.lock().unwrap().clone()
}
@ -376,6 +389,15 @@ impl FakeFs {
#[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> {
@ -648,7 +670,10 @@ async fn read_then_update_context_roundtrips() {
.await
.unwrap();
assert_eq!(contexts.content("agents/backend.md").as_deref(), Some("edited"));
assert_eq!(
contexts.content("agents/backend.md").as_deref(),
Some("edited")
);
}
#[tokio::test]
@ -696,7 +721,10 @@ type LaunchFixture = (
);
/// Wires a LaunchAgent over fakes for a given injection strategy/plan.
fn launch_fixture(injection: ContextInjection, plan: Option<ContextInjectionPlan>) -> LaunchFixture {
fn launch_fixture(
injection: ContextInjection,
plan: Option<ContextInjectionPlan>,
) -> LaunchFixture {
launch_fixture_with_profile(profile(pid(9), injection), plan)
}
@ -738,6 +766,7 @@ fn launch_fixture_with_profile_and_recall(
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(recall),
None,
);
(launch, agent, fs, pty, bus, sessions, tr, session_probe)
}
@ -763,7 +792,10 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
}),
);
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
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.
@ -802,14 +834,18 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
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");
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")));
assert!(fs.created_dirs().contains(&format!("{run_dir}/.claude")));
// Spawn happened at the isolated run dir with the profile command.
let spawns = pty.spawns();
@ -835,6 +871,34 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
);
}
#[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(
@ -860,11 +924,10 @@ fn nid(n: u128) -> domain::NodeId {
}
/// **Singleton invariant (T1)**: launching an agent that already owns a live
/// session in *another* cell is refused with `AgentAlreadyRunning`, pointing at
/// the existing host node. The registry is left untouched and the PTY is never
/// spawned.
/// session in another cell re-attaches the existing session to the requested
/// node. The PTY is never spawned a second time.
#[tokio::test]
async fn launch_refuses_agent_already_running_elsewhere() {
async fn launch_reattaches_agent_already_running_elsewhere() {
let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(ContextInjectionPlan::File {
@ -877,28 +940,24 @@ async fn launch_refuses_agent_already_running_elsewhere() {
seed_live_agent_session(&sessions, agent.id, host, sid(42));
let before = sessions.len();
// Attempt to launch the same agent into a *different* cell B.
// Open the same running agent in a different cell B.
let mut input = launch_input(agent.id);
input.node_id = Some(nid(2));
let err = launch.execute(input).await.expect_err("must be refused");
let target = nid(2);
input.node_id = Some(target);
let out = launch.execute(input).await.expect("reattach succeeds");
match err {
AppError::AgentAlreadyRunning { agent_id, node_id } => {
assert_eq!(agent_id, agent.id);
assert_eq!(node_id, host, "reports the existing host cell");
}
other => panic!("expected AgentAlreadyRunning, got {other:?}"),
}
assert_eq!(err.code(), "AGENT_ALREADY_RUNNING");
// Invariant preserved: registry unchanged, no spawn happened.
assert_eq!(sessions.len(), before, "registry must be unchanged");
assert!(pty.spawns().is_empty(), "no PTY spawn on a refused launch");
assert_eq!(out.session.id, sid(42), "returns the existing session");
assert_eq!(out.session.node_id, target, "session is rebound to target");
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 reattach");
}
/// A launch with an unspecified node (`node_id: None`) for an already-live agent
/// is also refused — it cannot be the same cell.
/// is a background/idempotent no-op: return the existing session without
/// respawning or changing its current host.
#[tokio::test]
async fn launch_refuses_already_running_agent_with_no_node() {
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 {
@ -908,11 +967,12 @@ async fn launch_refuses_already_running_agent_with_no_node() {
seed_live_agent_session(&sessions, agent.id, nid(1), sid(42));
// node_id defaults to None in launch_input.
let err = launch
let out = launch
.execute(launch_input(agent.id))
.await
.expect_err("must be refused");
assert!(matches!(err, AppError::AgentAlreadyRunning { .. }));
.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());
}
@ -937,7 +997,10 @@ async fn launch_same_node_is_idempotent_no_respawn() {
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!(
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");
}
@ -963,7 +1026,11 @@ async fn launch_succeeds_after_session_removed() {
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");
assert_eq!(
pty.spawns().len(),
1,
"exactly one spawn after the agent died"
);
}
/// **Anti-collision (ARCHITECTURE §14.1)**: two distinct agents of the *same*
@ -1009,6 +1076,7 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
Arc::new(SpyBus::default()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall::default()),
None,
);
launch.execute(launch_input(agent_a.id)).await.unwrap();
@ -1016,7 +1084,10 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
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");
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()]);
@ -1038,8 +1109,12 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
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"));
assert!(String::from_utf8(writes[0].1.clone())
.unwrap()
.contains("# alpha"));
assert!(String::from_utf8(writes[1].1.clone())
.unwrap()
.contains("# bravo"));
}
#[tokio::test]
@ -1048,7 +1123,13 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() {
// 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()
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));
@ -1083,6 +1164,7 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() {
Arc::new(SpyBus::default()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall::default()),
None,
);
launch.execute(launch_input(agent.id)).await.unwrap();
@ -1097,7 +1179,10 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() {
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}");
assert!(
persona_at < refac_at && refac_at < review_at,
"ordering: {doc}"
);
}
#[tokio::test]
@ -1106,12 +1191,25 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
// `# 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),
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()),
profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
@ -1124,7 +1222,10 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
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("# Mémoire projet"),
"memory section present: {doc}"
);
assert!(
doc.contains("- [Git optionnel](git-optional.md) — git reste un simple tool (project)"),
"first memory line exact format: {doc}"
@ -1139,7 +1240,10 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
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}");
assert!(
first_at < second_at,
"entries kept in recalled order: {doc}"
);
}
#[tokio::test]
@ -1155,7 +1259,10 @@ async fn launch_conventionfile_without_memory_omits_section() {
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}");
assert!(
!doc.contains("# Mémoire projet"),
"no memory ⇒ no section: {doc}"
);
}
#[tokio::test]
@ -1164,7 +1271,10 @@ async fn launch_recall_error_degrades_to_no_section_without_failing() {
// 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()),
profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
@ -1172,7 +1282,10 @@ async fn launch_recall_error_degrades_to_no_section_without_failing() {
);
// Launch still succeeds despite the recall error.
launch.execute(launch_input(agent.id)).await.expect("launch must succeed");
launch
.execute(launch_input(agent.id))
.await
.expect("launch must succeed");
let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap();
assert!(
@ -1185,12 +1298,7 @@ async fn launch_recall_error_degrades_to_no_section_without_failing() {
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 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()),
@ -1214,7 +1322,10 @@ 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));
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(
@ -1240,11 +1351,18 @@ async fn launch_skips_dangling_skill_ref_without_failing() {
Arc::new(SpyBus::default()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall::default()),
None,
);
launch.execute(launch_input(agent.id)).await.expect("launch must succeed");
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}");
assert!(
!doc.contains("# Skills"),
"no Skills section for a dangling ref: {doc}"
);
}
#[tokio::test]
@ -1281,7 +1399,10 @@ async fn launch_stdin_strategy_pipes_context_after_spawn() {
// 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()]);
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));
@ -1290,10 +1411,8 @@ async fn launch_stdin_strategy_pipes_context_after_spawn() {
#[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 (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");
@ -1310,7 +1429,10 @@ async fn launch_unknown_profile_is_not_found() {
let launch = LaunchAgent::new(
Arc::new(contexts),
Arc::new(profiles),
Arc::new(FakeRuntime::new(Arc::clone(&tr), Some(ContextInjectionPlan::Stdin))),
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()),
@ -1318,6 +1440,7 @@ async fn launch_unknown_profile_is_not_found() {
Arc::new(SpyBus::default()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall::default()),
None,
);
let err = launch.execute(launch_input(agent.id)).await.unwrap_err();
@ -1358,18 +1481,26 @@ async fn launch_first_time_with_assign_flag_mints_and_exposes_conversation_id()
Some(ContextInjectionPlan::Stdin),
);
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
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()));
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 }),
Some(SessionPlan::Assign {
conversation_id: expected
}),
);
}
@ -1403,7 +1534,10 @@ async fn launch_profile_without_session_block_passes_none() {
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");
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));
@ -1418,8 +1552,14 @@ async fn launch_degraded_mode_without_assign_flag_first_launch_is_none() {
Some(ContextInjectionPlan::Stdin),
);
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
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!(
out.assigned_conversation_id, None,
"degraded mode mints no id"
);
assert_eq!(*session.lock().unwrap(), Some(SessionPlan::None));
}

View File

@ -0,0 +1,256 @@
//! L5 tests for the embedder-configuration use cases (LOT C2).
//!
//! Ports are faked in-memory so the use cases run without any I/O:
//! - [`InMemoryEmbedderProfileStore`] — an [`EmbedderProfileStore`] backed by a
//! `Vec` with the same upsert/delete-NotFound semantics as the real
//! `FsEmbedderProfileStore`,
//! - [`FixedEnvInspector`] — an [`EmbedderEnvInspector`] returning a constant
//! [`EmbedderEnvReport`] (best-effort port, never errors).
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::ports::{EmbedderEnvInspector, EmbedderEnvReport, EmbedderProfileStore, StoreError};
use domain::profile::{EmbedderProfile, EmbedderStrategy};
use application::{
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines,
ListEmbedderProfiles, OnnxModelView, SaveEmbedderProfile, SaveEmbedderProfileInput,
};
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
/// In-memory [`EmbedderProfileStore`] mirroring the real store's semantics:
/// upsert-by-id (no duplicate), delete-absent ⇒ [`StoreError::NotFound`].
#[derive(Default, Clone)]
struct InMemoryEmbedderProfileStore(Arc<Mutex<Vec<EmbedderProfile>>>);
#[async_trait]
impl EmbedderProfileStore for InMemoryEmbedderProfileStore {
async fn list(&self) -> Result<Vec<EmbedderProfile>, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError> {
let mut v = self.0.lock().unwrap();
if let Some(slot) = v.iter_mut().find(|p| p.id == profile.id) {
*slot = profile.clone();
} else {
v.push(profile.clone());
}
Ok(())
}
async fn delete(&self, id: &str) -> Result<(), StoreError> {
let mut v = self.0.lock().unwrap();
let before = v.len();
v.retain(|p| p.id != id);
if v.len() == before {
return Err(StoreError::NotFound);
}
Ok(())
}
}
/// An [`EmbedderEnvInspector`] returning a fixed report (best-effort, infallible).
struct FixedEnvInspector(EmbedderEnvReport);
#[async_trait]
impl EmbedderEnvInspector for FixedEnvInspector {
async fn inspect(&self) -> EmbedderEnvReport {
self.0.clone()
}
}
fn save_input(id: &str, name: &str, dimension: usize) -> SaveEmbedderProfileInput {
SaveEmbedderProfileInput {
id: id.to_owned(),
name: name.to_owned(),
strategy: EmbedderStrategy::LocalOnnx,
model: Some("multilingual-e5-small".to_owned()),
endpoint: None,
api_key_env: None,
dimension,
}
}
// ---------------------------------------------------------------------------
// ListEmbedderProfiles / SaveEmbedderProfile
// ---------------------------------------------------------------------------
#[tokio::test]
async fn list_is_empty_then_contains_saved_profile() {
let store = InMemoryEmbedderProfileStore::default();
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
assert!(
list.execute().await.unwrap().profiles.is_empty(),
"no profile configured ⇒ empty list (default `none` posture)"
);
let saved = save
.execute(save_input("local-onnx", "Local ONNX", 384))
.await
.unwrap();
assert_eq!(saved.profile.id, "local-onnx");
assert_eq!(saved.profile.dimension, 384);
let listed = list.execute().await.unwrap().profiles;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0], saved.profile);
}
#[tokio::test]
async fn save_upserts_by_id_without_duplicating() {
let store = InMemoryEmbedderProfileStore::default();
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
save.execute(save_input("e", "before", 384)).await.unwrap();
let updated = save.execute(save_input("e", "after", 768)).await.unwrap();
let listed = list.execute().await.unwrap().profiles;
assert_eq!(listed.len(), 1, "same id replaces, no duplicate");
assert_eq!(listed[0], updated.profile);
assert_eq!(listed[0].name, "after");
assert_eq!(listed[0].dimension, 768);
}
#[tokio::test]
async fn save_invalid_dimension_is_invalid_and_writes_nothing() {
let store = InMemoryEmbedderProfileStore::default();
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
let err = save
.execute(save_input("bad", "Bad", 0))
.await
.expect_err("dimension 0 must be rejected");
assert_eq!(err.code(), "INVALID", "got {err:?}");
assert!(
list.execute().await.unwrap().profiles.is_empty(),
"a rejected profile must not be persisted"
);
}
#[tokio::test]
async fn save_empty_name_is_invalid_and_writes_nothing() {
let store = InMemoryEmbedderProfileStore::default();
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
let err = save
.execute(save_input("id", "", 384))
.await
.expect_err("empty name must be rejected");
assert_eq!(err.code(), "INVALID", "got {err:?}");
assert!(
list.execute().await.unwrap().profiles.is_empty(),
"a rejected profile must not be persisted"
);
}
// ---------------------------------------------------------------------------
// DeleteEmbedderProfile
// ---------------------------------------------------------------------------
#[tokio::test]
async fn delete_removes_existing_profile() {
let store = InMemoryEmbedderProfileStore::default();
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
let delete = DeleteEmbedderProfile::new(Arc::new(store.clone()));
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
save.execute(save_input("a", "A", 384)).await.unwrap();
save.execute(save_input("b", "B", 384)).await.unwrap();
delete
.execute(DeleteEmbedderProfileInput { id: "a".to_owned() })
.await
.unwrap();
let listed = list.execute().await.unwrap().profiles;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, "b");
}
#[tokio::test]
async fn delete_unknown_id_is_not_found() {
let store = InMemoryEmbedderProfileStore::default();
let delete = DeleteEmbedderProfile::new(Arc::new(store));
let err = delete
.execute(DeleteEmbedderProfileInput {
id: "ghost".to_owned(),
})
.await
.expect_err("deleting an unknown id errors");
assert_eq!(
err.code(),
"NOT_FOUND",
"StoreError::NotFound ⇒ AppError::NotFound, got {err:?}"
);
}
// ---------------------------------------------------------------------------
// DescribeEmbedderEngines
// ---------------------------------------------------------------------------
fn onnx_view() -> OnnxModelView {
OnnxModelView {
id: "multilingual-e5-small".to_owned(),
display_name: "Multilingual E5 Small".to_owned(),
dimension: 384,
approx_size_mb: 118,
recommended: true,
}
}
#[tokio::test]
async fn describe_engines_merges_catalogue_report_and_flags() {
let report = EmbedderEnvReport {
ollama_detected: true,
onnx_cached_models: vec!["multilingual-e5-small".to_owned()],
};
let inspector = Arc::new(FixedEnvInspector(report));
let describe = DescribeEmbedderEngines::new(
inspector,
vec![onnx_view()],
/* vector_http_enabled */ true,
/* vector_onnx_enabled */ false,
);
let view = describe.execute().await.unwrap();
// Catalogue injected verbatim.
assert_eq!(view.recommended_onnx, vec![onnx_view()]);
// Report fields surfaced.
assert!(view.ollama_detected);
assert_eq!(view.onnx_cached_models, vec!["multilingual-e5-small"]);
// Compiled-capability flags surfaced as injected.
assert!(view.vector_http_enabled);
assert!(!view.vector_onnx_enabled);
}
#[tokio::test]
async fn describe_engines_with_nothing_detected() {
let inspector = Arc::new(FixedEnvInspector(EmbedderEnvReport {
ollama_detected: false,
onnx_cached_models: vec![],
}));
let describe = DescribeEmbedderEngines::new(inspector, vec![], false, true);
let view = describe.execute().await.unwrap();
assert!(view.recommended_onnx.is_empty());
assert!(!view.ollama_detected);
assert!(view.onnx_cached_models.is_empty());
assert!(!view.vector_http_enabled);
assert!(view.vector_onnx_enabled);
}

View File

@ -28,7 +28,10 @@ fn fs_not_found_maps_to_not_found_other_to_filesystem() {
AppError::from(FsError::PermissionDenied("/tmp/x".into())).code(),
"FILESYSTEM"
);
assert_eq!(AppError::from(FsError::Io("boom".into())).code(), "FILESYSTEM");
assert_eq!(
AppError::from(FsError::Io("boom".into())).code(),
"FILESYSTEM"
);
}
#[test]
@ -67,7 +70,10 @@ fn memory_errors_map_per_variant() {
assert_eq!(invalid.code(), "INVALID");
assert_eq!(invalid, AppError::Invalid("bad yaml".to_owned()));
// Io → Store.
assert_eq!(AppError::from(MemoryError::Io("disk full".into())).code(), "STORE");
assert_eq!(
AppError::from(MemoryError::Io("disk full".into())).code(),
"STORE"
);
// Serialization → Store (the catch-all arm).
assert_eq!(
AppError::from(MemoryError::Serialization("bad index".into())).code(),

View File

@ -82,11 +82,7 @@ impl GitPort for FakeGit {
self.record(&format!("checkout:{branch}"));
Ok(())
}
async fn log(
&self,
_r: &ProjectPath,
_limit: usize,
) -> Result<Vec<GitCommitInfo>, GitError> {
async fn log(&self, _r: &ProjectPath, _limit: usize) -> Result<Vec<GitCommitInfo>, GitError> {
self.record("log");
Ok(Vec::new())
}
@ -135,7 +131,9 @@ async fn status_passes_through_to_port() {
staged: true,
}]);
let out = GitStatus::new(Arc::new(git.clone()))
.execute(GitStatusInput { root: ROOT.to_owned() })
.execute(GitStatusInput {
root: ROOT.to_owned(),
})
.await
.unwrap();
assert_eq!(out.entries.len(), 1);
@ -229,9 +227,14 @@ async fn checkout_publishes_event() {
#[tokio::test]
async fn branches_returns_list_and_current() {
let git = FakeGit::default();
git.set_branches(vec!["main".to_owned(), "dev".to_owned()], Some("main".to_owned()));
git.set_branches(
vec!["main".to_owned(), "dev".to_owned()],
Some("main".to_owned()),
);
let out = GitBranches::new(Arc::new(git.clone()))
.execute(GitBranchesInput { root: ROOT.to_owned() })
.execute(GitBranchesInput {
root: ROOT.to_owned(),
})
.await
.unwrap();
assert_eq!(out.branches, vec!["main", "dev"]);

View File

@ -180,8 +180,14 @@ fn lid(n: u128) -> LayoutId {
}
async fn register_project(store: &FakeStore, id: ProjectId) -> ProjectId {
let project =
Project::new(id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0).unwrap();
let project = Project::new(
id,
"Demo",
ProjectPath::new(ROOT).unwrap(),
RemoteRef::Local,
0,
)
.unwrap();
store.save_project(&project).await.unwrap();
id
}
@ -264,7 +270,10 @@ async fn load_migrates_a_legacy_layout_json() {
let fs = FakeFs::default();
let id = register_project(&store, pid(2)).await;
// Only the legacy single-layout file exists.
fs.put(LEGACY_PATH, &serde_json::to_vec(&single_leaf(nid(7))).unwrap());
fs.put(
LEGACY_PATH,
&serde_json::to_vec(&single_leaf(nid(7))).unwrap(),
);
let load = LoadLayout::new(Arc::new(store), Arc::new(fs.clone()));
let out = load
@ -300,7 +309,10 @@ async fn load_defaults_to_single_empty_leaf_when_absent() {
_ => panic!("expected a single default leaf"),
}
// Default was written through; two loads are deterministic.
assert_eq!(root_leaf_id(&read_active_tree(&fs)), root_leaf_id(&out.layout));
assert_eq!(
root_leaf_id(&read_active_tree(&fs)),
root_leaf_id(&out.layout)
);
assert!(fs.has_dir("/home/me/proj/.ideai"));
}
@ -415,7 +427,10 @@ async fn mutate_split_persists_camelcase_layout_and_announces() {
let tree = active_tree_json(&env.fs);
assert_eq!(tree["root"]["type"], "split", "tagged on `type`");
assert_eq!(tree["root"]["node"]["direction"], "row");
assert_eq!(tree["root"]["node"]["children"].as_array().unwrap().len(), 2);
assert_eq!(
tree["root"]["node"]["children"].as_array().unwrap().len(),
2
);
assert_eq!(
env.bus.events(),
@ -499,6 +514,62 @@ async fn mutate_set_session_attaches_and_clears() {
.is_none());
}
#[tokio::test]
async fn mutate_attach_session_moves_session_between_cells() {
let env = mut_env(pid(13)).await;
env.mutate
.execute(MutateLayoutInput {
project_id: env.project_id,
layout_id: None,
operation: LayoutOperation::Split {
target: nid(1),
direction: Direction::Row,
new_leaf: nid(2),
container: nid(9),
},
})
.await
.expect("split");
env.mutate
.execute(MutateLayoutInput {
project_id: env.project_id,
layout_id: None,
operation: LayoutOperation::SetSession {
target: nid(1),
session: Some(sid(77)),
},
})
.await
.expect("set initial session");
let out = env
.mutate
.execute(MutateLayoutInput {
project_id: env.project_id,
layout_id: None,
operation: LayoutOperation::AttachSession {
target: nid(2),
session: sid(77),
},
})
.await
.expect("attach existing session");
match &out.layout.root {
LayoutNode::Split(split) => {
match &split.children[0].node {
LayoutNode::Leaf(leaf) => assert!(leaf.session.is_none()),
_ => panic!("expected first leaf"),
}
match &split.children[1].node {
LayoutNode::Leaf(leaf) => assert_eq!(leaf.session, Some(sid(77))),
_ => panic!("expected second leaf"),
}
}
_ => panic!("expected split root"),
}
}
#[tokio::test]
async fn mutate_invalid_op_errors_and_does_not_persist() {
let env = mut_env(pid(14)).await;
@ -744,7 +815,9 @@ async fn create_layout_appends_and_activates_it() {
.unwrap();
let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
.execute(ListLayoutsInput { project_id: pid(30) })
.execute(ListLayoutsInput {
project_id: pid(30),
})
.await
.unwrap();
assert_eq!(list.layouts.len(), 2, "Default + Backend");
@ -774,21 +847,19 @@ async fn create_layout_rejects_empty_name() {
#[tokio::test]
async fn rename_layout_changes_the_name() {
let (store, fs, bus) = mgmt_env(pid(32)).await;
RenameLayout::new(
Arc::new(store.clone()),
Arc::new(fs.clone()),
Arc::new(bus),
)
.execute(RenameLayoutInput {
project_id: pid(32),
layout_id: lid(1),
name: "Main".to_owned(),
})
.await
.unwrap();
RenameLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
.execute(RenameLayoutInput {
project_id: pid(32),
layout_id: lid(1),
name: "Main".to_owned(),
})
.await
.unwrap();
let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
.execute(ListLayoutsInput { project_id: pid(32) })
.execute(ListLayoutsInput {
project_id: pid(32),
})
.await
.unwrap();
assert_eq!(list.layouts[0].name, "Main");
@ -825,21 +896,23 @@ async fn delete_active_layout_reassigns_active() {
.await
.unwrap();
let out = DeleteLayout::new(
Arc::new(store.clone()),
Arc::new(fs.clone()),
Arc::new(bus),
)
.execute(DeleteLayoutInput {
project_id: pid(34),
layout_id: created.layout_id,
})
.await
.unwrap();
assert_eq!(out.active_id, lid(1), "active fell back to the Default layout");
let out = DeleteLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
.execute(DeleteLayoutInput {
project_id: pid(34),
layout_id: created.layout_id,
})
.await
.unwrap();
assert_eq!(
out.active_id,
lid(1),
"active fell back to the Default layout"
);
let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
.execute(ListLayoutsInput { project_id: pid(34) })
.execute(ListLayoutsInput {
project_id: pid(34),
})
.await
.unwrap();
assert_eq!(list.layouts.len(), 1);
@ -863,17 +936,13 @@ async fn set_active_layout_switches_and_load_follows() {
.unwrap();
// Switch back to the Default layout.
SetActiveLayout::new(
Arc::new(store.clone()),
Arc::new(fs.clone()),
Arc::new(bus),
)
.execute(SetActiveLayoutInput {
project_id: pid(35),
layout_id: lid(1),
})
.await
.unwrap();
SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
.execute(SetActiveLayoutInput {
project_id: pid(35),
layout_id: lid(1),
})
.await
.unwrap();
let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs))
.execute(LoadLayoutInput {

View File

@ -17,7 +17,8 @@ use domain::{
use application::{
CreateMemory, CreateMemoryInput, DeleteMemory, DeleteMemoryInput, GetMemory, GetMemoryInput,
ListMemories, ListMemoriesInput, ReadMemoryIndex, ReadMemoryIndexInput, RecallMemory,
RecallMemoryInput, ResolveMemoryLinks, ResolveMemoryLinksInput, UpdateMemory, UpdateMemoryInput,
RecallMemoryInput, ResolveMemoryLinks, ResolveMemoryLinksInput, UpdateMemory,
UpdateMemoryInput,
};
// ---------------------------------------------------------------------------
@ -65,10 +66,7 @@ impl MemoryStore for FakeMemories {
Ok(())
}
async fn read_index(
&self,
_root: &ProjectPath,
) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
async fn read_index(&self, _root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
Ok(self
.0
.lock()
@ -292,7 +290,15 @@ async fn update_revalidates_invariants_and_rejects_empty_content() {
assert_eq!(err.code(), "INVALID");
// Original note untouched, no event.
assert_eq!(store.get(&root(), &slug("note-a")).await.unwrap().body.as_str(), "v1");
assert_eq!(
store
.get(&root(), &slug("note-a"))
.await
.unwrap()
.body
.as_str(),
"v1"
);
assert!(bus.events().is_empty());
}

View File

@ -17,7 +17,8 @@ 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::ids::SkillId;
use domain::ids::{AgentId, NodeId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
@ -25,7 +26,6 @@ use domain::ports::{
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
};
use domain::ids::SkillId;
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
@ -65,7 +65,10 @@ impl FakeContexts {
let me = Self::new();
{
let mut inner = me.0.lock().unwrap();
inner.manifest.entries.push(ManifestEntry::from_agent(agent));
inner
.manifest
.entries
.push(ManifestEntry::from_agent(agent));
inner
.contents
.insert(agent.context_path.clone(), content.to_owned());
@ -160,7 +163,11 @@ impl ProfileStore for FakeProfiles {
struct FakeSkills;
#[async_trait]
impl SkillStore for FakeSkills {
async fn list(&self, _scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
async fn list(
&self,
_scope: SkillScope,
_root: &ProjectPath,
) -> Result<Vec<Skill>, StoreError> {
Ok(Vec::new())
}
async fn get(
@ -206,7 +213,11 @@ struct RecordingSkills(Arc<Mutex<Vec<Skill>>>);
#[async_trait]
impl SkillStore for RecordingSkills {
async fn list(&self, _scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
async fn list(
&self,
_scope: SkillScope,
_root: &ProjectPath,
) -> Result<Vec<Skill>, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn get(
@ -287,14 +298,19 @@ impl FileSystem for FakeFs {
struct FakePty {
next_id: SessionId,
kills: Arc<Mutex<Vec<SessionId>>>,
spawns: Arc<Mutex<Vec<SessionId>>>,
}
impl FakePty {
fn new(next_id: SessionId) -> Self {
Self {
next_id,
kills: Arc::new(Mutex::new(Vec::new())),
spawns: Arc::new(Mutex::new(Vec::new())),
}
}
fn spawns(&self) -> Vec<SessionId> {
self.spawns.lock().unwrap().clone()
}
fn kills(&self) -> Vec<SessionId> {
self.kills.lock().unwrap().clone()
}
@ -302,6 +318,7 @@ impl FakePty {
#[async_trait]
impl PtyPort for FakePty {
async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
self.spawns.lock().unwrap().push(self.next_id);
Ok(PtyHandle {
session_id: self.next_id,
})
@ -368,6 +385,9 @@ fn aid(n: u128) -> AgentId {
fn sid(n: u128) -> SessionId {
SessionId::from_uuid(Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n))
}
fn project() -> Project {
Project::new(
@ -430,6 +450,7 @@ fn fixture(contexts: FakeContexts) -> Fixture {
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall),
None,
));
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(
@ -496,11 +517,9 @@ async fn spawn_unknown_agent_creates_then_launches() {
assert_eq!(manifest.entries[0].name, "dev-backend");
assert!(fx.sessions.session(&sid(777)).is_some());
let launched = fx
.bus
.events()
.into_iter()
.any(|e| matches!(e, DomainEvent::AgentLaunched { session_id, .. } if session_id == sid(777)));
let launched = fx.bus.events().into_iter().any(
|e| matches!(e, DomainEvent::AgentLaunched { session_id, .. } if session_id == sid(777)),
);
assert!(launched, "AgentLaunched must be published");
}
@ -523,6 +542,57 @@ async fn spawn_known_agent_launches_without_recreating() {
assert!(fx.sessions.session(&sid(777)).is_some());
}
#[tokio::test]
async fn agent_run_existing_agent_does_not_require_profile() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
fx.service
.dispatch(
&project(),
cmd(r#"{ "type":"agent.run", "targetAgent":"architect", "task":"Analyse" }"#),
)
.await
.expect("dispatch ok");
assert_eq!(fx.contexts.manifest().entries.len(), 1);
assert!(fx.sessions.session(&sid(777)).is_some());
}
#[tokio::test]
async fn agent_run_visible_reattaches_existing_session() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
let first_cell = nid(10);
let next_cell = nid(20);
fx.service
.dispatch(
&project(),
cmd(&format!(
r#"{{ "action":"spawn_agent", "name":"architect", "profile":"claude", "visibility":"visible", "nodeId":"{first_cell}" }}"#
)),
)
.await
.expect("first launch ok");
let out = fx
.service
.dispatch(
&project(),
cmd(&format!(
r#"{{ "type":"agent.run", "targetAgent":"architect", "visibility":"visible", "attachToCell":"{next_cell}" }}"#
)),
)
.await
.expect("reattach ok");
assert!(out.detail.contains("attached agent architect"));
let session = fx.sessions.session(&sid(777)).expect("live session");
assert_eq!(session.node_id, next_cell);
assert_eq!(fx.pty.spawns().len(), 1, "reattach must not respawn");
}
#[tokio::test]
async fn stop_agent_kills_the_right_session() {
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");

View File

@ -92,9 +92,7 @@ impl AgentRuntime for StubRuntime {
match self.by_command.get(&profile.command) {
Some(DetectResult::Available) => Ok(true),
Some(DetectResult::Missing) | None => Ok(false),
Some(DetectResult::Error) => {
Err(RuntimeError::Detection("boom".to_owned()))
}
Some(DetectResult::Error) => Err(RuntimeError::Detection("boom".to_owned())),
}
}
@ -163,7 +161,10 @@ async fn detect_error_degrades_to_unavailable_not_hard_failure() {
.await
.expect("detection error must not fail the use case");
assert!(!out.results[0].available, "errored detection ⇒ available:false");
assert!(
!out.results[0].available,
"errored detection ⇒ available:false"
);
}
// ---------------------------------------------------------------------------
@ -248,7 +249,10 @@ async fn save_then_list_then_delete() {
assert_eq!(list.execute().await.unwrap().profiles, vec![p.clone()]);
delete.execute(DeleteProfileInput { id: p.id }).await.unwrap();
delete
.execute(DeleteProfileInput { id: p.id })
.await
.unwrap();
assert!(list.execute().await.unwrap().profiles.is_empty());
}

View File

@ -22,7 +22,8 @@ use domain::{Project, ProjectId, ProjectPath, RemoteRef};
use application::{
CloseProject, CloseProjectInput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput,
ListProjects, OpenProject, OpenProjectInput,
ListProjects, OpenProject, OpenProjectInput, ReadProjectContext, ReadProjectContextInput,
UpdateProjectContext, UpdateProjectContextInput,
};
// ---------------------------------------------------------------------------
@ -75,11 +76,7 @@ impl FileSystem for FakeFs {
}
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
self.0
.lock()
.unwrap()
.dirs
.insert(path.as_str().to_owned());
self.0.lock().unwrap().dirs.insert(path.as_str().to_owned());
Ok(())
}
@ -221,6 +218,8 @@ struct Env {
bus: SpyBus,
create: CreateProject,
open: OpenProject,
read_context: ReadProjectContext,
update_context: UpdateProjectContext,
}
fn env() -> Env {
@ -238,6 +237,8 @@ fn env() -> Env {
Arc::new(bus.clone()),
);
let open = OpenProject::new(Arc::new(store.clone()), Arc::new(fs.clone()));
let read_context = ReadProjectContext::new(Arc::new(fs.clone()));
let update_context = UpdateProjectContext::new(Arc::new(fs.clone()));
Env {
store,
@ -245,6 +246,8 @@ fn env() -> Env {
bus,
create,
open,
read_context,
update_context,
}
}
@ -301,6 +304,44 @@ async fn create_registers_project_in_store() {
assert_eq!(stored[0].name, "Demo");
}
#[tokio::test]
async fn project_context_lives_under_ideai_and_missing_reads_empty() {
let env = env();
let out = env.create.execute(input("Demo", "/p")).await.unwrap();
let missing = env
.read_context
.execute(ReadProjectContextInput {
project: out.project.clone(),
})
.await
.unwrap();
assert_eq!(missing.content, "");
env.update_context
.execute(UpdateProjectContextInput {
project: out.project.clone(),
content: "# Shared\n\nUse pnpm.".to_owned(),
})
.await
.unwrap();
let bytes = env
.fs
.read_file("/p/.ideai/CONTEXT.md")
.expect("project context written under .ideai");
assert_eq!(String::from_utf8(bytes).unwrap(), "# Shared\n\nUse pnpm.");
let read_back = env
.read_context
.execute(ReadProjectContextInput {
project: out.project,
})
.await
.unwrap();
assert_eq!(read_back.content, "# Shared\n\nUse pnpm.");
}
#[tokio::test]
async fn create_publishes_project_created_event() {
let env = env();
@ -543,7 +584,10 @@ async fn close_without_workspace_skips_persistence() {
.await
.unwrap();
assert!(store.saved_workspace().is_none(), "no persistence when None");
assert!(
store.saved_workspace().is_none(),
"no persistence when None"
);
}
#[tokio::test]

View File

@ -50,7 +50,11 @@ struct FakeHost {
fs: Arc<dyn FileSystem>,
}
impl FakeHost {
fn make(kind: RemoteKind, connect_ok: bool, existing_root: Option<&str>) -> Arc<dyn RemoteHost> {
fn make(
kind: RemoteKind,
connect_ok: bool,
existing_root: Option<&str>,
) -> Arc<dyn RemoteHost> {
Arc::new(Self {
kind,
connect_ok,

View File

@ -58,7 +58,10 @@ impl SkillStore for FakeSkills {
}
async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
let mut v = self.0.lock().unwrap();
if let Some(slot) = v.iter_mut().find(|s| s.scope == skill.scope && s.id == skill.id) {
if let Some(slot) = v
.iter_mut()
.find(|s| s.scope == skill.scope && s.id == skill.id)
{
*slot = skill.clone();
} else {
v.push(skill.clone());
@ -85,7 +88,10 @@ impl SkillStore for FakeSkills {
struct FakeContexts(Arc<Mutex<AgentManifest>>);
impl FakeContexts {
fn new(entries: Vec<ManifestEntry>) -> Self {
Self(Arc::new(Mutex::new(AgentManifest { version: 1, entries })))
Self(Arc::new(Mutex::new(AgentManifest {
version: 1,
entries,
})))
}
fn manifest(&self) -> AgentManifest {
self.0.lock().unwrap().clone()
@ -197,7 +203,11 @@ async fn create_skill_persists_in_its_scope() {
assert_eq!(out.skill.scope, SkillScope::Project);
assert_eq!(
store.list(SkillScope::Project, &root()).await.unwrap().len(),
store
.list(SkillScope::Project, &root())
.await
.unwrap()
.len(),
1
);
assert!(store

View File

@ -186,8 +186,14 @@ fn agent_leaf(node: NodeId, agent: Option<AgentId>) -> LeafCell {
}
async fn register_project(store: &FakeStore, id: ProjectId) {
let project =
Project::new(id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0).unwrap();
let project = Project::new(
id,
"Demo",
ProjectPath::new(ROOT).unwrap(),
RemoteRef::Local,
0,
)
.unwrap();
store.save_project(&project).await.unwrap();
}
@ -222,11 +228,7 @@ fn was_running(fs: &FakeFs, node: NodeId) -> Option<bool> {
find(&tree.root, node)
}
fn make_use_case(
store: &FakeStore,
fs: &FakeFs,
live: &FakeLive,
) -> SnapshotRunningAgents {
fn make_use_case(store: &FakeStore, fs: &FakeFs, live: &FakeLive) -> SnapshotRunningAgents {
SnapshotRunningAgents::new(
Arc::new(store.clone()) as Arc<dyn ProjectStore>,
Arc::new(fs.clone()) as Arc<dyn FileSystem>,
@ -247,7 +249,11 @@ async fn live_agent_is_marked_running() {
let leaf = nid(10);
let agent = aid(100);
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
seed_layouts(
&fs,
lid(1),
&LayoutTree::single(agent_leaf(leaf, Some(agent))),
);
let live = FakeLive::with_nodes(&[leaf]);
let uc = make_use_case(&store, &fs, &live);
@ -271,7 +277,11 @@ async fn stopped_agent_is_marked_not_running() {
let leaf = nid(10);
let agent = aid(100);
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
seed_layouts(
&fs,
lid(1),
&LayoutTree::single(agent_leaf(leaf, Some(agent))),
);
// Registry is empty: the agent has no live session.
let live = FakeLive::default();
@ -347,7 +357,11 @@ async fn snapshot_reads_liveness_before_kill() {
let leaf = nid(10);
let agent = aid(100);
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
seed_layouts(
&fs,
lid(1),
&LayoutTree::single(agent_leaf(leaf, Some(agent))),
);
let live = FakeLive::with_nodes(&[leaf]);
let uc = make_use_case(&store, &fs, &live);
@ -365,7 +379,11 @@ async fn snapshot_reads_liveness_before_kill() {
// Re-seed and demonstrate the opposite order yields `false` — proving the
// flag is sensitive to registry state at call time (hence order matters).
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
seed_layouts(
&fs,
lid(1),
&LayoutTree::single(agent_leaf(leaf, Some(agent))),
);
uc.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await
.unwrap();
@ -443,7 +461,10 @@ async fn duplicate_leaves_same_agent_only_live_node_is_running() {
.unwrap();
assert_eq!(out.running, 1, "only the live cell counts as running");
assert_eq!(out.stopped, 1, "the duplicate (dead) cell counts as stopped");
assert_eq!(
out.stopped, 1,
"the duplicate (dead) cell counts as stopped"
);
assert_eq!(was_running(&fs, leaf_a), Some(true));
assert_eq!(
was_running(&fs, leaf_b),

View File

@ -69,7 +69,10 @@ struct FakeContexts(Arc<Mutex<(AgentManifest, HashMap<String, String>)>>);
impl FakeContexts {
fn new(entries: Vec<ManifestEntry>) -> Self {
Self(Arc::new(Mutex::new((
AgentManifest { version: 1, entries },
AgentManifest {
version: 1,
entries,
},
HashMap::new(),
))))
}
@ -92,13 +95,11 @@ impl FakeContexts {
}
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_p: &Project,
agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
async fn read_context(&self, _p: &Project, agent: &AgentId) -> Result<MarkdownDoc, StoreError> {
let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
self.content(&md).map(MarkdownDoc::new).ok_or(StoreError::NotFound)
self.content(&md)
.map(MarkdownDoc::new)
.ok_or(StoreError::NotFound)
}
async fn write_context(
&self,
@ -107,7 +108,11 @@ impl AgentContextStore for FakeContexts {
md: &MarkdownDoc,
) -> Result<(), StoreError> {
let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
self.0.lock().unwrap().1.insert(path, md.as_str().to_owned());
self.0
.lock()
.unwrap()
.1
.insert(path, md.as_str().to_owned());
Ok(())
}
async fn load_manifest(&self, _p: &Project) -> Result<AgentManifest, StoreError> {
@ -186,7 +191,16 @@ fn template(id: TemplateId, name: &str, content: &str, version: u64) -> AgentTem
}
/// A synchronized, template-backed manifest entry synced at `synced`.
fn synced_entry(agent: AgentId, md: &str, template: TemplateId, synced: u64) -> ManifestEntry {
ManifestEntry::new(agent, "A", md, pid(1), Some(template), true, Some(v(synced))).unwrap()
ManifestEntry::new(
agent,
"A",
md,
pid(1),
Some(template),
true,
Some(v(synced)),
)
.unwrap()
}
// ---------------------------------------------------------------------------
@ -234,13 +248,16 @@ async fn update_template_bumps_version_and_publishes_event() {
#[tokio::test]
async fn update_unknown_template_is_not_found() {
let err = UpdateTemplate::new(Arc::new(FakeTemplates::default()), Arc::new(SpyBus::default()))
.execute(UpdateTemplateInput {
template_id: tid(404),
content: "x".to_owned(),
})
.await
.unwrap_err();
let err = UpdateTemplate::new(
Arc::new(FakeTemplates::default()),
Arc::new(SpyBus::default()),
)
.execute(UpdateTemplateInput {
template_id: tid(404),
content: "x".to_owned(),
})
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
@ -279,7 +296,10 @@ async fn create_agent_from_template_links_origin_and_seeds_context() {
}
);
// Context seeded with the template content under the agent's md path.
assert_eq!(contexts.content(&out.agent.context_path).as_deref(), Some("# body"));
assert_eq!(
contexts.content(&out.agent.context_path).as_deref(),
Some("# body")
);
assert_eq!(contexts.manifest().entries.len(), 1);
}
@ -295,8 +315,16 @@ async fn detect_drift_flags_only_synchronized_agents_behind() {
// a2: synchronized, synced at v3 → up to date, no drift.
// a3: from template but NOT synchronized → ignored.
// a4: scratch (no template) → ignored.
let a3 = ManifestEntry::new(aid(3), "A3", "agents/a3.md", pid(1), Some(tid(1)), false, Some(v(1)))
.unwrap();
let a3 = ManifestEntry::new(
aid(3),
"A3",
"agents/a3.md",
pid(1),
Some(tid(1)),
false,
Some(v(1)),
)
.unwrap();
let a4 = ManifestEntry::new(aid(4), "A4", "agents/a4.md", pid(1), None, false, None).unwrap();
let contexts = FakeContexts::new(vec![
synced_entry(aid(1), "agents/a1.md", tid(1), 1),
@ -306,14 +334,10 @@ async fn detect_drift_flags_only_synchronized_agents_behind() {
]);
let bus = SpyBus::default();
let out = DetectAgentDrift::new(
Arc::new(store),
Arc::new(contexts),
Arc::new(bus.clone()),
)
.execute(DetectAgentDriftInput { project: project() })
.await
.unwrap();
let out = DetectAgentDrift::new(Arc::new(store), Arc::new(contexts), Arc::new(bus.clone()))
.execute(DetectAgentDriftInput { project: project() })
.await
.unwrap();
assert_eq!(out.drifts.len(), 1, "only a1 drifts");
assert_eq!(out.drifts[0].agent_id, aid(1));
@ -375,7 +399,10 @@ async fn sync_applies_to_synchronized_and_updates_version_and_context() {
assert!(out.synced);
assert_eq!(out.version, Some(v(3)));
// Context replaced by the template content.
assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("newest body"));
assert_eq!(
contexts.content("agents/a1.md").as_deref(),
Some("newest body")
);
// Manifest synced version advanced to 3.
let entry = &contexts.manifest().entries[0];
assert_eq!(entry.synced_template_version, Some(v(3)));
@ -392,9 +419,16 @@ async fn sync_applies_to_synchronized_and_updates_version_and_context() {
async fn sync_ignores_non_synchronized_agent() {
let store = FakeTemplates::with(vec![template(tid(1), "T", "body", 3)]);
// Non-synchronized agent from a template.
let entry =
ManifestEntry::new(aid(1), "A", "agents/a1.md", pid(1), Some(tid(1)), false, Some(v(1)))
.unwrap();
let entry = ManifestEntry::new(
aid(1),
"A",
"agents/a1.md",
pid(1),
Some(tid(1)),
false,
Some(v(1)),
)
.unwrap();
let contexts = FakeContexts::new(vec![entry]);
contexts
.write_context(&project(), &aid(1), &MarkdownDoc::new("keep me"))
@ -417,5 +451,8 @@ async fn sync_ignores_non_synchronized_agent() {
assert!(!out.synced, "non-synchronized agent is left untouched");
assert_eq!(out.version, None);
assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("keep me"));
assert!(bus.events().is_empty(), "no sync event for an ignored agent");
assert!(
bus.events().is_empty(),
"no sync event for an ignored agent"
);
}

View File

@ -447,16 +447,16 @@ async fn close_kills_pty_removes_session_and_returns_code() {
let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
let out = close
.execute(CloseTerminalInput {
session_id: sid(5),
})
.execute(CloseTerminalInput { session_id: sid(5) })
.await
.expect("close succeeds");
assert_eq!(out.code, Some(0));
assert!(sessions.is_empty(), "session removed from registry");
assert!(
pty.calls().iter().any(|c| matches!(c, Call::Kill { id } if *id == sid(5))),
pty.calls()
.iter()
.any(|c| matches!(c, Call::Kill { id } if *id == sid(5))),
"kill called for the session"
);
}
@ -477,9 +477,7 @@ async fn close_surfaces_signal_exit_as_none_code() {
let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
let out = close
.execute(CloseTerminalInput {
session_id: sid(6),
})
.execute(CloseTerminalInput { session_id: sid(6) })
.await
.unwrap();
assert_eq!(out.code, None);