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 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.3","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.3","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.3","status":"observed","client_payloads":[{"schema_version":"lifeloop.v0.3","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.3","status":"observed","failure_class":null,"retry_class":null}'
;;
esac
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");
}
#[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",
"before-prompt-build",
],
r#"{"prompt":"hello"}"#,
);
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 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();
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");
}