//! Behavior tests for `lifeloop::protocol` hook-protocol rendering.
//!
//! Coverage mirrors the Claude/Codex protocol corpus extracted from
//! the prior CCD slice, translated to neutral lifecycle inputs.
//! Fixtures that encoded client-specific continuity vocabulary
//! (memory, recall, governance) are intentionally dropped — those
//! are out of Lifeloop scope.

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,
    )
}

// ============================================================================
// Lifecycle-event-to-hook-event mapping
// ============================================================================

#[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());
}

// ============================================================================
// Claude rendering
// ============================================================================

#[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();
    // decision/reason must sit at the top level, not nested in hookSpecificOutput.
    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 { .. });
}

// ============================================================================
// Codex rendering
// ============================================================================

#[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_post_compact_renders_side_channel_context_payloads() {
    let mut env = pre_prompt_envelope("post compact context");
    env.acceptable_placements = vec![AcceptablePlacement {
        placement: PlacementClass::SideChannelContext,
        requirement: RequirementLevel::Preferred,
    }];
    let payloads = [ProtocolPayload::new(
        &env,
        PlacementClass::SideChannelContext,
    )];
    let req = RenderRequest {
        adapter: ProtocolAdapter::Codex,
        adapter_id: CODEX_ID,
        adapter_version: VERSION,
        integration_mode: IntegrationMode::NativeHook,
        event: LifecycleEventKind::ContextCompacted,
        frame: None,
        payloads: &payloads,
        directive: None,
    };

    let payload = render_hook_payload(&req).unwrap();

    assert_eq!(payload.hook_event_name, Some("PostCompact"));
    assert_eq!(
        payload.body["hookSpecificOutput"]["hookEventName"],
        "PostCompact"
    );
    let wrapper = rendered_wrapper(&payload);
    assert_eq!(wrapper["payloads"][0]["body"], "post compact context");
}

#[test]
fn codex_post_compact_skips_pre_prompt_payloads() {
    let env = pre_prompt_envelope("pre prompt context");
    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::ContextCompacted,
        frame: None,
        payloads: &payloads,
        directive: None,
    };

    let payload = render_hook_payload(&req).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 { .. });
}

// ============================================================================
// Validation
// ============================================================================

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

// ============================================================================
// Placement filtering
// ============================================================================

#[test]
fn payloads_with_non_pre_prompt_placement_are_skipped() {
    // ReceiptOnly payloads should not flow into additionalContext.
    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(), "{}");
}

// ============================================================================
// Body opacity (issue #21)
//
// Lifeloop must transport `body` opaquely and never parse it. The renderer
// emits a `{"payloads":[...]}` transport envelope; each entry carries the
// body as a verbatim JSON string (or `body_ref` as a reference).
// ============================================================================

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() {
    // A body that is a JSON object literal must be carried as a string,
    // never parsed into the wrapper.
    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"}"#);
    // Ensure no parsed-object leak into the wrapper.
    assert!(
        wrapper.get("foo").is_none(),
        "wrapper must not absorb body keys"
    );
}

#[test]
fn additional_context_preserves_overlapping_keys_across_payloads() {
    // Two payloads with the same JSON key in their bodies must each
    // produce a distinct entry in the array — no merging, no overwriting.
    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() {
    // body=None, body_ref=Some must surface body_ref and no body field.
    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() {
    // Two payloads with distinct ids must appear in input order in the array.
    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() {
    // A payload with neither body nor body_ref must be skipped entirely.
    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");
}
