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