Skip to main content

apiari_tui/
events_parser.rs

1//! Parse agent events.jsonl into ConversationEntry items.
2//!
3//! The events file is written by swarm's agent TUI at
4//! `.swarm/agents/{worktree_id}/events.jsonl`.
5
6use crate::conversation::ConversationEntry;
7use chrono::{DateTime, Local, Utc};
8use serde::{Deserialize, Serialize};
9use std::io::BufRead;
10use std::path::Path;
11
12/// Format a UTC timestamp as local time for display.
13fn fmt_ts(ts: &DateTime<Utc>) -> String {
14    ts.with_timezone(&Local).format("%-I:%M %p").to_string()
15}
16
17/// A structured event written to the agent's event log.
18///
19/// This is the canonical type for agent event serialization. Both swarm
20/// (writing) and apiari (reading) use this type.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(tag = "type", rename_all = "snake_case")]
23pub enum AgentEvent {
24    /// Session started.
25    Start {
26        timestamp: DateTime<Utc>,
27        prompt: String,
28        model: Option<String>,
29    },
30    /// User sent a follow-up message.
31    UserMessage {
32        timestamp: DateTime<Utc>,
33        text: String,
34    },
35    /// Assistant emitted text.
36    AssistantText {
37        timestamp: DateTime<Utc>,
38        text: String,
39    },
40    /// Assistant requested a tool call.
41    ToolUse {
42        timestamp: DateTime<Utc>,
43        tool: String,
44        input: String,
45    },
46    /// Tool execution completed.
47    ToolResult {
48        timestamp: DateTime<Utc>,
49        tool: String,
50        output: String,
51        is_error: bool,
52    },
53    /// SDK returned a result — session is now idle and resumable.
54    SessionResult {
55        timestamp: DateTime<Utc>,
56        turns: u64,
57        cost_usd: Option<f64>,
58        session_id: Option<String>,
59    },
60    /// Session errored.
61    Error {
62        timestamp: DateTime<Utc>,
63        message: String,
64    },
65}
66
67/// Parse an events.jsonl file into conversation entries.
68///
69/// Reads all events and converts them into a flat list of `ConversationEntry`
70/// items suitable for rendering. Unlike `read_last_session`, this doesn't
71/// require a completed session — it replays whatever events exist.
72///
73/// `SessionResult` events become status lines showing turn count and cost.
74pub 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(&timestamp),
102                });
103            }
104            AgentEvent::UserMessage {
105                text, timestamp, ..
106            } => {
107                entries.push(ConversationEntry::User {
108                    text,
109                    timestamp: fmt_ts(&timestamp),
110                });
111            }
112            AgentEvent::AssistantText {
113                text, timestamp, ..
114            } => {
115                // Merge consecutive assistant text chunks into a single entry.
116                // The SDK streams text in small fragments; combining them produces
117                // one coherent message for markdown rendering.
118                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(&timestamp),
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                // Update the last ToolCall entry with the result
143                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); // User, AssistantText, ToolCall, Status
225
226        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        // User, AssistantText, Status(session1), User, AssistantText, Status(session2)
316        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}