Skip to main content

agx_core/
gemini.rs

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    /// Gemini's native usage shape is `usageMetadata` with camelCase fields
28    /// per the Gemini API. Optional — absent on older sessions and on
29    /// non-model messages.
30    #[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
117// Gemini message.content is polymorphic:
118//   - a bare string for assistant messages
119//   - a list of {text: "..."} objects for user messages
120//   - sometimes empty/null when toolCalls are the real payload
121fn 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
135// Gemini toolCall.result is a list of wrappers:
136//   [{functionResponse: {id, name, response: {output: "..."}}}, ...]
137// Extract the first output string if possible; fall back to pretty-printed JSON
138// so the detail pane always has something useful.
139fn 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        // Empty text is skipped, tool_use + tool_result still emitted
246        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        // Gemini message with text + tool calls — usage goes on the first
291        // step (text), not the tool steps.
292        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}