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