Luigit
repositories / smith

smith

There are many coding harnesses - but this one is fast

owned by admin

smith-core/src/trace.rs

Raw
//! 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<TraceRecord> {
    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<TraceRecord> {
    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<TraceKind> {
        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);
    }
}