1use crate::timeline::{
2 Step, Usage, assistant_text_step, attach_usage_to_first, compute_durations, parse_iso_ms,
3 pretty_json, tool_result_step, tool_use_step, user_text_step,
4};
5use anyhow::{Context, Result};
6use serde::Deserialize;
7use std::path::Path;
8
9#[derive(Debug, Deserialize)]
10struct Session {
11 #[serde(default)]
12 messages: Vec<Message>,
13}
14
15#[derive(Debug, Deserialize)]
16struct Message {
17 #[serde(rename = "type")]
18 msg_type: String,
19 #[serde(default)]
20 timestamp: Option<String>,
21 #[serde(default)]
22 content: serde_json::Value,
23 #[serde(default, rename = "toolCalls")]
24 tool_calls: Vec<ToolCall>,
25 #[serde(default)]
26 model: Option<String>,
27 #[serde(default, rename = "usageMetadata")]
31 usage_metadata: Option<GeminiUsage>,
32}
33
34#[derive(Debug, Deserialize)]
35struct GeminiUsage {
36 #[serde(default, rename = "promptTokenCount")]
37 prompt_tokens: Option<u64>,
38 #[serde(default, rename = "candidatesTokenCount")]
39 output_tokens: Option<u64>,
40 #[serde(default, rename = "cachedContentTokenCount")]
41 cached_tokens: Option<u64>,
42}
43
44#[derive(Debug, Deserialize)]
45struct ToolCall {
46 #[serde(default)]
47 id: String,
48 #[serde(default)]
49 name: String,
50 #[serde(default)]
51 timestamp: Option<String>,
52 #[serde(default)]
53 args: serde_json::Value,
54 #[serde(default)]
55 result: serde_json::Value,
56}
57
58pub fn load(path: &Path) -> Result<Vec<Step>> {
59 let content = std::fs::read_to_string(path)
60 .with_context(|| format!("reading gemini session file: {}", path.display()))?;
61 let session: Session = serde_json::from_str(&content)
62 .with_context(|| format!("parsing gemini session file: {}", path.display()))?;
63
64 let mut steps = Vec::new();
65 for msg in &session.messages {
66 let msg_ts = msg.timestamp.as_deref().and_then(parse_iso_ms);
67 match msg.msg_type.as_str() {
68 "user" => {
69 let text = extract_message_text(&msg.content);
70 if !text.trim().is_empty() {
71 let mut step = user_text_step(&text);
72 step.timestamp_ms = msg_ts;
73 steps.push(step);
74 }
75 }
76 "gemini" => {
77 let first_idx = steps.len();
78 let text = extract_message_text(&msg.content);
79 if !text.trim().is_empty() {
80 let mut step = assistant_text_step(&text);
81 step.timestamp_ms = msg_ts;
82 steps.push(step);
83 }
84 for tc in &msg.tool_calls {
85 let tc_ts = tc.timestamp.as_deref().and_then(parse_iso_ms).or(msg_ts);
86 let input_pretty = pretty_json(&tc.args);
87 let mut use_step = tool_use_step(&tc.id, &tc.name, &input_pretty);
88 use_step.timestamp_ms = tc_ts;
89 steps.push(use_step);
90 let result_text = extract_gemini_tool_result(&tc.result);
91 let mut res_step =
92 tool_result_step(&tc.id, &result_text, Some(&tc.name), Some(&input_pretty));
93 res_step.timestamp_ms = tc_ts;
94 steps.push(res_step);
95 }
96 if steps.len() > first_idx {
97 let usage = msg
98 .usage_metadata
99 .as_ref()
100 .map(|u| Usage {
101 tokens_in: u.prompt_tokens,
102 tokens_out: u.output_tokens,
103 cache_read: u.cached_tokens,
104 cache_create: None,
105 })
106 .unwrap_or_default();
107 attach_usage_to_first(&mut steps, first_idx, msg.model.as_deref(), &usage);
108 }
109 }
110 _ => {}
111 }
112 }
113 compute_durations(&mut steps);
114 Ok(steps)
115}
116
117fn extract_message_text(content: &serde_json::Value) -> String {
122 if let Some(s) = content.as_str() {
123 return s.to_string();
124 }
125 if let Some(arr) = content.as_array() {
126 return arr
127 .iter()
128 .filter_map(|item| item.get("text").and_then(|t| t.as_str()))
129 .collect::<Vec<_>>()
130 .join("\n");
131 }
132 String::new()
133}
134
135fn extract_gemini_tool_result(result: &serde_json::Value) -> String {
140 if let Some(arr) = result.as_array() {
141 for item in arr {
142 if let Some(output) = item
143 .get("functionResponse")
144 .and_then(|fr| fr.get("response"))
145 .and_then(|r| r.get("output"))
146 .and_then(|o| o.as_str())
147 {
148 return output.to_string();
149 }
150 }
151 }
152 if let Some(s) = result.as_str() {
153 return s.to_string();
154 }
155 if result.is_null() {
156 return String::new();
157 }
158 pretty_json(result)
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::timeline::StepKind;
165 use std::io::Write;
166 use tempfile::NamedTempFile;
167
168 fn write_file(content: &str) -> NamedTempFile {
169 let mut f = NamedTempFile::new().unwrap();
170 f.write_all(content.as_bytes()).unwrap();
171 f
172 }
173
174 #[test]
175 fn parses_user_and_assistant_messages() {
176 let json = r#"{
177 "sessionId": "s1",
178 "messages": [
179 {"type": "user", "id": "m1", "content": [{"text": "hello"}]},
180 {"type": "gemini", "id": "m2", "content": "hi there"}
181 ]
182 }"#;
183 let f = write_file(json);
184 let steps = load(f.path()).unwrap();
185 assert_eq!(steps.len(), 2);
186 assert_eq!(steps[0].kind, StepKind::UserText);
187 assert!(steps[0].detail.contains("hello"));
188 assert_eq!(steps[1].kind, StepKind::AssistantText);
189 assert!(steps[1].detail.contains("hi there"));
190 }
191
192 #[test]
193 fn splits_toolcall_into_tool_use_and_tool_result() {
194 let json = r#"{
195 "sessionId": "s1",
196 "messages": [
197 {
198 "type": "gemini",
199 "id": "m1",
200 "content": "Let me list the files.",
201 "toolCalls": [
202 {
203 "id": "tc1",
204 "name": "list_directory",
205 "args": {"dir_path": "."},
206 "result": [{"functionResponse": {"id": "tc1", "name": "list_directory", "response": {"output": "file1\nfile2"}}}]
207 }
208 ]
209 }
210 ]
211 }"#;
212 let f = write_file(json);
213 let steps = load(f.path()).unwrap();
214 assert_eq!(steps.len(), 3);
215 assert_eq!(steps[0].kind, StepKind::AssistantText);
216 assert!(steps[0].detail.contains("list the files"));
217 assert_eq!(steps[1].kind, StepKind::ToolUse);
218 assert!(steps[1].label.contains("list_directory"));
219 assert!(steps[1].detail.contains("dir_path"));
220 assert_eq!(steps[2].kind, StepKind::ToolResult);
221 assert!(steps[2].label.contains("list_directory"));
222 assert!(steps[2].detail.contains("Tool: list_directory"));
223 assert!(steps[2].detail.contains("Input:"));
224 assert!(steps[2].detail.contains("Result:"));
225 assert!(steps[2].detail.contains("file1"));
226 }
227
228 #[test]
229 fn skips_empty_assistant_content_when_only_toolcalls() {
230 let json = r#"{
231 "sessionId": "s1",
232 "messages": [
233 {
234 "type": "gemini",
235 "id": "m1",
236 "content": "",
237 "toolCalls": [
238 {"id": "tc1", "name": "Read", "args": {}, "result": []}
239 ]
240 }
241 ]
242 }"#;
243 let f = write_file(json);
244 let steps = load(f.path()).unwrap();
245 assert_eq!(steps.len(), 2);
247 assert_eq!(steps[0].kind, StepKind::ToolUse);
248 assert_eq!(steps[1].kind, StepKind::ToolResult);
249 }
250
251 #[test]
252 fn skips_info_messages() {
253 let json = r#"{
254 "sessionId": "s1",
255 "messages": [
256 {"type": "info", "id": "m1", "content": "Request cancelled."},
257 {"type": "user", "id": "m2", "content": [{"text": "retry"}]}
258 ]
259 }"#;
260 let f = write_file(json);
261 let steps = load(f.path()).unwrap();
262 assert_eq!(steps.len(), 1);
263 assert_eq!(steps[0].kind, StepKind::UserText);
264 }
265
266 #[test]
267 fn parses_usagemetadata_and_model_on_gemini_message() {
268 let json = r#"{
269 "sessionId":"s1",
270 "messages":[
271 {
272 "type":"gemini",
273 "content":"hello",
274 "model":"gemini-2-5-pro",
275 "usageMetadata":{"promptTokenCount":80,"candidatesTokenCount":40,"cachedContentTokenCount":20}
276 }
277 ]
278 }"#;
279 let f = write_file(json);
280 let steps = load(f.path()).unwrap();
281 assert_eq!(steps.len(), 1);
282 assert_eq!(steps[0].model.as_deref(), Some("gemini-2-5-pro"));
283 assert_eq!(steps[0].tokens_in, Some(80));
284 assert_eq!(steps[0].tokens_out, Some(40));
285 assert_eq!(steps[0].cache_read, Some(20));
286 }
287
288 #[test]
289 fn usage_attaches_to_text_when_both_text_and_toolcalls() {
290 let json = r#"{
293 "sessionId":"s1",
294 "messages":[
295 {
296 "type":"gemini",
297 "content":"preamble",
298 "model":"gemini-2-5-pro",
299 "usageMetadata":{"promptTokenCount":50,"candidatesTokenCount":25},
300 "toolCalls":[
301 {"id":"tc1","name":"ls","args":{},"result":[]}
302 ]
303 }
304 ]
305 }"#;
306 let f = write_file(json);
307 let steps = load(f.path()).unwrap();
308 assert_eq!(steps.len(), 3);
309 assert_eq!(steps[0].tokens_in, Some(50));
310 assert_eq!(steps[1].tokens_in, None);
311 assert_eq!(steps[2].tokens_in, None);
312 }
313
314 #[test]
315 fn falls_back_to_pretty_json_for_nonstandard_tool_result() {
316 let json = r#"{
317 "sessionId": "s1",
318 "messages": [
319 {
320 "type": "gemini",
321 "id": "m1",
322 "content": "",
323 "toolCalls": [
324 {"id": "tc1", "name": "weird", "args": {}, "result": {"some": "object"}}
325 ]
326 }
327 ]
328 }"#;
329 let f = write_file(json);
330 let steps = load(f.path()).unwrap();
331 assert_eq!(steps.len(), 2);
332 assert!(steps[1].detail.contains("some"));
333 }
334}