harn-cli 0.10.160

CLI for the Harn programming language — run, test, REPL, format, and lint
//! Conformance for the LLM outcome vocabularies on the
//! `harn.acp.prompt_error.v1` envelope.
//!
//! The class this guards: `category`/`kind`/`reason` used to leave Harn as
//! bare strings with no exported owner, so a host could not tell a value Harn
//! emits from one it invented. These tests bind the owner (`harn_vm`), the
//! envelope projection (`harn_serve`), and the generated Rust binding
//! together, and prove the unknown escape preserves a value the binding
//! predates instead of folding it into a known variant.

use harn_serve::adapters::acp::{AcpPromptErrorData, AcpPromptFailureFacts};
use harn_vm::llm::AgentTerminalClass;

use super::generated_rust_binding::{HarnLlmErrorCategory, HarnLlmErrorKind, HarnLlmErrorReason};
use super::*;

#[test]
fn admission_snapshot_round_trips_through_schema_generated_host_records() {
    use super::generated_rust_binding::{
        HarnInferenceAdmissionSnapshot, HarnInferenceAdmissionStatus,
    };
    use harn_vm::llm::api::{
        preview_inference_admission, InferenceAdmissionRequest, InferenceBoundary, InferenceReach,
    };
    let _guard = crate::tests::common::harn_state_lock::lock_harn_state();
    let _host = crate::env_guard::ScopedEnvVar::unset("HARN_INFERENCE_BOUNDARY_JSON");
    let _endpoint = crate::env_guard::ScopedEnvVar::set("OLLAMA_HOST", "http://127.0.0.1:9");
    for (provider, expected) in [
        ("ollama", HarnInferenceAdmissionStatus::Admitted),
        ("openai", HarnInferenceAdmissionStatus::Denied),
        (
            "unresearched-provider",
            HarnInferenceAdmissionStatus::Unknown,
        ),
    ] {
        let request = InferenceAdmissionRequest {
            provider: provider.into(),
            model: "projection-fixture-model".into(),
            boundary: Some(InferenceBoundary {
                reach: InferenceReach::LocalOnly,
                allow_training_discounts: false,
            }),
            data_controls: None,
        };
        let wire = serde_json::to_value(preview_inference_admission(&request)).unwrap();
        let decoded: HarnInferenceAdmissionSnapshot = serde_json::from_value(wire.clone()).unwrap();
        assert_eq!(decoded.status, expected);
        assert_eq!(serde_json::to_value(decoded).unwrap(), wire);
        let mut absent = wire;
        absent.as_object_mut().unwrap().remove("status");
        assert!(serde_json::from_value::<HarnInferenceAdmissionSnapshot>(absent).is_err());
    }
}

#[test]
fn local_policy_denial_survives_the_native_protocol_projection() {
    let facts = AcpPromptFailureFacts::from_thrown(&serde_json::json!({
        "category": "egress_blocked",
        "kind": "terminal",
        "reason": "policy_denied",
        "origin": "local",
        "rule": "inference_boundary.local_only",
        "retryable": false,
    }));
    let envelope = serde_json::to_value(AcpPromptErrorData::with_facts(
        AgentTerminalClass::ToolPolicyRejected,
        facts,
    ))
    .expect("policy envelope serializes");
    let reason: HarnLlmErrorReason =
        serde_json::from_value(envelope["reason"].clone()).expect("typed reason decodes");
    assert_eq!(reason, HarnLlmErrorReason::PolicyDenied);
    assert_eq!(envelope["origin"], "local");
    assert_eq!(envelope["rule"], "inference_boundary.local_only");
    assert_eq!(envelope["retryable"], false);
    assert_eq!(envelope["terminalClass"], "tool_policy_rejected");
}

#[test]
fn managed_spend_pause_survives_the_generated_prompt_error_projection() {
    for (owner, expected) in [
        (
            AgentTerminalClass::ManagedSpendPaused,
            HarnLlmErrorReason::ManagedSpendPaused,
        ),
        (
            AgentTerminalClass::ProviderBilling,
            HarnLlmErrorReason::BillingLimit,
        ),
        (
            AgentTerminalClass::RateLimited,
            HarnLlmErrorReason::RateLimit,
        ),
    ] {
        let reason = match owner {
            AgentTerminalClass::ManagedSpendPaused => "managed_spend_paused",
            AgentTerminalClass::ProviderBilling => "billing_limit",
            AgentTerminalClass::RateLimited => "rate_limit",
            _ => unreachable!(),
        };
        let transient = owner == AgentTerminalClass::RateLimited;
        let facts = AcpPromptFailureFacts::from_thrown(&serde_json::json!({
            "category": if transient { "rate_limit" } else { "generic" },
            "kind": if transient { "transient" } else { "terminal" }, "reason": reason,
        }));
        let envelope = serde_json::to_value(AcpPromptErrorData::with_facts(owner, facts)).unwrap();
        let decoded: HarnLlmErrorReason =
            serde_json::from_value(envelope["reason"].clone()).unwrap();
        assert_eq!(decoded, expected);
        assert!(decoded.is_known());
        assert_eq!(envelope["terminalClass"], owner.as_str());
        assert_eq!(serde_json::to_value(decoded).unwrap(), envelope["reason"]);
    }
}

/// The generated binding must carry exactly the owner's vocabulary. If this
/// drifts, the artifact is stale and every consumer of it is guessing.
#[test]
fn generated_rust_enums_match_the_owning_vocabularies() {
    let known = |values: Vec<String>, generated: Vec<String>| {
        assert_eq!(values, generated);
    };
    known(
        llm_error_reason_values(),
        HarnLlmErrorReason::KNOWN
            .iter()
            .map(|reason| reason.as_str().to_string())
            .collect(),
    );
    known(
        llm_error_kind_values(),
        HarnLlmErrorKind::KNOWN
            .iter()
            .map(|kind| kind.as_str().to_string())
            .collect(),
    );
    known(
        llm_error_category_values(),
        HarnLlmErrorCategory::KNOWN
            .iter()
            .map(|category| category.as_str().to_string())
            .collect(),
    );
}

/// A `network_error` failure travels the production projection and decodes
/// into the typed reason on the far side.
#[test]
fn network_error_failure_round_trips_through_the_generated_rust_types() {
    // Shaped exactly as `harn_vm`'s provider-error path throws it: the
    // classifier's `reason`/`kind` strings plus the category it owns.
    let facts = AcpPromptFailureFacts::from_thrown(&serde_json::json!({
        "category": "transient_network",
        "kind": "transient",
        "reason": "network_error",
        "message": "error sending request: connection reset by peer",
    }));
    let envelope = serde_json::to_value(AcpPromptErrorData::with_facts(
        AgentTerminalClass::ProviderUnavailable,
        facts,
    ))
    .expect("envelope serializes");

    let reason: HarnLlmErrorReason =
        serde_json::from_value(envelope["reason"].clone()).expect("reason decodes");
    let kind: HarnLlmErrorKind =
        serde_json::from_value(envelope["kind"].clone()).expect("kind decodes");
    let category: HarnLlmErrorCategory =
        serde_json::from_value(envelope["category"].clone()).expect("category decodes");

    assert_eq!(reason, HarnLlmErrorReason::NetworkError);
    assert_eq!(kind, HarnLlmErrorKind::Transient);
    assert_eq!(category, HarnLlmErrorCategory::TransientNetwork);
    assert!(reason.is_known() && kind.is_known() && category.is_known());

    // Re-serializing returns the same wire strings the producer wrote.
    assert_eq!(
        serde_json::to_value(&reason).expect("reason serializes"),
        envelope["reason"]
    );
    assert_eq!(
        serde_json::to_value(&kind).expect("kind serializes"),
        envelope["kind"]
    );
}

/// Negative control for the unknown escape.
///
/// `provider_connection_failed` is the value a host invented because no typed
/// owner existed. It is not in Harn's vocabulary and never will be. A binding
/// generated before some future reason exists must land such a value in
/// `Unrecognized` and hand it back unchanged, never fold it into a neighbour
/// and never silently drop it.
#[test]
fn a_value_outside_the_vocabulary_lands_in_the_unknown_escape() {
    let reason: HarnLlmErrorReason =
        serde_json::from_value(serde_json::json!("provider_connection_failed"))
            .expect("an unrecognized reason still decodes");
    assert_eq!(
        reason,
        HarnLlmErrorReason::Unrecognized("provider_connection_failed".to_string())
    );
    assert!(!reason.is_known());
    assert_eq!(reason.as_str(), "provider_connection_failed");
    assert_eq!(
        serde_json::to_value(&reason).expect("serializes"),
        serde_json::json!("provider_connection_failed")
    );

    // `unknown` is a real Harn reason and must NOT be confused with the
    // escape: "Harn classified this as unknown" is a different fact from
    // "this binding does not recognize the string".
    let classified_unknown: HarnLlmErrorReason =
        serde_json::from_value(serde_json::json!("unknown")).expect("decodes");
    assert_eq!(classified_unknown, HarnLlmErrorReason::Unknown);
    assert!(classified_unknown.is_known());

    let kind: HarnLlmErrorKind =
        serde_json::from_value(serde_json::json!("provider_degraded")).expect("decodes");
    assert_eq!(
        kind,
        HarnLlmErrorKind::Unrecognized("provider_degraded".to_string())
    );
    assert!(!kind.is_known());
}