//! CLI integration tests for `lifeloop host-hook` renewal brokering.

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

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

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

/// Strip the `GIT_*` location vars from a `Command`.
///
/// When tests run under a harness that has set `GIT_DIR`,
/// `GIT_COMMON_DIR`, `GIT_WORK_TREE`, or `GIT_INDEX_FILE` (e.g.
/// lefthook's pre-push hook on the lifeloop bare repo), child `git`
/// invocations and any spawned binary that probes git locations will
/// inherit those vars and silently operate on the *outer* repo
/// instead of the tempdir fixture. Calling this on every spawn site
/// localizes the child to whatever its `--path` / `current_dir`
/// arguments actually say.
///
/// Mirrors `crates/ccd/src/doctor.rs::git_at` (production-side fix
/// for the same class of bug in `ccd doctor`'s worktree probe).
fn strip_git_env(cmd: &mut Command) -> &mut Command {
    cmd.env_remove("GIT_DIR")
        .env_remove("GIT_COMMON_DIR")
        .env_remove("GIT_WORK_TREE")
        .env_remove("GIT_INDEX_FILE")
}

fn write_fake_ccd(dir: &std::path::Path) -> std::path::PathBuf {
    let path = dir.join("fake-ccd.sh");
    fs::write(
        &path,
        r#"#!/bin/sh
printf '%s\n' "$*" >> "$FAKE_CCD_LOG"
case "$*" in
  *"lifeloop client"*)
    input=$(cat)
    printf '%s' "$input" > "${FAKE_CCD_LOG}.stdin"
    if [ "$FAKE_CCD_MODE" = "client-failed" ]; then
      printf '%s\n' '{"schema_version":"lifeloop.v0.2","status":"failed","failure_class":"internal_error","retry_class":"retry_after_reread","metadata":{"reason":"forced-client-failure"}}'
    elif [ "$FAKE_CCD_MODE" = "client-skipped" ]; then
      printf '%s\n' '{"schema_version":"lifeloop.v0.2","status":"skipped","failure_class":null,"retry_class":null,"metadata":{"reason":"forced-client-skip"}}'
    else
      case "$input" in
        *'"event":"context.compacted"'*)
          printf '%s\n' '{"schema_version":"lifeloop.v0.2","status":"observed","client_payloads":[{"schema_version":"lifeloop.v0.2","payload_id":"pay-context-1","client_id":"ccd","payload_kind":"ccd.context.post_compaction","format":"text/plain","content_encoding":"utf8","body":"post compact context","byte_size":20,"acceptable_placements":[{"placement":"pre_prompt_frame","requirement":"optional"},{"placement":"side_channel_context","requirement":"preferred"}]}],"failure_class":null,"retry_class":null}'
          ;;
        *)
          printf '%s\n' '{"schema_version":"lifeloop.v0.2","status":"observed","failure_class":null,"retry_class":null}'
          ;;
      esac
    fi
    ;;
  *"start --refresh --continuation cnt-test-1"*)
    if [ "$FAKE_CCD_MODE" = "continuation-unbound" ]; then
      printf '%s\n' '{"command":"start","ok":true,"activation":{"thread_id":"thread-test-1"},"thread":{"status":"bound","thread_id":"thread-test-1","active_session_matches":false}}'
    else
      printf '%s\n' '{"command":"start","ok":true,"activation":{"thread_id":"thread-test-1"},"thread":{"status":"bound","thread_id":"thread-test-1","active_session_matches":true}}'
    fi
    ;;
  *"session renew prepare"*)
    if [ "$FAKE_CCD_MODE" = "missing-token" ]; then
      printf '%s\n' '{"command":"session-renew-prepare","ok":true,"renewal":{"renewal_lease_id":"lease-test-1","thread_id":"thread-test-1"},"continuation":{"path":"/tmp/test","command":"ccd start --refresh --continuation <missing>"}}'
    elif [ "$FAKE_CCD_MODE" = "missing-thread" ]; then
      printf '%s\n' '{"command":"session-renew-prepare","ok":true,"renewal":{"renewal_lease_id":"lease-test-1"},"continuation":{"token":"cnt-test-1","path":"/tmp/test","command":"ccd start --refresh --continuation cnt-test-1"}}'
    else
      printf '%s\n' '{"command":"session-renew-prepare","ok":true,"renewal":{"renewal_lease_id":"lease-test-1","thread_id":"thread-test-1"},"continuation":{"token":"cnt-test-1","path":"/tmp/test","command":"ccd start --refresh --continuation cnt-test-1"}}'
    fi
    ;;
  *"start --refresh --fields command,ok,session_boundary,thread"*)
    if [ "$FAKE_CCD_MODE" = "boundary-ok-false" ]; then
      printf '%s\n' '{"command":"start","ok":false,"error":"boundary denied"}'
    elif [ "$FAKE_CCD_MODE" = "boundary-no-renewal" ]; then
      printf '%s\n' '{"command":"start","ok":true,"session_boundary":{"action":"continue","summary":"no renewal"},"thread":{"status":"bound","thread_id":"thread-test-1","active_session_matches":true}}'
    else
      printf '%s\n' '{"command":"start","ok":true,"session_boundary":{"action":"renew","summary":"renew now"},"thread":{"status":"bound","thread_id":"thread-test-1","active_session_matches":true}}'
    fi
    ;;
  *)
    printf '%s\n' '{"command":"unknown","ok":true}'
    ;;
esac
"#,
    )
    .expect("write fake ccd");
    #[cfg(unix)]
    {
        let mut perms = fs::metadata(&path).expect("metadata").permissions();
        perms.set_mode(0o755);
        fs::set_permissions(&path, perms).expect("chmod");
    }
    path
}

fn run_host_hook(
    fake_ccd: &std::path::Path,
    log: &std::path::Path,
    mode: &str,
    args: &[&str],
    stdin: &str,
) -> (i32, String, String) {
    run_host_hook_with_path_env(fake_ccd, log, mode, args, stdin, None)
}

fn run_host_hook_with_path_env(
    fake_ccd: &std::path::Path,
    log: &std::path::Path,
    mode: &str,
    args: &[&str],
    stdin: &str,
    path_env: Option<&str>,
) -> (i32, String, String) {
    let mut command = Command::new(lifeloop_bin());
    strip_git_env(&mut command)
        .args(args)
        .env("FAKE_CCD_LOG", log)
        .env("FAKE_CCD_MODE", mode)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    if let Some(path_env) = path_env {
        command.env("PATH", path_env);
    }
    let mut child = command.spawn().expect("spawn lifeloop");
    child
        .stdin
        .as_mut()
        .expect("stdin")
        .write_all(stdin.as_bytes())
        .expect("write stdin");
    let output = child.wait_with_output().expect("wait");
    let _ = fake_ccd;
    (
        output.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&output.stdout).into_owned(),
        String::from_utf8_lossy(&output.stderr).into_owned(),
    )
}

#[test]
fn before_prompt_build_dispatches_prompt_to_ccd_lifeloop_client() {
    let dir = tempfile::tempdir().unwrap();
    let fake = write_fake_ccd(dir.path());
    let log = dir.path().join("fake.log");
    let root = dir.path().join("work");
    fs::create_dir(&root).unwrap();
    let stdin = r#"{
        "hook_event_name": "UserPromptSubmit",
        "prompt": "please preserve this prompt",
        "session_id": "sess-prompt-1",
        "run_id": "run-prompt-1",
        "turn_id": "turn-prompt-1",
        "transcript_path": "/tmp/transcript.jsonl"
    }"#;

    let (code, stdout, stderr) = run_host_hook(
        &fake,
        &log,
        "ok",
        &[
            "--output",
            "hook-protocol",
            "host-hook",
            "--path",
            root.to_str().unwrap(),
            "--host",
            "codex",
            "--client-cmd",
            fake.to_str().unwrap(),
            "--profile",
            "prompt-profile",
            "--hook",
            "before-prompt-build",
        ],
        stdin,
    );

    assert_eq!(code, 0, "stderr=`{stderr}`");
    assert_eq!(stdout.trim(), "{}");
    let logged_args = fs::read_to_string(&log).expect("fake ccd log");
    assert!(
        logged_args.contains("lifeloop client"),
        "expected lifeloop client invocation, got `{logged_args}`"
    );
    assert!(
        logged_args.contains(&format!("--path {}", root.display())),
        "expected path passthrough, got `{logged_args}`"
    );
    assert!(
        logged_args.contains(&format!("lifeloop client --path {}", root.display())),
        "expected CCD path after lifeloop client, got `{logged_args}`"
    );
    assert!(
        logged_args.contains("--profile prompt-profile"),
        "expected profile passthrough, got `{logged_args}`"
    );
    let envelope: serde_json::Value =
        serde_json::from_str(&fs::read_to_string(log.with_extension("log.stdin")).unwrap())
            .expect("dispatch envelope JSON");
    assert_eq!(
        envelope.pointer("/request/event").and_then(|v| v.as_str()),
        Some("frame.opening")
    );
    assert_eq!(
        envelope
            .pointer("/request/metadata/prompt")
            .and_then(|v| v.as_str()),
        Some("please preserve this prompt")
    );
    assert_eq!(
        envelope
            .pointer("/request/metadata/hook_event_name")
            .and_then(|v| v.as_str()),
        Some("UserPromptSubmit")
    );
    assert_eq!(
        envelope
            .pointer("/request/metadata/transcript_path")
            .and_then(|v| v.as_str()),
        Some("/tmp/transcript.jsonl")
    );
    assert_eq!(
        envelope
            .pointer("/request/harness_session_id")
            .and_then(|v| v.as_str()),
        Some("sess-prompt-1")
    );
    assert_eq!(
        envelope
            .pointer("/request/frame_context/frame_id")
            .and_then(|v| v.as_str()),
        Some("turn-prompt-1")
    );
    assert!(envelope.pointer("/request/harness_task_id").is_none());
}

#[test]
fn before_prompt_build_propagates_failed_ccd_response() {
    let dir = tempfile::tempdir().unwrap();
    let fake = write_fake_ccd(dir.path());
    let log = dir.path().join("fake.log");
    let root = dir.path().join("work");
    fs::create_dir(&root).unwrap();
    let stdin = r#"{
        "hook_event_name": "UserPromptSubmit",
        "prompt": "please preserve this prompt",
        "session_id": "sess-prompt-1"
    }"#;

    let (code, _stdout, stderr) = run_host_hook(
        &fake,
        &log,
        "client-failed",
        &[
            "--output",
            "hook-protocol",
            "host-hook",
            "--path",
            root.to_str().unwrap(),
            "--host",
            "codex",
            "--client-cmd",
            fake.to_str().unwrap(),
            "--hook",
            "before-prompt-build",
        ],
        stdin,
    );

    assert_eq!(code, 1);
    assert!(stderr.contains("host-hook: CCD callback did not accept prompt event"));
    assert!(stderr.contains("status=Failed"));
    assert!(stderr.contains("forced-client-failure"));
}

#[test]
fn before_prompt_build_rejects_skipped_ccd_response() {
    let dir = tempfile::tempdir().unwrap();
    let fake = write_fake_ccd(dir.path());
    let log = dir.path().join("fake.log");
    let root = dir.path().join("work");
    fs::create_dir(&root).unwrap();
    let stdin = r#"{
        "hook_event_name": "UserPromptSubmit",
        "prompt": "please preserve this prompt",
        "session_id": "sess-prompt-1"
    }"#;

    let (code, _stdout, stderr) = run_host_hook(
        &fake,
        &log,
        "client-skipped",
        &[
            "--output",
            "hook-protocol",
            "host-hook",
            "--path",
            root.to_str().unwrap(),
            "--host",
            "codex",
            "--client-cmd",
            fake.to_str().unwrap(),
            "--hook",
            "before-prompt-build",
        ],
        stdin,
    );

    assert_eq!(code, 1);
    assert!(stderr.contains("host-hook: CCD callback did not accept prompt event"));
    assert!(stderr.contains("status=Skipped"));
    assert!(stderr.contains("forced-client-skip"));
}

#[test]
fn before_prompt_build_rejects_oversized_prompt_metadata() {
    let dir = tempfile::tempdir().unwrap();
    let fake = write_fake_ccd(dir.path());
    let log = dir.path().join("fake.log");
    let root = dir.path().join("work");
    fs::create_dir(&root).unwrap();
    let prompt = "x".repeat(64 * 1024 + 1);
    let stdin = serde_json::json!({
        "hook_event_name": "UserPromptSubmit",
        "prompt": prompt,
        "session_id": "sess-prompt-1"
    })
    .to_string();

    let (code, _stdout, stderr) = run_host_hook(
        &fake,
        &log,
        "ok",
        &[
            "--output",
            "hook-protocol",
            "host-hook",
            "--path",
            root.to_str().unwrap(),
            "--host",
            "codex",
            "--client-cmd",
            fake.to_str().unwrap(),
            "--hook",
            "before-prompt-build",
        ],
        &stdin,
    );

    assert_eq!(code, 1);
    assert!(stderr.contains("prompt metadata exceeds 65536 bytes"));
    assert!(!log.exists(), "CCD client should not be invoked");
}

#[test]
fn before_prompt_build_ignores_unsupported_host() {
    let dir = tempfile::tempdir().unwrap();
    let fake = write_fake_ccd(dir.path());
    let log = dir.path().join("fake.log");
    let root = dir.path().join("work");
    fs::create_dir(&root).unwrap();
    let stdin = r#"{
        "hook_event_name": "UserPromptSubmit",
        "prompt": "please preserve this prompt",
        "session_id": "sess-prompt-1"
    }"#;

    let (code, stdout, stderr) = run_host_hook(
        &fake,
        &log,
        "ok",
        &[
            "--output",
            "hook-protocol",
            "host-hook",
            "--path",
            root.to_str().unwrap(),
            "--host",
            "unknown-host",
            "--client-cmd",
            fake.to_str().unwrap(),
            "--hook",
            "before-prompt-build",
        ],
        stdin,
    );

    assert_eq!(code, 0, "stderr=`{stderr}`");
    assert_eq!(stdout.trim(), "{}");
    assert!(!log.exists(), "CCD client should not be invoked");
}

fn run_renewal_status(args: &[&str]) -> (i32, String, String) {
    run_renewal_status_with_path_env(args, None)
}

fn run_renewal_status_with_path_env(
    args: &[&str],
    path_env: Option<&str>,
) -> (i32, String, String) {
    let mut command = Command::new(lifeloop_bin());
    strip_git_env(&mut command).args(args);
    if let Some(path_env) = path_env {
        command.env("PATH", path_env);
    }
    let output = command.output().expect("spawn 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 codex_stop_prepares_renewal_and_stores_continuation_out_of_band() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();
    let fake_s = fake_ccd.display().to_string();

    let (code, stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "--output",
            "hook-protocol",
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--hook",
            "on-agent-end",
            "--client-cmd",
            &fake_s,
        ],
        r#"{"hook_event_name":"Stop","session_id":"codex-session-before","turn_id":"turn-1","stop_hook_active":false}"#,
    );

    assert_eq!(code, 0, "stderr=`{stderr}`");
    assert!(
        !stdout.contains("cnt-test-1"),
        "continuation token must not be printed to hook stdout: {stdout}"
    );
    let rendered: serde_json::Value = serde_json::from_str(&stdout).expect("hook JSON");
    assert_eq!(rendered["decision"], "block");
    assert!(
        rendered["reason"]
            .as_str()
            .expect("reason")
            .contains("stored out of band")
    );

    let pending_path = state_dir.join("ccd-renewal-pending.json");
    let pending: serde_json::Value =
        serde_json::from_str(&fs::read_to_string(&pending_path).expect("pending")).unwrap();
    assert_eq!(pending["continuation_token"], "cnt-test-1");
    assert_eq!(pending["thread_id"], "thread-test-1");
    #[cfg(unix)]
    {
        let mode = fs::metadata(&pending_path)
            .expect("pending metadata")
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(mode, 0o600, "pending token file must be user-only");
    }
    let receipt_path = pending["reset_prepare_receipt_path"]
        .as_str()
        .expect("receipt path");
    let receipt: lifeloop::LifecycleReceipt =
        serde_json::from_str(&fs::read_to_string(receipt_path).expect("receipt")).unwrap();
    receipt.validate().expect("receipt validates");
    assert_eq!(receipt.event, lifeloop::LifecycleEventKind::SessionEnding);
    assert_eq!(receipt.status, lifeloop::ReceiptStatus::Observed);
    assert_eq!(
        receipt.payload_receipts[0].payload_kind,
        "lifeloop.renewal.reset_prepare"
    );

    let commands = fs::read_to_string(&log).expect("log");
    assert!(commands.contains("session renew prepare"));
    assert!(commands.contains("--adapter codex"));
    assert!(commands.contains("--reset-path wrapper"));
    assert!(commands.contains("--lifeloop-receipt"));
    assert!(!commands.contains("--continuation cnt-test-1"));

    let (code, stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "renewal",
            "status",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--client-id",
            "ccd",
        ],
        "",
    );
    assert_eq!(code, 0, "stderr=`{stderr}`");
    assert!(
        !stdout.contains("cnt-test-1"),
        "status output must not expose continuation token: {stdout}"
    );
    let status: serde_json::Value = serde_json::from_str(&stdout).expect("status JSON");
    assert_eq!(status["state"], "pending_continuation");
    assert_eq!(status["client_id"], "ccd");
    assert_eq!(status["adapter_id"], "codex");
    assert_eq!(status["pending_token_present"], true);
    assert_eq!(status["thread_id"], "thread-test-1");
    assert_eq!(status["renewal_lease_id"], "lease-test-1");
    assert_eq!(
        status["reset_prepare_receipt_path"],
        serde_json::Value::String(receipt_path.to_string())
    );
}

#[test]
fn codex_stop_refuses_to_overwrite_existing_pending_continuation() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();
    let fake_s = fake_ccd.display().to_string();
    let args = [
        "--output",
        "hook-protocol",
        "host-hook",
        "--path",
        &repo_s,
        "--state-dir",
        &state_dir_s,
        "--host",
        "codex",
        "--hook",
        "on-agent-end",
        "--client-cmd",
        &fake_s,
    ];

    let (code, _stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &args,
        r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
    );
    assert_eq!(code, 0, "stderr=`{stderr}`");

    let pending_path = state_dir.join("ccd-renewal-pending.json");
    let original = fs::read_to_string(&pending_path).expect("pending");

    let (code, _stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &args,
        r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
    );
    assert_eq!(code, 1);
    assert!(stderr.contains("refusing to overwrite continuation token"));
    assert_eq!(
        fs::read_to_string(&pending_path).expect("pending after retry"),
        original,
        "existing one-shot continuation token must remain intact"
    );
}

#[test]
fn codex_stop_records_no_renewal_status_when_boundary_does_not_renew() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();
    let fake_s = fake_ccd.display().to_string();

    let (code, stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "boundary-no-renewal",
        &[
            "--output",
            "hook-protocol",
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--hook",
            "on-agent-end",
            "--client-cmd",
            &fake_s,
        ],
        r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
    );
    assert_eq!(code, 0, "stderr=`{stderr}`");
    assert_eq!(stdout.trim(), "{}");

    let (code, stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "renewal",
            "status",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--client-id",
            "ccd",
        ],
        "",
    );
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let status: serde_json::Value = serde_json::from_str(&stdout).expect("status JSON");
    assert_eq!(status["state"], "no_renewal");
    assert_eq!(status["pending_token_present"], false);
    assert!(
        status["message"]
            .as_str()
            .unwrap()
            .contains("without a renewal")
    );
}

#[test]
fn default_state_dir_uses_parent_git_directory_for_nested_paths() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let repo = tmp.path().join("repo");
    let nested = repo.join("src").join("module");
    fs::create_dir_all(&repo).expect("repo dir");
    init_git_repo(&repo);
    fs::create_dir_all(&nested).expect("nested");
    let nested_s = nested.display().to_string();
    let fake_s = fake_ccd.display().to_string();

    let (code, _stdout, stderr) = run_host_hook_with_path_env(
        &fake_ccd,
        &log,
        "",
        &[
            "--output",
            "hook-protocol",
            "host-hook",
            "--path",
            &nested_s,
            "--host",
            "codex",
            "--hook",
            "on-agent-end",
            "--client-cmd",
            &fake_s,
        ],
        r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
        Some(""),
    );

    assert_eq!(code, 0, "stderr=`{stderr}`");
    assert!(
        repo.join(".git")
            .join("lifeloop")
            .join("renewal")
            .join("ccd-renewal-pending.json")
            .exists(),
        "pending state should live under the repository git dir"
    );
    assert!(
        !nested.join(".lifeloop").exists(),
        "nested worktree paths must not receive token-bearing fallback state"
    );
}

#[test]
fn default_state_dir_falls_back_for_non_git_paths() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let project = tmp.path().join("project");
    fs::create_dir_all(&project).expect("project dir");
    let project_s = project.display().to_string();
    let fake_s = fake_ccd.display().to_string();

    let (code, _stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "--output",
            "hook-protocol",
            "host-hook",
            "--path",
            &project_s,
            "--host",
            "codex",
            "--hook",
            "on-agent-end",
            "--client-cmd",
            &fake_s,
        ],
        r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
    );

    assert_eq!(code, 0, "stderr=`{stderr}`");
    assert!(
        project
            .join(".lifeloop")
            .join("renewal")
            .join("ccd-renewal-pending.json")
            .exists(),
        "non-Git fallback state should live under the provided path"
    );

    let (code, stdout, stderr) = run_renewal_status(&[
        "renewal",
        "status",
        "--path",
        &project_s,
        "--host",
        "codex",
        "--client-id",
        "ccd",
    ]);
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let status: serde_json::Value = serde_json::from_str(&stdout).expect("status JSON");
    assert_eq!(status["state"], "pending_continuation");
    assert_eq!(status["pending_token_present"], true);
    let expected_pending_path = project
        .join(".lifeloop")
        .join("renewal")
        .join("ccd-renewal-pending.json")
        .display()
        .to_string();
    assert_eq!(
        status["pending_path"].as_str(),
        Some(expected_pending_path.as_str())
    );
}

#[test]
fn default_state_dir_uses_per_worktree_gitdir_for_linked_worktrees() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let repo = tmp.path().join("repo");
    let linked = tmp.path().join("linked");
    fs::create_dir_all(&repo).expect("repo dir");
    init_git_repo(&repo);
    commit_file(&repo);
    git(
        &repo,
        &["worktree", "add", "-b", "linked", linked.to_str().unwrap()],
    );
    let linked_s = linked.display().to_string();
    let fake_s = fake_ccd.display().to_string();

    let (code, _stdout, stderr) = run_host_hook_with_path_env(
        &fake_ccd,
        &log,
        "",
        &[
            "--output",
            "hook-protocol",
            "host-hook",
            "--path",
            &linked_s,
            "--host",
            "codex",
            "--hook",
            "on-agent-end",
            "--client-cmd",
            &fake_s,
        ],
        r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
        Some(""),
    );

    assert_eq!(code, 0, "stderr=`{stderr}`");
    let state_dir = git_path(&linked, &["rev-parse", "--git-path", "lifeloop/renewal"]);
    assert!(
        state_dir.join("ccd-renewal-pending.json").exists(),
        "linked worktree state should live under its per-worktree gitdir: {}",
        state_dir.display()
    );
    assert!(
        !linked.join(".lifeloop").exists(),
        "linked worktrees must not receive token-bearing fallback state"
    );

    let (code, stdout, stderr) = run_renewal_status_with_path_env(
        &[
            "renewal",
            "status",
            "--path",
            &linked_s,
            "--host",
            "codex",
            "--client-id",
            "ccd",
        ],
        Some(""),
    );
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let status: serde_json::Value = serde_json::from_str(&stdout).expect("status JSON");
    assert_eq!(status["state"], "pending_continuation");
    assert_eq!(status["pending_token_present"], true);
    let expected_pending_path = state_dir
        .join("ccd-renewal-pending.json")
        .display()
        .to_string();
    assert_eq!(
        status["pending_path"].as_str(),
        Some(expected_pending_path.as_str())
    );
}

#[test]
fn codex_session_start_consumes_pending_continuation_and_preserves_thread() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();
    let fake_s = fake_ccd.display().to_string();
    let common = [
        "--output",
        "hook-protocol",
        "host-hook",
        "--path",
        &repo_s,
        "--state-dir",
        &state_dir_s,
        "--host",
        "codex",
        "--client-cmd",
        &fake_s,
    ];

    let mut stop_args = common.to_vec();
    stop_args.extend(["--hook", "on-agent-end"]);
    let (code, _stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &stop_args,
        r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
    );
    assert_eq!(code, 0, "stderr=`{stderr}`");

    let mut start_args = common.to_vec();
    start_args.extend(["--hook", "on-session-start"]);
    let (code, stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &start_args,
        r#"{"hook_event_name":"SessionStart","session_id":"codex-session-after"}"#,
    );
    assert_eq!(code, 0, "stderr=`{stderr}`");
    assert!(
        !stdout.contains("cnt-test-1"),
        "continuation token must not be printed to hook stdout: {stdout}"
    );
    let rendered: serde_json::Value = serde_json::from_str(&stdout).expect("hook JSON");
    assert_eq!(
        rendered["hookSpecificOutput"]["hookEventName"],
        "SessionStart"
    );
    let additional = rendered["hookSpecificOutput"]["additionalContext"]
        .as_str()
        .expect("additional context");
    assert!(additional.contains("lifeloop.renewal.continuation"));
    assert!(additional.contains("thread-test-1"));
    assert!(!state_dir.join("ccd-renewal-pending.json").exists());

    let commands = fs::read_to_string(&log).expect("log");
    assert!(commands.contains("start --refresh --continuation cnt-test-1"));

    let (code, stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "renewal",
            "status",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--client-id",
            "ccd",
        ],
        "",
    );
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let status: serde_json::Value = serde_json::from_str(&stdout).expect("status JSON");
    assert_eq!(status["state"], "fulfilled");
    assert_eq!(status["pending_token_present"], false);
    assert_eq!(status["thread_id"], "thread-test-1");
}

#[test]
fn codex_stop_fails_closed_when_prepare_loses_continuation_token() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();
    let fake_s = fake_ccd.display().to_string();

    let (code, _stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "missing-token",
        &[
            "--output",
            "hook-protocol",
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--hook",
            "on-agent-end",
            "--client-cmd",
            &fake_s,
        ],
        r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
    );

    assert_eq!(code, 1);
    assert!(stderr.contains("continuation payload lost"));
    assert!(!state_dir.join("ccd-renewal-pending.json").exists());
}

#[test]
fn codex_stop_fails_closed_when_prepare_omits_thread_binding() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();
    let fake_s = fake_ccd.display().to_string();

    let (code, _stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "missing-thread",
        &[
            "--output",
            "hook-protocol",
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--hook",
            "on-agent-end",
            "--client-cmd",
            &fake_s,
        ],
        r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
    );

    assert_eq!(code, 1);
    assert!(stderr.contains("renewal.thread_id"));
    assert!(!state_dir.join("ccd-renewal-pending.json").exists());
}

#[test]
fn codex_session_start_preserves_pending_when_thread_binding_is_not_active() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();
    let fake_s = fake_ccd.display().to_string();
    let common = [
        "--output",
        "hook-protocol",
        "host-hook",
        "--path",
        &repo_s,
        "--state-dir",
        &state_dir_s,
        "--host",
        "codex",
        "--client-cmd",
        &fake_s,
    ];

    let mut stop_args = common.to_vec();
    stop_args.extend(["--hook", "on-agent-end"]);
    let (code, _stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &stop_args,
        r#"{"hook_event_name":"Stop","session_id":"codex-session-before","stop_hook_active":false}"#,
    );
    assert_eq!(code, 0, "stderr=`{stderr}`");

    let pending_path = state_dir.join("ccd-renewal-pending.json");
    assert!(pending_path.exists());

    let mut start_args = common.to_vec();
    start_args.extend(["--hook", "on-session-start"]);
    let (code, _stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "continuation-unbound",
        &start_args,
        r#"{"hook_event_name":"SessionStart","session_id":"codex-session-after"}"#,
    );
    assert_eq!(code, 1);
    assert!(stderr.contains("active thread binding"));
    assert!(
        pending_path.exists(),
        "failed continuation must leave the one-shot token available for recovery"
    );

    let (code, stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "renewal",
            "status",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--client-id",
            "ccd",
        ],
        "",
    );
    assert_eq!(code, 0, "stderr=`{stderr}`");
    let status: serde_json::Value = serde_json::from_str(&stdout).expect("status JSON");
    assert_eq!(status["state"], "failed");
    assert_eq!(status["pending_token_present"], true);
    assert_eq!(status["failure_class"], "state_conflict");
    assert_eq!(status["retry_class"], "retry_after_reread");
}

#[test]
fn host_hook_accepts_command_local_output_flag() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");

    let (code, stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--output",
            "hook-protocol",
            "--host",
            "codex",
            "--hook",
            "on-agent-end",
        ],
        r#"{"hook_event_name":"Stop"}"#,
    );

    assert_eq!(code, 0, "stderr=`{stderr}`");
    assert_eq!(stdout.trim(), "{}");
}

#[test]
fn global_output_is_rejected_for_non_host_hook_commands() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");

    let (code, _stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &["--output", "hook-protocol", "manifest", "list"],
        "",
    );

    assert_eq!(code, 2);
    assert!(stderr.contains("--output is only supported for `host-hook`"));
}

#[test]
fn claude_pre_compact_records_compaction_signal() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();

    let (code, stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "claude",
            "--hook",
            "on-compaction-notice",
        ],
        r#"{"hook_event_name":"PreCompact","session_id":"claude-session-1","trigger":"auto"}"#,
    );

    assert_eq!(code, 0, "stderr=`{stderr}`");
    assert_eq!(stdout.trim(), "{}");

    let status_path = state_dir.join("host-context-status.json");
    assert!(status_path.is_file(), "status file should exist");
    let raw = fs::read_to_string(&status_path).expect("read status");
    let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid json");
    assert_eq!(parsed["host"], "claude");
    assert_eq!(parsed["session_id"], "claude-session-1");
    assert_eq!(parsed["compacted"], true);
    assert_eq!(parsed["schema_version"], "1");
    assert!(parsed["observed_at_epoch_s"].as_u64().is_some());
}

#[test]
fn claude_pre_compact_without_session_id_fails_closed() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();

    let (code, _stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "claude",
            "--hook",
            "on-compaction-notice",
        ],
        r#"{"hook_event_name":"PreCompact","trigger":"auto"}"#,
    );

    assert_ne!(code, 0);
    assert!(
        stderr.contains("session_id"),
        "stderr should mention session_id: `{stderr}`"
    );
    assert!(
        !state_dir.join("host-context-status.json").exists(),
        "status file should not be written when input is invalid"
    );
}

#[test]
fn claude_session_end_clears_matching_session_status() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();

    // First: record a compaction.
    let (code, _stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "claude",
            "--hook",
            "on-compaction-notice",
        ],
        r#"{"hook_event_name":"PreCompact","session_id":"claude-session-2","trigger":"auto"}"#,
    );
    assert_eq!(code, 0, "pre-compact stderr=`{stderr}`");
    assert!(state_dir.join("host-context-status.json").is_file());

    // Then: session-end with the same session_id should clear it.
    let (code, stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "claude",
            "--hook",
            "on-session-end",
        ],
        r#"{"hook_event_name":"SessionEnd","session_id":"claude-session-2"}"#,
    );

    assert_eq!(code, 0, "session-end stderr=`{stderr}`");
    assert_eq!(stdout.trim(), "{}");
    assert!(
        !state_dir.join("host-context-status.json").exists(),
        "status file should be cleared after session-end"
    );
}

#[test]
fn codex_session_start_clears_stale_host_context_status() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();

    // Record a Codex compaction signal, then start a new Codex session
    // without a client_cmd. Codex has no installed on-session-end hook, so
    // on-session-start is where the stale compaction state must be cleared.
    let (code, _stdout, _stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--hook",
            "on-compaction-notice",
        ],
        r#"{"hook_event_name":"PreCompact","session_id":"codex-session-old","trigger":"auto"}"#,
    );
    assert_eq!(code, 0);
    assert!(state_dir.join("host-context-status.json").is_file());

    let (code, _stdout, _stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--hook",
            "on-session-start",
        ],
        r#"{"hook_event_name":"SessionStart","session_id":"codex-session-new"}"#,
    );
    assert_eq!(code, 0);
    assert!(
        !state_dir.join("host-context-status.json").exists(),
        "codex on-session-start must clear stale compaction status from the previous session"
    );
}

#[test]
fn codex_session_start_preserves_status_when_session_id_unchanged() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();

    // Codex emits SessionStart for `source: "resume"` of the same session
    // after a compaction. The status must survive the resume so the
    // compaction signal is still available to downstream consumers.
    let (code, _stdout, _stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--hook",
            "on-compaction-notice",
        ],
        r#"{"hook_event_name":"PreCompact","session_id":"codex-session-stable","trigger":"auto"}"#,
    );
    assert_eq!(code, 0);

    let (code, _stdout, _stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--hook",
            "on-session-start",
        ],
        r#"{"hook_event_name":"SessionStart","session_id":"codex-session-stable","source":"resume"}"#,
    );
    assert_eq!(code, 0);
    let raw = fs::read_to_string(state_dir.join("host-context-status.json"))
        .expect("status must survive same-session resume");
    let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid json");
    assert_eq!(parsed["session_id"], "codex-session-stable");
    assert_eq!(parsed["compacted"], true);
}

#[test]
fn codex_session_start_clears_status_for_source_clear_even_when_session_unchanged() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();

    // Codex SessionStart with `source: "clear"` is itself a context-reset
    // boundary, so any persisted compaction signal must be cleared even
    // when the `session_id` is unchanged.
    let (code, _stdout, _stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--hook",
            "on-compaction-notice",
        ],
        r#"{"hook_event_name":"PreCompact","session_id":"codex-session-reset","trigger":"auto"}"#,
    );
    assert_eq!(code, 0);

    let (code, _stdout, _stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--hook",
            "on-session-start",
        ],
        r#"{"hook_event_name":"SessionStart","session_id":"codex-session-reset","source":"clear"}"#,
    );
    assert_eq!(code, 0);
    assert!(
        !state_dir.join("host-context-status.json").exists(),
        "codex on-session-start with source=clear must clear stale compaction status even when session_id is unchanged"
    );
}

#[test]
fn codex_compaction_notice_records_status_under_host_neutral_dispatch() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();

    // Same hook name, different --host: the dispatch must be host-agnostic
    // so Codex/openclaw/hermes installed hooks also reach the writer.
    let (code, stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--hook",
            "on-compaction-notice",
        ],
        r#"{"hook_event_name":"PreCompact","session_id":"codex-session-9","trigger":"auto"}"#,
    );

    assert_eq!(code, 0, "stderr=`{stderr}`");
    assert_eq!(stdout.trim(), "{}");
    let raw = fs::read_to_string(state_dir.join("host-context-status.json"))
        .expect("status must be written for non-claude host");
    let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid json");
    assert_eq!(parsed["host"], "codex");
    assert_eq!(parsed["session_id"], "codex-session-9");
    assert_eq!(parsed["compacted"], true);
}

#[test]
fn pre_compact_dispatches_pressure_event_to_ccd_and_keeps_status() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();
    let fake_s = fake_ccd.display().to_string();

    let (code, stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--client-cmd",
            &fake_s,
            "--profile",
            "compaction-profile",
            "--hook",
            "on-compaction-notice",
        ],
        r#"{"hook_event_name":"PreCompact","session_id":"codex-session-pressure","run_id":"run-1","trigger":"auto"}"#,
    );

    assert_eq!(code, 0, "stderr=`{stderr}`");
    assert_eq!(stdout.trim(), "{}");
    let status: serde_json::Value = serde_json::from_str(
        &fs::read_to_string(state_dir.join("host-context-status.json")).expect("status"),
    )
    .expect("status JSON");
    assert_eq!(status["host"], "codex");
    assert_eq!(status["session_id"], "codex-session-pressure");
    assert_eq!(status["compacted"], true);

    let logged_args = fs::read_to_string(&log).expect("fake ccd log");
    assert!(logged_args.contains("lifeloop client"));
    assert!(logged_args.contains(&format!("--path {repo_s}")));
    assert!(logged_args.contains(&format!("lifeloop client --path {repo_s}")));
    assert!(logged_args.contains("--profile compaction-profile"));
    let envelope: serde_json::Value =
        serde_json::from_str(&fs::read_to_string(log.with_extension("log.stdin")).unwrap())
            .expect("dispatch envelope JSON");
    assert_eq!(
        envelope.pointer("/request/event").and_then(|v| v.as_str()),
        Some("context.pressure_observed")
    );
    assert_eq!(
        envelope
            .pointer("/request/metadata/source_hook")
            .and_then(|v| v.as_str()),
        Some("PreCompact")
    );
    assert_eq!(
        envelope
            .pointer("/request/metadata/compaction_signal")
            .and_then(|v| v.as_bool()),
        Some(true)
    );
    assert_eq!(
        envelope
            .pointer("/request/harness_session_id")
            .and_then(|v| v.as_str()),
        Some("codex-session-pressure")
    );
}

#[test]
fn pre_compact_skipped_ccd_response_is_nonfatal_and_keeps_status() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();
    let fake_s = fake_ccd.display().to_string();

    let (code, stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "client-skipped",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--client-cmd",
            &fake_s,
            "--hook",
            "on-compaction-notice",
        ],
        r#"{"hook_event_name":"PreCompact","session_id":"codex-session-skipped"}"#,
    );

    assert_eq!(code, 0, "stderr=`{stderr}`");
    assert_eq!(stdout.trim(), "{}");
    let status: serde_json::Value = serde_json::from_str(
        &fs::read_to_string(state_dir.join("host-context-status.json")).expect("status"),
    )
    .expect("status JSON");
    assert_eq!(status["session_id"], "codex-session-skipped");
    assert_eq!(status["compacted"], true);
}

#[test]
fn post_compact_dispatches_compacted_event_and_surfaces_context_payload() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();
    let fake_s = fake_ccd.display().to_string();

    let (code, stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "codex",
            "--client-cmd",
            &fake_s,
            "--hook",
            "on-compaction-notice",
        ],
        r#"{"hook_event_name":"PostCompact","session_id":"codex-session-compacted"}"#,
    );

    assert_eq!(code, 0, "stderr=`{stderr}`");
    let rendered: serde_json::Value = serde_json::from_str(&stdout).expect("hook JSON");
    assert_eq!(
        rendered["hookSpecificOutput"]["hookEventName"],
        "PostCompact"
    );
    let additional = rendered["hookSpecificOutput"]["additionalContext"]
        .as_str()
        .expect("additional context");
    let wrapper: serde_json::Value = serde_json::from_str(additional).expect("wrapper JSON");
    assert_eq!(
        wrapper["payloads"][0]["payload_kind"],
        "ccd.context.post_compaction"
    );
    assert_eq!(wrapper["payloads"][0]["body"], "post compact context");

    let envelope: serde_json::Value =
        serde_json::from_str(&fs::read_to_string(log.with_extension("log.stdin")).unwrap())
            .expect("dispatch envelope JSON");
    assert_eq!(
        envelope.pointer("/request/event").and_then(|v| v.as_str()),
        Some("context.compacted")
    );
    assert_eq!(
        envelope
            .pointer("/request/metadata/source_hook")
            .and_then(|v| v.as_str()),
        Some("PostCompact")
    );
    assert!(
        state_dir.join("host-context-status.json").is_file(),
        "status recording must still happen before dispatch"
    );
}

#[test]
fn claude_session_end_without_session_id_fails_closed() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();

    // Pre-existing compaction status that must survive a malformed session-end.
    let (code, _stdout, _stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "claude",
            "--hook",
            "on-compaction-notice",
        ],
        r#"{"hook_event_name":"PreCompact","session_id":"claude-session-keep","trigger":"auto"}"#,
    );
    assert_eq!(code, 0);

    let (code, _stdout, stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "claude",
            "--hook",
            "on-session-end",
        ],
        r#"{"hook_event_name":"SessionEnd"}"#,
    );

    assert_ne!(code, 0);
    assert!(
        stderr.contains("session_id"),
        "stderr should mention session_id: `{stderr}`"
    );
    assert!(
        state_dir.join("host-context-status.json").is_file(),
        "status file must survive a malformed session-end"
    );
}

#[test]
fn claude_session_end_preserves_status_for_different_session() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let fake_ccd = write_fake_ccd(tmp.path());
    let log = tmp.path().join("ccd.log");
    let state_dir = tmp.path().join("state");
    let repo = tmp.path().join("repo");
    fs::create_dir(&repo).expect("repo");
    let state_dir_s = state_dir.display().to_string();
    let repo_s = repo.display().to_string();

    // Record compaction for session A.
    let (code, _stdout, _stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "claude",
            "--hook",
            "on-compaction-notice",
        ],
        r#"{"hook_event_name":"PreCompact","session_id":"claude-session-A","trigger":"auto"}"#,
    );
    assert_eq!(code, 0);

    // session-end for unrelated session B — file must remain.
    let (code, _stdout, _stderr) = run_host_hook(
        &fake_ccd,
        &log,
        "",
        &[
            "host-hook",
            "--path",
            &repo_s,
            "--state-dir",
            &state_dir_s,
            "--host",
            "claude",
            "--hook",
            "on-session-end",
        ],
        r#"{"hook_event_name":"SessionEnd","session_id":"claude-session-B"}"#,
    );

    assert_eq!(code, 0);
    let status_path = state_dir.join("host-context-status.json");
    assert!(status_path.is_file(), "status file must remain");
    let raw = fs::read_to_string(&status_path).expect("read status");
    let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid json");
    assert_eq!(parsed["session_id"], "claude-session-A");
}

fn init_git_repo(path: &std::path::Path) {
    git(path, &["init", "-b", "main"]);
}

fn commit_file(path: &std::path::Path) {
    fs::write(path.join("README.md"), "test\n").expect("write README");
    git(path, &["add", "README.md"]);
    let mut cmd = Command::new("git");
    strip_git_env(&mut cmd);
    let status = cmd
        .args([
            "-c",
            "user.name=Lifeloop Test",
            "-c",
            "user.email=lifeloop@example.invalid",
            "commit",
            "-m",
            "init",
        ])
        .current_dir(path)
        .status()
        .expect("git commit");
    assert!(status.success(), "git commit failed");
}

fn git(path: &std::path::Path, args: &[&str]) {
    let mut cmd = Command::new("git");
    strip_git_env(&mut cmd);
    let status = cmd
        .args(args)
        .current_dir(path)
        .status()
        .expect("git command");
    assert!(status.success(), "git {args:?} failed");
}

fn git_path(path: &std::path::Path, args: &[&str]) -> std::path::PathBuf {
    let mut cmd = Command::new("git");
    strip_git_env(&mut cmd);
    let output = cmd
        .args(args)
        .current_dir(path)
        .output()
        .expect("git command");
    assert!(output.status.success(), "git {args:?} failed");
    let stdout = String::from_utf8(output.stdout).expect("utf8 git path");
    let raw = stdout.trim();
    let resolved = std::path::PathBuf::from(raw);
    if resolved.is_absolute() {
        resolved
    } else {
        path.join(resolved)
    }
}
