nexo-microapp-sdk 0.1.18

Reusable runtime helpers for Phase 11 stdio microapps consuming the nexo-rs daemon (JSON-RPC dispatch loop, BindingContext parsing, typed replies).
//! Handler context — what tool / hook handlers see at call time.

use std::sync::Arc;

use nexo_tool_meta::{BindingContext, InboundMessageMeta};
use uuid::Uuid;

#[cfg(feature = "outbound")]
use crate::outbound::OutboundDispatcher;

/// Context passed to every [`crate::ToolHandler`] call.
///
/// Carries the `(agent_id, session_id, binding, inbound)` tuple
/// parsed from the inbound JSON-RPC `_meta` block plus optional
/// helpers gated behind cargo features (`outbound`).
#[derive(Debug, Clone)]
pub struct ToolCtx {
    /// Stable agent identifier (`agents.yaml.<id>`).
    pub agent_id: String,
    /// Active session UUID. `None` for delegation receive,
    /// heartbeat bootstrap, tests.
    pub session_id: Option<Uuid>,
    /// Inbound binding when matched. `None` for paths without a
    /// binding match.
    pub binding: Option<BindingContext>,
    /// Per-turn inbound message metadata (sender id, msg id,
    /// timestamp, …) when the producer populated it. `None` for
    /// legacy producers and for tests that don't inject one.
    pub inbound: Option<InboundMessageMeta>,

    /// Outbound dispatcher — only available with the `outbound`
    /// feature on. Compile-time gate (no runtime
    /// `FeatureDisabled` error path).
    #[cfg(feature = "outbound")]
    pub(crate) outbound: Arc<OutboundDispatcher>,

    // Hold even when feature off so future fields can be added
    // semver-minor; the field is private and unused.
    #[cfg(not(feature = "outbound"))]
    #[allow(dead_code)]
    pub(crate) _outbound_marker: std::marker::PhantomData<Arc<()>>,

    /// Admin RPC client — `Some` when the microapp builder opts
    /// in via [`crate::Microapp::with_admin`]. Tools call admin
    /// RPC through `ctx.admin()`. Only present with the `admin`
    /// cargo feature.
    #[cfg(feature = "admin")]
    pub(crate) admin: Option<Arc<crate::admin::AdminClient>>,
}

impl ToolCtx {
    /// Borrow the parsed [`BindingContext`] when the inbound
    /// matched a binding. Returns `None` for delegation receive,
    /// heartbeat bootstrap, tests.
    pub fn binding(&self) -> Option<&BindingContext> {
        self.binding.as_ref()
    }

    /// Borrow the parsed [`InboundMessageMeta`] when the producer
    /// populated it. Returns `None` for legacy producers that
    /// don't populate it and for tests that didn't inject one.
    pub fn inbound(&self) -> Option<&InboundMessageMeta> {
        self.inbound.as_ref()
    }

    /// Borrow the outbound dispatcher.
    ///
    /// Only available with the `outbound` cargo feature on. Calling
    /// without the feature is a compile error.
    #[cfg(feature = "outbound")]
    pub fn outbound(&self) -> &OutboundDispatcher {
        &self.outbound
    }

    /// Borrow the admin RPC client when one was wired via
    /// [`crate::Microapp::with_admin`]. Returns `None` if the
    /// microapp did not opt in. Only available with the `admin`
    /// cargo feature on.
    #[cfg(feature = "admin")]
    pub fn admin(&self) -> Option<&crate::admin::AdminClient> {
        self.admin.as_deref()
    }
}

/// Context passed to every [`crate::HookHandler`] call.
#[derive(Debug, Clone)]
pub struct HookCtx {
    /// Stable agent identifier.
    pub agent_id: String,
    /// Inbound binding when matched. `None` for paths without a
    /// binding match.
    pub binding: Option<BindingContext>,
    /// Per-turn inbound message metadata when populated by the
    /// producer. Hooks (e.g. `before_message`) read this for
    /// anti-loop / sender-aware decisions before tool dispatch.
    pub inbound: Option<InboundMessageMeta>,
    /// Admin RPC client — `Some` when the microapp builder opts
    /// in via [`crate::Microapp::with_admin`]. Hooks call admin
    /// RPC through `ctx.admin()`. Only present with the `admin`
    /// cargo feature.
    #[cfg(feature = "admin")]
    pub(crate) admin: Option<Arc<crate::admin::AdminClient>>,
}

impl HookCtx {
    /// Borrow the parsed [`InboundMessageMeta`] when the producer
    /// populated it.
    pub fn inbound(&self) -> Option<&InboundMessageMeta> {
        self.inbound.as_ref()
    }

    /// Borrow the admin RPC client when one was wired via
    /// [`crate::Microapp::with_admin`]. Returns `None` if the
    /// microapp did not opt in. Only available with the `admin`
    /// cargo feature on.
    #[cfg(feature = "admin")]
    pub fn admin(&self) -> Option<&crate::admin::AdminClient> {
        self.admin.as_deref()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use nexo_tool_meta::BindingContext;

    fn ctx_with_binding(b: Option<BindingContext>) -> ToolCtx {
        ToolCtx {
            agent_id: "ana".into(),
            session_id: None,
            binding: b,
            inbound: None,
            #[cfg(not(feature = "outbound"))]
            _outbound_marker: std::marker::PhantomData,
            #[cfg(feature = "outbound")]
            outbound: Arc::new(OutboundDispatcher::new_stub()),
            #[cfg(feature = "admin")]
            admin: None,
        }
    }

    #[test]
    fn binding_accessor_returns_some_when_present() {
        let b = BindingContext::agent_only("ana");
        let ctx = ctx_with_binding(Some(b.clone()));
        assert_eq!(ctx.binding(), Some(&b));
    }

    #[test]
    fn binding_accessor_returns_none_when_absent() {
        let ctx = ctx_with_binding(None);
        assert!(ctx.binding().is_none());
    }

    #[test]
    fn inbound_accessor_returns_none_when_absent() {
        let ctx = ctx_with_binding(None);
        assert!(ctx.inbound().is_none());
    }

    #[test]
    fn inbound_accessor_returns_some_when_present() {
        let inbound = nexo_tool_meta::InboundMessageMeta::external_user("+5491100", "wa.X");
        let mut ctx = ctx_with_binding(None);
        ctx.inbound = Some(inbound.clone());
        assert_eq!(ctx.inbound(), Some(&inbound));
    }

    #[test]
    fn hook_ctx_round_trip() {
        let b = BindingContext::agent_only("ana");
        let h = HookCtx {
            agent_id: "ana".into(),
            binding: Some(b.clone()),
            inbound: None,
            #[cfg(feature = "admin")]
            admin: None,
        };
        assert_eq!(h.binding, Some(b));
        assert!(h.inbound().is_none());
    }
}