1use crate::conversation::ConversationEntry;
7use chrono::{DateTime, Local, Utc};
8use serde::{Deserialize, Serialize};
9use std::io::BufRead;
10use std::path::Path;
11
12fn fmt_ts(ts: &DateTime<Utc>) -> String {
14 ts.with_timezone(&Local).format("%-I:%M %p").to_string()
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(tag = "type", rename_all = "snake_case")]
23pub enum AgentEvent {
24 Start {
26 timestamp: DateTime<Utc>,
27 prompt: String,
28 model: Option<String>,
29 },
30 UserMessage {
32 timestamp: DateTime<Utc>,
33 text: String,
34 },
35 AssistantText {
37 timestamp: DateTime<Utc>,
38 text: String,
39 },
40 ToolUse {
42 timestamp: DateTime<Utc>,
43 tool: String,
44 input: String,
45 },
46 ToolResult {
48 timestamp: DateTime<Utc>,
49 tool: String,
50 output: String,
51 is_error: bool,
52 },
53 SessionResult {
55 timestamp: DateTime<Utc>,
56 turns: u64,
57 cost_usd: Option<f64>,
58 session_id: Option<String>,
59 },
60 Error {
62 timestamp: DateTime<Utc>,
63 message: String,
64 },
65}
66
67pub fn parse_events(path: &Path) -> Vec<ConversationEntry> {
75 let file = match std::fs::File::open(path) {
76 Ok(f) => f,
77 Err(_) => return Vec::new(),
78 };
79 let reader = std::io::BufReader::new(file);
80 let mut entries: Vec<ConversationEntry> = Vec::new();
81
82 for line in reader.lines() {
83 let line = match line {
84 Ok(l) => l,
85 Err(_) => continue,
86 };
87 if line.trim().is_empty() {
88 continue;
89 }
90 let event: AgentEvent = match serde_json::from_str(&line) {
91 Ok(e) => e,
92 Err(_) => continue,
93 };
94
95 match event {
96 AgentEvent::Start {
97 prompt, timestamp, ..
98 } => {
99 entries.push(ConversationEntry::User {
100 text: prompt,
101 timestamp: fmt_ts(×tamp),
102 });
103 }
104 AgentEvent::UserMessage {
105 text, timestamp, ..
106 } => {
107 entries.push(ConversationEntry::User {
108 text,
109 timestamp: fmt_ts(×tamp),
110 });
111 }
112 AgentEvent::AssistantText {
113 text, timestamp, ..
114 } => {
115 if let Some(ConversationEntry::AssistantText {
119 text: prev_text, ..
120 }) = entries.last_mut()
121 {
122 prev_text.push_str(&text);
123 } else {
124 entries.push(ConversationEntry::AssistantText {
125 text,
126 timestamp: fmt_ts(×tamp),
127 });
128 }
129 }
130 AgentEvent::ToolUse { tool, input, .. } => {
131 entries.push(ConversationEntry::ToolCall {
132 tool,
133 input,
134 output: None,
135 is_error: false,
136 collapsed: true,
137 });
138 }
139 AgentEvent::ToolResult {
140 output, is_error, ..
141 } => {
142 if let Some(ConversationEntry::ToolCall {
144 output: o,
145 is_error: e,
146 ..
147 }) = entries.last_mut()
148 {
149 *o = Some(output);
150 *e = is_error;
151 }
152 }
153 AgentEvent::SessionResult {
154 turns, cost_usd, ..
155 } => {
156 let cost_str = cost_usd.map(|c| format!(", ${:.2}", c)).unwrap_or_default();
157 entries.push(ConversationEntry::Status {
158 text: format!("Session complete ({} turns{})", turns, cost_str),
159 });
160 }
161 AgentEvent::Error { message, .. } => {
162 entries.push(ConversationEntry::Status {
163 text: format!("Error: {}", message),
164 });
165 }
166 }
167 }
168
169 entries
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use std::io::Write;
176 use tempfile::NamedTempFile;
177
178 fn write_events(events: &[AgentEvent]) -> NamedTempFile {
179 let mut f = NamedTempFile::new().unwrap();
180 for ev in events {
181 let json = serde_json::to_string(ev).unwrap();
182 writeln!(f, "{}", json).unwrap();
183 }
184 f.flush().unwrap();
185 f
186 }
187
188 fn ts() -> DateTime<Utc> {
189 Utc::now()
190 }
191
192 #[test]
193 fn parse_basic_conversation() {
194 let f = write_events(&[
195 AgentEvent::Start {
196 timestamp: ts(),
197 prompt: "fix the bug".into(),
198 model: Some("opus".into()),
199 },
200 AgentEvent::AssistantText {
201 timestamp: ts(),
202 text: "I'll fix it".into(),
203 },
204 AgentEvent::ToolUse {
205 timestamp: ts(),
206 tool: "Read".into(),
207 input: "src/main.rs".into(),
208 },
209 AgentEvent::ToolResult {
210 timestamp: ts(),
211 tool: "Read".into(),
212 output: "fn main() {}".into(),
213 is_error: false,
214 },
215 AgentEvent::SessionResult {
216 timestamp: ts(),
217 turns: 3,
218 cost_usd: Some(0.05),
219 session_id: Some("s1".into()),
220 },
221 ]);
222
223 let entries = parse_events(f.path());
224 assert_eq!(entries.len(), 4); assert!(matches!(
227 &entries[0],
228 ConversationEntry::User { text, .. } if text == "fix the bug"
229 ));
230 assert!(matches!(
231 &entries[1],
232 ConversationEntry::AssistantText { text, .. } if text == "I'll fix it"
233 ));
234 assert!(matches!(
235 &entries[2],
236 ConversationEntry::ToolCall { tool, output: Some(out), is_error: false, .. }
237 if tool == "Read" && out == "fn main() {}"
238 ));
239 assert!(matches!(
240 &entries[3],
241 ConversationEntry::Status { text } if text.contains("3 turns") && text.contains("$0.05")
242 ));
243 }
244
245 #[test]
246 fn parse_empty_file() {
247 let f = NamedTempFile::new().unwrap();
248 let entries = parse_events(f.path());
249 assert!(entries.is_empty());
250 }
251
252 #[test]
253 fn parse_nonexistent_file() {
254 let entries = parse_events(Path::new("/nonexistent/events.jsonl"));
255 assert!(entries.is_empty());
256 }
257
258 #[test]
259 fn parse_skips_corrupt_lines() {
260 let mut f = NamedTempFile::new().unwrap();
261 let ev = AgentEvent::Start {
262 timestamp: ts(),
263 prompt: "go".into(),
264 model: None,
265 };
266 writeln!(f, "{}", serde_json::to_string(&ev).unwrap()).unwrap();
267 writeln!(f, "not json").unwrap();
268 writeln!(f).unwrap();
269 let ev2 = AgentEvent::AssistantText {
270 timestamp: ts(),
271 text: "done".into(),
272 };
273 writeln!(f, "{}", serde_json::to_string(&ev2).unwrap()).unwrap();
274 f.flush().unwrap();
275
276 let entries = parse_events(f.path());
277 assert_eq!(entries.len(), 2);
278 }
279
280 #[test]
281 fn parse_followup_messages() {
282 let f = write_events(&[
283 AgentEvent::Start {
284 timestamp: ts(),
285 prompt: "initial".into(),
286 model: None,
287 },
288 AgentEvent::AssistantText {
289 timestamp: ts(),
290 text: "done".into(),
291 },
292 AgentEvent::SessionResult {
293 timestamp: ts(),
294 turns: 1,
295 cost_usd: None,
296 session_id: Some("s1".into()),
297 },
298 AgentEvent::UserMessage {
299 timestamp: ts(),
300 text: "follow up".into(),
301 },
302 AgentEvent::AssistantText {
303 timestamp: ts(),
304 text: "follow up answer".into(),
305 },
306 AgentEvent::SessionResult {
307 timestamp: ts(),
308 turns: 3,
309 cost_usd: Some(0.10),
310 session_id: Some("s1".into()),
311 },
312 ]);
313
314 let entries = parse_events(f.path());
315 assert_eq!(entries.len(), 6);
317 assert!(matches!(
318 &entries[3],
319 ConversationEntry::User { text, .. } if text == "follow up"
320 ));
321 }
322
323 #[test]
324 fn parse_error_events() {
325 let f = write_events(&[
326 AgentEvent::Start {
327 timestamp: ts(),
328 prompt: "go".into(),
329 model: None,
330 },
331 AgentEvent::Error {
332 timestamp: ts(),
333 message: "rate limited".into(),
334 },
335 ]);
336
337 let entries = parse_events(f.path());
338 assert_eq!(entries.len(), 2);
339 assert!(matches!(
340 &entries[1],
341 ConversationEntry::Status { text } if text == "Error: rate limited"
342 ));
343 }
344}