openlatch-client 0.5.4

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Agent-agnostic reads of the two facts every consumer wants: the prompt a
//! developer typed, and the model that answered it.
//!
//! # Why this is here and not on the platform
//!
//! `data` is the agent's raw payload and is never rewritten — that is the
//! envelope's contract and nothing here breaks it. What these produce are
//! CloudEvents **extension attributes**, stamped beside `olverdict` and
//! `ollatencyms` at the single egress, so a consumer reads one field whatever
//! agent produced the event. The alternative is a per-agent parser in every
//! consumer, which is the same knowledge duplicated everywhere it can drift.
//!
//! # Why they match on SHAPE, never on agent
//!
//! Three shipped agents already disagree: Claude Code and Codex put the prompt
//! at `prompt`, Cline nests it at `userPromptSubmit.prompt`. A `match source`
//! would answer `None` for the fourth agent and for every fork of one — and
//! `source` is an open string precisely so an unknown agent round-trips. So
//! these probe the two shapes that exist and stay silent otherwise. A new agent
//! using either gets normalized with no code change; one using neither gets no
//! attribute, which is the honest answer rather than a guess.

use serde_json::Value;

/// The prompt a developer submitted, if this payload carries one.
///
/// Checked top-level first: that is the plain shape, and an agent that nests a
/// DIFFERENT `prompt` deeper should not outrank it.
pub fn prompt_of(data: &Value) -> Option<&str> {
    if let Some(p) = data.get("prompt").and_then(Value::as_str) {
        return non_empty(p);
    }
    // One level down, under any key — Cline's `userPromptSubmit.prompt`, and
    // the same shape any agent that groups a hook's fields under its own name
    // would produce. Deliberately not recursive: an unbounded search over a
    // payload we do not control is a way to pick up a `prompt` that means
    // something else entirely.
    data.as_object()?
        .values()
        .find_map(|v| v.get("prompt").and_then(Value::as_str))
        .and_then(non_empty)
}

/// The model that served the turn, as `(provider, slug)`.
///
/// Both halves are optional and independent: an agent may name a model with no
/// provider, and reporting `unknown` for the missing half would be inventing a
/// value the agent did not send.
pub fn model_of(data: &Value) -> (Option<&str>, Option<&str>) {
    let Some(model) = data.get("model") else {
        return (None, None);
    };
    // A bare string is the model itself — there is no provider to report.
    if let Some(slug) = model.as_str() {
        return (None, non_empty(slug));
    }
    (
        model
            .get("provider")
            .and_then(Value::as_str)
            .and_then(non_empty),
        model
            .get("slug")
            .and_then(Value::as_str)
            .and_then(non_empty),
    )
}

/// An empty string is not a value. Stamping `olprompt: ""` would make a
/// consumer's "did this event carry a prompt" test true for one that did not.
fn non_empty(s: &str) -> Option<&str> {
    let t = s.trim();
    (!t.is_empty()).then_some(s)
}

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

    /// Every shipped agent's real shape, and a shape none of them use.
    #[test]
    fn the_prompt_is_found_by_shape_across_agents() {
        // Claude Code and Codex — verified against this crate's own fixtures.
        assert_eq!(
            prompt_of(&json!({"prompt": "hello", "session_id": "abc"})),
            Some("hello")
        );
        // Cline — verified against a live 4.1.17 payload.
        assert_eq!(
            prompt_of(&json!({
                "hookName": "UserPromptSubmit",
                "userPromptSubmit": {"prompt": "hey there !!"}
            })),
            Some("hey there !!")
        );
        // An agent this build has never seen, nesting under its own name.
        assert_eq!(
            prompt_of(&json!({"someFutureHook": {"prompt": "p"}})),
            Some("p")
        );
        // Nothing to report is `None`, never a placeholder.
        assert_eq!(prompt_of(&json!({"toolName": "Bash"})), None);
        assert_eq!(prompt_of(&json!({"prompt": "   "})), None);
    }

    #[test]
    fn the_model_is_read_without_inventing_the_missing_half() {
        assert_eq!(
            model_of(&json!({"model": {"provider": "ollama", "slug": "qwen2.5-coder:7b"}})),
            (Some("ollama"), Some("qwen2.5-coder:7b"))
        );
        // A bare string names the model and says nothing about the provider.
        assert_eq!(
            model_of(&json!({"model": "claude-opus-5"})),
            (None, Some("claude-opus-5"))
        );
        // Cline sends this verbatim when it cannot resolve the model. It is the
        // AGENT's value and rides in `data` untouched; what must not happen is
        // it being promoted into the normalized attribute as though we knew it.
        assert_eq!(
            model_of(&json!({"model": {"provider": "unknown", "slug": "unknown"}})),
            (Some("unknown"), Some("unknown"))
        );
        assert_eq!(model_of(&json!({})), (None, None));
    }
}