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

@ -13,13 +13,13 @@ use std::sync::Arc;
use async_trait::async_trait;
use domain::ids::ProfileId;
use domain::ports::{
AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError,
ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec,
};
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
use domain::project::ProjectPath;
use domain::ids::ProfileId;
use domain::MarkdownDoc;
use infrastructure::CliAgentRuntime;
@ -98,7 +98,9 @@ fn prepare_convention_file_keeps_args_and_plans_file() {
);
let root = ProjectPath::new("/home/me/proj").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
.unwrap();
assert_eq!(spec.command, "mycli");
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged");
@ -124,7 +126,9 @@ fn prepare_flag_with_path_substitutes_and_splits() {
);
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
.unwrap();
// static args first, then the substituted+split flag args.
assert_eq!(
@ -149,7 +153,9 @@ fn prepare_flag_without_path_is_switch_then_path() {
let p = profile(ContextInjection::flag("-f").unwrap(), "{projectRoot}");
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
.unwrap();
assert_eq!(spec.args, vec!["--static", "arg", "-f", ".ideai/agent.md"]);
assert_eq!(
@ -170,9 +176,15 @@ fn prepare_stdin_keeps_args_and_plans_stdin() {
let p = profile(ContextInjection::stdin(), "{projectRoot}");
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
.unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for stdin");
assert_eq!(
spec.args,
vec!["--static", "arg"],
"args unchanged for stdin"
);
assert_eq!(spec.context_plan, Some(ContextInjectionPlan::Stdin));
}
@ -189,7 +201,9 @@ fn prepare_env_keeps_args_and_plans_env() {
);
let root = ProjectPath::new("/p").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
.unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for env");
assert_eq!(
@ -207,13 +221,12 @@ fn prepare_env_keeps_args_and_plans_env() {
#[test]
fn prepare_substitutes_project_root_in_cwd_template() {
let rt = pure_runtime();
let p = profile(
ContextInjection::stdin(),
"{projectRoot}/subdir",
);
let p = profile(ContextInjection::stdin(), "{projectRoot}/subdir");
let root = ProjectPath::new("/home/me/proj").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None)
.unwrap();
assert_eq!(spec.cwd.as_str(), "/home/me/proj/subdir");
}
@ -223,7 +236,9 @@ fn prepare_empty_cwd_template_defaults_to_base() {
let p = profile(ContextInjection::stdin(), "");
let base = ProjectPath::new("/home/me/proj").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &base, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &base, &SessionPlan::None)
.unwrap();
assert_eq!(spec.cwd.as_str(), "/home/me/proj");
}
@ -232,10 +247,15 @@ fn prepare_substitutes_agent_run_dir_in_cwd_template() {
// The canonical template (ARCHITECTURE §14.1): `{agentRunDir}` resolves to the
// base cwd the launcher passes — the agent's isolated run directory.
let rt = pure_runtime();
let p = profile(ContextInjection::convention_file("CLAUDE.md").unwrap(), "{agentRunDir}");
let p = profile(
ContextInjection::convention_file("CLAUDE.md").unwrap(),
"{agentRunDir}",
);
let run_dir = ProjectPath::new("/home/me/proj/.ideai/run/agent-1").unwrap();
let spec = rt.prepare_invocation(&p, &ctx(), &run_dir, &SessionPlan::None).unwrap();
let spec = rt
.prepare_invocation(&p, &ctx(), &run_dir, &SessionPlan::None)
.unwrap();
assert_eq!(spec.cwd.as_str(), "/home/me/proj/.ideai/run/agent-1");
}
@ -362,8 +382,12 @@ fn session_none_profile_adds_nothing_for_any_plan() {
for plan in [
SessionPlan::None,
SessionPlan::Assign { conversation_id: "id-1".to_owned() },
SessionPlan::Resume { conversation_id: "id-1".to_owned() },
SessionPlan::Assign {
conversation_id: "id-1".to_owned(),
},
SessionPlan::Resume {
conversation_id: "id-1".to_owned(),
},
] {
let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}");
@ -383,7 +407,9 @@ fn session_assign_with_flag_emits_flag_and_id() {
&p,
&ctx(),
&root(),
&SessionPlan::Assign { conversation_id: "abc".to_owned() },
&SessionPlan::Assign {
conversation_id: "abc".to_owned(),
},
)
.unwrap();
@ -403,7 +429,9 @@ fn session_resume_with_flag_emits_resume_and_id() {
&p,
&ctx(),
&root(),
&SessionPlan::Resume { conversation_id: "abc".to_owned() },
&SessionPlan::Resume {
conversation_id: "abc".to_owned(),
},
)
.unwrap();
@ -421,7 +449,9 @@ fn session_resume_without_assign_flag_emits_resume_only() {
&p,
&ctx(),
&root(),
&SessionPlan::Resume { conversation_id: "abc".to_owned() },
&SessionPlan::Resume {
conversation_id: "abc".to_owned(),
},
)
.unwrap();
@ -454,7 +484,9 @@ fn session_assign_without_flag_adds_nothing() {
&p,
&ctx(),
&root(),
&SessionPlan::Assign { conversation_id: "abc".to_owned() },
&SessionPlan::Assign {
conversation_id: "abc".to_owned(),
},
)
.unwrap();
@ -471,8 +503,12 @@ fn no_session_profile_is_unaffected_by_any_plan() {
for plan in [
SessionPlan::None,
SessionPlan::Assign { conversation_id: "x".to_owned() },
SessionPlan::Resume { conversation_id: "x".to_owned() },
SessionPlan::Assign {
conversation_id: "x".to_owned(),
},
SessionPlan::Resume {
conversation_id: "x".to_owned(),
},
] {
let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}");
@ -500,7 +536,9 @@ fn session_args_come_after_context_injection_args() {
&p,
&ctx(),
&root(),
&SessionPlan::Assign { conversation_id: "abc".to_owned() },
&SessionPlan::Assign {
conversation_id: "abc".to_owned(),
},
)
.unwrap();

View File

@ -140,8 +140,14 @@ async fn manifest_file_is_camelcase_json_under_ideai() {
.expect("top-level `agents` array");
assert_eq!(agents.len(), 1);
let entry = &agents[0];
assert_eq!(entry.get("mdPath").and_then(|v| v.as_str()), Some("agents/backend.md"));
assert_eq!(
entry.get("mdPath").and_then(|v| v.as_str()),
Some("agents/backend.md")
);
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("Backend"));
assert!(entry.get("profileId").is_some(), "camelCase profileId present");
assert!(
entry.get("profileId").is_some(),
"camelCase profileId present"
);
assert!(entry.get("md_path").is_none(), "no snake_case leak");
}

View File

@ -0,0 +1,236 @@
//! L5 integration tests for the LOT C2 embedder-config adapters:
//! - [`FsEmbedderProfileStore`] driven through the [`EmbedderProfileStore`] port
//! against a real [`LocalFileSystem`] + temp dir (round-trip, `embedder.json`
//! persistence, delete-absent ⇒ `NotFound`),
//! - [`EmbedderEnvProbe::inspect`] without the HTTP feature (`ollama_detected`
//! always `false`) and reflecting a prepared ONNX cache.
//!
//! No network is required. The temp-dir convention mirrors `tests/project_store.rs`
//! (`std::env::temp_dir()` + a UUID, self-cleaning on drop) — no new dependency.
use std::path::PathBuf;
use std::sync::Arc;
use domain::ports::{
EmbedderEnvInspector, EmbedderProfileStore, FileSystem, RemotePath, StoreError,
};
use domain::profile::{EmbedderProfile, EmbedderStrategy};
use infrastructure::{
EmbedderEnvProbe, FsEmbedderProfileStore, LocalFileSystem, ONNX_CACHE_SUBDIR,
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
};
use uuid::Uuid;
/// A unique scratch directory under the OS temp dir, cleaned up on drop.
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-l5-embedder-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
fn app_data_dir(&self) -> String {
self.0.to_string_lossy().into_owned()
}
fn path(&self) -> &std::path::Path {
&self.0
}
fn child(&self, name: &str) -> RemotePath {
RemotePath::new(self.0.join(name).to_string_lossy().into_owned())
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn store(tmp: &TempDir) -> FsEmbedderProfileStore {
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
FsEmbedderProfileStore::new(fs, tmp.app_data_dir())
}
fn sample(id: &str, name: &str, dimension: usize) -> EmbedderProfile {
EmbedderProfile::new(
id,
name,
EmbedderStrategy::LocalOnnx,
Some("multilingual-e5-small".to_owned()),
None,
None,
dimension,
)
.unwrap()
}
// ---------------------------------------------------------------------------
// FsEmbedderProfileStore via the EmbedderProfileStore port
// ---------------------------------------------------------------------------
#[tokio::test]
async fn save_then_list_roundtrips() {
let tmp = TempDir::new();
let store: &dyn EmbedderProfileStore = &store(&tmp);
assert!(
store.list().await.unwrap().is_empty(),
"no embedder.json yet ⇒ empty list"
);
let p = sample("local-onnx", "Local ONNX", 384);
store.save(&p).await.unwrap();
assert_eq!(store.list().await.unwrap(), vec![p]);
}
#[tokio::test]
async fn save_upserts_by_id_without_duplicating() {
let tmp = TempDir::new();
let store: &dyn EmbedderProfileStore = &store(&tmp);
store.save(&sample("e", "before", 384)).await.unwrap();
let updated = sample("e", "after", 768);
store.save(&updated).await.unwrap();
let listed = store.list().await.unwrap();
assert_eq!(listed.len(), 1, "upsert must not duplicate by id");
assert_eq!(listed[0], updated);
}
#[tokio::test]
async fn delete_removes_profile() {
let tmp = TempDir::new();
let store: &dyn EmbedderProfileStore = &store(&tmp);
store.save(&sample("a", "A", 384)).await.unwrap();
store.save(&sample("b", "B", 384)).await.unwrap();
store.delete("a").await.unwrap();
let listed = store.list().await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, "b");
}
#[tokio::test]
async fn delete_unknown_is_not_found() {
let tmp = TempDir::new();
let store: &dyn EmbedderProfileStore = &store(&tmp);
store.save(&sample("a", "A", 384)).await.unwrap();
let err = store
.delete("ghost")
.await
.expect_err("deleting unknown id fails");
assert!(matches!(err, StoreError::NotFound), "got {err:?}");
}
#[tokio::test]
async fn embedder_file_is_camelcase_versioned() {
let tmp = TempDir::new();
let store: &dyn EmbedderProfileStore = &store(&tmp);
store
.save(
&EmbedderProfile::new(
"api-openai",
"OpenAI",
EmbedderStrategy::Api,
Some("text-embedding-3-small".to_owned()),
Some("https://api.openai.com/v1/embeddings".to_owned()),
Some("OPENAI_API_KEY".to_owned()),
1536,
)
.unwrap(),
)
.await
.unwrap();
let fs = LocalFileSystem::new();
let bytes = fs.read(&tmp.child("embedder.json")).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["version"], 1);
let profiles = json
.get("profiles")
.and_then(|v| v.as_array())
.expect("top-level `profiles` array");
assert_eq!(profiles.len(), 1);
let entry = &profiles[0];
assert_eq!(entry["id"], "api-openai");
assert_eq!(entry["strategy"], "api");
// camelCase field, never the secret itself.
assert_eq!(entry["apiKeyEnv"], "OPENAI_API_KEY");
assert!(entry.get("api_key_env").is_none(), "no snake_case leak");
assert_eq!(entry["dimension"], 1536);
}
// ---------------------------------------------------------------------------
// EmbedderEnvProbe::inspect — pure-FS cache scan, no network
// ---------------------------------------------------------------------------
#[tokio::test]
async fn probe_empty_cache_reports_nothing() {
let tmp = TempDir::new();
// An ONNX cache subdir that exists but is empty ⇒ no cached models.
let cache = tmp.path().join(ONNX_CACHE_SUBDIR);
std::fs::create_dir_all(&cache).unwrap();
let probe = EmbedderEnvProbe::new(cache, "http://127.0.0.1:1"); // unreachable host
let report = probe.inspect().await;
assert!(report.onnx_cached_models.is_empty(), "empty cache ⇒ none");
assert!(
!report.ollama_detected,
"without vector-http (or with an unreachable host) ⇒ never detected"
);
}
#[tokio::test]
async fn probe_missing_cache_dir_is_best_effort_empty() {
// A cache dir that does not exist must not panic — best-effort ⇒ empty.
let cache = std::env::temp_dir().join(format!("idea-no-such-cache-{}", Uuid::new_v4()));
assert!(!cache.exists());
let probe = EmbedderEnvProbe::new(cache, "http://127.0.0.1:1");
let report = probe.inspect().await;
assert!(report.onnx_cached_models.is_empty());
assert!(!report.ollama_detected);
}
#[tokio::test]
async fn probe_detects_prepared_onnx_cache() {
let tmp = TempDir::new();
let cache = tmp.path().join(ONNX_CACHE_SUBDIR);
std::fs::create_dir_all(&cache).unwrap();
// Recreate the hf-hub-style cache layout for the recommended model:
// a non-empty subdirectory whose name contains the model token
// (`onnx_model_is_cached`: needle = id with `_`/`/`→`-`, lowercased).
let model = RECOMMENDED_ONNX_MODELS
.iter()
.find(|m| m.recommended)
.expect("a recommended model exists");
let model_dir = cache.join(format!("models--intfloat--{}", model.id));
std::fs::create_dir_all(&model_dir).unwrap();
// Non-empty: the probe treats an empty dir as "not present".
std::fs::write(model_dir.join("model.onnx"), b"stub").unwrap();
let probe = EmbedderEnvProbe::new(cache, "http://127.0.0.1:1");
let report = probe.inspect().await;
assert_eq!(
report.onnx_cached_models,
vec![model.id.to_owned()],
"the prepared cached model must be detected"
);
assert!(!report.ollama_detected, "no real Ollama required");
}
#[test]
fn compiled_capability_flags_match_features() {
// The consts must mirror the build's features exactly (honest reporting to the UI).
assert_eq!(VECTOR_HTTP_ENABLED, cfg!(feature = "vector-http"));
assert_eq!(VECTOR_ONNX_ENABLED, cfg!(feature = "vector-onnx"));
}

View File

@ -4,8 +4,8 @@
use std::path::PathBuf;
use domain::ports::GitPort;
use domain::ports::GitError;
use domain::ports::GitPort;
use domain::project::ProjectPath;
use infrastructure::Git2Repository;
use uuid::Uuid;
@ -99,7 +99,12 @@ async fn unstage_after_first_commit_resets_index() {
// Modify + stage, then unstage → the change is no longer staged.
tmp.write("a.txt", "v2");
git.stage(&root, "a.txt").await.unwrap();
assert!(git.status(&root).await.unwrap().iter().any(|s| s.path == "a.txt" && s.staged));
assert!(git
.status(&root)
.await
.unwrap()
.iter()
.any(|s| s.path == "a.txt" && s.staged));
git.unstage(&root, "a.txt").await.unwrap();
let st = git.status(&root).await.unwrap();

View File

@ -57,9 +57,7 @@ async fn drain_request(stream: &mut tokio::net::TcpStream) {
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|w| w == needle)
haystack.windows(needle.len()).position(|w| w == needle)
}
/// Builds an HTTP `200 OK` response with a JSON body and the right `Content-Length`.
@ -98,17 +96,22 @@ async fn http_embedder_parses_vectors_and_restores_input_order() {
.embed(&["first".to_string(), "second".to_string()])
.await
.expect("embed must succeed");
assert_eq!(out, vec![vec![1.0, 0.0], vec![0.0, 1.0]], "input order restored by index");
assert_eq!(
out,
vec![vec![1.0, 0.0], vec![0.0, 1.0]],
"input order restored by index"
);
}
#[tokio::test]
async fn http_embedder_empty_input_short_circuits_without_network() {
// A closed/never-bound endpoint: no request must be made for empty input.
let embedder = HttpEmbedder::from_profile(&local_server_profile(
"http://127.0.0.1:1/v1/embeddings",
4,
));
let out = embedder.embed(&[]).await.expect("empty input ⇒ empty output, no I/O");
let embedder =
HttpEmbedder::from_profile(&local_server_profile("http://127.0.0.1:1/v1/embeddings", 4));
let out = embedder
.embed(&[])
.await
.expect("empty input ⇒ empty output, no I/O");
assert!(out.is_empty());
}
@ -124,10 +127,8 @@ async fn http_embedder_non_2xx_is_unavailable() {
#[tokio::test]
async fn http_embedder_unreachable_host_is_unavailable() {
// Port 1 on loopback: nothing listens ⇒ connection refused ⇒ Unavailable.
let embedder = HttpEmbedder::from_profile(&local_server_profile(
"http://127.0.0.1:1/v1/embeddings",
2,
));
let embedder =
HttpEmbedder::from_profile(&local_server_profile("http://127.0.0.1:1/v1/embeddings", 2));
let err = embedder.embed(&["x".to_string()]).await.unwrap_err();
assert!(matches!(err, EmbedderError::Unavailable(_)), "got {err:?}");
}

View File

@ -22,7 +22,9 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::markdown::MarkdownDoc;
use domain::memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType};
use domain::memory::{
Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
};
use domain::ports::{
DirEntry, FileSystem, FsError, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, RemotePath,
};
@ -114,7 +116,10 @@ fn query(budget: usize) -> MemoryQuery {
}
fn slugs(entries: &[MemoryIndexEntry]) -> Vec<String> {
entries.iter().map(|e| e.slug.as_str().to_string()).collect()
entries
.iter()
.map(|e| e.slug.as_str().to_string())
.collect()
}
// ---------------------------------------------------------------------------
@ -161,10 +166,7 @@ impl MemoryStore for CountingStore {
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
panic!("recall must not call delete")
}
async fn read_index(
&self,
_root: &ProjectPath,
) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
async fn read_index(&self, _root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
self.read_index_calls.fetch_add(1, Ordering::SeqCst);
Ok(Vec::new())
}
@ -224,17 +226,27 @@ async fn ample_budget_returns_all_entries_in_index_order() {
#[tokio::test]
async fn intermediate_budget_truncates_at_exact_boundary() {
let notes = [note("aaaa", "bbbb"), note("cccc", "dddd"), note("eeee", "ffff")];
let notes = [
note("aaaa", "bbbb"),
note("cccc", "dddd"),
note("eeee", "ffff"),
];
let recall = recall_over(&notes).await;
// Budget 1 < 2 ⇒ first entry already exceeds ⇒ none.
assert!(recall.recall(&root(), &query(1)).await.unwrap().is_empty());
// Budget 2 == cost(1) ⇒ exactly the first entry; second (would reach 4) dropped.
assert_eq!(slugs(&recall.recall(&root(), &query(2)).await.unwrap()), vec!["aaaa"]);
assert_eq!(
slugs(&recall.recall(&root(), &query(2)).await.unwrap()),
vec!["aaaa"]
);
// Budget 3 < 4 ⇒ still only the first.
assert_eq!(slugs(&recall.recall(&root(), &query(3)).await.unwrap()), vec!["aaaa"]);
assert_eq!(
slugs(&recall.recall(&root(), &query(3)).await.unwrap()),
vec!["aaaa"]
);
// Budget 4 == cost(1)+cost(2) ⇒ first two; third (would reach 6) dropped.
assert_eq!(
@ -266,9 +278,15 @@ async fn entry_cost_rounds_up_via_ceil() {
let recall = recall_over(&[note("ab", "c"), note("de", "fgh")]).await;
// Budget 1 covers only the 1-token first entry; the 2-token second is dropped.
assert_eq!(slugs(&recall.recall(&root(), &query(1)).await.unwrap()), vec!["ab"]);
assert_eq!(
slugs(&recall.recall(&root(), &query(1)).await.unwrap()),
vec!["ab"]
);
// Budget 2 still cannot fit the second on top of the first (1 + 2 = 3 > 2).
assert_eq!(slugs(&recall.recall(&root(), &query(2)).await.unwrap()), vec!["ab"]);
assert_eq!(
slugs(&recall.recall(&root(), &query(2)).await.unwrap()),
vec!["ab"]
);
// Budget 3 fits both (1 + 2 == 3).
assert_eq!(
slugs(&recall.recall(&root(), &query(3)).await.unwrap()),

View File

@ -106,12 +106,17 @@ async fn save_writes_md_and_index_line() {
let fs = MemFs::arc();
let store = FsMemoryStore::new(fs.clone());
store
.save(&root(), &note("alpha", "the hook", MemoryType::Project, "# Body"))
.save(
&root(),
&note("alpha", "the hook", MemoryType::Project, "# Body"),
)
.await
.unwrap();
// The note `.md` exists with frontmatter + body.
let md = fs.raw("/proj/.ideai/memory/alpha.md").expect("note written");
let md = fs
.raw("/proj/.ideai/memory/alpha.md")
.expect("note written");
assert!(md.starts_with("---\n"));
assert!(md.contains("name: alpha"));
assert!(md.contains("description: the hook"));
@ -119,7 +124,9 @@ async fn save_writes_md_and_index_line() {
assert!(md.contains("# Body"));
// The index has the header + the line.
let index = fs.raw("/proj/.ideai/memory/MEMORY.md").expect("index written");
let index = fs
.raw("/proj/.ideai/memory/MEMORY.md")
.expect("index written");
assert!(index.starts_with("# Memory Index"));
assert!(index.contains("- [alpha](alpha.md) — the hook"));
}
@ -272,7 +279,10 @@ async fn resolve_links_ignores_broken_links() {
async fn resolve_links_missing_source_is_not_found() {
let store = FsMemoryStore::new(MemFs::arc());
assert!(matches!(
store.resolve_links(&root(), &slug("nope")).await.unwrap_err(),
store
.resolve_links(&root(), &slug("nope"))
.await
.unwrap_err(),
MemoryError::NotFound
));
}

View File

@ -71,7 +71,8 @@ async fn onnx_unknown_model_is_unsupported() {
// *before* any `try_new`/download — so this is safe without a network. We point
// the cache at a path that is never created to prove no download is attempted.
let cache = std::path::Path::new("/idea-onnx-cache-that-never-exists");
let embedder = OnnxEmbedder::from_profile(&onnx_profile(Some("definitely-unknown"), 384), cache);
let embedder =
OnnxEmbedder::from_profile(&onnx_profile(Some("definitely-unknown"), 384), cache);
let err = embedder
.embed(&["x".to_string()])
@ -113,7 +114,8 @@ async fn onnx_construction_is_cheap() {
// and an unknown one (construction never fails for either).
let cache = std::path::Path::new("/idea-onnx-cache-that-never-exists-3");
let known = OnnxEmbedder::from_profile(&onnx_profile(Some("multilingual-e5-small"), 384), cache);
let known =
OnnxEmbedder::from_profile(&onnx_profile(Some("multilingual-e5-small"), 384), cache);
assert_eq!(known.id(), "test-onnx");
assert_eq!(known.dimension(), 384);
@ -139,14 +141,16 @@ async fn embedder_from_profile_localonnx_is_real_engine_not_unsupported_stub() {
// whereas the StubEmbedder would return Unsupported even for an empty batch.
let cache = std::path::Path::new("/idea-onnx-cache-that-never-exists-4");
let known = onnx_profile(Some("multilingual-e5-small"), 384);
let embedder =
embedder_from_profile(&known, cache).expect("localOnnx must yield an embedder");
let embedder = embedder_from_profile(&known, cache).expect("localOnnx must yield an embedder");
assert_eq!(embedder.dimension(), 384);
let out = embedder
.embed(&[])
.await
.expect("real engine short-circuits empty input to Ok(vec![])");
assert!(out.is_empty(), "empty batch ⇒ empty result on the real engine");
assert!(
out.is_empty(),
"empty batch ⇒ empty result on the real engine"
);
// And an *unknown* model still surfaces Unsupported (deferred resolution), so the
// mapping is the real engine in both cases (the stub would also say Unsupported,
@ -187,11 +191,17 @@ async fn onnx_embeds_e5_small_real_model() {
for v in &out {
assert_eq!(v.len(), 384, "e5-small produces 384-dim vectors");
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-2, "fastembed L2-normalises; norm ≈ 1, got {norm}");
assert!(
(norm - 1.0).abs() < 1e-2,
"fastembed L2-normalises; norm ≈ 1, got {norm}"
);
}
// Deterministic across calls (model already cached after the first call).
let again = embedder.embed(&texts).await.expect("second embedding must succeed");
let again = embedder
.embed(&texts)
.await
.expect("second embedding must succeed");
assert_eq!(out, again, "embedding must be deterministic across calls");
}

View File

@ -16,6 +16,7 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::DomainEvent;
use domain::ids::SkillId;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
@ -24,7 +25,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;
@ -94,7 +94,13 @@ impl AgentContextStore for FakeContexts {
) -> Result<MarkdownDoc, StoreError> {
let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
Ok(MarkdownDoc::new(
self.0.lock().unwrap().contents.get(&md).cloned().unwrap_or_default(),
self.0
.lock()
.unwrap()
.contents
.get(&md)
.cloned()
.unwrap_or_default(),
))
}
async fn write_context(
@ -150,7 +156,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(
@ -321,6 +331,7 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
bus.clone(),
Arc::new(SeqIds(Mutex::new(1))),
Arc::new(FakeRecall),
None,
));
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
@ -342,7 +353,11 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
}
fn read_response(request_path: &std::path::Path) -> OrchestratorResponse {
let mut name = request_path.file_name().unwrap().to_string_lossy().into_owned();
let mut name = request_path
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
name.push_str(".response.json");
let response_path = request_path.with_file_name(name);
let bytes = std::fs::read(&response_path).expect("response file written");
@ -375,6 +390,30 @@ async fn valid_spawn_request_succeeds_and_is_consumed() {
assert_eq!(contexts.entries()[0].name, "dev-backend");
}
#[tokio::test]
async fn valid_agent_run_request_succeeds_and_reports_type() {
let tmp = TempDir::new();
let contexts = FakeContexts::new();
let service = build_service(contexts.clone());
let req = tmp.0.join("req-agent-run.json");
std::fs::write(
&req,
br#"{ "type": "agent.run", "requestedBy": "Main", "targetAgent": "architect", "profile": "claude-code", "task": "Analyse", "visibility": "background" }"#,
)
.unwrap();
let response = process_request_file(&req, &project(), &service).await;
assert!(response.ok, "expected ok, got {response:?}");
assert_eq!(response.action.as_deref(), Some("agent.run"));
assert!(!req.exists(), "request file must be removed");
let on_disk = read_response(&req);
assert!(on_disk.ok);
assert_eq!(contexts.entries().len(), 1);
assert_eq!(contexts.entries()[0].name, "architect");
}
#[tokio::test]
async fn valid_create_skill_request_succeeds_and_is_consumed() {
let tmp = TempDir::new();
@ -408,7 +447,11 @@ async fn invalid_json_request_yields_error_response() {
assert!(!response.ok);
assert!(
response.error.as_deref().unwrap_or_default().contains("invalid json"),
response
.error
.as_deref()
.unwrap_or_default()
.contains("invalid json"),
"got {response:?}"
);
assert!(!req.exists(), "poisoned request must still be removed");
@ -427,5 +470,9 @@ async fn unknown_action_yields_error_response() {
assert!(!response.ok);
assert_eq!(response.action.as_deref(), Some("explode"));
assert!(response.error.as_deref().unwrap_or_default().contains("unknown orchestrator action"));
assert!(response
.error
.as_deref()
.unwrap_or_default()
.contains("unknown orchestrator action"));
}

View File

@ -134,6 +134,9 @@ async fn registry_file_is_camelcase_json() {
"project uses camelCase `createdAt`, got {entry}"
);
assert!(entry.get("created_at").is_none(), "no snake_case leak");
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("jsoncheck"));
assert_eq!(
entry.get("name").and_then(|v| v.as_str()),
Some("jsoncheck")
);
assert_eq!(entry.get("root").and_then(|v| v.as_str()), Some("/srv/app"));
}

View File

@ -35,10 +35,7 @@ fn size() -> PtySize {
/// Drains an output stream to a single `Vec<u8>` on a worker thread, returning
/// the collected bytes or panicking if it does not finish within `TIMEOUT`.
fn drain_with_timeout(
stream: domain::ports::OutputStream,
timeout: Duration,
) -> Vec<u8> {
fn drain_with_timeout(stream: domain::ports::OutputStream, timeout: Duration) -> Vec<u8> {
let (tx, rx) = mpsc::channel();
let worker = thread::spawn(move || {
let mut all = Vec::new();
@ -80,10 +77,7 @@ async fn write_is_echoed_back_through_output_stream() {
// `cat` echoes its stdin back to stdout; we feed it a line then close stdin
// by killing it, and assert we saw the echoed bytes.
let pty = PortablePtyAdapter::new();
let handle = pty
.spawn(sh_spec("cat"), size())
.await
.expect("spawn cat");
let handle = pty.spawn(sh_spec("cat"), size()).await.expect("spawn cat");
let stream = pty.subscribe_output(&handle).expect("subscribe once");
@ -118,10 +112,7 @@ async fn subscribe_output_is_re_subscribable_for_reattach() {
// first view detaches (drops its stream), a second view re-attaches and
// still receives subsequent output — the core of the no-kill navigation fix.
let pty = PortablePtyAdapter::new();
let handle = pty
.spawn(sh_spec("cat"), size())
.await
.expect("spawn cat");
let handle = pty.spawn(sh_spec("cat"), size()).await.expect("spawn cat");
// First attachment: subscribe, observe an echo, then drop the stream
// (simulating a view tearing down on navigation — NOT a kill).

View File

@ -49,15 +49,9 @@ async fn selector_builds_local_host() {
#[test]
fn selector_rejects_ssh_and_wsl_for_now() {
let ssh = RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "/srv").unwrap();
assert!(matches!(
remote_host(&ssh),
Err(RemoteError::Connection(_))
));
assert!(matches!(remote_host(&ssh), Err(RemoteError::Connection(_))));
let wsl = RemoteRef::wsl("Ubuntu").unwrap();
assert!(matches!(
remote_host(&wsl),
Err(RemoteError::Connection(_))
));
assert!(matches!(remote_host(&wsl), Err(RemoteError::Connection(_))));
}
/// Local host PTY/spawner handles are cloneable port objects (Arc-backed).

View File

@ -68,7 +68,12 @@ async fn global_roundtrip_save_get_list() {
let s = store(&tmp);
let root = tmp.project_root();
let sk = skill(sid(1), "refactor", "# Refactor workflow", SkillScope::Global);
let sk = skill(
sid(1),
"refactor",
"# Refactor workflow",
SkillScope::Global,
);
s.save(&sk, &root).await.unwrap();
assert_eq!(s.get(SkillScope::Global, &root, sid(1)).await.unwrap(), sk);
@ -111,9 +116,12 @@ async fn scopes_are_isolated() {
let s = store(&tmp);
let root = tmp.project_root();
s.save(&skill(sid(1), "g", "global body", SkillScope::Global), &root)
.await
.unwrap();
s.save(
&skill(sid(1), "g", "global body", SkillScope::Global),
&root,
)
.await
.unwrap();
s.save(
&skill(sid(2), "p", "project body", SkillScope::Project),
&root,
@ -191,9 +199,12 @@ async fn index_is_camelcase_with_content_hash() {
let tmp = TempDir::new();
let s = store(&tmp);
let root = tmp.project_root();
s.save(&skill(sid(1), "refactor", "hello", SkillScope::Global), &root)
.await
.unwrap();
s.save(
&skill(sid(1), "refactor", "hello", SkillScope::Global),
&root,
)
.await
.unwrap();
let fs = LocalFileSystem::new();
let bytes = fs.read(&tmp.child("skills/index.json")).await.unwrap();
@ -204,8 +215,5 @@ async fn index_is_camelcase_with_content_hash() {
entry.get("contentHash").is_some(),
"camelCase contentHash present"
);
assert!(
entry.get("content_hash").is_none(),
"no snake_case leak"
);
assert!(entry.get("content_hash").is_none(), "no snake_case leak");
}

View File

@ -116,14 +116,20 @@ async fn delete_removes_from_index() {
async fn index_is_camelcase_with_content_hash() {
let tmp = TempDir::new();
let store = store(&tmp);
store.save(&template(tid(1), "Backend", "hello")).await.unwrap();
store
.save(&template(tid(1), "Backend", "hello"))
.await
.unwrap();
let fs = LocalFileSystem::new();
let bytes = fs.read(&tmp.child("templates/index.json")).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let entry = &json.get("templates").unwrap().as_array().unwrap()[0];
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("Backend"));
assert!(entry.get("contentHash").is_some(), "camelCase contentHash present");
assert!(
entry.get("contentHash").is_some(),
"camelCase contentHash present"
);
assert!(entry.get("defaultProfileId").is_some());
assert!(entry.get("content_hash").is_none(), "no snake_case leak");
}

View File

@ -152,7 +152,10 @@ fn query(text: &str, budget: usize) -> MemoryQuery {
}
fn slugs(entries: &[MemoryIndexEntry]) -> Vec<String> {
entries.iter().map(|e| e.slug.as_str().to_string()).collect()
entries
.iter()
.map(|e| e.slug.as_str().to_string())
.collect()
}
async fn store_with(notes: &[Memory], fs: Arc<MemFs>) -> Arc<FsMemoryStore> {
@ -215,16 +218,16 @@ async fn hash_embedder_dimension_is_consistent() {
.await
.unwrap();
assert_eq!(out.len(), 2, "one vector per input, order preserved");
assert!(out.iter().all(|v| v.len() == 48), "every vector is dimension-long");
assert!(
out.iter().all(|v| v.len() == 48),
"every vector is dimension-long"
);
}
#[tokio::test]
async fn hash_embedder_vectors_are_l2_normalised() {
let e = HashEmbedder::new("h", 64);
let out = e
.embed(&["one two three four".to_string()])
.await
.unwrap();
let out = e.embed(&["one two three four".to_string()]).await.unwrap();
let norm = out[0].iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-5, "norm ≈ 1, got {norm}");
}
@ -332,7 +335,12 @@ async fn vector_recall_ranks_closest_note_first() {
.recall(&root(), &query("beta kiwi mango papaya", 100_000))
.await
.unwrap();
assert_eq!(out.first().map(|e| e.slug.as_str()), Some("beta"), "ranked: {:?}", slugs(&out));
assert_eq!(
out.first().map(|e| e.slug.as_str()),
Some("beta"),
"ranked: {:?}",
slugs(&out)
);
// All three notes fit the ample budget.
assert_eq!(out.len(), 3);
}
@ -352,8 +360,15 @@ async fn vector_recall_truncates_to_budget() {
let recall = vector_recall(embedder, store, fs);
// Budget 2 fits only the top-ranked `beta` (cost 2); the next would exceed.
let out = recall.recall(&root(), &query("beta kiwi", 2)).await.unwrap();
assert_eq!(slugs(&out), vec!["beta"], "budget must keep only the closest");
let out = recall
.recall(&root(), &query("beta kiwi", 2))
.await
.unwrap();
assert_eq!(
slugs(&out),
vec!["beta"],
"budget must keep only the closest"
);
}
#[tokio::test]
@ -389,20 +404,32 @@ async fn vector_recall_writes_and_reuses_the_derived_cache() {
let recall = VectorMemoryRecall::new(spy.clone(), store, fs.clone());
// First recall: embeds 2 note texts + 1 query text = 3.
let _ = recall.recall(&root(), &query("alpha apple", 100_000)).await.unwrap();
let _ = recall
.recall(&root(), &query("alpha apple", 100_000))
.await
.unwrap();
let after_first = spy.embedded_texts.load(Ordering::SeqCst);
assert_eq!(after_first, 3, "2 notes + 1 query embedded on a cold cache");
// The derived cache file now exists with both slugs and the embedder id.
let doc = fs.raw(VECTORS_PATH).expect("vectors.json written");
assert!(doc.contains("\"embedderId\": \"spy\""), "tagged with embedder id: {doc}");
assert!(
doc.contains("\"embedderId\": \"spy\""),
"tagged with embedder id: {doc}"
);
assert!(doc.contains("\"alpha\""), "alpha vector cached: {doc}");
assert!(doc.contains("\"beta\""), "beta vector cached: {doc}");
// Second recall: note vectors come from the cache; only the query is embedded.
let _ = recall.recall(&root(), &query("beta banana", 100_000)).await.unwrap();
let _ = recall
.recall(&root(), &query("beta banana", 100_000))
.await
.unwrap();
let delta = spy.embedded_texts.load(Ordering::SeqCst) - after_first;
assert_eq!(delta, 1, "cache reuse ⇒ only the query is re-embedded, got {delta}");
assert_eq!(
delta, 1,
"cache reuse ⇒ only the query is re-embedded, got {delta}"
);
}
#[tokio::test]
@ -417,7 +444,10 @@ async fn vector_recall_invalidates_cache_on_embedder_id_change() {
.recall(&root(), &query("alpha apple", 100_000))
.await
.unwrap();
assert!(fs.raw(VECTORS_PATH).unwrap().contains("\"embedderId\": \"first\""));
assert!(fs
.raw(VECTORS_PATH)
.unwrap()
.contains("\"embedderId\": \"first\""));
// A different embedder id must invalidate ⇒ recompute all note vectors.
let second = Arc::new(SpyEmbedder::new("second", 128));
@ -432,7 +462,10 @@ async fn vector_recall_invalidates_cache_on_embedder_id_change() {
"id change must invalidate and recompute note vectors"
);
// The file is now tagged with the new embedder id.
assert!(fs.raw(VECTORS_PATH).unwrap().contains("\"embedderId\": \"second\""));
assert!(fs
.raw(VECTORS_PATH)
.unwrap()
.contains("\"embedderId\": \"second\""));
}
#[tokio::test]
@ -472,7 +505,10 @@ async fn vector_recall_with_stub_embedder_errors_without_panic() {
let stub = Arc::new(StubEmbedder::new("stub", 64, "localOnnx"));
let recall = VectorMemoryRecall::new(stub, store, fs);
let res = recall.recall(&root(), &query("alpha apple", 1000)).await;
assert!(res.is_err(), "stub embedder ⇒ Err on the vector path, got {res:?}");
assert!(
res.is_err(),
"stub embedder ⇒ Err on the vector path, got {res:?}"
);
}
// ===========================================================================
@ -520,7 +556,10 @@ async fn adaptive_none_strategy_matches_naive_exactly() {
let q = query("kiwi mango papaya", budget);
let expected = naive.recall(&root(), &q).await.unwrap();
let got = adaptive.recall(&root(), &q).await.unwrap();
assert_eq!(got, expected, "strategy none must equal naive at budget {budget}");
assert_eq!(
got, expected,
"strategy none must equal naive at budget {budget}"
);
}
}
@ -546,7 +585,11 @@ async fn adaptive_small_memory_routes_to_naive_order() {
.recall(&root(), &query("gamma grape", 100_000))
.await
.unwrap();
assert_eq!(slugs(&out), vec!["alpha", "beta", "gamma"], "naïve keeps index order");
assert_eq!(
slugs(&out),
vec!["alpha", "beta", "gamma"],
"naïve keeps index order"
);
}
#[tokio::test]
@ -614,9 +657,15 @@ async fn adaptive_falls_back_to_naive_when_embedder_fails() {
let total = infrastructure::index_token_size(&store.read_index(&root()).await.unwrap());
let q = query("carrot potato onion", total - 1);
let got = adaptive.recall(&root(), &q).await.expect("fallback must not error");
let got = adaptive
.recall(&root(), &q)
.await
.expect("fallback must not error");
let expected = naive_ref.recall(&root(), &q).await.unwrap();
assert_eq!(got, expected, "embedder failure must degrade to the naïve result");
assert_eq!(
got, expected,
"embedder failure must degrade to the naïve result"
);
assert!(!got.is_empty(), "the degraded result still returns entries");
}
@ -646,11 +695,18 @@ impl Drop for TempDir {
#[test]
fn recommended_onnx_models_advertise_e5_small_384() {
assert_eq!(RECOMMENDED_ONNX_MODELS.len(), 1, "exactly one curated model");
assert_eq!(
RECOMMENDED_ONNX_MODELS.len(),
1,
"exactly one curated model"
);
let m = RECOMMENDED_ONNX_MODELS[0];
assert_eq!(m.id, "multilingual-e5-small");
assert_eq!(m.dimension, 384);
assert!(m.recommended, "the single curated model is the recommended default");
assert!(
m.recommended,
"the single curated model is the recommended default"
);
}
#[test]
@ -681,7 +737,9 @@ fn onnx_model_is_cached_true_when_model_subdir_present_and_nonempty() {
// *contains* the model token (slashes/underscores normalised to `-`), mirroring
// hf-hub's `models--<org>--<name>` layout.
let tmp = TempDir::new();
let model_dir = tmp.path().join("models--Qdrant--multilingual-e5-small-onnx");
let model_dir = tmp
.path()
.join("models--Qdrant--multilingual-e5-small-onnx");
std::fs::create_dir_all(&model_dir).unwrap();
// The subdir must be non-empty to count as "cached".
std::fs::write(model_dir.join("model.onnx"), b"not really a model").unwrap();
@ -696,7 +754,11 @@ fn onnx_model_is_cached_true_when_model_subdir_present_and_nonempty() {
fn onnx_model_is_cached_false_when_matching_subdir_is_empty() {
// A matching but *empty* subdir does not count as a present cached model.
let tmp = TempDir::new();
std::fs::create_dir_all(tmp.path().join("models--Qdrant--multilingual-e5-small-onnx")).unwrap();
std::fs::create_dir_all(
tmp.path()
.join("models--Qdrant--multilingual-e5-small-onnx"),
)
.unwrap();
assert!(
!onnx_model_is_cached(tmp.path(), "multilingual-e5-small"),
"an empty matching subdir is not a cached model"