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 recovery: RwLock<Option<Arc<crate::resilience::RecoveryCoordinator>>>,
106}
107
108#[allow(dead_code)]
110struct DelegationConfig {
111 max_retries: u32,
113 base_delay_ms: u64,
115 max_delay_ms: u64,
117 #[allow(dead_code)]
119 timeout_ms: u64,
120}
121
122impl Default for DelegationConfig {
123 fn default() -> Self {
124 Self {
125 max_retries: 3,
126 base_delay_ms: 100,
127 max_delay_ms: 5000,
128 timeout_ms: 5000,
129 }
130 }
131}
132
133#[allow(dead_code)]
134impl DelegationConfig {
135 fn backoff_delay(&self, attempt: u32) -> u64 {
137 let delay = self.base_delay_ms * 2_u64.saturating_pow(attempt.min(10));
138 delay.min(self.max_delay_ms)
139 }
140}
141
142impl Orchestrator {
143 pub fn new(
145 event_bus: EventBus,
146 state_store: Arc<StateStore>,
147 lifecycle: AgentLifecycleManager,
148 ) -> Self {
149 Self::with_config(
150 event_bus,
151 state_store,
152 lifecycle,
153 crate::config::OrchestratorConfig::default(),
154 )
155 }
156
157 pub fn with_config(
159 event_bus: EventBus,
160 state_store: Arc<StateStore>,
161 lifecycle: AgentLifecycleManager,
162 _config: crate::config::OrchestratorConfig,
163 ) -> Self {
164 Self {
165 intent_engine: RwLock::new(None),
166 event_bus,
167 state_store,
168 git_layer: None,
169 lifecycle,
170 a2a: None,
171 project_manager: RwLock::new(None),
172 mount_manager: RwLock::new(None),
173 conversation_buffer: RwLock::new(ConversationBuffer::default()),
174 delegation_config: DelegationConfig::default(),
175 intent_config: RwLock::new(crate::config::IntentConfig::default()),
176 a2a_breaker: Arc::new(crate::a2a::circuit_breaker::A2ACircuitBreaker::new(5, 30)),
177 recovery: RwLock::new(None),
178 }
179 }
180
181 pub fn set_intent_engine(&self, engine: Arc<dyn oxios_ouroboros::IntentEngineOps>) {
184 *self.intent_engine.write() = Some(engine);
185 }
186
187 pub fn set_recovery(&self, coordinator: Arc<crate::resilience::RecoveryCoordinator>) {
191 *self.recovery.write() = Some(coordinator);
192 }
193
194 pub fn has_intent_engine(&self) -> bool {
196 self.intent_engine.read().is_some()
197 }
198
199 pub fn set_project_manager(&self, manager: Arc<ProjectManager>) {
201 *self.project_manager.write() = Some(manager);
202 }
203
204 pub fn set_mount_manager(&self, manager: Arc<MountManager>) {
206 *self.mount_manager.write() = Some(manager);
207 }
208
209 pub fn mount_manager(&self) -> Option<Arc<MountManager>> {
211 self.mount_manager.read().as_ref().cloned()
212 }
213
214 pub fn project_manager(&self) -> Option<Arc<ProjectManager>> {
216 self.project_manager.read().as_ref().cloned()
217 }
218
219 pub fn detect_project_tag(&self, message: &str) -> Option<String> {
221 self.project_manager.read().as_ref().and_then(|pm| {
222 let projects = pm.list_projects();
223 let result = crate::project::detect_project(message, &projects);
224 match result {
225 crate::project::DetectionResult::Found(id) => pm.get_project(id).map(|p| p.tag()),
226 crate::project::DetectionResult::NoMatch { .. } => None,
227 }
228 })
229 }
230
231 fn resolve_mount_workspace(
245 &self,
246 mount_ids: Option<&str>,
247 project_ids: Option<&str>,
248 user_message: &str,
249 ) -> (
250 Vec<MountId>,
251 Option<String>,
252 Vec<std::path::PathBuf>,
253 String,
254 ) {
255 use crate::mount::Mount;
256
257 let Some(mm) = self.mount_manager() else {
258 return (Vec::new(), None, Vec::new(), String::new());
259 };
260
261 let mut ids: Vec<MountId> = if let Some(ids_str) = mount_ids {
263 ids_str
264 .split(',')
265 .filter_map(|s| MountId::parse_str(s.trim()).ok())
266 .collect()
267 } else {
268 match mm.detect(user_message) {
269 crate::mount::DetectionResult::Found(id) => vec![id],
270 crate::mount::DetectionResult::NoMatch { .. } => vec![],
271 }
272 };
273 let mut seen = std::collections::HashSet::new();
275 ids.retain(|id| seen.insert(*id));
276
277 let project_for_instructions: Option<crate::project::Project> = if let Some(project_ids_str) =
284 project_ids
285 && let Some(first_id_str) = project_ids_str.split(',').next()
286 && let Some(pm) = self.project_manager()
287 && let Ok(pid) = Uuid::parse_str(first_id_str.trim())
288 {
289 let proj = pm.get_project(pid);
290 if let Some(ref project) = proj {
291 for mid in &project.mount_ids {
292 if !ids.contains(mid) {
293 ids.push(*mid);
294 }
295 }
296 }
297 proj
298 } else {
299 None
300 };
301
302 if ids.is_empty() {
303 return (Vec::new(), None, Vec::new(), String::new());
304 }
305
306 for id in &ids {
309 mm.touch(*id);
310 }
311
312 let mounts: Vec<Mount> = mm.get_mounts_ordered(&ids);
313 if mounts.is_empty() {
314 return (Vec::new(), None, Vec::new(), String::new());
315 }
316
317 let mut paths: Vec<std::path::PathBuf> = Vec::new();
319 for m in &mounts {
320 for p in &m.paths {
321 if !paths.contains(p) {
322 paths.push(p.clone());
323 }
324 }
325 }
326
327 if let Some(project) = &project_for_instructions
332 && project.mount_ids.is_empty()
333 && !project.paths.is_empty()
334 {
335 for p in &project.paths {
336 if !paths.contains(p) {
337 paths.push(p.clone());
338 }
339 }
340 }
341
342 let tag = if mounts.len() == 1 {
344 mounts[0].tag()
345 } else {
346 let names: Vec<&str> = mounts.iter().map(|m| m.name.as_str()).collect();
347 format!("[🔧 {}]", names.join(" + "))
348 };
349
350 let mut context = build_workspace_context_body(&mounts).unwrap_or_default();
351
352 if let Some(project) = project_for_instructions {
358 let instructions = if project.instructions.len() > 2000 {
360 let mut end = 2000;
361 while end > 0 && !project.instructions.is_char_boundary(end) {
362 end -= 1;
363 }
364 format!("{}...", &project.instructions[..end])
365 } else {
366 project.instructions.clone()
367 };
368 if !instructions.is_empty() {
369 context.push_str(&format!(
370 "\n### Project Instructions: {}\n{}\n",
371 project.name, instructions
372 ));
373 }
374 }
375
376 const MAX_CONTEXT_CHARS: usize = 6000;
378 if context.len() > MAX_CONTEXT_CHARS {
379 let mut end = MAX_CONTEXT_CHARS;
380 while end > 0 && !context.is_char_boundary(end) {
381 end -= 1;
382 }
383 context.truncate(end);
384 context.push_str("\n...(context truncated)...\n");
385 }
386
387 let context_opt = if context.is_empty() {
388 None
389 } else {
390 Some(context)
391 };
392 (ids, context_opt, paths, tag)
393 }
394
395 pub fn set_a2a(&mut self, a2a: Arc<crate::a2a::A2AProtocol>) {
397 self.a2a = Some(a2a);
398 }
399
400 pub fn set_git_layer(&mut self, git_layer: Arc<GitLayer>) {
402 self.git_layer = Some(git_layer);
403 }
404
405 pub async fn restore_sessions(&self) {
411 }
413
414 #[allow(dead_code)]
415 fn git_commit(&self, rel_path: &str, message: &str) {
416 if let Some(ref gl) = self.git_layer
417 && gl.is_enabled()
418 {
419 let _ = gl.commit_file(rel_path, message);
420 }
421 }
422
423 pub async fn handle(
463 &self,
464 engine: &dyn oxios_ouroboros::IntentEngineOps,
465 msg: &str,
466 ctx: &oxios_ouroboros::MsgCtx,
467 ) -> Result<HandleResponse> {
468 let assessment = engine.assess(msg, ctx).await?;
470
471 match assessment {
472 oxios_ouroboros::Assessment::Conversation(reply) => Ok(HandleResponse::Reply(reply)),
473
474 oxios_ouroboros::Assessment::Clarify { questions } => {
475 Ok(HandleResponse::Clarify(questions))
476 }
477
478 oxios_ouroboros::Assessment::Task(scope) => {
479 let mut directive = match scope {
481 oxios_ouroboros::Scope::Trivial => {
482 oxios_ouroboros::Directive::from_message(msg)
483 }
484 oxios_ouroboros::Scope::Substantial => engine.crystallize(msg, ctx).await?,
485 };
486
487 let env = self.resolve_exec_env(ctx, msg);
489
490 let mut result = self.execute_directive(&directive, &env).await?;
493
494 let (verdict, evaluation_passed) = match scope {
496 oxios_ouroboros::Scope::Trivial => (None, None),
497 oxios_ouroboros::Scope::Substantial => {
498 let (r, v) = self
499 .verify_or_retry(engine, &mut directive, &env, result, msg, ctx)
500 .await?;
501 result = r;
502 let passed = v.all_passed();
503 (Some(v), Some(passed))
504 }
505 };
506
507 self.lifecycle.reap_zombies();
508
509 Ok(HandleResponse::Task {
510 scope,
511 directive: Box::new(directive),
512 env: Box::new(env),
513 result: Box::new(result),
514 verdict,
515 evaluation_passed,
516 })
517 }
518 }
519 }
520
521 pub async fn handle_unified(
528 &self,
529 user_id: &str,
530 msg: &str,
531 session_id: Option<&str>,
532 project_ids: Option<&str>,
533 mount_ids: Option<&str>,
534 request_id: &str,
535 ) -> Result<OrchestrationResult> {
536 let engine = self
538 .intent_engine
539 .read()
540 .clone()
541 .expect("IntentEngine not wired — kernel assembler bug");
542
543 let sid = session_id.unwrap_or(request_id).to_string();
545 let history = self.load_session_history(&sid).await;
546 let ctx = oxios_ouroboros::MsgCtx {
547 session_id: sid.clone(),
548 history,
549 project_ids: project_ids.map(String::from),
550 mount_ids: mount_ids.map(String::from),
551 user_id: user_id.to_string(),
552 };
553
554 let start = std::time::Instant::now();
556 let response = self.handle(engine.as_ref(), msg, &ctx).await?;
557 let duration_ms = start.elapsed().as_millis() as u64;
558
559 Ok(self.handle_response_to_orchestration_result(response, &ctx, duration_ms))
560 }
561
562 async fn load_session_history(&self, session_id: &str) -> Vec<oxios_ouroboros::Exchange> {
564 let sid = crate::state_store::SessionId(session_id.to_string());
565 match self.state_store.load_session(&sid).await {
566 Ok(Some(session)) => session
567 .user_messages
568 .iter()
569 .zip(session.agent_responses.iter())
570 .map(|(u, a)| oxios_ouroboros::Exchange {
571 user: u.content.clone(),
572 agent: a.content.clone(),
573 })
574 .collect(),
575 _ => Vec::new(),
576 }
577 }
578
579 fn handle_response_to_orchestration_result(
580 &self,
581 response: HandleResponse,
582 ctx: &oxios_ouroboros::MsgCtx,
583 duration_ms: u64,
584 ) -> OrchestrationResult {
585 let metrics = get_metrics();
586 metrics.orch_duration.observe(duration_ms as f64 / 1000.0);
587
588 match response {
589 HandleResponse::Reply(reply) => OrchestrationResult {
590 session_id: Some(ctx.session_id.clone()),
591 primary_project_id: None,
592 project_tag: None,
593 active_mount_ids: Vec::new(),
594 mount_tag: None,
595 response: reply,
596 agent_id: None,
597 phase_reached: "interview".to_string(),
598 evaluation_passed: None,
599 output: None,
600 tool_calls: Vec::new(),
601 interview_questions: None,
602 interview_round: None,
603 },
604 HandleResponse::Clarify(questions) => {
605 let questions_text = questions
606 .iter()
607 .map(|q| q.text.clone())
608 .collect::<Vec<_>>()
609 .join("\n");
610 let structured = Some(
611 questions
612 .iter()
613 .map(|q| oxios_ouroboros::InterviewQuestionOutput {
614 id: q.id.clone(),
615 text: q.text.clone(),
616 kind: format!("{:?}", q.kind).to_lowercase(),
617 options: q
618 .options
619 .iter()
620 .map(|o| oxios_ouroboros::InterviewOptionOutput {
621 value: o.value.clone(),
622 label: o.label.clone(),
623 description: String::new(),
624 })
625 .collect(),
626 })
627 .collect(),
628 );
629 OrchestrationResult {
630 session_id: Some(ctx.session_id.clone()),
631 primary_project_id: None,
632 project_tag: None,
633 active_mount_ids: Vec::new(),
634 mount_tag: None,
635 response: questions_text,
636 agent_id: None,
637 phase_reached: "interview".to_string(),
638 evaluation_passed: None,
639 output: None,
640 tool_calls: Vec::new(),
641 interview_questions: structured,
642 interview_round: Some(((ctx.history.len() / 2) as u32).max(1)),
643 }
644 }
645 HandleResponse::Task {
646 scope: _,
647 directive,
648 env,
649 result,
650 verdict,
651 evaluation_passed,
652 } => {
653 let response_text = if directive.acceptance_criteria.is_empty() {
654 result.output.clone()
655 } else {
656 match &verdict {
657 Some(v) if v.all_passed() => result.output.clone(),
658 Some(v) => format!(
659 "{}\n\n⚠ Review notes:\n{}",
660 result.output,
661 v.notes.join("\n")
662 ),
663 None => result.output.clone(),
664 }
665 };
666 if evaluation_passed.unwrap_or(false) {
667 metrics.agents_completed.inc();
668 } else {
669 metrics.agents_failed.inc();
670 }
671 OrchestrationResult {
672 session_id: Some(ctx.session_id.clone()),
673 primary_project_id: env.project_id,
674 project_tag: None,
675 active_mount_ids: Vec::new(),
676 mount_tag: None,
677 response: response_text,
678 agent_id: None,
679 phase_reached: "execute".to_string(),
680 evaluation_passed,
681 output: Some(result.output.clone()),
682 tool_calls: result.tool_calls.clone(),
683 interview_questions: None,
684 interview_round: None,
685 }
686 }
687 }
688 }
689
690 fn resolve_exec_env(
697 &self,
698 ctx: &oxios_ouroboros::MsgCtx,
699 msg: &str,
700 ) -> oxios_ouroboros::ExecEnv {
701 let (active_mount_ids, workspace_context, mount_paths, _mount_tag) =
702 self.resolve_mount_workspace(ctx.mount_ids.as_deref(), ctx.project_ids.as_deref(), msg);
703 let _ = active_mount_ids;
707
708 let project_id = ctx
711 .project_ids
712 .as_deref()
713 .and_then(|ids| {
714 ids.split(',')
715 .next()
716 .and_then(|s| Uuid::parse_str(s.trim()).ok())
717 })
718 .or_else(|| {
719 self.detect_project_tag(msg).and_then(|_tag| {
720 self.project_manager().and_then(|pm| {
721 let projects = pm.list_projects();
722 match crate::project::detect_project(msg, &projects) {
723 crate::project::DetectionResult::Found(id) => Some(id),
724 crate::project::DetectionResult::NoMatch { .. } => None,
725 }
726 })
727 })
728 });
729
730 if let Some(pid) = project_id
732 && let Some(pm) = self.project_manager()
733 {
734 pm.touch(pid);
735 }
736
737 oxios_ouroboros::ExecEnv {
738 workspace_context,
739 mount_paths,
740 project_id,
741 cspace_hint: None,
742 model_override: None,
743 restore_state: None,
744 }
745 }
746
747 async fn execute_directive(
757 &self,
758 directive: &oxios_ouroboros::Directive,
759 env: &oxios_ouroboros::ExecEnv,
760 ) -> Result<ExecutionResult> {
761 let coordinator = self.recovery.read().as_ref().cloned();
769 if let Some(coordinator) = coordinator {
770 coordinator.execute(&self.lifecycle, directive, env).await
771 } else {
772 self.lifecycle
773 .execute_directive(directive, env, Priority::Normal)
774 .await
775 }
776 }
777
778 async fn verify_or_retry(
785 &self,
786 engine: &dyn oxios_ouroboros::IntentEngineOps,
787 directive: &mut oxios_ouroboros::Directive,
788 env: &oxios_ouroboros::ExecEnv,
789 initial_result: ExecutionResult,
790 _msg: &str,
791 _ctx: &oxios_ouroboros::MsgCtx,
792 ) -> Result<(ExecutionResult, oxios_ouroboros::Verdict)> {
793 let verdict = engine.review(directive, &initial_result).await?;
794
795 if verdict.all_passed() || verdict.gaps.is_empty() {
796 return Ok((initial_result, verdict));
797 }
798
799 let enable_retry = self.intent_config.read().enable_retry;
802 if !enable_retry {
803 tracing::info!("Review failed but retry disabled (enable_retry=false)");
804 return Ok((initial_result, verdict));
805 }
806
807 let metrics = get_metrics();
808 metrics.retry_attempted.inc();
809
810 tracing::info!(
811 gaps = verdict.gaps.len(),
812 "Review failed — retrying with feedback"
813 );
814
815 let retry_result = self
817 .lifecycle
818 .execute_with_feedback(
819 directive,
820 env,
821 &initial_result,
822 &verdict.gaps,
823 Priority::Normal,
824 )
825 .await?;
826
827 let retry_verdict = engine.review(directive, &retry_result).await?;
829
830 if retry_verdict.score > verdict.score {
832 metrics.retry_improved.inc();
833 } else if retry_verdict.score < verdict.score {
834 metrics.retry_degraded.inc();
835 } else {
836 metrics.retry_unchanged.inc();
837 }
838
839 let chosen_result = if retry_verdict.score >= verdict.score {
841 retry_result
842 } else {
843 initial_result
844 };
845
846 Ok((chosen_result, retry_verdict))
847 }
848}
849
850#[derive(Debug, Clone)]
862pub enum HandleResponse {
863 Reply(String),
865 Clarify(Vec<oxios_ouroboros::Question>),
867 Task {
869 scope: oxios_ouroboros::Scope,
871 directive: Box<oxios_ouroboros::Directive>,
873 env: Box<oxios_ouroboros::ExecEnv>,
875 result: Box<ExecutionResult>,
877 verdict: Option<oxios_ouroboros::Verdict>,
879 evaluation_passed: Option<bool>,
881 },
882}
883
884#[derive(Debug, Clone, Serialize, Deserialize)]
886pub struct OrchestrationResult {
887 #[serde(skip_serializing_if = "Option::is_none")]
889 pub session_id: Option<String>,
890 #[serde(skip_serializing_if = "Option::is_none")]
892 pub primary_project_id: Option<Uuid>,
893 #[serde(skip_serializing_if = "Option::is_none")]
895 pub project_tag: Option<String>,
896 #[serde(default, skip_serializing_if = "Vec::is_empty")]
898 pub active_mount_ids: Vec<MountId>,
899 #[serde(skip_serializing_if = "Option::is_none")]
901 pub mount_tag: Option<String>,
902 pub response: String,
904 #[serde(skip_serializing_if = "Option::is_none")]
906 pub agent_id: Option<AgentId>,
907 pub phase_reached: String,
909 pub evaluation_passed: Option<bool>,
915 #[serde(skip_serializing_if = "Option::is_none")]
917 pub output: Option<String>,
918 #[serde(default, skip_serializing_if = "Vec::is_empty")]
920 pub tool_calls: Vec<oxios_ouroboros::ToolCallRecord>,
921 #[serde(default, skip_serializing_if = "Option::is_none")]
929 pub interview_questions: Option<Vec<oxios_ouroboros::InterviewQuestionOutput>>,
930 #[serde(default, skip_serializing_if = "Option::is_none")]
933 pub interview_round: Option<u32>,
934}
935
936fn build_workspace_context_body(mounts: &[crate::mount::Mount]) -> Option<String> {
945 if mounts.is_empty() {
946 return None;
947 }
948 let mut out = String::new();
949 out.push_str("### Active Mounts\n");
950
951 for (i, m) in mounts.iter().enumerate() {
952 let primary = i == 0;
953 let path = m
954 .primary_path()
955 .map(|p| p.to_string_lossy().to_string())
956 .unwrap_or_else(|| "(no path)".to_string());
957
958 if primary {
959 out.push_str(&format!("- **{}** → {}\n", m.name, path));
960 if !m.auto_description.is_empty() {
961 let desc: String = m
963 .auto_description
964 .lines()
965 .take(3)
966 .collect::<Vec<_>>()
967 .join("\n ");
968 out.push_str(&format!(" {}\n", desc));
969 }
970 let summary = m.summary_line();
971 if !summary.is_empty() {
972 out.push_str(&format!(" _{}_\n", summary));
973 }
974 if m.enrichment_pending {
975 out.push_str(" _(content changed — consider re-scanning this Mount)_\n");
976 }
977 } else {
978 let summary = m.summary_line();
980 let suffix = if summary.is_empty() {
981 String::new()
982 } else {
983 format!(" — {}", summary)
984 };
985 out.push_str(&format!("- **{}** → {}{}\n", m.name, path, suffix));
986 }
987 }
988
989 Some(out)
990}
991
992#[cfg(test)]
993mod mount_workspace_tests {
994 use super::*;
995 use crate::mount::{Mount, MountSource};
996 use std::path::PathBuf;
997
998 #[test]
999 fn test_workspace_context_primary_full_secondary_terse() {
1000 let mut oxios =
1001 Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
1002 oxios.auto_description = "Agent OS.\nRust + tokio.".to_string();
1003 oxios.auto_meta.summary = "Rust agent OS".to_string();
1004
1005 let mut oxi = Mount::from_name_and_path("oxi", PathBuf::from("/oxi"));
1006 oxi.auto_meta.summary = "SDK".to_string();
1007
1008 let body = build_workspace_context_body(&[oxios, oxi]).unwrap();
1009 assert!(body.contains("### Active Mounts"));
1010 assert!(body.contains("Agent OS."));
1012 assert!(body.contains("_Rust agent OS_"));
1013 assert!(body.contains("**oxi** → /oxi — SDK"));
1015 }
1016
1017 #[test]
1018 fn test_workspace_context_empty_is_none() {
1019 assert!(build_workspace_context_body(&[]).is_none());
1020 }
1021
1022 #[test]
1026 fn test_resolve_mount_workspace_detects_and_collects_paths() {
1027 use crate::mount::MountManager;
1028 use oxios_memory::memory::sqlite::MemoryDatabase;
1029 use std::sync::Arc;
1030
1031 let db = Arc::new(MemoryDatabase::open_in_memory(64).unwrap());
1032 let mm = Arc::new(MountManager::new(db, None).unwrap());
1033
1034 let oxios = mm
1036 .create_mount(
1037 "oxios".to_string(),
1038 vec![PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios")],
1039 MountSource::Manual,
1040 )
1041 .unwrap();
1042 let oxi_sdk = mm
1043 .create_mount(
1044 "oxi-sdk".to_string(),
1045 vec![PathBuf::from("/Users/me/oxi")],
1046 MountSource::Manual,
1047 )
1048 .unwrap();
1049 mm.update_enrichment(oxios.id, Some("Agent OS in Rust.".to_string()), None)
1050 .unwrap();
1051
1052 let mounts = mm.get_mounts_ordered(&[oxios.id, oxi_sdk.id]);
1056 assert_eq!(mounts.len(), 2);
1057
1058 let body = build_workspace_context_body(&mounts).unwrap();
1059 assert!(body.contains("oxios"));
1060 assert!(body.contains("Agent OS in Rust."));
1061 assert!(body.contains("oxi-sdk"));
1062
1063 let mut paths = Vec::new();
1065 for m in &mounts {
1066 for p in &m.paths {
1067 if !paths.contains(p) {
1068 paths.push(p.clone());
1069 }
1070 }
1071 }
1072 assert_eq!(paths.len(), 2);
1073 assert_eq!(paths[0], PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
1074 assert_eq!(paths[1], PathBuf::from("/Users/me/oxi"));
1075 }
1076
1077 #[test]
1080 fn test_detection_seeds_primary_on_name_mention() {
1081 use crate::mount::{DetectionResult, detect_mounts};
1082
1083 let oxios =
1084 Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
1085 let result = detect_mounts("oxios 코드리뷰해줘", std::slice::from_ref(&oxios));
1086 assert!(matches!(result, DetectionResult::Found(id) if id == oxios.id));
1087 }
1088}