Skip to main content

supercode_interchange/session/
grok.rs

1//! Grok session codec: loaders, writers and native-record helpers.
2
3use super::*;
4
5impl Session {
6    /// Load Grok's resumable `chat_history.jsonl` transcript.
7    ///
8    /// The surrounding session directory carries the session id, workspace,
9    /// and `summary.json`; [`Self::from_grok_str`] handles the transcript
10    /// itself while this path-aware entry point overlays that directory
11    /// metadata.
12    pub fn from_grok(path: impl AsRef<Path>) -> Result<Session> {
13        let path = path.as_ref();
14        let mut session = Self::from_grok_str(&std::fs::read_to_string(path)?)?;
15        session.capture_grok_path_metadata(path);
16        Ok(session)
17    }
18
19    /// Parse Grok's line-oriented `chat_history.jsonl` format.
20    ///
21    /// Conversational records are `user`, `assistant`, and `tool_result`.
22    /// `system` is the regenerated base prompt and is retained in
23    /// [`SessionMeta::system_prompt`]; encrypted reasoning and backend-only
24    /// state remain byte-exact in [`Session::raw`] but are intentionally not
25    /// replayed as chat turns.
26    pub fn from_grok_str(jsonl: &str) -> Result<Session> {
27        let mut meta = SessionMeta::new(SessionSource::Grok);
28        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
29        let raw: Vec<String> = raw_lines.iter().map(|line| line.to_string()).collect();
30        let mut messages = Vec::new();
31        let mut parse_error_lines = 0usize;
32        let mut tool_names: HashMap<String, String> = HashMap::new();
33
34        // The parse walk skips blank lines; `raw` keeps them. Position `i` of the walk is raw line
35        // `raw_record[i]`.
36        let raw_record: Vec<usize> = raw_lines
37            .iter()
38            .enumerate()
39            .filter(|(_, line)| !line.trim().is_empty())
40            .map(|(index, _)| index)
41            .collect();
42        for (record_index, line) in non_empty_lines(jsonl).enumerate() {
43            // What the previous record created belongs to it; this record starts after.
44            if record_index > 0 {
45                stamp_message_records(&mut messages, raw_record[record_index - 1]);
46            }
47            let value: Value = match serde_json::from_str(line) {
48                Ok(value) => value,
49                Err(_) => {
50                    parse_error_lines += 1;
51                    continue;
52                }
53            };
54            restore_codex_provenance_from_top_level(&value, &mut meta)?;
55            // PARITY-23: records the open-union arm below would drop are
56            // grok's residue inventory — captured for cross-format hops.
57            if let Some(kind) = grok_residue_kind(&value) {
58                capture_native_residue(&mut meta, "grok", record_index, line, &value, &kind);
59            }
60            match value.get("type").and_then(Value::as_str) {
61                Some("system") => {
62                    if meta.system_prompt.is_none() {
63                        meta.system_prompt = value
64                            .get("content")
65                            .and_then(Value::as_str)
66                            .map(str::to_string);
67                    }
68                }
69                Some("user") => {
70                    let content = extract_text_content(value.get("content"));
71                    let role = if value.get("synthetic_reason").and_then(Value::as_str)
72                        == Some("supercode_system_event")
73                    {
74                        Role::System
75                    } else {
76                        Role::User
77                    };
78                    let content = if role == Role::User {
79                        match grok_human_user_text(&content) {
80                            Some(content) => content,
81                            None if value.get(SUPERCODE_GROK_MESSAGE_KEY).is_some() => {
82                                String::new()
83                            }
84                            None => continue,
85                        }
86                    } else {
87                        content
88                    };
89                    let mut message = ChatMessage {
90                        role,
91                        content: Some(content),
92                        content_parts: None,
93                        tool_calls: None,
94                        tool_call_id: None,
95                        name: None,
96                        metadata: Default::default(),
97                    };
98                    capture_grok_scalar_metadata(
99                        &value,
100                        &mut message,
101                        &["prompt_index", "prior_turn_interrupt", "synthetic_reason"],
102                    );
103                    restore_grok_message_extension(&value, &mut message);
104                    messages.push(message);
105                }
106                Some("assistant") => {
107                    let calls: Vec<ToolCall> = value
108                        .get("tool_calls")
109                        .and_then(Value::as_array)
110                        .into_iter()
111                        .flatten()
112                        .filter_map(|call| {
113                            let id = call.get("id")?.as_str()?.to_string();
114                            let name = call.get("name")?.as_str()?.to_string();
115                            let arguments = call
116                                .get("arguments")
117                                .map(value_to_arg_string)
118                                .unwrap_or_else(|| "{}".to_string());
119                            tool_names.insert(id.clone(), name.clone());
120                            Some(function_call(&id, &name, arguments))
121                        })
122                        .collect();
123                    let content = value
124                        .get("content")
125                        .and_then(Value::as_str)
126                        .filter(|content| !content.is_empty())
127                        .map(str::to_string);
128                    let mut message = ChatMessage {
129                        role: Role::Assistant,
130                        content,
131                        content_parts: None,
132                        tool_calls: (!calls.is_empty()).then_some(calls),
133                        tool_call_id: None,
134                        name: None,
135                        metadata: Default::default(),
136                    };
137                    capture_grok_scalar_metadata(
138                        &value,
139                        &mut message,
140                        &["model_id", "model_fingerprint", "reasoning_effort"],
141                    );
142                    if let Some(model) = value.get("model_id").and_then(Value::as_str) {
143                        meta.model = Some(model.to_string());
144                    }
145                    restore_grok_message_extension(&value, &mut message);
146                    messages.push(message);
147                }
148                Some("tool_result") => {
149                    let id = value
150                        .get("tool_call_id")
151                        .and_then(Value::as_str)
152                        .unwrap_or_default();
153                    let content = value
154                        .get("content")
155                        .map(|value| match value {
156                            Value::String(text) => text.clone(),
157                            other => extract_text_content(Some(other)),
158                        })
159                        .unwrap_or_default();
160                    let mut message = tool_message(id, content);
161                    message.name = tool_names.get(id).cloned();
162                    // A tool result's screenshots: `images: [{type: "image", url: <data URI>}]`.
163                    let images: Vec<Value> = value
164                        .get("images")
165                        .and_then(Value::as_array)
166                        .into_iter()
167                        .flatten()
168                        .filter_map(|image| image.get("url").and_then(Value::as_str))
169                        .filter(|url| !url.is_empty())
170                        .map(|url| serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
171                        .collect();
172                    if !images.is_empty() {
173                        let mut parts = Vec::new();
174                        if let Some(text) = message.content.as_deref().filter(|t| !t.is_empty()) {
175                            parts.push(serde_json::json!({"type": "text", "text": text}));
176                        }
177                        parts.extend(images);
178                        message.content_parts = Some(parts);
179                    }
180                    restore_grok_message_extension(&value, &mut message);
181                    messages.push(message);
182                }
183                // `reasoning` contains encrypted chain-of-thought and
184                // `backend_tool_call` is execution bookkeeping. Both survive
185                // verbatim in raw without being replayed to another model.
186                _ => {}
187            }
188        }
189
190        if let Some(&last) = raw_record.last() {
191            stamp_message_records(&mut messages, last);
192        }
193        ensure_tool_results_paired(&mut messages);
194        meta.message_records = take_message_records(&mut messages);
195        let imported_message_count = Some(messages.len());
196        Ok(Session {
197            meta,
198            messages,
199            subagents: Vec::new(),
200            raw,
201            raw_trailing_newline,
202            imported_message_count,
203            raw_is_verbatim: true,
204            parse_error_lines,
205            load_residue: Vec::new(),
206        })
207    }
208
209    pub(super) fn capture_grok_path_metadata(&mut self, transcript: &Path) {
210        let Some(session_dir) = transcript.parent() else {
211            return;
212        };
213        self.meta.session_id = session_dir
214            .file_name()
215            .and_then(|name| name.to_str())
216            .map(str::to_string);
217        self.meta.cwd = session_dir
218            .parent()
219            .and_then(Path::file_name)
220            .and_then(|name| name.to_str())
221            .and_then(percent_decode_path)
222            .map(PathBuf::from);
223
224        let Ok(summary_text) = std::fs::read_to_string(session_dir.join("summary.json")) else {
225            return;
226        };
227        let Ok(summary) = serde_json::from_str::<Value>(&summary_text) else {
228            return;
229        };
230        if let Some(model) = summary.get("current_model_id").and_then(Value::as_str) {
231            self.meta.model = Some(model.to_string());
232        }
233        for (source, target) in [
234            ("generated_title", "session_name"),
235            ("created_at", "created_at"),
236            ("updated_at", "updated_at"),
237            ("chat_format_version", "grok_chat_format_version"),
238        ] {
239            if let Some(value) = summary.get(source) {
240                self.meta.lineage.insert(
241                    target.to_string(),
242                    value
243                        .as_str()
244                        .map(str::to_string)
245                        .unwrap_or_else(|| value.to_string()),
246                );
247            }
248        }
249    }
250}
251
252/// Record types grok's loader consumes into the canonical model; anything
253/// else on a grok transcript is residue (the loader's open-union arm is the
254/// authoritative inventory, per the PARITY-23 design doc).
255const GROK_CONSUMED_TYPES: [&str; 4] = ["system", "user", "assistant", "tool_result"];
256
257pub(super) fn grok_residue_kind(record: &Value) -> Option<String> {
258    record.as_object()?;
259    match record.get("type").and_then(Value::as_str) {
260        Some(kind) if !GROK_CONSUMED_TYPES.contains(&kind) => Some(kind.to_string()),
261        Some(_) => None,
262        None => Some("untyped".to_string()),
263    }
264}
265
266// ---- Grok -------------------------------------------------------------
267
268fn capture_grok_scalar_metadata(value: &Value, message: &mut ChatMessage, keys: &[&str]) {
269    for key in keys {
270        if let Some(value) = value.get(*key) {
271            message.metadata.insert(
272                format!("grok_{key}"),
273                value
274                    .as_str()
275                    .map(str::to_string)
276                    .unwrap_or_else(|| value.to_string()),
277            );
278        }
279    }
280}
281
282fn grok_human_user_text(raw: &str) -> Option<String> {
283    let text = raw.trim();
284    if text.is_empty() || text.starts_with("<user_info>") || text.starts_with("<system-reminder>") {
285        return None;
286    }
287    let unwrapped = text
288        .strip_prefix("<user_query>")
289        .and_then(|value| value.strip_suffix("</user_query>"))
290        .map(str::trim)
291        .unwrap_or(text);
292    (!unwrapped.is_empty()).then(|| unwrapped.to_string())
293}
294
295/// Portable extension for messages whose canonical fields cannot be expressed
296/// by the target's stock schema. It was introduced for Grok and retains that
297/// on-disk key for compatibility. Gemini has the same need: Claude Code and
298/// Codex have no native slot for a tool-result name or Gemini-only metadata.
299/// Their readers tolerate unknown namespaced fields, so forwarding this
300/// adapter-owned envelope keeps those cross-format hops reversible without
301/// pretending the stock schemas represent the fields directly.
302const SUPERCODE_GROK_MESSAGE_KEY: &str = "_supercode_grok_message";
303
304fn grok_message_extension(source: SessionSource, message: &ChatMessage) -> Option<Value> {
305    let metadata = message
306        .metadata
307        .iter()
308        .filter(|(key, _)| {
309            key.starts_with("gemini_") || key.starts_with("grok_") || key.starts_with("goose_")
310        })
311        .map(|(key, value)| (key.clone(), Value::String(value.clone())))
312        .collect::<serde_json::Map<_, _>>();
313
314    // `meta.source` changes after every reload. Keying portability only on
315    // the immediate source therefore made Grok metadata survive one hop but
316    // disappear on A -> B -> C translations. Once Grok-owned fields are
317    // present, keep forwarding them regardless of the current container.
318    let has_portable_fields =
319        !metadata.is_empty() || message.metadata.contains_key("codex_custom_tool_call_ids");
320    (matches!(
321        source,
322        SessionSource::Gemini | SessionSource::Grok | SessionSource::Goose
323    ) || has_portable_fields
324        || message.content_parts.is_some())
325    .then(|| {
326        serde_json::json!({
327            "schema": 2,
328            "role": message.role,
329            "content": message.content,
330            "content_parts": message.content_parts,
331            "tool_calls": message.tool_calls,
332            "tool_call_id": message.tool_call_id,
333            "name": message.name,
334            "metadata": message.metadata,
335        })
336    })
337}
338
339pub(super) fn set_grok_target_message_extension(value: &mut Value, message: &ChatMessage) {
340    value[SUPERCODE_GROK_MESSAGE_KEY] = serde_json::json!({
341        "schema": 2,
342        "role": message.role,
343        "content": message.content,
344        "content_parts": message.content_parts,
345        "tool_calls": message.tool_calls,
346        "tool_call_id": message.tool_call_id,
347        "name": message.name,
348        "metadata": message.metadata,
349    });
350}
351
352pub(super) fn set_grok_message_extension(
353    value: &mut Value,
354    source: SessionSource,
355    message: &ChatMessage,
356) {
357    if let Some(extension) = grok_message_extension(source, message) {
358        value[SUPERCODE_GROK_MESSAGE_KEY] = extension;
359    }
360}
361
362pub(super) fn restore_grok_message_extension(value: &Value, message: &mut ChatMessage) {
363    let Some(extension) = value.get(SUPERCODE_GROK_MESSAGE_KEY) else {
364        return;
365    };
366    // Codex temporarily marks a text assistant item so immediately-following
367    // function-call items can merge back into the same canonical turn. The
368    // portable envelope must not erase that loader-private marker before the
369    // merge happens; `from_codex_str` removes it before returning.
370    let codex_open_turn = message.metadata.get("__codex_open_turn").cloned();
371    let codex_turn_id = message.metadata.get("turn_id").cloned();
372    let extension_has_turn_id = extension
373        .get("metadata")
374        .and_then(Value::as_object)
375        .is_some_and(|metadata| metadata.contains_key("turn_id"));
376    if extension.get("schema").and_then(Value::as_u64) == Some(2) {
377        if let Some(role) = extension
378            .get("role")
379            .and_then(|value| serde_json::from_value(value.clone()).ok())
380        {
381            message.role = role;
382        }
383        message.content = extension
384            .get("content")
385            .and_then(Value::as_str)
386            .map(str::to_string);
387        message.content_parts = extension
388            .get("content_parts")
389            .and_then(|value| serde_json::from_value(value.clone()).ok());
390        // Tool calls are shared native structure in every supported format.
391        // Keep the loader's reconstruction instead of restoring this copy:
392        // Codex stores a combined text+tool turn across multiple records, so
393        // eagerly restoring calls on its text record would duplicate them
394        // when the following function-call records merge.
395        message.tool_call_id = extension
396            .get("tool_call_id")
397            .and_then(Value::as_str)
398            .map(str::to_string);
399        message.name = extension
400            .get("name")
401            .and_then(Value::as_str)
402            .map(str::to_string);
403        message.metadata.clear();
404    }
405    if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
406        for (key, value) in metadata {
407            if let Some(value) = value.as_str() {
408                message.metadata.insert(key.clone(), value.to_string());
409            }
410        }
411    }
412    if let Some(name) = extension.get("name").and_then(Value::as_str) {
413        message.name = Some(name.to_string());
414    }
415    if let Some(marker) = codex_open_turn {
416        message
417            .metadata
418            .insert("__codex_open_turn".to_string(), marker);
419    }
420    if let Some(turn_id) = codex_turn_id {
421        message.metadata.insert("turn_id".to_string(), turn_id);
422        if !extension_has_turn_id {
423            message.metadata.insert(
424                "__grok_remove_synthetic_turn_id".to_string(),
425                "true".to_string(),
426            );
427        }
428    }
429}
430
431pub(super) fn restore_single_grok_message(value: &Value, messages: &mut [ChatMessage]) {
432    if let [message] = messages {
433        restore_grok_message_extension(value, message);
434    }
435}
436
437impl Session {
438    // ---- Grok writers -----------------------------------------------
439
440    /// Synthesize Grok's resumable `chat_history.jsonl` transcript.
441    pub(super) fn to_grok_jsonl(&self) -> String {
442        let mut out = String::new();
443        if let Some(prompt) = self
444            .meta
445            .system_prompt
446            .as_deref()
447            .filter(|prompt| !prompt.is_empty())
448        {
449            push_jsonl(
450                &mut out,
451                &serde_json::json!({
452                    "type": "system",
453                    "content": prompt,
454                }),
455            );
456        }
457        self.write_grok_records(&mut out, &self.messages);
458        // PARITY-23: grok-source residue restored from a foreign hop is
459        // NATIVE here again — re-emit the exact source records (relative
460        // order preserved) instead of wrapping them in an envelope.
461        if self.meta.native_residue_source.as_deref() == Some("grok") {
462            let mut records: Vec<&Value> = self.meta.native_residue.iter().collect();
463            records.sort_by_key(|entry| {
464                entry
465                    .get("record_index")
466                    .and_then(Value::as_u64)
467                    .unwrap_or(u64::MAX)
468            });
469            for entry in records {
470                if let Some(raw) = entry.get("raw").and_then(Value::as_str) {
471                    out.push_str(raw);
472                    out.push('\n');
473                }
474            }
475        } else if let Some(extension) = native_residue_envelope(&self.meta) {
476            if out.is_empty() {
477                push_jsonl(
478                    &mut out,
479                    &serde_json::json!({"type": "system", "content": ""}),
480                );
481            }
482            inject_first_jsonl_top_level(
483                &mut out,
484                SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY,
485                native_residue_summary(&extension),
486            );
487            inject_first_jsonl_top_level(&mut out, SUPERCODE_NATIVE_RESIDUE_KEY, extension);
488        }
489        out
490    }
491
492    fn write_grok_records(&self, out: &mut String, messages: &[ChatMessage]) {
493        for message in messages {
494            if is_replay_excluded(message) {
495                continue;
496            }
497            let mut value = match message.role {
498                Role::System => serde_json::json!({
499                    "type": "user",
500                    "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
501                    "synthetic_reason": "supercode_system_event",
502                }),
503                Role::User => {
504                    let mut value = serde_json::json!({
505                        "type": "user",
506                        "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
507                    });
508                    if let Some(object) = value.as_object_mut() {
509                        for (metadata, field) in [
510                            ("grok_prompt_index", "prompt_index"),
511                            ("grok_prior_turn_interrupt", "prior_turn_interrupt"),
512                            ("grok_synthetic_reason", "synthetic_reason"),
513                        ] {
514                            if let Some(raw) = message.metadata.get(metadata) {
515                                object.insert(
516                                    field.to_string(),
517                                    serde_json::from_str(raw)
518                                        .unwrap_or_else(|_| Value::String(raw.clone())),
519                                );
520                            }
521                        }
522                    }
523                    value
524                }
525                Role::Assistant => {
526                    let calls = message
527                        .tool_calls()
528                        .iter()
529                        .map(|call| {
530                            serde_json::json!({
531                                "id": call.id,
532                                "name": call.function.name,
533                                "arguments": call.function.arguments,
534                            })
535                        })
536                        .collect::<Vec<_>>();
537                    let mut value = serde_json::json!({
538                        "type": "assistant",
539                        "content": message.content.clone().unwrap_or_default(),
540                        "tool_calls": calls,
541                        "model_id": message.metadata.get("grok_model_id")
542                            .or(self.meta.model.as_ref())
543                            .cloned()
544                            .unwrap_or_else(|| "unknown".to_string()),
545                    });
546                    if let Some(object) = value.as_object_mut() {
547                        for (metadata, field) in [
548                            ("grok_model_fingerprint", "model_fingerprint"),
549                            ("grok_reasoning_effort", "reasoning_effort"),
550                        ] {
551                            if let Some(raw) = message.metadata.get(metadata) {
552                                object.insert(field.to_string(), Value::String(raw.clone()));
553                            }
554                        }
555                    }
556                    value
557                }
558                Role::Tool => {
559                    let mut value = serde_json::json!({
560                        "type": "tool_result",
561                        "tool_call_id": message.tool_call_id.clone().unwrap_or_default(),
562                        "content": message.content.clone().unwrap_or_default(),
563                    });
564                    let images: Vec<Value> = message
565                        .content_parts
566                        .iter()
567                        .flatten()
568                        .filter(|part| {
569                            part.get("type").and_then(Value::as_str) == Some("image_url")
570                        })
571                        .filter_map(|part| {
572                            part.get("image_url")
573                                .and_then(|u| u.get("url"))
574                                .and_then(Value::as_str)
575                        })
576                        .map(|url| serde_json::json!({"type": "image", "url": url}))
577                        .collect();
578                    if !images.is_empty() {
579                        value["images"] = Value::Array(images);
580                    }
581                    value
582                }
583            };
584            set_grok_target_message_extension(&mut value, message);
585            push_jsonl(out, &value);
586        }
587    }
588
589    /// Replay a Grok imported prefix verbatim, then append newly-created
590    /// canonical turns. Grok stores the session id in the directory name,
591    /// not in transcript records, so there is no in-file id to rewrite.
592    pub(super) fn to_grok_jsonl_spliced(&self) -> String {
593        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
594        if raw_prefix_len == 0 {
595            return self.to_grok_jsonl();
596        }
597        let mut out = String::new();
598        for line in &self.raw[..raw_prefix_len] {
599            out.push_str(line);
600            out.push('\n');
601        }
602        self.write_grok_records(&mut out, &self.messages[message_prefix_len..]);
603        out
604    }
605}