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:
@ -37,9 +37,7 @@ fn map_io(path: &RemotePath, err: &io::Error) -> FsError {
|
||||
#[async_trait]
|
||||
impl FileSystem for LocalFileSystem {
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
fs::read(path.as_str())
|
||||
.await
|
||||
.map_err(|e| map_io(path, &e))
|
||||
fs::read(path.as_str()).await.map_err(|e| map_io(path, &e))
|
||||
}
|
||||
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
@ -69,11 +67,7 @@ impl FileSystem for LocalFileSystem {
|
||||
.map_err(|e| map_io(path, &e))?;
|
||||
|
||||
while let Some(entry) = read_dir.next_entry().await.map_err(|e| map_io(path, &e))? {
|
||||
let is_dir = entry
|
||||
.file_type()
|
||||
.await
|
||||
.map(|t| t.is_dir())
|
||||
.unwrap_or(false);
|
||||
let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
|
||||
entries.push(DirEntry {
|
||||
name: entry.file_name().to_string_lossy().into_owned(),
|
||||
is_dir,
|
||||
|
||||
@ -177,11 +177,7 @@ impl GitPort for Git2Repository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn log(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
limit: usize,
|
||||
) -> Result<Vec<GitCommitInfo>, GitError> {
|
||||
async fn log(&self, root: &ProjectPath, limit: usize) -> Result<Vec<GitCommitInfo>, GitError> {
|
||||
let repo = open(root)?;
|
||||
let mut revwalk = repo.revwalk().map_err(op)?;
|
||||
// No commits yet ⇒ nothing to walk.
|
||||
|
||||
@ -224,9 +224,7 @@ fn parse_transcript(body: &[u8]) -> ConversationDetails {
|
||||
let output = usage.output_tokens.unwrap_or(0);
|
||||
if input != 0 || output != 0 {
|
||||
saw_usage = true;
|
||||
token_total = token_total
|
||||
.saturating_add(input)
|
||||
.saturating_add(output);
|
||||
token_total = token_total.saturating_add(input).saturating_add(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -345,10 +343,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_no_topic_or_usage() {
|
||||
let body = concat!(
|
||||
r#"{"role":"assistant","content":"no usage here"}"#,
|
||||
"\n",
|
||||
);
|
||||
let body = concat!(r#"{"role":"assistant","content":"no usage here"}"#, "\n",);
|
||||
let d = parse_transcript(body.as_bytes());
|
||||
assert_eq!(d.last_topic, None);
|
||||
assert_eq!(d.token_count, None);
|
||||
|
||||
@ -39,13 +39,15 @@ pub use process::LocalProcessSpawner;
|
||||
pub use pty::PortablePtyAdapter;
|
||||
pub use remote::{remote_host, LocalHost};
|
||||
pub use runtime::CliAgentRuntime;
|
||||
pub use store::{
|
||||
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
||||
AdaptiveMemoryRecall, FsEmbedderProfileStore, FsMemoryStore, FsProfileStore, FsProjectStore,
|
||||
FsSkillStore, FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall,
|
||||
OnnxModelInfo, StubEmbedder, VectorMemoryRecall, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||
};
|
||||
#[cfg(feature = "vector-http")]
|
||||
pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
|
||||
#[cfg(feature = "vector-onnx")]
|
||||
pub use store::OnnxEmbedder;
|
||||
#[cfg(feature = "vector-http")]
|
||||
pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
|
||||
pub use store::{
|
||||
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
||||
AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||
FsMemoryStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, HashEmbedder,
|
||||
IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall,
|
||||
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
||||
VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
|
||||
@ -205,7 +205,10 @@ async fn scan_once(
|
||||
/// Whether `path` is a request file to process: a `.json` that is not a
|
||||
/// `.response.json` sibling we wrote ourselves.
|
||||
fn is_request_file(path: &Path) -> bool {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or_default();
|
||||
name.ends_with(".json") && !name.ends_with(".response.json")
|
||||
}
|
||||
|
||||
@ -242,14 +245,21 @@ async fn dispatch_file(
|
||||
Ok(r) => r,
|
||||
Err(e) => return OrchestratorResponse::failure(None, format!("invalid json: {e}")),
|
||||
};
|
||||
let action = request.action.clone();
|
||||
let action = request
|
||||
.request_type
|
||||
.as_ref()
|
||||
.or(request.action.as_ref())
|
||||
.cloned();
|
||||
let command = match request.validate() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return OrchestratorResponse::failure(Some(action), e.to_string()),
|
||||
Err(e) => return OrchestratorResponse::failure(action, e.to_string()),
|
||||
};
|
||||
match service.dispatch(project, command).await {
|
||||
Ok(out) => OrchestratorResponse::success(action, out.detail),
|
||||
Err(e) => OrchestratorResponse::failure(Some(action), e.to_string()),
|
||||
Ok(out) => OrchestratorResponse::success(
|
||||
action.unwrap_or_else(|| "unknown".to_owned()),
|
||||
out.detail,
|
||||
),
|
||||
Err(e) => OrchestratorResponse::failure(action, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -256,9 +256,7 @@ impl PtyPort for PortablePtyAdapter {
|
||||
live.writer
|
||||
.write_all(data)
|
||||
.map_err(|e| PtyError::Io(e.to_string()))?;
|
||||
live.writer
|
||||
.flush()
|
||||
.map_err(|e| PtyError::Io(e.to_string()))
|
||||
live.writer.flush().map_err(|e| PtyError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError> {
|
||||
@ -313,10 +311,7 @@ impl PtyPort for PortablePtyAdapter {
|
||||
|
||||
// Ask the child to terminate, then wait for its real status.
|
||||
let _ = live.child.kill();
|
||||
let status = live
|
||||
.child
|
||||
.wait()
|
||||
.map_err(|e| PtyError::Io(e.to_string()))?;
|
||||
let status = live.child.wait().map_err(|e| PtyError::Io(e.to_string()))?;
|
||||
|
||||
// Dropping master/writer closes the PTY; the reader thread then sees EOF.
|
||||
// Dropping the broadcast hub drops every subscriber's sender, so any
|
||||
|
||||
@ -70,8 +70,7 @@ impl CliAgentRuntime {
|
||||
let args = tokens.map(str::to_owned).collect();
|
||||
// Detection runs in a neutral cwd; "." is a safe relative placeholder the
|
||||
// spawner resolves against the process cwd.
|
||||
let cwd = ProjectPath::new("/")
|
||||
.map_err(|e| RuntimeError::Detection(e.to_string()))?;
|
||||
let cwd = ProjectPath::new("/").map_err(|e| RuntimeError::Detection(e.to_string()))?;
|
||||
Ok(SpawnSpec {
|
||||
command,
|
||||
args,
|
||||
@ -90,7 +89,10 @@ impl CliAgentRuntime {
|
||||
/// `{projectRoot}` is still substituted with the same base for backwards
|
||||
/// compatibility (the caller always passes the run dir now). An empty template
|
||||
/// defaults to the base itself.
|
||||
fn resolve_cwd(profile: &AgentProfile, base: &ProjectPath) -> Result<ProjectPath, RuntimeError> {
|
||||
fn resolve_cwd(
|
||||
profile: &AgentProfile,
|
||||
base: &ProjectPath,
|
||||
) -> Result<ProjectPath, RuntimeError> {
|
||||
let template = profile.cwd_template.trim();
|
||||
if template.is_empty() {
|
||||
return Ok(base.clone());
|
||||
@ -117,10 +119,7 @@ impl CliAgentRuntime {
|
||||
/// (e.g. `-f` → `["-f", "<path>"]`); a flag *with* `{path}` is split on
|
||||
/// whitespace after substitution (e.g. `--context-file {path}` →
|
||||
/// `["--context-file", "<path>"]`).
|
||||
fn injection_plan(
|
||||
injection: &ContextInjection,
|
||||
ctx: &PreparedContext,
|
||||
) -> ContextInjectionPlan {
|
||||
fn injection_plan(injection: &ContextInjection, ctx: &PreparedContext) -> ContextInjectionPlan {
|
||||
match injection {
|
||||
ContextInjection::ConventionFile { target } => ContextInjectionPlan::File {
|
||||
target: target.clone(),
|
||||
|
||||
@ -67,7 +67,10 @@ impl IdeaiContextStore {
|
||||
|
||||
/// Absolute path of the manifest file for a project.
|
||||
fn manifest_path(project: &Project) -> RemotePath {
|
||||
RemotePath::new(Self::join(&project.root, &format!("{IDEAI_DIR}/{AGENTS_FILE}")))
|
||||
RemotePath::new(Self::join(
|
||||
&project.root,
|
||||
&format!("{IDEAI_DIR}/{AGENTS_FILE}"),
|
||||
))
|
||||
}
|
||||
|
||||
/// Absolute path of an agent context `.md` from its (`.ideai/`-relative)
|
||||
|
||||
@ -23,10 +23,27 @@
|
||||
//! repeatable vectors from a hashing bag-of-words, good enough for tests and for a
|
||||
//! trivial offline fallback).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{Embedder, EmbedderError};
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::ports::{
|
||||
Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderPromptDismissal,
|
||||
EmbedderPromptStore, FileSystem, FsError, RemotePath, StoreError,
|
||||
};
|
||||
use domain::profile::{EmbedderProfile, EmbedderStrategy};
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
/// Whether this binary was compiled with the HTTP embedding capability
|
||||
/// (`localServer`/`api` real engines + Ollama detection). Reported honestly to the
|
||||
/// C2/C3 UI so it only offers strategies actually wired into *this* build.
|
||||
pub const VECTOR_HTTP_ENABLED: bool = cfg!(feature = "vector-http");
|
||||
|
||||
/// Whether this binary was compiled with the in-process ONNX embedding capability
|
||||
/// (`localOnnx` real engine via `fastembed`). Reported honestly to the C2/C3 UI.
|
||||
pub const VECTOR_ONNX_ENABLED: bool = cfg!(feature = "vector-onnx");
|
||||
|
||||
/// Builds the concrete [`Embedder`] for a profile, or `None` when the strategy is
|
||||
/// [`EmbedderStrategy::None`] (recall stays naïve).
|
||||
@ -50,7 +67,10 @@ pub fn embedder_from_profile(
|
||||
EmbedderStrategy::LocalOnnx => {
|
||||
#[cfg(feature = "vector-onnx")]
|
||||
{
|
||||
Some(Box::new(OnnxEmbedder::from_profile(profile, onnx_cache_dir)))
|
||||
Some(Box::new(OnnxEmbedder::from_profile(
|
||||
profile,
|
||||
onnx_cache_dir,
|
||||
)))
|
||||
}
|
||||
#[cfg(not(feature = "vector-onnx"))]
|
||||
{
|
||||
@ -325,10 +345,9 @@ impl Embedder for HttpEmbedder {
|
||||
}
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| EmbedderError::Unavailable(format!("request to `{}` failed: {e}", self.endpoint)))?;
|
||||
let response = request.send().await.map_err(|e| {
|
||||
EmbedderError::Unavailable(format!("request to `{}` failed: {e}", self.endpoint))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(EmbedderError::Unavailable(format!(
|
||||
@ -464,6 +483,189 @@ pub fn onnx_model_is_cached(cache_dir: &std::path::Path, model: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EmbedderEnvProbe — best-effort local-environment inspector (LOT C2/C3).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Default base URL of a local Ollama-style embedding server, probed by
|
||||
/// [`EmbedderEnvProbe`] to detect an already-installed local engine (the
|
||||
/// Linux-spirit "detect the existing first" rule). The base (no path): the probe
|
||||
/// appends `/api/tags` via [`detect_ollama`].
|
||||
pub const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434";
|
||||
|
||||
/// Concrete [`EmbedderEnvInspector`]: a **best-effort, never-failing** probe of the
|
||||
/// local embedding environment.
|
||||
///
|
||||
/// - `onnx_cached_models`: for each model in [`RECOMMENDED_ONNX_MODELS`], reports
|
||||
/// the ids already present in `onnx_cache_dir` (pure FS, no `fastembed`
|
||||
/// dependency). The blocking `read_dir` runs on a `spawn_blocking` thread so it
|
||||
/// never stalls the async runtime.
|
||||
/// - `ollama_detected`: `true` only when built with the `vector-http` capability
|
||||
/// **and** an Ollama-style server answers at `ollama_base_url`; `false` otherwise.
|
||||
///
|
||||
/// Construction is cheap and infallible; nothing touches disk or the network until
|
||||
/// [`inspect`](EmbedderEnvInspector::inspect) is called.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmbedderEnvProbe {
|
||||
onnx_cache_dir: PathBuf,
|
||||
/// Only read under `vector-http` (by [`detect_ollama`]); retained unconditionally
|
||||
/// so the constructor signature is stable across build configs.
|
||||
#[cfg_attr(not(feature = "vector-http"), allow(dead_code))]
|
||||
ollama_base_url: String,
|
||||
}
|
||||
|
||||
impl EmbedderEnvProbe {
|
||||
/// Builds the probe from the ONNX cache directory (`<app_data_dir>/embedders/onnx`,
|
||||
/// see [`ONNX_CACHE_SUBDIR`]) and the base URL of the local Ollama-style server.
|
||||
#[must_use]
|
||||
pub fn new(onnx_cache_dir: PathBuf, ollama_base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
onnx_cache_dir,
|
||||
ollama_base_url: ollama_base_url.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmbedderEnvInspector for EmbedderEnvProbe {
|
||||
async fn inspect(&self) -> EmbedderEnvReport {
|
||||
// Pure-FS cache scan off the runtime: `onnx_model_is_cached` calls blocking
|
||||
// `read_dir`, so run the whole scan on a blocking thread.
|
||||
let cache_dir = self.onnx_cache_dir.clone();
|
||||
let onnx_cached_models = tokio::task::spawn_blocking(move || {
|
||||
RECOMMENDED_ONNX_MODELS
|
||||
.iter()
|
||||
.filter(|m| onnx_model_is_cached(&cache_dir, m.id))
|
||||
.map(|m| m.id.to_owned())
|
||||
.collect::<Vec<String>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
#[cfg(feature = "vector-http")]
|
||||
let ollama_detected = detect_ollama(&self.ollama_base_url).await;
|
||||
#[cfg(not(feature = "vector-http"))]
|
||||
let ollama_detected = false;
|
||||
|
||||
EmbedderEnvReport {
|
||||
ollama_detected,
|
||||
onnx_cached_models,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FsEmbedderPromptStore — per-project embedder-suggestion state (LOT C3).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `.ideai/` directory name inside a project root.
|
||||
const PROMPT_IDEAI_DIR: &str = ".ideai";
|
||||
/// Memory sub-dir (the suggestion state lives beside the memory it concerns).
|
||||
const PROMPT_MEMORY_DIR: &str = "memory";
|
||||
/// State file name (dot-prefixed: derived/local state, like the vector index).
|
||||
const PROMPT_FILE: &str = ".embedder-prompt.json";
|
||||
|
||||
/// Wire form of the suggestion-dismissal choice (camelCase).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
enum DismissalWire {
|
||||
Later,
|
||||
Never,
|
||||
}
|
||||
|
||||
impl From<EmbedderPromptDismissal> for DismissalWire {
|
||||
fn from(d: EmbedderPromptDismissal) -> Self {
|
||||
match d {
|
||||
EmbedderPromptDismissal::Later => Self::Later,
|
||||
EmbedderPromptDismissal::Never => Self::Never,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DismissalWire> for EmbedderPromptDismissal {
|
||||
fn from(w: DismissalWire) -> Self {
|
||||
match w {
|
||||
DismissalWire::Later => Self::Later,
|
||||
DismissalWire::Never => Self::Never,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// On-disk shape of `.ideai/memory/.embedder-prompt.json`:
|
||||
/// `{ "dismissed": "later" | "never" }`.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
struct PromptDoc {
|
||||
dismissed: DismissalWire,
|
||||
}
|
||||
|
||||
/// File-backed [`EmbedderPromptStore`] (LOT C3): persists the per-project response
|
||||
/// to the embedder suggestion under `.ideai/memory/.embedder-prompt.json`, through
|
||||
/// the [`FileSystem`] port (so it is location-neutral, SSH/WSL unchanged).
|
||||
///
|
||||
/// Reads are **best-effort**: a missing file ⇒ `Ok(None)` (never answered); a
|
||||
/// malformed file also degrades to `Ok(None)` rather than surfacing an error, so a
|
||||
/// hand-corrupted state never blocks the suggestion logic.
|
||||
#[derive(Clone)]
|
||||
pub struct FsEmbedderPromptStore {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
impl FsEmbedderPromptStore {
|
||||
/// Builds the store from the [`FileSystem`] port.
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
|
||||
Self { fs }
|
||||
}
|
||||
|
||||
/// `<root>/.ideai/memory`.
|
||||
fn memory_dir(root: &ProjectPath) -> String {
|
||||
let base = root.as_str().trim_end_matches(['/', '\\']);
|
||||
format!("{base}/{PROMPT_IDEAI_DIR}/{PROMPT_MEMORY_DIR}")
|
||||
}
|
||||
|
||||
/// `<memory-dir>/.embedder-prompt.json`.
|
||||
fn prompt_path(root: &ProjectPath) -> RemotePath {
|
||||
RemotePath::new(format!("{}/{PROMPT_FILE}", Self::memory_dir(root)))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmbedderPromptStore for FsEmbedderPromptStore {
|
||||
async fn read(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
) -> Result<Option<EmbedderPromptDismissal>, StoreError> {
|
||||
match self.fs.read(&Self::prompt_path(root)).await {
|
||||
Ok(bytes) => Ok(serde_json::from_slice::<PromptDoc>(&bytes)
|
||||
.ok()
|
||||
.map(|doc| doc.dismissed.into())),
|
||||
// Never answered (or unreadable) ⇒ no recorded dismissal.
|
||||
Err(FsError::NotFound(_)) | Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
dismissal: EmbedderPromptDismissal,
|
||||
) -> Result<(), StoreError> {
|
||||
let dir = RemotePath::new(Self::memory_dir(root));
|
||||
self.fs
|
||||
.create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
let doc = PromptDoc {
|
||||
dismissed: dismissal.into(),
|
||||
};
|
||||
let bytes = serde_json::to_vec_pretty(&doc)
|
||||
.map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
self.fs
|
||||
.write(&Self::prompt_path(root), &bytes)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "vector-onnx")]
|
||||
mod onnx {
|
||||
use std::path::{Path, PathBuf};
|
||||
@ -546,9 +748,7 @@ mod onnx {
|
||||
.get_or_try_init(|| async {
|
||||
let cache_dir = self.cache_dir.clone();
|
||||
let built = tokio::task::spawn_blocking(move || {
|
||||
TextEmbedding::try_new(
|
||||
InitOptions::new(model).with_cache_dir(cache_dir),
|
||||
)
|
||||
TextEmbedding::try_new(InitOptions::new(model).with_cache_dir(cache_dir))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| EmbedderError::Io(format!("onnx init task failed: {e}")))?
|
||||
|
||||
@ -14,14 +14,15 @@ mod template;
|
||||
mod vector;
|
||||
|
||||
pub use context::IdeaiContextStore;
|
||||
pub use embedder::{
|
||||
embedder_from_profile, onnx_model_is_cached, HashEmbedder, OnnxModelInfo, StubEmbedder,
|
||||
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||
};
|
||||
#[cfg(feature = "vector-http")]
|
||||
pub use embedder::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
|
||||
#[cfg(feature = "vector-onnx")]
|
||||
pub use embedder::OnnxEmbedder;
|
||||
#[cfg(feature = "vector-http")]
|
||||
pub use embedder::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
|
||||
pub use embedder::{
|
||||
embedder_from_profile, onnx_model_is_cached, EmbedderEnvProbe, FsEmbedderPromptStore,
|
||||
HashEmbedder, OnnxModelInfo, StubEmbedder, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||
pub use project::FsProjectStore;
|
||||
|
||||
@ -23,7 +23,7 @@ use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError};
|
||||
use domain::ports::{EmbedderProfileStore, FileSystem, ProfileStore, RemotePath, StoreError};
|
||||
use domain::profile::{AgentProfile, EmbedderProfile};
|
||||
|
||||
/// File name of the profiles store inside the app-data dir.
|
||||
@ -181,10 +181,11 @@ impl Default for EmbedderDoc {
|
||||
/// calqué on [`FsProfileStore`]: `embedder.json` in the global IDE app-data dir,
|
||||
/// mirroring `profiles.json`.
|
||||
///
|
||||
/// No domain `EmbedderProfileStore` port exists yet — embedder profiles are pure
|
||||
/// configuration loaded at the composition root, so this is a concrete loader. A
|
||||
/// dedicated port (parallel to [`ProfileStore`]) is an easy follow-up the day the
|
||||
/// UI needs CRUD over embedder profiles.
|
||||
/// Implements the domain [`EmbedderProfileStore`] port (parallel to [`ProfileStore`]).
|
||||
/// The inherent `list`/`save`/`delete` methods are kept so the composition root can
|
||||
/// load the configured profile *before* type-erasing to `Arc<dyn EmbedderProfileStore>`
|
||||
/// (e.g. inside a one-shot blocking runtime in `state.rs`); the trait impl simply
|
||||
/// delegates to them.
|
||||
///
|
||||
/// Cheap to clone (everything behind `Arc`).
|
||||
#[derive(Clone)]
|
||||
@ -270,3 +271,18 @@ impl FsEmbedderProfileStore {
|
||||
self.write_doc(&doc).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmbedderProfileStore for FsEmbedderProfileStore {
|
||||
async fn list(&self) -> Result<Vec<EmbedderProfile>, StoreError> {
|
||||
FsEmbedderProfileStore::list(self).await
|
||||
}
|
||||
|
||||
async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError> {
|
||||
FsEmbedderProfileStore::save(self, profile).await
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &str) -> Result<(), StoreError> {
|
||||
FsEmbedderProfileStore::delete(self, id).await
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,8 +76,9 @@ impl FsProjectStore {
|
||||
async fn read_registry(&self) -> Result<Registry, StoreError> {
|
||||
let path = self.path(REGISTRY_FILE);
|
||||
match self.fs.read(&path).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||
.map_err(|e| StoreError::Serialization(e.to_string())),
|
||||
Ok(bytes) => {
|
||||
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
|
||||
}
|
||||
Err(domain::ports::FsError::NotFound(_)) => Ok(Registry {
|
||||
version: REGISTRY_VERSION,
|
||||
projects: Vec::new(),
|
||||
|
||||
@ -183,8 +183,13 @@ impl FsSkillStore {
|
||||
})?;
|
||||
let content =
|
||||
String::from_utf8(bytes).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
Skill::new(entry.id, entry.name.clone(), MarkdownDoc::new(content), scope)
|
||||
.map_err(|e| StoreError::Serialization(e.to_string()))
|
||||
Skill::new(
|
||||
entry.id,
|
||||
entry.name.clone(),
|
||||
MarkdownDoc::new(content),
|
||||
scope,
|
||||
)
|
||||
.map_err(|e| StoreError::Serialization(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -235,10 +235,7 @@ impl VectorMemoryRecall {
|
||||
/// the `?` plumbing compiles; in practice [`AdaptiveMemoryRecall`] guards this
|
||||
/// path and falls back to naïve before any such error reaches a use case.
|
||||
async fn recall_embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> {
|
||||
self.embedder
|
||||
.embed(texts)
|
||||
.await
|
||||
.map_err(map_embedder_error)
|
||||
self.embedder.embed(texts).await.map_err(map_embedder_error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
236
crates/infrastructure/tests/embedder_config.rs
Normal file
236
crates/infrastructure/tests/embedder_config.rs
Normal 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"));
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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:?}");
|
||||
}
|
||||
|
||||
@ -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(¬es).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()),
|
||||
|
||||
@ -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(), ¬e("alpha", "the hook", MemoryType::Project, "# Body"))
|
||||
.save(
|
||||
&root(),
|
||||
¬e("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
|
||||
));
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user