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