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

@ -105,10 +105,19 @@ fn profile_uses_camel_case_keys_and_skips_none_options() {
)
.unwrap();
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("\"apiKeyEnv\":\"MY_KEY\""), "camelCase apiKeyEnv: {json}");
assert!(json.contains("\"strategy\":\"api\""), "camelCase strategy: {json}");
assert!(
json.contains("\"apiKeyEnv\":\"MY_KEY\""),
"camelCase apiKeyEnv: {json}"
);
assert!(
json.contains("\"strategy\":\"api\""),
"camelCase strategy: {json}"
);
// `model` is None ⇒ skipped from the wire form.
assert!(!json.contains("\"model\""), "None model must be skipped: {json}");
assert!(
!json.contains("\"model\""),
"None model must be skipped: {json}"
);
}
#[test]
@ -138,7 +147,10 @@ fn none_profile_has_none_strategy() {
let p = EmbedderProfile::none();
assert_eq!(p.strategy, EmbedderStrategy::None);
assert_eq!(p.id, "none");
assert!(p.dimension > 0, "even the none profile keeps a non-zero dimension");
assert!(
p.dimension > 0,
"even the none profile keeps a non-zero dimension"
);
// And it round-trips like any other profile.
roundtrip(&p);
}
@ -151,7 +163,9 @@ fn none_profile_has_none_strategy() {
fn new_rejects_empty_id() {
assert!(matches!(
EmbedderProfile::new("", "Name", EmbedderStrategy::None, None, None, None, 8),
Err(DomainError::EmptyField { field: "embedder.id" })
Err(DomainError::EmptyField {
field: "embedder.id"
})
));
}
@ -177,7 +191,8 @@ fn new_rejects_zero_dimension() {
#[test]
fn new_accepts_valid_minimal_profile() {
let p = EmbedderProfile::new("id", "Name", EmbedderStrategy::None, None, None, None, 1).unwrap();
let p =
EmbedderProfile::new("id", "Name", EmbedderStrategy::None, None, None, None, 1).unwrap();
assert_eq!(p.dimension, 1);
assert_eq!(p.strategy, EmbedderStrategy::None);
}

View File

@ -5,8 +5,8 @@ mod helpers;
use domain::{
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError,
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, SessionStrategy,
Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef,
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
};
use helpers::{AtomicSeqIdGenerator, FixedClock};
use uuid::Uuid;
@ -203,8 +203,17 @@ fn profile_rejects_empty_command() {
#[test]
fn profile_rejects_empty_name() {
let err = AgentProfile::new(profile_id(), "", "claude", vec![], ci_stdin(), None, "{r}", None)
.unwrap_err();
let err = AgentProfile::new(
profile_id(),
"",
"claude",
vec![],
ci_stdin(),
None,
"{r}",
None,
)
.unwrap_err();
assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.name"));
}
@ -331,7 +340,8 @@ fn template_version_next_increments() {
#[test]
fn template_rejects_empty_name() {
let err = AgentTemplate::new(template_id(), "", MarkdownDoc::new(""), profile_id()).unwrap_err();
let err =
AgentTemplate::new(template_id(), "", MarkdownDoc::new(""), profile_id()).unwrap_err();
assert!(matches!(err, DomainError::EmptyField { .. }));
}
@ -345,9 +355,16 @@ fn agent_id(n: u128) -> domain::AgentId {
#[test]
fn manifest_entry_synchronized_requires_template_metadata() {
let err =
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, true, None)
.unwrap_err();
let err = ManifestEntry::new(
agent_id(1),
"A",
"agents/a.md",
profile_id(),
None,
true,
None,
)
.unwrap_err();
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
// template id present but version missing → still rejected.
@ -366,9 +383,16 @@ fn manifest_entry_synchronized_requires_template_metadata() {
#[test]
fn manifest_entry_rejects_empty_name() {
let err =
ManifestEntry::new(agent_id(1), " ", "agents/a.md", profile_id(), None, false, None)
.unwrap_err();
let err = ManifestEntry::new(
agent_id(1),
" ",
"agents/a.md",
profile_id(),
None,
false,
None,
)
.unwrap_err();
assert!(matches!(err, DomainError::EmptyField { .. }));
}
@ -416,24 +440,52 @@ fn manifest_entry_agent_roundtrip() {
#[test]
fn manifest_rejects_duplicate_md_path() {
let e1 =
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None)
.unwrap();
let e2 =
ManifestEntry::new(agent_id(2), "B", "agents/a.md", profile_id(), None, false, None)
.unwrap();
let e1 = ManifestEntry::new(
agent_id(1),
"A",
"agents/a.md",
profile_id(),
None,
false,
None,
)
.unwrap();
let e2 = ManifestEntry::new(
agent_id(2),
"B",
"agents/a.md",
profile_id(),
None,
false,
None,
)
.unwrap();
let err = AgentManifest::new(1, vec![e1, e2]).unwrap_err();
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
}
#[test]
fn manifest_unique_md_paths_ok() {
let e1 =
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None)
.unwrap();
let e2 =
ManifestEntry::new(agent_id(2), "B", "agents/b.md", profile_id(), None, false, None)
.unwrap();
let e1 = ManifestEntry::new(
agent_id(1),
"A",
"agents/a.md",
profile_id(),
None,
false,
None,
)
.unwrap();
let e2 = ManifestEntry::new(
agent_id(2),
"B",
"agents/b.md",
profile_id(),
None,
false,
None,
)
.unwrap();
assert!(AgentManifest::new(1, vec![e1, e2]).is_ok());
}
@ -465,7 +517,12 @@ fn skill_rejects_empty_name() {
SkillScope::Project,
)
.unwrap_err();
assert_eq!(err, DomainError::EmptyField { field: "skill.name" });
assert_eq!(
err,
DomainError::EmptyField {
field: "skill.name"
}
);
}
#[test]

View File

@ -72,9 +72,7 @@ impl Default for AtomicSeqIdGenerator {
impl IdGenerator for AtomicSeqIdGenerator {
fn new_uuid(&self) -> Uuid {
let n = self
.next
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let n = self.next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Uuid::from_u128(u128::from(n))
}
}

View File

@ -3,11 +3,11 @@
mod helpers;
use domain::ids::AgentId;
use domain::{
Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell,
SplitContainer, WeightedChild,
};
use domain::ids::AgentId;
use helpers::{node, session};
fn agent_id(n: u128) -> AgentId {
@ -463,6 +463,36 @@ fn set_session_duplicate_across_leaves_rejected() {
assert_eq!(err, LayoutError::DuplicateSession(session(100)));
}
#[test]
fn attach_session_attaches_background_session_to_leaf() {
let tree = two_leaves(None, None);
let out = tree.attach_session(node(2), session(100)).unwrap();
assert_eq!(session_for(&out, node(1)), None);
assert_eq!(session_for(&out, node(2)), Some(session(100)));
}
#[test]
fn attach_session_moves_visible_session_to_target_leaf() {
let tree = two_leaves(Some(100), None);
let out = tree.attach_session(node(2), session(100)).unwrap();
assert_eq!(session_for(&out, node(1)), None);
assert_eq!(session_for(&out, node(2)), Some(session(100)));
}
#[test]
fn attach_session_same_leaf_is_idempotent() {
let tree = two_leaves(Some(100), None);
let out = tree.attach_session(node(1), session(100)).unwrap();
assert_eq!(out, tree);
}
#[test]
fn attach_session_rejects_target_occupied_by_other_session() {
let tree = two_leaves(Some(100), Some(200));
let err = tree.attach_session(node(2), session(100)).unwrap_err();
assert_eq!(err, LayoutError::CrossContainer);
}
// ---------------------------------------------------------------------------
// set_cell_agent (#3: per-cell agent)
// ---------------------------------------------------------------------------
@ -503,7 +533,9 @@ fn set_cell_agent_is_immutable_source_unchanged() {
#[test]
fn set_cell_agent_missing_leaf_is_node_not_found() {
let tree = single(1, None);
let err = tree.set_cell_agent(node(404), Some(agent_id(1))).unwrap_err();
let err = tree
.set_cell_agent(node(404), Some(agent_id(1)))
.unwrap_err();
assert_eq!(err, LayoutError::NodeNotFound(node(404)));
}

View File

@ -2,9 +2,7 @@
//! [`MemorySlug`] validation, `[[slug]]` link scanning, frontmatter serde
//! round-trip, and the derived index entry.
use domain::{
DomainError, MarkdownDoc, Memory, MemoryFrontmatter, MemorySlug, MemoryType,
};
use domain::{DomainError, MarkdownDoc, Memory, MemoryFrontmatter, MemorySlug, MemoryType};
fn fm(slug: &str, description: &str, ty: MemoryType) -> MemoryFrontmatter {
MemoryFrontmatter {
@ -93,7 +91,10 @@ fn memory_rejects_empty_description() {
#[test]
fn memory_rejects_empty_body() {
let r = Memory::new(fm("ok-slug", "hook", MemoryType::User), MarkdownDoc::new(""));
let r = Memory::new(
fm("ok-slug", "hook", MemoryType::User),
MarkdownDoc::new(""),
);
assert!(matches!(r.unwrap_err(), DomainError::EmptyField { .. }));
}
@ -103,7 +104,9 @@ fn memory_rejects_empty_body() {
#[test]
fn outgoing_links_none() {
assert!(note("n", "plain body, no links").outgoing_links().is_empty());
assert!(note("n", "plain body, no links")
.outgoing_links()
.is_empty());
}
#[test]

View File

@ -6,8 +6,8 @@ mod helpers;
use domain::{
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction,
LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef,
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion,
WeightedChild,
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth,
TemplateVersion, WeightedChild,
};
use helpers::{node, session};
use uuid::Uuid;
@ -72,7 +72,14 @@ fn project_uses_camel_case_and_tagged_remote() {
#[test]
fn remote_ssh_roundtrip_and_tags() {
let r = RemoteRef::ssh("host", 2222, "me", SshAuth::Key { path: "/k".into() }, "/srv").unwrap();
let r = RemoteRef::ssh(
"host",
2222,
"me",
SshAuth::Key { path: "/k".into() },
"/srv",
)
.unwrap();
assert_eq!(roundtrip(&r), r);
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("\"kind\":\"ssh\""), "json was {json}");
@ -118,9 +125,12 @@ fn profile_roundtrip_all_injection_variants() {
#[test]
fn context_injection_strategy_tag_is_camel_case() {
let json = serde_json::to_string(&ContextInjection::convention_file("CLAUDE.md").unwrap())
.unwrap();
assert!(json.contains("\"strategy\":\"conventionFile\""), "json was {json}");
let json =
serde_json::to_string(&ContextInjection::convention_file("CLAUDE.md").unwrap()).unwrap();
assert!(
json.contains("\"strategy\":\"conventionFile\""),
"json was {json}"
);
let json = serde_json::to_string(&ContextInjection::stdin()).unwrap();
assert!(json.contains("\"strategy\":\"stdin\""), "json was {json}");
}
@ -203,7 +213,10 @@ fn session_assign_flag_omitted_when_none() {
let session = SessionStrategy::new(None, "--continue").unwrap();
let json = serde_json::to_string(&session).unwrap();
assert!(!json.contains("assignFlag"), "json was {json}");
assert!(json.contains("\"resumeFlag\":\"--continue\""), "json was {json}");
assert!(
json.contains("\"resumeFlag\":\"--continue\""),
"json was {json}"
);
assert_eq!(roundtrip(&session), session);
}
@ -244,13 +257,22 @@ fn agent_roundtrip_from_template() {
let json = serde_json::to_string(&a).unwrap();
assert!(json.contains("\"contextPath\""), "json was {json}");
// AgentOrigin tagged with `type`, camelCased.
assert!(json.contains("\"type\":\"fromTemplate\""), "json was {json}");
assert!(
json.contains("\"type\":\"fromTemplate\""),
"json was {json}"
);
// Inner fields must be camelCased per ARCHITECTURE §9.1:
// { "type":"fromTemplate", "templateId":"...", "syncedTemplateVersion":N }.
assert!(json.contains("\"templateId\""), "json was {json}");
assert!(json.contains("\"syncedTemplateVersion\":4"), "json was {json}");
assert!(
json.contains("\"syncedTemplateVersion\":4"),
"json was {json}"
);
assert!(!json.contains("\"template_id\""), "json was {json}");
assert!(!json.contains("\"synced_template_version\""), "json was {json}");
assert!(
!json.contains("\"synced_template_version\""),
"json was {json}"
);
assert!(!json.contains("\"synced_version\""), "json was {json}");
}
@ -285,14 +307,18 @@ fn manifest_roundtrip_and_camel_case() {
Some(TemplateVersion(5)),
)
.unwrap();
let e2 = ManifestEntry::new(aid(3), "Beta", "agents/b.md", profid(9), None, false, None).unwrap();
let e2 =
ManifestEntry::new(aid(3), "Beta", "agents/b.md", profid(9), None, false, None).unwrap();
let m = AgentManifest::new(1, vec![e1, e2]).unwrap();
assert_eq!(roundtrip(&m), m);
let json = serde_json::to_string(&m).unwrap();
// entries are serialized under "agents".
assert!(json.contains("\"agents\":["), "json was {json}");
assert!(json.contains("\"mdPath\""), "json was {json}");
assert!(json.contains("\"syncedTemplateVersion\":5"), "json was {json}");
assert!(
json.contains("\"syncedTemplateVersion\":5"),
"json was {json}"
);
// Non-synchronized entry omits optional template fields (skip_serializing_if).
assert!(!json.contains("\"templateId\":null"), "json was {json}");
}
@ -307,13 +333,25 @@ fn sid(n: u128) -> SkillId {
#[test]
fn skill_roundtrip_and_camel_case_scope() {
let s = Skill::new(sid(1), "code-review", MarkdownDoc::new("body"), SkillScope::Global).unwrap();
let s = Skill::new(
sid(1),
"code-review",
MarkdownDoc::new("body"),
SkillScope::Global,
)
.unwrap();
assert_eq!(roundtrip(&s), s);
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"scope\":\"global\""), "json was {json}");
assert!(json.contains("\"contentMd\""), "json was {json}");
let p = Skill::new(sid(2), "simplify", MarkdownDoc::new("b"), SkillScope::Project).unwrap();
let p = Skill::new(
sid(2),
"simplify",
MarkdownDoc::new("b"),
SkillScope::Project,
)
.unwrap();
let pj = serde_json::to_string(&p).unwrap();
assert!(pj.contains("\"scope\":\"project\""), "json was {pj}");
}
@ -412,7 +450,10 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
}
let json = serde_json::to_string(&tree).unwrap();
// agent present when set
assert!(json.contains("\"agent\""), "agent field should be present when set; json was {json}");
assert!(
json.contains("\"agent\""),
"agent field should be present when set; json was {json}"
);
// null variant omitted
let tree_no_agent = LayoutTree::new(LayoutNode::Leaf(LeafCell {
id: node(2),
@ -422,5 +463,8 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
agent_was_running: false,
}));
let json2 = serde_json::to_string(&tree_no_agent).unwrap();
assert!(!json2.contains("\"agent\""), "agent field should be omitted when None; json was {json2}");
assert!(
!json2.contains("\"agent\""),
"agent field should be omitted when None; json was {json2}"
);
}

View File

@ -3,8 +3,8 @@
//! window is dropped; an active moved tab hands activity back to a sibling.
use domain::{
LayoutNode, LayoutTree, LayoutError, LeafCell, NodeId, ProjectId, Tab, TabId, Window,
WindowId, Workspace,
LayoutError, LayoutNode, LayoutTree, LeafCell, NodeId, ProjectId, Tab, TabId, Window, WindowId,
Workspace,
};
use uuid::Uuid;