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