lifeloop-cli 0.1.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Fake-client fixture (issue #2 acceptance).
//!
//! Proves a Lifeloop event can produce a client response without linking to
//! CCD. Two complementary proofs:
//!
//! 1. **In-process protocol roundtrip.** A pure-Rust `fake_client` function
//!    consumes a `CallbackRequest` and emits a `CallbackResponse`. No CCD,
//!    no network, no harness — just the lifeloop crate's public types.
//! 2. **CLI stdio roundtrip.** The same envelope is piped through the
//!    `lifeloop envelope validate` and `envelope echo` commands. This proves
//!    the JSON-over-stdin/stdout transport documented in the spec works
//!    end-to-end for fixture tests.
//!
//! If either path imports CCD, the test stops linking. The lifeloop crate
//! has no `ccd` dependency — Cargo.toml is the load-bearing guard.

use lifeloop::{
    AcceptablePlacement, CallbackRequest, CallbackResponse, FailureClass, FrameContext,
    IntegrationMode, LifecycleEventKind, PayloadEnvelope, PayloadRef, PlacementClass,
    ReceiptStatus, RequirementLevel, SCHEMA_VERSION,
};
use std::io::Write;
use std::process::{Command, Stdio};

fn frame_opening_request() -> CallbackRequest {
    CallbackRequest {
        schema_version: SCHEMA_VERSION.to_string(),
        event: LifecycleEventKind::FrameOpening,
        event_id: "evt-1".into(),
        adapter_id: "codex".into(),
        adapter_version: "0.1.0".into(),
        integration_mode: IntegrationMode::NativeHook,
        invocation_id: "inv-1".into(),
        harness_session_id: Some("session-123".into()),
        harness_run_id: None,
        harness_task_id: None,
        frame_context: Some(FrameContext::top_level("frm-1")),
        capability_snapshot_ref: None,
        payload_refs: vec![PayloadRef {
            payload_id: "pay-staged-1".into(),
            payload_kind: "instruction_frame".into(),
            content_digest: None,
            byte_size: Some(11),
        }],
        sequence: Some(1),
        idempotency_key: Some("idem-1".into()),
        metadata: serde_json::Map::new(),
    }
}

/// In-process fake client: plays the role CCD or RLM would play in production.
/// On a `frame.opening` event it returns one payload destined for a
/// pre-prompt placement; on every other event it returns a delivered receipt
/// with no payload. On a malformed request it fails closed with
/// `invalid_request`.
fn fake_client(req: &CallbackRequest) -> CallbackResponse {
    if req.validate().is_err() {
        return CallbackResponse::failed(FailureClass::InvalidRequest);
    }
    match req.event {
        LifecycleEventKind::FrameOpening => {
            let payload = PayloadEnvelope {
                schema_version: SCHEMA_VERSION.to_string(),
                payload_id: "pay-fake-1".into(),
                client_id: "fake-client".into(),
                payload_kind: "instruction_frame".into(),
                format: "client-defined".into(),
                content_encoding: "utf8".into(),
                body: Some("hello from the fake client".into()),
                body_ref: None,
                byte_size: 27,
                content_digest: None,
                acceptable_placements: vec![AcceptablePlacement {
                    placement: PlacementClass::PrePromptFrame,
                    requirement: RequirementLevel::Preferred,
                }],
                idempotency_key: req.idempotency_key.clone(),
                expires_at_epoch_s: None,
                redaction: None,
                metadata: serde_json::Map::new(),
            };
            let mut resp = CallbackResponse::ok(ReceiptStatus::Delivered);
            resp.client_payloads.push(payload);
            resp
        }
        _ => CallbackResponse::ok(ReceiptStatus::Delivered),
    }
}

#[test]
fn fake_client_produces_valid_response_for_frame_opening() {
    let req = frame_opening_request();
    let resp = fake_client(&req);
    assert_eq!(resp.status, ReceiptStatus::Delivered);
    assert_eq!(resp.client_payloads.len(), 1);
    assert!(resp.validate().is_ok());

    let payload = &resp.client_payloads[0];
    assert_eq!(payload.client_id, "fake-client");
    assert_eq!(payload.idempotency_key.as_deref(), Some("idem-1"));
}

#[test]
fn fake_client_fails_closed_on_invalid_request() {
    let mut req = frame_opening_request();
    req.frame_context = None;
    let resp = fake_client(&req);
    assert_eq!(resp.status, ReceiptStatus::Failed);
    assert_eq!(resp.failure_class, Some(FailureClass::InvalidRequest));
    assert!(resp.validate().is_ok());
}

#[test]
fn fake_client_handles_session_started_with_no_payload() {
    let mut req = frame_opening_request();
    req.event = LifecycleEventKind::SessionStarted;
    req.frame_context = None;
    let resp = fake_client(&req);
    assert_eq!(resp.status, ReceiptStatus::Delivered);
    assert!(resp.client_payloads.is_empty());
    assert!(resp.validate().is_ok());
}

// ============================================================================
// CLI stdio roundtrip
// ============================================================================

fn lifeloop_bin() -> std::path::PathBuf {
    std::path::PathBuf::from(env!("CARGO_BIN_EXE_lifeloop"))
}

fn run_cli(args: &[&str], stdin_bytes: &[u8]) -> (i32, String, String) {
    let mut child = Command::new(lifeloop_bin())
        .args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn lifeloop binary");
    child
        .stdin
        .as_mut()
        .unwrap()
        .write_all(stdin_bytes)
        .expect("pipe stdin");
    let output = child.wait_with_output().expect("wait for lifeloop");
    (
        output.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&output.stdout).into_owned(),
        String::from_utf8_lossy(&output.stderr).into_owned(),
    )
}

#[test]
fn cli_validates_a_well_formed_request() {
    let req = frame_opening_request();
    let bytes = serde_json::to_vec(&req).unwrap();
    let (code, stdout, stderr) = run_cli(&["envelope", "validate", "request"], &bytes);
    assert_eq!(code, 0, "stdout=`{stdout}` stderr=`{stderr}`");
    assert_eq!(stdout.trim(), "ok");
    assert!(stderr.is_empty());
}

#[test]
fn cli_rejects_a_request_with_missing_frame_context() {
    // Validation error: data parses cleanly but violates a contract rule.
    let mut req = frame_opening_request();
    req.frame_context = None;
    let bytes = serde_json::to_vec(&req).unwrap();
    let (code, _stdout, stderr) = run_cli(&["envelope", "validate", "request"], &bytes);
    assert_eq!(code, 1, "expected validation exit (1), got {code}");
    assert!(stderr.contains("frame.* events require frame_context"));
}

#[test]
fn cli_rejects_a_request_with_unknown_event_name() {
    // Input error: serde rejects the unknown wire name before validation runs.
    let mut value = serde_json::to_value(frame_opening_request()).unwrap();
    value["event"] = serde_json::Value::String("session.unknown".into());
    let bytes = serde_json::to_vec(&value).unwrap();
    let (code, _stdout, stderr) = run_cli(&["envelope", "validate", "request"], &bytes);
    assert_eq!(code, 3, "expected input exit (3), got {code}");
    // Pin the spec-relevant fragment, not the wrapper text.
    assert!(
        stderr.contains("session.unknown") || stderr.contains("unknown variant"),
        "stderr should name the bad variant: {stderr}"
    );
}

#[test]
fn cli_validates_a_response() {
    let resp = fake_client(&frame_opening_request());
    let bytes = serde_json::to_vec(&resp).unwrap();
    let (code, stdout, stderr) = run_cli(&["envelope", "validate", "response"], &bytes);
    assert_eq!(code, 0, "stderr=`{stderr}`");
    assert_eq!(stdout.trim(), "ok");
}

#[test]
fn cli_rejects_a_failed_response_missing_failure_class() {
    let mut resp = CallbackResponse::failed(FailureClass::Timeout);
    resp.failure_class = None;
    let bytes = serde_json::to_vec(&resp).unwrap();
    let (code, _stdout, stderr) = run_cli(&["envelope", "validate", "response"], &bytes);
    assert_eq!(code, 1, "expected validation exit (1), got {code}");
    assert!(stderr.contains("failure_class"));
}

#[test]
fn cli_echoes_a_request_through_serde_roundtrip() {
    let req = frame_opening_request();
    let bytes = serde_json::to_vec(&req).unwrap();
    let (code, stdout, _stderr) = run_cli(&["envelope", "echo", "request"], &bytes);
    assert_eq!(code, 0);
    let parsed: CallbackRequest = serde_json::from_str(stdout.trim())
        .expect("echo output should re-parse as a CallbackRequest");
    assert_eq!(parsed, req);
}

#[test]
fn cli_rejects_empty_stdin() {
    let (code, _stdout, stderr) = run_cli(&["envelope", "validate", "request"], b"");
    assert_eq!(code, 3, "expected input exit (3), got {code}");
    assert!(stderr.contains("stdin is empty"));
}

#[test]
fn cli_rejects_unknown_subcommand_with_usage_exit() {
    let (code, _stdout, stderr) = run_cli(&["bogus"], b"");
    assert_eq!(code, 2, "expected usage exit (2), got {code}");
    assert!(stderr.contains("unknown command"));
}

#[test]
fn cli_rejects_envelope_kind_with_usage_exit() {
    let (code, _stdout, stderr) = run_cli(&["envelope", "validate", "manifest"], b"");
    assert_eq!(code, 2, "expected usage exit (2), got {code}");
    assert!(stderr.contains("unknown kind"));
}