1use 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#[derive(Debug, Clone, Default)]
21pub struct ReconstructedState {
22 pub messages: Vec<Message>,
24 pub last_seq: Option<u64>,
26 pub provider_name: String,
27 pub model: String,
28 pub cwd: String,
29}
30
31pub struct ReplayEngine;
33
34impl ReplayEngine {
35 #[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 #[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 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
107fn 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 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
206fn 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
226fn 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
260fn 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 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 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 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 #[allow(clippy::too_many_lines)] 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 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 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 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 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 #[tokio::test]
753 async fn test_replay_streaming_matches_vec_based_fold_on_large_log() {
754 const N_TURNS: u64 = 250; let dir = tempfile::tempdir().unwrap();
757 let log = seed_large_synthetic_log(dir.path(), N_TURNS).await;
758
759 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 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 #[tokio::test]
789 async fn test_replay_torn_tail_across_chunk_boundary() {
790 const N_TURNS: u64 = 250; 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 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 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 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 #[tokio::test]
839 async fn test_replay_up_to_matches_fold_at_chunk_boundaries() {
840 const N_TURNS: u64 = 250; 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}