1use 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 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
100pub fn parse_opencode_events(jsonl_path: &Path) -> io::Result<Vec<ToolInvocation>> {
103 Ok(extract_invocations(&read_jsonl::<Value>(jsonl_path)?))
104}
105
106pub 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 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 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 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 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 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}