1use std::fs;
15use std::io;
16use std::path::Path;
17
18use crate::core::ToolInvocation;
19
20use super::TranscriptSummary;
21use super::claude_code_transcript::{
22 TranscriptRecord, UsageRecord, extract_invocations, last_assistant_text, read_records,
23};
24
25#[derive(Debug, serde::Deserialize)]
27struct ResultEvent {
28 #[serde(default)]
29 result: Option<String>,
30 #[serde(default)]
31 duration_ms: Option<i64>,
32 #[serde(default)]
33 is_error: Option<bool>,
34 #[serde(default)]
35 usage: Option<UsageRecord>,
36}
37
38pub fn parse_claude_stream_json(path: &Path) -> io::Result<Vec<ToolInvocation>> {
41 Ok(extract_invocations(&read_records(path)?))
42}
43
44pub fn parse_claude_stream_json_full(path: &Path) -> io::Result<TranscriptSummary> {
49 let raw = fs::read_to_string(path)?;
50 let mut records: Vec<TranscriptRecord> = Vec::new();
51 let mut result_event: Option<ResultEvent> = None;
52 for line in raw.split('\n') {
53 if line.trim().is_empty() {
54 continue;
55 }
56 let Ok(record) = serde_json::from_str::<TranscriptRecord>(line) else {
58 continue;
59 };
60 if record.record_type.as_deref() == Some("result") {
61 result_event = serde_json::from_str::<ResultEvent>(line).ok();
62 }
63 records.push(record);
64 }
65
66 let total_tokens = result_event
67 .as_ref()
68 .and_then(|e| e.usage.as_ref())
69 .map(|u| {
70 u.input_tokens.unwrap_or(0)
71 + u.output_tokens.unwrap_or(0)
72 + u.cache_creation_input_tokens.unwrap_or(0)
73 + u.cache_read_input_tokens.unwrap_or(0)
74 });
75 let duration_ms = result_event.as_ref().and_then(|e| e.duration_ms);
76 let final_text = match &result_event {
77 Some(ev) if ev.is_error != Some(true) => {
78 ev.result.clone().or_else(|| last_assistant_text(&records))
79 }
80 _ => last_assistant_text(&records),
81 };
82
83 Ok(TranscriptSummary {
84 tool_invocations: extract_invocations(&records),
85 total_tokens,
86 duration_ms,
87 final_text,
88 })
89}
90
91#[cfg(test)]
92mod tests {
93 use serde_json::{Value, json};
94 use std::fs;
95 use std::path::Path;
96 use tempfile::TempDir;
97
98 fn write_jsonl(path: &Path, lines: &[Value]) {
99 let body = lines
100 .iter()
101 .map(|l| l.to_string())
102 .collect::<Vec<_>>()
103 .join("\n");
104 fs::write(path, format!("{body}\n")).unwrap();
105 }
106
107 fn usage() -> Value {
108 json!({
109 "input_tokens": 4932,
110 "output_tokens": 139,
111 "cache_creation_input_tokens": 8287,
112 "cache_read_input_tokens": 33490,
113 })
114 }
115
116 #[test]
117 fn extracts_tool_use_and_result_skipping_non_message_events() {
118 let dir = TempDir::new().unwrap();
119 let path = dir.path().join("events.jsonl");
120 write_jsonl(
121 &path,
122 &[
123 json!({"type": "system", "subtype": "init", "cwd": "/env", "model": "claude-opus-4-8", "tools": ["Bash"]}),
124 json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "usage": usage(), "content": [
125 {"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "ls"}}
126 ]}}),
127 json!({"type": "rate_limit_event", "rate_limit_info": {}}),
128 json!({"type": "user", "message": {"role": "user", "content": [
129 {"type": "tool_result", "tool_use_id": "toolu_1", "content": "a.txt"}
130 ]}}),
131 json!({"type": "result", "subtype": "success", "is_error": false, "result": "Done", "duration_ms": 5637, "usage": usage()}),
132 ],
133 );
134
135 let result = super::parse_claude_stream_json(&path).unwrap();
136 assert_eq!(result.len(), 1);
137 assert_eq!(result[0].name, "Bash");
138 assert_eq!(result[0].ordinal, 0);
139 assert_eq!(result[0].args, Some(json!({"command": "ls"})));
140 assert_eq!(result[0].result, Some(Value::String("a.txt".into())));
141 }
142
143 #[test]
144 fn result_event_supplies_final_text_duration_and_tokens() {
145 let dir = TempDir::new().unwrap();
146 let path = dir.path().join("events.jsonl");
147 write_jsonl(
148 &path,
149 &[
150 json!({"type": "system", "subtype": "init"}),
151 json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "text", "text": "working"}]}}),
152 json!({"type": "result", "subtype": "success", "is_error": false, "result": "Done", "duration_ms": 5637, "usage": usage()}),
153 ],
154 );
155
156 let summary = super::parse_claude_stream_json_full(&path).unwrap();
157 assert_eq!(summary.final_text, Some("Done".into()));
158 assert_eq!(summary.duration_ms, Some(5637));
159 assert_eq!(summary.total_tokens, Some(46848));
161 }
162
163 #[test]
164 fn skill_tool_use_is_preserved_for_meta_check() {
165 let dir = TempDir::new().unwrap();
166 let path = dir.path().join("events.jsonl");
167 write_jsonl(
168 &path,
169 &[
170 json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [
171 {"type": "tool_use", "id": "toolu_s", "name": "Skill", "input": {"command": "slow-powers-eval-mr-review"}}
172 ]}}),
173 json!({"type": "result", "subtype": "success", "is_error": false, "result": "ok", "duration_ms": 10, "usage": usage()}),
174 ],
175 );
176 let result = super::parse_claude_stream_json(&path).unwrap();
177 assert_eq!(result.len(), 1);
178 assert_eq!(result[0].name, "Skill");
179 assert_eq!(
180 result[0].args,
181 Some(json!({"command": "slow-powers-eval-mr-review"}))
182 );
183 }
184
185 #[test]
186 fn final_text_falls_back_to_last_assistant_text_when_result_is_error() {
187 let dir = TempDir::new().unwrap();
188 let path = dir.path().join("events.jsonl");
189 write_jsonl(
190 &path,
191 &[
192 json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "text", "text": "partial work"}]}}),
193 json!({"type": "result", "subtype": "error_during_execution", "is_error": true, "result": "Execution error", "duration_ms": 12, "usage": usage()}),
194 ],
195 );
196 let summary = super::parse_claude_stream_json_full(&path).unwrap();
197 assert_eq!(summary.final_text, Some("partial work".into()));
198 }
199
200 #[test]
201 fn skips_malformed_jsonl_lines() {
202 let dir = TempDir::new().unwrap();
203 let path = dir.path().join("events.jsonl");
204 let good = json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [
205 {"type": "tool_use", "id": "toolu_1", "name": "Read", "input": {"file_path": "/tmp/x"}}
206 ]}});
207 let result_line = json!({"type": "result", "is_error": false, "result": "Done", "duration_ms": 1, "usage": usage()});
208 let body = format!("{good}\nnot valid json\n{result_line}\n");
209 fs::write(&path, body).unwrap();
210
211 let result = super::parse_claude_stream_json(&path).unwrap();
212 assert_eq!(result.len(), 1);
213 assert_eq!(result[0].name, "Read");
214 }
215
216 #[test]
217 fn null_duration_and_tokens_when_no_result_event() {
218 let dir = TempDir::new().unwrap();
219 let path = dir.path().join("events.jsonl");
220 write_jsonl(
221 &path,
222 &[
223 json!({"type": "system", "subtype": "init"}),
224 json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "text", "text": "incomplete"}]}}),
225 ],
226 );
227 let summary = super::parse_claude_stream_json_full(&path).unwrap();
228 assert_eq!(summary.duration_ms, None);
229 assert_eq!(summary.total_tokens, None);
230 assert_eq!(summary.final_text, Some("incomplete".into()));
231 }
232}