lifeloop-cli 0.2.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! CLI integration tests for `lifeloop event invoke` (issue #9).
//!
//! Drives the host lifecycle pipeline end-to-end via the binary,
//! using the existing `lifeloop-fake-ccd-client` subprocess as the
//! `--client-cmd` for the happy path.

use std::io::Write;
use std::process::{Command, Stdio};

use lifeloop::{
    AcceptablePlacement, CallbackRequest, DispatchEnvelope, FrameContext, IntegrationMode,
    LifecycleEventKind, PayloadEnvelope, PayloadRef, PlacementClass, RequirementLevel,
    SCHEMA_VERSION,
};

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

fn fake_client_bin() -> std::path::PathBuf {
    std::path::PathBuf::from(env!("CARGO_BIN_EXE_lifeloop-fake-ccd-client"))
}

fn run(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");
    // The child may close its stdin before we finish writing
    // (e.g. arg parser rejects an unknown flag and exits immediately).
    // Tolerate BrokenPipe but surface any other write failure.
    match child.stdin.as_mut().unwrap().write_all(stdin_bytes) {
        Ok(()) => {}
        Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
        Err(e) => panic!("pipe stdin: {e}"),
    }
    let out = child.wait_with_output().expect("wait");
    (
        out.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&out.stdout).into_owned(),
        String::from_utf8_lossy(&out.stderr).into_owned(),
    )
}

fn frame_opening_request_codex() -> CallbackRequest {
    // Use codex (registered in the manifest registry) so adapter
    // resolution succeeds.
    let entry = lifeloop::lookup_manifest("codex").unwrap();
    CallbackRequest {
        schema_version: SCHEMA_VERSION.to_string(),
        event: LifecycleEventKind::FrameOpening,
        event_id: "evt-cli-1".into(),
        adapter_id: "codex".into(),
        adapter_version: entry.manifest.adapter_version,
        integration_mode: IntegrationMode::NativeHook,
        invocation_id: "inv-cli-1".into(),
        harness_session_id: Some("session-cli".into()),
        harness_run_id: Some("run-cli".into()),
        harness_task_id: None,
        frame_context: Some(FrameContext::top_level("frm-1")),
        capability_snapshot_ref: None,
        payload_refs: vec![PayloadRef {
            payload_id: "pay-1".into(),
            payload_kind: "instruction_frame".into(),
            content_digest: None,
            byte_size: Some(11),
        }],
        sequence: Some(1),
        idempotency_key: Some("idem-cli-1".into()),
        metadata: serde_json::Map::new(),
    }
}

fn session_request_codex(
    event: LifecycleEventKind,
    suffix: &str,
    sequence: u64,
) -> CallbackRequest {
    let entry = lifeloop::lookup_manifest("codex").unwrap();
    CallbackRequest {
        schema_version: SCHEMA_VERSION.to_string(),
        event,
        event_id: format!("evt-renewal-{suffix}"),
        adapter_id: "codex".into(),
        adapter_version: entry.manifest.adapter_version,
        integration_mode: IntegrationMode::NativeHook,
        invocation_id: format!("inv-renewal-{suffix}"),
        harness_session_id: Some(format!("session-renewal-{suffix}")),
        harness_run_id: Some(format!("run-renewal-{suffix}")),
        harness_task_id: None,
        frame_context: None,
        capability_snapshot_ref: Some(format!("cap-renewal-{suffix}")),
        payload_refs: Vec::new(),
        sequence: Some(sequence),
        idempotency_key: Some(format!("idem-renewal-{suffix}")),
        metadata: serde_json::Map::new(),
    }
}

fn dispatch_bytes(req: CallbackRequest, payloads: Vec<PayloadEnvelope>) -> Vec<u8> {
    serde_json::to_vec(&DispatchEnvelope::new(req, payloads)).expect("serialize envelope")
}

fn instruction_payload(payload_id: &str, body: &str) -> PayloadEnvelope {
    PayloadEnvelope {
        schema_version: SCHEMA_VERSION.to_string(),
        payload_id: payload_id.into(),
        client_id: "ccd".into(),
        payload_kind: "ccd.instruction_frame".into(),
        format: "client-defined".into(),
        content_encoding: "utf8".into(),
        body: Some(body.into()),
        body_ref: None,
        byte_size: body.len() as u64,
        content_digest: None,
        acceptable_placements: vec![AcceptablePlacement {
            placement: PlacementClass::PrePromptFrame,
            requirement: RequirementLevel::Preferred,
        }],
        idempotency_key: None,
        expires_at_epoch_s: None,
        redaction: None,
        metadata: serde_json::Map::new(),
    }
}

fn renewal_payload(
    payload_id: &str,
    payload_kind: &str,
    body: &str,
    placement: PlacementClass,
) -> PayloadEnvelope {
    let mut metadata = serde_json::Map::new();
    metadata.insert(
        "renewal_boundary".into(),
        serde_json::json!("reset_continuation"),
    );
    PayloadEnvelope {
        schema_version: SCHEMA_VERSION.to_string(),
        payload_id: payload_id.into(),
        client_id: "ccd".into(),
        payload_kind: payload_kind.into(),
        format: "client-defined".into(),
        content_encoding: "utf8".into(),
        body: Some(body.into()),
        body_ref: None,
        byte_size: body.len() as u64,
        content_digest: Some(format!("sha256:{payload_id}")),
        acceptable_placements: vec![AcceptablePlacement {
            placement,
            requirement: RequirementLevel::Required,
        }],
        idempotency_key: Some(format!("idem-{payload_id}")),
        expires_at_epoch_s: None,
        redaction: None,
        metadata,
    }
}

#[test]
fn event_invoke_happy_path_emits_lifecycle_receipt() {
    let req = frame_opening_request_codex();
    let bytes = dispatch_bytes(req, Vec::new());
    let fake = fake_client_bin();
    let (code, stdout, stderr) = run(
        &[
            "event",
            "invoke",
            "--client-cmd",
            fake.to_str().unwrap(),
            "--client-arg",
            "ok",
            "--timeout-ms",
            "5000",
            "--client-id",
            "test-client",
            "--receipt-id",
            "rcpt-1",
            "--at-epoch-s",
            "1700000000",
        ],
        &bytes,
    );
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let v: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is JSON");
    assert_eq!(v.get("receipt_id").and_then(|s| s.as_str()), Some("rcpt-1"));
    assert_eq!(
        v.get("client_id").and_then(|s| s.as_str()),
        Some("test-client")
    );
    assert_eq!(v.get("adapter_id").and_then(|s| s.as_str()), Some("codex"));
    // Status must be one of the spec vocabulary values.
    let status = v.get("status").and_then(|s| s.as_str()).unwrap();
    assert!(
        ["delivered", "degraded", "skipped", "observed", "failed"].contains(&status),
        "unexpected status: {status}"
    );
}

#[test]
fn event_invoke_carries_opaque_renewal_payload_receipts() {
    let prepare_req = session_request_codex(LifecycleEventKind::SessionEnding, "prepare", 41);
    let prepare_payload = renewal_payload(
        "pay-renewal-prepare",
        "lifeloop.renewal.reset_prepare",
        r#"{"phase":"prepare","opaque_client_fact":"client-owned"}"#,
        PlacementClass::ReceiptOnly,
    );
    let (code, stdout, stderr) = run(
        &[
            "event",
            "invoke",
            "--in-process",
            "--client-id",
            "ccd",
            "--receipt-id",
            "rcpt-renewal-prepare",
            "--at-epoch-s",
            "1779000000",
        ],
        &dispatch_bytes(prepare_req, vec![prepare_payload]),
    );
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let receipt: serde_json::Value = serde_json::from_str(&stdout).expect("receipt JSON");
    assert_eq!(receipt["event"], "session.ending");
    assert_eq!(
        receipt["payload_receipts"][0]["payload_kind"],
        "lifeloop.renewal.reset_prepare"
    );
    assert_eq!(receipt["payload_receipts"][0]["placement"], "receipt_only");

    let continuation_req = session_request_codex(LifecycleEventKind::SessionStarted, "continue", 1);
    let continuation_payload = renewal_payload(
        "pay-renewal-continuation",
        "lifeloop.renewal.continuation",
        r#"{"phase":"continued","opaque_client_fact":"client-owned"}"#,
        PlacementClass::DeveloperEquivalentFrame,
    );
    let (code, stdout, stderr) = run(
        &[
            "event",
            "invoke",
            "--in-process",
            "--client-id",
            "ccd",
            "--receipt-id",
            "rcpt-renewal-continuation",
            "--at-epoch-s",
            "1779000100",
        ],
        &dispatch_bytes(continuation_req, vec![continuation_payload]),
    );
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let receipt: serde_json::Value = serde_json::from_str(&stdout).expect("receipt JSON");
    assert_eq!(receipt["event"], "session.started");
    assert_eq!(
        receipt["payload_receipts"][0]["payload_kind"],
        "lifeloop.renewal.continuation"
    );
    assert_eq!(
        receipt["payload_receipts"][0]["placement"],
        "developer_equivalent_frame"
    );
    assert_eq!(
        receipt["payload_receipts"][0]["content_digest"],
        "sha256:pay-renewal-continuation"
    );
}

#[test]
fn event_invoke_in_process_short_circuits_subprocess() {
    let req = frame_opening_request_codex();
    let bytes = dispatch_bytes(req, Vec::new());
    let (code, stdout, stderr) = run(
        &[
            "event",
            "invoke",
            "--in-process",
            "--client-id",
            "test",
            "--receipt-id",
            "r1",
            "--at-epoch-s",
            "0",
        ],
        &bytes,
    );
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(v.get("receipt_id").and_then(|s| s.as_str()), Some("r1"));
}

#[test]
fn event_invoke_rejects_invalid_request_with_validation_exit() {
    // Frame.opening without frame_context — validate() rejects it.
    let mut req = frame_opening_request_codex();
    req.frame_context = None;
    let bytes = dispatch_bytes(req, Vec::new());
    let (code, _stdout, stderr) = run(&["event", "invoke", "--in-process"], &bytes);
    assert_eq!(code, 1, "expected validation exit (1), got {code}");
    assert!(stderr.contains("frame_context"));
}

#[test]
fn event_invoke_missing_client_cmd_is_usage_error() {
    let req = frame_opening_request_codex();
    let bytes = dispatch_bytes(req, Vec::new());
    let (code, _stdout, stderr) = run(&["event", "invoke"], &bytes);
    assert_eq!(code, 2, "expected usage exit (2), got {code}");
    assert!(stderr.contains("--client-cmd"));
}

#[test]
fn event_invoke_payload_bearing_frame_opening_round_trips_body() {
    // End-to-end proof for issue #22: a payload-bearing frame.opening
    // dispatch reaches the subprocess client, the client receives the
    // payload body verbatim, and the receipt's payload_receipts records
    // the delivered placement.
    let req = frame_opening_request_codex();
    let payload_body = "instruction-frame-body-from-cli";
    let payloads = vec![instruction_payload("pay-cli-1", payload_body)];
    let bytes = dispatch_bytes(req, payloads);

    let fake = fake_client_bin();
    let (code, stdout, stderr) = run(
        &[
            "event",
            "invoke",
            "--client-cmd",
            fake.to_str().unwrap(),
            "--client-arg",
            "ok",
            "--timeout-ms",
            "5000",
            "--client-id",
            "test-client",
            "--receipt-id",
            "rcpt-pay-1",
            "--at-epoch-s",
            "1700000001",
        ],
        &bytes,
    );
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let v: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is JSON");
    // Receipt envelope fields.
    assert_eq!(
        v.get("receipt_id").and_then(|s| s.as_str()),
        Some("rcpt-pay-1"),
    );
    // payload_receipts must record the delivered payload (issue #22
    // acceptance: receipt synthesis includes correct payload_receipts).
    let receipts = v
        .get("payload_receipts")
        .and_then(|r| r.as_array())
        .expect("payload_receipts present");
    assert!(
        !receipts.is_empty(),
        "expected at least one payload_receipt, receipt was: {v}",
    );
    let first = &receipts[0];
    assert_eq!(
        first.get("payload_id").and_then(|s| s.as_str()),
        Some("pay-cli-1"),
    );
    assert_eq!(
        first.get("payload_kind").and_then(|s| s.as_str()),
        Some("ccd.instruction_frame"),
    );
    assert!(
        first.get("content_digest").is_none(),
        "content_digest should be omitted when source payload has none"
    );
    let status = first.get("status").and_then(|s| s.as_str()).unwrap();
    assert!(
        ["delivered", "degraded", "skipped", "failed"].contains(&status),
        "unexpected payload status: {status}",
    );
}

#[test]
fn event_invoke_unknown_flag_is_usage_error() {
    let (code, _stdout, stderr) = run(&["event", "invoke", "--bogus"], b"{}");
    assert_eq!(code, 2);
    assert!(stderr.contains("unknown flag"));
}

#[test]
fn event_invoke_empty_stdin_is_input_error() {
    let (code, _stdout, stderr) = run(&["event", "invoke", "--in-process"], b"");
    assert_eq!(code, 3, "expected input exit (3), got {code}");
    assert!(stderr.contains("stdin is empty"));
}