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"))
}
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
*"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) {
let mut child = Command::new(lifeloop_bin())
.args(args)
.env("FAKE_CCD_LOG", log)
.env("FAKE_CCD_MODE", mode)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.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 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.join(".git")).expect("git dir");
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(
&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}"#,
);
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 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();
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());
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();
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();
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();
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();
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 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();
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();
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);
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");
}