use lifeloop::protocol::{
ClaudeHookEvent, CodexHookEvent, FrameAdmissionDirective, ProtocolAdapter, ProtocolPayload,
RenderError, RenderRequest, claude_hook_event_for, codex_hook_event_for, render_hook_payload,
};
use lifeloop::{
AcceptablePlacement, IntegrationMode, LifecycleEventKind, PayloadEnvelope, PlacementClass,
RequirementLevel, SCHEMA_VERSION,
};
const CLAUDE_ID: &str = "claude";
const CODEX_ID: &str = "codex";
const VERSION: &str = "1.0.0";
fn pre_prompt_envelope(body: &str) -> PayloadEnvelope {
PayloadEnvelope {
schema_version: SCHEMA_VERSION.to_string(),
payload_id: "pl-1".to_string(),
client_id: "test-client".to_string(),
payload_kind: "lifecycle.context".to_string(),
format: "application/json".to_string(),
content_encoding: "identity".to_string(),
body: Some(body.to_string()),
body_ref: None,
byte_size: body.len() as u64,
content_digest: None,
acceptable_placements: vec![AcceptablePlacement {
placement: PlacementClass::PrePromptFrame,
requirement: RequirementLevel::Preferred,
}],
idempotency_key: None,
expires_at_epoch_s: None,
redaction: None,
metadata: serde_json::Map::new(),
}
}
fn minimal_req(
adapter: ProtocolAdapter,
adapter_id: &'static str,
event: LifecycleEventKind,
) -> RenderRequest<'static> {
RenderRequest::minimal(
adapter,
adapter_id,
VERSION,
IntegrationMode::NativeHook,
event,
)
}
#[test]
fn claude_maps_supported_lifecycle_events() {
assert_eq!(
claude_hook_event_for(LifecycleEventKind::SessionStarting),
Some(ClaudeHookEvent::SessionStart),
);
assert_eq!(
claude_hook_event_for(LifecycleEventKind::SessionStarted),
Some(ClaudeHookEvent::SessionStart),
);
assert_eq!(
claude_hook_event_for(LifecycleEventKind::FrameOpening),
Some(ClaudeHookEvent::UserPromptSubmit),
);
assert_eq!(
claude_hook_event_for(LifecycleEventKind::ContextPressureObserved),
Some(ClaudeHookEvent::PreCompact),
);
assert_eq!(
claude_hook_event_for(LifecycleEventKind::FrameEnding),
Some(ClaudeHookEvent::Stop),
);
assert_eq!(
claude_hook_event_for(LifecycleEventKind::FrameEnded),
Some(ClaudeHookEvent::Stop),
);
assert_eq!(
claude_hook_event_for(LifecycleEventKind::SessionEnding),
Some(ClaudeHookEvent::SessionEnd),
);
assert_eq!(
claude_hook_event_for(LifecycleEventKind::SessionEnded),
Some(ClaudeHookEvent::SessionEnd),
);
}
#[test]
fn claude_returns_none_for_unmapped_events() {
assert!(claude_hook_event_for(LifecycleEventKind::SupervisorTick).is_none());
assert!(claude_hook_event_for(LifecycleEventKind::ContextCompacted).is_none());
assert!(claude_hook_event_for(LifecycleEventKind::CapabilityDegraded).is_none());
assert!(claude_hook_event_for(LifecycleEventKind::ReceiptEmitted).is_none());
}
#[test]
fn codex_maps_supported_lifecycle_events() {
assert_eq!(
codex_hook_event_for(LifecycleEventKind::SessionStarting),
Some(CodexHookEvent::SessionStart),
);
assert_eq!(
codex_hook_event_for(LifecycleEventKind::FrameOpening),
Some(CodexHookEvent::UserPromptSubmit),
);
assert_eq!(
codex_hook_event_for(LifecycleEventKind::ContextPressureObserved),
Some(CodexHookEvent::PreCompact),
);
assert_eq!(
codex_hook_event_for(LifecycleEventKind::ContextCompacted),
Some(CodexHookEvent::PostCompact),
);
assert_eq!(
codex_hook_event_for(LifecycleEventKind::FrameEnding),
Some(CodexHookEvent::Stop),
);
}
#[test]
fn codex_does_not_surface_frame_opened_or_session_end() {
assert!(codex_hook_event_for(LifecycleEventKind::FrameOpened).is_none());
assert!(codex_hook_event_for(LifecycleEventKind::SessionEnding).is_none());
assert!(codex_hook_event_for(LifecycleEventKind::SessionEnded).is_none());
}
#[test]
fn claude_session_start_emits_additional_context_string() {
let payload = render_hook_payload(&minimal_req(
ProtocolAdapter::Claude,
CLAUDE_ID,
LifecycleEventKind::SessionStarting,
))
.unwrap();
assert_eq!(payload.hook_event_name, Some("SessionStart"));
assert_eq!(
payload.body["hookSpecificOutput"]["hookEventName"],
"SessionStart"
);
assert!(payload.body["hookSpecificOutput"]["additionalContext"].is_string());
assert!(payload.body.get("decision").is_none());
}
#[test]
fn claude_session_start_renders_supplied_payload_body_into_additional_context() {
let env = pre_prompt_envelope(r#"{"next_focus":["finish PR-2"],"branch":"feat/lifeloop"}"#);
let payloads = [ProtocolPayload::new(&env, PlacementClass::PrePromptFrame)];
let req = RenderRequest {
adapter: ProtocolAdapter::Claude,
adapter_id: CLAUDE_ID,
adapter_version: VERSION,
integration_mode: IntegrationMode::NativeHook,
event: LifecycleEventKind::SessionStarting,
frame: None,
payloads: &payloads,
directive: None,
};
let payload = render_hook_payload(&req).unwrap();
let rendered = payload.body["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap();
assert!(rendered.contains("next_focus"), "rendered: {rendered}");
assert!(rendered.contains("finish PR-2"), "rendered: {rendered}");
assert!(rendered.contains("feat/lifeloop"), "rendered: {rendered}");
}
#[test]
fn claude_user_prompt_submit_without_directive_omits_decision() {
let payload = render_hook_payload(&minimal_req(
ProtocolAdapter::Claude,
CLAUDE_ID,
LifecycleEventKind::FrameOpening,
))
.unwrap();
assert_eq!(payload.hook_event_name, Some("UserPromptSubmit"));
assert_eq!(
payload.body["hookSpecificOutput"]["hookEventName"],
"UserPromptSubmit"
);
assert!(payload.body.get("decision").is_none());
}
#[test]
fn claude_user_prompt_submit_with_block_directive_sets_top_level_decision() {
let directive = FrameAdmissionDirective::block("policy requires operator review");
let req = RenderRequest {
adapter: ProtocolAdapter::Claude,
adapter_id: CLAUDE_ID,
adapter_version: VERSION,
integration_mode: IntegrationMode::NativeHook,
event: LifecycleEventKind::FrameOpening,
frame: None,
payloads: &[],
directive: Some(&directive),
};
let payload = render_hook_payload(&req).unwrap();
assert_eq!(payload.body["decision"], "block");
assert_eq!(
payload.body["reason"].as_str().unwrap(),
"policy requires operator review"
);
assert!(payload.body["hookSpecificOutput"].get("decision").is_none());
}
#[test]
fn claude_user_prompt_submit_with_allow_directive_omits_decision() {
let directive = FrameAdmissionDirective::allow();
let req = RenderRequest {
adapter: ProtocolAdapter::Claude,
adapter_id: CLAUDE_ID,
adapter_version: VERSION,
integration_mode: IntegrationMode::NativeHook,
event: LifecycleEventKind::FrameOpening,
frame: None,
payloads: &[],
directive: Some(&directive),
};
let payload = render_hook_payload(&req).unwrap();
assert!(payload.body.get("decision").is_none());
}
#[test]
fn claude_stop_pre_compact_session_end_are_quiet() {
for event in [
LifecycleEventKind::FrameEnded,
LifecycleEventKind::ContextPressureObserved,
LifecycleEventKind::SessionEnded,
] {
let payload =
render_hook_payload(&minimal_req(ProtocolAdapter::Claude, CLAUDE_ID, event)).unwrap();
assert_eq!(payload.body, serde_json::json!({}));
}
}
#[test]
fn claude_unsupported_event_returns_unsupported_event_error() {
let err = render_hook_payload(&minimal_req(
ProtocolAdapter::Claude,
CLAUDE_ID,
LifecycleEventKind::SupervisorTick,
))
.unwrap_err();
matches!(err, RenderError::UnsupportedEvent { .. });
}
#[test]
fn codex_user_prompt_submit_with_payload_renders_additional_context() {
let env = pre_prompt_envelope(r#"{"task":"ship native hooks"}"#);
let payloads = [ProtocolPayload::new(&env, PlacementClass::PrePromptFrame)];
let req = RenderRequest {
adapter: ProtocolAdapter::Codex,
adapter_id: CODEX_ID,
adapter_version: VERSION,
integration_mode: IntegrationMode::NativeHook,
event: LifecycleEventKind::FrameOpening,
frame: None,
payloads: &payloads,
directive: None,
};
let payload = render_hook_payload(&req).unwrap();
assert_eq!(payload.hook_event_name, Some("UserPromptSubmit"));
assert_eq!(
payload.body["hookSpecificOutput"]["hookEventName"],
"UserPromptSubmit"
);
let rendered = payload.body["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap();
assert!(rendered.contains("ship native hooks"));
}
#[test]
fn codex_user_prompt_submit_block_directive_sets_top_level_decision() {
let directive = FrameAdmissionDirective::block("operator review pending");
let req = RenderRequest {
adapter: ProtocolAdapter::Codex,
adapter_id: CODEX_ID,
adapter_version: VERSION,
integration_mode: IntegrationMode::NativeHook,
event: LifecycleEventKind::FrameOpening,
frame: None,
payloads: &[],
directive: Some(&directive),
};
let payload = render_hook_payload(&req).unwrap();
assert_eq!(payload.body["decision"], "block");
assert_eq!(
payload.body["reason"].as_str().unwrap(),
"operator review pending"
);
}
#[test]
fn codex_stop_with_block_directive_emits_continuation_request() {
let directive = FrameAdmissionDirective::block("complete the requested follow-up first");
let req = RenderRequest {
adapter: ProtocolAdapter::Codex,
adapter_id: CODEX_ID,
adapter_version: VERSION,
integration_mode: IntegrationMode::NativeHook,
event: LifecycleEventKind::FrameEnded,
frame: None,
payloads: &[],
directive: Some(&directive),
};
let payload = render_hook_payload(&req).unwrap();
assert_eq!(payload.hook_event_name, Some("Stop"));
assert_eq!(payload.body["decision"], "block");
assert!(
payload.body["reason"]
.as_str()
.unwrap()
.contains("complete the requested follow-up")
);
}
#[test]
fn codex_stop_without_directive_is_empty_object() {
let payload = render_hook_payload(&minimal_req(
ProtocolAdapter::Codex,
CODEX_ID,
LifecycleEventKind::FrameEnded,
))
.unwrap();
assert_eq!(payload.body, serde_json::json!({}));
}
#[test]
fn codex_stop_with_allow_directive_is_empty_object() {
let directive = FrameAdmissionDirective::allow();
let req = RenderRequest {
adapter: ProtocolAdapter::Codex,
adapter_id: CODEX_ID,
adapter_version: VERSION,
integration_mode: IntegrationMode::NativeHook,
event: LifecycleEventKind::FrameEnded,
frame: None,
payloads: &[],
directive: Some(&directive),
};
let payload = render_hook_payload(&req).unwrap();
assert_eq!(payload.body, serde_json::json!({}));
}
#[test]
fn codex_pre_compact_is_quiet() {
let payload = render_hook_payload(&minimal_req(
ProtocolAdapter::Codex,
CODEX_ID,
LifecycleEventKind::ContextPressureObserved,
))
.unwrap();
assert_eq!(payload.hook_event_name, Some("PreCompact"));
assert_eq!(payload.body, serde_json::json!({}));
}
#[test]
fn codex_post_compact_is_quiet() {
let payload = render_hook_payload(&minimal_req(
ProtocolAdapter::Codex,
CODEX_ID,
LifecycleEventKind::ContextCompacted,
))
.unwrap();
assert_eq!(payload.hook_event_name, Some("PostCompact"));
assert_eq!(payload.body, serde_json::json!({}));
}
#[test]
fn codex_unsupported_event_returns_error() {
let err = render_hook_payload(&minimal_req(
ProtocolAdapter::Codex,
CODEX_ID,
LifecycleEventKind::SessionEnded,
))
.unwrap_err();
matches!(err, RenderError::UnsupportedEvent { .. });
}
#[test]
fn adapter_id_mismatch_is_rejected() {
let err = render_hook_payload(&minimal_req(
ProtocolAdapter::Claude,
CODEX_ID,
LifecycleEventKind::SessionStarting,
))
.unwrap_err();
match err {
RenderError::AdapterIdMismatch { adapter, .. } => {
assert_eq!(adapter, ProtocolAdapter::Claude);
}
other => panic!("expected AdapterIdMismatch, got {other:?}"),
}
}
#[test]
fn claude_code_alias_is_accepted_for_claude() {
let payload = render_hook_payload(&minimal_req(
ProtocolAdapter::Claude,
"claude-code",
LifecycleEventKind::SessionStarting,
))
.unwrap();
assert_eq!(payload.hook_event_name, Some("SessionStart"));
}
#[test]
fn block_directive_with_empty_reason_is_rejected() {
let directive = FrameAdmissionDirective::Block {
reason: " ".to_string(),
};
let req = RenderRequest {
adapter: ProtocolAdapter::Claude,
adapter_id: CLAUDE_ID,
adapter_version: VERSION,
integration_mode: IntegrationMode::NativeHook,
event: LifecycleEventKind::FrameOpening,
frame: None,
payloads: &[],
directive: Some(&directive),
};
let err = render_hook_payload(&req).unwrap_err();
matches!(err, RenderError::InvalidDirective(_));
}
#[test]
fn payloads_with_non_pre_prompt_placement_are_skipped() {
let mut env = pre_prompt_envelope(r#"{"should":"not appear"}"#);
env.acceptable_placements = vec![AcceptablePlacement {
placement: PlacementClass::ReceiptOnly,
requirement: RequirementLevel::Required,
}];
let payloads = [ProtocolPayload::new(&env, PlacementClass::ReceiptOnly)];
let req = RenderRequest {
adapter: ProtocolAdapter::Claude,
adapter_id: CLAUDE_ID,
adapter_version: VERSION,
integration_mode: IntegrationMode::NativeHook,
event: LifecycleEventKind::SessionStarting,
frame: None,
payloads: &payloads,
directive: None,
};
let payload = render_hook_payload(&req).unwrap();
let rendered = payload.body["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap();
assert!(!rendered.contains("should"), "rendered: {rendered}");
assert_eq!(rendered, "{}");
}
#[test]
fn body_string_returns_serialized_json() {
let payload = render_hook_payload(&minimal_req(
ProtocolAdapter::Claude,
CLAUDE_ID,
LifecycleEventKind::FrameEnded,
))
.unwrap();
assert_eq!(payload.body_string(), "{}");
}
fn pre_prompt_envelope_with_id(id: &str, body: &str) -> PayloadEnvelope {
let mut env = pre_prompt_envelope(body);
env.payload_id = id.to_string();
env
}
fn rendered_wrapper(payload: &lifeloop::protocol::RenderedHookPayload) -> serde_json::Value {
let rendered = payload.body["hookSpecificOutput"]["additionalContext"]
.as_str()
.expect("additionalContext is a string");
serde_json::from_str(rendered).expect("renderer output is valid JSON")
}
#[test]
fn additional_context_carries_json_object_body_as_opaque_string() {
let env = pre_prompt_envelope(r#"{"foo":"a"}"#);
let payloads = [ProtocolPayload::new(&env, PlacementClass::PrePromptFrame)];
let req = RenderRequest {
adapter: ProtocolAdapter::Claude,
adapter_id: CLAUDE_ID,
adapter_version: VERSION,
integration_mode: IntegrationMode::NativeHook,
event: LifecycleEventKind::SessionStarting,
frame: None,
payloads: &payloads,
directive: None,
};
let payload = render_hook_payload(&req).unwrap();
let wrapper = rendered_wrapper(&payload);
let entry = &wrapper["payloads"][0];
assert!(
entry["body"].is_string(),
"body must be a JSON string, got: {entry}"
);
assert_eq!(entry["body"].as_str().unwrap(), r#"{"foo":"a"}"#);
assert!(
wrapper.get("foo").is_none(),
"wrapper must not absorb body keys"
);
}
#[test]
fn additional_context_preserves_overlapping_keys_across_payloads() {
let env_a = pre_prompt_envelope_with_id("pl-1", r#"{"foo":"a"}"#);
let env_b = pre_prompt_envelope_with_id("pl-2", r#"{"foo":"b"}"#);
let payloads = [
ProtocolPayload::new(&env_a, PlacementClass::PrePromptFrame),
ProtocolPayload::new(&env_b, PlacementClass::PrePromptFrame),
];
let req = RenderRequest {
adapter: ProtocolAdapter::Claude,
adapter_id: CLAUDE_ID,
adapter_version: VERSION,
integration_mode: IntegrationMode::NativeHook,
event: LifecycleEventKind::SessionStarting,
frame: None,
payloads: &payloads,
directive: None,
};
let payload = render_hook_payload(&req).unwrap();
let wrapper = rendered_wrapper(&payload);
let array = wrapper["payloads"].as_array().expect("payloads is array");
assert_eq!(array.len(), 2, "both payloads must appear: {wrapper}");
assert_eq!(array[0]["body"].as_str().unwrap(), r#"{"foo":"a"}"#);
assert_eq!(array[1]["body"].as_str().unwrap(), r#"{"foo":"b"}"#);
}
#[test]
fn additional_context_carries_body_ref_as_reference() {
let mut env = pre_prompt_envelope(r#"{"unused":true}"#);
env.body = None;
env.body_ref = Some("blob://abc".to_string());
let payloads = [ProtocolPayload::new(&env, PlacementClass::PrePromptFrame)];
let req = RenderRequest {
adapter: ProtocolAdapter::Claude,
adapter_id: CLAUDE_ID,
adapter_version: VERSION,
integration_mode: IntegrationMode::NativeHook,
event: LifecycleEventKind::SessionStarting,
frame: None,
payloads: &payloads,
directive: None,
};
let payload = render_hook_payload(&req).unwrap();
let wrapper = rendered_wrapper(&payload);
let entry = &wrapper["payloads"][0];
assert_eq!(entry["body_ref"].as_str().unwrap(), "blob://abc");
assert!(
entry.get("body").is_none(),
"body field must be absent when only body_ref is set: {entry}"
);
}
#[test]
fn additional_context_preserves_input_order() {
let env_a = pre_prompt_envelope_with_id("pl-first", r#"first"#);
let env_b = pre_prompt_envelope_with_id("pl-second", r#"second"#);
let payloads = [
ProtocolPayload::new(&env_a, PlacementClass::PrePromptFrame),
ProtocolPayload::new(&env_b, PlacementClass::PrePromptFrame),
];
let req = RenderRequest {
adapter: ProtocolAdapter::Claude,
adapter_id: CLAUDE_ID,
adapter_version: VERSION,
integration_mode: IntegrationMode::NativeHook,
event: LifecycleEventKind::SessionStarting,
frame: None,
payloads: &payloads,
directive: None,
};
let payload = render_hook_payload(&req).unwrap();
let wrapper = rendered_wrapper(&payload);
let array = wrapper["payloads"].as_array().expect("payloads is array");
assert_eq!(array.len(), 2);
assert_eq!(array[0]["payload_id"].as_str().unwrap(), "pl-first");
assert_eq!(array[1]["payload_id"].as_str().unwrap(), "pl-second");
}
#[test]
fn additional_context_skips_payloads_without_body_or_ref() {
let mut env_empty = pre_prompt_envelope_with_id("pl-empty", r#"ignored"#);
env_empty.body = None;
env_empty.body_ref = None;
let env_present = pre_prompt_envelope_with_id("pl-present", r#"hello"#);
let payloads = [
ProtocolPayload::new(&env_empty, PlacementClass::PrePromptFrame),
ProtocolPayload::new(&env_present, PlacementClass::PrePromptFrame),
];
let req = RenderRequest {
adapter: ProtocolAdapter::Claude,
adapter_id: CLAUDE_ID,
adapter_version: VERSION,
integration_mode: IntegrationMode::NativeHook,
event: LifecycleEventKind::SessionStarting,
frame: None,
payloads: &payloads,
directive: None,
};
let payload = render_hook_payload(&req).unwrap();
let wrapper = rendered_wrapper(&payload);
let array = wrapper["payloads"].as_array().expect("payloads is array");
assert_eq!(
array.len(),
1,
"empty-body payload must be skipped: {wrapper}"
);
assert_eq!(array[0]["payload_id"].as_str().unwrap(), "pl-present");
assert_eq!(array[0]["body"].as_str().unwrap(), "hello");
}