lifeloop-cli 0.1.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");
    child
        .stdin
        .as_mut()
        .unwrap()
        .write_all(stdin_bytes)
        .expect("pipe stdin");
    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 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(),
    }
}

#[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_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"),
    );
    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"));
}