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:
@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user