1#![allow(clippy::all)]
2
3use super::agent_events::AgentEventEmitter;
4use super::effective::EffectiveBindingPolicy;
5use super::peer_directory::PeerDirectory;
6use super::redaction::Redactor;
7use super::routing::AgentRouter;
8use super::tool_registry::ToolRegistry;
9use super::transcripts_index::TranscriptsIndex;
10use crate::plan_mode::PlanModeState;
11use crate::session::SessionManager;
12use crate::todo::TodoList;
13use nexo_broker::AnyBroker;
14use nexo_config::types::agents::AgentConfig;
15use nexo_mcp::SessionMcpRuntime;
16use nexo_memory::LongTermMemory;
17use std::sync::Arc;
18use tokio::sync::RwLock;
19use uuid::Uuid;
20#[derive(Clone)]
21pub struct AgentContext {
22 pub agent_id: String,
23 pub config: Arc<AgentConfig>,
24 pub broker: AnyBroker,
25 pub sessions: Arc<SessionManager>,
26 pub memory: Option<Arc<LongTermMemory>>,
27 pub router: Option<Arc<AgentRouter>>,
28 pub peers: Option<Arc<PeerDirectory>>,
33 pub mcp: Option<Arc<SessionMcpRuntime>>,
35 pub session_id: Option<Uuid>,
40 pub effective: Option<Arc<EffectiveBindingPolicy>>,
47 pub effective_tools: Option<Arc<ToolRegistry>>,
53 pub credentials: Option<Arc<nexo_auth::AgentCredentialResolver>>,
59 pub breakers: Option<Arc<nexo_auth::BreakerRegistry>>,
62 pub redactor: Option<Arc<Redactor>>,
65 pub transcripts_index: Option<Arc<TranscriptsIndex>>,
69 pub link_extractor: Option<Arc<crate::link_understanding::LinkExtractor>>,
73 pub context_optimization: Option<nexo_config::types::llm::ResolvedContextOptimization>,
85 pub event_emitter: Option<Arc<dyn AgentEventEmitter>>,
95 pub dispatch: Option<Arc<super::dispatch_handlers::DispatchToolContext>>,
101 pub repl_registry: Option<Arc<super::repl_registry::ReplRegistry>>,
105 pub sender_trusted: bool,
110 pub inbound_origin: Option<(String, String, String)>,
115 pub plan_mode: Arc<RwLock<PlanModeState>>,
122 pub plan_approval_registry: Arc<crate::agent::plan_mode_tool::PlanApprovalRegistry>,
128 pub todos: Arc<RwLock<TodoList>>,
135 pub team_id: Option<String>,
139 pub team_member_name: Option<String>,
143 pub inbox: Arc<RwLock<Vec<DmMessage>>>,
149 pub proactive_enabled: bool,
153 pub binding_role: Option<String>,
157 #[doc(hidden)]
166 pub assistant: nexo_assistant::ResolvedAssistant,
167 pub binding: Option<BindingContext>,
177
178 pub inbound: Option<InboundMessageMeta>,
187}
188
189#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
193pub struct DmMessage {
194 pub from: String,
195 pub body: serde_json::Value,
196 pub correlation_id: Option<String>,
197 pub received_at: i64,
198}
199
200pub use nexo_tool_meta::{BindingContext, InboundKind, InboundMessageMeta};
226
227pub fn binding_context_from_effective(
247 policy: &EffectiveBindingPolicy,
248 agent_id: impl Into<String>,
249 session_id: Option<Uuid>,
250) -> BindingContext {
251 let mut ctx = BindingContext::agent_only(agent_id);
252 ctx.session_id = session_id;
253 if policy.binding_index.is_some() {
254 ctx.channel = policy.channel.clone();
255 ctx.account_id = policy.account_id.clone();
256 ctx.binding_id = policy.binding_id();
257 }
258 ctx.language = policy.language.clone();
264 ctx
265}
266impl AgentContext {
267 pub fn new(
268 agent_id: impl Into<String>,
269 config: Arc<AgentConfig>,
270 broker: AnyBroker,
271 sessions: Arc<SessionManager>,
272 ) -> Self {
273 Self {
274 agent_id: agent_id.into(),
275 config,
276 broker,
277 sessions,
278 memory: None,
279 router: None,
280 peers: None,
281 mcp: None,
282 session_id: None,
283 effective: None,
284 effective_tools: None,
285 credentials: None,
286 breakers: None,
287 redactor: None,
288 transcripts_index: None,
289 link_extractor: None,
290 context_optimization: None,
292 event_emitter: None,
293 dispatch: None,
294 sender_trusted: false,
295 inbound_origin: None,
296 plan_mode: Arc::new(RwLock::new(PlanModeState::default())),
297 plan_approval_registry: Arc::new(
298 crate::agent::plan_mode_tool::PlanApprovalRegistry::default(),
299 ),
300 todos: Arc::new(RwLock::new(TodoList::new())),
301 team_id: None,
302 team_member_name: None,
303 inbox: Arc::new(RwLock::new(Vec::new())),
304 proactive_enabled: false,
305 binding_role: None,
306 assistant: nexo_assistant::ResolvedAssistant::disabled(),
307 repl_registry: None,
308 binding: None,
315 inbound: None,
321 }
322 }
323
324 pub fn with_team(mut self, team_id: impl Into<String>, name: impl Into<String>) -> Self {
328 self.team_id = Some(team_id.into());
329 self.team_member_name = Some(name.into());
330 self
331 }
332
333 pub fn is_teammate(&self) -> bool {
337 self.team_id.is_some() && self.team_member_name.is_some()
338 }
339
340 pub fn with_plan_mode(mut self, state: Arc<RwLock<PlanModeState>>) -> Self {
345 self.plan_mode = state;
346 self
347 }
348
349 pub fn with_plan_approval_registry(
354 mut self,
355 registry: Arc<crate::agent::plan_mode_tool::PlanApprovalRegistry>,
356 ) -> Self {
357 self.plan_approval_registry = registry;
358 self
359 }
360
361 pub fn is_interactive(&self) -> bool {
371 self.inbound_origin.is_some()
372 }
373
374 pub fn with_sender_trusted(mut self, v: bool) -> Self {
375 self.sender_trusted = v;
376 self
377 }
378
379 pub fn with_inbound_origin(
380 mut self,
381 plugin: impl Into<String>,
382 instance: impl Into<String>,
383 sender_id: impl Into<String>,
384 ) -> Self {
385 self.inbound_origin = Some((plugin.into(), instance.into(), sender_id.into()));
386 self
387 }
388
389 pub fn with_inbound_meta(mut self, meta: InboundMessageMeta) -> Self {
395 self.inbound = Some(meta);
396 self
397 }
398
399 pub fn with_dispatch(mut self, d: Arc<super::dispatch_handlers::DispatchToolContext>) -> Self {
400 self.dispatch = Some(d);
401 self
402 }
403 pub fn with_context_optimization(
411 mut self,
412 co: nexo_config::types::llm::ResolvedContextOptimization,
413 ) -> Self {
414 self.context_optimization = Some(co);
415 self
416 }
417 pub fn with_redactor(mut self, redactor: Arc<Redactor>) -> Self {
418 self.redactor = Some(redactor);
419 self
420 }
421 pub fn with_event_emitter(mut self, emitter: Arc<dyn AgentEventEmitter>) -> Self {
426 self.event_emitter = Some(emitter);
427 self
428 }
429 pub fn with_transcripts_index(mut self, index: Arc<TranscriptsIndex>) -> Self {
430 self.transcripts_index = Some(index);
431 self
432 }
433 pub fn with_link_extractor(
434 mut self,
435 ext: Arc<crate::link_understanding::LinkExtractor>,
436 ) -> Self {
437 self.link_extractor = Some(ext);
438 self
439 }
440 pub fn with_memory(mut self, memory: Arc<LongTermMemory>) -> Self {
441 self.memory = Some(memory);
442 self
443 }
444 pub fn with_router(mut self, router: Arc<AgentRouter>) -> Self {
445 self.router = Some(router);
446 self
447 }
448 pub fn with_peers(mut self, peers: Arc<PeerDirectory>) -> Self {
449 self.peers = Some(peers);
450 self
451 }
452 pub fn with_mcp(mut self, mcp: Arc<SessionMcpRuntime>) -> Self {
453 self.mcp = Some(mcp);
454 self
455 }
456 pub fn with_session_id(mut self, id: Uuid) -> Self {
457 self.session_id = Some(id);
458 self
459 }
460 pub fn with_effective(mut self, effective: Arc<EffectiveBindingPolicy>) -> Self {
461 self.proactive_enabled = effective.proactive.enabled;
462 self.binding_role = effective.role.clone();
463 self.binding = Some(binding_context_from_effective(
475 &effective,
476 self.agent_id.clone(),
477 self.session_id,
478 ));
479 self.effective = Some(effective);
480 self
481 }
482
483 pub fn with_mcp_channel_source(mut self, source: impl Into<String>) -> Self {
489 if let Some(b) = self.binding.as_mut() {
490 b.mcp_channel_source = Some(source.into());
491 }
492 self
493 }
494 pub fn with_effective_tools(mut self, tools: Arc<ToolRegistry>) -> Self {
495 self.effective_tools = Some(tools);
496 self
497 }
498
499 pub fn with_event_source(mut self, meta: nexo_tool_meta::EventSourceMeta) -> Self {
507 if let Some(b) = self.binding.as_mut() {
508 b.event_source = Some(meta);
509 } else {
510 tracing::debug!("with_event_source called on a context without a binding — no-op");
511 }
512 self
513 }
514 pub fn with_credentials(
515 mut self,
516 credentials: Arc<nexo_auth::AgentCredentialResolver>,
517 ) -> Self {
518 self.credentials = Some(credentials);
519 self
520 }
521 pub fn with_breakers(mut self, breakers: Arc<nexo_auth::BreakerRegistry>) -> Self {
522 self.breakers = Some(breakers);
523 self
524 }
525 pub fn effective_policy(&self) -> Arc<EffectiveBindingPolicy> {
530 if let Some(eff) = &self.effective {
531 return Arc::clone(eff);
532 }
533 Arc::new(EffectiveBindingPolicy::from_agent_defaults(&self.config))
534 }
535
536 pub fn build_meta_value(&self) -> serde_json::Value {
550 nexo_tool_meta::build_meta_value(
551 &self.agent_id,
552 self.session_id,
553 self.binding.as_ref(),
554 self.inbound.as_ref(),
555 )
556 }
557}
558
559#[cfg(test)]
560mod plan_mode_tests {
561 use super::*;
562 use crate::plan_mode::{PlanModeReason, PlanModeState};
563 use nexo_config::types::agents::{
564 AgentConfig, AgentRuntimeConfig, DreamingYamlConfig, HeartbeatConfig, ModelConfig,
565 OutboundAllowlistConfig, WorkspaceGitConfig,
566 };
567
568 fn ctx() -> AgentContext {
569 let cfg = AgentConfig {
570 id: "a".into(),
571 model: ModelConfig {
572 provider: "x".into(),
573 model: "y".into(),
574 },
575 plugins: Vec::new(),
576 heartbeat: HeartbeatConfig::default(),
577 config: AgentRuntimeConfig::default(),
578 system_prompt: String::new(),
579 workspace: String::new(),
580 skills: Vec::new(),
581 skills_dir: "./skills".into(),
582 skill_overrides: Default::default(),
583 transcripts_dir: String::new(),
584 dreaming: DreamingYamlConfig::default(),
585 workspace_git: WorkspaceGitConfig::default(),
586 tool_rate_limits: None,
587 tool_args_validation: None,
588 extra_docs: Vec::new(),
589 inbound_bindings: Vec::new(),
590 allowed_tools: Vec::new(),
591 sender_rate_limit: None,
592 allowed_delegates: Vec::new(),
593 accept_delegates_from: Vec::new(),
594 description: String::new(),
595 google_auth: None,
596 credentials: Default::default(),
597 link_understanding: serde_json::Value::Null,
598 web_search: serde_json::Value::Null,
599 pairing_policy: serde_json::Value::Null,
600 language: None,
601 locale_prompts: Default::default(),
602 outbound_allowlist: OutboundAllowlistConfig::default(),
603 context_optimization: None,
604 dispatch_policy: Default::default(),
605 plan_mode: Default::default(),
606 remote_triggers: Vec::new(),
607 lsp: nexo_config::types::lsp::LspPolicy::default(),
608 config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
609 team: nexo_config::types::team::TeamPolicy::default(),
610 proactive: Default::default(),
611 repl: Default::default(),
612 auto_dream: None,
613 assistant_mode: None,
614 away_summary: None,
615 brief: None,
616 channels: None,
617 auto_approve: false,
618 extract_memories: None,
619 event_subscribers: Vec::new(),
620 tenant_id: None,
621 extensions_config: std::collections::BTreeMap::new(),
622 active: true,
623 };
624 AgentContext::new(
625 "a",
626 Arc::new(cfg),
627 AnyBroker::local(),
628 Arc::new(SessionManager::new(std::time::Duration::from_secs(60), 8)),
629 )
630 }
631
632 #[tokio::test]
633 async fn plan_mode_default_off() {
634 let c = ctx();
635 assert!(c.plan_mode.read().await.is_off());
636 }
637
638 #[tokio::test]
639 async fn plan_mode_set_then_read() {
640 let c = ctx();
641 {
642 let mut g = c.plan_mode.write().await;
643 *g = PlanModeState::on(
644 42,
645 PlanModeReason::ModelRequested {
646 reason: Some("rationale".into()),
647 },
648 );
649 }
650 assert!(c.plan_mode.read().await.is_on());
651 }
652
653 #[tokio::test]
654 async fn is_interactive_requires_inbound_origin() {
655 let c = ctx();
656 assert!(!c.is_interactive());
657 let c = c.with_inbound_origin("whatsapp", "default", "+1234");
658 assert!(c.is_interactive());
659 }
660
661 #[tokio::test]
662 async fn with_plan_mode_shares_handle() {
663 let shared = Arc::new(RwLock::new(PlanModeState::on(
664 7,
665 PlanModeReason::OperatorRequested,
666 )));
667 let c = ctx().with_plan_mode(Arc::clone(&shared));
668 {
671 let mut g = shared.write().await;
672 *g = PlanModeState::Off;
673 }
674 assert!(c.plan_mode.read().await.is_off());
675 }
676
677 #[tokio::test]
682 async fn team_fields_default_to_none() {
683 let c = ctx();
684 assert!(c.team_id.is_none());
685 assert!(c.team_member_name.is_none());
686 assert!(!c.is_teammate());
687 assert!(c.inbox.read().await.is_empty());
688 }
689
690 #[tokio::test]
691 async fn with_team_sets_both_fields() {
692 let c = ctx().with_team("feature-x", "researcher");
693 assert_eq!(c.team_id.as_deref(), Some("feature-x"));
694 assert_eq!(c.team_member_name.as_deref(), Some("researcher"));
695 assert!(c.is_teammate());
696 }
697
698 #[tokio::test]
699 async fn dm_message_serde_roundtrip() {
700 let m = DmMessage {
701 from: "team-lead".into(),
702 body: serde_json::json!({"hi": 1}),
703 correlation_id: Some("c-1".into()),
704 received_at: 100,
705 };
706 let json = serde_json::to_string(&m).unwrap();
707 let back: DmMessage = serde_json::from_str(&json).unwrap();
708 assert_eq!(m, back);
709 }
710
711 #[tokio::test]
712 async fn inbox_appends_persist_across_clones() {
713 let c = ctx().with_team("feature-x", "researcher");
716 c.inbox.write().await.push(DmMessage {
717 from: "team-lead".into(),
718 body: serde_json::json!("hi"),
719 correlation_id: None,
720 received_at: 1,
721 });
722 let same = c.clone();
723 assert_eq!(same.inbox.read().await.len(), 1);
724 }
725
726 #[tokio::test]
731 async fn binding_is_none_before_with_effective() {
732 let c = ctx();
733 assert!(c.binding.is_none());
734 }
735
736 #[tokio::test]
737 async fn with_effective_populates_binding_from_policy() {
738 use nexo_config::types::agents::InboundBinding;
739
740 let mut a = (*ctx().config).clone();
741 a.inbound_bindings.push(InboundBinding {
742 plugin: "whatsapp".into(),
743 instance: Some("personal".into()),
744 ..Default::default()
745 });
746 let policy = Arc::new(EffectiveBindingPolicy::resolve(&a, 0));
747
748 let c = ctx().with_effective(policy);
749 let b = c.binding.expect("binding populated by with_effective");
750 assert_eq!(b.agent_id, "a"); assert_eq!(b.channel.as_deref(), Some("whatsapp"));
752 assert_eq!(b.account_id.as_deref(), Some("personal"));
753 assert_eq!(b.binding_id.as_deref(), Some("whatsapp:personal"));
754 assert!(b.mcp_channel_source.is_none());
755 }
756
757 #[tokio::test]
758 async fn with_mcp_channel_source_layers_on_top_of_with_effective() {
759 use nexo_config::types::agents::InboundBinding;
760
761 let mut a = (*ctx().config).clone();
762 a.inbound_bindings.push(InboundBinding {
763 plugin: "telegram".into(),
764 instance: Some("kate_tg".into()),
765 ..Default::default()
766 });
767 let policy = Arc::new(EffectiveBindingPolicy::resolve(&a, 0));
768
769 let c = ctx()
770 .with_effective(policy)
771 .with_mcp_channel_source("slack");
772 let b = c.binding.expect("binding populated");
773 assert_eq!(b.channel.as_deref(), Some("telegram"));
775 assert_eq!(b.account_id.as_deref(), Some("kate_tg"));
776 assert_eq!(b.mcp_channel_source.as_deref(), Some("slack"));
778 }
779
780 #[tokio::test]
781 async fn with_mcp_channel_source_no_op_when_no_binding_match() {
782 let c = ctx().with_mcp_channel_source("slack");
787 assert!(c.binding.is_none());
788 }
789
790 #[tokio::test]
791 async fn with_event_source_populates_when_binding_present() {
792 let mut c = ctx();
793 c.binding = Some(BindingContext::agent_only("ana"));
794 let meta = nexo_tool_meta::EventSourceMeta {
795 subject: "webhook.github.opened".into(),
796 envelope_id: None,
797 synthesis_mode: "synthesize".into(),
798 };
799 let c = c.with_event_source(meta.clone());
800 let binding = c.binding.expect("binding stays Some");
801 assert_eq!(binding.event_source, Some(meta));
802 }
803
804 #[tokio::test]
805 async fn with_event_source_no_op_when_no_binding_match() {
806 let meta = nexo_tool_meta::EventSourceMeta {
807 subject: "x.y".into(),
808 envelope_id: None,
809 synthesis_mode: "tick".into(),
810 };
811 let c = ctx().with_event_source(meta);
812 assert!(c.binding.is_none());
813 }
814}
815
816#[cfg(test)]
817mod binding_context_tests {
818 use super::BindingContext;
821 use uuid::Uuid;
822
823 #[test]
824 fn agent_only_minimal_context_clears_binding_fields() {
825 let ctx = BindingContext::agent_only("ana");
826 assert_eq!(ctx.agent_id, "ana");
827 assert!(ctx.session_id.is_none());
828 assert!(ctx.channel.is_none());
829 assert!(ctx.account_id.is_none());
830 assert!(ctx.binding_id.is_none());
831 assert!(ctx.mcp_channel_source.is_none());
832 }
833
834 #[test]
835 fn render_binding_id_with_account_id_renders_channel_colon_account() {
836 assert_eq!(
837 nexo_tool_meta::binding_id_render("whatsapp", Some("personal")),
838 "whatsapp:personal"
839 );
840 assert_eq!(
841 nexo_tool_meta::binding_id_render("telegram", Some("kate_tg")),
842 "telegram:kate_tg"
843 );
844 }
845
846 #[test]
847 fn render_binding_id_without_account_id_uses_default_sentinel() {
848 assert_eq!(
849 nexo_tool_meta::binding_id_render("whatsapp", None),
850 "whatsapp:default"
851 );
852 }
853
854 fn full_binding(
855 agent: &str,
856 session: Option<Uuid>,
857 channel: Option<&str>,
858 account: Option<&str>,
859 mcp: Option<&str>,
860 ) -> BindingContext {
861 let mut b = BindingContext::agent_only(agent);
862 b.session_id = session;
863 if let Some(c) = channel {
864 b.channel = Some(c.into());
865 }
866 if let Some(a) = account {
867 b.account_id = Some(a.into());
868 }
869 if let (Some(c), Some(_)) = (channel, account) {
870 b.binding_id = Some(nexo_tool_meta::binding_id_render(c, account));
871 } else if let Some(c) = channel {
872 b.binding_id = Some(nexo_tool_meta::binding_id_render(c, None));
873 }
874 if let Some(s) = mcp {
875 b = b.with_mcp_channel_source(s);
876 }
877 b
878 }
879
880 #[test]
881 fn with_mcp_channel_source_sets_field_inline() {
882 let ctx = BindingContext::agent_only("ana").with_mcp_channel_source("slack");
883 assert_eq!(ctx.mcp_channel_source.as_deref(), Some("slack"));
884 assert_eq!(ctx.agent_id, "ana");
885 }
886
887 #[test]
888 fn binding_context_is_clone_eq_serializable() {
889 let ctx = full_binding(
890 "ana",
891 Some(Uuid::nil()),
892 Some("whatsapp"),
893 Some("personal"),
894 Some("slack"),
895 );
896 let cloned = ctx.clone();
897 assert_eq!(ctx, cloned);
898 let json = serde_json::to_value(&ctx).unwrap();
899 assert_eq!(json["agent_id"], "ana");
900 assert_eq!(json["channel"], "whatsapp");
901 assert_eq!(json["account_id"], "personal");
902 assert_eq!(json["binding_id"], "whatsapp:personal");
903 assert_eq!(json["mcp_channel_source"], "slack");
904 }
905
906 #[test]
907 fn binding_context_skips_serializing_none_fields() {
908 let ctx = BindingContext::agent_only("ana");
909 let json = serde_json::to_value(&ctx).unwrap();
910 let obj = json.as_object().expect("expected object");
911 assert!(obj.contains_key("agent_id"));
912 assert!(!obj.contains_key("session_id"));
914 assert!(!obj.contains_key("channel"));
915 assert!(!obj.contains_key("account_id"));
916 assert!(!obj.contains_key("binding_id"));
917 assert!(!obj.contains_key("mcp_channel_source"));
918 }
919
920 #[test]
921 fn binding_context_round_trips_through_serde() {
922 let ctx = full_binding(
923 "carlos",
924 Some(Uuid::from_u128(42)),
925 Some("whatsapp"),
926 Some("business"),
927 None,
928 );
929 let json = serde_json::to_string(&ctx).unwrap();
930 let back: BindingContext = serde_json::from_str(&json).unwrap();
931 assert_eq!(ctx, back);
932 }
933
934 fn mini_agent() -> nexo_config::types::agents::AgentConfig {
937 use nexo_config::types::agents::{
938 AgentConfig, AgentRuntimeConfig, DreamingYamlConfig, HeartbeatConfig, ModelConfig,
939 OutboundAllowlistConfig, WorkspaceGitConfig,
940 };
941 AgentConfig {
942 id: "ana".into(),
943 model: ModelConfig {
944 provider: "anthropic".into(),
945 model: "claude-haiku-4-5".into(),
946 },
947 plugins: Vec::new(),
948 heartbeat: HeartbeatConfig::default(),
949 config: AgentRuntimeConfig::default(),
950 system_prompt: String::new(),
951 workspace: String::new(),
952 skills: Vec::new(),
953 skills_dir: String::new(),
954 skill_overrides: Default::default(),
955 transcripts_dir: String::new(),
956 dreaming: DreamingYamlConfig::default(),
957 workspace_git: WorkspaceGitConfig::default(),
958 tool_rate_limits: None,
959 tool_args_validation: None,
960 extra_docs: Vec::new(),
961 inbound_bindings: Vec::new(),
962 allowed_tools: Vec::new(),
963 sender_rate_limit: None,
964 allowed_delegates: Vec::new(),
965 accept_delegates_from: Vec::new(),
966 description: String::new(),
967 google_auth: None,
968 credentials: Default::default(),
969 link_understanding: serde_json::Value::Null,
970 web_search: serde_json::Value::Null,
971 pairing_policy: serde_json::Value::Null,
972 language: None,
973 locale_prompts: Default::default(),
974 outbound_allowlist: OutboundAllowlistConfig::default(),
975 context_optimization: None,
976 dispatch_policy: Default::default(),
977 plan_mode: Default::default(),
978 remote_triggers: Vec::new(),
979 lsp: nexo_config::types::lsp::LspPolicy::default(),
980 config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
981 team: nexo_config::types::team::TeamPolicy::default(),
982 proactive: Default::default(),
983 repl: Default::default(),
984 auto_dream: None,
985 assistant_mode: None,
986 away_summary: None,
987 brief: None,
988 channels: None,
989 auto_approve: false,
990 extract_memories: None,
991 event_subscribers: Vec::new(),
992 tenant_id: None,
993 extensions_config: std::collections::BTreeMap::new(),
994 active: true,
995 }
996 }
997
998 #[test]
999 fn from_effective_with_matched_binding_populates_tuple() {
1000 use super::EffectiveBindingPolicy;
1001 use nexo_config::types::agents::InboundBinding;
1002
1003 let mut a = mini_agent();
1004 a.inbound_bindings.push(InboundBinding {
1005 plugin: "whatsapp".into(),
1006 instance: Some("personal".into()),
1007 ..Default::default()
1008 });
1009 let policy = EffectiveBindingPolicy::resolve(&a, 0);
1010 let ctx = super::binding_context_from_effective(&policy, "ana", Some(Uuid::from_u128(1)));
1011
1012 assert_eq!(ctx.agent_id, "ana");
1013 assert_eq!(ctx.session_id, Some(Uuid::from_u128(1)));
1014 assert_eq!(ctx.channel.as_deref(), Some("whatsapp"));
1015 assert_eq!(ctx.account_id.as_deref(), Some("personal"));
1016 assert_eq!(ctx.binding_id.as_deref(), Some("whatsapp:personal"));
1017 assert!(ctx.mcp_channel_source.is_none());
1018 }
1019
1020 #[test]
1021 fn from_effective_with_synthesised_policy_keeps_tuple_none() {
1022 use super::EffectiveBindingPolicy;
1023
1024 let a = mini_agent();
1025 let policy = EffectiveBindingPolicy::from_agent_defaults(&a);
1026 let ctx = super::binding_context_from_effective(&policy, "delegation", None);
1027
1028 assert_eq!(ctx.agent_id, "delegation");
1029 assert!(ctx.session_id.is_none());
1030 assert!(ctx.channel.is_none());
1031 assert!(ctx.account_id.is_none());
1032 assert!(ctx.binding_id.is_none());
1033 assert!(ctx.mcp_channel_source.is_none());
1034 }
1035
1036 #[test]
1037 fn from_effective_chains_with_mcp_channel_source() {
1038 use super::EffectiveBindingPolicy;
1039 use nexo_config::types::agents::InboundBinding;
1040
1041 let mut a = mini_agent();
1042 a.inbound_bindings.push(InboundBinding {
1043 plugin: "telegram".into(),
1044 instance: Some("kate_tg".into()),
1045 ..Default::default()
1046 });
1047 let policy = EffectiveBindingPolicy::resolve(&a, 0);
1048 let ctx = super::binding_context_from_effective(&policy, "ana", None)
1049 .with_mcp_channel_source("slack");
1050
1051 assert_eq!(ctx.channel.as_deref(), Some("telegram"));
1053 assert_eq!(ctx.account_id.as_deref(), Some("kate_tg"));
1054 assert_eq!(ctx.mcp_channel_source.as_deref(), Some("slack"));
1056 }
1057
1058 #[test]
1059 fn from_effective_two_personas_get_distinct_binding_ids() {
1060 use super::EffectiveBindingPolicy;
1061 use nexo_config::types::agents::InboundBinding;
1062
1063 let mut a = mini_agent();
1064 a.inbound_bindings.push(InboundBinding {
1065 plugin: "whatsapp".into(),
1066 instance: Some("personal".into()),
1067 ..Default::default()
1068 });
1069 a.inbound_bindings.push(InboundBinding {
1070 plugin: "whatsapp".into(),
1071 instance: Some("business".into()),
1072 ..Default::default()
1073 });
1074 let p0 = EffectiveBindingPolicy::resolve(&a, 0);
1075 let p1 = EffectiveBindingPolicy::resolve(&a, 1);
1076 let c0 = super::binding_context_from_effective(&p0, "ana", None);
1077 let c1 = super::binding_context_from_effective(&p1, "carlos", None);
1078
1079 assert_eq!(c0.binding_id.as_deref(), Some("whatsapp:personal"));
1080 assert_eq!(c1.binding_id.as_deref(), Some("whatsapp:business"));
1081 assert_ne!(c0.binding_id, c1.binding_id);
1082 }
1083}
1084
1085#[cfg(test)]
1086mod build_meta_value_tests {
1087 use super::{AgentContext, BindingContext};
1092 use crate::session::SessionManager;
1093 use nexo_broker::AnyBroker;
1094 use nexo_config::types::agents::{
1095 AgentConfig, AgentRuntimeConfig, HeartbeatConfig, ModelConfig,
1096 };
1097 use std::sync::Arc;
1098 use std::time::Duration;
1099 use uuid::Uuid;
1100
1101 fn mini_ctx(agent: &str, session: Option<Uuid>) -> AgentContext {
1102 let cfg = Arc::new(AgentConfig {
1103 id: agent.into(),
1104 model: ModelConfig {
1105 provider: "stub".into(),
1106 model: "m1".into(),
1107 },
1108 plugins: Vec::new(),
1109 heartbeat: HeartbeatConfig::default(),
1110 config: AgentRuntimeConfig::default(),
1111 system_prompt: String::new(),
1112 workspace: String::new(),
1113 skills: Vec::new(),
1114 skills_dir: String::new(),
1115 skill_overrides: Default::default(),
1116 transcripts_dir: String::new(),
1117 dreaming: Default::default(),
1118 workspace_git: Default::default(),
1119 tool_rate_limits: None,
1120 tool_args_validation: None,
1121 extra_docs: Vec::new(),
1122 inbound_bindings: Vec::new(),
1123 allowed_tools: Vec::new(),
1124 sender_rate_limit: None,
1125 allowed_delegates: Vec::new(),
1126 accept_delegates_from: Vec::new(),
1127 description: String::new(),
1128 outbound_allowlist: Default::default(),
1129 google_auth: None,
1130 credentials: Default::default(),
1131 link_understanding: serde_json::Value::Null,
1132 web_search: serde_json::Value::Null,
1133 pairing_policy: serde_json::Value::Null,
1134 language: None,
1135 locale_prompts: Default::default(),
1136 context_optimization: None,
1137 dispatch_policy: Default::default(),
1138 plan_mode: Default::default(),
1139 remote_triggers: Vec::new(),
1140 lsp: nexo_config::types::lsp::LspPolicy::default(),
1141 config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
1142 team: nexo_config::types::team::TeamPolicy::default(),
1143 proactive: Default::default(),
1144 repl: Default::default(),
1145 auto_dream: None,
1146 assistant_mode: None,
1147 away_summary: None,
1148 brief: None,
1149 channels: None,
1150 auto_approve: false,
1151 extract_memories: None,
1152 event_subscribers: Vec::new(),
1153 tenant_id: None,
1154 extensions_config: std::collections::BTreeMap::new(),
1155 active: true,
1156 });
1157 let broker = AnyBroker::local();
1158 let sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
1159 let ctx = AgentContext::new(agent, cfg, broker, sessions);
1160 match session {
1161 Some(id) => ctx.with_session_id(id),
1162 None => ctx,
1163 }
1164 }
1165
1166 #[tokio::test]
1167 async fn meta_without_binding_emits_legacy_block_only() {
1168 let ctx = mini_ctx("delegation", None);
1169 let meta = ctx.build_meta_value();
1170 assert_eq!(meta["agent_id"], "delegation");
1171 assert!(meta["session_id"].is_null());
1172 assert!(meta.get("nexo").is_none());
1173 }
1174
1175 #[tokio::test]
1176 async fn meta_with_binding_emits_dual_namespaces() {
1177 let mut ctx = mini_ctx("ana", Some(Uuid::nil()));
1178 let mut b = BindingContext::agent_only("ana");
1179 b.session_id = Some(Uuid::nil());
1180 b.channel = Some("whatsapp".into());
1181 b.account_id = Some("personal".into());
1182 b.binding_id = Some("whatsapp:personal".into());
1183 ctx.binding = Some(b);
1184 let meta = ctx.build_meta_value();
1185
1186 assert_eq!(meta["agent_id"], "ana");
1188 assert!(meta["session_id"].is_string());
1189
1190 let binding = &meta["nexo"]["binding"];
1192 assert_eq!(binding["agent_id"], "ana");
1193 assert_eq!(binding["channel"], "whatsapp");
1194 assert_eq!(binding["account_id"], "personal");
1195 assert_eq!(binding["binding_id"], "whatsapp:personal");
1196 assert!(binding.get("mcp_channel_source").is_none());
1197 }
1198
1199 #[tokio::test]
1200 async fn meta_session_id_serialises_as_string_when_present() {
1201 let sid = Uuid::from_u128(0x42);
1202 let ctx = mini_ctx("ana", Some(sid));
1203 let meta = ctx.build_meta_value();
1204 assert_eq!(meta["session_id"], sid.to_string());
1205 }
1206}