1use std::sync::Arc;
11
12use anyhow::Result;
13use oxios_ouroboros::ExecutionResult;
14use parking_lot::RwLock;
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18use crate::agent_lifecycle::AgentLifecycleManager;
19use crate::event_bus::EventBus;
20use crate::git_layer::GitLayer;
21use crate::metrics::get_metrics;
22use crate::mount::{MountId, MountManager};
23use crate::project::{ConversationBuffer, ProjectManager};
24use crate::scheduler::Priority;
25use crate::state_store::StateStore;
26use crate::types::AgentId;
27
28#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
30pub enum AgentRole {
31 #[default]
33 Worker,
34 Manager,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SubTask {
41 pub id: Uuid,
43 pub description: String,
45 pub required_capability: Option<String>,
47 pub result: Option<String>,
49 pub success: bool,
51 #[serde(default)]
53 pub role: AgentRole,
54}
55
56impl SubTask {
57 pub fn new(description: impl Into<String>) -> Self {
59 Self {
60 id: Uuid::new_v4(),
61 description: description.into(),
62 required_capability: None,
63 result: None,
64 success: false,
65 role: AgentRole::default(),
66 }
67 }
68
69 pub fn with_capability(mut self, cap: impl Into<String>) -> Self {
71 self.required_capability = Some(cap.into());
72 self
73 }
74}
75
76#[allow(dead_code)]
78pub struct Orchestrator {
79 intent_engine: RwLock<Option<Arc<dyn oxios_ouroboros::IntentEngineOps>>>,
82 event_bus: EventBus,
83 state_store: Arc<StateStore>,
84 git_layer: Option<Arc<GitLayer>>,
86 lifecycle: AgentLifecycleManager,
88 a2a: Option<Arc<crate::a2a::A2AProtocol>>,
90 project_manager: RwLock<Option<Arc<ProjectManager>>>,
92 mount_manager: RwLock<Option<Arc<MountManager>>>,
94 conversation_buffer: RwLock<ConversationBuffer>,
96 delegation_config: DelegationConfig,
98 a2a_breaker: Arc<crate::a2a::circuit_breaker::A2ACircuitBreaker>,
100 intent_config: RwLock<crate::config::IntentConfig>,
102}
103
104#[allow(dead_code)]
106struct DelegationConfig {
107 max_retries: u32,
109 base_delay_ms: u64,
111 max_delay_ms: u64,
113 #[allow(dead_code)]
115 timeout_ms: u64,
116}
117
118impl Default for DelegationConfig {
119 fn default() -> Self {
120 Self {
121 max_retries: 3,
122 base_delay_ms: 100,
123 max_delay_ms: 5000,
124 timeout_ms: 5000,
125 }
126 }
127}
128
129#[allow(dead_code)]
130impl DelegationConfig {
131 fn backoff_delay(&self, attempt: u32) -> u64 {
133 let delay = self.base_delay_ms * 2_u64.saturating_pow(attempt.min(10));
134 delay.min(self.max_delay_ms)
135 }
136}
137
138impl Orchestrator {
139 pub fn new(
141 event_bus: EventBus,
142 state_store: Arc<StateStore>,
143 lifecycle: AgentLifecycleManager,
144 ) -> Self {
145 Self::with_config(
146 event_bus,
147 state_store,
148 lifecycle,
149 crate::config::OrchestratorConfig::default(),
150 )
151 }
152
153 pub fn with_config(
155 event_bus: EventBus,
156 state_store: Arc<StateStore>,
157 lifecycle: AgentLifecycleManager,
158 _config: crate::config::OrchestratorConfig,
159 ) -> Self {
160 Self {
161 intent_engine: RwLock::new(None),
162 event_bus,
163 state_store,
164 git_layer: None,
165 lifecycle,
166 a2a: None,
167 project_manager: RwLock::new(None),
168 mount_manager: RwLock::new(None),
169 conversation_buffer: RwLock::new(ConversationBuffer::default()),
170 delegation_config: DelegationConfig::default(),
171 intent_config: RwLock::new(crate::config::IntentConfig::default()),
172 a2a_breaker: Arc::new(crate::a2a::circuit_breaker::A2ACircuitBreaker::new(5, 30)),
173 }
174 }
175
176 pub fn set_intent_engine(&self, engine: Arc<dyn oxios_ouroboros::IntentEngineOps>) {
179 *self.intent_engine.write() = Some(engine);
180 }
181
182 pub fn has_intent_engine(&self) -> bool {
184 self.intent_engine.read().is_some()
185 }
186
187 pub fn set_project_manager(&self, manager: Arc<ProjectManager>) {
189 *self.project_manager.write() = Some(manager);
190 }
191
192 pub fn set_mount_manager(&self, manager: Arc<MountManager>) {
194 *self.mount_manager.write() = Some(manager);
195 }
196
197 pub fn mount_manager(&self) -> Option<Arc<MountManager>> {
199 self.mount_manager.read().as_ref().cloned()
200 }
201
202 pub fn project_manager(&self) -> Option<Arc<ProjectManager>> {
204 self.project_manager.read().as_ref().cloned()
205 }
206
207 pub fn detect_project_tag(&self, message: &str) -> Option<String> {
209 self.project_manager.read().as_ref().and_then(|pm| {
210 let projects = pm.list_projects();
211 let result = crate::project::detect_project(message, &projects);
212 match result {
213 crate::project::DetectionResult::Found(id) => pm.get_project(id).map(|p| p.tag()),
214 crate::project::DetectionResult::NoMatch { .. } => None,
215 }
216 })
217 }
218
219 fn resolve_mount_workspace(
233 &self,
234 mount_ids: Option<&str>,
235 project_ids: Option<&str>,
236 user_message: &str,
237 ) -> (
238 Vec<MountId>,
239 Option<String>,
240 Vec<std::path::PathBuf>,
241 String,
242 ) {
243 use crate::mount::Mount;
244
245 let Some(mm) = self.mount_manager() else {
246 return (Vec::new(), None, Vec::new(), String::new());
247 };
248
249 let mut ids: Vec<MountId> = if let Some(ids_str) = mount_ids {
251 ids_str
252 .split(',')
253 .filter_map(|s| MountId::parse_str(s.trim()).ok())
254 .collect()
255 } else {
256 match mm.detect(user_message) {
257 crate::mount::DetectionResult::Found(id) => vec![id],
258 crate::mount::DetectionResult::NoMatch { .. } => vec![],
259 }
260 };
261 let mut seen = std::collections::HashSet::new();
263 ids.retain(|id| seen.insert(*id));
264
265 let project_for_instructions: Option<crate::project::Project> = if let Some(project_ids_str) =
272 project_ids
273 && let Some(first_id_str) = project_ids_str.split(',').next()
274 && let Some(pm) = self.project_manager()
275 && let Ok(pid) = Uuid::parse_str(first_id_str.trim())
276 {
277 let proj = pm.get_project(pid);
278 if let Some(ref project) = proj {
279 for mid in &project.mount_ids {
280 if !ids.contains(mid) {
281 ids.push(*mid);
282 }
283 }
284 }
285 proj
286 } else {
287 None
288 };
289
290 if ids.is_empty() {
291 return (Vec::new(), None, Vec::new(), String::new());
292 }
293
294 for id in &ids {
297 mm.touch(*id);
298 }
299
300 let mounts: Vec<Mount> = mm.get_mounts_ordered(&ids);
301 if mounts.is_empty() {
302 return (Vec::new(), None, Vec::new(), String::new());
303 }
304
305 let mut paths: Vec<std::path::PathBuf> = Vec::new();
307 for m in &mounts {
308 for p in &m.paths {
309 if !paths.contains(p) {
310 paths.push(p.clone());
311 }
312 }
313 }
314
315 if let Some(project) = &project_for_instructions
320 && project.mount_ids.is_empty()
321 && !project.paths.is_empty()
322 {
323 for p in &project.paths {
324 if !paths.contains(p) {
325 paths.push(p.clone());
326 }
327 }
328 }
329
330 let tag = if mounts.len() == 1 {
332 mounts[0].tag()
333 } else {
334 let names: Vec<&str> = mounts.iter().map(|m| m.name.as_str()).collect();
335 format!("[🔧 {}]", names.join(" + "))
336 };
337
338 let mut context = build_workspace_context_body(&mounts).unwrap_or_default();
339
340 if let Some(project) = project_for_instructions {
346 let instructions = if project.instructions.len() > 2000 {
348 let mut end = 2000;
349 while end > 0 && !project.instructions.is_char_boundary(end) {
350 end -= 1;
351 }
352 format!("{}...", &project.instructions[..end])
353 } else {
354 project.instructions.clone()
355 };
356 if !instructions.is_empty() {
357 context.push_str(&format!(
358 "\n### Project Instructions: {}\n{}\n",
359 project.name, instructions
360 ));
361 }
362 }
363
364 const MAX_CONTEXT_CHARS: usize = 6000;
366 if context.len() > MAX_CONTEXT_CHARS {
367 let mut end = MAX_CONTEXT_CHARS;
368 while end > 0 && !context.is_char_boundary(end) {
369 end -= 1;
370 }
371 context.truncate(end);
372 context.push_str("\n...(context truncated)...\n");
373 }
374
375 let context_opt = if context.is_empty() {
376 None
377 } else {
378 Some(context)
379 };
380 (ids, context_opt, paths, tag)
381 }
382
383 pub fn set_a2a(&mut self, a2a: Arc<crate::a2a::A2AProtocol>) {
385 self.a2a = Some(a2a);
386 }
387
388 pub fn set_git_layer(&mut self, git_layer: Arc<GitLayer>) {
390 self.git_layer = Some(git_layer);
391 }
392
393 pub async fn restore_sessions(&self) {
399 }
401
402 #[allow(dead_code)]
403 fn git_commit(&self, rel_path: &str, message: &str) {
404 if let Some(ref gl) = self.git_layer
405 && gl.is_enabled()
406 {
407 let _ = gl.commit_file(rel_path, message);
408 }
409 }
410
411 pub async fn handle(
451 &self,
452 engine: &dyn oxios_ouroboros::IntentEngineOps,
453 msg: &str,
454 ctx: &oxios_ouroboros::MsgCtx,
455 ) -> Result<HandleResponse> {
456 let assessment = engine.assess(msg, ctx).await?;
458
459 match assessment {
460 oxios_ouroboros::Assessment::Conversation(reply) => Ok(HandleResponse::Reply(reply)),
461
462 oxios_ouroboros::Assessment::Clarify { questions } => {
463 Ok(HandleResponse::Clarify(questions))
464 }
465
466 oxios_ouroboros::Assessment::Task(scope) => {
467 let mut directive = match scope {
469 oxios_ouroboros::Scope::Trivial => {
470 oxios_ouroboros::Directive::from_message(msg)
471 }
472 oxios_ouroboros::Scope::Substantial => engine.crystallize(msg, ctx).await?,
473 };
474
475 let env = self.resolve_exec_env(ctx, msg);
477
478 let mut result = self.execute_directive(&directive, &env).await?;
481
482 let (verdict, evaluation_passed) = match scope {
484 oxios_ouroboros::Scope::Trivial => (None, None),
485 oxios_ouroboros::Scope::Substantial => {
486 let (r, v) = self
487 .verify_or_retry(engine, &mut directive, &env, result, msg, ctx)
488 .await?;
489 result = r;
490 let passed = v.all_passed();
491 (Some(v), Some(passed))
492 }
493 };
494
495 self.lifecycle.reap_zombies();
496
497 Ok(HandleResponse::Task {
498 scope,
499 directive: Box::new(directive),
500 env,
501 result: Box::new(result),
502 verdict,
503 evaluation_passed,
504 })
505 }
506 }
507 }
508
509 pub async fn handle_unified(
516 &self,
517 user_id: &str,
518 msg: &str,
519 session_id: Option<&str>,
520 project_ids: Option<&str>,
521 mount_ids: Option<&str>,
522 request_id: &str,
523 ) -> Result<OrchestrationResult> {
524 let engine = self
526 .intent_engine
527 .read()
528 .clone()
529 .expect("IntentEngine not wired — kernel assembler bug");
530
531 let sid = session_id.unwrap_or(request_id).to_string();
533 let history = self.load_session_history(&sid).await;
534 let ctx = oxios_ouroboros::MsgCtx {
535 session_id: sid.clone(),
536 history,
537 project_ids: project_ids.map(String::from),
538 mount_ids: mount_ids.map(String::from),
539 user_id: user_id.to_string(),
540 };
541
542 let start = std::time::Instant::now();
544 let response = self.handle(engine.as_ref(), msg, &ctx).await?;
545 let duration_ms = start.elapsed().as_millis() as u64;
546
547 Ok(self.handle_response_to_orchestration_result(response, &ctx, duration_ms))
548 }
549
550 async fn load_session_history(&self, session_id: &str) -> Vec<oxios_ouroboros::Exchange> {
552 let sid = crate::state_store::SessionId(session_id.to_string());
553 match self.state_store.load_session(&sid).await {
554 Ok(Some(session)) => session
555 .user_messages
556 .iter()
557 .zip(session.agent_responses.iter())
558 .map(|(u, a)| oxios_ouroboros::Exchange {
559 user: u.content.clone(),
560 agent: a.content.clone(),
561 })
562 .collect(),
563 _ => Vec::new(),
564 }
565 }
566
567 fn handle_response_to_orchestration_result(
568 &self,
569 response: HandleResponse,
570 ctx: &oxios_ouroboros::MsgCtx,
571 duration_ms: u64,
572 ) -> OrchestrationResult {
573 let metrics = get_metrics();
574 metrics.orch_duration.observe(duration_ms as f64 / 1000.0);
575
576 match response {
577 HandleResponse::Reply(reply) => OrchestrationResult {
578 session_id: Some(ctx.session_id.clone()),
579 primary_project_id: None,
580 project_tag: None,
581 active_mount_ids: Vec::new(),
582 mount_tag: None,
583 response: reply,
584 agent_id: None,
585 phase_reached: "interview".to_string(),
586 evaluation_passed: None,
587 output: None,
588 tool_calls: Vec::new(),
589 interview_questions: None,
590 interview_round: None,
591 },
592 HandleResponse::Clarify(questions) => {
593 let questions_text = questions
594 .iter()
595 .map(|q| q.text.clone())
596 .collect::<Vec<_>>()
597 .join("\n");
598 let structured = Some(
599 questions
600 .iter()
601 .map(|q| oxios_ouroboros::InterviewQuestionOutput {
602 id: q.id.clone(),
603 text: q.text.clone(),
604 kind: format!("{:?}", q.kind).to_lowercase(),
605 options: q
606 .options
607 .iter()
608 .map(|o| oxios_ouroboros::InterviewOptionOutput {
609 value: o.value.clone(),
610 label: o.label.clone(),
611 description: String::new(),
612 })
613 .collect(),
614 })
615 .collect(),
616 );
617 OrchestrationResult {
618 session_id: Some(ctx.session_id.clone()),
619 primary_project_id: None,
620 project_tag: None,
621 active_mount_ids: Vec::new(),
622 mount_tag: None,
623 response: questions_text,
624 agent_id: None,
625 phase_reached: "interview".to_string(),
626 evaluation_passed: None,
627 output: None,
628 tool_calls: Vec::new(),
629 interview_questions: structured,
630 interview_round: Some(((ctx.history.len() / 2) as u32).max(1)),
631 }
632 }
633 HandleResponse::Task {
634 scope: _,
635 directive,
636 env,
637 result,
638 verdict,
639 evaluation_passed,
640 } => {
641 let response_text = if directive.acceptance_criteria.is_empty() {
642 result.output.clone()
643 } else {
644 match &verdict {
645 Some(v) if v.all_passed() => result.output.clone(),
646 Some(v) => format!(
647 "{}\n\n⚠ Review notes:\n{}",
648 result.output,
649 v.notes.join("\n")
650 ),
651 None => result.output.clone(),
652 }
653 };
654 if evaluation_passed.unwrap_or(false) {
655 metrics.agents_completed.inc();
656 } else {
657 metrics.agents_failed.inc();
658 }
659 OrchestrationResult {
660 session_id: Some(ctx.session_id.clone()),
661 primary_project_id: env.project_id,
662 project_tag: None,
663 active_mount_ids: Vec::new(),
664 mount_tag: None,
665 response: response_text,
666 agent_id: None,
667 phase_reached: "execute".to_string(),
668 evaluation_passed,
669 output: Some(result.output.clone()),
670 tool_calls: result.tool_calls.clone(),
671 interview_questions: None,
672 interview_round: None,
673 }
674 }
675 }
676 }
677
678 fn resolve_exec_env(
685 &self,
686 ctx: &oxios_ouroboros::MsgCtx,
687 msg: &str,
688 ) -> oxios_ouroboros::ExecEnv {
689 let (active_mount_ids, workspace_context, mount_paths, _mount_tag) =
690 self.resolve_mount_workspace(ctx.mount_ids.as_deref(), ctx.project_ids.as_deref(), msg);
691 let _ = active_mount_ids;
695
696 let project_id = ctx
699 .project_ids
700 .as_deref()
701 .and_then(|ids| {
702 ids.split(',')
703 .next()
704 .and_then(|s| Uuid::parse_str(s.trim()).ok())
705 })
706 .or_else(|| {
707 self.detect_project_tag(msg).and_then(|_tag| {
708 self.project_manager().and_then(|pm| {
709 let projects = pm.list_projects();
710 match crate::project::detect_project(msg, &projects) {
711 crate::project::DetectionResult::Found(id) => Some(id),
712 crate::project::DetectionResult::NoMatch { .. } => None,
713 }
714 })
715 })
716 });
717
718 if let Some(pid) = project_id
720 && let Some(pm) = self.project_manager()
721 {
722 pm.touch(pid);
723 }
724
725 oxios_ouroboros::ExecEnv {
726 workspace_context,
727 mount_paths,
728 project_id,
729 cspace_hint: None,
730 }
731 }
732
733 async fn execute_directive(
743 &self,
744 directive: &oxios_ouroboros::Directive,
745 env: &oxios_ouroboros::ExecEnv,
746 ) -> Result<ExecutionResult> {
747 self.lifecycle
748 .execute_directive(directive, env, Priority::Normal)
749 .await
750 }
751
752 async fn verify_or_retry(
759 &self,
760 engine: &dyn oxios_ouroboros::IntentEngineOps,
761 directive: &mut oxios_ouroboros::Directive,
762 env: &oxios_ouroboros::ExecEnv,
763 initial_result: ExecutionResult,
764 _msg: &str,
765 _ctx: &oxios_ouroboros::MsgCtx,
766 ) -> Result<(ExecutionResult, oxios_ouroboros::Verdict)> {
767 let verdict = engine.review(directive, &initial_result).await?;
768
769 if verdict.all_passed() || verdict.gaps.is_empty() {
770 return Ok((initial_result, verdict));
771 }
772
773 let enable_retry = self.intent_config.read().enable_retry;
776 if !enable_retry {
777 tracing::info!("Review failed but retry disabled (enable_retry=false)");
778 return Ok((initial_result, verdict));
779 }
780
781 let metrics = get_metrics();
782 metrics.retry_attempted.inc();
783
784 tracing::info!(
785 gaps = verdict.gaps.len(),
786 "Review failed — retrying with feedback"
787 );
788
789 let retry_result = self
791 .lifecycle
792 .execute_with_feedback(
793 directive,
794 env,
795 &initial_result,
796 &verdict.gaps,
797 Priority::Normal,
798 )
799 .await?;
800
801 let retry_verdict = engine.review(directive, &retry_result).await?;
803
804 if retry_verdict.score > verdict.score {
806 metrics.retry_improved.inc();
807 } else if retry_verdict.score < verdict.score {
808 metrics.retry_degraded.inc();
809 } else {
810 metrics.retry_unchanged.inc();
811 }
812
813 let chosen_result = if retry_verdict.score >= verdict.score {
815 retry_result
816 } else {
817 initial_result
818 };
819
820 Ok((chosen_result, retry_verdict))
821 }
822}
823
824#[derive(Debug, Clone)]
836pub enum HandleResponse {
837 Reply(String),
839 Clarify(Vec<oxios_ouroboros::Question>),
841 Task {
843 scope: oxios_ouroboros::Scope,
845 directive: Box<oxios_ouroboros::Directive>,
847 env: oxios_ouroboros::ExecEnv,
849 result: Box<ExecutionResult>,
851 verdict: Option<oxios_ouroboros::Verdict>,
853 evaluation_passed: Option<bool>,
855 },
856}
857
858#[derive(Debug, Clone, Serialize, Deserialize)]
860pub struct OrchestrationResult {
861 #[serde(skip_serializing_if = "Option::is_none")]
863 pub session_id: Option<String>,
864 #[serde(skip_serializing_if = "Option::is_none")]
866 pub primary_project_id: Option<Uuid>,
867 #[serde(skip_serializing_if = "Option::is_none")]
869 pub project_tag: Option<String>,
870 #[serde(default, skip_serializing_if = "Vec::is_empty")]
872 pub active_mount_ids: Vec<MountId>,
873 #[serde(skip_serializing_if = "Option::is_none")]
875 pub mount_tag: Option<String>,
876 pub response: String,
878 #[serde(skip_serializing_if = "Option::is_none")]
880 pub agent_id: Option<AgentId>,
881 pub phase_reached: String,
883 pub evaluation_passed: Option<bool>,
889 #[serde(skip_serializing_if = "Option::is_none")]
891 pub output: Option<String>,
892 #[serde(default, skip_serializing_if = "Vec::is_empty")]
894 pub tool_calls: Vec<oxios_ouroboros::ToolCallRecord>,
895 #[serde(default, skip_serializing_if = "Option::is_none")]
903 pub interview_questions: Option<Vec<oxios_ouroboros::InterviewQuestionOutput>>,
904 #[serde(default, skip_serializing_if = "Option::is_none")]
907 pub interview_round: Option<u32>,
908}
909
910fn build_workspace_context_body(mounts: &[crate::mount::Mount]) -> Option<String> {
919 if mounts.is_empty() {
920 return None;
921 }
922 let mut out = String::new();
923 out.push_str("### Active Mounts\n");
924
925 for (i, m) in mounts.iter().enumerate() {
926 let primary = i == 0;
927 let path = m
928 .primary_path()
929 .map(|p| p.to_string_lossy().to_string())
930 .unwrap_or_else(|| "(no path)".to_string());
931
932 if primary {
933 out.push_str(&format!("- **{}** → {}\n", m.name, path));
934 if !m.auto_description.is_empty() {
935 let desc: String = m
937 .auto_description
938 .lines()
939 .take(3)
940 .collect::<Vec<_>>()
941 .join("\n ");
942 out.push_str(&format!(" {}\n", desc));
943 }
944 let summary = m.summary_line();
945 if !summary.is_empty() {
946 out.push_str(&format!(" _{}_\n", summary));
947 }
948 if m.enrichment_pending {
949 out.push_str(" _(content changed — consider re-scanning this Mount)_\n");
950 }
951 } else {
952 let summary = m.summary_line();
954 let suffix = if summary.is_empty() {
955 String::new()
956 } else {
957 format!(" — {}", summary)
958 };
959 out.push_str(&format!("- **{}** → {}{}\n", m.name, path, suffix));
960 }
961 }
962
963 Some(out)
964}
965
966#[cfg(test)]
967mod mount_workspace_tests {
968 use super::*;
969 use crate::mount::{Mount, MountSource};
970 use std::path::PathBuf;
971
972 #[test]
973 fn test_workspace_context_primary_full_secondary_terse() {
974 let mut oxios =
975 Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
976 oxios.auto_description = "Agent OS.\nRust + tokio.".to_string();
977 oxios.auto_meta.summary = "Rust agent OS".to_string();
978
979 let mut oxi = Mount::from_name_and_path("oxi", PathBuf::from("/oxi"));
980 oxi.auto_meta.summary = "SDK".to_string();
981
982 let body = build_workspace_context_body(&[oxios, oxi]).unwrap();
983 assert!(body.contains("### Active Mounts"));
984 assert!(body.contains("Agent OS."));
986 assert!(body.contains("_Rust agent OS_"));
987 assert!(body.contains("**oxi** → /oxi — SDK"));
989 }
990
991 #[test]
992 fn test_workspace_context_empty_is_none() {
993 assert!(build_workspace_context_body(&[]).is_none());
994 }
995
996 #[test]
1000 fn test_resolve_mount_workspace_detects_and_collects_paths() {
1001 use crate::mount::MountManager;
1002 use oxios_memory::memory::sqlite::MemoryDatabase;
1003 use std::sync::Arc;
1004
1005 let db = Arc::new(MemoryDatabase::open_in_memory(64).unwrap());
1006 let mm = Arc::new(MountManager::new(db, None).unwrap());
1007
1008 let oxios = mm
1010 .create_mount(
1011 "oxios".to_string(),
1012 vec![PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios")],
1013 MountSource::Manual,
1014 )
1015 .unwrap();
1016 let oxi_sdk = mm
1017 .create_mount(
1018 "oxi-sdk".to_string(),
1019 vec![PathBuf::from("/Users/me/oxi")],
1020 MountSource::Manual,
1021 )
1022 .unwrap();
1023 mm.update_enrichment(oxios.id, Some("Agent OS in Rust.".to_string()), None)
1024 .unwrap();
1025
1026 let mounts = mm.get_mounts_ordered(&[oxios.id, oxi_sdk.id]);
1030 assert_eq!(mounts.len(), 2);
1031
1032 let body = build_workspace_context_body(&mounts).unwrap();
1033 assert!(body.contains("oxios"));
1034 assert!(body.contains("Agent OS in Rust."));
1035 assert!(body.contains("oxi-sdk"));
1036
1037 let mut paths = Vec::new();
1039 for m in &mounts {
1040 for p in &m.paths {
1041 if !paths.contains(p) {
1042 paths.push(p.clone());
1043 }
1044 }
1045 }
1046 assert_eq!(paths.len(), 2);
1047 assert_eq!(paths[0], PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
1048 assert_eq!(paths[1], PathBuf::from("/Users/me/oxi"));
1049 }
1050
1051 #[test]
1054 fn test_detection_seeds_primary_on_name_mention() {
1055 use crate::mount::{DetectionResult, detect_mounts};
1056
1057 let oxios =
1058 Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
1059 let result = detect_mounts("oxios 코드리뷰해줘", std::slice::from_ref(&oxios));
1060 assert!(matches!(result, DetectionResult::Found(id) if id == oxios.id));
1061 }
1062}