Skip to main content

supercode_interchange/session/
gemini.rs

1//! Gemini session codec: loaders, writers and native-record helpers.
2
3use super::*;
4
5impl Session {
6    /// Load a Gemini CLI transcript from disk.
7    pub fn from_gemini(path: impl AsRef<Path>) -> Result<Session> {
8        Self::from_gemini_str(&std::fs::read_to_string(path.as_ref())?)
9    }
10
11    /// Parse Gemini CLI's line-oriented session format.
12    ///
13    /// Gemini stores a header without a `type`, followed by `user` and
14    /// `gemini` records. Function calls are embedded in assistant content
15    /// parts and function responses in user content parts. Unknown records
16    /// remain byte-exact in [`Session::raw`] instead of silently entering the
17    /// replay conversation.
18    pub fn from_gemini_str(jsonl: &str) -> Result<Session> {
19        let mut meta = SessionMeta::new(SessionSource::Gemini);
20        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
21        let raw = raw_lines.iter().map(|line| line.to_string()).collect();
22        let mut messages = Vec::new();
23        let mut parse_error_lines = 0usize;
24        let mut pending_by_name: HashMap<String, Vec<String>> = HashMap::new();
25
26        // The parse walk skips blank lines; `raw` keeps them. Position `i` of the walk is raw line
27        // `raw_record[i]`.
28        let raw_record: Vec<usize> = raw_lines
29            .iter()
30            .enumerate()
31            .filter(|(_, line)| !line.trim().is_empty())
32            .map(|(index, _)| index)
33            .collect();
34        for (line_index, line) in non_empty_lines(jsonl).enumerate() {
35            // What the previous record created belongs to it; this record starts after.
36            if line_index > 0 {
37                stamp_message_records(&mut messages, raw_record[line_index - 1]);
38            }
39            let value: Value = match serde_json::from_str(line) {
40                Ok(value) => value,
41                Err(_) => {
42                    parse_error_lines += 1;
43                    continue;
44                }
45            };
46            let kind = value.get("type").and_then(Value::as_str);
47            if kind.is_none() {
48                if meta.session_id.is_none() {
49                    meta.session_id = value
50                        .get("sessionId")
51                        .and_then(Value::as_str)
52                        .map(str::to_string);
53                }
54                for (source, target) in [
55                    ("projectHash", "gemini_project_hash"),
56                    ("startTime", "created_at"),
57                    ("lastUpdated", "updated_at"),
58                    ("kind", "gemini_session_kind"),
59                ] {
60                    if let Some(raw) = value.get(source) {
61                        meta.lineage.insert(
62                            target.to_string(),
63                            raw.as_str()
64                                .map(str::to_string)
65                                .unwrap_or_else(|| raw.to_string()),
66                        );
67                    }
68                }
69                continue;
70            }
71            if kind != Some("user") && kind != Some("gemini") {
72                continue;
73            }
74
75            let timestamp = value.get("timestamp").and_then(Value::as_str);
76            let model = value.get("model").and_then(Value::as_str);
77            if let Some(model) = model {
78                meta.model = Some(model.to_string());
79            }
80            let content = value.get("content").unwrap_or(&Value::Null);
81            let parts = content.as_array();
82            let text = match content {
83                Value::String(text) => text.clone(),
84                Value::Array(parts) => parts
85                    .iter()
86                    .filter_map(|part| part.get("text").and_then(Value::as_str))
87                    .collect::<Vec<_>>()
88                    .join(" ")
89                    .trim()
90                    .to_string(),
91                _ => String::new(),
92            };
93
94            if kind == Some("gemini") {
95                let legacy_calls = parts
96                    .into_iter()
97                    .flatten()
98                    .filter_map(|part| part.get("functionCall"));
99                let native_calls = value
100                    .get("toolCalls")
101                    .and_then(Value::as_array)
102                    .into_iter()
103                    .flatten();
104                let calls = native_calls
105                    .chain(legacy_calls)
106                    .enumerate()
107                    .filter_map(|(call_index, call)| {
108                        let name = call.get("name")?.as_str()?.to_string();
109                        let id = call
110                            .get("id")
111                            .and_then(Value::as_str)
112                            .map(str::to_string)
113                            .unwrap_or_else(|| format!("gemini-{line_index}-{call_index}"));
114                        pending_by_name
115                            .entry(name.clone())
116                            .or_default()
117                            .push(id.clone());
118                        let arguments = call
119                            .get("args")
120                            .map(value_to_arg_string)
121                            .unwrap_or_else(|| "{}".to_string());
122                        Some(function_call(&id, &name, arguments))
123                    })
124                    .collect::<Vec<_>>();
125                let mut message = ChatMessage {
126                    role: Role::Assistant,
127                    content: (!text.is_empty()).then_some(text),
128                    content_parts: None,
129                    tool_calls: (!calls.is_empty()).then_some(calls),
130                    tool_call_id: None,
131                    name: None,
132                    metadata: Default::default(),
133                };
134                if let Some(timestamp) = timestamp {
135                    message
136                        .metadata
137                        .insert("timestamp".into(), timestamp.into());
138                }
139                if let Some(model) = model {
140                    message.metadata.insert("gemini_model".into(), model.into());
141                }
142                if let Some(thoughts) = value.get("thoughts").filter(|value| !value.is_null()) {
143                    message
144                        .metadata
145                        .insert("gemini_thoughts".into(), thoughts.to_string());
146                }
147                restore_gemini_message_extension(&value, &mut message);
148                if message.content.is_some() || message.tool_calls.is_some() {
149                    messages.push(message);
150                }
151                continue;
152            }
153
154            let mut user_parts = Vec::new();
155            if let Some(parts) = parts {
156                for part in parts {
157                    if let Some(response) = part.get("functionResponse") {
158                        push_gemini_user_parts(
159                            &mut messages,
160                            std::mem::take(&mut user_parts),
161                            timestamp,
162                            &value,
163                        );
164                        let name = response
165                            .get("name")
166                            .and_then(Value::as_str)
167                            .unwrap_or("tool")
168                            .to_string();
169                        let explicit_id = response
170                            .get("id")
171                            .and_then(Value::as_str)
172                            .map(str::to_string);
173                        if let Some(id) = explicit_id.as_deref() {
174                            if let Some(ids) = pending_by_name.get_mut(&name) {
175                                if let Some(position) = ids.iter().position(|pending| pending == id)
176                                {
177                                    ids.remove(position);
178                                }
179                            }
180                        }
181                        let id = explicit_id
182                            .or_else(|| {
183                                pending_by_name
184                                    .get_mut(&name)
185                                    .and_then(|ids| (!ids.is_empty()).then(|| ids.remove(0)))
186                            })
187                            .unwrap_or_else(|| format!("gemini-{line_index}-response"));
188                        let output = response
189                            .get("response")
190                            .and_then(|response| response.get("output"))
191                            .map(|output| {
192                                output
193                                    .as_str()
194                                    .map(str::to_string)
195                                    .unwrap_or_else(|| output.to_string())
196                            })
197                            .or_else(|| response.get("response").map(Value::to_string))
198                            .unwrap_or_default();
199                        let mut message = tool_message(&id, output);
200                        message.name = Some(name);
201                        if let Some(timestamp) = timestamp {
202                            message
203                                .metadata
204                                .insert("timestamp".into(), timestamp.into());
205                        }
206                        restore_gemini_message_extension(&value, &mut message);
207                        messages.push(message);
208                        continue;
209                    }
210                    if let Some(text) = part.get("text").and_then(Value::as_str) {
211                        user_parts.push(serde_json::json!({"type": "text", "text": text}));
212                        continue;
213                    }
214                    if let Some(inline) = part.get("inlineData") {
215                        let Some(data) = inline.get("data").and_then(Value::as_str) else {
216                            continue;
217                        };
218                        let media_type = inline
219                            .get("mimeType")
220                            .and_then(Value::as_str)
221                            .unwrap_or("application/octet-stream");
222                        user_parts.push(serde_json::json!({
223                            "type": "image_url",
224                            "image_url": {"url": format!("data:{media_type};base64,{data}")},
225                        }));
226                    }
227                }
228            } else if !text.is_empty() {
229                user_parts.push(serde_json::json!({"type": "text", "text": text}));
230            }
231            push_gemini_user_parts(&mut messages, user_parts, timestamp, &value);
232        }
233
234        if let Some(&last) = raw_record.last() {
235            stamp_message_records(&mut messages, last);
236        }
237        ensure_tool_results_paired(&mut messages);
238        meta.message_records = take_message_records(&mut messages);
239        let imported_message_count = Some(messages.len());
240        Ok(Session {
241            meta,
242            messages,
243            subagents: Vec::new(),
244            raw,
245            raw_trailing_newline,
246            imported_message_count,
247            raw_is_verbatim: true,
248            parse_error_lines,
249            load_residue: Vec::new(),
250        })
251    }
252}
253
254const SUPERCODE_GEMINI_MESSAGE_KEY: &str = "_supercode_gemini_message";
255
256fn set_gemini_message_extension(value: &mut Value, message: &ChatMessage) {
257    value[SUPERCODE_GEMINI_MESSAGE_KEY] = serde_json::json!({
258        "schema": 1,
259        "role": message.role,
260        "content": message.content,
261        "content_parts": message.content_parts,
262        "tool_calls": message.tool_calls,
263        "tool_call_id": message.tool_call_id,
264        "name": message.name,
265        "metadata": message.metadata,
266    });
267}
268
269pub(super) fn restore_gemini_message_extension(value: &Value, message: &mut ChatMessage) {
270    let Some(extension) = value.get(SUPERCODE_GEMINI_MESSAGE_KEY) else {
271        return;
272    };
273    if extension.get("schema").and_then(Value::as_u64) != Some(1) {
274        return;
275    }
276    if let Some(role) = extension
277        .get("role")
278        .and_then(|value| serde_json::from_value(value.clone()).ok())
279    {
280        message.role = role;
281    }
282    message.content = extension
283        .get("content")
284        .and_then(Value::as_str)
285        .map(str::to_string);
286    message.content_parts = extension
287        .get("content_parts")
288        .and_then(|value| serde_json::from_value(value.clone()).ok());
289    message.tool_calls = extension
290        .get("tool_calls")
291        .and_then(|value| serde_json::from_value(value.clone()).ok());
292    message.tool_call_id = extension
293        .get("tool_call_id")
294        .and_then(Value::as_str)
295        .map(str::to_string);
296    message.name = extension
297        .get("name")
298        .and_then(Value::as_str)
299        .map(str::to_string);
300    message.metadata.clear();
301    if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
302        for (key, value) in metadata {
303            if let Some(value) = value.as_str() {
304                message.metadata.insert(key.clone(), value.to_string());
305            }
306        }
307    }
308}
309
310impl Session {
311    // ---- Gemini writers ---------------------------------------------
312
313    pub(super) fn to_gemini_jsonl(&self) -> String {
314        let mut out = String::new();
315        self.write_gemini_header(&mut out, self.meta.session_id.as_deref());
316        self.write_gemini_records(&mut out, &self.messages);
317        push_jsonl(
318            &mut out,
319            &serde_json::json!({
320                "$set": {"lastUpdated": SYNTH_TS}
321            }),
322        );
323        out
324    }
325
326    fn write_gemini_header(&self, out: &mut String, session_id: Option<&str>) {
327        push_jsonl(
328            out,
329            &serde_json::json!({
330                "sessionId": session_id.unwrap_or("supercode-gemini-session"),
331                "projectHash": self.meta.lineage.get("gemini_project_hash")
332                    .cloned().unwrap_or_else(|| "supercode".to_string()),
333                "startTime": self.meta.lineage.get("created_at")
334                    .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
335                "lastUpdated": self.meta.lineage.get("updated_at")
336                    .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
337                "kind": self.meta.lineage.get("gemini_session_kind")
338                    .cloned().unwrap_or_else(|| "main".to_string()),
339            }),
340        );
341    }
342
343    fn write_gemini_records(&self, out: &mut String, messages: &[ChatMessage]) {
344        let mut call_names = HashMap::new();
345        for (index, message) in messages.iter().enumerate() {
346            if is_replay_excluded(message) {
347                continue;
348            }
349            let timestamp = message
350                .metadata
351                .get("timestamp")
352                .cloned()
353                .unwrap_or_else(|| SYNTH_TS.to_string());
354            match message.role {
355                Role::System | Role::User => {
356                    let mut parts = Vec::new();
357                    let text = message.content.clone().or_else(|| {
358                        message.content_parts.as_ref().and_then(|parts| {
359                            let text = parts
360                                .iter()
361                                .filter_map(|part| part.get("text").and_then(Value::as_str))
362                                .collect::<Vec<_>>()
363                                .join(" ");
364                            (!text.is_empty()).then_some(text)
365                        })
366                    });
367                    if let Some(text) = text {
368                        let text = if message.role == Role::System {
369                            format!("[System] {text}")
370                        } else {
371                            text
372                        };
373                        parts.push(serde_json::json!({"text": text}));
374                    }
375                    if let Some(content_parts) = &message.content_parts {
376                        for part in content_parts {
377                            let Some(url) = part
378                                .get("image_url")
379                                .and_then(|value| value.get("url"))
380                                .and_then(Value::as_str)
381                            else {
382                                continue;
383                            };
384                            let Some(rest) = url.strip_prefix("data:") else {
385                                continue;
386                            };
387                            let Some((media_type, data)) = rest.split_once(";base64,") else {
388                                continue;
389                            };
390                            parts.push(serde_json::json!({
391                                "inlineData": {"mimeType": media_type, "data": data}
392                            }));
393                        }
394                    }
395                    if !parts.is_empty() {
396                        let mut value = serde_json::json!({
397                            "id": format!("supercode-user-{index}"),
398                            "timestamp": timestamp,
399                            "type": "user",
400                            "content": parts,
401                        });
402                        set_gemini_message_extension(&mut value, message);
403                        push_jsonl(out, &value);
404                    }
405                }
406                Role::Assistant => {
407                    let mut tool_calls = Vec::new();
408                    for call in message.tool_calls() {
409                        call_names.insert(call.id.clone(), call.function.name.clone());
410                        let args = serde_json::from_str::<Value>(&call.function.arguments)
411                            .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
412                        tool_calls.push(serde_json::json!({
413                            "id": call.id,
414                            "name": call.function.name,
415                            "args": args,
416                        }));
417                    }
418                    let mut value = serde_json::json!({
419                        "id": format!("supercode-gemini-{index}"),
420                        "timestamp": timestamp,
421                        "type": "gemini",
422                        "content": message.content.clone().unwrap_or_default(),
423                        "model": message.metadata.get("gemini_model")
424                            .or(self.meta.model.as_ref())
425                            .cloned().unwrap_or_else(|| "unknown".to_string()),
426                    });
427                    if !tool_calls.is_empty() {
428                        value["toolCalls"] = Value::Array(tool_calls);
429                    }
430                    if let Some(thoughts) = message.metadata.get("gemini_thoughts") {
431                        value["thoughts"] = serde_json::from_str(thoughts)
432                            .unwrap_or_else(|_| Value::String(thoughts.clone()));
433                    }
434                    set_gemini_message_extension(&mut value, message);
435                    push_jsonl(out, &value);
436                }
437                Role::Tool => {
438                    let id = message.tool_call_id.clone().unwrap_or_default();
439                    let name = message
440                        .name
441                        .clone()
442                        .or_else(|| call_names.get(&id).cloned())
443                        .unwrap_or_else(|| "tool".to_string());
444                    let output = message.content.clone().unwrap_or_else(|| {
445                        message
446                            .content_parts
447                            .as_ref()
448                            .map(|parts| Value::Array(parts.clone()))
449                            .map(|value| value.to_string())
450                            .unwrap_or_default()
451                    });
452                    let mut value = serde_json::json!({
453                        "id": format!("supercode-tool-{index}"),
454                        "timestamp": timestamp,
455                        "type": "user",
456                        "content": [{
457                            "functionResponse": {
458                                "id": id,
459                                "name": name,
460                                "response": {"output": output}
461                            }
462                        }],
463                    });
464                    set_gemini_message_extension(&mut value, message);
465                    push_jsonl(out, &value);
466                }
467            }
468        }
469    }
470
471    pub(super) fn to_gemini_jsonl_spliced(&self, session_id: Option<&str>) -> String {
472        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
473        if raw_prefix_len == 0 {
474            let mut out = String::new();
475            self.write_gemini_header(&mut out, session_id.or(self.meta.session_id.as_deref()));
476            self.write_gemini_records(&mut out, &self.messages);
477            return out;
478        }
479        let mut out = String::new();
480        for (index, line) in self.raw[..raw_prefix_len].iter().enumerate() {
481            if index == 0 && session_id.is_some() {
482                if let Ok(mut value) = serde_json::from_str::<Value>(line) {
483                    if value.get("type").is_none() && value.get("sessionId").is_some() {
484                        value["sessionId"] =
485                            Value::String(session_id.unwrap_or_default().to_string());
486                        push_jsonl(&mut out, &value);
487                        continue;
488                    }
489                }
490            }
491            out.push_str(line);
492            out.push('\n');
493        }
494        self.write_gemini_records(&mut out, &self.messages[message_prefix_len..]);
495        out
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn gemini_user_parts_preserve_text_media_and_response_order() {
505        let session = Session::from_gemini_str(
506            r#"{"sessionId":"11111111-1111-4111-8111-111111111111","projectHash":"p"}
507{"type":"gemini","content":"calling","toolCalls":[{"id":"a","name":"read","args":{}},{"id":"b","name":"read","args":{}}]}
508{"type":"user","content":[{"text":"before"},{"functionResponse":{"id":"b","name":"read","response":{"output":"B"}}},{"inlineData":{"mimeType":"image/png","data":"YQ=="}},{"functionResponse":{"name":"read","response":{"output":"A"}}},{"text":"after"}]}
509"#,
510        )
511        .unwrap();
512
513        assert_eq!(session.messages.len(), 6);
514        assert_eq!(
515            session.messages[1].content_parts.as_ref().unwrap()[0]["text"],
516            "before"
517        );
518        assert_eq!(session.messages[2].tool_call_id.as_deref(), Some("b"));
519        assert!(
520            session.messages[3].content_parts.as_ref().unwrap()[0]["image_url"]["url"]
521                .as_str()
522                .unwrap()
523                .starts_with("data:image/png;base64,")
524        );
525        assert_eq!(session.messages[4].tool_call_id.as_deref(), Some("a"));
526        assert_eq!(
527            session.messages[5].content_parts.as_ref().unwrap()[0]["text"],
528            "after"
529        );
530    }
531}