Skip to main content

nexo_core/agent/
context.rs

1#![allow(clippy::all)] // Phase 79 scaffolding — re-enable when 79.x fully shipped
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    /// Snapshot of peer agents running in this process. Feeds the
29    /// auto-generated `# PEERS` system-prompt block so the LLM knows
30    /// which ids to pass to `delegate(...)`. `None` in test/bootstrap
31    /// contexts where peer discovery doesn't apply.
32    pub peers: Option<Arc<PeerDirectory>>,
33    /// Phase 12.4 — MCP runtime scoped to this session (if MCP is enabled).
34    pub mcp: Option<Arc<SessionMcpRuntime>>,
35    /// Phase 11.5 follow-up — active session id when the context is built
36    /// inside an LLM turn. None for contexts built outside the loop
37    /// (heartbeat bootstrap, tests). Used by tool handlers that opt into
38    /// context passthrough.
39    pub session_id: Option<Uuid>,
40    /// Per-binding capability snapshot resolved at intake. `Some` when the
41    /// runtime matched the inbound event to an `InboundBinding` for this
42    /// agent; `None` for paths without a binding match (delegation
43    /// receive, heartbeat, tests). Use [`AgentContext::effective_policy`]
44    /// to access a policy that always has a value — it synthesises one
45    /// from the agent-level config when `effective` is `None`.
46    pub effective: Option<Arc<EffectiveBindingPolicy>>,
47    /// Per-binding tool registry — shares handlers with the agent's base
48    /// registry but only exposes tools that survive the binding's
49    /// `allowed_tools` filter. `None` on code paths without a binding
50    /// match (delegation receive, heartbeat, tests); consumers fall
51    /// back to the behavior's base registry in that case.
52    pub effective_tools: Option<Arc<ToolRegistry>>,
53    /// Phase 17 — resolver that maps this agent's id to the opaque
54    /// credential handles it is allowed to use for outbound traffic.
55    /// `None` in early-boot / test contexts; consumers must treat that
56    /// as "no credentials configured" (tools return an unbound error
57    /// rather than publishing from an arbitrary account).
58    pub credentials: Option<Arc<nexo_auth::AgentCredentialResolver>>,
59    /// Phase 17 — per-(channel, instance) breaker registry shared by
60    /// plugin outbound tools. `None` for runtimes without credentials.
61    pub breakers: Option<Arc<nexo_auth::BreakerRegistry>>,
62    /// Pre-persistence redactor for transcript content. `None` in
63    /// test/bootstrap contexts → behavior keeps content untouched.
64    pub redactor: Option<Arc<Redactor>>,
65    /// FTS5 index over transcript content. `None` when the subsystem
66    /// is disabled or initialization failed; consumers fall back to
67    /// JSONL-only persistence + substring scan.
68    pub transcripts_index: Option<Arc<TranscriptsIndex>>,
69    /// Phase 21 — shared link extractor (HTTP client + LRU cache).
70    /// `None` in early-boot / test contexts; llm_behavior treats
71    /// that as "link understanding disabled regardless of config".
72    pub link_extractor: Option<Arc<crate::link_understanding::LinkExtractor>>,
73    /// Phase 25 — shared multi-provider web-search router. `None`
74    /// when no provider is configured for this process; the
75    /// `web_search` tool errors out cleanly in that case.
76    pub web_search_router: Option<Arc<nexo_web_search::WebSearchRouter>>,
77    /// Phase F follow-up (hot-reload) — current effective enables for
78    /// the four context-optimization mechanisms. Set per-event by
79    /// `AgentRuntime` from `RuntimeSnapshot::context_optimization`, so
80    /// a config reload that flips a flag is observed on the *next*
81    /// turn without restarting the behavior. `None` for legacy /
82    /// test contexts that haven't been wired through the snapshot —
83    /// in that case `llm_behavior` falls back to the boot-time
84    /// `prompt_cache_enabled` / `compaction_runtime.enabled` flags.
85    pub context_optimization: Option<nexo_config::types::llm::ResolvedContextOptimization>,
86    /// Phase 82.11.c — agent event emitter threaded from the
87    /// `AgentRuntime` so `llm_behavior` can attach it to
88    /// per-turn `TranscriptWriter` instances. Without this,
89    /// transcript appends emit through the default
90    /// `NoopAgentEventEmitter` and never reach the bootstrap's
91    /// broadcast firehose, leaving subscribers (microapps with
92    /// `agent_events_subscribe_all`) silent on live updates.
93    /// `None` for test/bootstrap contexts; consumers fall back
94    /// to no-op emission in that case.
95    pub event_emitter: Option<Arc<dyn AgentEventEmitter>>,
96    /// PT-1 — bundle of services consumed by the dispatch tool
97    /// handlers (program_phase, list_agents, etc.). Populated at
98    /// boot when the project tracker is enabled. `None` keeps the
99    /// dispatch tools off — handlers return a friendly error so
100    /// the LLM doesn't pretend they worked.
101    pub dispatch: Option<Arc<super::dispatch_handlers::DispatchToolContext>>,
102    /// Phase 79.12 — REPL session registry. `Some` when `repl-tool`
103    /// feature is enabled AND the binding config has `repl.enabled`.
104    /// Holds persistent Python/Node/bash subprocesses.
105    pub repl_registry: Option<Arc<super::repl_registry::ReplRegistry>>,
106    /// B3 — sender's pairing-trust bit, set by intake after the
107    /// pairing gate runs (Phase 26). Defaults to `false` so any
108    /// path that forgets to thread it through fails closed under
109    /// `require_trusted=true`. Read-only tools bypass this gate.
110    pub sender_trusted: bool,
111    /// B3 — `(plugin, instance, sender_id)` of the inbound event
112    /// that produced this turn, when the runtime matched a binding.
113    /// Lets the dispatch handler synthesise an `OriginChannel` for
114    /// `program_phase` so `notify_origin` lands back in the chat.
115    pub inbound_origin: Option<(String, String, String)>,
116    /// Phase 79.1 — plan-mode state for this goal. Shared across the
117    /// dispatcher (read on every tool call) and the EnterPlanMode /
118    /// ExitPlanMode tools (write). SQLite is canonical (column on
119    /// `agent_registry.goals.plan_mode`); this is a hot cache. New
120    /// contexts default to `Off`; the runtime hydrates the value from
121    /// the registry at goal spawn / reattach (Phase 71).
122    pub plan_mode: Arc<RwLock<PlanModeState>>,
123    /// Phase 79.1 — process-shared registry of pending plan-mode
124    /// approvals. `EnterPlanMode` does not touch it; `ExitPlanMode`
125    /// installs a waiter when `plan_mode.require_approval` is on; the
126    /// `plan_mode_resolve` operator tool fires the matching waiter.
127    /// Tests construct their own registry to avoid cross-test races.
128    pub plan_approval_registry: Arc<crate::agent::plan_mode_tool::PlanApprovalRegistry>,
129    /// Phase 79.4 — intra-turn scratch todo list. Owned by the model
130    /// (mutated via `TodoWrite`). Distinct from Phase 14 TaskFlow:
131    /// Todo is in-memory + per-goal + flat; TaskFlow is persistent
132    /// + cross-session + DAG. Reattach does not restore todos —
133    /// they die with the goal because re-deriving them mid-turn is
134    /// cheap and stale items are confusing.
135    pub todos: Arc<RwLock<TodoList>>,
136    /// Phase 79.6 — when set, this goal is running as a member
137    /// of a named team. The lead's `team_id` is its own team's
138    /// id; ordinary sub-agents stay `None`.
139    pub team_id: Option<String>,
140    /// Phase 79.6 — human-readable member name within
141    /// `team_id` (e.g. `"researcher"`). `None` ⇔ `team_id.is_none()`.
142    /// `Some(TEAM_LEAD_NAME)` for the lead's own goal.
143    pub team_member_name: Option<String>,
144    /// Phase 79.6 — DMs the team router delivered while this
145    /// goal was running. Consumed at the start of each turn by
146    /// the prompt-assembly path. Concurrent appends are
147    /// serialised by the goal's tokio task scheduler — there is
148    /// no inner lock because the consume is single-threaded
149    /// per-goal.
150    pub inbox: Arc<RwLock<Vec<DmMessage>>>,
151    /// Phase 77.20 — whether this goal runs in proactive tick-loop mode.
152    /// Set at goal spawn from `EffectiveBindingPolicy::proactive().enabled`.
153    /// Read by `llm_behavior` to inject the proactive system hint.
154    pub proactive_enabled: bool,
155    /// Phase 77.20 — binding role tag (`"coordinator"`, `"worker"`, `"proactive"`,
156    /// or `None`). Stored here so `llm_behavior` can inject the coordinator
157    /// hint without re-reading the binding config on every turn.
158    pub binding_role: Option<String>,
159    /// Phase 80.15 — boot-resolved assistant-mode view. Read by
160    /// downstream consumers (driver-loop tick generator, cron default
161    /// flip, brief mode auto-on, dream-context kairos signal,
162    /// remote-control auto-tier in Phase 80.17). The `enabled` flag
163    /// is boot-immutable; the addendum text inside it can be
164    /// hot-reloaded through the Phase 18 path. `Default::default()`
165    /// is the zero-cost disabled view — fixtures and bootstrap
166    /// contexts can rely on it without opting in.
167    #[doc(hidden)]
168    pub assistant: nexo_assistant::ResolvedAssistant,
169    /// Phase 82.1 Step 3 — composed binding context propagated
170    /// to tool calls via `_meta.nexo.binding`. `Some` when
171    /// intake matched an `InboundBinding`; `None` for
172    /// bindingless paths (delegation receive, heartbeat
173    /// bootstrap, tests).
174    ///
175    /// Construct via `super::binding_context_from_effective(&policy,
176    /// agent_id, session_id)` at the intake site that matches
177    /// the binding (Step 4). Tool dispatch reads this through
178    /// `inject_context_meta` to populate the JSON-RPC
179    /// `params._nexo_context` block (Step 5).
180    pub binding: Option<BindingContext>,
181
182    /// Phase 82.5 — per-turn metadata about the inbound message
183    /// that triggered this agent turn (sender id, msg id,
184    /// timestamp, …). `Some` when the intake site populated it
185    /// (whatsapp plugin, event-subscriber binding, webhook
186    /// receiver, delegation receive, heartbeat tick, …); `None`
187    /// for legacy producers not yet migrated and for tests.
188    /// Surfaces under `_meta.nexo.inbound` via
189    /// [`AgentContext::build_meta_value`].
190    pub inbound: Option<InboundMessageMeta>,
191}
192
193/// One inbound team message attached to a goal's `AgentContext.inbox`.
194/// Mirror of [`crate::team_message_router::DmFrame`] minus the wire
195/// fields the call site already knows (`team_id`, `to`).
196#[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
204/// Phase 82.1 — binding context propagated to tool calls so
205/// extensions and MCP servers can route per-(channel, account_id,
206/// agent_id) tuple without re-deriving it from each tool call's
207/// payload.
208///
209/// Serialised under `_meta.nexo.binding` in JSON-RPC `tools/call`
210/// (extensions ignore unknown fields) and as the `meta` block of
211/// MCP `call_tool_with_meta`.
212///
213/// `agent_id` is mandatory; the rest are `Option` because some
214/// dispatch paths (delegation receive, heartbeat bootstrap, tests)
215/// have no binding match — `None` is the correct state, not a
216/// sentinel string.
217///
218/// `mcp_channel_source` is populated when the inbound that
219/// triggered this turn arrived via a Phase 80.9 MCP channel
220/// server (e.g., `"slack"`, `"telegram"`). Lets a tool
221/// distinguish "telegram-binding answered via MCP slack server"
222/// from "telegram-binding answered via native Telegram plugin"
223/// while still seeing the same `(channel, account_id)` binding
224/// tuple. Matches the `goal_turns.source = "channel:slack"`
225/// audit column shipped with Phase 80.9.
226// Phase 82.2.b — `BindingContext` lives in the standalone
227// `nexo-tool-meta` crate so third-party microapps can `cargo add
228// nexo-tool-meta` without pulling the agent runtime. Re-exported
229// here for backward compat with internal callers.
230pub use nexo_tool_meta::{BindingContext, InboundKind, InboundMessageMeta};
231
232/// Construct a [`BindingContext`] from an already-resolved
233/// Phase 16 binding policy + agent / session identity.
234///
235/// Lives here (not on `BindingContext` itself) because it depends
236/// on [`EffectiveBindingPolicy`], which is internal to the agent
237/// runtime. Microapps never construct a `BindingContext` — they
238/// receive one wire-encoded under `_meta.nexo.binding` and parse
239/// via `nexo_tool_meta::parse_binding_from_meta`.
240///
241/// When the policy has no `binding_index` (synthesised by
242/// [`EffectiveBindingPolicy::from_agent_defaults`] for
243/// delegation / heartbeat / tests), the `(channel, account_id,
244/// binding_id)` tuple stays `None`. Only `agent_id` + `session_id`
245/// carry through.
246///
247/// `mcp_channel_source` is propagated separately by the intake
248/// site that received a Phase 80.9 MCP-channel inbound. This fn
249/// never infers it from the policy alone; callers chain
250/// `.with_mcp_channel_source(s)` when applicable.
251pub 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    // Phase 81.19.b locale follow-up item 6 — surface the
264    // resolved binding > agent locale on the wire so the SDK's
265    // STT inbound transform handler can read it from
266    // `ctx.binding.language` and pass it as a whisper hint
267    // (BCP-47 trimmed to ISO-639-1 inside the handler).
268    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            // Phase 82.1 Step 3 — `None` is the default for
314            // `AgentContext::new`. Intake sites that match an
315            // inbound to an `InboundBinding` populate this via
316            // `super::binding_context_from_effective(&policy, agent_id,
317            // session_id)` (Step 4). Bindingless paths
318            // (delegation receive, heartbeat bootstrap, tests)
319            // keep `None`.
320            binding: None,
321            // Phase 82.5 — populated by the intake site that
322            // produced the turn (whatsapp plugin, event-subscriber,
323            // webhook receiver, delegation, heartbeat). `None`
324            // from the bare constructor; producers layer their
325            // meta after `new()`.
326            inbound: None,
327        }
328    }
329
330    /// Phase 79.6 — mark this context as running as a teammate.
331    /// `name` is the human-readable handle within the team
332    /// (`"researcher"`, `"tester"`, or `TEAM_LEAD_NAME`).
333    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    /// Phase 79.6 — `true` when both `team_id` and
340    /// `team_member_name` are set. The runtime's
341    /// teammate-cannot-spawn-teammate guard inspects this.
342    pub fn is_teammate(&self) -> bool {
343        self.team_id.is_some() && self.team_member_name.is_some()
344    }
345
346    /// Phase 79.1 — install a pre-built plan-mode handle. Used at
347    /// goal hydration so the runtime can share the same `Arc<RwLock>`
348    /// between the dispatcher (gate) and the registry mirror (write
349    /// path).
350    pub fn with_plan_mode(mut self, state: Arc<RwLock<PlanModeState>>) -> Self {
351        self.plan_mode = state;
352        self
353    }
354
355    /// Phase 79.1 — install a process-shared plan-mode approval
356    /// registry. Production wiring constructs one per process and
357    /// hands it to every `AgentContext`; tests build their own to
358    /// avoid cross-test races.
359    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    /// Phase 79.1 — `true` when this goal is rooted in a live channel
368    /// that can deliver an operator approval message. Sub-agent goals
369    /// (delegations, future TeamCreate workers), cron / poller /
370    /// heartbeat-spawned goals, and bootstrap contexts all return
371    /// `false` because they have no inbound channel through which an
372    /// operator could approve a plan.
373    ///
374    /// Reference: `research/src/acp/session-interaction-mode.ts:4-15`
375    /// — same intent, "interactive" vs "parent-owned-background".
376    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    /// Phase 82.5 — install per-turn [`InboundMessageMeta`] on the
396    /// context. Producers (channel plugins, event-subscriber,
397    /// delegation, heartbeat) build the meta at the intake site and
398    /// the per-turn dispatch loop layers it on the cloned context
399    /// before invoking tools / hooks.
400    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    /// Set the per-turn context-optimization snapshot. Called by the
414    /// agent runtime intake after loading the active `RuntimeSnapshot`,
415    /// so a hot-reload that swaps the snapshot is observed without
416    /// rebuilding the behavior.
417    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    /// Phase 82.11.c — install the firehose emitter so per-turn
429    /// `TranscriptWriter` instances built in `llm_behavior` can
430    /// chain `.with_emitter()` and broadcast `TranscriptAppended`
431    /// to subscribers.
432    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        // Phase 82.1 Step 4 — populate the BindingContext as a
471        // side effect of installing the policy. Every intake
472        // path that resolves an inbound to an `InboundBinding`
473        // funnels through `with_effective`, so this single call
474        // site is sufficient — no need to chase N intake-side
475        // call paths individually. Bindingless paths
476        // (delegation receive / heartbeat bootstrap / tests)
477        // never call `with_effective` and therefore keep
478        // `binding == None`. `mcp_channel_source` stays None
479        // here; it is layered on top by the channel-aware
480        // intake site that received the Phase 80.9 MCP-channel
481        // inbound (`with_mcp_channel_source` chained after).
482        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    /// Phase 82.1 Step 4 — layer the Phase 80.9 MCP channel
492    /// source on top of the BindingContext after
493    /// `with_effective` has run. No-op if `binding` is `None`
494    /// (paths without a binding match cannot have an
495    /// MCP-channel source — the source rides alongside an
496    /// already-matched binding, not as a substitute).
497    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    /// Phase 82.4.b.b — populate `binding.event_source` when the
509    /// inbound was synthesised from a NATS event subscriber.
510    /// No-op when `self.binding` is `None`; logged at debug level
511    /// so the call-site can stay branchless if the caller doesn't
512    /// want to gate the call. Caller is expected to gate at the
513    /// call site for hot paths (every native-channel inbound
514    /// passing through the resolver).
515    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    /// Returns the active effective policy, synthesising one from the
535    /// agent-level config when no binding was matched. Cheap to call in
536    /// hot paths: returns an existing `Arc` when available and builds a
537    /// fresh one only for unbound contexts.
538    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    /// Phase 82.1 Step 6 — single source of truth for the `_meta`
546    /// payload exposed to extension tools (Phase 11 stdio JSON-RPC)
547    /// and MCP tools (`tools/call` `params._meta`). Both surfaces
548    /// must emit identical wire shapes so a microapp speaks the
549    /// same dialect regardless of which transport delivered the
550    /// call.
551    ///
552    /// Returned value is a JSON object with two layers:
553    /// - flat `agent_id` + `session_id` for backward-compat with
554    ///   pre-Phase-82 consumers,
555    /// - nested `nexo.binding` carrying `BindingContext` when the
556    ///   intake matched a binding (omitted otherwise to keep the
557    ///   wire compact for delegation receive / heartbeat
558    ///   bootstrap / tests).
559    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        // Mutating the shared handle is observed via the context
678        // — proves the Arc was wired through, not cloned-by-value.
679        {
680            let mut g = shared.write().await;
681            *g = PlanModeState::Off;
682        }
683        assert!(c.plan_mode.read().await.is_off());
684    }
685
686    // -----------------------------------------------------------
687    // Phase 79.6 — team fields
688    // -----------------------------------------------------------
689
690    #[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        // Inbox is `Arc<RwLock<Vec<DmMessage>>>` so two refs
723        // to the same context share the queue.
724        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    // -----------------------------------------------------------
736    // Phase 82.1 Step 4 — `with_effective` populates `binding`
737    // -----------------------------------------------------------
738
739    #[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"); // ctx() helper uses agent id "a"
760        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        // Native binding tuple from policy
783        assert_eq!(b.channel.as_deref(), Some("telegram"));
784        assert_eq!(b.account_id.as_deref(), Some("kate_tg"));
785        // Phase 80.9 source layered on top
786        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        // No `with_effective` called → binding stays None →
792        // `with_mcp_channel_source` is a no-op (mcp_channel_source
793        // rides alongside an already-matched binding, never as a
794        // substitute).
795        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    //! Phase 82.1 Step 1 tests — `BindingContext` struct +
828    //! standalone helpers. `from_effective` (which closes the
829    //! loop with `EffectiveBindingPolicy`) lands at Step 3 once
830    //! Step 2 extends the policy struct.
831
832    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        // None fields skipped per #[serde(skip_serializing_if = "Option::is_none")]
925        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    // -- Phase 82.1 Step 3 — from_effective constructor --
947
948    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        // Native binding tuple stays from policy.
1063        assert_eq!(ctx.channel.as_deref(), Some("telegram"));
1064        assert_eq!(ctx.account_id.as_deref(), Some("kate_tg"));
1065        // MCP source layered on top.
1066        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    //! Phase 82.1 Step 6 — `AgentContext::build_meta_value` is the
1099    //! single source of truth for the `_meta` shape sent over both
1100    //! Phase 11 stdio and Phase 12 MCP `tools/call`. These tests
1101    //! lock down the dual-write contract so a refactor that breaks
1102    //! either surface fails here first.
1103    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        // Legacy flat block intact (backward-compat).
1198        assert_eq!(meta["agent_id"], "ana");
1199        assert!(meta["session_id"].is_string());
1200
1201        // Nested binding block.
1202        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}