Skip to main content

eval_magic/adapters/opencode/
transcript.rs

1//! OpenCode transcript (event-stream) parsing.
2//!
3//! `opencode run --format json` emits a JSONL event stream whose envelopes
4//! carry `{type, timestamp (epoch ms), sessionID, ...data}`. `tool_use` events
5//! hold a self-contained tool part — the name at `part.tool`, the arguments at
6//! `part.state.input`, and the outcome at `part.state.output` (completed) or
7//! `part.state.error` (error) — which become ordered [`ToolInvocation`]s.
8//! Token usage is summed from `step_finish` parts (`part.tokens`, cache
9//! reads excluded — matching the codex parser's accounting), the final
10//! message is the last `text` part's text, and the duration is the spread of
11//! the envelope timestamps.
12
13use crate::adapters::TranscriptSummary;
14use crate::adapters::transcript::{TranscriptEvent, read_jsonl};
15use crate::core::ToolInvocation;
16use serde_json::Value;
17use std::io;
18use std::path::Path;
19
20fn extract_invocations(records: &[Value]) -> Vec<ToolInvocation> {
21    let mut invocations = Vec::new();
22    for record in records {
23        if record.get("type").and_then(Value::as_str) != Some("tool_use") {
24            continue;
25        }
26        let Some(part) = record.get("part").and_then(Value::as_object) else {
27            continue;
28        };
29        let Some(name) = part.get("tool").and_then(Value::as_str) else {
30            continue;
31        };
32        let state = part.get("state");
33        let args = state
34            .and_then(|s| s.get("input"))
35            .filter(|input| input.is_object())
36            .cloned();
37        // Completed states carry `output`, error states carry `error` (both
38        // strings in OpenCode's SDK shapes).
39        let result = state
40            .and_then(|s| s.get("output").or_else(|| s.get("error")))
41            .and_then(Value::as_str)
42            .map(str::to_string);
43        let ordinal = invocations.len() as u32;
44        invocations.push(ToolInvocation {
45            name: name.to_string(),
46            args,
47            result: result.map(Value::String),
48            ordinal,
49        });
50    }
51    invocations
52}
53
54fn extract_events(records: &[Value]) -> Vec<TranscriptEvent> {
55    let mut events = Vec::new();
56    for record in records {
57        let ordinal = events.len() as u32;
58        match record.get("type").and_then(Value::as_str) {
59            Some("tool_use") => {
60                let Some(part) = record.get("part").and_then(Value::as_object) else {
61                    continue;
62                };
63                let Some(name) = part.get("tool").and_then(Value::as_str) else {
64                    continue;
65                };
66                let state = part.get("state");
67                let args = state
68                    .and_then(|s| s.get("input"))
69                    .filter(|input| input.is_object())
70                    .cloned();
71                let result = state
72                    .and_then(|s| s.get("output").or_else(|| s.get("error")))
73                    .and_then(Value::as_str)
74                    .map(|value| Value::String(value.to_string()));
75                events.push(TranscriptEvent::ToolInvocation {
76                    ordinal,
77                    name: name.to_string(),
78                    args,
79                    result,
80                });
81            }
82            Some("text") => {
83                if let Some(text) = record
84                    .get("part")
85                    .and_then(|part| part.get("text"))
86                    .and_then(Value::as_str)
87                {
88                    events.push(TranscriptEvent::AssistantMessage {
89                        ordinal,
90                        text: text.to_string(),
91                    });
92                }
93            }
94            _ => {}
95        }
96    }
97    events
98}
99
100/// Parse an OpenCode `--format json` event stream into ordered tool
101/// invocations.
102pub fn parse_opencode_events(jsonl_path: &Path) -> io::Result<Vec<ToolInvocation>> {
103    Ok(extract_invocations(&read_jsonl::<Value>(jsonl_path)?))
104}
105
106/// Parse an OpenCode `--format json` event stream into a full
107/// [`TranscriptSummary`].
108pub fn parse_opencode_events_full(jsonl_path: &Path) -> io::Result<TranscriptSummary> {
109    let records = read_jsonl::<Value>(jsonl_path)?;
110
111    let mut first_ts: Option<i64> = None;
112    let mut last_ts: Option<i64> = None;
113    let mut timestamp_count = 0usize;
114    let mut final_text: Option<String> = None;
115    let mut total_tokens: Option<i64> = None;
116    let session_id = records
117        .iter()
118        .find_map(|record| record.get("sessionID").and_then(Value::as_str))
119        .map(str::to_string);
120
121    for record in &records {
122        // OpenCode envelope timestamps are epoch milliseconds (numbers), not
123        // RFC 3339 strings.
124        if let Some(ts) = record.get("timestamp").and_then(Value::as_i64) {
125            if first_ts.is_none() {
126                first_ts = Some(ts);
127            }
128            last_ts = Some(ts);
129            timestamp_count += 1;
130        }
131
132        let rtype = record.get("type").and_then(Value::as_str);
133
134        if rtype == Some("text")
135            && let Some(text) = record
136                .get("part")
137                .and_then(|p| p.get("text"))
138                .and_then(Value::as_str)
139        {
140            final_text = Some(text.to_string());
141        }
142
143        if rtype == Some("step_finish")
144            && let Some(tokens) = record
145                .get("part")
146                .and_then(|p| p.get("tokens"))
147                .and_then(Value::as_object)
148        {
149            let get = |k: &str| tokens.get(k).and_then(Value::as_i64).unwrap_or(0);
150            // cache.read/write excluded, matching the codex parser's accounting.
151            let sum = get("input") + get("output") + get("reasoning");
152            total_tokens = Some(total_tokens.unwrap_or(0) + sum);
153        }
154    }
155
156    let duration_ms = match (first_ts, last_ts) {
157        (Some(f), Some(l)) if timestamp_count >= 2 => Some(l - f),
158        _ => None,
159    };
160
161    Ok(TranscriptSummary {
162        tool_invocations: extract_invocations(&records),
163        events: extract_events(&records),
164        session_id,
165        total_tokens,
166        duration_ms,
167        final_text,
168    })
169}
170
171#[cfg(test)]
172mod tests {
173    use super::{parse_opencode_events, parse_opencode_events_full};
174    use crate::core::ToolInvocation;
175    use serde_json::{Value, json};
176    use std::fs;
177    use std::path::Path;
178    use tempfile::TempDir;
179
180    fn write_jsonl(path: &Path, lines: &[Value]) {
181        let body = lines
182            .iter()
183            .map(|l| l.to_string())
184            .collect::<Vec<_>>()
185            .join("\n");
186        fs::write(path, format!("{body}\n")).unwrap();
187    }
188
189    /// A `tool_use` envelope wrapping a tool part with the given state.
190    fn tool_use(ts: i64, tool: &str, state: Value) -> Value {
191        json!({
192            "type": "tool_use",
193            "timestamp": ts,
194            "sessionID": "ses_1",
195            "part": {
196                "id": "prt_1",
197                "sessionID": "ses_1",
198                "messageID": "msg_1",
199                "type": "tool",
200                "callID": "call_1",
201                "tool": tool,
202                "state": state,
203            }
204        })
205    }
206
207    #[test]
208    fn extracts_completed_and_errored_tool_calls_with_ordinals_args_and_results() {
209        let dir = TempDir::new().unwrap();
210        let path = dir.path().join("events.jsonl");
211        write_jsonl(
212            &path,
213            &[
214                tool_use(
215                    1_000,
216                    "bash",
217                    json!({"status": "completed", "input": {"command": "bun test", "description": "run tests"}, "output": "2 pass\n0 fail", "title": "bun test", "metadata": {}, "time": {"start": 900, "end": 1_000}}),
218                ),
219                tool_use(
220                    2_000,
221                    "edit",
222                    json!({"status": "error", "input": {"filePath": "/tmp/x.md", "oldString": "a", "newString": "b"}, "error": "permission denied", "time": {"start": 1_900, "end": 2_000}}),
223                ),
224                tool_use(
225                    3_000,
226                    "read",
227                    json!({"status": "completed", "output": "file contents", "title": "read", "metadata": {}, "time": {"start": 2_900, "end": 3_000}}),
228                ),
229            ],
230        );
231
232        let result = parse_opencode_events(&path).unwrap();
233        assert_eq!(
234            result,
235            vec![
236                ToolInvocation {
237                    name: "bash".into(),
238                    args: Some(json!({"command": "bun test", "description": "run tests"})),
239                    result: Some(Value::String("2 pass\n0 fail".into())),
240                    ordinal: 0,
241                },
242                ToolInvocation {
243                    name: "edit".into(),
244                    args: Some(
245                        json!({"filePath": "/tmp/x.md", "oldString": "a", "newString": "b"})
246                    ),
247                    result: Some(Value::String("permission denied".into())),
248                    ordinal: 1,
249                },
250                // No `input` object on the state → null args.
251                ToolInvocation {
252                    name: "read".into(),
253                    args: None,
254                    result: Some(Value::String("file contents".into())),
255                    ordinal: 2,
256                },
257            ]
258        );
259    }
260
261    #[test]
262    fn skill_tool_calls_keep_the_name_argument_for_the_meta_check() {
263        let dir = TempDir::new().unwrap();
264        let path = dir.path().join("events.jsonl");
265        write_jsonl(
266            &path,
267            &[tool_use(
268                1_000,
269                "skill",
270                json!({"status": "completed", "input": {"name": "slow-powers-eval-1-with-skill-mr-review"}, "output": "<skill_content/>", "title": "skill", "metadata": {}, "time": {"start": 900, "end": 1_000}}),
271            )],
272        );
273
274        let invocations = parse_opencode_events(&path).unwrap();
275        assert_eq!(invocations.len(), 1);
276        assert_eq!(invocations[0].name, "skill");
277        assert_eq!(
278            invocations[0].args.as_ref().and_then(|a| a.get("name")),
279            Some(&json!("slow-powers-eval-1-with-skill-mr-review"))
280        );
281    }
282
283    #[test]
284    fn non_tool_events_produce_no_invocations() {
285        let dir = TempDir::new().unwrap();
286        let path = dir.path().join("events.jsonl");
287        write_jsonl(
288            &path,
289            &[
290                json!({"type": "step_start", "timestamp": 1_000, "sessionID": "ses_1", "part": {"id": "p1", "type": "step-start"}}),
291                json!({"type": "reasoning", "timestamp": 2_000, "sessionID": "ses_1", "part": {"id": "p2", "type": "reasoning", "text": "thinking"}}),
292                json!({"type": "text", "timestamp": 3_000, "sessionID": "ses_1", "part": {"id": "p3", "type": "text", "text": "hello"}}),
293                json!({"type": "step_finish", "timestamp": 4_000, "sessionID": "ses_1", "part": {"id": "p4", "type": "step-finish", "reason": "stop", "cost": 0.0, "tokens": {"input": 1, "output": 1, "reasoning": 0, "cache": {"read": 0, "write": 0}}}}),
294                json!({"type": "error", "timestamp": 5_000, "sessionID": "ses_1", "error": {"name": "UnknownError", "data": {"message": "boom"}}}),
295            ],
296        );
297        assert_eq!(parse_opencode_events(&path).unwrap(), vec![]);
298    }
299
300    #[test]
301    fn malformed_tool_use_parts_are_skipped() {
302        let dir = TempDir::new().unwrap();
303        let path = dir.path().join("events.jsonl");
304        write_jsonl(
305            &path,
306            &[
307                json!({"type": "tool_use", "timestamp": 1_000, "sessionID": "ses_1"}),
308                json!({"type": "tool_use", "timestamp": 2_000, "sessionID": "ses_1", "part": "not an object"}),
309                json!({"type": "tool_use", "timestamp": 3_000, "sessionID": "ses_1", "part": {"id": "p1", "type": "tool"}}),
310                json!({"type": "tool_use", "timestamp": 4_000, "sessionID": "ses_1", "part": {"id": "p2", "type": "tool", "tool": 7}}),
311            ],
312        );
313        assert_eq!(parse_opencode_events(&path).unwrap(), vec![]);
314    }
315
316    #[test]
317    fn skips_malformed_jsonl_lines() {
318        let dir = TempDir::new().unwrap();
319        let path = dir.path().join("events.jsonl");
320        let good = tool_use(
321            1_000,
322            "grep",
323            json!({"status": "completed", "input": {"pattern": "todo"}, "output": "3 matches", "title": "grep", "metadata": {}, "time": {"start": 900, "end": 1_000}}),
324        );
325        fs::write(&path, format!("{good}\nnot valid json\n")).unwrap();
326        assert_eq!(parse_opencode_events(&path).unwrap().len(), 1);
327    }
328
329    #[test]
330    fn full_summary_extracts_final_text_token_sum_and_timestamp_duration() {
331        let dir = TempDir::new().unwrap();
332        let path = dir.path().join("events.jsonl");
333        write_jsonl(
334            &path,
335            &[
336                json!({"type": "step_start", "timestamp": 1_000, "sessionID": "ses_1", "part": {"id": "p1", "type": "step-start"}}),
337                tool_use(
338                    2_000,
339                    "bash",
340                    json!({"status": "completed", "input": {"command": "ls"}, "output": "README.md", "title": "ls", "metadata": {}, "time": {"start": 1_900, "end": 2_000}}),
341                ),
342                json!({"type": "text", "timestamp": 3_000, "sessionID": "ses_1", "part": {"id": "p3", "type": "text", "text": "First."}}),
343                json!({"type": "text", "timestamp": 4_000, "sessionID": "ses_1", "part": {"id": "p4", "type": "text", "text": "Final."}}),
344                json!({"type": "step_finish", "timestamp": 5_000, "sessionID": "ses_1", "part": {"id": "p5", "type": "step-finish", "reason": "stop", "cost": 0.002, "tokens": {"input": 100, "output": 20, "reasoning": 5, "cache": {"read": 75, "write": 0}}}}),
345                json!({"type": "step_finish", "timestamp": 6_000, "sessionID": "ses_1", "part": {"id": "p6", "type": "step-finish", "reason": "stop", "cost": 0.001, "tokens": {"input": 40, "output": 2, "reasoning": 0, "cache": {"read": 0, "write": 0}}}}),
346            ],
347        );
348
349        let full = parse_opencode_events_full(&path).unwrap();
350        assert_eq!(full.session_id.as_deref(), Some("ses_1"));
351        assert_eq!(
352            serde_json::to_value(&full.events).unwrap(),
353            json!([
354                {
355                    "type": "tool_invocation",
356                    "ordinal": 0,
357                    "name": "bash",
358                    "args": {"command": "ls"},
359                    "result": "README.md"
360                },
361                {
362                    "type": "assistant_message",
363                    "ordinal": 1,
364                    "text": "First."
365                },
366                {
367                    "type": "assistant_message",
368                    "ordinal": 2,
369                    "text": "Final."
370                }
371            ])
372        );
373        assert_eq!(full.tool_invocations.len(), 1);
374        assert_eq!(full.tool_invocations[0].name, "bash");
375        assert_eq!(full.final_text, Some("Final.".into()));
376        // cache.read (75) excluded, matching the codex parser's accounting.
377        assert_eq!(full.total_tokens, Some(167));
378        assert_eq!(full.duration_ms, Some(5_000));
379    }
380
381    #[test]
382    fn returns_null_usage_and_duration_when_sparse() {
383        let dir = TempDir::new().unwrap();
384        let path = dir.path().join("events.jsonl");
385        write_jsonl(
386            &path,
387            &[
388                json!({"type": "text", "timestamp": 1_000, "sessionID": "ses_1", "part": {"id": "p1", "type": "text", "text": "Done."}}),
389            ],
390        );
391        let full = parse_opencode_events_full(&path).unwrap();
392        assert_eq!(full.final_text, Some("Done.".into()));
393        assert_eq!(full.total_tokens, None);
394        assert_eq!(full.duration_ms, None);
395    }
396
397    #[test]
398    fn step_finish_without_tokens_leaves_the_total_untouched() {
399        let dir = TempDir::new().unwrap();
400        let path = dir.path().join("events.jsonl");
401        write_jsonl(
402            &path,
403            &[
404                json!({"type": "step_finish", "timestamp": 1_000, "sessionID": "ses_1", "part": {"id": "p1", "type": "step-finish", "reason": "tool-calls", "cost": 0.0}}),
405                json!({"type": "step_finish", "timestamp": 2_000, "sessionID": "ses_1", "part": {"id": "p2", "type": "step-finish", "reason": "stop", "cost": 0.001, "tokens": {"input": 10, "output": 2, "reasoning": 0, "cache": {"read": 0, "write": 0}}}}),
406            ],
407        );
408        assert_eq!(
409            parse_opencode_events_full(&path).unwrap().total_tokens,
410            Some(12)
411        );
412    }
413}