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

@ -7,16 +7,16 @@ use app_tauri_lib::dto::{
LayoutOperationDto, OpenTerminalRequestDto, ReattachResultDto, ResizeTerminalRequestDto,
TerminalClosedDto, WriteTerminalRequestDto,
};
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
use application::{AppError, HealthInput};
use domain::events::DomainEvent;
use domain::ids::{AgentId, SessionId};
use domain::ProjectId;
use domain::TemplateVersion;
use domain::TemplateId;
use domain::TemplateVersion;
use serde_json::json;
use uuid::Uuid;
@ -188,7 +188,10 @@ fn terminal_closed_dto_serialises_code_camel_case() {
// Signalled (None) round-trips as null.
let none = TerminalClosedDto::from(CloseTerminalOutput { code: None });
assert_eq!(serde_json::to_value(&none).unwrap(), json!({ "code": null }));
assert_eq!(
serde_json::to_value(&none).unwrap(),
json!({ "code": null })
);
}
#[test]
@ -198,7 +201,10 @@ fn reattach_result_dto_serialises_camel_case() {
scrollback: vec![104, 105],
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v, json!({ "sessionId": "sess-1", "scrollback": [104, 105] }));
assert_eq!(
v,
json!({ "sessionId": "sess-1", "scrollback": [104, 105] })
);
}
#[test]
@ -327,7 +333,18 @@ fn layout_dto_round_trips_a_split_tree_shape() {
conversation_id: None,
agent_was_running: false,
})
.split(nid(1), Direction::Column, LeafCell { id: nid(2), session: None, agent: None, conversation_id: None, agent_was_running: false }, nid(9))
.split(
nid(1),
Direction::Column,
LeafCell {
id: nid(2),
session: None,
agent: None,
conversation_id: None,
agent_was_running: false,
},
nid(9),
)
.unwrap();
let dto = LayoutDto::from(LoadLayoutOutput {
layout_id: domain::LayoutId::new_random(),

View File

@ -11,8 +11,8 @@ use application::AppError;
use application::{
CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput,
};
use domain::ports::ConversationDetails;
use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
use domain::ports::ConversationDetails;
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
use domain::{Agent, AgentOrigin, ProjectPath};
use serde_json::json;
@ -44,7 +44,10 @@ fn agent_dto_serialises_camelcase() {
assert_eq!(v["id"], agent.id.to_string());
assert_eq!(v["name"], "My Agent");
assert_eq!(v["contextPath"], "agents/my-agent.md", "camelCase key");
assert_eq!(v["profileId"], ProfileId::from_uuid(Uuid::from_u128(2)).to_string());
assert_eq!(
v["profileId"],
ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
);
assert_eq!(v["synchronized"], false);
// origin: tagged `{ "type": "scratch" }`
assert_eq!(v["origin"]["type"], "scratch");
@ -68,7 +71,9 @@ fn agent_list_dto_is_transparent_array() {
#[test]
fn create_agent_output_maps_to_agent_dto() {
let agent = make_agent(5, 6);
let out = CreateAgentOutput { agent: agent.clone() };
let out = CreateAgentOutput {
agent: agent.clone(),
};
let dto = AgentDto::from(out);
assert_eq!(dto.0.id, agent.id);
}
@ -168,19 +173,25 @@ fn agent_already_running_error_code_is_stable() {
// Option A: a stable code + a text message (the node is NOT enriched into the
// ErrorDto — the frontend branches on the code alone).
assert_eq!(dto.code, "AGENT_ALREADY_RUNNING");
assert!(dto.message.contains("already running"), "message: {}", dto.message);
assert!(
dto.message.contains("already running"),
"message: {}",
dto.message
);
}
#[test]
fn live_agent_list_dto_serialises_camelcase_array() {
let agent_a = AgentId::from_uuid(Uuid::from_u128(11));
let node_a = NodeId::from_uuid(Uuid::from_u128(21));
let dto = LiveAgentListDto::from_pairs(vec![(agent_a, node_a)]);
let session_a = domain::SessionId::from_uuid(Uuid::from_u128(31));
let dto = LiveAgentListDto::from_pairs(vec![(agent_a, node_a, session_a)]);
let v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["agentId"], agent_a.to_string());
assert_eq!(arr[0]["nodeId"], node_a.to_string());
assert_eq!(arr[0]["sessionId"], session_a.to_string());
// No snake_case leak.
assert!(arr[0].get("agent_id").is_none());
assert!(arr[0].get("node_id").is_none());
@ -246,7 +257,10 @@ fn conversation_details_dto_omits_fields_when_none() {
let v = serde_json::to_value(&dto).unwrap();
// Both optional fields are omitted from the wire (absent, not null).
assert!(v.get("lastTopic").is_none(), "absent topic ⇒ field omitted");
assert!(v.get("tokenCount").is_none(), "absent tokens ⇒ field omitted");
assert!(
v.get("tokenCount").is_none(),
"absent tokens ⇒ field omitted"
);
assert_eq!(v, json!({}), "fully degraded ⇒ empty object");
}

View File

@ -6,7 +6,9 @@ use app_tauri_lib::dto::{
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
GitFileStatusDto, GitStageRequestDto, GitStatusListDto, GraphCommitDto, GraphCommitListDto,
};
use application::{GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput};
use application::{
GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput,
};
use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};
use serde_json::json;
use uuid::Uuid;

View File

@ -5,8 +5,8 @@
use app_tauri_lib::dto::{
parse_layout_id, CreateLayoutRequestDto, CreateLayoutResultDto, DeleteLayoutRequestDto,
DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto, RenameLayoutRequestDto,
SetActiveLayoutRequestDto,
DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto,
RenameLayoutRequestDto, SetActiveLayoutRequestDto,
};
use application::{
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput,
@ -63,8 +63,16 @@ fn layout_info_dto_git_graph_kind() {
fn list_layouts_dto_from_output() {
let out = ListLayoutsOutput {
layouts: vec![
LayoutInfo { id: lid(1), name: "Default".to_owned(), kind: LayoutKind::Terminal },
LayoutInfo { id: lid(2), name: "Backend".to_owned(), kind: LayoutKind::GitGraph },
LayoutInfo {
id: lid(1),
name: "Default".to_owned(),
kind: LayoutKind::Terminal,
},
LayoutInfo {
id: lid(2),
name: "Backend".to_owned(),
kind: LayoutKind::GitGraph,
},
],
active_id: lid(1),
};
@ -204,7 +212,10 @@ fn set_cell_agent_op_deserialises_with_agent() {
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
let op = dto.into_operation().unwrap();
match op {
application::LayoutOperation::SetCellAgent { target: t, agent: a } => {
application::LayoutOperation::SetCellAgent {
target: t,
agent: a,
} => {
assert_eq!(t, target);
assert_eq!(a, Some(agent));
}
@ -223,7 +234,10 @@ fn set_cell_agent_op_deserialises_with_null_agent() {
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
let op = dto.into_operation().unwrap();
match op {
application::LayoutOperation::SetCellAgent { target: t, agent: None } => {
application::LayoutOperation::SetCellAgent {
target: t,
agent: None,
} => {
assert_eq!(t, target);
}
_ => panic!("expected SetCellAgent with None agent"),

View File

@ -53,8 +53,14 @@ fn template_dto_serialises_camelcase() {
ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
);
// no snake_case leak
assert!(v.get("content_md").is_none(), "no snake_case leak for contentMd");
assert!(v.get("default_profile_id").is_none(), "no snake_case leak for defaultProfileId");
assert!(
v.get("content_md").is_none(),
"no snake_case leak for contentMd"
);
assert!(
v.get("default_profile_id").is_none(),
"no snake_case leak for defaultProfileId"
);
}
#[test]
@ -98,7 +104,9 @@ fn template_list_dto_empty() {
#[test]
fn create_template_output_maps_to_template_dto() {
let tmpl = make_template(5, 6);
let out = CreateTemplateOutput { template: tmpl.clone() };
let out = CreateTemplateOutput {
template: tmpl.clone(),
};
let dto = TemplateDto::from(out);
assert_eq!(dto.0.id, tmpl.id);
}
@ -107,7 +115,9 @@ fn create_template_output_maps_to_template_dto() {
fn update_template_output_maps_to_template_dto() {
let tmpl = make_template(7, 8);
let bumped = tmpl.with_updated_content(MarkdownDoc::new("# Updated".to_owned()));
let out = UpdateTemplateOutput { template: bumped.clone() };
let out = UpdateTemplateOutput {
template: bumped.clone(),
};
let dto = TemplateDto::from(out);
assert_eq!(dto.0.version, TemplateVersion(2));
assert_eq!(dto.0.id, bumped.id);
@ -132,7 +142,10 @@ fn agent_drift_dto_serialises_camelcase() {
assert_eq!(v["from"], 1u64);
assert_eq!(v["to"], 3u64);
// no snake_case leak
assert!(v.get("agent_id").is_none(), "no snake_case leak for agentId");
assert!(
v.get("agent_id").is_none(),
"no snake_case leak for agentId"
);
}
#[test]

View File

@ -15,7 +15,10 @@ fn move_tab_result_serializes_new_window_id_camel_case() {
let dto = MoveTabResultDto::from(out);
let json = serde_json::to_string(&dto).unwrap();
assert!(json.contains("\"newWindowId\""), "json was {json}");
assert!(!json.contains("new_window_id"), "no snake_case leak: {json}");
assert!(
!json.contains("new_window_id"),
"no snake_case leak: {json}"
);
}
#[test]

View File

@ -72,7 +72,10 @@ async fn stop_watch_unregisters_the_watcher() {
assert!(has_watcher(&state, &project.id));
state.stop_orchestrator_watch(&project.id);
assert!(!has_watcher(&state, &project.id), "watcher removed on close");
assert!(
!has_watcher(&state, &project.id),
"watcher removed on close"
);
assert_eq!(watcher_count(&state), 0);
// Stopping an unknown project is a no-op (does not panic).

View File

@ -47,7 +47,10 @@ fn send_output_delivers_bytes_to_registered_channel() {
bridge.register(session, capturing_channel(Arc::clone(&sink)));
let delivered = bridge.send_output(&session, vec![104, 105]);
assert!(delivered, "send_output should return true for a live session");
assert!(
delivered,
"send_output should return true for a live session"
);
let captured = sink.lock().unwrap();
assert_eq!(captured.as_slice(), &[vec![104, 105]]);
@ -82,10 +85,17 @@ fn register_same_session_twice_replaces_channel() {
bridge.register(session, capturing_channel(Arc::clone(&first)));
bridge.register(session, capturing_channel(Arc::clone(&second)));
assert_eq!(bridge.active_sessions(), 1, "same id is replaced, not added");
assert_eq!(
bridge.active_sessions(),
1,
"same id is replaced, not added"
);
bridge.send_output(&session, vec![9]);
assert!(first.lock().unwrap().is_empty(), "old channel no longer used");
assert!(
first.lock().unwrap().is_empty(),
"old channel no longer used"
);
assert_eq!(second.lock().unwrap().as_slice(), &[vec![9]]);
}
@ -116,7 +126,11 @@ fn unregister_if_is_a_noop_for_a_superseded_generation() {
bridge.unregister_if(&session, old_gen);
// The current channel survives and still delivers — no duplication, no drop.
assert_eq!(bridge.active_sessions(), 1, "live re-attach must not be removed");
assert_eq!(
bridge.active_sessions(),
1,
"live re-attach must not be removed"
);
assert!(bridge.send_output(&session, vec![7]));
assert_eq!(new.lock().unwrap().as_slice(), &[vec![7]]);
}