#![doc = include_str!("../README.md")]
pub mod derive;
pub mod extract;
pub mod project;
pub use derive::{DeriveConfig, derive_path, file_write_diff, unified_diff};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum ConvoError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("provider error: {0}")]
Provider(String),
#[error("{0}")]
Other(#[from] Box<dyn std::error::Error + Send + Sync>),
}
pub type Result<T> = std::result::Result<T, ConvoError>;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Role {
User,
Assistant,
System,
Other(String),
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Role::User => write!(f, "user"),
Role::Assistant => write!(f, "assistant"),
Role::System => write!(f, "system"),
Role::Other(s) => write!(f, "{}", s),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TokenUsage {
pub input_tokens: Option<u32>,
pub output_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_tokens: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EnvironmentSnapshot {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vcs_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vcs_revision: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegatedWork {
pub agent_id: String,
pub prompt: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub turns: Vec<Turn>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationEvent {
pub id: String,
pub timestamp: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
pub event_type: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub data: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCategory {
FileRead,
FileWrite,
FileSearch,
Shell,
Network,
Delegation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolInvocation {
pub id: String,
pub name: String,
pub input: serde_json::Value,
pub result: Option<ToolResult>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<ToolCategory>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub content: String,
pub is_error: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Turn {
pub id: String,
pub parent_id: Option<String>,
pub role: Role,
pub timestamp: String,
pub text: String,
pub thinking: Option<String>,
pub tool_uses: Vec<ToolInvocation>,
pub model: Option<String>,
pub stop_reason: Option<String>,
pub token_usage: Option<TokenUsage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<EnvironmentSnapshot>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delegations: Vec<DelegatedWork>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationView {
pub id: String,
pub started_at: Option<DateTime<Utc>>,
pub last_activity: Option<DateTime<Utc>>,
pub turns: Vec<Turn>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_usage: Option<TokenUsage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files_changed: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub session_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<ConversationEvent>,
}
impl ConversationView {
pub fn title(&self, max_len: usize) -> Option<String> {
let text = self
.turns
.iter()
.find(|t| t.role == Role::User && !t.text.is_empty())
.map(|t| &t.text)?;
if text.chars().count() > max_len {
let truncated: String = text.chars().take(max_len).collect();
Some(format!("{}...", truncated))
} else {
Some(text.clone())
}
}
pub fn turns_by_role(&self, role: &Role) -> Vec<&Turn> {
self.turns.iter().filter(|t| &t.role == role).collect()
}
pub fn turns_since(&self, turn_id: &str) -> &[Turn] {
match self.turns.iter().position(|t| t.id == turn_id) {
Some(idx) if idx + 1 < self.turns.len() => &self.turns[idx + 1..],
Some(_) => &[],
None => &self.turns,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationMeta {
pub id: String,
pub started_at: Option<DateTime<Utc>>,
pub last_activity: Option<DateTime<Utc>>,
pub message_count: usize,
pub file_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub predecessor: Option<SessionLink>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub successor: Option<SessionLink>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionLinkKind {
Rotation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionLink {
pub session_id: String,
pub kind: SessionLinkKind,
}
#[derive(Debug, Clone)]
pub enum WatcherEvent {
Turn(Box<Turn>),
TurnUpdated(Box<Turn>),
Progress {
kind: String,
data: serde_json::Value,
},
}
impl WatcherEvent {
pub fn as_turn(&self) -> Option<&Turn> {
match self {
WatcherEvent::Turn(t) | WatcherEvent::TurnUpdated(t) => Some(t),
WatcherEvent::Progress { .. } => None,
}
}
pub fn as_progress(&self) -> Option<(&str, &serde_json::Value)> {
match self {
WatcherEvent::Progress { kind, data } => Some((kind, data)),
_ => None,
}
}
pub fn is_update(&self) -> bool {
matches!(self, WatcherEvent::TurnUpdated(_))
}
pub fn turn_id(&self) -> Option<&str> {
self.as_turn().map(|t| t.id.as_str())
}
}
pub trait ConversationProvider {
fn list_conversations(&self, project: &str) -> Result<Vec<String>>;
fn load_conversation(&self, project: &str, conversation_id: &str) -> Result<ConversationView>;
fn load_metadata(&self, project: &str, conversation_id: &str) -> Result<ConversationMeta>;
fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>>;
}
pub trait ConversationWatcher {
fn poll(&mut self) -> Result<Vec<WatcherEvent>>;
fn seen_count(&self) -> usize;
}
pub use extract::extract_conversation;
pub use project::{AnyProjector, ConversationProjector};
#[cfg(test)]
mod tests {
use super::*;
fn sample_view() -> ConversationView {
ConversationView {
id: "sess-1".into(),
started_at: None,
last_activity: None,
turns: vec![
Turn {
id: "t1".into(),
parent_id: None,
role: Role::User,
timestamp: "2026-01-01T00:00:00Z".into(),
text: "Fix the authentication bug in login.rs".into(),
thinking: None,
tool_uses: vec![],
model: None,
stop_reason: None,
token_usage: None,
environment: None,
delegations: vec![],
extra: HashMap::new(),
},
Turn {
id: "t2".into(),
parent_id: Some("t1".into()),
role: Role::Assistant,
timestamp: "2026-01-01T00:00:01Z".into(),
text: "I'll fix that for you.".into(),
thinking: Some("The bug is in the token validation".into()),
tool_uses: vec![ToolInvocation {
id: "tool-1".into(),
name: "Read".into(),
input: serde_json::json!({"file": "src/login.rs"}),
result: Some(ToolResult {
content: "fn login() { ... }".into(),
is_error: false,
}),
category: Some(ToolCategory::FileRead),
}],
model: Some("claude-opus-4-6".into()),
stop_reason: Some("end_turn".into()),
token_usage: Some(TokenUsage {
input_tokens: Some(100),
output_tokens: Some(50),
cache_read_tokens: None,
cache_write_tokens: None,
}),
environment: None,
delegations: vec![],
extra: HashMap::new(),
},
Turn {
id: "t3".into(),
parent_id: Some("t2".into()),
role: Role::User,
timestamp: "2026-01-01T00:00:02Z".into(),
text: "Thanks!".into(),
thinking: None,
tool_uses: vec![],
model: None,
stop_reason: None,
token_usage: None,
environment: None,
delegations: vec![],
extra: HashMap::new(),
},
],
total_usage: None,
provider_id: None,
files_changed: vec![],
session_ids: vec![],
events: vec![],
}
}
#[test]
fn test_title_short() {
let view = sample_view();
let title = view.title(100).unwrap();
assert_eq!(title, "Fix the authentication bug in login.rs");
}
#[test]
fn test_title_truncated() {
let view = sample_view();
let title = view.title(10).unwrap();
assert_eq!(title, "Fix the au...");
}
#[test]
fn test_title_empty() {
let view = ConversationView {
id: "empty".into(),
started_at: None,
last_activity: None,
turns: vec![],
total_usage: None,
provider_id: None,
files_changed: vec![],
session_ids: vec![],
events: vec![],
};
assert!(view.title(50).is_none());
}
#[test]
fn test_turns_by_role() {
let view = sample_view();
let users = view.turns_by_role(&Role::User);
assert_eq!(users.len(), 2);
let assistants = view.turns_by_role(&Role::Assistant);
assert_eq!(assistants.len(), 1);
}
#[test]
fn test_turns_since_middle() {
let view = sample_view();
let since = view.turns_since("t1");
assert_eq!(since.len(), 2);
assert_eq!(since[0].id, "t2");
}
#[test]
fn test_turns_since_last() {
let view = sample_view();
let since = view.turns_since("t3");
assert!(since.is_empty());
}
#[test]
fn test_turns_since_unknown() {
let view = sample_view();
let since = view.turns_since("nonexistent");
assert_eq!(since.len(), 3);
}
#[test]
fn test_role_display() {
assert_eq!(Role::User.to_string(), "user");
assert_eq!(Role::Assistant.to_string(), "assistant");
assert_eq!(Role::System.to_string(), "system");
assert_eq!(Role::Other("tool".into()).to_string(), "tool");
}
#[test]
fn test_role_equality() {
assert_eq!(Role::User, Role::User);
assert_ne!(Role::User, Role::Assistant);
assert_eq!(Role::Other("x".into()), Role::Other("x".into()));
assert_ne!(Role::Other("x".into()), Role::Other("y".into()));
}
#[test]
fn test_turn_serde_roundtrip() {
let turn = &sample_view().turns[1];
let json = serde_json::to_string(turn).unwrap();
let back: Turn = serde_json::from_str(&json).unwrap();
assert_eq!(back.id, "t2");
assert_eq!(back.model, Some("claude-opus-4-6".into()));
assert_eq!(back.tool_uses.len(), 1);
assert_eq!(back.tool_uses[0].name, "Read");
assert!(back.tool_uses[0].result.is_some());
}
#[test]
fn test_conversation_view_serde_roundtrip() {
let view = sample_view();
let json = serde_json::to_string(&view).unwrap();
let back: ConversationView = serde_json::from_str(&json).unwrap();
assert_eq!(back.id, "sess-1");
assert_eq!(back.turns.len(), 3);
}
#[test]
fn test_watcher_event_variants() {
let turn_event = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
assert!(matches!(turn_event, WatcherEvent::Turn(_)));
let updated_event = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[1].clone()));
assert!(matches!(updated_event, WatcherEvent::TurnUpdated(_)));
let progress_event = WatcherEvent::Progress {
kind: "agent_progress".into(),
data: serde_json::json!({"status": "running"}),
};
assert!(matches!(progress_event, WatcherEvent::Progress { .. }));
}
#[test]
fn test_watcher_event_as_turn() {
let turn = sample_view().turns[0].clone();
let event = WatcherEvent::Turn(Box::new(turn.clone()));
assert_eq!(event.as_turn().unwrap().id, "t1");
let updated = WatcherEvent::TurnUpdated(Box::new(turn));
assert_eq!(updated.as_turn().unwrap().id, "t1");
let progress = WatcherEvent::Progress {
kind: "test".into(),
data: serde_json::Value::Null,
};
assert!(progress.as_turn().is_none());
}
#[test]
fn test_watcher_event_as_progress() {
let progress = WatcherEvent::Progress {
kind: "hook_progress".into(),
data: serde_json::json!({"hookName": "pre-commit"}),
};
let (kind, data) = progress.as_progress().unwrap();
assert_eq!(kind, "hook_progress");
assert_eq!(data["hookName"], "pre-commit");
let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
assert!(turn.as_progress().is_none());
}
#[test]
fn test_watcher_event_is_update() {
let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
assert!(!turn.is_update());
let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone()));
assert!(updated.is_update());
let progress = WatcherEvent::Progress {
kind: "test".into(),
data: serde_json::Value::Null,
};
assert!(!progress.is_update());
}
#[test]
fn test_watcher_event_turn_id() {
let turn = WatcherEvent::Turn(Box::new(sample_view().turns[1].clone()));
assert_eq!(turn.turn_id(), Some("t2"));
let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone()));
assert_eq!(updated.turn_id(), Some("t1"));
let progress = WatcherEvent::Progress {
kind: "test".into(),
data: serde_json::Value::Null,
};
assert!(progress.turn_id().is_none());
}
#[test]
fn test_token_usage_default() {
let usage = TokenUsage::default();
assert!(usage.input_tokens.is_none());
assert!(usage.output_tokens.is_none());
assert!(usage.cache_read_tokens.is_none());
assert!(usage.cache_write_tokens.is_none());
}
#[test]
fn test_token_usage_cache_fields_serde() {
let usage = TokenUsage {
input_tokens: Some(100),
output_tokens: Some(50),
cache_read_tokens: Some(500),
cache_write_tokens: Some(200),
};
let json = serde_json::to_string(&usage).unwrap();
let back: TokenUsage = serde_json::from_str(&json).unwrap();
assert_eq!(back.cache_read_tokens, Some(500));
assert_eq!(back.cache_write_tokens, Some(200));
}
#[test]
fn test_token_usage_cache_fields_omitted() {
let json = r#"{"input_tokens":100,"output_tokens":50}"#;
let usage: TokenUsage = serde_json::from_str(json).unwrap();
assert_eq!(usage.input_tokens, Some(100));
assert!(usage.cache_read_tokens.is_none());
assert!(usage.cache_write_tokens.is_none());
}
#[test]
fn test_environment_snapshot_serde() {
let env = EnvironmentSnapshot {
working_dir: Some("/home/user/project".into()),
vcs_branch: Some("main".into()),
vcs_revision: Some("abc123".into()),
};
let json = serde_json::to_string(&env).unwrap();
let back: EnvironmentSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(back.working_dir.as_deref(), Some("/home/user/project"));
assert_eq!(back.vcs_branch.as_deref(), Some("main"));
assert_eq!(back.vcs_revision.as_deref(), Some("abc123"));
}
#[test]
fn test_environment_snapshot_default() {
let env = EnvironmentSnapshot::default();
assert!(env.working_dir.is_none());
assert!(env.vcs_branch.is_none());
assert!(env.vcs_revision.is_none());
}
#[test]
fn test_environment_snapshot_skip_none_fields() {
let env = EnvironmentSnapshot {
working_dir: Some("/tmp".into()),
vcs_branch: None,
vcs_revision: None,
};
let json = serde_json::to_string(&env).unwrap();
assert!(!json.contains("vcs_branch"));
assert!(!json.contains("vcs_revision"));
}
#[test]
fn test_delegated_work_serde() {
let dw = DelegatedWork {
agent_id: "agent-123".into(),
prompt: "Search for the bug".into(),
turns: vec![],
result: Some("Found the bug in auth.rs".into()),
};
let json = serde_json::to_string(&dw).unwrap();
assert!(!json.contains("turns")); let back: DelegatedWork = serde_json::from_str(&json).unwrap();
assert_eq!(back.agent_id, "agent-123");
assert_eq!(back.result.as_deref(), Some("Found the bug in auth.rs"));
assert!(back.turns.is_empty());
}
#[test]
fn test_tool_category_serde() {
let ti = ToolInvocation {
id: "t1".into(),
name: "Bash".into(),
input: serde_json::json!({"command": "ls"}),
result: None,
category: Some(ToolCategory::Shell),
};
let json = serde_json::to_string(&ti).unwrap();
assert!(json.contains("\"shell\""));
let back: ToolInvocation = serde_json::from_str(&json).unwrap();
assert_eq!(back.category, Some(ToolCategory::Shell));
}
#[test]
fn test_tool_category_none_skipped() {
let ti = ToolInvocation {
id: "t1".into(),
name: "CustomTool".into(),
input: serde_json::json!({}),
result: None,
category: None,
};
let json = serde_json::to_string(&ti).unwrap();
assert!(!json.contains("category"));
}
#[test]
fn test_tool_category_missing_defaults_none() {
let json = r#"{"id":"t1","name":"Read","input":{},"result":null}"#;
let ti: ToolInvocation = serde_json::from_str(json).unwrap();
assert!(ti.category.is_none());
}
#[test]
fn test_tool_category_all_variants_roundtrip() {
let variants = vec![
ToolCategory::FileRead,
ToolCategory::FileWrite,
ToolCategory::FileSearch,
ToolCategory::Shell,
ToolCategory::Network,
ToolCategory::Delegation,
];
for cat in variants {
let json = serde_json::to_value(cat).unwrap();
let back: ToolCategory = serde_json::from_value(json).unwrap();
assert_eq!(back, cat);
}
}
#[test]
fn test_turn_with_environment_and_delegations() {
let turn = Turn {
id: "t1".into(),
parent_id: None,
role: Role::Assistant,
timestamp: "2026-01-01T00:00:00Z".into(),
text: "Delegating...".into(),
thinking: None,
tool_uses: vec![],
model: None,
stop_reason: None,
token_usage: None,
environment: Some(EnvironmentSnapshot {
working_dir: Some("/project".into()),
vcs_branch: Some("feat/auth".into()),
vcs_revision: None,
}),
delegations: vec![DelegatedWork {
agent_id: "sub-1".into(),
prompt: "Find the bug".into(),
turns: vec![],
result: None,
}],
extra: HashMap::new(),
};
let json = serde_json::to_string(&turn).unwrap();
let back: Turn = serde_json::from_str(&json).unwrap();
assert_eq!(
back.environment.as_ref().unwrap().vcs_branch.as_deref(),
Some("feat/auth")
);
assert_eq!(back.delegations.len(), 1);
assert_eq!(back.delegations[0].agent_id, "sub-1");
}
#[test]
fn test_turn_without_new_fields_deserializes() {
let json = r#"{"id":"t1","parent_id":null,"role":"User","timestamp":"2026-01-01T00:00:00Z","text":"hi","thinking":null,"tool_uses":[],"model":null,"stop_reason":null,"token_usage":null}"#;
let turn: Turn = serde_json::from_str(json).unwrap();
assert!(turn.environment.is_none());
assert!(turn.delegations.is_empty());
}
#[test]
fn test_conversation_view_new_fields_serde() {
let view = ConversationView {
id: "s1".into(),
started_at: None,
last_activity: None,
turns: vec![],
total_usage: Some(TokenUsage {
input_tokens: Some(1000),
output_tokens: Some(500),
cache_read_tokens: Some(800),
cache_write_tokens: None,
}),
provider_id: Some("claude-code".into()),
files_changed: vec!["src/main.rs".into(), "src/lib.rs".into()],
session_ids: vec![],
events: vec![],
};
let json = serde_json::to_string(&view).unwrap();
let back: ConversationView = serde_json::from_str(&json).unwrap();
assert_eq!(back.provider_id.as_deref(), Some("claude-code"));
assert_eq!(back.files_changed, vec!["src/main.rs", "src/lib.rs"]);
assert_eq!(back.total_usage.as_ref().unwrap().input_tokens, Some(1000));
assert_eq!(
back.total_usage.as_ref().unwrap().cache_read_tokens,
Some(800)
);
}
#[test]
fn test_conversation_view_old_format_deserializes() {
let json = r#"{"id":"s1","started_at":null,"last_activity":null,"turns":[]}"#;
let view: ConversationView = serde_json::from_str(json).unwrap();
assert!(view.total_usage.is_none());
assert!(view.provider_id.is_none());
assert!(view.files_changed.is_empty());
}
#[test]
fn test_conversation_meta() {
let meta = ConversationMeta {
id: "sess-1".into(),
started_at: None,
last_activity: None,
message_count: 5,
file_path: Some("/tmp/test.jsonl".into()),
predecessor: None,
successor: None,
};
let json = serde_json::to_string(&meta).unwrap();
let back: ConversationMeta = serde_json::from_str(&json).unwrap();
assert_eq!(back.message_count, 5);
}
#[test]
fn test_conversation_event_serde_roundtrip() {
let event = ConversationEvent {
id: "evt-1".into(),
timestamp: "2026-01-01T00:00:00Z".into(),
parent_id: Some("t1".into()),
event_type: "attachment".into(),
data: {
let mut m = HashMap::new();
m.insert("cwd".into(), serde_json::json!("/project"));
m.insert("version".into(), serde_json::json!("1.0"));
m
},
};
let json = serde_json::to_string(&event).unwrap();
let back: ConversationEvent = serde_json::from_str(&json).unwrap();
assert_eq!(back.id, "evt-1");
assert_eq!(back.event_type, "attachment");
assert_eq!(back.parent_id.as_deref(), Some("t1"));
assert_eq!(back.data["cwd"], serde_json::json!("/project"));
}
#[test]
fn test_conversation_event_empty_data_omitted() {
let event = ConversationEvent {
id: "evt-2".into(),
timestamp: "2026-01-01T00:00:00Z".into(),
parent_id: None,
event_type: "system".into(),
data: HashMap::new(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(!json.contains("data"));
assert!(!json.contains("parent_id"));
}
#[test]
fn test_conversation_view_with_events_serde() {
let view = ConversationView {
id: "s1".into(),
started_at: None,
last_activity: None,
turns: vec![],
total_usage: None,
provider_id: None,
files_changed: vec![],
session_ids: vec![],
events: vec![ConversationEvent {
id: "evt-1".into(),
timestamp: "2026-01-01T00:00:00Z".into(),
parent_id: None,
event_type: "attachment".into(),
data: HashMap::new(),
}],
};
let json = serde_json::to_string(&view).unwrap();
assert!(json.contains("events"));
let back: ConversationView = serde_json::from_str(&json).unwrap();
assert_eq!(back.events.len(), 1);
assert_eq!(back.events[0].event_type, "attachment");
}
#[test]
fn test_conversation_view_empty_events_omitted() {
let view = ConversationView {
id: "s1".into(),
started_at: None,
last_activity: None,
turns: vec![],
total_usage: None,
provider_id: None,
files_changed: vec![],
session_ids: vec![],
events: vec![],
};
let json = serde_json::to_string(&view).unwrap();
assert!(!json.contains("events"));
}
#[test]
fn test_conversation_view_old_format_no_events() {
let json = r#"{"id":"s1","started_at":null,"last_activity":null,"turns":[]}"#;
let view: ConversationView = serde_json::from_str(json).unwrap();
assert!(view.events.is_empty());
}
}