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);
}
}