//! Replay evidence derived from durable session state (`SMH-SPEC-SPEC0001`, //! Sessions). //! //! A trace is an ordered, provider-free reconstruction of what a session //! recorded. It is derived only from persisted entry records, never from //! decoded content, so the same session file always produces the same trace, //! byte for byte. use crate::session::{COMPACTION_KIND, EntryKind, Session}; use smith::id::SessionId; /// Replay-relevant trace record (durable replay evidence). #[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)] pub struct TraceRecord { /// Timestamp of the recorded event, in Unix milliseconds. pub timestamp_ms: i64, /// Session the event belongs to. pub session_id: SessionId, /// Event kind. pub kind: TraceKind, /// Stable reference to the recorded event. pub event_ref: String, } /// Kind of replay event. #[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)] pub enum TraceKind { /// Session created. SessionCreated, /// Entry added. EntryAdded, /// Message streamed. MessageStreamed, /// Tool call emitted. ToolCallEmitted, /// Tool result received. ToolResultReceived, /// Compaction occurred. Compaction, /// Replay started. ReplayStarted, /// Replay completed. ReplayCompleted, } /// Build the trace of a session's active branch. /// /// Every entry contributes the record kind that matches what it recorded, so /// tool activity and compaction stay visible instead of collapsing into one /// generic kind. #[must_use] pub fn build_trace(session: &Session) -> Vec { let branch = session.active_branch(); let mut trace = vec![TraceRecord { timestamp_ms: 0, session_id: session.id(), kind: TraceKind::SessionCreated, event_ref: format!("branch:{}", branch.id()), }]; for entry in branch.entries() { let (kind, event_ref) = match entry.kind { EntryKind::Message { id, .. } => (TraceKind::MessageStreamed, format!("message:{id}")), EntryKind::ToolCall { call_id } => { (TraceKind::ToolCallEmitted, format!("call:{call_id}")) } EntryKind::ToolResult { call_id, ok } => ( TraceKind::ToolResultReceived, format!("result:{call_id}:{}", if ok { "ok" } else { "failed" }), ), EntryKind::Meta { compaction: true } => ( TraceKind::Compaction, format!("meta:{COMPACTION_KIND}:{}", entry.id), ), EntryKind::Meta { compaction: false } => { (TraceKind::EntryAdded, format!("meta:{}", entry.id)) } }; trace.push(TraceRecord { timestamp_ms: entry.timestamp_ms, session_id: session.id(), kind, event_ref, }); } trace } /// Replay a session without a provider. /// /// The trace is framed by explicit start and completion records and uses only /// persisted timestamps, so replaying the same session twice is identical. #[must_use] pub fn replay(session: &Session) -> Vec { let body = build_trace(session); let last_ms = body.last().map_or(0, |record| record.timestamp_ms); let mut trace = Vec::with_capacity(body.len() + 2); trace.push(TraceRecord { timestamp_ms: 0, session_id: session.id(), kind: TraceKind::ReplayStarted, event_ref: format!("session:{}", session.id()), }); trace.extend(body); trace.push(TraceRecord { timestamp_ms: last_ms, session_id: session.id(), kind: TraceKind::ReplayCompleted, event_ref: format!("entries:{}", session.active_branch().entries().len()), }); trace } #[cfg(test)] #[expect( clippy::unwrap_used, reason = "tests may panic on invariant violations" )] mod tests { use super::*; use crate::session::EntryContent; use crate::store::{load_session, open_session}; use crate::tools::ToolSession; use smith::config::Config; use smith::message::{Message, Role}; fn kinds(trace: &[TraceRecord]) -> Vec { trace.iter().map(|record| record.kind).collect() } #[test] fn trace_reports_tool_activity_and_compaction_in_order() { let mut session = Session::new(); session .append(&EntryContent::Message(Message::with_text( Role::User, "hello", ))) .unwrap(); let mut tools = ToolSession::new(std::env::temp_dir()); tools.session = session; tools .invoke("ls", &serde_json::json!({"path": "."})) .unwrap(); tools .session .append(&EntryContent::Meta { kind: COMPACTION_KIND.to_string(), detail: serde_json::json!({}), }) .unwrap(); assert_eq!( kinds(&replay(&tools.session)), vec![ TraceKind::ReplayStarted, TraceKind::SessionCreated, TraceKind::MessageStreamed, TraceKind::ToolCallEmitted, TraceKind::ToolResultReceived, TraceKind::Compaction, TraceKind::ReplayCompleted, ] ); } #[test] fn replaying_a_persisted_session_is_byte_stable() { let dir = std::env::temp_dir().join(format!("smith_trace_{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("trace.smh"); let cfg = Config::default_valid(); { let opened = open_session(&path, cfg.clone()).unwrap(); let mut tools = ToolSession::with_session(&dir, opened.session, opened.writer) .with_config(cfg.clone()); tools .invoke( "write", &serde_json::json!({"path": "note.txt", "content": "hi"}), ) .unwrap(); tools .invoke("read", &serde_json::json!({"path": "note.txt"})) .unwrap(); } let encode = |session: &Session| { let mut bytes = Vec::new(); ciborium::ser::into_writer(&replay(session), &mut bytes).unwrap(); bytes }; let first = load_session(&path, cfg.max_frame_bytes).unwrap().frames; let second = load_session(&path, cfg.max_frame_bytes).unwrap().frames; let left = encode(&Session::from_frames(&first).unwrap()); let right = encode(&Session::from_frames(&second).unwrap()); assert_eq!(left, right); assert_eq!( kinds(&replay(&Session::from_frames(&first).unwrap())), vec![ TraceKind::ReplayStarted, TraceKind::SessionCreated, TraceKind::ToolCallEmitted, TraceKind::ToolResultReceived, TraceKind::ToolCallEmitted, TraceKind::ToolResultReceived, TraceKind::ReplayCompleted, ] ); let _ = std::fs::remove_dir_all(&dir); } }