1#![allow(clippy::all)] use 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 web_search_router: Option<Arc<nexo_web_search::WebSearchRouter>>,
77 pub context_optimization: Option<nexo_config::types::llm::ResolvedContextOptimization>,
86 pub event_emitter: Option<Arc<dyn AgentEventEmitter>>,
96 pub dispatch: Option<Arc<super::dispatch_handlers::DispatchToolContext>>,
102 pub repl_registry: Option<Arc<super::repl_registry::ReplRegistry>>,
106 pub sender_trusted: bool,
111 pub inbound_origin: Option<(String, String, String)>,
116 pub plan_mode: Arc<RwLock<PlanModeState>>,
123 pub plan_approval_registry: Arc<crate::agent::plan_mode_tool::PlanApprovalRegistry>,
129 pub todos: Arc<RwLock<TodoList>>,
136 pub team_id: Option<String>,
140 pub team_member_name: Option<String>,
144 pub inbox: Arc<RwLock<Vec<DmMessage>>>,
151 pub proactive_enabled: bool,
155 pub binding_role: Option<String>,
159 #[doc(hidden)]
168 pub assistant: nexo_assistant::ResolvedAssistant,
169 pub binding: Option<BindingContext>,
181
182 pub inbound: Option<InboundMessageMeta>,
191}
192
193#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
197pub struct DmMessage {
198 pub from: String,
199 pub body: serde_json::Value,
200 pub correlation_id: Option<String>,
201 pub received_at: i64,
202}
203
204pub use nexo_tool_meta::{BindingContext, InboundKind, InboundMessageMeta};
231
232pub fn binding_context_from_effective(
252 policy: &EffectiveBindingPolicy,
253 agent_id: impl Into<String>,
254 session_id: Option<Uuid>,
255) -> BindingContext {
256 let mut ctx = BindingContext::agent_only(agent_id);
257 ctx.session_id = session_id;
258 if policy.binding_index.is_some() {
259 ctx.channel = policy.channel.clone();
260 ctx.account_id = policy.account_id.clone();
261 ctx.binding_id = policy.binding_id();
262 }
263 ctx.language = policy.language.clone();
269 ctx
270}
271impl AgentContext {
272 pub fn new(
273 agent_id: impl Into<String>,
274 config: Arc<AgentConfig>,
275 broker: AnyBroker,
276 sessions: Arc<SessionManager>,
277 ) -> Self {
278 Self {
279 agent_id: agent_id.into(),
280 config,
281 broker,
282 sessions,
283 memory: None,
284 router: None,
285 peers: None,
286 mcp: None,
287 session_id: None,
288 effective: None,
289 effective_tools: None,
290 credentials: None,
291 breakers: None,
292 redactor: None,
293 transcripts_index: None,
294 link_extractor: None,
295 web_search_router: None,
296 context_optimization: None,
297 event_emitter: None,
298 dispatch: None,
299 sender_trusted: false,
300 inbound_origin: None,
301 plan_mode: Arc::new(RwLock::new(PlanModeState::default())),
302 plan_approval_registry: Arc::new(
303 crate::agent::plan_mode_tool::PlanApprovalRegistry::default(),
304 ),
305 todos: Arc::new(RwLock::new(TodoList::new())),
306 team_id: None,
307 team_member_name: None,
308 inbox: Arc::new(RwLock::new(Vec::new())),
309 proactive_enabled: false,
310 binding_role: None,
311 assistant: nexo_assistant::ResolvedAssistant::disabled(),
312 repl_registry: None,
313 binding: None,
321 inbound: None,
327 }
328 }
329
330 pub fn with_team(mut self, team_id: impl Into<String>, name: impl Into<String>) -> Self {
334 self.team_id = Some(team_id.into());
335 self.team_member_name = Some(name.into());
336 self
337 }
338
339 pub fn is_teammate(&self) -> bool {
343 self.team_id.is_some() && self.team_member_name.is_some()
344 }
345
346 pub fn with_plan_mode(mut self, state: Arc<RwLock<PlanModeState>>) -> Self {
351 self.plan_mode = state;
352 self
353 }
354
355 pub fn with_plan_approval_registry(
360 mut self,
361 registry: Arc<crate::agent::plan_mode_tool::PlanApprovalRegistry>,
362 ) -> Self {
363 self.plan_approval_registry = registry;
364 self
365 }
366
367 pub fn is_interactive(&self) -> bool {
377 self.inbound_origin.is_some()
378 }
379
380 pub fn with_sender_trusted(mut self, v: bool) -> Self {
381 self.sender_trusted = v;
382 self
383 }
384
385 pub fn with_inbound_origin(
386 mut self,
387 plugin: impl Into<String>,
388 instance: impl Into<String>,
389 sender_id: impl Into<String>,
390 ) -> Self {
391 self.inbound_origin = Some((plugin.into(), instance.into(), sender_id.into()));
392 self
393 }
394
395 pub fn with_inbound_meta(mut self, meta: InboundMessageMeta) -> Self {
401 self.inbound = Some(meta);
402 self
403 }
404
405 pub fn with_dispatch(mut self, d: Arc<super::dispatch_handlers::DispatchToolContext>) -> Self {
406 self.dispatch = Some(d);
407 self
408 }
409 pub fn with_web_search_router(mut self, router: Arc<nexo_web_search::WebSearchRouter>) -> Self {
410 self.web_search_router = Some(router);
411 self
412 }
413 pub fn with_context_optimization(
418 mut self,
419 co: nexo_config::types::llm::ResolvedContextOptimization,
420 ) -> Self {
421 self.context_optimization = Some(co);
422 self
423 }
424 pub fn with_redactor(mut self, redactor: Arc<Redactor>) -> Self {
425 self.redactor = Some(redactor);
426 self
427 }
428 pub fn with_event_emitter(mut self, emitter: Arc<dyn AgentEventEmitter>) -> Self {
433 self.event_emitter = Some(emitter);
434 self
435 }
436 pub fn with_transcripts_index(mut self, index: Arc<TranscriptsIndex>) -> Self {
437 self.transcripts_index = Some(index);
438 self
439 }
440 pub fn with_link_extractor(
441 mut self,
442 ext: Arc<crate::link_understanding::LinkExtractor>,
443 ) -> Self {
444 self.link_extractor = Some(ext);
445 self
446 }
447 pub fn with_memory(mut self, memory: Arc<LongTermMemory>) -> Self {
448 self.memory = Some(memory);
449 self
450 }
451 pub fn with_router(mut self, router: Arc<AgentRouter>) -> Self {
452 self.router = Some(router);
453 self
454 }
455 pub fn with_peers(mut self, peers: Arc<PeerDirectory>) -> Self {
456 self.peers = Some(peers);
457 self
458 }
459 pub fn with_mcp(mut self, mcp: Arc<SessionMcpRuntime>) -> Self {
460 self.mcp = Some(mcp);
461 self
462 }
463 pub fn with_session_id(mut self, id: Uuid) -> Self {
464 self.session_id = Some(id);
465 self
466 }
467 pub fn with_effective(mut self, effective: Arc<EffectiveBindingPolicy>) -> Self {
468 self.proactive_enabled = effective.proactive.enabled;
469 self.binding_role = effective.role.clone();
470 self.binding = Some(binding_context_from_effective(
483 &effective,
484 self.agent_id.clone(),
485 self.session_id,
486 ));
487 self.effective = Some(effective);
488 self
489 }
490
491 pub fn with_mcp_channel_source(mut self, source: impl Into<String>) -> Self {
498 if let Some(b) = self.binding.as_mut() {
499 b.mcp_channel_source = Some(source.into());
500 }
501 self
502 }
503 pub fn with_effective_tools(mut self, tools: Arc<ToolRegistry>) -> Self {
504 self.effective_tools = Some(tools);
505 self
506 }
507
508 pub fn with_event_source(mut self, meta: nexo_tool_meta::EventSourceMeta) -> Self {
516 if let Some(b) = self.binding.as_mut() {
517 b.event_source = Some(meta);
518 } else {
519 tracing::debug!("with_event_source called on a context without a binding — no-op");
520 }
521 self
522 }
523 pub fn with_credentials(
524 mut self,
525 credentials: Arc<nexo_auth::AgentCredentialResolver>,
526 ) -> Self {
527 self.credentials = Some(credentials);
528 self
529 }
530 pub fn with_breakers(mut self, breakers: Arc<nexo_auth::BreakerRegistry>) -> Self {
531 self.breakers = Some(breakers);
532 self
533 }
534 pub fn effective_policy(&self) -> Arc<EffectiveBindingPolicy> {
539 if let Some(eff) = &self.effective {
540 return Arc::clone(eff);
541 }
542 Arc::new(EffectiveBindingPolicy::from_agent_defaults(&self.config))
543 }
544
545 pub fn build_meta_value(&self) -> serde_json::Value {
560 nexo_tool_meta::build_meta_value(
561 &self.agent_id,
562 self.session_id,
563 self.binding.as_ref(),
564 self.inbound.as_ref(),
565 )
566 }
567}
568
569#[cfg(test)]
570mod plan_mode_tests {
571 use super::*;
572 use crate::plan_mode::{PlanModeReason, PlanModeState};
573 use nexo_config::types::agents::{
574 AgentConfig, AgentRuntimeConfig, DreamingYamlConfig, HeartbeatConfig, ModelConfig,
575 OutboundAllowlistConfig, WorkspaceGitConfig,
576 };
577
578 fn ctx() -> AgentContext {
579 let cfg = AgentConfig {
580 id: "a".into(),
581 model: ModelConfig {
582 provider: "x".into(),
583 model: "y".into(),
584 },
585 plugins: Vec::new(),
586 heartbeat: HeartbeatConfig::default(),
587 config: AgentRuntimeConfig::default(),
588 system_prompt: String::new(),
589 workspace: String::new(),
590 skills: Vec::new(),
591 skills_dir: "./skills".into(),
592 skill_overrides: Default::default(),
593 transcripts_dir: String::new(),
594 dreaming: DreamingYamlConfig::default(),
595 workspace_git: WorkspaceGitConfig::default(),
596 tool_rate_limits: None,
597 tool_args_validation: None,
598 extra_docs: Vec::new(),
599 inbound_bindings: Vec::new(),
600 allowed_tools: Vec::new(),
601 sender_rate_limit: None,
602 allowed_delegates: Vec::new(),
603 accept_delegates_from: Vec::new(),
604 description: String::new(),
605 google_auth: None,
606 credentials: Default::default(),
607 link_understanding: serde_json::Value::Null,
608 web_search: serde_json::Value::Null,
609 pairing_policy: serde_json::Value::Null,
610 language: None,
611 outbound_allowlist: OutboundAllowlistConfig::default(),
612 context_optimization: None,
613 dispatch_policy: Default::default(),
614 plan_mode: Default::default(),
615 remote_triggers: Vec::new(),
616 lsp: nexo_config::types::lsp::LspPolicy::default(),
617 config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
618 team: nexo_config::types::team::TeamPolicy::default(),
619 proactive: Default::default(),
620 repl: Default::default(),
621 auto_dream: None,
622 assistant_mode: None,
623 away_summary: None,
624 brief: None,
625 channels: None,
626 auto_approve: false,
627 extract_memories: None,
628 event_subscribers: Vec::new(),
629 tenant_id: None,
630 extensions_config: std::collections::BTreeMap::new(),
631 active: true,
632 };
633 AgentContext::new(
634 "a",
635 Arc::new(cfg),
636 AnyBroker::local(),
637 Arc::new(SessionManager::new(std::time::Duration::from_secs(60), 8)),
638 )
639 }
640
641 #[tokio::test]
642 async fn plan_mode_default_off() {
643 let c = ctx();
644 assert!(c.plan_mode.read().await.is_off());
645 }
646
647 #[tokio::test]
648 async fn plan_mode_set_then_read() {
649 let c = ctx();
650 {
651 let mut g = c.plan_mode.write().await;
652 *g = PlanModeState::on(
653 42,
654 PlanModeReason::ModelRequested {
655 reason: Some("rationale".into()),
656 },
657 );
658 }
659 assert!(c.plan_mode.read().await.is_on());
660 }
661
662 #[tokio::test]
663 async fn is_interactive_requires_inbound_origin() {
664 let c = ctx();
665 assert!(!c.is_interactive());
666 let c = c.with_inbound_origin("whatsapp", "default", "+1234");
667 assert!(c.is_interactive());
668 }
669
670 #[tokio::test]
671 async fn with_plan_mode_shares_handle() {
672 let shared = Arc::new(RwLock::new(PlanModeState::on(
673 7,
674 PlanModeReason::OperatorRequested,
675 )));
676 let c = ctx().with_plan_mode(Arc::clone(&shared));
677 {
680 let mut g = shared.write().await;
681 *g = PlanModeState::Off;
682 }
683 assert!(c.plan_mode.read().await.is_off());
684 }
685
686 #[tokio::test]
691 async fn team_fields_default_to_none() {
692 let c = ctx();
693 assert!(c.team_id.is_none());
694 assert!(c.team_member_name.is_none());
695 assert!(!c.is_teammate());
696 assert!(c.inbox.read().await.is_empty());
697 }
698
699 #[tokio::test]
700 async fn with_team_sets_both_fields() {
701 let c = ctx().with_team("feature-x", "researcher");
702 assert_eq!(c.team_id.as_deref(), Some("feature-x"));
703 assert_eq!(c.team_member_name.as_deref(), Some("researcher"));
704 assert!(c.is_teammate());
705 }
706
707 #[tokio::test]
708 async fn dm_message_serde_roundtrip() {
709 let m = DmMessage {
710 from: "team-lead".into(),
711 body: serde_json::json!({"hi": 1}),
712 correlation_id: Some("c-1".into()),
713 received_at: 100,
714 };
715 let json = serde_json::to_string(&m).unwrap();
716 let back: DmMessage = serde_json::from_str(&json).unwrap();
717 assert_eq!(m, back);
718 }
719
720 #[tokio::test]
721 async fn inbox_appends_persist_across_clones() {
722 let c = ctx().with_team("feature-x", "researcher");
725 c.inbox.write().await.push(DmMessage {
726 from: "team-lead".into(),
727 body: serde_json::json!("hi"),
728 correlation_id: None,
729 received_at: 1,
730 });
731 let same = c.clone();
732 assert_eq!(same.inbox.read().await.len(), 1);
733 }
734
735 #[tokio::test]
740 async fn binding_is_none_before_with_effective() {
741 let c = ctx();
742 assert!(c.binding.is_none());
743 }
744
745 #[tokio::test]
746 async fn with_effective_populates_binding_from_policy() {
747 use nexo_config::types::agents::InboundBinding;
748
749 let mut a = (*ctx().config).clone();
750 a.inbound_bindings.push(InboundBinding {
751 plugin: "whatsapp".into(),
752 instance: Some("personal".into()),
753 ..Default::default()
754 });
755 let policy = Arc::new(EffectiveBindingPolicy::resolve(&a, 0));
756
757 let c = ctx().with_effective(policy);
758 let b = c.binding.expect("binding populated by with_effective");
759 assert_eq!(b.agent_id, "a"); assert_eq!(b.channel.as_deref(), Some("whatsapp"));
761 assert_eq!(b.account_id.as_deref(), Some("personal"));
762 assert_eq!(b.binding_id.as_deref(), Some("whatsapp:personal"));
763 assert!(b.mcp_channel_source.is_none());
764 }
765
766 #[tokio::test]
767 async fn with_mcp_channel_source_layers_on_top_of_with_effective() {
768 use nexo_config::types::agents::InboundBinding;
769
770 let mut a = (*ctx().config).clone();
771 a.inbound_bindings.push(InboundBinding {
772 plugin: "telegram".into(),
773 instance: Some("kate_tg".into()),
774 ..Default::default()
775 });
776 let policy = Arc::new(EffectiveBindingPolicy::resolve(&a, 0));
777
778 let c = ctx()
779 .with_effective(policy)
780 .with_mcp_channel_source("slack");
781 let b = c.binding.expect("binding populated");
782 assert_eq!(b.channel.as_deref(), Some("telegram"));
784 assert_eq!(b.account_id.as_deref(), Some("kate_tg"));
785 assert_eq!(b.mcp_channel_source.as_deref(), Some("slack"));
787 }
788
789 #[tokio::test]
790 async fn with_mcp_channel_source_no_op_when_no_binding_match() {
791 let c = ctx().with_mcp_channel_source("slack");
796 assert!(c.binding.is_none());
797 }
798
799 #[tokio::test]
800 async fn with_event_source_populates_when_binding_present() {
801 let mut c = ctx();
802 c.binding = Some(BindingContext::agent_only("ana"));
803 let meta = nexo_tool_meta::EventSourceMeta {
804 subject: "webhook.github.opened".into(),
805 envelope_id: None,
806 synthesis_mode: "synthesize".into(),
807 };
808 let c = c.with_event_source(meta.clone());
809 let binding = c.binding.expect("binding stays Some");
810 assert_eq!(binding.event_source, Some(meta));
811 }
812
813 #[tokio::test]
814 async fn with_event_source_no_op_when_no_binding_match() {
815 let meta = nexo_tool_meta::EventSourceMeta {
816 subject: "x.y".into(),
817 envelope_id: None,
818 synthesis_mode: "tick".into(),
819 };
820 let c = ctx().with_event_source(meta);
821 assert!(c.binding.is_none());
822 }
823}
824
825#[cfg(test)]
826mod binding_context_tests {
827 use super::BindingContext;
833 use uuid::Uuid;
834
835 #[test]
836 fn agent_only_minimal_context_clears_binding_fields() {
837 let ctx = BindingContext::agent_only("ana");
838 assert_eq!(ctx.agent_id, "ana");
839 assert!(ctx.session_id.is_none());
840 assert!(ctx.channel.is_none());
841 assert!(ctx.account_id.is_none());
842 assert!(ctx.binding_id.is_none());
843 assert!(ctx.mcp_channel_source.is_none());
844 }
845
846 #[test]
847 fn render_binding_id_with_account_id_renders_channel_colon_account() {
848 assert_eq!(
849 nexo_tool_meta::binding_id_render("whatsapp", Some("personal")),
850 "whatsapp:personal"
851 );
852 assert_eq!(
853 nexo_tool_meta::binding_id_render("telegram", Some("kate_tg")),
854 "telegram:kate_tg"
855 );
856 }
857
858 #[test]
859 fn render_binding_id_without_account_id_uses_default_sentinel() {
860 assert_eq!(
861 nexo_tool_meta::binding_id_render("whatsapp", None),
862 "whatsapp:default"
863 );
864 }
865
866 fn full_binding(
867 agent: &str,
868 session: Option<Uuid>,
869 channel: Option<&str>,
870 account: Option<&str>,
871 mcp: Option<&str>,
872 ) -> BindingContext {
873 let mut b = BindingContext::agent_only(agent);
874 b.session_id = session;
875 if let Some(c) = channel {
876 b.channel = Some(c.into());
877 }
878 if let Some(a) = account {
879 b.account_id = Some(a.into());
880 }
881 if let (Some(c), Some(_)) = (channel, account) {
882 b.binding_id = Some(nexo_tool_meta::binding_id_render(c, account));
883 } else if let Some(c) = channel {
884 b.binding_id = Some(nexo_tool_meta::binding_id_render(c, None));
885 }
886 if let Some(s) = mcp {
887 b = b.with_mcp_channel_source(s);
888 }
889 b
890 }
891
892 #[test]
893 fn with_mcp_channel_source_sets_field_inline() {
894 let ctx = BindingContext::agent_only("ana").with_mcp_channel_source("slack");
895 assert_eq!(ctx.mcp_channel_source.as_deref(), Some("slack"));
896 assert_eq!(ctx.agent_id, "ana");
897 }
898
899 #[test]
900 fn binding_context_is_clone_eq_serializable() {
901 let ctx = full_binding(
902 "ana",
903 Some(Uuid::nil()),
904 Some("whatsapp"),
905 Some("personal"),
906 Some("slack"),
907 );
908 let cloned = ctx.clone();
909 assert_eq!(ctx, cloned);
910 let json = serde_json::to_value(&ctx).unwrap();
911 assert_eq!(json["agent_id"], "ana");
912 assert_eq!(json["channel"], "whatsapp");
913 assert_eq!(json["account_id"], "personal");
914 assert_eq!(json["binding_id"], "whatsapp:personal");
915 assert_eq!(json["mcp_channel_source"], "slack");
916 }
917
918 #[test]
919 fn binding_context_skips_serializing_none_fields() {
920 let ctx = BindingContext::agent_only("ana");
921 let json = serde_json::to_value(&ctx).unwrap();
922 let obj = json.as_object().expect("expected object");
923 assert!(obj.contains_key("agent_id"));
924 assert!(!obj.contains_key("session_id"));
926 assert!(!obj.contains_key("channel"));
927 assert!(!obj.contains_key("account_id"));
928 assert!(!obj.contains_key("binding_id"));
929 assert!(!obj.contains_key("mcp_channel_source"));
930 }
931
932 #[test]
933 fn binding_context_round_trips_through_serde() {
934 let ctx = full_binding(
935 "carlos",
936 Some(Uuid::from_u128(42)),
937 Some("whatsapp"),
938 Some("business"),
939 None,
940 );
941 let json = serde_json::to_string(&ctx).unwrap();
942 let back: BindingContext = serde_json::from_str(&json).unwrap();
943 assert_eq!(ctx, back);
944 }
945
946 fn mini_agent() -> nexo_config::types::agents::AgentConfig {
949 use nexo_config::types::agents::{
950 AgentConfig, AgentRuntimeConfig, DreamingYamlConfig, HeartbeatConfig, ModelConfig,
951 OutboundAllowlistConfig, WorkspaceGitConfig,
952 };
953 AgentConfig {
954 id: "ana".into(),
955 model: ModelConfig {
956 provider: "anthropic".into(),
957 model: "claude-haiku-4-5".into(),
958 },
959 plugins: Vec::new(),
960 heartbeat: HeartbeatConfig::default(),
961 config: AgentRuntimeConfig::default(),
962 system_prompt: String::new(),
963 workspace: String::new(),
964 skills: Vec::new(),
965 skills_dir: String::new(),
966 skill_overrides: Default::default(),
967 transcripts_dir: String::new(),
968 dreaming: DreamingYamlConfig::default(),
969 workspace_git: WorkspaceGitConfig::default(),
970 tool_rate_limits: None,
971 tool_args_validation: None,
972 extra_docs: Vec::new(),
973 inbound_bindings: Vec::new(),
974 allowed_tools: Vec::new(),
975 sender_rate_limit: None,
976 allowed_delegates: Vec::new(),
977 accept_delegates_from: Vec::new(),
978 description: String::new(),
979 google_auth: None,
980 credentials: Default::default(),
981 link_understanding: serde_json::Value::Null,
982 web_search: serde_json::Value::Null,
983 pairing_policy: serde_json::Value::Null,
984 language: None,
985 outbound_allowlist: OutboundAllowlistConfig::default(),
986 context_optimization: None,
987 dispatch_policy: Default::default(),
988 plan_mode: Default::default(),
989 remote_triggers: Vec::new(),
990 lsp: nexo_config::types::lsp::LspPolicy::default(),
991 config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
992 team: nexo_config::types::team::TeamPolicy::default(),
993 proactive: Default::default(),
994 repl: Default::default(),
995 auto_dream: None,
996 assistant_mode: None,
997 away_summary: None,
998 brief: None,
999 channels: None,
1000 auto_approve: false,
1001 extract_memories: None,
1002 event_subscribers: Vec::new(),
1003 tenant_id: None,
1004 extensions_config: std::collections::BTreeMap::new(),
1005 active: true,
1006 }
1007 }
1008
1009 #[test]
1010 fn from_effective_with_matched_binding_populates_tuple() {
1011 use super::EffectiveBindingPolicy;
1012 use nexo_config::types::agents::InboundBinding;
1013
1014 let mut a = mini_agent();
1015 a.inbound_bindings.push(InboundBinding {
1016 plugin: "whatsapp".into(),
1017 instance: Some("personal".into()),
1018 ..Default::default()
1019 });
1020 let policy = EffectiveBindingPolicy::resolve(&a, 0);
1021 let ctx = super::binding_context_from_effective(&policy, "ana", Some(Uuid::from_u128(1)));
1022
1023 assert_eq!(ctx.agent_id, "ana");
1024 assert_eq!(ctx.session_id, Some(Uuid::from_u128(1)));
1025 assert_eq!(ctx.channel.as_deref(), Some("whatsapp"));
1026 assert_eq!(ctx.account_id.as_deref(), Some("personal"));
1027 assert_eq!(ctx.binding_id.as_deref(), Some("whatsapp:personal"));
1028 assert!(ctx.mcp_channel_source.is_none());
1029 }
1030
1031 #[test]
1032 fn from_effective_with_synthesised_policy_keeps_tuple_none() {
1033 use super::EffectiveBindingPolicy;
1034
1035 let a = mini_agent();
1036 let policy = EffectiveBindingPolicy::from_agent_defaults(&a);
1037 let ctx = super::binding_context_from_effective(&policy, "delegation", None);
1038
1039 assert_eq!(ctx.agent_id, "delegation");
1040 assert!(ctx.session_id.is_none());
1041 assert!(ctx.channel.is_none());
1042 assert!(ctx.account_id.is_none());
1043 assert!(ctx.binding_id.is_none());
1044 assert!(ctx.mcp_channel_source.is_none());
1045 }
1046
1047 #[test]
1048 fn from_effective_chains_with_mcp_channel_source() {
1049 use super::EffectiveBindingPolicy;
1050 use nexo_config::types::agents::InboundBinding;
1051
1052 let mut a = mini_agent();
1053 a.inbound_bindings.push(InboundBinding {
1054 plugin: "telegram".into(),
1055 instance: Some("kate_tg".into()),
1056 ..Default::default()
1057 });
1058 let policy = EffectiveBindingPolicy::resolve(&a, 0);
1059 let ctx = super::binding_context_from_effective(&policy, "ana", None)
1060 .with_mcp_channel_source("slack");
1061
1062 assert_eq!(ctx.channel.as_deref(), Some("telegram"));
1064 assert_eq!(ctx.account_id.as_deref(), Some("kate_tg"));
1065 assert_eq!(ctx.mcp_channel_source.as_deref(), Some("slack"));
1067 }
1068
1069 #[test]
1070 fn from_effective_two_personas_get_distinct_binding_ids() {
1071 use super::EffectiveBindingPolicy;
1072 use nexo_config::types::agents::InboundBinding;
1073
1074 let mut a = mini_agent();
1075 a.inbound_bindings.push(InboundBinding {
1076 plugin: "whatsapp".into(),
1077 instance: Some("personal".into()),
1078 ..Default::default()
1079 });
1080 a.inbound_bindings.push(InboundBinding {
1081 plugin: "whatsapp".into(),
1082 instance: Some("business".into()),
1083 ..Default::default()
1084 });
1085 let p0 = EffectiveBindingPolicy::resolve(&a, 0);
1086 let p1 = EffectiveBindingPolicy::resolve(&a, 1);
1087 let c0 = super::binding_context_from_effective(&p0, "ana", None);
1088 let c1 = super::binding_context_from_effective(&p1, "carlos", None);
1089
1090 assert_eq!(c0.binding_id.as_deref(), Some("whatsapp:personal"));
1091 assert_eq!(c1.binding_id.as_deref(), Some("whatsapp:business"));
1092 assert_ne!(c0.binding_id, c1.binding_id);
1093 }
1094}
1095
1096#[cfg(test)]
1097mod build_meta_value_tests {
1098 use super::{AgentContext, BindingContext};
1104 use crate::session::SessionManager;
1105 use nexo_broker::AnyBroker;
1106 use nexo_config::types::agents::{
1107 AgentConfig, AgentRuntimeConfig, HeartbeatConfig, ModelConfig,
1108 };
1109 use std::sync::Arc;
1110 use std::time::Duration;
1111 use uuid::Uuid;
1112
1113 fn mini_ctx(agent: &str, session: Option<Uuid>) -> AgentContext {
1114 let cfg = Arc::new(AgentConfig {
1115 id: agent.into(),
1116 model: ModelConfig {
1117 provider: "stub".into(),
1118 model: "m1".into(),
1119 },
1120 plugins: Vec::new(),
1121 heartbeat: HeartbeatConfig::default(),
1122 config: AgentRuntimeConfig::default(),
1123 system_prompt: String::new(),
1124 workspace: String::new(),
1125 skills: Vec::new(),
1126 skills_dir: String::new(),
1127 skill_overrides: Default::default(),
1128 transcripts_dir: String::new(),
1129 dreaming: Default::default(),
1130 workspace_git: Default::default(),
1131 tool_rate_limits: None,
1132 tool_args_validation: None,
1133 extra_docs: Vec::new(),
1134 inbound_bindings: Vec::new(),
1135 allowed_tools: Vec::new(),
1136 sender_rate_limit: None,
1137 allowed_delegates: Vec::new(),
1138 accept_delegates_from: Vec::new(),
1139 description: String::new(),
1140 outbound_allowlist: Default::default(),
1141 google_auth: None,
1142 credentials: Default::default(),
1143 link_understanding: serde_json::Value::Null,
1144 web_search: serde_json::Value::Null,
1145 pairing_policy: serde_json::Value::Null,
1146 language: None,
1147 context_optimization: None,
1148 dispatch_policy: Default::default(),
1149 plan_mode: Default::default(),
1150 remote_triggers: Vec::new(),
1151 lsp: nexo_config::types::lsp::LspPolicy::default(),
1152 config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
1153 team: nexo_config::types::team::TeamPolicy::default(),
1154 proactive: Default::default(),
1155 repl: Default::default(),
1156 auto_dream: None,
1157 assistant_mode: None,
1158 away_summary: None,
1159 brief: None,
1160 channels: None,
1161 auto_approve: false,
1162 extract_memories: None,
1163 event_subscribers: Vec::new(),
1164 tenant_id: None,
1165 extensions_config: std::collections::BTreeMap::new(),
1166 active: true,
1167 });
1168 let broker = AnyBroker::local();
1169 let sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
1170 let ctx = AgentContext::new(agent, cfg, broker, sessions);
1171 match session {
1172 Some(id) => ctx.with_session_id(id),
1173 None => ctx,
1174 }
1175 }
1176
1177 #[tokio::test]
1178 async fn meta_without_binding_emits_legacy_block_only() {
1179 let ctx = mini_ctx("delegation", None);
1180 let meta = ctx.build_meta_value();
1181 assert_eq!(meta["agent_id"], "delegation");
1182 assert!(meta["session_id"].is_null());
1183 assert!(meta.get("nexo").is_none());
1184 }
1185
1186 #[tokio::test]
1187 async fn meta_with_binding_emits_dual_namespaces() {
1188 let mut ctx = mini_ctx("ana", Some(Uuid::nil()));
1189 let mut b = BindingContext::agent_only("ana");
1190 b.session_id = Some(Uuid::nil());
1191 b.channel = Some("whatsapp".into());
1192 b.account_id = Some("personal".into());
1193 b.binding_id = Some("whatsapp:personal".into());
1194 ctx.binding = Some(b);
1195 let meta = ctx.build_meta_value();
1196
1197 assert_eq!(meta["agent_id"], "ana");
1199 assert!(meta["session_id"].is_string());
1200
1201 let binding = &meta["nexo"]["binding"];
1203 assert_eq!(binding["agent_id"], "ana");
1204 assert_eq!(binding["channel"], "whatsapp");
1205 assert_eq!(binding["account_id"], "personal");
1206 assert_eq!(binding["binding_id"], "whatsapp:personal");
1207 assert!(binding.get("mcp_channel_source").is_none());
1208 }
1209
1210 #[tokio::test]
1211 async fn meta_session_id_serialises_as_string_when_present() {
1212 let sid = Uuid::from_u128(0x42);
1213 let ctx = mini_ctx("ana", Some(sid));
1214 let meta = ctx.build_meta_value();
1215 assert_eq!(meta["session_id"], sid.to_string());
1216 }
1217}