Skip to main content

nexo_core/agent/
context.rs

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    /// 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    /// MCP runtime scoped to this session (if MCP is enabled).
34    pub mcp: Option<Arc<SessionMcpRuntime>>,
35    /// 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    /// Resolver that maps this agent's id to the opaque credential
54    /// 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    /// 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    /// 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 95 — web_search_router field removed. The `web_search`
74    // tool now lives in the `nexo-rs-plugin-web-search` subprocess;
75    // RemoteToolHandler routes `tool.invoke` over stdio.
76    /// Current effective enables for the four context-optimization
77    /// mechanisms (hot-reloadable). Set per-event by
78    /// `AgentRuntime` from `RuntimeSnapshot::context_optimization`, so
79    /// a config reload that flips a flag is observed on the *next*
80    /// turn without restarting the behavior. `None` for legacy /
81    /// test contexts that haven't been wired through the snapshot —
82    /// in that case `llm_behavior` falls back to the boot-time
83    /// `prompt_cache_enabled` / `compaction_runtime.enabled` flags.
84    pub context_optimization: Option<nexo_config::types::llm::ResolvedContextOptimization>,
85    /// Agent event emitter threaded from the
86    /// `AgentRuntime` so `llm_behavior` can attach it to
87    /// per-turn `TranscriptWriter` instances. Without this,
88    /// transcript appends emit through the default
89    /// `NoopAgentEventEmitter` and never reach the bootstrap's
90    /// broadcast firehose, leaving subscribers (microapps with
91    /// `agent_events_subscribe_all`) silent on live updates.
92    /// `None` for test/bootstrap contexts; consumers fall back
93    /// to no-op emission in that case.
94    pub event_emitter: Option<Arc<dyn AgentEventEmitter>>,
95    /// Bundle of services consumed by the dispatch tool
96    /// handlers (program_phase, list_agents, etc.). Populated at
97    /// boot when the project tracker is enabled. `None` keeps the
98    /// dispatch tools off — handlers return a friendly error so
99    /// the LLM doesn't pretend they worked.
100    pub dispatch: Option<Arc<super::dispatch_handlers::DispatchToolContext>>,
101    /// REPL session registry. `Some` when `repl-tool`
102    /// feature is enabled AND the binding config has `repl.enabled`.
103    /// Holds persistent Python/Node/bash subprocesses.
104    pub repl_registry: Option<Arc<super::repl_registry::ReplRegistry>>,
105    /// Sender's pairing-trust bit, set by intake after the
106    /// pairing gate runs. Defaults to `false` so any path that
107    /// forgets to thread it through fails closed under
108    /// `require_trusted=true`. Read-only tools bypass this gate.
109    pub sender_trusted: bool,
110    /// `(plugin, instance, sender_id)` of the inbound event
111    /// that produced this turn, when the runtime matched a binding.
112    /// Lets the dispatch handler synthesise an `OriginChannel` for
113    /// `program_phase` so `notify_origin` lands back in the chat.
114    pub inbound_origin: Option<(String, String, String)>,
115    /// Plan-mode state for this goal. Shared across the
116    /// dispatcher (read on every tool call) and the EnterPlanMode /
117    /// ExitPlanMode tools (write). SQLite is canonical (column on
118    /// `agent_registry.goals.plan_mode`); this is a hot cache. New
119    /// contexts default to `Off`; the runtime hydrates the value from
120    /// the registry at goal spawn / reattach.
121    pub plan_mode: Arc<RwLock<PlanModeState>>,
122    /// Process-shared registry of pending plan-mode approvals.
123    /// `EnterPlanMode` does not touch it; `ExitPlanMode`
124    /// installs a waiter when `plan_mode.require_approval` is on; the
125    /// `plan_mode_resolve` operator tool fires the matching waiter.
126    /// Tests construct their own registry to avoid cross-test races.
127    pub plan_approval_registry: Arc<crate::agent::plan_mode_tool::PlanApprovalRegistry>,
128    /// Intra-turn scratch todo list. Owned by the model
129    /// (mutated via `TodoWrite`). Distinct from TaskFlow:
130    /// Todo is in-memory + per-goal + flat; TaskFlow is persistent
131    /// + cross-session + DAG. Reattach does not restore todos —
132    /// they die with the goal because re-deriving them mid-turn is
133    /// cheap and stale items are confusing.
134    pub todos: Arc<RwLock<TodoList>>,
135    /// When set, this goal is running as a member of a named team.
136    /// The lead's `team_id` is its own team's id; ordinary
137    /// sub-agents stay `None`.
138    pub team_id: Option<String>,
139    /// Human-readable member name within `team_id` (e.g.
140    /// `"researcher"`). `None` ⇔ `team_id.is_none()`.
141    /// `Some(TEAM_LEAD_NAME)` for the lead's own goal.
142    pub team_member_name: Option<String>,
143    /// DMs the team router delivered while this goal was running.
144    /// Consumed at the start of each turn by the prompt-assembly
145    /// path. Concurrent appends are serialised by the goal's tokio
146    /// task scheduler — there is no inner lock because the consume
147    /// is single-threaded per-goal.
148    pub inbox: Arc<RwLock<Vec<DmMessage>>>,
149    /// Whether this goal runs in proactive tick-loop mode.
150    /// Set at goal spawn from `EffectiveBindingPolicy::proactive().enabled`.
151    /// Read by `llm_behavior` to inject the proactive system hint.
152    pub proactive_enabled: bool,
153    /// Binding role tag (`"coordinator"`, `"worker"`, `"proactive"`,
154    /// or `None`). Stored here so `llm_behavior` can inject the coordinator
155    /// hint without re-reading the binding config on every turn.
156    pub binding_role: Option<String>,
157    /// Boot-resolved assistant-mode view. Read by downstream
158    /// consumers (driver-loop tick generator, cron default flip,
159    /// brief mode auto-on, dream-context kairos signal,
160    /// remote-control auto-tier). The `enabled` flag is
161    /// boot-immutable; the addendum text inside it can be
162    /// hot-reloaded. `Default::default()` is the zero-cost
163    /// disabled view — fixtures and bootstrap contexts can rely on
164    /// it without opting in.
165    #[doc(hidden)]
166    pub assistant: nexo_assistant::ResolvedAssistant,
167    /// Composed binding context propagated to tool calls via
168    /// `_meta.nexo.binding`. `Some` when intake matched an
169    /// `InboundBinding`; `None` for bindingless paths (delegation
170    /// receive, heartbeat bootstrap, tests).
171    ///
172    /// Construct via `super::binding_context_from_effective(&policy,
173    /// agent_id, session_id)` at the intake site that matches the
174    /// binding. Tool dispatch reads this to populate the JSON-RPC
175    /// `params._meta` block.
176    pub binding: Option<BindingContext>,
177
178    /// Per-turn metadata about the inbound message
179    /// that triggered this agent turn (sender id, msg id,
180    /// timestamp, …). `Some` when the intake site populated it
181    /// (whatsapp plugin, event-subscriber binding, webhook
182    /// receiver, delegation receive, heartbeat tick, …); `None`
183    /// for legacy producers not yet migrated and for tests.
184    /// Surfaces under `_meta.nexo.inbound` via
185    /// [`AgentContext::build_meta_value`].
186    pub inbound: Option<InboundMessageMeta>,
187}
188
189/// One inbound team message attached to a goal's `AgentContext.inbox`.
190/// Mirror of [`crate::team_message_router::DmFrame`] minus the wire
191/// fields the call site already knows (`team_id`, `to`).
192#[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
200/// Binding context propagated to tool calls so extensions and MCP
201/// servers can route per-(channel, account_id,
202/// agent_id) tuple without re-deriving it from each tool call's
203/// payload.
204///
205/// Serialised under `_meta.nexo.binding` in JSON-RPC `tools/call`
206/// (extensions ignore unknown fields) and as the `meta` block of
207/// MCP `call_tool_with_meta`.
208///
209/// `agent_id` is mandatory; the rest are `Option` because some
210/// dispatch paths (delegation receive, heartbeat bootstrap, tests)
211/// have no binding match — `None` is the correct state, not a
212/// sentinel string.
213///
214/// `mcp_channel_source` is populated when the inbound that
215/// triggered this turn arrived via an MCP channel server (e.g.,
216/// `"slack"`, `"telegram"`). Lets a tool distinguish
217/// "telegram-binding answered via MCP slack server" from
218/// "telegram-binding answered via native Telegram plugin" while
219/// still seeing the same `(channel, account_id)` binding tuple.
220/// Matches the `goal_turns.source = "channel:slack"` audit column.
221// `BindingContext` lives in the standalone `nexo-tool-meta` crate
222// so third-party microapps can `cargo add nexo-tool-meta` without
223// pulling the agent runtime. Re-exported here for backward compat
224// with internal callers.
225pub use nexo_tool_meta::{BindingContext, InboundKind, InboundMessageMeta};
226
227/// Construct a [`BindingContext`] from an already-resolved
228/// binding policy + agent / session identity.
229///
230/// Lives here (not on `BindingContext` itself) because it depends
231/// on [`EffectiveBindingPolicy`], which is internal to the agent
232/// runtime. Microapps never construct a `BindingContext` — they
233/// receive one wire-encoded under `_meta.nexo.binding` and parse
234/// via `nexo_tool_meta::parse_binding_from_meta`.
235///
236/// When the policy has no `binding_index` (synthesised by
237/// [`EffectiveBindingPolicy::from_agent_defaults`] for
238/// delegation / heartbeat / tests), the `(channel, account_id,
239/// binding_id)` tuple stays `None`. Only `agent_id` + `session_id`
240/// carry through.
241///
242/// `mcp_channel_source` is propagated separately by the intake
243/// site that received an MCP-channel inbound. This fn never
244/// infers it from the policy alone; callers chain
245/// `.with_mcp_channel_source(s)` when applicable.
246pub 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    // Surface the resolved binding > agent locale on the wire so
259    // the SDK's
260    // STT inbound transform handler can read it from
261    // `ctx.binding.language` and pass it as a whisper hint
262    // (BCP-47 trimmed to ISO-639-1 inside the handler).
263    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            // Phase 95 — web_search_router removed.
291            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            // `None` is the default for `AgentContext::new`. Intake
309            // sites that match an inbound to an `InboundBinding`
310            // populate this via
311            // `super::binding_context_from_effective(&policy, agent_id,
312            // session_id)`. Bindingless paths (delegation receive,
313            // heartbeat bootstrap, tests) keep `None`.
314            binding: None,
315            // Populated by the intake site that
316            // produced the turn (whatsapp plugin, event-subscriber,
317            // webhook receiver, delegation, heartbeat). `None`
318            // from the bare constructor; producers layer their
319            // meta after `new()`.
320            inbound: None,
321        }
322    }
323
324    /// Mark this context as running as a teammate.
325    /// `name` is the human-readable handle within the team
326    /// (`"researcher"`, `"tester"`, or `TEAM_LEAD_NAME`).
327    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    /// `true` when both `team_id` and
334    /// `team_member_name` are set. The runtime's
335    /// teammate-cannot-spawn-teammate guard inspects this.
336    pub fn is_teammate(&self) -> bool {
337        self.team_id.is_some() && self.team_member_name.is_some()
338    }
339
340    /// Install a pre-built plan-mode handle. Used at
341    /// goal hydration so the runtime can share the same `Arc<RwLock>`
342    /// between the dispatcher (gate) and the registry mirror (write
343    /// path).
344    pub fn with_plan_mode(mut self, state: Arc<RwLock<PlanModeState>>) -> Self {
345        self.plan_mode = state;
346        self
347    }
348
349    /// Install a process-shared plan-mode approval registry.
350    /// Production wiring constructs one per process and
351    /// hands it to every `AgentContext`; tests build their own to
352    /// avoid cross-test races.
353    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    /// `true` when this goal is rooted in a live channel
362    /// that can deliver an operator approval message. Sub-agent goals
363    /// (delegations, future TeamCreate workers), cron / poller /
364    /// heartbeat-spawned goals, and bootstrap contexts all return
365    /// `false` because they have no inbound channel through which an
366    /// operator could approve a plan.
367    ///
368    /// Reference: `research/src/acp/session-interaction-mode.ts:4-15`
369    /// — same intent, "interactive" vs "parent-owned-background".
370    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    /// Install per-turn [`InboundMessageMeta`] on the context.
390    /// Producers (channel plugins, event-subscriber,
391    /// delegation, heartbeat) build the meta at the intake site and
392    /// the per-turn dispatch loop layers it on the cloned context
393    /// before invoking tools / hooks.
394    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    // Phase 95 — with_web_search_router builder removed alongside
404    // the field. The standalone subprocess plugin owns the router
405    // now.
406    /// Set the per-turn context-optimization snapshot. Called by the
407    /// agent runtime intake after loading the active `RuntimeSnapshot`,
408    /// so a hot-reload that swaps the snapshot is observed without
409    /// rebuilding the behavior.
410    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    /// Install the firehose emitter so per-turn
422    /// `TranscriptWriter` instances built in `llm_behavior` can
423    /// chain `.with_emitter()` and broadcast `TranscriptAppended`
424    /// to subscribers.
425    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        // Populate the BindingContext as a side effect of
464        // installing the policy. Every intake path that resolves
465        // an inbound to an `InboundBinding` funnels through
466        // `with_effective`, so this single call site is sufficient
467        // — no need to chase N intake-side call paths individually.
468        // Bindingless paths (delegation receive / heartbeat
469        // bootstrap / tests) never call `with_effective` and
470        // therefore keep `binding == None`. `mcp_channel_source`
471        // stays None here; it is layered on top by the
472        // channel-aware intake site that received the MCP-channel
473        // inbound (`with_mcp_channel_source` chained after).
474        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    /// Layer the MCP channel source on top of the BindingContext
484    /// after `with_effective` has run. No-op if `binding` is `None`
485    /// (paths without a binding match cannot have an
486    /// MCP-channel source — the source rides alongside an
487    /// already-matched binding, not as a substitute).
488    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    /// Populate `binding.event_source` when the inbound was
500    /// synthesised from a NATS event subscriber.
501    /// No-op when `self.binding` is `None`; logged at debug level
502    /// so the call-site can stay branchless if the caller doesn't
503    /// want to gate the call. Caller is expected to gate at the
504    /// call site for hot paths (every native-channel inbound
505    /// passing through the resolver).
506    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    /// Returns the active effective policy, synthesising one from the
526    /// agent-level config when no binding was matched. Cheap to call in
527    /// hot paths: returns an existing `Arc` when available and builds a
528    /// fresh one only for unbound contexts.
529    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    /// Single source of truth for the `_meta` payload exposed to
537    /// extension tools (stdio JSON-RPC) and MCP tools (`tools/call`
538    /// `params._meta`). Both surfaces must emit identical wire
539    /// shapes so a microapp speaks the same dialect regardless of
540    /// which transport delivered the call.
541    ///
542    /// Returned value is a JSON object with two layers:
543    /// - flat `agent_id` + `session_id` for backward-compat with
544    ///   older consumers,
545    /// - nested `nexo.binding` carrying `BindingContext` when the
546    ///   intake matched a binding (omitted otherwise to keep the
547    ///   wire compact for delegation receive / heartbeat
548    ///   bootstrap / tests).
549    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        // Mutating the shared handle is observed via the context
669        // — proves the Arc was wired through, not cloned-by-value.
670        {
671            let mut g = shared.write().await;
672            *g = PlanModeState::Off;
673        }
674        assert!(c.plan_mode.read().await.is_off());
675    }
676
677    // -----------------------------------------------------------
678    // team fields
679    // -----------------------------------------------------------
680
681    #[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        // Inbox is `Arc<RwLock<Vec<DmMessage>>>` so two refs
714        // to the same context share the queue.
715        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    // -----------------------------------------------------------
727    // `with_effective` populates `binding`
728    // -----------------------------------------------------------
729
730    #[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"); // ctx() helper uses agent id "a"
751        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        // Native binding tuple from policy
774        assert_eq!(b.channel.as_deref(), Some("telegram"));
775        assert_eq!(b.account_id.as_deref(), Some("kate_tg"));
776        // MCP source layered on top
777        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        // No `with_effective` called → binding stays None →
783        // `with_mcp_channel_source` is a no-op (mcp_channel_source
784        // rides alongside an already-matched binding, never as a
785        // substitute).
786        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    //! `BindingContext` struct + standalone helpers.
819
820    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        // None fields skipped per #[serde(skip_serializing_if = "Option::is_none")]
913        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    // -- from_effective constructor --
935
936    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        // Native binding tuple stays from policy.
1052        assert_eq!(ctx.channel.as_deref(), Some("telegram"));
1053        assert_eq!(ctx.account_id.as_deref(), Some("kate_tg"));
1054        // MCP source layered on top.
1055        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    //! `AgentContext::build_meta_value` is the single source of
1088    //! truth for the `_meta` shape sent over both stdio and MCP
1089    //! `tools/call`. These tests lock down the dual-write contract
1090    //! so a refactor that breaks either surface fails here first.
1091    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        // Legacy flat block intact (backward-compat).
1187        assert_eq!(meta["agent_id"], "ana");
1188        assert!(meta["session_id"].is_string());
1189
1190        // Nested binding block.
1191        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}