monoloop-loop 0.1.2

Minimal extensible Loop: lossless canonical subscription, empty-capable tools
Documentation
//! Session identity helpers for lifecycle envelopes (DECISIONS D-004 / D-044).

use monoloop_contracts::{
    ChannelId, ExchangeId, SessionId, SessionKey, ToolActionId, TransactionId,
};

/// Exchange-scoped tool action id so reused provider call ids stay distinct (§22.6).
pub(crate) fn tool_action_id_for_exchange(
    exchange_id: ExchangeId,
    provider_tool_call_id: &str,
) -> ToolActionId {
    ToolActionId::new(format!("{exchange_id}:{provider_tool_call_id}"))
}

/// Inverse of [`tool_action_id_for_exchange`]: strip `{exchange_id}:` prefix.
///
/// Returns `None` when the action id is not scoped to this exchange.
pub(crate) fn provider_tool_call_id_from_action(
    exchange_id: ExchangeId,
    tool_action_id: &ToolActionId,
) -> Option<&str> {
    let prefix = format!("{exchange_id}:");
    tool_action_id
        .as_str()
        .strip_prefix(&prefix)
        .filter(|s| !s.is_empty())
}

/// Transaction-scoped SessionId for sessionless DirectLlm tool/event envelopes.
///
/// Not an external resume identity (not for `session/load`).
pub(crate) fn transaction_scoped_session_id(transaction_id: TransactionId) -> SessionId {
    SessionId::try_new(format!("tx-{transaction_id}"))
        .or_else(|_| SessionId::try_new("direct"))
        .expect("session id")
}

/// True when `id` is exactly the synthetic id for this `transaction_id` (or legacy `direct`).
///
/// Exact match only — never treat arbitrary `tx-…` external ids as synthetic (LAW 7).
pub(crate) fn is_transaction_scoped_session(id: &SessionId, transaction_id: TransactionId) -> bool {
    let s = id.as_str();
    if s == "direct" {
        return true;
    }
    s == transaction_scoped_session_id(transaction_id).as_str()
}

/// SessionKey for tool results: admitted session when present, else transaction-scoped.
pub(crate) fn session_key_for(
    channel_id: ChannelId,
    session_id: Option<SessionId>,
    transaction_id: TransactionId,
) -> SessionKey {
    let sid = session_id.unwrap_or_else(|| transaction_scoped_session_id(transaction_id));
    SessionKey::new(channel_id, sid)
}

/// Resolve publisher session for one event.
///
/// Prefer an authoritative `preferred` id over a prior **exact** synthetic key for
/// this transaction; never replace an already-authoritative id (LAW 7).
pub(crate) fn ensure_session(
    session: &mut Option<SessionId>,
    preferred: Option<SessionId>,
    transaction_id: TransactionId,
) -> SessionId {
    if let Some(s) = preferred {
        let upgrade = match session.as_ref() {
            None => true,
            Some(cur) => is_transaction_scoped_session(cur, transaction_id),
        };
        if upgrade {
            *session = Some(s.clone());
            return s;
        }
        return session.clone().expect("authoritative session present");
    }
    if let Some(s) = session.clone() {
        return s;
    }
    let s = transaction_scoped_session_id(transaction_id);
    *session = Some(s.clone());
    s
}

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

    #[test]
    fn synthetic_and_authoritative_upgrade() {
        let tx = TransactionId::generate();
        let mut session = None;
        let syn = ensure_session(&mut session, None, tx);
        assert!(is_transaction_scoped_session(&syn, tx));
        let auth = SessionId::try_new("grok-real").unwrap();
        let got = ensure_session(&mut session, Some(auth.clone()), tx);
        assert_eq!(got, auth);
        // Second preferred must not flip an authoritative id.
        let other = SessionId::try_new("grok-other").unwrap();
        let kept = ensure_session(&mut session, Some(other), tx);
        assert_eq!(kept, auth);
    }

    #[test]
    fn tx_prefix_external_id_is_not_synthetic() {
        let tx = TransactionId::generate();
        // An external id that merely starts with "tx-" must not be treated as ours.
        let external = SessionId::try_new("tx-external-not-ours").unwrap();
        assert!(!is_transaction_scoped_session(&external, tx));
        let mut session = Some(external.clone());
        let other = SessionId::try_new("grok-claim").unwrap();
        // Must not upgrade away from a non-synthetic id.
        let kept = ensure_session(&mut session, Some(other), tx);
        assert_eq!(kept, external);
    }

    #[test]
    fn session_key_matches_scoped_id() {
        let tx = TransactionId::generate();
        let ch = ChannelId::try_new("llm").unwrap();
        let key = session_key_for(ch.clone(), None, tx);
        assert_eq!(key.session_id, transaction_scoped_session_id(tx));
        assert_eq!(key.channel_id, ch);
    }

    #[test]
    fn exchange_scoped_action_round_trips_provider_id() {
        let exchange = ExchangeId::generate();
        let action = tool_action_id_for_exchange(exchange, "call_reuse");
        assert_eq!(
            provider_tool_call_id_from_action(exchange, &action),
            Some("call_reuse")
        );
        let other = ExchangeId::generate();
        assert!(provider_tool_call_id_from_action(other, &action).is_none());
    }
}