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, KernelEvent};
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_recovery(&self, coordinator: Arc<crate::resilience::RecoveryCoordinator>) {
190 *self.recovery.write() = Some(coordinator);
191 }
192
193 pub fn has_intent_engine(&self) -> bool {
195 self.intent_engine.read().is_some()
196 }
197
198 pub fn set_project_manager(&self, manager: Arc<ProjectManager>) {
200 *self.project_manager.write() = Some(manager);
201 }
202
203 pub fn set_mount_manager(&self, manager: Arc<MountManager>) {
205 *self.mount_manager.write() = Some(manager);
206 }
207
208 pub fn mount_manager(&self) -> Option<Arc<MountManager>> {
210 self.mount_manager.read().as_ref().cloned()
211 }
212
213 pub fn project_manager(&self) -> Option<Arc<ProjectManager>> {
215 self.project_manager.read().as_ref().cloned()
216 }
217
218 pub fn detect_project_tag(&self, message: &str) -> Option<String> {
220 self.project_manager.read().as_ref().and_then(|pm| {
221 let projects = pm.list_projects();
222 let result = crate::project::detect_project(message, &projects);
223 match result {
224 crate::project::DetectionResult::Found(id) => pm.get_project(id).map(|p| p.tag()),
225 crate::project::DetectionResult::NoMatch { .. } => None,
226 }
227 })
228 }
229
230 fn resolve_mount_workspace(
244 &self,
245 mount_ids: Option<&str>,
246 project_ids: Option<&str>,
247 user_message: &str,
248 ) -> (
249 Vec<MountId>,
250 Option<String>,
251 Vec<std::path::PathBuf>,
252 String,
253 ) {
254 use crate::mount::Mount;
255
256 let Some(mm) = self.mount_manager() else {
257 return (Vec::new(), None, Vec::new(), String::new());
258 };
259
260 let mut ids: Vec<MountId> = if let Some(ids_str) = mount_ids {
262 ids_str
263 .split(',')
264 .filter_map(|s| MountId::parse_str(s.trim()).ok())
265 .collect()
266 } else {
267 match mm.detect(user_message) {
268 crate::mount::DetectionResult::Found(id) => vec![id],
269 crate::mount::DetectionResult::NoMatch { .. } => vec![],
270 }
271 };
272 let mut seen = std::collections::HashSet::new();
274 ids.retain(|id| seen.insert(*id));
275
276 let project_for_instructions: Option<crate::project::Project> = if let Some(project_ids_str) =
283 project_ids
284 && let Some(first_id_str) = project_ids_str.split(',').next()
285 && let Some(pm) = self.project_manager()
286 && let Ok(pid) = Uuid::parse_str(first_id_str.trim())
287 {
288 let proj = pm.get_project(pid);
289 if let Some(ref project) = proj {
290 for mid in &project.mount_ids {
291 if !ids.contains(mid) {
292 ids.push(*mid);
293 }
294 }
295 }
296 proj
297 } else {
298 None
299 };
300
301 if ids.is_empty() {
302 return (Vec::new(), None, Vec::new(), String::new());
303 }
304
305 for id in &ids {
308 mm.touch(*id);
309 }
310
311 let mounts: Vec<Mount> = mm.get_mounts_ordered(&ids);
312 if mounts.is_empty() {
313 return (Vec::new(), None, Vec::new(), String::new());
314 }
315
316 let mut paths: Vec<std::path::PathBuf> = Vec::new();
318 for m in &mounts {
319 for p in &m.paths {
320 if !paths.contains(p) {
321 paths.push(p.clone());
322 }
323 }
324 }
325
326 if let Some(project) = &project_for_instructions
331 && project.mount_ids.is_empty()
332 && !project.paths.is_empty()
333 {
334 for p in &project.paths {
335 if !paths.contains(p) {
336 paths.push(p.clone());
337 }
338 }
339 }
340
341 let tag = if mounts.len() == 1 {
343 mounts[0].tag()
344 } else {
345 let names: Vec<&str> = mounts.iter().map(|m| m.name.as_str()).collect();
346 format!("[🔧 {}]", names.join(" + "))
347 };
348
349 let mut context = build_workspace_context_body(&mounts).unwrap_or_default();
350
351 if let Some(project) = project_for_instructions {
357 let instructions = if project.instructions.len() > 2000 {
359 let mut end = 2000;
360 while end > 0 && !project.instructions.is_char_boundary(end) {
361 end -= 1;
362 }
363 format!("{}...", &project.instructions[..end])
364 } else {
365 project.instructions.clone()
366 };
367 if !instructions.is_empty() {
368 context.push_str(&format!(
369 "\n### Project Instructions: {}\n{}\n",
370 project.name, instructions
371 ));
372 }
373 }
374
375 const MAX_CONTEXT_CHARS: usize = 6000;
377 if context.len() > MAX_CONTEXT_CHARS {
378 let mut end = MAX_CONTEXT_CHARS;
379 while end > 0 && !context.is_char_boundary(end) {
380 end -= 1;
381 }
382 context.truncate(end);
383 context.push_str("\n...(context truncated)...\n");
384 }
385
386 let context_opt = if context.is_empty() {
387 None
388 } else {
389 Some(context)
390 };
391 (ids, context_opt, paths, tag)
392 }
393
394 pub fn set_a2a(&mut self, a2a: Arc<crate::a2a::A2AProtocol>) {
396 self.a2a = Some(a2a);
397 }
398
399 pub fn set_git_layer(&mut self, git_layer: Arc<GitLayer>) {
401 self.git_layer = Some(git_layer);
402 }
403
404 pub async fn restore_sessions(&self) {
410 }
412
413 #[allow(dead_code)]
414 fn git_commit(&self, rel_path: &str, message: &str) {
415 if let Some(ref gl) = self.git_layer
416 && gl.is_enabled()
417 {
418 let _ = gl.commit_file(rel_path, message);
419 }
420 }
421
422 pub async fn handle(
462 &self,
463 engine: &dyn oxios_ouroboros::IntentEngineOps,
464 msg: &str,
465 ctx: &oxios_ouroboros::MsgCtx,
466 ) -> Result<HandleResponse> {
467 let _ = self.event_bus.publish(KernelEvent::PhaseStarted {
470 session_id: ctx.session_id.clone(),
471 phase: "assess".to_string(),
472 summary: None,
473 });
474 let assessment = engine.assess(msg, ctx).await?;
475
476 let _ = self.event_bus.publish(KernelEvent::PhaseCompleted {
477 session_id: ctx.session_id.clone(),
478 phase: "assess".to_string(),
479 });
480
481 let publish_phase = |phase: &'static str| {
484 let _ = self.event_bus.publish(KernelEvent::PhaseStarted {
485 session_id: ctx.session_id.clone(),
486 phase: phase.to_string(),
487 summary: None,
488 });
489 };
490 let complete_phase = |phase: &'static str| {
491 let _ = self.event_bus.publish(KernelEvent::PhaseCompleted {
492 session_id: ctx.session_id.clone(),
493 phase: phase.to_string(),
494 });
495 };
496 match assessment {
497 oxios_ouroboros::Assessment::Conversation(reply) => Ok(HandleResponse::Reply(reply)),
498
499 oxios_ouroboros::Assessment::Clarify { questions } => {
500 Ok(HandleResponse::Clarify(questions))
501 }
502
503 oxios_ouroboros::Assessment::Task(scope) => {
504 let mut directive = match scope {
506 oxios_ouroboros::Scope::Trivial => {
507 oxios_ouroboros::Directive::from_message(msg)
508 }
509 oxios_ouroboros::Scope::Substantial => {
510 publish_phase("plan");
511 let d = engine.crystallize(msg, ctx).await?;
512 complete_phase("plan");
513 d
514 }
515 };
516
517 let env = self.resolve_exec_env(ctx, msg);
519
520 publish_phase("execute");
523 let mut result = self.execute_directive(&directive, &env).await?;
524 complete_phase("execute");
525
526 let (verdict, evaluation_passed) = match scope {
528 oxios_ouroboros::Scope::Trivial => (None, None),
529 oxios_ouroboros::Scope::Substantial => {
530 publish_phase("review");
531 let (r, v) = self
532 .verify_or_retry(engine, &mut directive, &env, result, msg, ctx)
533 .await?;
534 complete_phase("review");
535 result = r;
536 let passed = v.all_passed();
537 (Some(v), Some(passed))
538 }
539 };
540
541 Ok(HandleResponse::Task {
542 scope,
543 directive: Box::new(directive),
544 env: Box::new(env),
545 result: Box::new(result),
546 verdict,
547 evaluation_passed,
548 })
549 }
550 }
551 }
552
553 pub async fn handle_unified(
560 &self,
561 user_id: &str,
562 msg: &str,
563 session_id: Option<&str>,
564 project_ids: Option<&str>,
565 mount_ids: Option<&str>,
566 request_id: &str,
567 ) -> Result<OrchestrationResult> {
568 let engine = self
570 .intent_engine
571 .read()
572 .clone()
573 .expect("IntentEngine not wired — kernel assembler bug");
574
575 let sid = session_id.unwrap_or(request_id).to_string();
577 let history = self.load_session_history(&sid).await;
578 let ctx = oxios_ouroboros::MsgCtx {
579 session_id: sid.clone(),
580 history,
581 project_ids: project_ids.map(String::from),
582 mount_ids: mount_ids.map(String::from),
583 user_id: user_id.to_string(),
584 };
585
586 let start = std::time::Instant::now();
588 let response = self.handle(engine.as_ref(), msg, &ctx).await?;
589 let duration_ms = start.elapsed().as_millis() as u64;
590
591 Ok(self.handle_response_to_orchestration_result(response, &ctx, duration_ms))
592 }
593
594 async fn load_session_history(&self, session_id: &str) -> Vec<oxios_ouroboros::Exchange> {
596 let sid = crate::state_store::SessionId(session_id.to_string());
597 match self.state_store.load_session(&sid).await {
598 Ok(Some(session)) => session
599 .user_messages
600 .iter()
601 .zip(session.agent_responses.iter())
602 .map(|(u, a)| oxios_ouroboros::Exchange {
603 user: u.content.clone(),
604 agent: a.content.clone(),
605 })
606 .collect(),
607 _ => Vec::new(),
608 }
609 }
610
611 fn handle_response_to_orchestration_result(
612 &self,
613 response: HandleResponse,
614 ctx: &oxios_ouroboros::MsgCtx,
615 duration_ms: u64,
616 ) -> OrchestrationResult {
617 let metrics = get_metrics();
618 metrics.orch_duration.observe(duration_ms as f64 / 1000.0);
619
620 match response {
621 HandleResponse::Reply(reply) => OrchestrationResult {
622 session_id: Some(ctx.session_id.clone()),
623 primary_project_id: None,
624 project_tag: None,
625 active_mount_ids: Vec::new(),
626 mount_tag: None,
627 response: reply,
628 agent_id: None,
629 phase_reached: "interview".to_string(),
630 evaluation_passed: None,
631 output: None,
632 tool_calls: Vec::new(),
633 interview_questions: None,
634 interview_round: None,
635 reasoning_text: String::new(),
636 },
637 HandleResponse::Clarify(questions) => {
638 let questions_text = questions
639 .iter()
640 .map(|q| q.text.clone())
641 .collect::<Vec<_>>()
642 .join("\n");
643 let structured = Some(
644 questions
645 .iter()
646 .map(|q| oxios_ouroboros::InterviewQuestionOutput {
647 id: q.id.clone(),
648 text: q.text.clone(),
649 kind: format!("{:?}", q.kind).to_lowercase(),
650 options: q
651 .options
652 .iter()
653 .map(|o| oxios_ouroboros::InterviewOptionOutput {
654 value: o.value.clone(),
655 label: o.label.clone(),
656 description: String::new(),
657 })
658 .collect(),
659 })
660 .collect(),
661 );
662 OrchestrationResult {
663 session_id: Some(ctx.session_id.clone()),
664 primary_project_id: None,
665 project_tag: None,
666 active_mount_ids: Vec::new(),
667 mount_tag: None,
668 response: questions_text,
669 agent_id: None,
670 phase_reached: "interview".to_string(),
671 evaluation_passed: None,
672 output: None,
673 tool_calls: Vec::new(),
674 interview_questions: structured,
675 interview_round: Some(((ctx.history.len() / 2) as u32).max(1)),
676 reasoning_text: String::new(),
677 }
678 }
679 HandleResponse::Task {
680 scope: _,
681 directive,
682 env,
683 result,
684 verdict,
685 evaluation_passed,
686 } => {
687 let response_text = if directive.acceptance_criteria.is_empty() {
688 result.output.clone()
689 } else {
690 match &verdict {
691 Some(v) if v.all_passed() => result.output.clone(),
692 Some(v) => format!(
693 "{}\n\n⚠ Review notes:\n{}",
694 result.output,
695 v.notes.join("\n")
696 ),
697 None => result.output.clone(),
698 }
699 };
700 if evaluation_passed.unwrap_or(false) {
701 metrics.agents_completed.inc();
702 } else {
703 metrics.agents_failed.inc();
704 }
705 OrchestrationResult {
706 session_id: Some(ctx.session_id.clone()),
707 primary_project_id: env.project_id,
708 project_tag: None,
709 active_mount_ids: Vec::new(),
710 mount_tag: None,
711 response: response_text,
712 agent_id: None,
713 phase_reached: "execute".to_string(),
714 evaluation_passed,
715 output: Some(result.output.clone()),
716 tool_calls: result.tool_calls.clone(),
717 interview_questions: None,
718 interview_round: None,
719 reasoning_text: result.reasoning_text.clone(),
720 }
721 }
722 }
723 }
724
725 fn resolve_exec_env(
732 &self,
733 ctx: &oxios_ouroboros::MsgCtx,
734 msg: &str,
735 ) -> oxios_ouroboros::ExecEnv {
736 let (active_mount_ids, workspace_context, mount_paths, _mount_tag) =
737 self.resolve_mount_workspace(ctx.mount_ids.as_deref(), ctx.project_ids.as_deref(), msg);
738 let _ = active_mount_ids;
742
743 let project_id = ctx
746 .project_ids
747 .as_deref()
748 .and_then(|ids| {
749 ids.split(',')
750 .next()
751 .and_then(|s| Uuid::parse_str(s.trim()).ok())
752 })
753 .or_else(|| {
754 self.detect_project_tag(msg).and_then(|_tag| {
755 self.project_manager().and_then(|pm| {
756 let projects = pm.list_projects();
757 match crate::project::detect_project(msg, &projects) {
758 crate::project::DetectionResult::Found(id) => Some(id),
759 crate::project::DetectionResult::NoMatch { .. } => None,
760 }
761 })
762 })
763 });
764
765 if let Some(pid) = project_id
767 && let Some(pm) = self.project_manager()
768 {
769 pm.touch(pid);
770 }
771
772 oxios_ouroboros::ExecEnv {
773 workspace_context,
774 mount_paths,
775 project_id,
776 cspace_hint: None,
777 model_override: None,
778 restore_state: None,
779 }
780 }
781
782 async fn execute_directive(
785 &self,
786 directive: &oxios_ouroboros::Directive,
787 env: &oxios_ouroboros::ExecEnv,
788 ) -> Result<ExecutionResult> {
789 let coordinator = self.recovery.read().as_ref().cloned();
797 if let Some(coordinator) = coordinator {
798 coordinator.execute(&self.lifecycle, directive, env).await
799 } else {
800 self.lifecycle.execute_directive(directive, env).await
801 }
802 }
803
804 async fn verify_or_retry(
811 &self,
812 engine: &dyn oxios_ouroboros::IntentEngineOps,
813 directive: &mut oxios_ouroboros::Directive,
814 env: &oxios_ouroboros::ExecEnv,
815 initial_result: ExecutionResult,
816 _msg: &str,
817 _ctx: &oxios_ouroboros::MsgCtx,
818 ) -> Result<(ExecutionResult, oxios_ouroboros::Verdict)> {
819 let verdict = engine.review(directive, &initial_result).await?;
820
821 if verdict.all_passed() || verdict.gaps.is_empty() {
822 return Ok((initial_result, verdict));
823 }
824
825 let enable_retry = self.intent_config.read().enable_retry;
828 if !enable_retry {
829 tracing::info!("Review failed but retry disabled (enable_retry=false)");
830 return Ok((initial_result, verdict));
831 }
832
833 let metrics = get_metrics();
834 metrics.retry_attempted.inc();
835
836 tracing::info!(
837 gaps = verdict.gaps.len(),
838 "Review failed — retrying with feedback"
839 );
840
841 let retry_result = self
843 .lifecycle
844 .execute_with_feedback(directive, env, &initial_result, &verdict.gaps)
845 .await?;
846
847 let retry_verdict = engine.review(directive, &retry_result).await?;
849
850 if retry_verdict.score > verdict.score {
852 metrics.retry_improved.inc();
853 } else if retry_verdict.score < verdict.score {
854 metrics.retry_degraded.inc();
855 } else {
856 metrics.retry_unchanged.inc();
857 }
858
859 let chosen_result = if retry_verdict.score >= verdict.score {
861 retry_result
862 } else {
863 initial_result
864 };
865
866 Ok((chosen_result, retry_verdict))
867 }
868}
869
870#[derive(Debug, Clone)]
882pub enum HandleResponse {
883 Reply(String),
885 Clarify(Vec<oxios_ouroboros::Question>),
887 Task {
889 scope: oxios_ouroboros::Scope,
891 directive: Box<oxios_ouroboros::Directive>,
893 env: Box<oxios_ouroboros::ExecEnv>,
895 result: Box<ExecutionResult>,
897 verdict: Option<oxios_ouroboros::Verdict>,
899 evaluation_passed: Option<bool>,
901 },
902}
903
904#[derive(Debug, Clone, Serialize, Deserialize)]
906pub struct OrchestrationResult {
907 #[serde(skip_serializing_if = "Option::is_none")]
909 pub session_id: Option<String>,
910 #[serde(skip_serializing_if = "Option::is_none")]
912 pub primary_project_id: Option<Uuid>,
913 #[serde(skip_serializing_if = "Option::is_none")]
915 pub project_tag: Option<String>,
916 #[serde(default, skip_serializing_if = "Vec::is_empty")]
918 pub active_mount_ids: Vec<MountId>,
919 #[serde(skip_serializing_if = "Option::is_none")]
921 pub mount_tag: Option<String>,
922 pub response: String,
924 #[serde(skip_serializing_if = "Option::is_none")]
926 pub agent_id: Option<AgentId>,
927 pub phase_reached: String,
929 pub evaluation_passed: Option<bool>,
935 #[serde(skip_serializing_if = "Option::is_none")]
937 pub output: Option<String>,
938 #[serde(default, skip_serializing_if = "Vec::is_empty")]
940 pub tool_calls: Vec<oxios_ouroboros::ToolCallRecord>,
941 #[serde(default, skip_serializing_if = "Option::is_none")]
949 pub interview_questions: Option<Vec<oxios_ouroboros::InterviewQuestionOutput>>,
950 #[serde(default, skip_serializing_if = "Option::is_none")]
953 pub interview_round: Option<u32>,
954
955 #[serde(default, skip_serializing_if = "String::is_empty")]
960 pub reasoning_text: String,
961}
962
963fn build_workspace_context_body(mounts: &[crate::mount::Mount]) -> Option<String> {
972 if mounts.is_empty() {
973 return None;
974 }
975 let mut out = String::new();
976 out.push_str("### Active Mounts\n");
977
978 for (i, m) in mounts.iter().enumerate() {
979 let primary = i == 0;
980 let path = m
981 .primary_path()
982 .map(|p| p.to_string_lossy().to_string())
983 .unwrap_or_else(|| "(no path)".to_string());
984
985 if primary {
986 out.push_str(&format!("- **{}** → {}\n", m.name, path));
987 if !m.auto_description.is_empty() {
988 let desc: String = m
990 .auto_description
991 .lines()
992 .take(3)
993 .collect::<Vec<_>>()
994 .join("\n ");
995 out.push_str(&format!(" {}\n", desc));
996 }
997 let summary = m.summary_line();
998 if !summary.is_empty() {
999 out.push_str(&format!(" _{}_\n", summary));
1000 }
1001 if m.enrichment_pending {
1002 out.push_str(" _(content changed — consider re-scanning this Mount)_\n");
1003 }
1004 } else {
1005 let summary = m.summary_line();
1007 let suffix = if summary.is_empty() {
1008 String::new()
1009 } else {
1010 format!(" — {}", summary)
1011 };
1012 out.push_str(&format!("- **{}** → {}{}\n", m.name, path, suffix));
1013 }
1014 }
1015
1016 Some(out)
1017}
1018
1019#[cfg(test)]
1020mod mount_workspace_tests {
1021 use super::*;
1022 use crate::mount::{Mount, MountSource};
1023 use std::path::PathBuf;
1024
1025 #[test]
1026 fn test_workspace_context_primary_full_secondary_terse() {
1027 let mut oxios =
1028 Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
1029 oxios.auto_description = "Agent OS.\nRust + tokio.".to_string();
1030 oxios.auto_meta.summary = "Rust agent OS".to_string();
1031
1032 let mut oxi = Mount::from_name_and_path("oxi", PathBuf::from("/oxi"));
1033 oxi.auto_meta.summary = "SDK".to_string();
1034
1035 let body = build_workspace_context_body(&[oxios, oxi]).unwrap();
1036 assert!(body.contains("### Active Mounts"));
1037 assert!(body.contains("Agent OS."));
1039 assert!(body.contains("_Rust agent OS_"));
1040 assert!(body.contains("**oxi** → /oxi — SDK"));
1042 }
1043
1044 #[test]
1045 fn test_workspace_context_empty_is_none() {
1046 assert!(build_workspace_context_body(&[]).is_none());
1047 }
1048
1049 #[test]
1053 fn test_resolve_mount_workspace_detects_and_collects_paths() {
1054 use crate::mount::MountManager;
1055 use oxios_memory::memory::sqlite::MemoryDatabase;
1056 use std::sync::Arc;
1057
1058 let db = Arc::new(MemoryDatabase::open_in_memory(64).unwrap());
1059 let mm = Arc::new(MountManager::new(db, None).unwrap());
1060
1061 let oxios = mm
1063 .create_mount(
1064 "oxios".to_string(),
1065 vec![PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios")],
1066 MountSource::Manual,
1067 )
1068 .unwrap();
1069 let oxi_sdk = mm
1070 .create_mount(
1071 "oxi-sdk".to_string(),
1072 vec![PathBuf::from("/Users/me/oxi")],
1073 MountSource::Manual,
1074 )
1075 .unwrap();
1076 mm.update_enrichment(oxios.id, Some("Agent OS in Rust.".to_string()), None)
1077 .unwrap();
1078
1079 let mounts = mm.get_mounts_ordered(&[oxios.id, oxi_sdk.id]);
1083 assert_eq!(mounts.len(), 2);
1084
1085 let body = build_workspace_context_body(&mounts).unwrap();
1086 assert!(body.contains("oxios"));
1087 assert!(body.contains("Agent OS in Rust."));
1088 assert!(body.contains("oxi-sdk"));
1089
1090 let mut paths = Vec::new();
1092 for m in &mounts {
1093 for p in &m.paths {
1094 if !paths.contains(p) {
1095 paths.push(p.clone());
1096 }
1097 }
1098 }
1099 assert_eq!(paths.len(), 2);
1100 assert_eq!(paths[0], PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
1101 assert_eq!(paths[1], PathBuf::from("/Users/me/oxi"));
1102 }
1103
1104 #[test]
1107 fn test_detection_seeds_primary_on_name_mention() {
1108 use crate::mount::{DetectionResult, detect_mounts};
1109
1110 let oxios =
1111 Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
1112 let result = detect_mounts("oxios 코드리뷰해줘", std::slice::from_ref(&oxios));
1113 assert!(matches!(result, DetectionResult::Found(id) if id == oxios.id));
1114 }
1115}