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::state_store::StateStore;
25use crate::types::AgentId;
26
27#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
29pub enum AgentRole {
30 #[default]
32 Worker,
33 Manager,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SubTask {
40 pub id: Uuid,
42 pub description: String,
44 pub required_capability: Option<String>,
46 pub result: Option<String>,
48 pub success: bool,
50 #[serde(default)]
52 pub role: AgentRole,
53}
54
55impl SubTask {
56 pub fn new(description: impl Into<String>) -> Self {
58 Self {
59 id: Uuid::new_v4(),
60 description: description.into(),
61 required_capability: None,
62 result: None,
63 success: false,
64 role: AgentRole::default(),
65 }
66 }
67
68 pub fn with_capability(mut self, cap: impl Into<String>) -> Self {
70 self.required_capability = Some(cap.into());
71 self
72 }
73}
74
75#[allow(dead_code)]
77pub struct Orchestrator {
78 intent_engine: RwLock<Option<Arc<dyn oxios_ouroboros::IntentEngineOps>>>,
81 event_bus: EventBus,
82 state_store: Arc<StateStore>,
83 git_layer: Option<Arc<GitLayer>>,
85 lifecycle: AgentLifecycleManager,
87 a2a: Option<Arc<crate::a2a::A2AProtocol>>,
89 project_manager: RwLock<Option<Arc<ProjectManager>>>,
91 mount_manager: RwLock<Option<Arc<MountManager>>>,
93 conversation_buffer: RwLock<ConversationBuffer>,
95 delegation_config: DelegationConfig,
97 a2a_breaker: Arc<crate::a2a::circuit_breaker::A2ACircuitBreaker>,
99 intent_config: RwLock<crate::config::IntentConfig>,
101 recovery: RwLock<Option<Arc<crate::resilience::RecoveryCoordinator>>>,
105}
106
107#[allow(dead_code)]
109struct DelegationConfig {
110 max_retries: u32,
112 base_delay_ms: u64,
114 max_delay_ms: u64,
116 #[allow(dead_code)]
118 timeout_ms: u64,
119}
120
121impl Default for DelegationConfig {
122 fn default() -> Self {
123 Self {
124 max_retries: 3,
125 base_delay_ms: 100,
126 max_delay_ms: 5000,
127 timeout_ms: 5000,
128 }
129 }
130}
131
132#[allow(dead_code)]
133impl DelegationConfig {
134 fn backoff_delay(&self, attempt: u32) -> u64 {
136 let delay = self.base_delay_ms * 2_u64.saturating_pow(attempt.min(10));
137 delay.min(self.max_delay_ms)
138 }
139}
140
141impl Orchestrator {
142 pub fn new(
144 event_bus: EventBus,
145 state_store: Arc<StateStore>,
146 lifecycle: AgentLifecycleManager,
147 ) -> Self {
148 Self::with_config(
149 event_bus,
150 state_store,
151 lifecycle,
152 crate::config::OrchestratorConfig::default(),
153 )
154 }
155
156 pub fn with_config(
158 event_bus: EventBus,
159 state_store: Arc<StateStore>,
160 lifecycle: AgentLifecycleManager,
161 _config: crate::config::OrchestratorConfig,
162 ) -> Self {
163 Self {
164 intent_engine: RwLock::new(None),
165 event_bus,
166 state_store,
167 git_layer: None,
168 lifecycle,
169 a2a: None,
170 project_manager: RwLock::new(None),
171 mount_manager: RwLock::new(None),
172 conversation_buffer: RwLock::new(ConversationBuffer::default()),
173 delegation_config: DelegationConfig::default(),
174 intent_config: RwLock::new(crate::config::IntentConfig::default()),
175 a2a_breaker: Arc::new(crate::a2a::circuit_breaker::A2ACircuitBreaker::new(5, 30)),
176 recovery: RwLock::new(None),
177 }
178 }
179
180 pub fn set_intent_engine(&self, engine: Arc<dyn oxios_ouroboros::IntentEngineOps>) {
183 *self.intent_engine.write() = Some(engine);
184 }
185
186 pub fn set_intent_config(&self, cfg: crate::config::IntentConfig) {
190 *self.intent_config.write() = cfg;
191 }
192
193 pub fn set_recovery(&self, coordinator: Arc<crate::resilience::RecoveryCoordinator>) {
197 *self.recovery.write() = Some(coordinator);
198 }
199
200 pub fn has_intent_engine(&self) -> bool {
202 self.intent_engine.read().is_some()
203 }
204
205 pub fn set_project_manager(&self, manager: Arc<ProjectManager>) {
207 *self.project_manager.write() = Some(manager);
208 }
209
210 pub fn set_mount_manager(&self, manager: Arc<MountManager>) {
212 *self.mount_manager.write() = Some(manager);
213 }
214
215 pub fn mount_manager(&self) -> Option<Arc<MountManager>> {
217 self.mount_manager.read().as_ref().cloned()
218 }
219
220 pub fn project_manager(&self) -> Option<Arc<ProjectManager>> {
222 self.project_manager.read().as_ref().cloned()
223 }
224
225 pub fn detect_project_tag(&self, message: &str) -> Option<String> {
227 self.project_manager.read().as_ref().and_then(|pm| {
228 let projects = pm.list_projects();
229 let result = crate::project::detect_project(message, &projects);
230 match result {
231 crate::project::DetectionResult::Found(id) => pm.get_project(id).map(|p| p.tag()),
232 crate::project::DetectionResult::NoMatch { .. } => None,
233 }
234 })
235 }
236
237 fn resolve_mount_workspace(
251 &self,
252 mount_ids: Option<&str>,
253 project_ids: Option<&str>,
254 user_message: &str,
255 ) -> (
256 Vec<MountId>,
257 Option<String>,
258 Vec<std::path::PathBuf>,
259 String,
260 ) {
261 use crate::mount::Mount;
262
263 let Some(mm) = self.mount_manager() else {
264 return (Vec::new(), None, Vec::new(), String::new());
265 };
266
267 let mut ids: Vec<MountId> = if let Some(ids_str) = mount_ids {
269 ids_str
270 .split(',')
271 .filter_map(|s| MountId::parse_str(s.trim()).ok())
272 .collect()
273 } else {
274 match mm.detect(user_message) {
275 crate::mount::DetectionResult::Found(id) => vec![id],
276 crate::mount::DetectionResult::NoMatch { .. } => vec![],
277 }
278 };
279 let mut seen = std::collections::HashSet::new();
281 ids.retain(|id| seen.insert(*id));
282
283 let project_for_instructions: Option<crate::project::Project> = if let Some(project_ids_str) =
290 project_ids
291 && let Some(first_id_str) = project_ids_str.split(',').next()
292 && let Some(pm) = self.project_manager()
293 && let Ok(pid) = Uuid::parse_str(first_id_str.trim())
294 {
295 let proj = pm.get_project(pid);
296 if let Some(ref project) = proj {
297 for mid in &project.mount_ids {
298 if !ids.contains(mid) {
299 ids.push(*mid);
300 }
301 }
302 }
303 proj
304 } else {
305 None
306 };
307
308 if ids.is_empty() {
309 return (Vec::new(), None, Vec::new(), String::new());
310 }
311
312 for id in &ids {
315 mm.touch(*id);
316 }
317
318 let mounts: Vec<Mount> = mm.get_mounts_ordered(&ids);
319 if mounts.is_empty() {
320 return (Vec::new(), None, Vec::new(), String::new());
321 }
322
323 let mut paths: Vec<std::path::PathBuf> = Vec::new();
325 for m in &mounts {
326 for p in &m.paths {
327 if !paths.contains(p) {
328 paths.push(p.clone());
329 }
330 }
331 }
332
333 let tag = if mounts.len() == 1 {
335 mounts[0].tag()
336 } else {
337 let names: Vec<&str> = mounts.iter().map(|m| m.name.as_str()).collect();
338 format!("[🔧 {}]", names.join(" + "))
339 };
340
341 let mut context = build_workspace_context_body(&mounts).unwrap_or_default();
342
343 if let Some(project) = project_for_instructions {
349 let instructions = if project.instructions.len() > 2000 {
351 let mut end = 2000;
352 while end > 0 && !project.instructions.is_char_boundary(end) {
353 end -= 1;
354 }
355 format!("{}...", &project.instructions[..end])
356 } else {
357 project.instructions.clone()
358 };
359 if !instructions.is_empty() {
360 context.push_str(&format!(
361 "\n### Project Instructions: {}\n{}\n",
362 project.name, instructions
363 ));
364 }
365 }
366
367 const MAX_CONTEXT_CHARS: usize = 6000;
369 if context.len() > MAX_CONTEXT_CHARS {
370 let mut end = MAX_CONTEXT_CHARS;
371 while end > 0 && !context.is_char_boundary(end) {
372 end -= 1;
373 }
374 context.truncate(end);
375 context.push_str("\n...(context truncated)...\n");
376 }
377
378 let context_opt = if context.is_empty() {
379 None
380 } else {
381 Some(context)
382 };
383 (ids, context_opt, paths, tag)
384 }
385
386 pub fn set_a2a(&mut self, a2a: Arc<crate::a2a::A2AProtocol>) {
388 self.a2a = Some(a2a);
389 }
390
391 pub fn set_git_layer(&mut self, git_layer: Arc<GitLayer>) {
393 self.git_layer = Some(git_layer);
394 }
395
396 pub async fn restore_sessions(&self) {
402 }
404
405 #[allow(dead_code)]
406 fn git_commit(&self, rel_path: &str, message: &str) {
407 if let Some(ref gl) = self.git_layer
408 && gl.is_enabled()
409 {
410 let _ = gl.commit_file(rel_path, message);
411 }
412 }
413
414 pub async fn handle(
450 &self,
451 engine: &dyn oxios_ouroboros::IntentEngineOps,
452 msg: &str,
453 ctx: &oxios_ouroboros::MsgCtx,
454 ) -> Result<HandleResponse> {
455 let mut directive = oxios_ouroboros::Directive::from_message(msg);
457
458 let env = self.resolve_exec_env(ctx, msg);
460
461 let mut result = self.execute_directive(&directive, &env).await?;
463
464 let (verdict, evaluation_passed) = if directive.needs_review() {
469 let (r, v) = self
470 .verify_or_retry(engine, &mut directive, &env, result, msg, ctx)
471 .await?;
472 result = r;
473 let passed = v.all_passed();
474 (Some(v), Some(passed))
475 } else {
476 (None, None)
477 };
478
479 Ok(HandleResponse {
480 directive: Box::new(directive),
481 env: Box::new(env),
482 result: Box::new(result),
483 verdict,
484 evaluation_passed,
485 })
486 }
487
488 #[allow(clippy::too_many_arguments)]
495 pub async fn handle_unified(
496 &self,
497 user_id: &str,
498 msg: &str,
499 session_id: Option<&str>,
500 project_ids: Option<&str>,
501 mount_ids: Option<&str>,
502 role: Option<&str>,
503 model_override: Option<&str>,
504 model_params: Option<oxios_ouroboros::ModelParams>,
505 request_id: &str,
506 ) -> Result<OrchestrationResult> {
507 let engine = self
509 .intent_engine
510 .read()
511 .clone()
512 .expect("IntentEngine not wired — kernel assembler bug");
513
514 let sid = session_id.unwrap_or(request_id).to_string();
516 let history = self.load_session_history(&sid).await;
517 let ctx = oxios_ouroboros::MsgCtx {
518 session_id: sid.clone(),
519 history,
520 project_ids: project_ids.map(String::from),
521 mount_ids: mount_ids.map(String::from),
522 role: role.map(String::from),
523 model_override: model_override.map(String::from),
524 user_id: user_id.to_string(),
525 model_params,
526 };
527
528 let start = std::time::Instant::now();
530 let response = self.handle(engine.as_ref(), msg, &ctx).await?;
531 let duration_ms = start.elapsed().as_millis() as u64;
532
533 Ok(self.handle_response_to_orchestration_result(response, &ctx, duration_ms))
534 }
535
536 async fn load_session_history(&self, session_id: &str) -> Vec<oxios_ouroboros::Exchange> {
538 let sid = crate::state_store::SessionId(session_id.to_string());
539 match self.state_store.load_session(&sid).await {
540 Ok(Some(session)) => session
541 .user_messages
542 .iter()
543 .zip(session.agent_responses.iter())
544 .map(|(u, a)| oxios_ouroboros::Exchange {
545 user: u.content.clone(),
546 agent: a.content.clone(),
547 })
548 .collect(),
549 _ => Vec::new(),
550 }
551 }
552
553 fn handle_response_to_orchestration_result(
554 &self,
555 response: HandleResponse,
556 ctx: &oxios_ouroboros::MsgCtx,
557 duration_ms: u64,
558 ) -> OrchestrationResult {
559 let metrics = get_metrics();
560 metrics.orch_duration.observe(duration_ms as f64 / 1000.0);
561
562 let HandleResponse {
563 directive,
564 env,
565 result,
566 verdict,
567 evaluation_passed,
568 } = response;
569
570 let failure_class: Option<oxios_ouroboros::FailureClass> = result.failure_class;
574 let response_text = if !result.success && result.output.trim().is_empty() {
575 failure_class_to_user_message(failure_class.as_ref())
576 } else if directive.acceptance_criteria.is_empty() {
577 result.output.clone()
578 } else {
579 match &verdict {
580 Some(v) if v.all_passed() => result.output.clone(),
581 Some(v) => format!(
582 "{}\n\n⚠ Review notes:\n{}",
583 result.output,
584 v.notes.join("\n")
585 ),
586 None => result.output.clone(),
587 }
588 };
589 if evaluation_passed.unwrap_or(false) {
590 metrics.agents_completed.inc();
591 } else {
592 metrics.agents_failed.inc();
593 }
594 OrchestrationResult {
595 session_id: Some(ctx.session_id.clone()),
596 primary_project_id: env.project_id,
597 project_tag: None,
598 active_mount_ids: Vec::new(),
599 mount_tag: None,
600 response: response_text,
601 agent_id: None,
602 phase_reached: "execute".to_string(),
603 evaluation_passed,
604 output: Some(result.output.clone()),
605 tool_calls: result.tool_calls.clone(),
606 failure_class,
607 interview_questions: None,
608 interview_round: None,
609 reasoning_text: result.reasoning_text.clone(),
610 reasoning_segments: result.reasoning_segments.clone(),
611 }
612 }
613 fn resolve_exec_env(
619 &self,
620 ctx: &oxios_ouroboros::MsgCtx,
621 msg: &str,
622 ) -> oxios_ouroboros::ExecEnv {
623 let (active_mount_ids, workspace_context, mount_paths, _mount_tag) =
624 self.resolve_mount_workspace(ctx.mount_ids.as_deref(), ctx.project_ids.as_deref(), msg);
625 let _ = active_mount_ids;
629
630 let project_id = ctx
633 .project_ids
634 .as_deref()
635 .and_then(|ids| {
636 ids.split(',')
637 .next()
638 .and_then(|s| Uuid::parse_str(s.trim()).ok())
639 })
640 .or_else(|| {
641 self.detect_project_tag(msg).and_then(|_tag| {
642 self.project_manager().and_then(|pm| {
643 let projects = pm.list_projects();
644 match crate::project::detect_project(msg, &projects) {
645 crate::project::DetectionResult::Found(id) => Some(id),
646 crate::project::DetectionResult::NoMatch { .. } => None,
647 }
648 })
649 })
650 });
651
652 if let Some(pid) = project_id
654 && let Some(pm) = self.project_manager()
655 {
656 pm.touch(pid);
657 }
658
659 oxios_ouroboros::ExecEnv {
660 workspace_context,
661 mount_paths,
662 project_id,
663 cspace_hint: None,
664 model_override: ctx.model_override.clone(),
665 role: ctx.role.clone(),
666 restore_state: None,
667 session_id: Some(ctx.session_id.clone()),
668 model_params: ctx.model_params.clone(),
669 }
670 }
671
672 async fn execute_directive(
675 &self,
676 directive: &oxios_ouroboros::Directive,
677 env: &oxios_ouroboros::ExecEnv,
678 ) -> Result<ExecutionResult> {
679 let coordinator = self.recovery.read().as_ref().cloned();
687 if let Some(coordinator) = coordinator {
688 coordinator.execute(&self.lifecycle, directive, env).await
689 } else {
690 self.lifecycle.execute_directive(directive, env).await
691 }
692 }
693
694 async fn verify_or_retry(
706 &self,
707 engine: &dyn oxios_ouroboros::IntentEngineOps,
708 directive: &mut oxios_ouroboros::Directive,
709 env: &oxios_ouroboros::ExecEnv,
710 initial_result: ExecutionResult,
711 _msg: &str,
712 _ctx: &oxios_ouroboros::MsgCtx,
713 ) -> Result<(ExecutionResult, oxios_ouroboros::Verdict)> {
714 let verdict = engine.review(directive, &initial_result).await?;
715
716 if verdict.all_passed() || verdict.gaps.is_empty() {
717 return Ok((initial_result, verdict));
718 }
719
720 let enable_retry = self.intent_config.read().enable_retry;
723 if !enable_retry {
724 tracing::info!("Review failed but retry disabled (enable_retry=false)");
725 return Ok((initial_result, verdict));
726 }
727
728 let metrics = get_metrics();
729 metrics.retry_attempted.inc();
730
731 tracing::info!(
732 gaps = verdict.gaps.len(),
733 "Review failed — retrying with feedback"
734 );
735
736 let retry_result = self
738 .lifecycle
739 .execute_with_feedback(directive, env, &initial_result, &verdict.gaps)
740 .await?;
741
742 let retry_verdict = engine.review(directive, &retry_result).await?;
744
745 if retry_verdict.score > verdict.score {
747 metrics.retry_improved.inc();
748 } else if retry_verdict.score < verdict.score {
749 metrics.retry_degraded.inc();
750 } else {
751 metrics.retry_unchanged.inc();
752 }
753
754 let chosen_result = if retry_verdict.score >= verdict.score {
756 retry_result
757 } else {
758 initial_result
759 };
760
761 Ok((chosen_result, retry_verdict))
762 }
763}
764
765#[derive(Debug, Clone)]
773pub struct HandleResponse {
774 pub directive: Box<oxios_ouroboros::Directive>,
776 pub env: Box<oxios_ouroboros::ExecEnv>,
778 pub result: Box<ExecutionResult>,
780 pub verdict: Option<oxios_ouroboros::Verdict>,
782 pub evaluation_passed: Option<bool>,
784}
785
786#[derive(Debug, Clone, Serialize, Deserialize)]
788pub struct OrchestrationResult {
789 #[serde(skip_serializing_if = "Option::is_none")]
791 pub session_id: Option<String>,
792 #[serde(skip_serializing_if = "Option::is_none")]
794 pub primary_project_id: Option<Uuid>,
795 #[serde(skip_serializing_if = "Option::is_none")]
797 pub project_tag: Option<String>,
798 #[serde(default, skip_serializing_if = "Vec::is_empty")]
800 pub active_mount_ids: Vec<MountId>,
801 #[serde(skip_serializing_if = "Option::is_none")]
803 pub mount_tag: Option<String>,
804 pub response: String,
806 #[serde(skip_serializing_if = "Option::is_none")]
808 pub agent_id: Option<AgentId>,
809 pub phase_reached: String,
811 pub evaluation_passed: Option<bool>,
817 #[serde(skip_serializing_if = "Option::is_none")]
819 pub output: Option<String>,
820 #[serde(default, skip_serializing_if = "Vec::is_empty")]
822 pub tool_calls: Vec<oxios_ouroboros::ToolCallRecord>,
823 #[serde(default, skip_serializing_if = "Option::is_none")]
831 pub interview_questions: Option<Vec<oxios_ouroboros::InterviewQuestionOutput>>,
832 #[serde(default, skip_serializing_if = "Option::is_none")]
835 pub interview_round: Option<u32>,
836
837 #[serde(default, skip_serializing_if = "String::is_empty")]
842 pub reasoning_text: String,
843 #[serde(default, skip_serializing_if = "Vec::is_empty")]
846 pub reasoning_segments: Vec<oxios_ouroboros::ReasoningSegment>,
847 #[serde(default, skip_serializing_if = "Option::is_none")]
851 pub failure_class: Option<oxios_ouroboros::FailureClass>,
852}
853fn failure_class_to_user_message(class: Option<&oxios_ouroboros::FailureClass>) -> String {
856 use oxios_ouroboros::FailureClass;
857 match class {
858 Some(FailureClass::BudgetExceeded) => {
859 "\u{26a0}\u{fe0f} Token budget exceeded for this provider. \
860 Try selecting a different model or configuring additional providers \
861 in Settings \u{2192} Engine."
862 .to_string()
863 }
864 Some(FailureClass::QuotaExhausted) => "\u{26a0}\u{fe0f} Provider quota exhausted. \
865 The selected provider has reached its rate or usage limit. \
866 Wait a moment and retry, or switch to a different model."
867 .to_string(),
868 Some(FailureClass::AuthFailure) => "\u{26a0}\u{fe0f} Authentication failed. \
869 Your API key for this provider may be invalid or expired. \
870 Check your credentials in Settings \u{2192} Engine."
871 .to_string(),
872 Some(FailureClass::ModelUnavailable) => "\u{26a0}\u{fe0f} Model unavailable. \
873 The selected model is no longer available or was not found. \
874 Choose a different model in Settings \u{2192} Engine."
875 .to_string(),
876 Some(FailureClass::ContextOverflow) => "\u{26a0}\u{fe0f} Context window exceeded. \
877 The conversation is too long for this model's context limit. \
878 Start a new session or switch to a model with a larger context window."
879 .to_string(),
880 Some(FailureClass::Transient) => {
881 "\u{26a0}\u{fe0f} A temporary error occurred while contacting the provider. \
882 The system will retry automatically. If the issue persists, \
883 try a different model or check your network connection."
884 .to_string()
885 }
886 Some(FailureClass::Unknown) | None => {
887 "\u{26a0}\u{fe0f} An unexpected error occurred during execution. \
888 Please try again. If the problem persists, check your provider \
889 configuration in Settings \u{2192} Engine."
890 .to_string()
891 }
892 }
893}
894
895fn build_workspace_context_body(mounts: &[crate::mount::Mount]) -> Option<String> {
904 if mounts.is_empty() {
905 return None;
906 }
907 let mut out = String::new();
908 out.push_str("### Active Mounts\n");
909
910 for (i, m) in mounts.iter().enumerate() {
911 let primary = i == 0;
912 let path = m
913 .primary_path()
914 .map(|p| p.to_string_lossy().to_string())
915 .unwrap_or_else(|| "(no path)".to_string());
916
917 if primary {
918 out.push_str(&format!("- **{}** → {}\n", m.name, path));
919 if !m.auto_description.is_empty() {
920 let desc: String = m
922 .auto_description
923 .lines()
924 .take(3)
925 .collect::<Vec<_>>()
926 .join("\n ");
927 out.push_str(&format!(" {}\n", desc));
928 }
929 let summary = m.summary_line();
930 if !summary.is_empty() {
931 out.push_str(&format!(" _{}_\n", summary));
932 }
933 if m.enrichment_pending {
934 out.push_str(" _(content changed — consider re-scanning this Mount)_\n");
935 }
936 } else {
937 let summary = m.summary_line();
939 let suffix = if summary.is_empty() {
940 String::new()
941 } else {
942 format!(" — {}", summary)
943 };
944 out.push_str(&format!("- **{}** → {}{}\n", m.name, path, suffix));
945 }
946 }
947
948 Some(out)
949}
950
951#[cfg(test)]
952mod mount_workspace_tests {
953 use super::*;
954 use crate::mount::{Mount, MountSource};
955 use std::path::PathBuf;
956
957 #[test]
958 fn test_workspace_context_primary_full_secondary_terse() {
959 let mut oxios =
960 Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
961 oxios.auto_description = "Agent OS.\nRust + tokio.".to_string();
962 oxios.auto_meta.summary = "Rust agent OS".to_string();
963
964 let mut oxi = Mount::from_name_and_path("oxi", PathBuf::from("/oxi"));
965 oxi.auto_meta.summary = "SDK".to_string();
966
967 let body = build_workspace_context_body(&[oxios, oxi]).unwrap();
968 assert!(body.contains("### Active Mounts"));
969 assert!(body.contains("Agent OS."));
971 assert!(body.contains("_Rust agent OS_"));
972 assert!(body.contains("**oxi** → /oxi — SDK"));
974 }
975
976 #[test]
977 fn test_workspace_context_empty_is_none() {
978 assert!(build_workspace_context_body(&[]).is_none());
979 }
980
981 #[test]
985 fn test_resolve_mount_workspace_detects_and_collects_paths() {
986 use crate::mount::MountManager;
987 use oxios_memory::memory::sqlite::MemoryDatabase;
988 use std::sync::Arc;
989
990 let db = Arc::new(MemoryDatabase::open_in_memory(64).unwrap());
991 let mm = Arc::new(MountManager::new(db, None).unwrap());
992
993 let oxios = mm
995 .create_mount(
996 "oxios".to_string(),
997 vec![PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios")],
998 MountSource::Manual,
999 )
1000 .unwrap();
1001 let oxi_sdk = mm
1002 .create_mount(
1003 "oxi-sdk".to_string(),
1004 vec![PathBuf::from("/Users/me/oxi")],
1005 MountSource::Manual,
1006 )
1007 .unwrap();
1008 mm.update_enrichment(oxios.id, Some("Agent OS in Rust.".to_string()), None)
1009 .unwrap();
1010
1011 let mounts = mm.get_mounts_ordered(&[oxios.id, oxi_sdk.id]);
1015 assert_eq!(mounts.len(), 2);
1016
1017 let body = build_workspace_context_body(&mounts).unwrap();
1018 assert!(body.contains("oxios"));
1019 assert!(body.contains("Agent OS in Rust."));
1020 assert!(body.contains("oxi-sdk"));
1021
1022 let mut paths = Vec::new();
1024 for m in &mounts {
1025 for p in &m.paths {
1026 if !paths.contains(p) {
1027 paths.push(p.clone());
1028 }
1029 }
1030 }
1031 assert_eq!(paths.len(), 2);
1032 assert_eq!(paths[0], PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
1033 assert_eq!(paths[1], PathBuf::from("/Users/me/oxi"));
1034 }
1035
1036 #[test]
1039 fn test_detection_seeds_primary_on_name_mention() {
1040 use crate::mount::{DetectionResult, detect_mounts};
1041
1042 let oxios =
1043 Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
1044 let result = detect_mounts("oxios 코드리뷰해줘", std::slice::from_ref(&oxios));
1045 assert!(matches!(result, DetectionResult::Found(id) if id == oxios.id));
1046 }
1047}