lifeloop-cli 0.3.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Behavior tests for `lifeloop::telemetry`.

use lifeloop::telemetry::{
    self, EnvAlias, PressureObservation, TelemetryError, claude, codex, gemini, opencode,
    resolve_env_string_with,
};
use std::sync::Mutex;

// Tests that touch the global `env_warning_sink` must serialize: cargo test
// runs integration tests in parallel by default, and the sink's reset/drain
// pair races otherwise. Using a process-local mutex keeps the test set
// dependency-free while still proving the once-per-process warning bound.
static ENV_SINK_LOCK: Mutex<()> = Mutex::new(());

fn fixture(name: &str) -> Vec<u8> {
    let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
        .join("telemetry")
        .join(name);
    std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
}

// ── claude ──────────────────────────────────────────────────────────

#[test]
fn claude_parse_session_log_extracts_latest_metrics() {
    let bytes = fixture("claude_session.jsonl");
    let obs = claude::parse_session_log(&bytes, 1_700_000_000, Some(200_000))
        .expect("parse ok")
        .expect("snapshot present");
    assert_eq!(obs.adapter_id, "claude");
    assert_eq!(obs.observed_at_epoch_s, 1_700_000_000);
    assert_eq!(obs.model_name.as_deref(), Some("claude-test"));
    // latest non-zero entry has input=200, cache_creation=0, cache_read=40
    // → prompt_tokens = 240
    assert_eq!(obs.total_tokens, Some(240));
    assert_eq!(obs.context_window_tokens, Some(200_000));
    assert!(obs.context_used_pct.is_some());
    // Aggregated input across both real entries: 100 + 200 = 300
    assert_eq!(obs.usage.input_tokens, 300);
    assert_eq!(obs.usage.output_tokens, 130);
    assert_eq!(obs.usage.cache_creation_input_tokens, 20);
    assert_eq!(obs.usage.cache_read_input_tokens, 70);
}

#[test]
fn claude_parse_returns_none_for_empty_log() {
    let obs = claude::parse_session_log(b"", 0, None).expect("ok");
    assert!(obs.is_none());
}

// ── codex ───────────────────────────────────────────────────────────

#[test]
fn codex_parse_session_log_uses_latest_token_count() {
    let bytes = fixture("codex_session.jsonl");
    let obs = codex::parse_session_log(&bytes, 1_700_000_100)
        .expect("parse ok")
        .expect("snapshot present");
    assert_eq!(obs.adapter_id, "codex");
    // last_token_usage on the latest token_count payload = 500
    assert_eq!(obs.total_tokens, Some(500));
    assert_eq!(obs.context_window_tokens, Some(4096));
    assert_eq!(obs.usage.blended_total_tokens, Some(2000));
    // Model from earlier line should be carried forward.
    assert_eq!(obs.model_name.as_deref(), Some("codex-test"));
}

#[test]
fn codex_parse_returns_none_with_no_token_count_events() {
    let obs = codex::parse_session_log(b"{\"type\":\"other\"}\n", 0).expect("ok");
    assert!(obs.is_none());
}

// ── gemini ──────────────────────────────────────────────────────────

#[test]
fn gemini_parse_telemetry_log_aggregates_session() {
    let bytes = fixture("gemini_telemetry.log");
    let obs = gemini::parse_telemetry_log(&bytes, 1_700_000_200)
        .expect("parse ok")
        .expect("snapshot present");
    assert_eq!(obs.adapter_id, "gemini");
    // Latest api_response: input=400, cached=80 → prompt=480
    assert_eq!(obs.total_tokens, Some(480));
    assert_eq!(obs.context_window_tokens, Some(8192));
    assert_eq!(obs.compaction_signal, Some(true));
    assert_eq!(obs.model_name.as_deref(), Some("gemini-test"));
    // input aggregated = 300 + 400
    assert_eq!(obs.usage.input_tokens, 700);
    assert_eq!(obs.usage.output_tokens, 220);
    assert_eq!(obs.usage.cache_read_input_tokens, 130);
}

// ── opencode ────────────────────────────────────────────────────────

#[test]
fn opencode_config_yields_context_window_and_model_name() {
    let bytes = fixture("opencode_config.json");
    assert_eq!(
        opencode::read_context_window_from_config(&bytes),
        Some(200_000)
    );
    assert_eq!(
        opencode::read_model_name_from_config(&bytes).as_deref(),
        Some("anthropic/test-model")
    );
}

#[test]
fn opencode_build_observation_from_metrics() {
    let metrics = opencode::SessionMetrics {
        prompt_tokens: 1500,
        completion_tokens: 400,
        summary_message_id: Some("summary-1".into()),
    };
    let obs = opencode::build_observation(metrics, 1_700_000_300, Some(200_000), None);
    assert_eq!(obs.adapter_id, "opencode");
    assert_eq!(obs.total_tokens, Some(1500));
    assert_eq!(obs.context_window_tokens, Some(200_000));
    assert_eq!(obs.compaction_signal, Some(true));
    assert_eq!(obs.usage.input_tokens, 1500);
    assert_eq!(obs.usage.output_tokens, 400);
}

#[test]
fn opencode_no_summary_means_no_compaction_signal() {
    let metrics = opencode::SessionMetrics {
        prompt_tokens: 100,
        completion_tokens: 0,
        summary_message_id: None,
    };
    let obs = opencode::build_observation(metrics, 0, None, None);
    assert!(obs.compaction_signal.is_none());
}

// ── host adapter dispatch helpers ───────────────────────────────────

#[test]
fn host_adapter_alias_resolves() {
    let _g = ENV_SINK_LOCK.lock().unwrap();
    telemetry::env_warning_sink().reset_for_tests();
    let read = |_name: &str| -> Option<String> { None };
    let resolved = resolve_env_string_with(telemetry::host::HOST_ADAPTER_ALIASES, &read);
    assert!(resolved.is_none());
}

// ── env precedence: LIFELOOP_* wins, bounded warning ───────────────

#[test]
fn lifeloop_alias_wins_over_ccd_compat() {
    let _g = ENV_SINK_LOCK.lock().unwrap();
    telemetry::env_warning_sink().reset_for_tests();
    let aliases = &[EnvAlias {
        lifeloop: "LIFELOOP_TELEMETRY_INTEG_X",
        ccd_compat: "CCD_TELEMETRY_INTEG_X",
    }];
    let read = |name: &str| -> Option<String> {
        match name {
            "LIFELOOP_TELEMETRY_INTEG_X" => Some("from-lifeloop".into()),
            "CCD_TELEMETRY_INTEG_X" => Some("from-ccd".into()),
            _ => None,
        }
    };
    let resolved = resolve_env_string_with(aliases, &read);
    assert_eq!(resolved.as_deref(), Some("from-lifeloop"));
    let warnings = telemetry::env_warning_sink().drain();
    assert_eq!(warnings.len(), 1);
    assert_eq!(warnings[0].lifeloop_key, "LIFELOOP_TELEMETRY_INTEG_X");
    assert_eq!(warnings[0].ccd_compat_key, "CCD_TELEMETRY_INTEG_X");
}

#[test]
fn ccd_compat_only_does_not_warn() {
    let _g = ENV_SINK_LOCK.lock().unwrap();
    telemetry::env_warning_sink().reset_for_tests();
    let aliases = &[EnvAlias {
        lifeloop: "LIFELOOP_TELEMETRY_INTEG_Y",
        ccd_compat: "CCD_TELEMETRY_INTEG_Y",
    }];
    let read = |name: &str| -> Option<String> {
        match name {
            "CCD_TELEMETRY_INTEG_Y" => Some("legacy".into()),
            _ => None,
        }
    };
    let resolved = resolve_env_string_with(aliases, &read);
    assert_eq!(resolved.as_deref(), Some("legacy"));
    assert!(telemetry::env_warning_sink().drain().is_empty());
}

// ── telemetry error → failure-class mapping ─────────────────────────

#[test]
fn telemetry_error_maps_to_named_failure_classes() {
    assert_eq!(
        TelemetryError::Unavailable("missing".into()).failure_class(),
        "telemetry_unavailable"
    );
    assert_eq!(
        TelemetryError::HookProtocol("bad shape".into()).failure_class(),
        "hook_protocol_error"
    );
    assert_eq!(
        TelemetryError::Internal("bug".into()).failure_class(),
        "internal_error"
    );
}

// ── neutral observation wire shape ──────────────────────────────────

#[test]
fn pressure_observation_round_trips_via_serde() {
    let obs = PressureObservation {
        adapter_id: "claude".into(),
        adapter_version: Some("0.1.0".into()),
        observed_at_epoch_s: 42,
        model_name: Some("claude-test".into()),
        total_tokens: Some(100),
        context_window_tokens: Some(1000),
        context_used_pct: Some(10),
        compaction_signal: Some(true),
        usage: telemetry::TokenUsage {
            input_tokens: 50,
            output_tokens: 30,
            cache_creation_input_tokens: 10,
            cache_read_input_tokens: 10,
            blended_total_tokens: None,
        },
    };
    let json = serde_json::to_string(&obs).unwrap();
    let back: PressureObservation = serde_json::from_str(&json).unwrap();
    assert_eq!(obs, back);
}