use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;

use crate::model::AgentMessageMetadata;
use crate::model::ConversationBody;
use crate::model::ConversationChannel;
use crate::model::ConversationItemKind;
use crate::model::ConversationPart;
use crate::model::ConversationRole;
use crate::model::ExecutionStatus;
use crate::model::ProducerRef;
use crate::model::ToolCallKind;
use crate::model::ToolCallSummary;
use crate::payload::RawPayloadKind;
use crate::raw_event::RawTraceEventPayload;
use crate::reducer::test_support::append_inference_completion;
use crate::reducer::test_support::append_inference_start;
use crate::reducer::test_support::create_started_writer;
use crate::reducer::test_support::expect_replay_error;
use crate::reducer::test_support::message;
use crate::reducer::test_support::start_turn;
use crate::reducer::test_support::trace_context;
use crate::replay_bundle;

#[test]
fn request_snapshots_reuse_history_without_deduping_new_identical_items() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let first_request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [message("user", "ok")]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", first_request)?;
    start_turn(&writer, "turn-2")?;

    let second_request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [
                message("user", "ok"),
                message("assistant", "ack"),
                message("user", "ok")
            ]
        }),
    )?;
    append_inference_start(&writer, "inference-2", "turn-2", second_request)?;

    let rollout = replay_bundle(temp.path())?;
    let first = &rollout.inference_calls["inference-1"].request_item_ids;
    let second = &rollout.inference_calls["inference-2"].request_item_ids;

    assert_eq!(first.len(), 1);
    assert_eq!(second.len(), 3);
    assert_eq!(second[0], first[0]);
    assert_ne!(second[2], first[0]);
    assert_eq!(rollout.conversation_items.len(), 3);
    assert_eq!(
        rollout.threads["thread-root"].conversation_item_ids,
        *second
    );

    Ok(())
}

#[test]
fn response_outputs_enter_thread_conversation_on_completion() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [message("user", "run tests")]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", request)?;

    let response = writer.write_json_payload(
        RawPayloadKind::InferenceResponse,
        &json!({
            "response_id": "resp-1",
            "output_items": [
                {
                    "type": "message",
                    "role": "assistant",
                    "content": [{"type": "output_text", "text": "tests passed"}]
                }
            ]
        }),
    )?;
    append_inference_completion(&writer, "inference-1", "resp-1", response)?;

    let rollout = replay_bundle(temp.path())?;
    let inference = &rollout.inference_calls["inference-1"];
    let mut expected_thread_items = inference.request_item_ids.clone();
    expected_thread_items.extend(inference.response_item_ids.clone());

    assert_eq!(inference.response_item_ids.len(), 1);
    assert_eq!(
        rollout.threads["thread-root"].conversation_item_ids,
        expected_thread_items,
    );

    Ok(())
}

#[test]
fn agent_messages_preserve_routing_and_content() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [
                {
                    "type": "agent_message",
                    "author": "/root/worker",
                    "recipient": "/root",
                    "content": [{"type": "input_text", "text": "done"}]
                },
                {
                    "type": "agent_message",
                    "author": "/root",
                    "recipient": "/root/worker",
                    "content": [{
                        "type": "encrypted_content",
                        "encrypted_content": "encrypted-task"
                    }]
                }
            ]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", request)?;

    let rollout = replay_bundle(temp.path())?;
    let actual = rollout.inference_calls["inference-1"]
        .request_item_ids
        .iter()
        .map(|item_id| {
            let item = &rollout.conversation_items[item_id];
            (
                item.role.clone(),
                item.channel.clone(),
                item.kind.clone(),
                item.agent_message.clone(),
                item.body.clone(),
            )
        })
        .collect::<Vec<_>>();

    assert_eq!(
        actual,
        vec![
            (
                ConversationRole::Assistant,
                Some(ConversationChannel::Analysis),
                ConversationItemKind::Message,
                Some(AgentMessageMetadata {
                    author: "/root/worker".to_string(),
                    recipient: "/root".to_string(),
                }),
                ConversationBody {
                    parts: vec![ConversationPart::Text {
                        text: "done".to_string(),
                    }],
                },
            ),
            (
                ConversationRole::Assistant,
                Some(ConversationChannel::Analysis),
                ConversationItemKind::Message,
                Some(AgentMessageMetadata {
                    author: "/root".to_string(),
                    recipient: "/root/worker".to_string(),
                }),
                ConversationBody {
                    parts: vec![ConversationPart::Encoded {
                        label: "encrypted_content".to_string(),
                        value: "encrypted-task".to_string(),
                    }],
                },
            ),
        ]
    );

    Ok(())
}

#[test]
fn later_full_request_reuses_prior_json_tool_call_by_position() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [message("user", "run tests")]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", request)?;

    let response = writer.write_json_payload(
        RawPayloadKind::InferenceResponse,
        &json!({
            "response_id": "resp-1",
            "output_items": [{
                "type": "function_call",
                "name": "shell",
                "arguments": "{\"cmd\":\"cargo test\"}",
                "call_id": "call-1"
            }]
        }),
    )?;
    append_inference_completion(&writer, "inference-1", "resp-1", response)?;
    start_turn(&writer, "turn-2")?;

    let next_request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [
                message("user", "run tests"),
                {
                    "type": "function_call",
                    "name": "shell",
                    "arguments": "{\"cmd\":\"cargo test\"}",
                    "call_id": "call-1"
                }
            ]
        }),
    )?;
    append_inference_start(&writer, "inference-2", "turn-2", next_request)?;

    let rollout = replay_bundle(temp.path())?;
    let first = &rollout.inference_calls["inference-1"];
    let second = &rollout.inference_calls["inference-2"];

    assert_eq!(
        second.request_item_ids,
        vec![
            first.request_item_ids[0].clone(),
            first.response_item_ids[0].clone(),
        ],
    );
    assert_eq!(rollout.conversation_items.len(), 2);

    Ok(())
}

#[test]
fn incremental_request_carries_prior_request_and_response_items_forward() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [message("user", "run tests")]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", request)?;

    let response = writer.write_json_payload(
        RawPayloadKind::InferenceResponse,
        &json!({
            "response_id": "resp-1",
            "token_usage": {
                "input_tokens": 10,
                "cached_input_tokens": 1,
                "output_tokens": 5,
                "reasoning_output_tokens": 2,
                "total_tokens": 15
            },
            "output_items": [
                {
                    "type": "function_call",
                    "name": "shell",
                    "arguments": "{\"cmd\":\"cargo test\"}",
                    "call_id": "call-1"
                }
            ]
        }),
    )?;
    append_inference_completion(&writer, "inference-1", "resp-1", response)?;
    start_turn(&writer, "turn-2")?;

    let incremental_request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "type": "response.create",
            "previous_response_id": "resp-1",
            "input": [
                {
                    "type": "function_call_output",
                    "call_id": "call-1",
                    "output": "tests passed"
                }
            ]
        }),
    )?;
    append_inference_start(&writer, "inference-2", "turn-2", incremental_request)?;

    let rollout = replay_bundle(temp.path())?;
    let first = &rollout.inference_calls["inference-1"];
    let second = &rollout.inference_calls["inference-2"];

    assert_eq!(first.response_item_ids.len(), 1);
    assert_eq!(
        second.request_item_ids,
        vec![
            first.request_item_ids[0].clone(),
            first.response_item_ids[0].clone(),
            rollout.threads["thread-root"].conversation_item_ids[2].clone(),
        ],
    );
    assert_eq!(
        rollout.threads["thread-root"].conversation_item_ids,
        second.request_item_ids,
    );
    assert_eq!(
        first.usage.as_ref().map(|usage| usage.input_tokens),
        Some(10),
    );

    Ok(())
}

#[test]
fn full_request_snapshot_can_reorder_existing_items_and_insert_summary() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [
                message("developer", "follow the repo rules"),
                message("user", "count files")
            ]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", request)?;
    start_turn(&writer, "turn-2")?;

    let compacted_request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [
                message("user", "count files"),
                message("user", "summary from a compacted prior attempt"),
                message("developer", "follow the repo rules")
            ]
        }),
    )?;
    append_inference_start(&writer, "inference-2", "turn-2", compacted_request)?;

    let rollout = replay_bundle(temp.path())?;
    let first = &rollout.inference_calls["inference-1"].request_item_ids;
    let second = &rollout.inference_calls["inference-2"].request_item_ids;

    assert_eq!(second[0], first[1]);
    assert_eq!(second[2], first[0]);
    assert_ne!(second[1], first[0]);
    assert_ne!(second[1], first[1]);
    assert_eq!(rollout.conversation_items.len(), 3);

    Ok(())
}

#[test]
fn reasoning_body_preserves_text_summary_and_encoded_content() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [message("user", "think visibly")]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", request)?;

    let response = writer.write_json_payload(
        RawPayloadKind::InferenceResponse,
        &json!({
            "response_id": "resp-1",
            "output_items": [{
                "type": "reasoning",
                "content": [{"type": "reasoning_text", "text": "raw reasoning"}],
                "summary": [{"type": "summary_text", "text": "brief summary"}],
                "encrypted_content": "encoded-reasoning"
            }]
        }),
    )?;
    append_inference_completion(&writer, "inference-1", "resp-1", response)?;

    let rollout = replay_bundle(temp.path())?;
    let reasoning_item_id = &rollout.inference_calls["inference-1"].response_item_ids[0];

    assert_eq!(
        rollout.conversation_items[reasoning_item_id].body.parts,
        vec![
            ConversationPart::Text {
                text: "raw reasoning".to_string(),
            },
            ConversationPart::Summary {
                text: "brief summary".to_string(),
            },
            ConversationPart::Encoded {
                label: "encrypted_content".to_string(),
                value: "encoded-reasoning".to_string(),
            },
        ],
    );

    Ok(())
}

#[test]
fn encrypted_reasoning_reuses_response_item_in_later_request() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let user = message("user", "count files");
    let function_call = json!({
        "type": "function_call",
        "name": "shell",
        "arguments": "{\"cmd\":\"find . -maxdepth 1 -type f | wc -l\"}",
        "call_id": "call-1"
    });
    let encrypted_reasoning = json!({
        "type": "reasoning",
        "summary": [],
        "encrypted_content": "encoded-reasoning"
    });
    let readable_reasoning = json!({
        "type": "reasoning",
        "content": [{"type": "text", "text": "need count"}],
        "summary": [],
        "encrypted_content": "encoded-reasoning"
    });

    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [user]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", request)?;

    let response = writer.write_json_payload(
        RawPayloadKind::InferenceResponse,
        &json!({
            "response_id": "resp-1",
            "output_items": [
                readable_reasoning,
                function_call
            ]
        }),
    )?;
    append_inference_completion(&writer, "inference-1", "resp-1", response)?;
    start_turn(&writer, "turn-2")?;

    let followup = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [
                user,
                encrypted_reasoning,
                function_call,
                {
                    "type": "function_call_output",
                    "call_id": "call-1",
                    "output": "31\n"
                }
            ]
        }),
    )?;
    append_inference_start(&writer, "inference-2", "turn-2", followup)?;

    let rollout = replay_bundle(temp.path())?;
    let first = &rollout.inference_calls["inference-1"];
    let second = &rollout.inference_calls["inference-2"];
    let output_item_id = rollout.threads["thread-root"].conversation_item_ids[3].clone();

    assert_eq!(
        second.request_item_ids,
        vec![
            first.request_item_ids[0].clone(),
            first.response_item_ids[0].clone(),
            first.response_item_ids[1].clone(),
            output_item_id,
        ],
    );
    assert_eq!(
        rollout.conversation_items[&first.response_item_ids[0]]
            .body
            .parts,
        vec![
            ConversationPart::Text {
                text: "need count".to_string(),
            },
            ConversationPart::Encoded {
                label: "encrypted_content".to_string(),
                value: "encoded-reasoning".to_string(),
            },
        ],
    );
    assert_eq!(rollout.conversation_items.len(), 4);
    assert_eq!(
        rollout.threads["thread-root"].conversation_item_ids,
        second.request_item_ids,
    );

    Ok(())
}

#[test]
fn encrypted_reasoning_upgrades_when_later_sighting_has_more_readable_body() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let user = message("user", "count files");
    // Both sightings carry the same encrypted identity, but each has a
    // different kind of readable evidence. The reducer should keep both
    // observations because text and summary are complementary.
    let text_only_reasoning = json!({
        "type": "reasoning",
        "content": [{"type": "text", "text": "need count"}],
        "summary": [],
        "encrypted_content": "encoded-reasoning"
    });
    let summary_only_reasoning = json!({
        "type": "reasoning",
        "summary": [{"type": "summary_text", "text": "counting files"}],
        "encrypted_content": "encoded-reasoning"
    });

    let first_request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [user, text_only_reasoning]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", first_request)?;
    start_turn(&writer, "turn-2")?;

    let second_request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [user, summary_only_reasoning]
        }),
    )?;
    append_inference_start(&writer, "inference-2", "turn-2", second_request)?;

    let rollout = replay_bundle(temp.path())?;
    let first = &rollout.inference_calls["inference-1"];
    let second = &rollout.inference_calls["inference-2"];
    let reasoning_item_id = &first.request_item_ids[1];

    // The reducer should keep one conversation item and merge the missing
    // readable kind without treating the second sighting as a conflict.
    assert_eq!(&second.request_item_ids[1], reasoning_item_id);
    assert_eq!(
        rollout.conversation_items[reasoning_item_id].body.parts,
        vec![
            ConversationPart::Text {
                text: "need count".to_string(),
            },
            ConversationPart::Summary {
                text: "counting files".to_string(),
            },
            ConversationPart::Encoded {
                label: "encrypted_content".to_string(),
                value: "encoded-reasoning".to_string(),
            },
        ],
    );
    assert_eq!(rollout.conversation_items.len(), 2);

    Ok(())
}

#[test]
fn same_encrypted_reasoning_with_different_text_reuses_first_readable_body() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let user = message("user", "count files");
    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [user]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", request)?;

    // The response is the first readable observation for this encrypted
    // reasoning blob, so it is the body later conflicting sightings must not
    // overwrite.
    let response = writer.write_json_payload(
        RawPayloadKind::InferenceResponse,
        &json!({
            "response_id": "resp-1",
            "output_items": [{
                "type": "reasoning",
                "content": [{"type": "text", "text": "first text"}],
                "summary": [],
                "encrypted_content": "encoded-reasoning"
            }]
        }),
    )?;
    append_inference_completion(&writer, "inference-1", "resp-1", response)?;
    start_turn(&writer, "turn-2")?;

    let conflicting_request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [
                user,
                {
                    "type": "reasoning",
                    "content": [{"type": "text", "text": "different text"}],
                    "summary": [],
                    "encrypted_content": "encoded-reasoning"
                }
            ]
        }),
    )?;
    append_inference_start(&writer, "inference-2", "turn-2", conflicting_request)?;

    let rollout = replay_bundle(temp.path())?;
    let first = &rollout.inference_calls["inference-1"];
    let second = &rollout.inference_calls["inference-2"];
    let reasoning_item_id = &first.response_item_ids[0];

    // Same encrypted identity still reuses the response item, but conflicting
    // readable text is not a safe upgrade.
    assert_eq!(
        second.request_item_ids,
        vec![first.request_item_ids[0].clone(), reasoning_item_id.clone(),],
    );
    assert_eq!(
        rollout.conversation_items[reasoning_item_id].body.parts,
        vec![
            ConversationPart::Text {
                text: "first text".to_string(),
            },
            ConversationPart::Encoded {
                label: "encrypted_content".to_string(),
                value: "encoded-reasoning".to_string(),
            },
        ],
    );
    assert_eq!(rollout.conversation_items.len(), 2);

    Ok(())
}

#[test]
fn model_visible_call_id_reuse_with_different_content_is_reducer_error() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [{
                "type": "function_call",
                "name": "shell",
                "arguments": "{\"cmd\":\"cargo test\"}",
                "call_id": "call-1"
            }]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", request)?;
    start_turn(&writer, "turn-2")?;

    let conflicting_request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [{
                "type": "function_call",
                "name": "shell",
                "arguments": "{\"cmd\":\"cargo check\"}",
                "call_id": "call-1"
            }]
        }),
    )?;
    append_inference_start(&writer, "inference-2", "turn-2", conflicting_request)?;

    expect_replay_error(
        &temp,
        "model-visible call id call-1 was reused with different content",
    )
}

#[test]
fn unsupported_model_item_is_reducer_error() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [
                {
                    "type": "new_unhandled_model_item",
                    "payload": "must not be silently skipped"
                }
            ]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", request)?;

    expect_replay_error(
        &temp,
        "unsupported model item type new_unhandled_model_item",
    )
}

#[test]
fn missing_request_input_is_reducer_error() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "model": "gpt-test"
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", request)?;

    expect_replay_error(&temp, "did not contain input")
}

#[test]
fn unknown_previous_response_id_is_reducer_error() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "previous_response_id": "resp-missing",
            "input": [message("user", "still here")]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", request)?;

    expect_replay_error(&temp, "unknown previous_response_id resp-missing")
}

#[test]
fn compaction_boundary_repeats_prefix_and_reuses_replacement_items() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let developer = message("developer", "follow repo rules");
    let user = message("user", "count files");
    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [developer, user]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", request)?;

    let summary = message("user", "summary from compacted history");
    let compaction_summary = json!({
        "type": "compaction",
        "encrypted_content": "encrypted-summary",
    });
    let checkpoint = writer.write_json_payload(
        RawPayloadKind::CompactionCheckpoint,
        &json!({
            "input_history": [developer, user],
            "replacement_history": [user, summary, compaction_summary]
        }),
    )?;
    writer.append_with_context(
        trace_context("turn-1"),
        RawTraceEventPayload::CompactionInstalled {
            compaction_id: "compaction-1".to_string(),
            checkpoint_payload: checkpoint,
        },
    )?;

    start_turn(&writer, "turn-2")?;
    let post_compaction_request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [developer, user, summary, compaction_summary]
        }),
    )?;
    append_inference_start(&writer, "inference-2", "turn-2", post_compaction_request)?;

    let rollout = replay_bundle(temp.path())?;
    let first = &rollout.inference_calls["inference-1"];
    let second = &rollout.inference_calls["inference-2"];
    let compaction = &rollout.compactions["compaction-1"];

    assert_eq!(compaction.input_item_ids, first.request_item_ids);
    assert_eq!(second.request_item_ids.len(), 4);
    assert_eq!(
        &second.request_item_ids[1..],
        compaction.replacement_item_ids.as_slice()
    );
    let marker = &rollout.conversation_items[&compaction.marker_item_id];
    assert_eq!(marker.kind, ConversationItemKind::CompactionMarker);
    assert_eq!(marker.body.parts, Vec::<ConversationPart>::new());
    assert_eq!(
        marker.produced_by,
        vec![ProducerRef::Compaction {
            compaction_id: "compaction-1".to_string()
        }],
    );
    assert_ne!(second.request_item_ids[0], first.request_item_ids[0]);
    assert_ne!(
        compaction.replacement_item_ids[0],
        first.request_item_ids[1]
    );
    assert_eq!(
        rollout.conversation_items[&compaction.replacement_item_ids[0]].produced_by,
        vec![ProducerRef::Compaction {
            compaction_id: "compaction-1".to_string()
        }],
    );
    assert_eq!(
        rollout.conversation_items[&compaction.replacement_item_ids[1]].produced_by,
        vec![ProducerRef::Compaction {
            compaction_id: "compaction-1".to_string()
        }],
    );
    assert_eq!(
        rollout.conversation_items[&compaction.replacement_item_ids[2]].channel,
        Some(ConversationChannel::Summary),
    );
    assert_eq!(
        rollout.conversation_items[&compaction.replacement_item_ids[2]].kind,
        ConversationItemKind::Message,
    );
    assert_eq!(
        rollout.conversation_items[&compaction.replacement_item_ids[2]]
            .body
            .parts,
        vec![ConversationPart::Encoded {
            label: "encrypted_content".to_string(),
            value: "encrypted-summary".to_string(),
        }],
    );

    Ok(())
}

#[test]
fn context_compaction_boundary_repeats_prefix_and_reuses_replacement_items() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let developer = message("developer", "follow repo rules");
    let user = message("user", "count files");
    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [developer, user]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", request)?;

    let summary = message("user", "summary from compacted history");
    let compaction_summary = json!({
        "type": "context_compaction",
        "encrypted_content": "encrypted-summary",
    });
    let checkpoint = writer.write_json_payload(
        RawPayloadKind::CompactionCheckpoint,
        &json!({
            "input_history": [developer, user],
            "replacement_history": [user, summary, compaction_summary]
        }),
    )?;
    writer.append_with_context(
        trace_context("turn-1"),
        RawTraceEventPayload::CompactionInstalled {
            compaction_id: "compaction-1".to_string(),
            checkpoint_payload: checkpoint,
        },
    )?;

    start_turn(&writer, "turn-2")?;
    let post_compaction_request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [developer, user, summary, compaction_summary]
        }),
    )?;
    append_inference_start(&writer, "inference-2", "turn-2", post_compaction_request)?;

    let rollout = replay_bundle(temp.path())?;
    let compaction = &rollout.compactions["compaction-1"];

    assert_eq!(
        rollout.conversation_items[&compaction.replacement_item_ids[2]].channel,
        Some(ConversationChannel::Summary),
    );
    assert_eq!(
        rollout.conversation_items[&compaction.replacement_item_ids[2]].kind,
        ConversationItemKind::Message,
    );
    assert_eq!(
        rollout.conversation_items[&compaction.replacement_item_ids[2]]
            .body
            .parts,
        vec![ConversationPart::Encoded {
            label: "encrypted_content".to_string(),
            value: "encrypted-summary".to_string(),
        }],
    );

    Ok(())
}

#[test]
fn tool_call_links_model_call_and_followup_output_items() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;
    start_turn(&writer, "turn-1")?;

    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [message("user", "run tests")]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-1", request)?;

    let response = writer.write_json_payload(
        RawPayloadKind::InferenceResponse,
        &json!({
            "response_id": "resp-1",
            "output_items": [{
                "type": "function_call",
                "name": "exec_command",
                "arguments": "{\"cmd\":\"cargo test\"}",
                "call_id": "call-1"
            }]
        }),
    )?;
    append_inference_completion(&writer, "inference-1", "resp-1", response)?;
    writer.append_with_context(
        trace_context("turn-1"),
        RawTraceEventPayload::ToolCallStarted {
            tool_call_id: "tool-1".to_string(),
            model_visible_call_id: Some("call-1".to_string()),
            code_mode_runtime_tool_id: None,
            requester: crate::raw_event::RawToolCallRequester::Model,
            kind: ToolCallKind::ExecCommand,
            summary: ToolCallSummary::Generic {
                label: "exec_command".to_string(),
                input_preview: Some("cargo test".to_string()),
                output_preview: None,
            },
            invocation_payload: None,
        },
    )?;
    writer.append_with_context(
        trace_context("turn-1"),
        RawTraceEventPayload::ToolCallEnded {
            tool_call_id: "tool-1".to_string(),
            status: ExecutionStatus::Completed,
            result_payload: None,
        },
    )?;

    start_turn(&writer, "turn-2")?;
    let followup = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "previous_response_id": "resp-1",
            "input": [{
                "type": "function_call_output",
                "call_id": "call-1",
                "output": "tests passed"
            }]
        }),
    )?;
    append_inference_start(&writer, "inference-2", "turn-2", followup)?;

    let rollout = replay_bundle(temp.path())?;
    let first_inference = &rollout.inference_calls["inference-1"];
    let second_inference = &rollout.inference_calls["inference-2"];
    let tool_call = &rollout.tool_calls["tool-1"];
    let output_item_id = second_inference
        .request_item_ids
        .last()
        .expect("follow-up output item");

    assert_eq!(
        first_inference.tool_call_ids_started_by_response,
        vec!["tool-1".to_string()],
    );
    assert_eq!(
        tool_call.model_visible_call_item_ids,
        first_inference.response_item_ids,
    );
    assert_eq!(
        tool_call.model_visible_output_item_ids,
        vec![output_item_id.clone()],
    );
    assert_eq!(
        rollout.conversation_items[output_item_id].produced_by,
        vec![ProducerRef::Tool {
            tool_call_id: "tool-1".to_string(),
        }],
    );

    Ok(())
}

#[test]
fn inference_start_rejects_unknown_codex_turn() -> anyhow::Result<()> {
    let temp = TempDir::new()?;
    let writer = create_started_writer(&temp)?;

    let request = writer.write_json_payload(
        RawPayloadKind::InferenceRequest,
        &json!({
            "input": [message("user", "hello")]
        }),
    )?;
    append_inference_start(&writer, "inference-1", "turn-missing", request)?;

    expect_replay_error(&temp, "referenced unknown codex turn turn-missing")
}
