use contextgraph_types::{FrameId, Representation, Verdict};
use serde::{Deserialize, Serialize};
pub const TRACE_FORMAT: &str = "contextgraph-trace/0.1-sketch";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TraceEvent {
pub seq: u64,
pub at: String,
pub session: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn: Option<u64>,
#[serde(flatten)]
pub body: EventBody,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionOutcome {
Completed,
Aborted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolStatus {
Ok,
Error,
Rejected,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RenderedFrame {
pub frame: FrameId,
#[serde(default, skip_serializing_if = "Representation::is_full")]
pub representation: Representation,
pub token_cost: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub citation_label: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum EventBody {
SessionStart {
agent: String,
harness: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
trace_format: Option<String>,
},
SessionEnd { outcome: SessionOutcome },
Resume { last_seq_seen: u64 },
TurnStart,
TurnEnd,
PromptAssembled {
budget_tokens: u32,
declared_total_tokens: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
composition_digest: Option<String>,
frames: Vec<RenderedFrame>,
},
ModelResponse {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tool_calls: Vec<String>,
},
ToolCall { call_id: String, tool: String },
ToolResult { call_id: String, status: ToolStatus },
VerifyObserved { frame: FrameId, verdict: Verdict },
SideEffect {
effect_id: String,
kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
call_id: Option<String>,
},
}
impl EventBody {
pub fn kind(&self) -> &'static str {
match self {
EventBody::SessionStart { .. } => "session_start",
EventBody::SessionEnd { .. } => "session_end",
EventBody::Resume { .. } => "resume",
EventBody::TurnStart => "turn_start",
EventBody::TurnEnd => "turn_end",
EventBody::PromptAssembled { .. } => "prompt_assembled",
EventBody::ModelResponse { .. } => "model_response",
EventBody::ToolCall { .. } => "tool_call",
EventBody::ToolResult { .. } => "tool_result",
EventBody::VerifyObserved { .. } => "verify_observed",
EventBody::SideEffect { .. } => "side_effect",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_journal_line_roundtrips_with_the_body_flattened() {
let event = TraceEvent {
seq: 5,
at: "2026-07-23T09:00:10Z".into(),
session: "sess_1".into(),
turn: Some(1),
body: EventBody::ToolCall {
call_id: "call_1".into(),
tool: "write_file".into(),
},
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"event\":\"tool_call\""));
assert!(json.contains("\"call_id\":\"call_1\""));
assert!(!json.contains("\"body\""));
let back: TraceEvent = serde_json::from_str(&json).unwrap();
assert_eq!(back, event);
}
#[test]
fn absent_turn_and_optional_fields_are_omitted_not_null() {
let event = TraceEvent {
seq: 1,
at: "2026-07-23T09:00:00Z".into(),
session: "sess_1".into(),
turn: None,
body: EventBody::SessionStart {
agent: "example-agent".into(),
harness: "stella/0.9".into(),
model: None,
trace_format: Some(TRACE_FORMAT.into()),
},
};
let json = serde_json::to_string(&event).unwrap();
assert!(!json.contains("\"turn\""));
assert!(!json.contains("\"model\""));
assert!(json.contains(TRACE_FORMAT));
}
#[test]
fn a_rendered_full_frame_omits_representation_like_the_frame_wire_shape() {
let rendered = RenderedFrame {
frame: FrameId::new("docs", "frm_1", Some("sha256:9f2c".into())),
representation: Representation::Full,
token_cost: 120,
citation_label: Some("workspace.ts L120-160".into()),
};
let json = serde_json::to_string(&rendered).unwrap();
assert!(!json.contains("representation"));
let back: RenderedFrame = serde_json::from_str(&json).unwrap();
assert_eq!(back, rendered);
}
#[test]
fn verify_observed_carries_the_wire_verdict_shape() {
let event = TraceEvent {
seq: 9,
at: "2026-07-23T09:00:14Z".into(),
session: "sess_1".into(),
turn: None,
body: EventBody::VerifyObserved {
frame: FrameId::new("docs", "frm_1", Some("sha256:9f2c".into())),
verdict: Verdict::Stale {
replacement_digest: None,
},
},
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"verdict\":{\"status\":\"stale\"}"));
let back: TraceEvent = serde_json::from_str(&json).unwrap();
assert_eq!(back, event);
}
}