use std::ops::ControlFlow;
use std::path::Path;
use zeph_llm::provider::{Message, MessagePart, Role};
use crate::error::SessionError;
use crate::event::{SessionEvent, SessionEventEnvelope};
use crate::log::SessionEventLog;
#[derive(Debug, Clone, Default)]
pub struct ReconstructedState {
pub messages: Vec<Message>,
pub last_seq: Option<u64>,
pub provider_name: String,
pub model: String,
pub cwd: String,
}
pub struct ReplayEngine;
impl ReplayEngine {
#[tracing::instrument(name = "session.replay.run", skip_all, level = "debug")]
pub async fn replay(
session_dir: &Path,
up_to: Option<u64>,
) -> Result<ReconstructedState, SessionError> {
let log = SessionEventLog::open(session_dir).await?;
let mut messages: Vec<Message> = Vec::new();
let mut origin_seqs: Vec<u64> = Vec::new();
let mut state = ReconstructedState::default();
log.read_chunked(|chunk| {
for envelope in chunk {
if fold_step(&mut state, &mut messages, &mut origin_seqs, envelope, up_to)
.is_break()
{
return ControlFlow::Break(());
}
}
ControlFlow::Continue(())
})
.await?;
state.messages = messages;
Ok(state)
}
#[must_use]
#[tracing::instrument(
name = "session.replay.fold",
skip_all,
level = "debug",
fields(event_count = events.len())
)]
pub fn fold(events: Vec<SessionEventEnvelope>, up_to: Option<u64>) -> ReconstructedState {
let mut messages: Vec<Message> = Vec::new();
let mut origin_seqs: Vec<u64> = Vec::new();
let mut state = ReconstructedState::default();
for envelope in events {
if fold_step(&mut state, &mut messages, &mut origin_seqs, envelope, up_to).is_break() {
break;
}
}
state.messages = messages;
state
}
}
fn fold_step(
state: &mut ReconstructedState,
messages: &mut Vec<Message>,
origin_seqs: &mut Vec<u64>,
envelope: SessionEventEnvelope,
up_to: Option<u64>,
) -> ControlFlow<()> {
if let Some(bound) = up_to
&& envelope.seq >= bound
{
return ControlFlow::Break(());
}
let seq = envelope.seq;
state.last_seq = Some(seq);
match envelope.kind {
SessionEvent::SessionStarted {
cwd,
provider_name,
model,
..
} => {
state.cwd = cwd;
state.provider_name = provider_name;
state.model = model;
}
SessionEvent::UserMessage { text, .. } => {
messages.push(Message::from_legacy(Role::User, text));
origin_seqs.push(seq);
}
SessionEvent::AssistantMessage { parts } => {
messages.push(Message::from_parts(Role::Assistant, parts));
origin_seqs.push(seq);
}
SessionEvent::ToolCall { id, name, input } => {
push_part_to_last_assistant(
messages,
origin_seqs,
seq,
MessagePart::ToolUse { id, name, input },
);
}
SessionEvent::ToolResult {
id,
output,
is_error,
..
} => {
push_part_to_tool_result_batch(
messages,
origin_seqs,
seq,
MessagePart::ToolResult {
tool_use_id: id,
content: output,
is_error,
},
);
}
SessionEvent::Condensation {
replaced_seq_range: (lo, hi),
summary,
..
} => {
replace_range(messages, origin_seqs, lo, hi, summary.to_markdown());
}
SessionEvent::Compaction { summary, .. } => {
if let Some(summary) = summary {
let hi = origin_seqs.last().copied().unwrap_or(seq);
replace_range(messages, origin_seqs, 0, hi, summary.to_markdown());
}
}
SessionEvent::ModelChanged {
provider_name,
model,
} => {
state.provider_name = provider_name;
state.model = model;
}
SessionEvent::ForkPoint { .. } | SessionEvent::SessionEnded { .. } => {}
}
ControlFlow::Continue(())
}
fn push_part_to_last_assistant(
messages: &mut Vec<Message>,
origin_seqs: &mut Vec<u64>,
seq: u64,
part: MessagePart,
) {
if let Some(last) = messages.last_mut()
&& last.role == Role::Assistant
{
last.parts.push(part);
return;
}
messages.push(Message::from_parts(Role::Assistant, vec![part]));
origin_seqs.push(seq);
}
fn push_part_to_tool_result_batch(
messages: &mut Vec<Message>,
origin_seqs: &mut Vec<u64>,
seq: u64,
part: MessagePart,
) {
let is_open_batch = messages.last().is_some_and(|m| {
m.role == Role::User
&& !m.parts.is_empty()
&& m.parts
.iter()
.all(|p| matches!(p, MessagePart::ToolResult { .. }))
});
if is_open_batch {
let last = messages.last_mut().expect("checked by is_open_batch above");
last.parts.push(part);
last.rebuild_content();
return;
}
messages.push(Message::from_parts(Role::User, vec![part]));
origin_seqs.push(seq);
}
fn replace_range(
messages: &mut Vec<Message>,
origin_seqs: &mut Vec<u64>,
lo: u64,
hi: u64,
summary_text: String,
) {
let mut new_messages = Vec::with_capacity(messages.len());
let mut new_seqs = Vec::with_capacity(origin_seqs.len());
let mut inserted = false;
for (message, seq) in messages.drain(..).zip(origin_seqs.drain(..)) {
if seq >= lo && seq <= hi {
if !inserted {
new_messages.push(Message::from_parts(
Role::System,
vec![MessagePart::Summary {
text: summary_text.clone(),
}],
));
new_seqs.push(lo);
inserted = true;
}
continue;
}
new_messages.push(message);
new_seqs.push(seq);
}
if !inserted {
new_messages.push(Message::from_parts(
Role::System,
vec![MessagePart::Summary { text: summary_text }],
));
new_seqs.push(lo);
}
*messages = new_messages;
*origin_seqs = new_seqs;
}
#[cfg(test)]
mod tests {
use super::*;
use zeph_common::memory::AnchoredSummary;
fn envelope(seq: u64, kind: SessionEvent) -> SessionEventEnvelope {
SessionEventEnvelope::new(seq, None, None, kind)
}
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
async fn test_replay_empty_session() {
let dir = tempfile::tempdir().unwrap();
let state = ReplayEngine::replay(dir.path(), None).await.unwrap();
assert!(state.messages.is_empty());
assert!(state.last_seq.is_none());
}
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
async fn test_replay_basic_turn() {
let dir = tempfile::tempdir().unwrap();
let log = SessionEventLog::open(dir.path()).await.unwrap();
log.append(
None,
None,
SessionEvent::SessionStarted {
session_id: "s1".to_owned(),
cwd: "/repo".to_owned(),
provider_name: "claude".to_owned(),
model: "opus".to_owned(),
forked_from: None,
},
)
.await
.unwrap();
log.append(
Some(1),
None,
SessionEvent::UserMessage {
text: "hi".to_owned(),
image_refs: vec![],
},
)
.await
.unwrap();
log.append(
Some(1),
None,
SessionEvent::AssistantMessage {
parts: vec![MessagePart::Text {
text: "hello".to_owned(),
}],
},
)
.await
.unwrap();
let state = ReplayEngine::replay(dir.path(), None).await.unwrap();
assert_eq!(state.messages.len(), 2);
assert_eq!(state.messages[0].role, Role::User);
assert_eq!(state.messages[1].role, Role::Assistant);
assert_eq!(state.provider_name, "claude");
assert_eq!(state.cwd, "/repo");
assert_eq!(state.last_seq, Some(2));
}
#[test]
#[serial_test::serial(session_history_integrity)]
fn test_replay_tool_roundtrip() {
let events = vec![
envelope(
0,
SessionEvent::UserMessage {
text: "run ls".to_owned(),
image_refs: vec![],
},
),
envelope(
1,
SessionEvent::ToolCall {
id: "tc1".to_owned(),
name: "shell".to_owned(),
input: serde_json::json!({"cmd": "ls"}),
},
),
envelope(
2,
SessionEvent::ToolResult {
id: "tc1".to_owned(),
name: "shell".to_owned(),
output: "file.txt".to_owned(),
is_error: false,
duration_ms: 5,
},
),
];
let state = ReplayEngine::fold(events, None);
assert_eq!(
state.messages.len(),
3,
"user message + assistant ToolUse message + user ToolResult message (#5464: a \
ToolResult must never merge into the preceding Assistant message — OpenAI/Claude \
both require it in a separate Role::User message)"
);
let assistant = &state.messages[1];
assert_eq!(assistant.role, Role::Assistant);
assert_eq!(assistant.parts.len(), 1);
assert!(matches!(assistant.parts[0], MessagePart::ToolUse { .. }));
let tool_result_msg = &state.messages[2];
assert_eq!(tool_result_msg.role, Role::User);
assert_eq!(tool_result_msg.parts.len(), 1);
assert!(matches!(
tool_result_msg.parts[0],
MessagePart::ToolResult { .. }
));
}
#[test]
#[serial_test::serial(session_history_integrity)]
fn test_replay_tool_result_batch_merges_into_one_user_message() {
let events = vec![
envelope(
0,
SessionEvent::AssistantMessage {
parts: vec![
MessagePart::ToolUse {
id: "tc1".to_owned(),
name: "shell".to_owned(),
input: serde_json::json!({}),
},
MessagePart::ToolUse {
id: "tc2".to_owned(),
name: "shell".to_owned(),
input: serde_json::json!({}),
},
],
},
),
envelope(
1,
SessionEvent::ToolResult {
id: "tc1".to_owned(),
name: "shell".to_owned(),
output: "a".to_owned(),
is_error: false,
duration_ms: 1,
},
),
envelope(
2,
SessionEvent::ToolResult {
id: "tc2".to_owned(),
name: "shell".to_owned(),
output: "b".to_owned(),
is_error: false,
duration_ms: 1,
},
),
];
let state = ReplayEngine::fold(events, None);
assert_eq!(state.messages.len(), 2);
assert_eq!(state.messages[1].role, Role::User);
assert_eq!(state.messages[1].parts.len(), 2);
}
#[test]
#[serial_test::serial(session_history_integrity)]
fn test_replay_tool_result_never_merges_into_plain_user_message() {
let events = vec![
envelope(
0,
SessionEvent::UserMessage {
text: "hello".to_owned(),
image_refs: vec![],
},
),
envelope(
1,
SessionEvent::ToolResult {
id: "tc1".to_owned(),
name: "shell".to_owned(),
output: "a".to_owned(),
is_error: false,
duration_ms: 1,
},
),
];
let state = ReplayEngine::fold(events, None);
assert_eq!(state.messages.len(), 2);
assert!(state.messages[0].parts.is_empty());
assert_eq!(state.messages[1].parts.len(), 1);
}
#[test]
#[serial_test::serial(session_history_integrity)]
fn test_replay_condensation_folds() {
let summary = AnchoredSummary {
session_intent: "test".to_owned(),
files_modified: vec![],
decisions_made: vec![],
open_questions: vec![],
next_steps: vec!["continue".to_owned()],
};
let events = vec![
envelope(
0,
SessionEvent::UserMessage {
text: "a".to_owned(),
image_refs: vec![],
},
),
envelope(
1,
SessionEvent::AssistantMessage {
parts: vec![MessagePart::Text {
text: "b".to_owned(),
}],
},
),
envelope(
2,
SessionEvent::Condensation {
replaced_seq_range: (0, 1),
summary,
tokens_before: 100,
tokens_after: 10,
},
),
envelope(
3,
SessionEvent::UserMessage {
text: "c".to_owned(),
image_refs: vec![],
},
),
];
let state = ReplayEngine::fold(events, None);
assert_eq!(state.messages.len(), 2);
assert_eq!(state.messages[0].role, Role::System);
assert!(matches!(
state.messages[0].parts[0],
MessagePart::Summary { .. }
));
assert_eq!(state.messages[1].role, Role::User);
}
#[test]
#[serial_test::serial(session_history_integrity)]
fn test_replay_stop_at_seq() {
let events = vec![
envelope(
0,
SessionEvent::UserMessage {
text: "a".to_owned(),
image_refs: vec![],
},
),
envelope(
1,
SessionEvent::UserMessage {
text: "b".to_owned(),
image_refs: vec![],
},
),
envelope(
2,
SessionEvent::UserMessage {
text: "c".to_owned(),
image_refs: vec![],
},
),
];
let state = ReplayEngine::fold(events, Some(2));
assert_eq!(state.messages.len(), 2);
assert_eq!(state.last_seq, Some(1));
}
#[allow(clippy::too_many_lines)] async fn seed_large_synthetic_log(dir: &Path, n_turns: u64) -> SessionEventLog {
use crate::event::CompactionTier;
use zeph_common::memory::AnchoredSummary;
let log = SessionEventLog::open(dir).await.unwrap();
log.append(
None,
None,
SessionEvent::SessionStarted {
session_id: "s1".to_owned(),
cwd: "/repo".to_owned(),
provider_name: "claude".to_owned(),
model: "opus".to_owned(),
forked_from: None,
},
)
.await
.unwrap();
for turn in 0..n_turns {
log.append(
Some(turn),
None,
SessionEvent::UserMessage {
text: format!("user turn {turn}"),
image_refs: vec![],
},
)
.await
.unwrap();
if turn % 7 == 0 {
log.append(
Some(turn),
None,
SessionEvent::AssistantMessage { parts: vec![] },
)
.await
.unwrap();
log.append(
Some(turn),
None,
SessionEvent::ToolCall {
id: format!("tc-{turn}"),
name: "shell".to_owned(),
input: serde_json::json!({"cmd": "ls"}),
},
)
.await
.unwrap();
log.append(
Some(turn),
None,
SessionEvent::ToolResult {
id: format!("tc-{turn}"),
name: "shell".to_owned(),
output: format!("output-{turn}"),
is_error: false,
duration_ms: 3,
},
)
.await
.unwrap();
} else {
log.append(
Some(turn),
None,
SessionEvent::AssistantMessage {
parts: vec![MessagePart::Text {
text: format!("assistant reply {turn}"),
}],
},
)
.await
.unwrap();
}
if turn == 100 {
log.append(
Some(turn),
None,
SessionEvent::Condensation {
replaced_seq_range: (0, 10),
summary: AnchoredSummary {
session_intent: "test".to_owned(),
files_modified: vec![],
decisions_made: vec![],
open_questions: vec![],
next_steps: vec!["continue".to_owned()],
},
tokens_before: 500,
tokens_after: 50,
},
)
.await
.unwrap();
}
if turn == 150 {
log.append(
Some(turn),
None,
SessionEvent::Compaction {
tier: CompactionTier::Hard,
cleared_count: 42,
summary: Some(AnchoredSummary {
session_intent: "test".to_owned(),
files_modified: vec![],
decisions_made: vec![],
open_questions: vec![],
next_steps: vec!["keep going".to_owned()],
}),
},
)
.await
.unwrap();
}
}
log.append(
None,
None,
SessionEvent::ForkPoint {
new_session_id: "child-of-s1".to_owned(),
},
)
.await
.unwrap();
log.append(
None,
None,
SessionEvent::SessionEnded {
reason: "user_quit".to_owned(),
},
)
.await
.unwrap();
log.append(
None,
None,
SessionEvent::ModelChanged {
provider_name: "openai".to_owned(),
model: "gpt-5.4".to_owned(),
},
)
.await
.unwrap();
log
}
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
async fn test_replay_streaming_matches_vec_based_fold_on_large_log() {
const N_TURNS: u64 = 250;
let dir = tempfile::tempdir().unwrap();
let log = seed_large_synthetic_log(dir.path(), N_TURNS).await;
let all_events = log.read_all().await.unwrap();
assert!(
all_events.len() > 100,
"synthetic log must exceed one REPLAY_CHUNK_SIZE to exercise multi-chunk streaming"
);
let vec_based = ReplayEngine::fold(all_events, None);
let streamed = ReplayEngine::replay(dir.path(), None).await.unwrap();
assert_eq!(streamed.last_seq, vec_based.last_seq);
assert_eq!(streamed.provider_name, vec_based.provider_name);
assert_eq!(streamed.model, vec_based.model);
assert_eq!(streamed.cwd, vec_based.cwd);
assert_eq!(streamed.messages.len(), vec_based.messages.len());
assert_eq!(
serde_json::to_string(&streamed.messages).unwrap(),
serde_json::to_string(&vec_based.messages).unwrap(),
"streaming replay must be byte-identical to the old Vec-based fold"
);
}
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
async fn test_replay_torn_tail_across_chunk_boundary() {
const N_TURNS: u64 = 250;
let dir = tempfile::tempdir().unwrap();
let log = seed_large_synthetic_log(dir.path(), N_TURNS).await;
let path = log.path().to_path_buf();
drop(log);
let full = tokio::fs::read(&path).await.unwrap();
let cut = full.len() - 5;
tokio::fs::write(&path, &full[..cut]).await.unwrap();
let whole_file_log = SessionEventLog::open(dir.path()).await.unwrap();
let whole_file_events = whole_file_log.read_all().await.unwrap();
assert!(
whole_file_events.len() > 100,
"test must still exceed one REPLAY_CHUNK_SIZE after dropping the torn line"
);
let expected = ReplayEngine::fold(whole_file_events, None);
assert_eq!(
expected.last_seq,
Some(576),
"the torn line must be the trailing seq-577 ModelChanged event (verified against the \
real fixture) — a differential-only assertion below would pass vacuously if both \
paths regressed identically and stopped dropping the torn tail at all"
);
let actual = ReplayEngine::replay(dir.path(), None).await.unwrap();
assert_eq!(
actual.last_seq, expected.last_seq,
"chunked replay must drop the torn tail at the same seq as the whole-file path"
);
assert_eq!(
serde_json::to_string(&actual.messages).unwrap(),
serde_json::to_string(&expected.messages).unwrap(),
"chunked replay must drop a torn tail beyond a chunk boundary identically to the \
whole-file read+fold path"
);
}
#[tokio::test]
#[serial_test::serial(session_history_integrity)]
async fn test_replay_up_to_matches_fold_at_chunk_boundaries() {
const N_TURNS: u64 = 250;
let dir = tempfile::tempdir().unwrap();
let log = seed_large_synthetic_log(dir.path(), N_TURNS).await;
let all_events = log.read_all().await.unwrap();
assert!(
all_events.len() > 200,
"synthetic log must exceed 200 events to exercise several REPLAY_CHUNK_SIZE boundaries"
);
for up_to in [99u64, 100, 101, 199, 200, 201] {
let expected = ReplayEngine::fold(all_events.clone(), Some(up_to));
let actual = ReplayEngine::replay(dir.path(), Some(up_to)).await.unwrap();
assert_eq!(
actual.last_seq, expected.last_seq,
"up_to={up_to}: last_seq mismatch between streamed replay and Vec-based fold"
);
assert_eq!(
serde_json::to_string(&actual.messages).unwrap(),
serde_json::to_string(&expected.messages).unwrap(),
"up_to={up_to}: streamed replay must match Vec-based fold exactly at/near chunk \
boundaries"
);
}
}
}