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