Skip to main content

zeph_session/
replay.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! [`ReplayEngine`]: deterministic fold of a session's event log into agent-ready messages.
5//!
6//! Replay never calls the LLM or a tool executor (spec §6.2, §15 NEVER) — it only folds
7//! previously recorded events. This is the correctness guarantee behind AC-2 (byte-identical
8//! replay) and the foundation `ForkEngine` (spec §7) builds on.
9
10use std::ops::ControlFlow;
11use std::path::Path;
12
13use zeph_llm::provider::{Message, MessagePart, Role};
14
15use crate::error::SessionError;
16use crate::event::{SessionEvent, SessionEventEnvelope};
17use crate::log::SessionEventLog;
18
19/// The result of folding a session's event log up to some point.
20#[derive(Debug, Clone, Default)]
21pub struct ReconstructedState {
22    /// Agent-ready message history, ready for hydration into `MessageState`.
23    pub messages: Vec<Message>,
24    /// The highest `seq` folded, or `None` if the log was empty.
25    pub last_seq: Option<u64>,
26    pub provider_name: String,
27    pub model: String,
28    pub cwd: String,
29}
30
31/// Folds a session's `events.jsonl` into a [`ReconstructedState`].
32pub struct ReplayEngine;
33
34impl ReplayEngine {
35    /// Replay the session log at `session_dir`.
36    ///
37    /// `up_to`, if set, is an *exclusive* upper bound on `seq` (used by `ForkEngine` to replay
38    /// only the prefix being copied). `None` replays the full log (resume).
39    ///
40    /// Reads the log in bounded chunks (spec §6.2 step 3: ≤ 100 raw envelopes in memory at
41    /// once) rather than materializing the whole file's parsed events into one `Vec` first —
42    /// unlike [`Self::fold`], which operates on an already-materialized `Vec` for callers that
43    /// already hold the events in memory (e.g. `llm_condenser.rs`, which folds an
44    /// already-sliced sub-`Vec`). `ForkEngine::fork` copies raw events via
45    /// `SessionEventLog::read_all` directly and calls this method (not `Self::fold`) only to
46    /// validate the cut point.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`SessionError::Io`] if the log cannot be opened/read.
51    #[tracing::instrument(name = "session.replay.run", skip_all, level = "debug")]
52    pub async fn replay(
53        session_dir: &Path,
54        up_to: Option<u64>,
55    ) -> Result<ReconstructedState, SessionError> {
56        let log = SessionEventLog::open(session_dir).await?;
57
58        let mut messages: Vec<Message> = Vec::new();
59        let mut origin_seqs: Vec<u64> = Vec::new();
60        let mut state = ReconstructedState::default();
61
62        log.read_chunked(|chunk| {
63            for envelope in chunk {
64                if fold_step(&mut state, &mut messages, &mut origin_seqs, envelope, up_to)
65                    .is_break()
66                {
67                    return ControlFlow::Break(());
68                }
69            }
70            ControlFlow::Continue(())
71        })
72        .await?;
73
74        state.messages = messages;
75        Ok(state)
76    }
77
78    /// Fold a sequence of envelopes already read from disk. Exposed separately from
79    /// [`Self::replay`] so callers that already hold the events (e.g. a live `SessionActor`
80    /// applying its own just-appended event, or `llm_condenser.rs`'s condense step folding an
81    /// already-sliced sub-`Vec`) can fold incrementally without re-reading the file.
82    #[must_use]
83    #[tracing::instrument(
84        name = "session.replay.fold",
85        skip_all,
86        level = "debug",
87        fields(event_count = events.len())
88    )]
89    pub fn fold(events: Vec<SessionEventEnvelope>, up_to: Option<u64>) -> ReconstructedState {
90        let mut messages: Vec<Message> = Vec::new();
91        // Parallel to `messages`: the seq of the event that produced each message, so a later
92        // `Condensation`/`Compaction` event can replace exactly the messages in its range.
93        let mut origin_seqs: Vec<u64> = Vec::new();
94        let mut state = ReconstructedState::default();
95
96        for envelope in events {
97            if fold_step(&mut state, &mut messages, &mut origin_seqs, envelope, up_to).is_break() {
98                break;
99            }
100        }
101
102        state.messages = messages;
103        state
104    }
105}
106
107/// Applies one envelope's effect to the running replay state (`state`, `messages`,
108/// `origin_seqs`). Shared by [`ReplayEngine::fold`] (iterating an in-memory `Vec`) and
109/// [`ReplayEngine::replay`] (iterating envelopes as they arrive from a chunked file read) so both
110/// paths apply identical fold semantics.
111///
112/// Returns [`ControlFlow::Break`] once `up_to` is reached, without applying `envelope` —
113/// callers must stop folding further envelopes.
114fn fold_step(
115    state: &mut ReconstructedState,
116    messages: &mut Vec<Message>,
117    origin_seqs: &mut Vec<u64>,
118    envelope: SessionEventEnvelope,
119    up_to: Option<u64>,
120) -> ControlFlow<()> {
121    if let Some(bound) = up_to
122        && envelope.seq >= bound
123    {
124        return ControlFlow::Break(());
125    }
126    let seq = envelope.seq;
127    state.last_seq = Some(seq);
128
129    match envelope.kind {
130        SessionEvent::SessionStarted {
131            cwd,
132            provider_name,
133            model,
134            ..
135        } => {
136            state.cwd = cwd;
137            state.provider_name = provider_name;
138            state.model = model;
139        }
140        SessionEvent::UserMessage { text, .. } => {
141            messages.push(Message::from_legacy(Role::User, text));
142            origin_seqs.push(seq);
143        }
144        SessionEvent::AssistantMessage { parts } => {
145            messages.push(Message::from_parts(Role::Assistant, parts));
146            origin_seqs.push(seq);
147        }
148        SessionEvent::ToolCall { id, name, input } => {
149            push_part_to_last_assistant(
150                messages,
151                origin_seqs,
152                seq,
153                MessagePart::ToolUse { id, name, input },
154            );
155        }
156        SessionEvent::ToolResult {
157            id,
158            output,
159            is_error,
160            ..
161        } => {
162            push_part_to_tool_result_batch(
163                messages,
164                origin_seqs,
165                seq,
166                MessagePart::ToolResult {
167                    tool_use_id: id,
168                    content: output,
169                    is_error,
170                },
171            );
172        }
173        SessionEvent::Condensation {
174            replaced_seq_range: (lo, hi),
175            summary,
176            ..
177        } => {
178            replace_range(messages, origin_seqs, lo, hi, summary.to_markdown());
179        }
180        SessionEvent::Compaction { summary, .. } => {
181            // Compaction's schema (spec §4.3) does not carry an explicit `replaced_seq_range`
182            // the way `Condensation` does — it is emitted from the live in-memory compactor,
183            // which prunes by message count, not by logged seq. Until P2 wires real emission
184            // (zeph-agent-persistence) and settles the exact seq-range accounting, fold
185            // conservatively: a recorded summary replaces everything folded so far. No-op when
186            // `summary` is absent (a soft-tier prune that dropped raw tool output but produced
187            // no summary).
188            if let Some(summary) = summary {
189                let hi = origin_seqs.last().copied().unwrap_or(seq);
190                replace_range(messages, origin_seqs, 0, hi, summary.to_markdown());
191            }
192        }
193        SessionEvent::ModelChanged {
194            provider_name,
195            model,
196        } => {
197            state.provider_name = provider_name;
198            state.model = model;
199        }
200        SessionEvent::ForkPoint { .. } | SessionEvent::SessionEnded { .. } => {}
201    }
202
203    ControlFlow::Continue(())
204}
205
206/// Append `part` (a `MessagePart::ToolUse`) to the last message if it is a pending `Assistant`
207/// message; otherwise start a new one. `ToolCall` events always follow the `AssistantMessage`
208/// that requested them within the same turn, but the fold does not assume `AssistantMessage` was
209/// itself logged first (a tool-only turn is valid).
210fn push_part_to_last_assistant(
211    messages: &mut Vec<Message>,
212    origin_seqs: &mut Vec<u64>,
213    seq: u64,
214    part: MessagePart,
215) {
216    if let Some(last) = messages.last_mut()
217        && last.role == Role::Assistant
218    {
219        last.parts.push(part);
220        return;
221    }
222    messages.push(Message::from_parts(Role::Assistant, vec![part]));
223    origin_seqs.push(seq);
224}
225
226/// Append `part` (a `MessagePart::ToolResult`) to the last message if it is an already-open
227/// tool-result batch; otherwise start a new `Role::User` message.
228///
229/// `zeph-llm`'s `OpenAI` and Claude serializers require every tool result to arrive in a
230/// `Role::User` message, never merged into the preceding `Role::Assistant` message that carried
231/// the matching `MessagePart::ToolUse` (#5464) — this mirrors the real shape
232/// `process_tool_result_batch` in `crates/zeph-core/src/agent/tool_execution/tier_loop.rs`
233/// produces live: one `Role::User` message per tool-call batch, holding one `ToolResult` part per
234/// tool. "Already-open batch" is a `Role::User` message with non-empty `parts` that are all
235/// `ToolResult` — a genuine `SessionEvent::UserMessage` always folds to empty `parts`
236/// ([`Message::from_legacy`]), so this never merges into a real user turn.
237fn push_part_to_tool_result_batch(
238    messages: &mut Vec<Message>,
239    origin_seqs: &mut Vec<u64>,
240    seq: u64,
241    part: MessagePart,
242) {
243    let is_open_batch = messages.last().is_some_and(|m| {
244        m.role == Role::User
245            && !m.parts.is_empty()
246            && m.parts
247                .iter()
248                .all(|p| matches!(p, MessagePart::ToolResult { .. }))
249    });
250    if is_open_batch {
251        let last = messages.last_mut().expect("checked by is_open_batch above");
252        last.parts.push(part);
253        last.rebuild_content();
254        return;
255    }
256    messages.push(Message::from_parts(Role::User, vec![part]));
257    origin_seqs.push(seq);
258}
259
260/// Replace every message whose origin `seq` falls within `[lo, hi]` (inclusive) with a single
261/// system summary message, preserving the position of the first replaced message.
262fn replace_range(
263    messages: &mut Vec<Message>,
264    origin_seqs: &mut Vec<u64>,
265    lo: u64,
266    hi: u64,
267    summary_text: String,
268) {
269    let mut new_messages = Vec::with_capacity(messages.len());
270    let mut new_seqs = Vec::with_capacity(origin_seqs.len());
271    let mut inserted = false;
272
273    for (message, seq) in messages.drain(..).zip(origin_seqs.drain(..)) {
274        if seq >= lo && seq <= hi {
275            if !inserted {
276                new_messages.push(Message::from_parts(
277                    Role::System,
278                    vec![MessagePart::Summary {
279                        text: summary_text.clone(),
280                    }],
281                ));
282                new_seqs.push(lo);
283                inserted = true;
284            }
285            continue;
286        }
287        new_messages.push(message);
288        new_seqs.push(seq);
289    }
290
291    if !inserted {
292        new_messages.push(Message::from_parts(
293            Role::System,
294            vec![MessagePart::Summary { text: summary_text }],
295        ));
296        new_seqs.push(lo);
297    }
298
299    *messages = new_messages;
300    *origin_seqs = new_seqs;
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use zeph_common::memory::AnchoredSummary;
307
308    fn envelope(seq: u64, kind: SessionEvent) -> SessionEventEnvelope {
309        SessionEventEnvelope::new(seq, None, None, kind)
310    }
311
312    #[tokio::test]
313    async fn test_replay_empty_session() {
314        let dir = tempfile::tempdir().unwrap();
315        let state = ReplayEngine::replay(dir.path(), None).await.unwrap();
316        assert!(state.messages.is_empty());
317        assert!(state.last_seq.is_none());
318    }
319
320    #[tokio::test]
321    async fn test_replay_basic_turn() {
322        let dir = tempfile::tempdir().unwrap();
323        let log = SessionEventLog::open(dir.path()).await.unwrap();
324        log.append(
325            None,
326            None,
327            SessionEvent::SessionStarted {
328                session_id: "s1".to_owned(),
329                cwd: "/repo".to_owned(),
330                provider_name: "claude".to_owned(),
331                model: "opus".to_owned(),
332                forked_from: None,
333            },
334        )
335        .await
336        .unwrap();
337        log.append(
338            Some(1),
339            None,
340            SessionEvent::UserMessage {
341                text: "hi".to_owned(),
342                image_refs: vec![],
343            },
344        )
345        .await
346        .unwrap();
347        log.append(
348            Some(1),
349            None,
350            SessionEvent::AssistantMessage {
351                parts: vec![MessagePart::Text {
352                    text: "hello".to_owned(),
353                }],
354            },
355        )
356        .await
357        .unwrap();
358
359        let state = ReplayEngine::replay(dir.path(), None).await.unwrap();
360        assert_eq!(state.messages.len(), 2);
361        assert_eq!(state.messages[0].role, Role::User);
362        assert_eq!(state.messages[1].role, Role::Assistant);
363        assert_eq!(state.provider_name, "claude");
364        assert_eq!(state.cwd, "/repo");
365        assert_eq!(state.last_seq, Some(2));
366    }
367
368    #[test]
369    fn test_replay_tool_roundtrip() {
370        let events = vec![
371            envelope(
372                0,
373                SessionEvent::UserMessage {
374                    text: "run ls".to_owned(),
375                    image_refs: vec![],
376                },
377            ),
378            envelope(
379                1,
380                SessionEvent::ToolCall {
381                    id: "tc1".to_owned(),
382                    name: "shell".to_owned(),
383                    input: serde_json::json!({"cmd": "ls"}),
384                },
385            ),
386            envelope(
387                2,
388                SessionEvent::ToolResult {
389                    id: "tc1".to_owned(),
390                    name: "shell".to_owned(),
391                    output: "file.txt".to_owned(),
392                    is_error: false,
393                    duration_ms: 5,
394                },
395            ),
396        ];
397        let state = ReplayEngine::fold(events, None);
398        assert_eq!(
399            state.messages.len(),
400            3,
401            "user message + assistant ToolUse message + user ToolResult message (#5464: a \
402             ToolResult must never merge into the preceding Assistant message — OpenAI/Claude \
403             both require it in a separate Role::User message)"
404        );
405        let assistant = &state.messages[1];
406        assert_eq!(assistant.role, Role::Assistant);
407        assert_eq!(assistant.parts.len(), 1);
408        assert!(matches!(assistant.parts[0], MessagePart::ToolUse { .. }));
409
410        let tool_result_msg = &state.messages[2];
411        assert_eq!(tool_result_msg.role, Role::User);
412        assert_eq!(tool_result_msg.parts.len(), 1);
413        assert!(matches!(
414            tool_result_msg.parts[0],
415            MessagePart::ToolResult { .. }
416        ));
417    }
418
419    #[test]
420    fn test_replay_tool_result_batch_merges_into_one_user_message() {
421        // Multiple ToolResult events from the same tool-call batch (tier_loop.rs's
422        // process_tool_result_batch persists one Role::User message per batch, holding one
423        // ToolResult part per tool) must fold back into a single Role::User message, not one
424        // per event.
425        let events = vec![
426            envelope(
427                0,
428                SessionEvent::AssistantMessage {
429                    parts: vec![
430                        MessagePart::ToolUse {
431                            id: "tc1".to_owned(),
432                            name: "shell".to_owned(),
433                            input: serde_json::json!({}),
434                        },
435                        MessagePart::ToolUse {
436                            id: "tc2".to_owned(),
437                            name: "shell".to_owned(),
438                            input: serde_json::json!({}),
439                        },
440                    ],
441                },
442            ),
443            envelope(
444                1,
445                SessionEvent::ToolResult {
446                    id: "tc1".to_owned(),
447                    name: "shell".to_owned(),
448                    output: "a".to_owned(),
449                    is_error: false,
450                    duration_ms: 1,
451                },
452            ),
453            envelope(
454                2,
455                SessionEvent::ToolResult {
456                    id: "tc2".to_owned(),
457                    name: "shell".to_owned(),
458                    output: "b".to_owned(),
459                    is_error: false,
460                    duration_ms: 1,
461                },
462            ),
463        ];
464        let state = ReplayEngine::fold(events, None);
465        assert_eq!(state.messages.len(), 2);
466        assert_eq!(state.messages[1].role, Role::User);
467        assert_eq!(state.messages[1].parts.len(), 2);
468    }
469
470    #[test]
471    fn test_replay_tool_result_never_merges_into_plain_user_message() {
472        // A genuine SessionEvent::UserMessage (folds to empty `parts`) must never be treated as
473        // an open tool-result batch, even if a ToolResult event immediately follows it.
474        let events = vec![
475            envelope(
476                0,
477                SessionEvent::UserMessage {
478                    text: "hello".to_owned(),
479                    image_refs: vec![],
480                },
481            ),
482            envelope(
483                1,
484                SessionEvent::ToolResult {
485                    id: "tc1".to_owned(),
486                    name: "shell".to_owned(),
487                    output: "a".to_owned(),
488                    is_error: false,
489                    duration_ms: 1,
490                },
491            ),
492        ];
493        let state = ReplayEngine::fold(events, None);
494        assert_eq!(state.messages.len(), 2);
495        assert!(state.messages[0].parts.is_empty());
496        assert_eq!(state.messages[1].parts.len(), 1);
497    }
498
499    #[test]
500    fn test_replay_condensation_folds() {
501        let summary = AnchoredSummary {
502            session_intent: "test".to_owned(),
503            files_modified: vec![],
504            decisions_made: vec![],
505            open_questions: vec![],
506            next_steps: vec!["continue".to_owned()],
507        };
508        let events = vec![
509            envelope(
510                0,
511                SessionEvent::UserMessage {
512                    text: "a".to_owned(),
513                    image_refs: vec![],
514                },
515            ),
516            envelope(
517                1,
518                SessionEvent::AssistantMessage {
519                    parts: vec![MessagePart::Text {
520                        text: "b".to_owned(),
521                    }],
522                },
523            ),
524            envelope(
525                2,
526                SessionEvent::Condensation {
527                    replaced_seq_range: (0, 1),
528                    summary,
529                    tokens_before: 100,
530                    tokens_after: 10,
531                },
532            ),
533            envelope(
534                3,
535                SessionEvent::UserMessage {
536                    text: "c".to_owned(),
537                    image_refs: vec![],
538                },
539            ),
540        ];
541        let state = ReplayEngine::fold(events, None);
542        // The two condensed messages collapse into one summary message, followed by the new one.
543        assert_eq!(state.messages.len(), 2);
544        assert_eq!(state.messages[0].role, Role::System);
545        assert!(matches!(
546            state.messages[0].parts[0],
547            MessagePart::Summary { .. }
548        ));
549        assert_eq!(state.messages[1].role, Role::User);
550    }
551
552    #[test]
553    fn test_replay_stop_at_seq() {
554        let events = vec![
555            envelope(
556                0,
557                SessionEvent::UserMessage {
558                    text: "a".to_owned(),
559                    image_refs: vec![],
560                },
561            ),
562            envelope(
563                1,
564                SessionEvent::UserMessage {
565                    text: "b".to_owned(),
566                    image_refs: vec![],
567                },
568            ),
569            envelope(
570                2,
571                SessionEvent::UserMessage {
572                    text: "c".to_owned(),
573                    image_refs: vec![],
574                },
575            ),
576        ];
577        let state = ReplayEngine::fold(events, Some(2));
578        assert_eq!(state.messages.len(), 2);
579        assert_eq!(state.last_seq, Some(1));
580    }
581
582    /// Seeds `dir` with a synthetic log spanning `n_turns` turns, exercising all 10
583    /// `SessionEvent` variants `fold_step` handles: a tool-call/tool-result pair every 7th turn,
584    /// a plain-text assistant reply otherwise, a `Condensation` and a `Compaction` partway
585    /// through (both exercise `replace_range`, via distinct range-computation logic), and a
586    /// trailing `ForkPoint`/`SessionEnded`/`ModelChanged` (the first two are no-ops in
587    /// `fold_step`; included for completeness). Returns the opened log so the caller can read it
588    /// back either whole-file or chunked.
589    #[allow(clippy::too_many_lines)] // exhaustive fixture covering every SessionEvent variant
590    async fn seed_large_synthetic_log(dir: &Path, n_turns: u64) -> SessionEventLog {
591        use crate::event::CompactionTier;
592        use zeph_common::memory::AnchoredSummary;
593
594        let log = SessionEventLog::open(dir).await.unwrap();
595
596        log.append(
597            None,
598            None,
599            SessionEvent::SessionStarted {
600                session_id: "s1".to_owned(),
601                cwd: "/repo".to_owned(),
602                provider_name: "claude".to_owned(),
603                model: "opus".to_owned(),
604                forked_from: None,
605            },
606        )
607        .await
608        .unwrap();
609
610        for turn in 0..n_turns {
611            log.append(
612                Some(turn),
613                None,
614                SessionEvent::UserMessage {
615                    text: format!("user turn {turn}"),
616                    image_refs: vec![],
617                },
618            )
619            .await
620            .unwrap();
621
622            if turn % 7 == 0 {
623                // A tool-call/tool-result pair every 7th turn.
624                log.append(
625                    Some(turn),
626                    None,
627                    SessionEvent::AssistantMessage { parts: vec![] },
628                )
629                .await
630                .unwrap();
631                log.append(
632                    Some(turn),
633                    None,
634                    SessionEvent::ToolCall {
635                        id: format!("tc-{turn}"),
636                        name: "shell".to_owned(),
637                        input: serde_json::json!({"cmd": "ls"}),
638                    },
639                )
640                .await
641                .unwrap();
642                log.append(
643                    Some(turn),
644                    None,
645                    SessionEvent::ToolResult {
646                        id: format!("tc-{turn}"),
647                        name: "shell".to_owned(),
648                        output: format!("output-{turn}"),
649                        is_error: false,
650                        duration_ms: 3,
651                    },
652                )
653                .await
654                .unwrap();
655            } else {
656                log.append(
657                    Some(turn),
658                    None,
659                    SessionEvent::AssistantMessage {
660                        parts: vec![MessagePart::Text {
661                            text: format!("assistant reply {turn}"),
662                        }],
663                    },
664                )
665                .await
666                .unwrap();
667            }
668
669            if turn == 100 {
670                // A condensation partway through, replacing an already-folded range.
671                log.append(
672                    Some(turn),
673                    None,
674                    SessionEvent::Condensation {
675                        replaced_seq_range: (0, 10),
676                        summary: AnchoredSummary {
677                            session_intent: "test".to_owned(),
678                            files_modified: vec![],
679                            decisions_made: vec![],
680                            open_questions: vec![],
681                            next_steps: vec!["continue".to_owned()],
682                        },
683                        tokens_before: 500,
684                        tokens_after: 50,
685                    },
686                )
687                .await
688                .unwrap();
689            }
690
691            if turn == 150 {
692                // A live hard-compaction partway through, replacing everything folded so far
693                // (Compaction's range-computation differs from Condensation's: it has no
694                // explicit `replaced_seq_range`, see fold_step's comment on this variant).
695                log.append(
696                    Some(turn),
697                    None,
698                    SessionEvent::Compaction {
699                        tier: CompactionTier::Hard,
700                        cleared_count: 42,
701                        summary: Some(AnchoredSummary {
702                            session_intent: "test".to_owned(),
703                            files_modified: vec![],
704                            decisions_made: vec![],
705                            open_questions: vec![],
706                            next_steps: vec!["keep going".to_owned()],
707                        }),
708                    },
709                )
710                .await
711                .unwrap();
712            }
713        }
714
715        // Trailing metadata/no-op events: ForkPoint and SessionEnded are no-ops in `fold_step`,
716        // included so this fixture genuinely covers all 10 SessionEvent variants as documented.
717        log.append(
718            None,
719            None,
720            SessionEvent::ForkPoint {
721                new_session_id: "child-of-s1".to_owned(),
722            },
723        )
724        .await
725        .unwrap();
726        log.append(
727            None,
728            None,
729            SessionEvent::SessionEnded {
730                reason: "user_quit".to_owned(),
731            },
732        )
733        .await
734        .unwrap();
735        log.append(
736            None,
737            None,
738            SessionEvent::ModelChanged {
739                provider_name: "openai".to_owned(),
740                model: "gpt-5.4".to_owned(),
741            },
742        )
743        .await
744        .unwrap();
745
746        log
747    }
748
749    /// Regression test for #5445 Finding 3: `ReplayEngine::replay`'s new chunked-read path must
750    /// produce output equivalent to the old whole-file-`Vec` + `fold` path on a large synthetic
751    /// log spanning several hundred events and every `SessionEvent` variant `fold_step` handles.
752    #[tokio::test]
753    async fn test_replay_streaming_matches_vec_based_fold_on_large_log() {
754        const N_TURNS: u64 = 250; // produces well over 100 events (> one REPLAY_CHUNK_SIZE)
755
756        let dir = tempfile::tempdir().unwrap();
757        let log = seed_large_synthetic_log(dir.path(), N_TURNS).await;
758
759        // Old path: whole-file Vec read + Vec-based fold.
760        let all_events = log.read_all().await.unwrap();
761        assert!(
762            all_events.len() > 100,
763            "synthetic log must exceed one REPLAY_CHUNK_SIZE to exercise multi-chunk streaming"
764        );
765        let vec_based = ReplayEngine::fold(all_events, None);
766
767        // New path: chunked streaming read via ReplayEngine::replay.
768        let streamed = ReplayEngine::replay(dir.path(), None).await.unwrap();
769
770        assert_eq!(streamed.last_seq, vec_based.last_seq);
771        assert_eq!(streamed.provider_name, vec_based.provider_name);
772        assert_eq!(streamed.model, vec_based.model);
773        assert_eq!(streamed.cwd, vec_based.cwd);
774        assert_eq!(streamed.messages.len(), vec_based.messages.len());
775        assert_eq!(
776            serde_json::to_string(&streamed.messages).unwrap(),
777            serde_json::to_string(&vec_based.messages).unwrap(),
778            "streaming replay must be byte-identical to the old Vec-based fold"
779        );
780    }
781
782    /// Regression test for #5841 finding 1: a torn trailing line on a log spanning multiple
783    /// `REPLAY_CHUNK_SIZE` chunks must be dropped identically whether read via
784    /// `ReplayEngine::replay` (new chunked-streaming path) or the old `SessionEventLog::open` +
785    /// `read_all` + `ReplayEngine::fold` whole-file path — the torn-tail check in
786    /// `read_events_chunked` only fires once, at EOF, so it must still catch a torn line that
787    /// falls beyond the last full chunk boundary.
788    #[tokio::test]
789    async fn test_replay_torn_tail_across_chunk_boundary() {
790        const N_TURNS: u64 = 250; // produces well over one REPLAY_CHUNK_SIZE (100) of events
791
792        let dir = tempfile::tempdir().unwrap();
793        let log = seed_large_synthetic_log(dir.path(), N_TURNS).await;
794        let path = log.path().to_path_buf();
795        drop(log);
796
797        // Simulate a torn write: truncate the file mid-way through the last physical line.
798        let full = tokio::fs::read(&path).await.unwrap();
799        let cut = full.len() - 5;
800        tokio::fs::write(&path, &full[..cut]).await.unwrap();
801
802        // Old path: whole-file read (drops the torn line) + Vec-based fold.
803        let whole_file_log = SessionEventLog::open(dir.path()).await.unwrap();
804        let whole_file_events = whole_file_log.read_all().await.unwrap();
805        assert!(
806            whole_file_events.len() > 100,
807            "test must still exceed one REPLAY_CHUNK_SIZE after dropping the torn line"
808        );
809        let expected = ReplayEngine::fold(whole_file_events, None);
810        assert_eq!(
811            expected.last_seq,
812            Some(576),
813            "the torn line must be the trailing seq-577 ModelChanged event (verified against the \
814             real fixture) — a differential-only assertion below would pass vacuously if both \
815             paths regressed identically and stopped dropping the torn tail at all"
816        );
817
818        // New path: chunked-streaming replay on the same torn file.
819        let actual = ReplayEngine::replay(dir.path(), None).await.unwrap();
820
821        assert_eq!(
822            actual.last_seq, expected.last_seq,
823            "chunked replay must drop the torn tail at the same seq as the whole-file path"
824        );
825        assert_eq!(
826            serde_json::to_string(&actual.messages).unwrap(),
827            serde_json::to_string(&expected.messages).unwrap(),
828            "chunked replay must drop a torn tail beyond a chunk boundary identically to the \
829             whole-file read+fold path"
830        );
831    }
832
833    /// Regression test for #5841 finding 2: `ReplayEngine::replay`'s `up_to` bound must behave
834    /// identically to the old Vec-based `ReplayEngine::fold` at and around several
835    /// `REPLAY_CHUNK_SIZE` boundaries (99/100/101, 199/200/201) — `up_to` can land inside a
836    /// chunk still being accumulated, right at a chunk-flush point, or just past one, and the
837    /// chunked path must still stop the fold at the exact same seq the whole-file path does.
838    #[tokio::test]
839    async fn test_replay_up_to_matches_fold_at_chunk_boundaries() {
840        const N_TURNS: u64 = 250; // produces well over 200 events
841
842        let dir = tempfile::tempdir().unwrap();
843        let log = seed_large_synthetic_log(dir.path(), N_TURNS).await;
844        let all_events = log.read_all().await.unwrap();
845        assert!(
846            all_events.len() > 200,
847            "synthetic log must exceed 200 events to exercise several REPLAY_CHUNK_SIZE boundaries"
848        );
849
850        for up_to in [99u64, 100, 101, 199, 200, 201] {
851            let expected = ReplayEngine::fold(all_events.clone(), Some(up_to));
852            let actual = ReplayEngine::replay(dir.path(), Some(up_to)).await.unwrap();
853
854            assert_eq!(
855                actual.last_seq, expected.last_seq,
856                "up_to={up_to}: last_seq mismatch between streamed replay and Vec-based fold"
857            );
858            assert_eq!(
859                serde_json::to_string(&actual.messages).unwrap(),
860                serde_json::to_string(&expected.messages).unwrap(),
861                "up_to={up_to}: streamed replay must match Vec-based fold exactly at/near chunk \
862                 boundaries"
863            );
864        }
865    }
866}