supercode_interchange/session.rs
1//! Natively load — and continue — real Claude Code and Codex sessions.
2//!
3//! Both tools persist their conversations as JSONL on disk:
4//!
5//! - **Claude Code**: `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`,
6//! one line per event in the Anthropic message format, linked by
7//! `uuid`/`parentUuid`.
8//! - **Codex**: `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`, where each line
9//! is a `{timestamp, type, payload}` envelope and the `response_item` lines
10//! form the canonical conversation.
11//!
12//! [`Session::load`] auto-detects the format and normalizes either one into a
13//! provider-neutral [`Vec<ChatMessage>`] that can be handed straight back to a
14//! model (via OpenRouter or any OpenAI-compatible endpoint) to continue.
15//!
16//! Provider-internal artifacts that don't replay across vendors — Anthropic
17//! `thinking` blocks, Codex `reasoning` items — are dropped during
18//! normalization.
19//!
20//! # Where this sits in supercode's priorities
21//!
22//! This module is the home of **feature 1 (translate between session formats)**
23//! and half of **feature 2 (emulate-to-continue)** — the load/emit surface for
24//! each harness ([`SessionFormat`], `from_*_str` loaders, `to_*_jsonl`
25//! emitters). See [`AGENTS.md`](../../../AGENTS.md) for the three ranked
26//! feature-priorities and the glue-tool positioning; the top priority is
27//! **feature 3 (continue losslessly *with massive token reduction*)**, which
28//! this fidelity work exists to make trustworthy. `opencode` + `pi` loaders
29//! are built against the frozen `docs/interop/opencode-pi-spec.md` contract
30//! — OpenCode additionally reads its native SQLite store (`opencode*.db`,
31//! PARITY-3/PARITY-16) via `rusqlite`, reconstructing the same envelope form
32//! [`Session::from_opencode_str`] already parses for the JSON-tree surfaces.
33
34use std::collections::{BTreeMap, HashMap, HashSet};
35use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
36use std::path::{Path, PathBuf};
37
38use rusqlite::Connection;
39use serde_json::Value;
40
41use crate::{
42 ChatMessage, Fidelity, FunctionCall, InterchangeError as Error, Result, Role, ToolCall,
43};
44
45/// Which tool produced a session log.
46///
47/// This is **read-provenance**: a fact recovered when a log is loaded (stored
48/// in [`SessionMeta::source`], filled in by auto-detection in
49/// `detect_source`), describing which tool originally wrote the file on
50/// disk. It answers "where did this session come from?" — e.g. for
51/// `inspect`/`convert` display in the CLI.
52///
53/// It is deliberately distinct from [`SessionFormat`], even though the two
54/// enums' variant lists currently coincide: [`SessionFormat`] selects a
55/// serialization codec (what to parse/export *as*), while `SessionSource`
56/// records history (what wrote the file). The pair is intentionally kept
57/// separate rather than merged — a session loaded from one tool's log can
58/// still be exported in the other tool's format, and the two concepts could
59/// diverge further (e.g. a format that is readable but not attributable, or
60/// multiple versioned formats sharing one source).
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum SessionSource {
63 /// A `~/.claude/projects/.../<id>.jsonl` transcript.
64 ClaudeCode,
65 /// A `~/.codex/sessions/.../rollout-*.jsonl` file.
66 Codex,
67 /// An OpenCode session — multi-file JSON tree(s) or SQLite `opencode*.db`
68 /// (`docs/interop/opencode-pi-spec.md` §1.2). Detection and loading are
69 /// wave B; this variant exists now so `SessionSource`/`SessionFormat` stay
70 /// 1:1 per the frozen interop spec (§0).
71 OpenCode,
72 /// A `~/.pi/agent/sessions/--<enc-cwd>--/<iso>_<sessionId>.jsonl`
73 /// transcript (`docs/interop/opencode-pi-spec.md` §1.1) — line-oriented
74 /// JSONL like Claude Code/Codex, so it shares their byte-lossless native
75 /// round-trip property.
76 Pi,
77 /// A Grok session transcript stored as
78 /// `~/.grok/sessions/<percent-encoded-cwd>/<session-id>/chat_history.jsonl`.
79 Grok,
80 /// A Gemini CLI transcript stored under
81 /// `~/.gemini/tmp/<project>/chats/session-*.jsonl`.
82 Gemini,
83 /// A Goose session exported through `_goose/unstable/session/export`, or
84 /// reconstructed from Goose's `sessions/sessions.db` native store.
85 Goose,
86 /// P5-3 safety-hardening fix (Fable-5 review, LOW "translation-fidelity
87 /// cosmetic"): a session that was never imported from ANY foreign
88 /// tool's log at all — authored directly by supercode's own agent loop,
89 /// with no foreign-tool prefix (`Session.raw` starts empty). Currently
90 /// only `crate::agent::Agent`'s `persist_subagent_transcript` (P5-3,
91 /// natively-spawned `spawn_subagent` children) uses this — before this
92 /// variant existed, that call site built its blank `Session` via
93 /// `Session::from_claude_code_str("")` purely as an "empty parser to
94 /// get a blank skeleton" trick, which left `meta.source ==
95 /// SessionSource::ClaudeCode` even though nothing Claude-Code-shaped
96 /// was ever involved, mislabeling a native supercode spawn as an
97 /// imported CC session on disk (and in any `inspect`/`convert` reading
98 /// it back). Never produced by auto-detection (`detect_source`) or any
99 /// `from_<tool>_str` loader — only by code that explicitly constructs
100 /// a `SessionMeta` with this source, so no existing imported-session
101 /// path can ever observe this variant appearing where it didn't before.
102 Native,
103}
104
105/// An on-disk session format supercode can both read and write.
106///
107/// Like an image editor that opens and exports several file formats, supercode
108/// keeps one canonical in-memory model ([`Session`]) and converts to/from each
109/// supported format on the edges.
110///
111/// This is a **write-target** / codec selector: a caller's request, passed to
112/// [`Session::load_str`], [`Session::to_jsonl`], and [`Session::save`],
113/// choosing which on-disk dialect to parse or emit. It answers "what format
114/// should I read/write?" — as opposed to [`SessionSource`], which records the
115/// provenance fact of what actually produced a loaded file. The two enums are
116/// intentionally kept separate (provenance fact vs. serialization choice) and
117/// should not be unified, even though their variants currently match
118/// one-to-one.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum SessionFormat {
121 /// Claude Code transcript JSONL.
122 ClaudeCode,
123 /// Codex rollout JSONL.
124 Codex,
125 /// OpenCode export-document / envelope JSONL (wave B; see
126 /// [`SessionSource::OpenCode`]).
127 OpenCode,
128 /// Pi session JSONL (see [`SessionSource::Pi`]).
129 Pi,
130 /// Grok `chat_history.jsonl` transcript.
131 Grok,
132 /// Gemini CLI session JSONL.
133 Gemini,
134 /// Goose native session-export JSON.
135 Goose,
136}
137
138impl SessionFormat {
139 /// The [`SessionSource`] a file of this format reports.
140 ///
141 /// This is the deliberate one-way bridge between the two concepts: a file
142 /// saved in this format will, when reloaded, report this provenance (see
143 /// `crates/harness/tests/session_saving.rs`), making the relationship
144 /// discoverable from the method itself.
145 pub fn source(self) -> SessionSource {
146 match self {
147 SessionFormat::ClaudeCode => SessionSource::ClaudeCode,
148 SessionFormat::Codex => SessionSource::Codex,
149 SessionFormat::OpenCode => SessionSource::OpenCode,
150 SessionFormat::Pi => SessionSource::Pi,
151 SessionFormat::Grok => SessionSource::Grok,
152 SessionFormat::Gemini => SessionSource::Gemini,
153 SessionFormat::Goose => SessionSource::Goose,
154 }
155 }
156}
157
158/// Metadata recovered from a session log.
159#[derive(Debug, Clone)]
160#[non_exhaustive]
161pub struct SessionMeta {
162 /// The tool that wrote the log.
163 pub source: SessionSource,
164 /// The session/rollout id.
165 pub session_id: Option<String>,
166 /// The model the session was running.
167 pub model: Option<String>,
168 /// The working directory the session ran in.
169 pub cwd: Option<PathBuf>,
170 /// The system / base-instructions prompt, when the log records it.
171 pub system_prompt: Option<String>,
172 /// Verbatim source-format header records (the Codex `session_meta` /
173 /// `turn_context` lines), preserved so re-export can replay the exact header
174 /// the original tool expects rather than guessing its required fields.
175 pub codex_headers: Vec<Value>,
176 /// Exact source lines for Codex execution/provenance records that affect
177 /// continuation semantics but must not be replayed as active events after
178 /// a foreign-format hop. Each entry records its original physical-line
179 /// index, discriminant, and verbatim JSONL text. Foreign writers carry the
180 /// list in a namespaced extension; a later Codex export restores headers
181 /// from it while keeping compaction/rollback/review records non-operative,
182 /// avoiding a second rollback or compaction of the already-normalized view.
183 pub codex_provenance: Vec<Value>,
184 /// The OpenCode analogue of [`Self::codex_headers`]
185 /// (`docs/interop/opencode-pi-spec.md` §1.2/§2.1): the verbatim
186 /// `SessionInfo` record (always element 0, or `Value::Null` if somehow
187 /// absent), plus any captured `session_diff`/`todo` side-records — each
188 /// wrapped as `{"key": [...], "value": ...}`, mirroring the envelope
189 /// shape `raw` uses, so a consumer can tell which storage key a header
190 /// record belongs to. These replay only via the direct-write fallback
191 /// (`Session::to_opencode_direct_write`); `opencode import` has no
192 /// ingestion path for `session_diff`/`todo` (S5).
193 pub opencode_headers: Vec<Value>,
194 /// Goose's native session-export object with `conversation` removed.
195 /// Goose stores sessions in SQLite but defines this JSON object as its
196 /// official import/export boundary. Keeping the shell lets an unchanged
197 /// direct round-trip remain byte exact while appended turns are spliced
198 /// into a stock-importable artifact without guessing native metadata.
199 pub goose_header: Option<Value>,
200 /// For a Claude Code subagent session: its `agentId` (the `agent-<id>` file
201 /// stem). `None` for top-level sessions.
202 pub agent_id: Option<String>,
203 /// For a subagent session: the `tool_use_id` of the parent `Task` call that
204 /// spawned it, recovered from the parent transcript's tool result. Best
205 /// effort — `None` if the link could not be established.
206 pub parent_tool_use_id: Option<String>,
207 /// Cross-file lineage keys for multi-file/multi-agent sessions (Codex
208 /// `parent_thread_id`, `forked_from_id`, `thread_source`, and the
209 /// `source.subagent.thread_spawn` fields `agent_role` / `agent_nickname` /
210 /// `depth`). Empty for a plain top-level session. Used by
211 /// [`Session::reconstruct_tree`] to nest children under their parents.
212 pub lineage: std::collections::BTreeMap<String, String>,
213}
214
215impl SessionMeta {
216 fn new(source: SessionSource) -> Self {
217 SessionMeta {
218 source,
219 session_id: None,
220 model: None,
221 cwd: None,
222 system_prompt: None,
223 codex_headers: Vec::new(),
224 codex_provenance: Vec::new(),
225 opencode_headers: Vec::new(),
226 goose_header: None,
227 agent_id: None,
228 parent_tool_use_id: None,
229 lineage: std::collections::BTreeMap::new(),
230 }
231 }
232}
233
234/// A normalized, replayable conversation loaded from a tool's session log.
235#[derive(Debug, Clone)]
236pub struct Session {
237 /// Recovered metadata.
238 pub meta: SessionMeta,
239 /// The conversation, normalized to the OpenAI chat-completions shape.
240 pub messages: Vec<ChatMessage>,
241 /// Subagent (Task) sub-conversations. Claude Code stores these as separate
242 /// `<session>/subagents/agent-*.jsonl` files; loading a session by path now
243 /// discovers and attaches them here (each is a full [`Session`] whose
244 /// `meta.agent_id` / `meta.parent_tool_use_id` link it back to its spawn).
245 pub subagents: Vec<Session>,
246 /// Every original JSONL line of the source log, STRICT-VERBATIM (IX-1):
247 /// captured via `split_lines_verbatim`, not the blank-skipping/trimming
248 /// `non_empty_lines` parse view, so a blank line, a CRLF (`\r\n`)
249 /// terminator, or trailing whitespace on a line all survive bit-for-bit
250 /// rather than being dropped/normalized away. Normalization into
251 /// `messages` is still lossy by design (it targets the OpenAI replay
252 /// shape), but these raw lines retain *everything* — including records
253 /// with no canonical representation (e.g. Claude `file-history-snapshot`)
254 /// — so a round-trip through the supercode-native format
255 /// ([`Session::to_native_jsonl`]) is byte-lossless for the line-oriented
256 /// formats (Claude Code/Codex/Pi), for ANY input (see
257 /// [`Self::raw_trailing_newline`] for the one piece of information a line
258 /// list alone can't carry).
259 pub raw: Vec<String>,
260 /// Whether the source text `raw` was captured from ended with a trailing
261 /// `\n`. `raw`'s line list alone can't distinguish a source ending with a
262 /// trailing newline from one that doesn't (both split into the same
263 /// lines) — this flag carries that fact out-of-band so
264 /// [`Self::to_native_jsonl`]/[`Self::from_native_str`] can reproduce the
265 /// original source bytes exactly, including the presence/absence of a
266 /// final newline. `true` for a `Session` whose `raw` isn't captured
267 /// verbatim from real source text (e.g. OpenCode's re-synthesized
268 /// export-document `raw`, or a `Session` assembled programmatically) —
269 /// matching the historical always-terminated-by-newline behavior for
270 /// those cases.
271 pub raw_trailing_newline: bool,
272 /// How many of `messages` (and, symmetrically, of `raw` — see below) came
273 /// from parsing the imported log, as opposed to being appended after
274 /// import. Set once, at the end of [`Self::from_claude_code_str`] /
275 /// [`Self::from_codex_str`], to `messages.len()` at that moment — i.e.
276 /// before [`Self::from_native_str`]'s subsequent loop reattaches any
277 /// appended [`crate::sidecar::NativeTurn`] records onto `messages`/`raw`.
278 /// That loop pushes exactly one `raw` line and one message per appended
279 /// turn, so the two lists grow in lockstep from here on: the raw-prefix
280 /// boundary A12's [`Self::to_jsonl_spliced`] needs is always recoverable
281 /// as `raw.len() - (messages.len() - imported_message_count)`, without a
282 /// second counter. `None` only when a `Session` is constructed some other
283 /// way than through those two loaders — splicing then has no boundary to
284 /// honor and treats every message as imported (equivalent to
285 /// `Some(messages.len())`).
286 /// A bounded display-history projection uses this field for the total
287 /// number of normalized messages observed before its in-memory window was
288 /// applied. Such a semantic view is never a continuation source, and all
289 /// splice callers clamp the value to `messages.len()`.
290 pub imported_message_count: Option<usize>,
291 /// Whether `raw` was captured strict-verbatim from real source text
292 /// (`true`) or re-synthesized by this crate (`false`) — the fact
293 /// [`Self::raw_verbatim`]'s callers need to know before claiming a
294 /// same-format `convert` is byte-identical (PARITY-AUDIT.md P006/P007).
295 /// `true` for every line-oriented loader (`from_claude_code_str`,
296 /// `from_codex_str`, `from_pi_str`) and OpenCode's own ENVELOPE read
297 /// surface (`from_opencode_str`'s per-line loop) — each of those splits
298 /// `raw` directly out of the source text via `split_lines_verbatim`, so
299 /// replaying it reproduces the original bytes exactly. `false` for
300 /// OpenCode's EXPORT-DOCUMENT read surface
301 /// (`Session::from_opencode_export_doc`): a pretty-printed
302 /// `{info, messages:[...]}` document has no per-line envelope structure
303 /// of its own, so `raw` there is one envelope line RE-SYNTHESIZED per
304 /// record — faithful in value, but not the original document's bytes.
305 /// A `Session` assembled programmatically (not through a `from_*_str`
306 /// loader) also defaults to `false` — no real source text was captured
307 /// at all.
308 pub raw_is_verbatim: bool,
309 /// PARITY-15: how many non-empty lines of the source text FAILED to
310 /// deserialize at all (a genuinely malformed/truncated JSON line — not
311 /// a well-formed-but-unmodeled record type, which is a normal,
312 /// intentional "skip", tracked separately by `crate::audit`). Every
313 /// line-oriented loader tolerates a stray corrupt line rather than
314 /// hard-failing the whole load (a single bad line must not make an
315 /// otherwise-healthy multi-thousand-line session unloadable) — but that
316 /// tolerance used to be completely invisible: `Session::load` returned
317 /// `Ok` either way, with no signal that anything was skipped. This
318 /// count is what lets a caller (the CLI, `inspect`/`convert`) surface
319 /// that loss loudly instead of silently. `0` for a cleanly-parsed file,
320 /// and for a `Session` assembled programmatically.
321 pub parse_error_lines: usize,
322 /// Named degradations a [`Fidelity::Semantic`] load accepted instead of
323 /// failing — the same "say exactly what was given up" residue list
324 /// `harness.v1.sessions.export` already reports for artifacts.
325 ///
326 /// ALWAYS empty for a lossless load: every stricter fidelity refuses a
327 /// transcript it cannot reconstruct exactly, which is what keeps
328 /// continuation/transfer/export guarantees intact. A non-empty list means
329 /// this session is a read-only VIEW ([`Session::load_with_fidelity`] with
330 /// [`Fidelity::Semantic`]) and must not be used as a continuation source.
331 pub load_residue: Vec<String>,
332}
333
334impl Session {
335 /// The fidelity this reconstruction actually achieved.
336 ///
337 /// Same rule the export path applies to an artifact: named residue means
338 /// [`Fidelity::Semantic`]; otherwise a verbatim source capture is
339 /// [`Fidelity::ByteLossless`] and a re-synthesized one is
340 /// [`Fidelity::ValueLossless`]. A subagent's residue counts as this
341 /// session's: the whole reconstruction is only as faithful as its least
342 /// faithful part, and each child still reports its own residue where it
343 /// was measured.
344 pub fn load_fidelity(&self) -> Fidelity {
345 let own = if !self.load_residue.is_empty() {
346 Fidelity::Semantic
347 } else if self.raw_is_verbatim {
348 Fidelity::ByteLossless
349 } else {
350 Fidelity::ValueLossless
351 };
352 if own != Fidelity::Semantic
353 && self
354 .subagents
355 .iter()
356 .any(|subagent| subagent.load_fidelity() == Fidelity::Semantic)
357 {
358 return Fidelity::Semantic;
359 }
360 own
361 }
362
363 /// Assemble a session from supercode's own flat store transcript (one
364 /// [`ChatMessage`] per JSONL line). These files are the native working
365 /// format written by Supercode's native session store, not a foreign
366 /// harness log, so routing them through format auto-detection would
367 /// misclassify them as an empty Claude Code session.
368 pub fn from_native_messages(messages: Vec<ChatMessage>) -> Session {
369 Session {
370 meta: SessionMeta::new(SessionSource::Native),
371 messages,
372 subagents: Vec::new(),
373 raw: Vec::new(),
374 raw_trailing_newline: true,
375 imported_message_count: None,
376 raw_is_verbatim: false,
377 parse_error_lines: 0,
378 load_residue: Vec::new(),
379 }
380 }
381
382 /// Load a session, auto-detecting whether it's a Claude Code or Codex log
383 /// — or, when `path` looks like a SQLite database, a real OpenCode
384 /// `opencode*.db` store (PARITY-3/PARITY-16): that check runs BEFORE any
385 /// UTF-8 text read, so a binary `.db` file is routed to
386 /// [`Self::from_opencode_sqlite`] instead of failing on a raw "stream
387 /// did not contain valid UTF-8" error (the confirmed footgun these items
388 /// close — see [`looks_like_sqlite`] and the UTF-8 diagnostic reader).
389 ///
390 /// A DIRECTORY is also accepted directly: `path` is probed with
391 /// [`detect_opencode_storage_surface`] BEFORE the SQLite/UTF-8 file
392 /// checks below (both of which assume a file and would otherwise surface
393 /// a cryptic "Is a directory" `io::Error` — the confirmed footgun this
394 /// closes). This lets `inspect`/`convert`/`resume` accept an OpenCode
395 /// DATA-ROOT directly (e.g. `~/.local/share/opencode`), matching what
396 /// `audit --format opencode` already does. A resolved `Sqlite` surface
397 /// loads exactly like pointing `load` at that `opencode*.db` file
398 /// directly (most-recently-updated top-level session). The legacy
399 /// `JsonTreeA`/`JsonTreeB` surfaces are classifier-only (see
400 /// [`OpenCodeStorageSurface`]) — there's no direct-JSON-tree loader, so
401 /// that case returns a clear error naming the `.db` file / `audit` as the
402 /// way in, rather than silently doing nothing or crashing.
403 pub fn load(path: impl AsRef<Path>) -> Result<Session> {
404 Self::load_with_fidelity(path, Fidelity::ByteLossless)
405 }
406
407 /// Load a session at a declared [`Fidelity`].
408 ///
409 /// [`Fidelity::Semantic`] is the READ-ONLY VIEW mode: a transcript whose
410 /// record graph cannot be reconstructed exactly (the everyday case for a
411 /// Claude Code session that has been compacted or resumed across files,
412 /// where a live record's `parentUuid` names a record that was pruned)
413 /// still loads, stitched best-effort in transcript order, and names what
414 /// it gave up in [`Session::load_residue`]. Every stricter level keeps
415 /// the historical behavior — refuse loudly — because a continuation,
416 /// transfer or export built on a guessed graph is exactly the loss
417 /// supercode exists to prevent. Callers that go on to RESUME a session
418 /// must therefore use [`Session::load`].
419 pub fn load_with_fidelity(path: impl AsRef<Path>, fidelity: Fidelity) -> Result<Session> {
420 Self::load_with_fidelity_and_subagents(path, fidelity, true)
421 }
422
423 /// Load only the selected session's own transcript at a declared fidelity.
424 ///
425 /// This is the read-only frontend path: Claude Code can place hundreds of
426 /// child transcripts beside a parent, but a chat viewport displaying the
427 /// parent must not eagerly parse and transport that entire child tree.
428 /// Translation, continuation, export, and the ordinary [`Self::load`]
429 /// path keep attaching every subagent unchanged.
430 #[doc(hidden)]
431 pub fn load_parent_with_fidelity(
432 path: impl AsRef<Path>,
433 fidelity: Fidelity,
434 ) -> Result<Session> {
435 Self::load_with_fidelity_and_subagents(path, fidelity, false)
436 }
437
438 /// Load a bounded, parent-only transcript for human display.
439 ///
440 /// Unlike the continuation loader, Codex compaction records do not erase
441 /// earlier visible assistant turns here: the native rollout still holds
442 /// those records, and a scrollback view should show what the human saw,
443 /// not only the compacted context the next model call will receive.
444 #[doc(hidden)]
445 pub fn load_display_view(
446 path: impl AsRef<Path>,
447 fidelity: Fidelity,
448 message_limit: usize,
449 ) -> Result<Session> {
450 let path = path.as_ref();
451 if path.is_dir() || looks_like_sqlite(path) {
452 let mut session = Self::load_parent_with_fidelity(path, fidelity)?;
453 truncate_session_messages(&mut session, message_limit);
454 return Ok(session);
455 }
456 let mut read_limit = message_limit.max(1);
457 let mut previous_window_len = 0usize;
458 let (mut session, omitted_prefix) = loop {
459 let (source, text, omitted_prefix) = read_display_jsonl(path, read_limit)?;
460 let mut candidate = match source {
461 Some(SessionSource::Codex) => Self::from_codex_display_str(&text, message_limit)?,
462 Some(SessionSource::Gemini) => {
463 let mut session = Self::from_gemini_str(&text)?;
464 session.raw_is_verbatim = false;
465 session.load_residue.push(
466 "display history is a bounded native-record projection, not a complete Gemini artifact"
467 .to_string(),
468 );
469 session
470 }
471 Some(SessionSource::Pi) => Self::from_pi_str(&text)?,
472 Some(SessionSource::Grok) => {
473 let mut session = Self::from_grok_str(&text)?;
474 session.capture_grok_path_metadata(path);
475 session
476 }
477 Some(SessionSource::OpenCode) => Self::from_opencode_str(&text)?,
478 _ => Self::from_claude_code_str_with_fidelity(&text, fidelity)?,
479 };
480 let observed_messages = candidate
481 .imported_message_count
482 .unwrap_or(candidate.messages.len())
483 .max(candidate.messages.len());
484 let human_turns = candidate
485 .messages
486 .iter()
487 .filter(|message| message.role == Role::User)
488 .count();
489 let window_len = text.len();
490 let sufficient =
491 !omitted_prefix || (observed_messages > message_limit.max(1) && human_turns >= 2);
492 // 16 KiB/message with a 64 MiB ceiling means 4096 is the first
493 // read limit that cannot grow the native byte window further.
494 // Smaller repeated lengths can be the intentional 4 MiB floor;
495 // keep doubling through that plateau instead of declaring a
496 // false pagination end.
497 let byte_window_exhausted = window_len <= previous_window_len && read_limit >= 4096;
498 if sufficient || byte_window_exhausted {
499 if omitted_prefix {
500 // The prefix is known to contain more native history even
501 // when this bounded window cannot cheaply normalize its
502 // exact size. Never turn that into a false end-of-history.
503 candidate.imported_message_count =
504 Some(observed_messages.max(message_limit.max(1).saturating_add(1)));
505 }
506 break (candidate, omitted_prefix);
507 }
508 previous_window_len = window_len;
509 read_limit = read_limit.saturating_mul(2);
510 };
511 if omitted_prefix {
512 session.load_residue.push(
513 "older native records remain outside this bounded display window".to_string(),
514 );
515 }
516 truncate_session_messages(&mut session, message_limit);
517 Ok(session)
518 }
519
520 fn load_with_fidelity_and_subagents(
521 path: impl AsRef<Path>,
522 fidelity: Fidelity,
523 include_subagents: bool,
524 ) -> Result<Session> {
525 let path = path.as_ref();
526 if path.is_dir() {
527 return match detect_opencode_storage_surface(path) {
528 Some((OpenCodeStorageSurface::Sqlite, db_path)) => {
529 Self::from_opencode_sqlite(&db_path, None)
530 }
531 Some((
532 OpenCodeStorageSurface::JsonTreeA | OpenCodeStorageSurface::JsonTreeB,
533 _,
534 )) => Err(crate::Error::Other(format!(
535 "{} is an OpenCode data root using a legacy JSON storage tree, which \
536 supercode does not load directly — point `inspect`/`convert`/`resume` \
537 at the store's `opencode*.db` SQLite file if this install has one, or \
538 use `audit --format opencode {}` instead",
539 path.display(),
540 path.display()
541 ))),
542 None => Err(crate::Error::Other(format!(
543 "{} is a directory, but no session file or OpenCode store was found in it \
544 (expected an `opencode*.db` SQLite file, or an OpenCode legacy JSON storage \
545 tree)",
546 path.display()
547 ))),
548 };
549 }
550 if looks_like_sqlite(path) {
551 return Self::from_opencode_sqlite(path, None);
552 }
553 let text = read_utf8_or_diagnose(path)?;
554 match detect_source(&text) {
555 Some(SessionSource::Codex) => Self::from_codex_str(&text),
556 Some(SessionSource::Pi) => Self::from_pi_str(&text),
557 Some(SessionSource::Grok) => {
558 let mut session = Self::from_grok_str(&text)?;
559 session.capture_grok_path_metadata(path);
560 Ok(session)
561 }
562 Some(SessionSource::Gemini) => Self::from_gemini_str(&text),
563 Some(SessionSource::Goose) => Self::from_goose_str(&text),
564 // IX-3: a detected OpenCode session must route to its own
565 // loader, not the Claude Code fallback below
566 // (`docs/interop/build-followups.md`).
567 Some(SessionSource::OpenCode) => Self::from_opencode_str(&text),
568 _ => {
569 let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
570 if include_subagents {
571 session.attach_claude_subagents(path, &text, fidelity)?;
572 }
573 Ok(session)
574 }
575 }
576 }
577
578 /// Load a Claude Code transcript from a file, attaching any subagent
579 /// (`Task`) sub-conversations stored alongside it.
580 pub fn from_claude_code(path: impl AsRef<Path>) -> Result<Session> {
581 Self::from_claude_code_with_fidelity(path, Fidelity::ByteLossless)
582 }
583
584 /// [`Session::from_claude_code`] at a declared [`Fidelity`] — see
585 /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
586 pub fn from_claude_code_with_fidelity(
587 path: impl AsRef<Path>,
588 fidelity: Fidelity,
589 ) -> Result<Session> {
590 let text = std::fs::read_to_string(path.as_ref())?;
591 let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
592 session.attach_claude_subagents(path.as_ref(), &text, fidelity)?;
593 Ok(session)
594 }
595
596 /// Discover and load `<session>/subagents/agent-*.jsonl` files for a
597 /// Claude Code transcript at `main_path`, linking each back to the parent
598 /// `Task` tool call via the agent id embedded in the parent's tool result.
599 fn attach_claude_subagents(
600 &mut self,
601 main_path: &Path,
602 main_text: &str,
603 fidelity: Fidelity,
604 ) -> Result<()> {
605 let Some(dir) = subagents_dir_for(main_path) else {
606 return Ok(());
607 };
608 let entries = std::fs::read_dir(&dir).map_err(|error| {
609 crate::Error::Other(format!(
610 "failed to enumerate Claude subagents at {}: {error}",
611 dir.display()
612 ))
613 })?;
614 let mut files = Vec::new();
615 for entry in entries {
616 let entry = entry.map_err(|error| {
617 crate::Error::Other(format!(
618 "failed to enumerate Claude subagents at {}: {error}",
619 dir.display()
620 ))
621 })?;
622 let path = entry.path();
623 if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl") {
624 files.push(path);
625 }
626 }
627 files.sort();
628
629 // Phase 1 — collect each subagent + its recovered agent id, without
630 // touching the main transcript yet.
631 let mut collected: Vec<(Session, Option<String>)> = Vec::with_capacity(files.len());
632 for file in files {
633 let text = read_utf8_or_diagnose(&file).map_err(|error| {
634 crate::Error::Other(format!(
635 "failed to read Claude subagent {}: {error}",
636 file.display()
637 ))
638 })?;
639 let sub = match Self::from_claude_code_str_with_fidelity(&text, fidelity) {
640 Ok(sub) => sub,
641 // A read-only VIEW keeps the main conversation rather than
642 // losing the whole session to one unreconstructable child;
643 // the skip is named, not silent. Every stricter fidelity
644 // still propagates the child's failure.
645 Err(error) if fidelity.tolerates_residue() => {
646 self.load_residue.push(format!(
647 "Claude subagent {} could not be reconstructed ({error}); it was omitted from this view",
648 file.display()
649 ));
650 continue;
651 }
652 Err(error) => {
653 return Err(crate::Error::Other(format!(
654 "failed to reconstruct Claude subagent {}: {error}",
655 file.display()
656 )))
657 }
658 };
659 // agentId: prefer the file's own record, fall back to the filename stem.
660 let agent_id = first_agent_id(&text).or_else(|| {
661 file.file_stem()
662 .and_then(|s| s.to_str())
663 .map(|s| s.trim_start_matches("agent-").to_string())
664 });
665 collected.push((sub, agent_id));
666 }
667
668 // Phase 2 — single pass over the main transcript to index every
669 // requested agent id at once, then assign each subagent's parent by
670 // an O(1) lookup.
671 let agent_ids: Vec<String> = collected.iter().filter_map(|(_, id)| id.clone()).collect();
672 let index = parent_tool_use_index(main_text, &agent_ids);
673
674 for (mut sub, agent_id) in collected {
675 sub.meta.parent_tool_use_id = agent_id.as_ref().and_then(|id| index.get(id).cloned());
676 sub.meta.agent_id = agent_id;
677 self.subagents.push(sub);
678 }
679 Ok(())
680 }
681
682 /// Load a Codex rollout from a file.
683 pub fn from_codex(path: impl AsRef<Path>) -> Result<Session> {
684 Self::from_codex_str(&std::fs::read_to_string(path.as_ref())?)
685 }
686
687 /// Parse a Claude Code transcript from an in-memory JSONL string.
688 pub fn from_claude_code_str(jsonl: &str) -> Result<Session> {
689 Self::from_claude_code_str_with_fidelity(jsonl, Fidelity::ByteLossless)
690 }
691
692 /// [`Session::from_claude_code_str`] at a declared [`Fidelity`] — see
693 /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
694 pub fn from_claude_code_str_with_fidelity(jsonl: &str, fidelity: Fidelity) -> Result<Session> {
695 let mut meta = SessionMeta::new(SessionSource::ClaudeCode);
696 let mut messages = Vec::new();
697 // IX-1: `raw` is captured STRICT-VERBATIM (blank lines, CRLF, trailing
698 // whitespace all preserved) — separate from the blank-skipping
699 // `non_empty_lines` walk just below, which still parses records only
700 // (a blank line is not a JSON record and must not become one).
701 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
702 let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
703 // PARITY-15: a malformed/truncated line is still tolerated (a
704 // single bad line must not make an otherwise-healthy multi-
705 // thousand-line session unloadable) — but it's no longer INVISIBLE.
706 let mut parse_error_lines = 0usize;
707 let mut index = ClaudeReplayIndex::default();
708
709 // Claude transcripts are append-only trees, not linear chat logs.
710 // Build a lightweight graph index first so normalization sees the
711 // same single active, post-compaction branch Claude Code would
712 // resume. `raw` above deliberately remains the complete source.
713 for (line_index, line) in raw_lines.iter().enumerate() {
714 if line.trim().is_empty() {
715 continue;
716 }
717 let v: Value = match serde_json::from_str(line) {
718 Ok(v) => v,
719 Err(_) => {
720 parse_error_lines += 1; // tolerate stray/corrupt lines
721 continue;
722 }
723 };
724 capture_claude_meta(&v, &mut meta, line)?;
725 index.observe(line_index, &v)?;
726 }
727
728 let ClaudeReplaySelection {
729 lines: replay_lines,
730 residue: load_residue,
731 } = index.select_lines(fidelity)?;
732 let mut pending_assistant: Option<Value> = None;
733
734 for line_index in replay_lines {
735 let line = raw_lines[line_index];
736 let v: Value = serde_json::from_str(line).map_err(crate::Error::Decode)?;
737
738 if v.get("type").and_then(Value::as_str) == Some("assistant") {
739 if v.get("isApiErrorMessage").and_then(Value::as_bool) == Some(true) {
740 flush_claude_assistant(&mut pending_assistant, &mut messages);
741 continue;
742 }
743 if let Some(pending) = pending_assistant.as_mut() {
744 if claude_assistant_message_id(pending).is_some_and(|message_id| {
745 claude_assistant_message_id(&v) == Some(message_id)
746 }) {
747 merge_claude_assistant_chunk(pending, &v);
748 continue;
749 }
750 flush_claude_assistant(&mut pending_assistant, &mut messages);
751 }
752 pending_assistant = Some(v);
753 continue;
754 }
755
756 flush_claude_assistant(&mut pending_assistant, &mut messages);
757
758 // WAVE-2 item 1: every Claude Code record carries a real
759 // top-level `timestamp` (ISO-8601) — provenance stamping below
760 // attaches it to every canonical `ChatMessage` this line
761 // produces, together with the record UUID and assistant model.
762 // `entry(...).or_insert_with` preserves any more-precise value a
763 // role-specific loader already supplied.
764 let before = messages.len();
765 match v.get("type").and_then(Value::as_str) {
766 Some("user") => push_claude_user(&v, &mut messages),
767 Some("assistant") => push_claude_assistant(&v, &mut messages),
768 Some("attachment") => push_claude_attachment(&v, &mut messages),
769 Some("system") => push_claude_system(&v, &mut messages),
770 _ => {} // mode, queue-operation, ... — skip
771 }
772 // UUID/model provenance remains meaningful even for legacy
773 // records that predate Claude Code's timestamp field.
774 capture_claude_record_provenance(&v, &mut messages[before..]);
775 restore_single_grok_message(&v, &mut messages[before..]);
776 }
777 flush_claude_assistant(&mut pending_assistant, &mut messages);
778
779 reorder_tool_results_after_calls(&mut messages);
780 ensure_tool_results_paired(&mut messages);
781 let imported_message_count = Some(messages.len());
782 Ok(Session {
783 meta,
784 messages,
785 subagents: Vec::new(),
786 raw,
787 raw_trailing_newline,
788 imported_message_count,
789 // Claude Code is line-oriented: `raw` is split directly out of
790 // the source text (strict-verbatim, IX-1).
791 raw_is_verbatim: true,
792 parse_error_lines,
793 load_residue,
794 })
795 }
796
797 /// Reconstruct multi-file subagent trees from a flat set of loaded sessions.
798 ///
799 /// Codex stores subagents as separate rollout files linked to their parent
800 /// by `lineage["parent_thread_id"]` (→ the parent's `session_id`). Given a
801 /// collection of sessions, this nests each child into its parent's
802 /// [`Session::subagents`] and returns only the roots. Children whose parent
803 /// isn't in the set are returned as roots themselves (best effort).
804 pub fn reconstruct_tree(sessions: Vec<Session>) -> Vec<Session> {
805 use std::collections::HashMap;
806 // Index each session's position by its session_id.
807 let mut idx: HashMap<String, usize> = HashMap::new();
808 for (i, s) in sessions.iter().enumerate() {
809 if let Some(id) = &s.meta.session_id {
810 idx.insert(id.clone(), i);
811 }
812 }
813 // Determine each session's parent (by index), if present in the set.
814 let parent_of: Vec<Option<usize>> = sessions
815 .iter()
816 .map(|s| {
817 s.meta
818 .lineage
819 .get("parent_thread_id")
820 .and_then(|p| idx.get(p).copied())
821 })
822 .collect();
823
824 // Move children into parents, deepest-first so chains nest correctly.
825 let mut slots: Vec<Option<Session>> = sessions.into_iter().map(Some).collect();
826 let mut order: Vec<usize> = (0..slots.len()).collect();
827 order.sort_by_key(|&i| std::cmp::Reverse(depth_of(i, &parent_of)));
828 for i in order {
829 if let Some(p) = parent_of[i] {
830 if p != i {
831 if let Some(child) = slots[i].take() {
832 if let Some(parent) = slots[p].as_mut() {
833 parent.subagents.push(child);
834 } else {
835 slots[i] = Some(child); // parent already moved; keep as root
836 }
837 }
838 }
839 }
840 }
841 slots.into_iter().flatten().collect()
842 }
843
844 /// Parse a session of a known format from an in-memory JSONL string.
845 pub fn load_str(jsonl: &str, format: SessionFormat) -> Result<Session> {
846 match format {
847 SessionFormat::ClaudeCode => Self::from_claude_code_str(jsonl),
848 SessionFormat::Codex => Self::from_codex_str(jsonl),
849 SessionFormat::Pi => Self::from_pi_str(jsonl),
850 SessionFormat::OpenCode => Self::from_opencode_str(jsonl),
851 SessionFormat::Grok => Self::from_grok_str(jsonl),
852 SessionFormat::Gemini => Self::from_gemini_str(jsonl),
853 SessionFormat::Goose => Self::from_goose_str(jsonl),
854 }
855 }
856
857 /// Serialize this session to JSONL in the given format.
858 ///
859 /// The conversation is synthesized from the canonical messages, so this
860 /// works for sessions loaded from *either* tool as well as ones supercode
861 /// built itself. Converting between formats (e.g. Codex → Claude Code) is an
862 /// "export": format-specific framing that has no slot in the target may be
863 /// dropped, but the user/assistant/tool conversation is preserved.
864 pub fn to_jsonl(&self, format: SessionFormat) -> Result<String> {
865 match format {
866 SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl()),
867 SessionFormat::Codex => Ok(self.to_codex_jsonl()),
868 SessionFormat::Pi => Ok(self.to_pi_jsonl()),
869 SessionFormat::OpenCode => self.to_opencode_jsonl(),
870 SessionFormat::Grok => Ok(self.to_grok_jsonl()),
871 SessionFormat::Gemini => Ok(self.to_gemini_jsonl()),
872 SessionFormat::Goose => Ok(self.to_goose_json()),
873 }
874 }
875
876 /// Export back to `format`, replaying the imported `raw` prefix
877 /// **verbatim** — original uuids/ids, real timestamps, and
878 /// loader-skipped records (e.g. Claude Code `file-history-snapshot`) that
879 /// [`Self::to_jsonl`]'s full synthesis discards or fakes — when `format`
880 /// is the session's own origin (`format.source() == self.meta.source`,
881 /// see [`SessionFormat::source`]) and there is a `raw` prefix to replay.
882 /// Only messages appended *after* import (tracked by
883 /// [`Self::imported_message_count`]) are synthesized, chained onto the
884 /// last original record found in the raw prefix.
885 ///
886 /// `session_id` of `Some(new)` rewrites the session id on every emitted
887 /// line, raw and synthesized alike (`sessionId` for Claude Code,
888 /// `session_meta.payload.id` for Codex); `None` leaves ids as recorded.
889 ///
890 /// Cross-format export (no verbatim prefix exists in the target dialect,
891 /// by definition) and a session with no `raw` lines both fall back
892 /// unchanged to [`Self::to_jsonl`] — full synthesis, same output as
893 /// today. A12 (SPEC.md §6): this turns "export back to origin" from
894 /// *semantic* to *near-byte* fidelity for the dominant hop-back case;
895 /// cross-format stays at the documented semantic tier.
896 pub fn to_jsonl_spliced(
897 &self,
898 format: SessionFormat,
899 session_id: Option<&str>,
900 ) -> Result<String> {
901 if self.parse_error_lines > 0
902 || self
903 .subagents
904 .iter()
905 .any(|subagent| subagent.parse_error_lines > 0)
906 {
907 return Err(Error::InvalidSession(
908 "refusing spliced export because the loaded session contains parse loss"
909 .to_string(),
910 ));
911 }
912 if self.raw.is_empty() || format.source() != self.meta.source {
913 if let Some(session_id) = session_id {
914 let mut rewritten = self.clone();
915 rewritten.meta.session_id = Some(session_id.to_string());
916 return rewritten.to_jsonl(format);
917 }
918 return self.to_jsonl(format);
919 }
920 match format {
921 SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl_spliced(session_id)),
922 SessionFormat::Codex => Ok(self.to_codex_jsonl_spliced(session_id)),
923 SessionFormat::Pi => self.to_pi_jsonl_spliced(session_id),
924 SessionFormat::OpenCode => self.to_opencode_jsonl_spliced(session_id),
925 SessionFormat::Grok => Ok(self.to_grok_jsonl_spliced()),
926 SessionFormat::Gemini => Ok(self.to_gemini_jsonl_spliced(session_id)),
927 SessionFormat::Goose => Ok(self.to_goose_json_spliced(session_id)),
928 }
929 }
930
931 /// Write this session to `path` in the given format.
932 pub fn save(&self, path: impl AsRef<Path>, format: SessionFormat) -> Result<()> {
933 std::fs::write(path.as_ref(), self.to_jsonl(format)?)?;
934 Ok(())
935 }
936
937 /// Reconstruct the exact source bytes this `Session` was loaded from,
938 /// out of [`Self::raw`] + [`Self::raw_trailing_newline`] (the exact
939 /// inverse of the strict-verbatim capture those two fields record — see
940 /// `join_lines_verbatim`).
941 ///
942 /// For a genuinely line-oriented source (Claude Code, Codex, Pi, and an
943 /// OpenCode *envelope*-form JSONL), `raw` is captured verbatim from the
944 /// original text, so this reproduces the original file byte-for-byte —
945 /// the P008/P009 diagonal-convert fix (`convert <file> --to
946 /// <same-format>` is byte-identical to `<file>`) is built on exactly
947 /// this. The one documented exception is an OpenCode **export-document**
948 /// source (a single pretty-printed JSON value, not JSONL): `raw` there
949 /// is RE-SYNTHESIZED as one envelope line per record (see
950 /// `from_opencode_export_doc`'s contract), so this returns a
951 /// verbatim reproduction of THAT captured representation rather than the
952 /// original pretty-printed document — a known, narrow residue, not a
953 /// silent loss (the same records are all still present).
954 pub fn raw_verbatim(&self) -> String {
955 join_lines_verbatim(&self.raw, self.raw_trailing_newline)
956 }
957
958 /// Serialize to the **supercode-native** lossless format: a header line
959 /// recording the original source, followed by every original JSONL line
960 /// verbatim. Unlike [`Self::to_jsonl`] (which targets a foreign tool's
961 /// schema and is necessarily lossy), this preserves *everything* — including
962 /// records with no canonical representation — so [`Self::from_native_str`]
963 /// reconstructs the session with full fidelity.
964 pub fn to_native_jsonl(&self) -> String {
965 let source = match self.meta.source {
966 SessionSource::ClaudeCode => "claude_code",
967 SessionSource::Codex => "codex",
968 SessionSource::Pi => "pi",
969 SessionSource::OpenCode => "opencode",
970 SessionSource::Grok => "grok",
971 SessionSource::Gemini => "gemini",
972 SessionSource::Goose => "goose",
973 // P5-3 safety-hardening fix: a natively-spawned session must
974 // never be written to disk labeled as an imported CC session.
975 SessionSource::Native => "native",
976 };
977 let header = serde_json::json!({
978 "supercode_native": 1,
979 "source": source,
980 // IX-1: carries whether the ORIGINAL imported source text ended
981 // with a trailing newline — `from_native_str` needs this to
982 // reconstruct the exact source bytes (not just the `raw` line
983 // list) when re-parsing the body with the per-source loader.
984 "raw_trailing_newline": self.raw_trailing_newline,
985 })
986 .to_string();
987 let mut out =
988 String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
989 out.push_str(&header);
990 out.push('\n');
991 for line in &self.raw {
992 out.push_str(line);
993 out.push('\n');
994 }
995 out
996 }
997
998 /// Serialize to the **supercode-native v2** format: the same imported-body
999 /// mechanism as [`Self::to_native_jsonl`] (a versioned header line
1000 /// followed by every `Session.raw` line verbatim), plus one
1001 /// [`crate::sidecar::NativeTurn`] record per message in `appended` — turns
1002 /// produced after import, which have no backing `raw` line of their own.
1003 /// `NativeTurn` carries `metadata` in full (unlike `ChatMessage`'s wire
1004 /// serde), so nothing the live agent loop records is lost to disk.
1005 ///
1006 /// `appended` is caller-supplied rather than inferred from
1007 /// `self.messages`: A1 doesn't track which of `self.messages` came from
1008 /// import vs. the live loop — that bookkeeping belongs to the live writer
1009 /// built on top of this (A2/A3).
1010 pub fn to_native_jsonl_v2(&self, appended: &[ChatMessage]) -> String {
1011 self.to_native_jsonl_v2_with_timestamp(appended, None)
1012 }
1013
1014 pub(crate) fn to_native_jsonl_v2_with_timestamp(
1015 &self,
1016 appended: &[ChatMessage],
1017 fixed_timestamp: Option<&str>,
1018 ) -> String {
1019 let source = match self.meta.source {
1020 SessionSource::ClaudeCode => "claude_code",
1021 SessionSource::Codex => "codex",
1022 SessionSource::Pi => "pi",
1023 SessionSource::OpenCode => "opencode",
1024 SessionSource::Grok => "grok",
1025 SessionSource::Gemini => "gemini",
1026 SessionSource::Goose => "goose",
1027 // P5-3 safety-hardening fix: a natively-spawned session must
1028 // never be written to disk labeled as an imported CC session.
1029 SessionSource::Native => "native",
1030 };
1031 // P5-3 (§2 module 9, §5.2 P5 row 3 "native write side — store
1032 // already parses CC sidechains + CX lineage on import"): a
1033 // natively-spawned subagent's own `Session` carries its lineage on
1034 // `SessionMeta` (`agent_id`/`parent_tool_use_id`/`lineage`) — before
1035 // this, `to_native_jsonl_v2` never wrote any of the three to disk at
1036 // all, so a native-spawned child's lineage was lost the instant it
1037 // round-tripped through a sidecar. Emitted only when non-empty/`Some`
1038 // (`skip_serializing_if`-equivalent via manual omission below) so a
1039 // plain top-level session's header is byte-identical to before this
1040 // change.
1041 let mut header_obj = serde_json::json!({
1042 "supercode_native": 2,
1043 "source": source,
1044 "session_id": self.meta.session_id,
1045 "created": fixed_timestamp
1046 .map(ToOwned::to_owned)
1047 .unwrap_or_else(crate::sidecar::now_rfc3339),
1048 // IX-1: see `to_native_jsonl`'s header field of the same name.
1049 "raw_trailing_newline": self.raw_trailing_newline,
1050 });
1051 if let Some(obj) = header_obj.as_object_mut() {
1052 if let Some(agent_id) = &self.meta.agent_id {
1053 obj.insert("agent_id".to_string(), Value::String(agent_id.clone()));
1054 }
1055 if let Some(parent_tool_use_id) = &self.meta.parent_tool_use_id {
1056 obj.insert(
1057 "parent_tool_use_id".to_string(),
1058 Value::String(parent_tool_use_id.clone()),
1059 );
1060 }
1061 if !self.meta.lineage.is_empty() {
1062 obj.insert(
1063 "lineage".to_string(),
1064 serde_json::to_value(&self.meta.lineage).unwrap_or(Value::Null),
1065 );
1066 }
1067 }
1068 let header = header_obj.to_string();
1069 let mut out =
1070 String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
1071 out.push_str(&header);
1072 out.push('\n');
1073 for line in &self.raw {
1074 out.push_str(line);
1075 out.push('\n');
1076 }
1077 for (turn_index, msg) in appended.iter().enumerate() {
1078 let turn = match fixed_timestamp {
1079 Some(timestamp) => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
1080 msg,
1081 timestamp.to_string(),
1082 turn_index as u64,
1083 ),
1084 None => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
1085 msg,
1086 crate::sidecar::now_rfc3339(),
1087 turn_index as u64,
1088 ),
1089 };
1090 out.push_str(&serde_json::to_string(&turn).unwrap_or_default());
1091 out.push('\n');
1092 }
1093 out
1094 }
1095
1096 /// Parse the supercode-native format produced by [`Self::to_native_jsonl`]
1097 /// / [`Self::to_native_jsonl_v2`]. A v1 file (no appended turns) parses
1098 /// exactly as before. A v2 file's appended `NativeTurn` records —
1099 /// discriminated by the `supercode_turn` key, which never appears in a v1
1100 /// body — are split out before the imported body is handed to the
1101 /// per-source loader, then reattached in file order: to `messages` (via
1102 /// [`crate::sidecar::NativeTurn::into_message`]) and to `raw` (verbatim),
1103 /// so a v2 file round-trips byte-for-byte through
1104 /// [`Self::to_native_jsonl_v2`] again.
1105 pub fn from_native_str(jsonl: &str) -> Result<Session> {
1106 // IX-1: the native WRAPPER's own lines are split verbatim (not via
1107 // the blank-skipping `non_empty_lines`) so that any `raw` line it
1108 // carries — which can itself be blank, CRLF-terminated, or
1109 // whitespace-padded, now that raw-capture is strict-verbatim —
1110 // survives being embedded in (and re-extracted from) this wrapper
1111 // bit-for-bit. The wrapper we ourselves emit never has a blank line
1112 // of its own (`to_native_jsonl(_v2)` always writes one well-formed
1113 // record per line), so this is a behavior-preserving switch for any
1114 // native text this crate produced; it also makes a hand-fed/legacy
1115 // native string tolerated exactly as `non_empty_lines` used to.
1116 let (all_lines, _wrapper_trailing_newline) = split_lines_verbatim(jsonl);
1117 let mut lines = all_lines.into_iter();
1118 let header = lines.next().unwrap_or("");
1119 let hv: Value = serde_json::from_str(header).unwrap_or(Value::Null);
1120 let source = hv.get("source").and_then(Value::as_str);
1121 // IX-1: whether the ORIGINAL imported source (before it was wrapped
1122 // in this native format) ended with a trailing newline — a property
1123 // of the pre-wrap source, not of this wrapper (which always
1124 // LF-terminates every line it writes, regardless). Missing on a
1125 // native file written before IX-1 (or a hand-built header in an
1126 // older test/sidecar) — default `true`, the historical
1127 // always-newline-terminated assumption.
1128 let raw_trailing_newline = hv
1129 .get("raw_trailing_newline")
1130 .and_then(Value::as_bool)
1131 .unwrap_or(true);
1132
1133 // Split appended NativeTurn records (v2) out of the imported body. A
1134 // v1 body never carries a `supercode_turn` key, so this is a no-op
1135 // there — one code path serves both versions.
1136 let mut body_lines: Vec<String> = Vec::new();
1137 let mut turn_lines: Vec<&str> = Vec::new();
1138 for line in lines {
1139 let is_turn = serde_json::from_str::<Value>(line)
1140 .ok()
1141 .is_some_and(|v| v.get("supercode_turn").is_some());
1142 if is_turn {
1143 turn_lines.push(line);
1144 } else {
1145 body_lines.push(line.to_string());
1146 }
1147 }
1148 // IX-1: reconstruct the ORIGINAL imported source bytes exactly —
1149 // `body_lines.join("\n")` alone would silently gain a trailing
1150 // newline the original source never had (or lose one it did have).
1151 let body = join_lines_verbatim(&body_lines, raw_trailing_newline);
1152
1153 // The remaining lines are the original log; re-parse with the right loader.
1154 let mut session = match source {
1155 Some("codex") => Self::from_codex_str(&body)?,
1156 Some("claude_code") => Self::from_claude_code_str(&body)?,
1157 Some("pi") => Self::from_pi_str(&body)?,
1158 Some("opencode") => Self::from_opencode_str(&body)?,
1159 Some("grok") => Self::from_grok_str(&body)?,
1160 Some("gemini") => Self::from_gemini_str(&body)?,
1161 Some("goose") => Self::from_goose_str(&body)?,
1162 // P5-3 safety-hardening fix: a natively-spawned session's body
1163 // is always empty (it never had any foreign-tool prefix to
1164 // begin with — see `SessionSource::Native`'s doc comment), so
1165 // any loader would parse it identically; `from_claude_code_str`
1166 // is reused purely as a blank-skeleton builder (empty
1167 // `raw`/`messages`), then its `meta.source` is corrected to
1168 // `Native` — never left mislabeled as `ClaudeCode`.
1169 Some("native") => {
1170 let mut s = Self::from_claude_code_str(&body)?;
1171 s.meta.source = SessionSource::Native;
1172 s
1173 }
1174 // No/unknown header — auto-detect the body.
1175 _ => match detect_source(&body) {
1176 Some(SessionSource::Codex) => Self::from_codex_str(&body)?,
1177 Some(SessionSource::Pi) => Self::from_pi_str(&body)?,
1178 Some(SessionSource::OpenCode) => Self::from_opencode_str(&body)?,
1179 Some(SessionSource::Grok) => Self::from_grok_str(&body)?,
1180 Some(SessionSource::Gemini) => Self::from_gemini_str(&body)?,
1181 Some(SessionSource::Goose) => Self::from_goose_str(&body)?,
1182 _ => Self::from_claude_code_str(&body)?,
1183 },
1184 };
1185
1186 for line in turn_lines {
1187 match serde_json::from_str::<crate::sidecar::NativeTurn>(line) {
1188 Ok(turn) => {
1189 session.raw.push(line.to_string());
1190 session.messages.push(turn.into_message());
1191 }
1192 Err(_) => {
1193 // A valid JSON object carrying the native-turn
1194 // discriminator belongs to this wrapper, not to the
1195 // imported body. If its required fields are malformed,
1196 // count it as parse loss so every fail-loud caller can
1197 // refuse continuation instead of silently dropping a
1198 // native history record. Keep the rejected source line
1199 // in `raw` as well: diagnostics must count it in their
1200 // denominator, and even corrupt input must not disappear
1201 // merely because it reached the parser.
1202 session.raw.push(line.to_string());
1203 session.parse_error_lines += 1;
1204 }
1205 }
1206 }
1207
1208 // P5-3 (native write side, see `Self::to_native_jsonl_v2`'s matching
1209 // header block): recover a natively-spawned subagent's own lineage
1210 // from the v2 header, when present. Overlays (rather than merges
1211 // into) whatever the per-source body loader may have already set on
1212 // `session.meta` — these three keys are ONLY ever written by
1213 // `to_native_jsonl_v2` itself (never by a CC/CX/OC/Pi body), so a
1214 // header that carries them is authoritative for a file this crate
1215 // produced.
1216 if let Some(agent_id) = hv.get("agent_id").and_then(Value::as_str) {
1217 session.meta.agent_id = Some(agent_id.to_string());
1218 }
1219 if let Some(parent_tool_use_id) = hv.get("parent_tool_use_id").and_then(Value::as_str) {
1220 session.meta.parent_tool_use_id = Some(parent_tool_use_id.to_string());
1221 }
1222 if let Some(lineage) = hv.get("lineage").and_then(Value::as_object) {
1223 for (k, v) in lineage {
1224 if let Some(s) = v.as_str() {
1225 session.meta.lineage.insert(k.clone(), s.to_string());
1226 }
1227 }
1228 }
1229
1230 Ok(session)
1231 }
1232
1233 /// The full-fidelity [`Session`] a sidecar denotes.
1234 ///
1235 /// The sidecar (native-v2 format, D1) is the imported body plus every
1236 /// appended [`crate::sidecar::NativeTurn`]. Unlike the deliberately
1237 /// tolerant lower-level native parser, this persisted-store entry point
1238 /// validates its framing header before loading anything: a missing,
1239 /// malformed, or unsupported header must never become a zero-message
1240 /// session that callers could continue as if it were complete.
1241 pub fn from_sidecar_str(s: &str) -> Result<Session> {
1242 let header = s.lines().next().ok_or_else(|| {
1243 Error::InvalidSession("sidecar header is missing from an empty artifact".to_string())
1244 })?;
1245 let value: Value = serde_json::from_str(header).map_err(|error| {
1246 Error::InvalidSession(format!("sidecar header is not valid JSON: {error}"))
1247 })?;
1248 let version = value.get("supercode_native").and_then(Value::as_u64);
1249 if !matches!(version, Some(1 | 2)) {
1250 return Err(Error::InvalidSession(
1251 "sidecar header must declare supported `supercode_native` version 1 or 2"
1252 .to_string(),
1253 ));
1254 }
1255 let source = value.get("source").and_then(Value::as_str);
1256 if !matches!(
1257 source,
1258 Some(
1259 "native"
1260 | "claude_code"
1261 | "codex"
1262 | "gemini"
1263 | "goose"
1264 | "opencode"
1265 | "pi"
1266 | "grok"
1267 )
1268 ) {
1269 return Err(Error::InvalidSession(
1270 "sidecar header must declare a supported `source`".to_string(),
1271 ));
1272 }
1273 Self::from_native_str(s)
1274 }
1275
1276 /// Parse a Codex rollout from an in-memory JSONL string.
1277 pub fn from_codex_str(jsonl: &str) -> Result<Session> {
1278 let mut meta = SessionMeta::new(SessionSource::Codex);
1279 let mut messages = Vec::new();
1280
1281 // First pass: collect the text of every assistant message that exists as
1282 // a canonical `response_item`. In normal sessions the streamed
1283 // `event_msg/agent_message` events duplicate these and are safely
1284 // skipped; in collab/multi-agent sessions the assistant narration lives
1285 // ONLY as `agent_message` events, so we recover the ones with no
1286 // response_item counterpart (deduping by exact text).
1287 let assistant_texts = collect_codex_assistant_texts(jsonl);
1288 // IX-1: strict-verbatim raw capture — see `from_claude_code_str`.
1289 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1290 let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1291 let mut pending_reasoning = String::new();
1292 let mut pending_reasoning_content = String::new();
1293 let mut pending_reasoning_encrypted = false;
1294 // PARITY-15: see `from_claude_code_str`'s identical counter.
1295 let mut parse_error_lines = 0usize;
1296 let mut restored_embedded_codex_provenance = false;
1297
1298 for (record_index, raw_line) in raw_lines.iter().enumerate() {
1299 let line = raw_line.trim();
1300 if line.is_empty() {
1301 continue;
1302 }
1303 let v: Value = match serde_json::from_str(line) {
1304 Ok(v) => v,
1305 Err(_) => {
1306 parse_error_lines += 1;
1307 continue;
1308 }
1309 };
1310 let payload = v.get("payload").unwrap_or(&Value::Null);
1311 if !restored_embedded_codex_provenance
1312 && v.get("type").and_then(Value::as_str) == Some("session_meta")
1313 && payload
1314 .get(SUPERCODE_CODEX_PROVENANCE_KEY)
1315 .map(|extension| restore_codex_provenance(extension, &mut meta))
1316 .transpose()?
1317 .unwrap_or(false)
1318 {
1319 restored_embedded_codex_provenance = true;
1320 }
1321 if !restored_embedded_codex_provenance {
1322 capture_codex_provenance_record(&mut meta, record_index, raw_line, &v);
1323 }
1324 // WAVE-2 item 1: every Codex record carries a real top-level
1325 // `timestamp` (ISO-8601) — threaded onto each `ChatMessage` a
1326 // line produces via `stamp_new_codex_messages` below, at each
1327 // arm that pushes messages.
1328 let line_ts = v.get("timestamp").and_then(Value::as_str);
1329
1330 match v.get("type").and_then(Value::as_str) {
1331 Some("session_meta") => {
1332 capture_codex_session_meta(payload, &mut meta);
1333 if !restored_embedded_codex_provenance {
1334 meta.codex_headers.push(v.clone());
1335 }
1336 }
1337 Some("turn_context") => {
1338 if meta.model.is_none() {
1339 meta.model = payload
1340 .get("model")
1341 .and_then(Value::as_str)
1342 .map(str::to_string);
1343 }
1344 if !restored_embedded_codex_provenance {
1345 meta.codex_headers.push(v.clone());
1346 }
1347 }
1348 Some("response_item")
1349 if payload.get("type").and_then(Value::as_str) == Some("reasoning") =>
1350 {
1351 // Retain reasoning (P3): summary text if any, the raw
1352 // `content` chain-of-thought text if any (N2 — this used
1353 // to be dropped despite `Coverage::Retained` claiming the
1354 // whole item survived; see `crate::audit`'s doc comment),
1355 // plus a flag for the opaque encrypted_content a
1356 // same-model continuation can replay. Stashed onto the
1357 // next assistant message below.
1358 let summary = extract_text_content(payload.get("summary"));
1359 if !summary.trim().is_empty() {
1360 push_str_field(&mut pending_reasoning, &summary);
1361 }
1362 // N2: `content` is `null` on the vast majority of real
1363 // turns (raw reasoning text is only ever populated for
1364 // certain reasoning-transcript configurations) — guard
1365 // on non-null BEFORE calling `extract_text_content`,
1366 // since `Some(&Value::Null)` would otherwise fall into
1367 // its `Some(other) => other.to_string()` arm and
1368 // stringify to the literal text `"null"`.
1369 if let Some(raw_content) = payload.get("content").filter(|v| !v.is_null()) {
1370 let text = extract_text_content(Some(raw_content));
1371 if !text.trim().is_empty() {
1372 push_str_field(&mut pending_reasoning_content, &text);
1373 }
1374 }
1375 // N1: `serde_json` returns `Some(&Value::Null)` for a
1376 // present-but-null `encrypted_content` key — which is
1377 // what EVERY real rollout's reasoning item carries
1378 // (upstream always serializes the field, never
1379 // `skip_serializing_if`, `codex-rs/protocol/src/
1380 // models.rs:970-983`). The old `.is_some()` check
1381 // false-flagged every single reasoning item as
1382 // "encrypted" on real data; only a genuinely non-null
1383 // value means the model actually returned an opaque
1384 // blob that a same-model continuation could replay.
1385 if payload
1386 .get("encrypted_content")
1387 .is_some_and(|v| !v.is_null())
1388 {
1389 pending_reasoning_encrypted = true;
1390 }
1391 }
1392 Some("response_item") => {
1393 let before = messages.len();
1394 push_codex_item(payload, &mut messages);
1395 // Attach any pending reasoning to a newly produced assistant turn.
1396 if messages.len() > before
1397 && (!pending_reasoning.is_empty()
1398 || !pending_reasoning_content.is_empty()
1399 || pending_reasoning_encrypted)
1400 {
1401 let is_assistant = messages
1402 .last()
1403 .map(|m| m.role == Role::Assistant)
1404 .unwrap_or(false);
1405 if is_assistant {
1406 let last = messages.last_mut().expect("checked above");
1407 if !pending_reasoning.is_empty() {
1408 last.metadata.insert(
1409 "reasoning".to_string(),
1410 std::mem::take(&mut pending_reasoning),
1411 );
1412 }
1413 if !pending_reasoning_content.is_empty() {
1414 last.metadata.insert(
1415 "reasoning_content".to_string(),
1416 std::mem::take(&mut pending_reasoning_content),
1417 );
1418 }
1419 if pending_reasoning_encrypted {
1420 last.metadata
1421 .insert("reasoning_encrypted".to_string(), "true".to_string());
1422 pending_reasoning_encrypted = false;
1423 }
1424 } else {
1425 // N3: the item that just landed is NOT the
1426 // assistant turn the pending reasoning was for
1427 // (e.g. an aborted turn's reasoning directly
1428 // followed by a user message) — the old code
1429 // unconditionally cleared the pending state
1430 // here, silently discarding it. Flush it as its
1431 // own message instead, inserted just before the
1432 // interrupting item so replay order stays
1433 // chronological, keeping `Coverage::Retained`
1434 // honest for this shape too.
1435 let orphan = orphaned_reasoning_message(
1436 &mut pending_reasoning,
1437 &mut pending_reasoning_content,
1438 &mut pending_reasoning_encrypted,
1439 );
1440 messages.insert(before, orphan);
1441 }
1442 }
1443 stamp_new_codex_messages(&mut messages, before, line_ts);
1444 restore_single_grok_message(payload, &mut messages[before..]);
1445 }
1446 // A compaction record replaces all prior turns with its
1447 // summarized `replacement_history` — exactly how Codex itself
1448 // resumes a compacted session.
1449 Some("compacted") => {
1450 messages.clear();
1451 if let Some(Value::Array(history)) = payload.get("replacement_history") {
1452 for item in history {
1453 push_codex_item(item, &mut messages);
1454 }
1455 }
1456 // `replacement_history` items carry no per-item
1457 // timestamp of their own (observed corpora) — the
1458 // `compacted` record's own timestamp (when it happened)
1459 // is the best-effort real source for every message it
1460 // synthesizes, so it stamps the whole rebuilt vec (index
1461 // 0, since `clear()` reset it above).
1462 stamp_new_codex_messages(&mut messages, 0, line_ts);
1463 // IX-6 fix: replaying `replacement_history` through
1464 // `push_codex_item` can leave the LAST replayed message
1465 // marked `__codex_open_turn` (if it's an assistant
1466 // `message`, per the combined-turn merge below). That
1467 // marker must not survive past the compaction boundary —
1468 // a live `function_call` arriving after this record is a
1469 // NEW turn, not a continuation of the compaction
1470 // summary's synthetic turn, so it must not merge into it.
1471 if let Some(last) = messages.last_mut() {
1472 last.metadata.remove("__codex_open_turn");
1473 }
1474 }
1475 Some("event_msg")
1476 if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1477 {
1478 let before = messages.len();
1479 let text = agent_message_text(payload);
1480 if !text.trim().is_empty() && !assistant_texts.contains(text.trim()) {
1481 push_assistant(&mut messages, text, Vec::new());
1482 if let Some(last) = messages.last_mut() {
1483 if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1484 last.metadata.insert("phase".to_string(), phase.to_string());
1485 }
1486 }
1487 }
1488 stamp_new_codex_messages(&mut messages, before, line_ts);
1489 }
1490 // The user rolled back (undid) the last N turns — replay must
1491 // drop them so the reloaded conversation matches what the user
1492 // actually kept.
1493 Some("event_msg")
1494 if payload.get("type").and_then(Value::as_str)
1495 == Some("thread_rolled_back") =>
1496 {
1497 let n = payload
1498 .get("num_turns")
1499 .and_then(Value::as_u64)
1500 .unwrap_or(1);
1501 for _ in 0..n {
1502 remove_last_turn(&mut messages);
1503 }
1504 }
1505 // The natural-language goal assigned to this thread (sometimes
1506 // the only place the objective text is recorded).
1507 Some("event_msg")
1508 if payload.get("type").and_then(Value::as_str)
1509 == Some("thread_goal_updated") =>
1510 {
1511 let before = messages.len();
1512 let goal = payload.get("goal");
1513 if let Some(obj) = goal
1514 .and_then(|g| g.get("objective"))
1515 .and_then(Value::as_str)
1516 {
1517 if !obj.trim().is_empty() {
1518 messages.push(ChatMessage::system(format!("[thread goal] {obj}")));
1519 // D4: `goal.objective` alone used to be the ONLY
1520 // captured field, but the audit labeled this
1521 // `Retained` as if the whole record survived.
1522 // `goal.status`/`goal.tokenBudget` (real
1523 // `ThreadGoal` wire fields, camelCase) are
1524 // captured too so that label is honest — see
1525 // `crate::audit::event_msg_coverage`'s doc
1526 // comment.
1527 if let Some(last) = messages.last_mut() {
1528 if let Some(status) =
1529 goal.and_then(|g| g.get("status")).and_then(Value::as_str)
1530 {
1531 last.metadata
1532 .insert("goal_status".to_string(), status.to_string());
1533 }
1534 if let Some(budget) = goal
1535 .and_then(|g| g.get("tokenBudget"))
1536 .and_then(Value::as_i64)
1537 {
1538 last.metadata.insert(
1539 "goal_token_budget".to_string(),
1540 budget.to_string(),
1541 );
1542 }
1543 }
1544 }
1545 }
1546 stamp_new_codex_messages(&mut messages, before, line_ts);
1547 }
1548 // Code-review output — unique assistant-generated content with no
1549 // `message` counterpart.
1550 Some("event_msg")
1551 if payload.get("type").and_then(Value::as_str)
1552 == Some("exited_review_mode") =>
1553 {
1554 let before = messages.len();
1555 if let Some(review) = payload.get("review_output") {
1556 let text = review
1557 .get("overall_explanation")
1558 .and_then(Value::as_str)
1559 .map(str::to_string)
1560 .unwrap_or_else(|| review.to_string());
1561 push_assistant(&mut messages, format!("[code review] {text}"), Vec::new());
1562 // D4: `overall_explanation` alone used to be the ONLY
1563 // captured field, but the audit labeled this
1564 // `Retained` as if `review_output.findings` survived
1565 // too. Capture `findings` verbatim (as JSON, onto
1566 // metadata) so that label is honest — this is the
1567 // only place review-mode findings (title/body/
1568 // confidence_score/priority/code_location) live.
1569 if let Some(findings) = review.get("findings") {
1570 if findings.as_array().is_some_and(|a| !a.is_empty()) {
1571 if let Some(last) = messages.last_mut() {
1572 if let Ok(s) = serde_json::to_string(findings) {
1573 last.metadata.insert("review_findings".to_string(), s);
1574 }
1575 }
1576 }
1577 }
1578 // N4: `overall_correctness`/`overall_confidence_score`
1579 // are the review's actual verdict — distinct from the
1580 // findings list and the explanation prose already
1581 // captured above — and were neither captured nor
1582 // disclosed as residue while the audit doc stayed
1583 // silent about them. Capture both onto the same
1584 // message's metadata, same pattern as `findings`.
1585 if let Some(last) = messages.last_mut() {
1586 if let Some(correctness) =
1587 review.get("overall_correctness").and_then(Value::as_str)
1588 {
1589 last.metadata.insert(
1590 "review_overall_correctness".to_string(),
1591 correctness.to_string(),
1592 );
1593 }
1594 if let Some(score) = review
1595 .get("overall_confidence_score")
1596 .and_then(Value::as_f64)
1597 {
1598 last.metadata.insert(
1599 "review_overall_confidence_score".to_string(),
1600 score.to_string(),
1601 );
1602 }
1603 }
1604 }
1605 stamp_new_codex_messages(&mut messages, before, line_ts);
1606 }
1607 _ => {} // other event_msg, token_count, ... — UI events, skip
1608 }
1609 }
1610
1611 // N3/PARITY-11: reasoning still pending at EOF is an aborted-turn
1612 // shape a real rollout can leave behind (the process was
1613 // interrupted mid-turn, after the model reasoned but before it
1614 // replied — end of file, or a rollback/compaction boundary that
1615 // clears the pending state some other way) — the old code silently
1616 // dropped it here (nothing ever consumed the pending buffers once
1617 // the loop ended). Flush it as its own trailing message instead, so
1618 // `Coverage::Retained` holds for this shape too. Superset of the
1619 // independently-discovered PARITY-11 fix: also folds in
1620 // `pending_reasoning_content` (the raw chain-of-thought, distinct
1621 // from `summary`/`reasoning`) via the shared `orphaned_reasoning_
1622 // message` helper, which the interrupted-by-a-user-message shape
1623 // (`orphaned_reasoning_is_flushed_not_discarded`, case A) also
1624 // relies on — a trailing-EOF-only flush here would miss that case.
1625 if !pending_reasoning.is_empty()
1626 || !pending_reasoning_content.is_empty()
1627 || pending_reasoning_encrypted
1628 {
1629 let orphan = orphaned_reasoning_message(
1630 &mut pending_reasoning,
1631 &mut pending_reasoning_content,
1632 &mut pending_reasoning_encrypted,
1633 );
1634 messages.push(orphan);
1635 }
1636
1637 ensure_tool_results_paired(&mut messages);
1638 // IX-6: `__codex_open_turn` is an internal bookkeeping marker for the
1639 // combined-turn merge above — strip it so it never leaks out as
1640 // visible `ChatMessage` metadata.
1641 for m in &mut messages {
1642 m.metadata.remove("__codex_open_turn");
1643 if m.metadata
1644 .remove("__grok_remove_synthetic_turn_id")
1645 .is_some()
1646 {
1647 m.metadata.remove("turn_id");
1648 }
1649 }
1650 let imported_message_count = Some(messages.len());
1651 Ok(Session {
1652 meta,
1653 messages,
1654 subagents: Vec::new(),
1655 raw,
1656 raw_trailing_newline,
1657 imported_message_count,
1658 // Codex is line-oriented: `raw` is split directly out of the
1659 // source text (strict-verbatim, IX-1).
1660 raw_is_verbatim: true,
1661 parse_error_lines,
1662 load_residue: Vec::new(),
1663 })
1664 }
1665
1666 /// Parse a Codex rollout as bounded human-visible history rather than as
1667 /// resumable model context. This deliberately ignores outer `compacted`
1668 /// replacement semantics: the original `response_item` records remain in
1669 /// the rollout and are the authoritative UI history.
1670 fn from_codex_display_str(jsonl: &str, message_limit: usize) -> Result<Session> {
1671 let mut meta = SessionMeta::new(SessionSource::Codex);
1672 let mut messages: Vec<ChatMessage> = Vec::new();
1673 let mut preceding_users = Vec::new();
1674 let mut parse_error_lines = 0usize;
1675 let mut record_count = 0usize;
1676 let mut total_message_count = 0usize;
1677 let retain = message_limit.max(1).saturating_add(64);
1678 let mut canonical_assistant_texts = HashSet::new();
1679
1680 for raw_line in non_empty_lines(jsonl) {
1681 record_count += 1;
1682 let value: Value = match serde_json::from_str(raw_line) {
1683 Ok(value) => value,
1684 Err(_) => {
1685 parse_error_lines += 1;
1686 continue;
1687 }
1688 };
1689 let payload = value.get("payload").unwrap_or(&Value::Null);
1690 let line_ts = value.get("timestamp").and_then(Value::as_str);
1691 match value.get("type").and_then(Value::as_str) {
1692 Some("session_meta") => capture_codex_session_meta(payload, &mut meta),
1693 Some("turn_context") if meta.model.is_none() => {
1694 meta.model = payload
1695 .get("model")
1696 .and_then(Value::as_str)
1697 .map(str::to_string);
1698 }
1699 Some("response_item")
1700 if payload.get("type").and_then(Value::as_str) != Some("reasoning") =>
1701 {
1702 let assistant_text = (payload.get("type").and_then(Value::as_str)
1703 == Some("message")
1704 && payload.get("role").and_then(Value::as_str) == Some("assistant"))
1705 .then(|| extract_text_content(payload.get("content")))
1706 .filter(|text| !text.trim().is_empty());
1707 if let Some(text) = assistant_text.as_deref() {
1708 if let Some(index) = messages.iter().rposition(|message| {
1709 message.metadata.contains_key("codex_event_message")
1710 && message.content.as_deref() == Some(text)
1711 }) {
1712 messages.remove(index);
1713 total_message_count = total_message_count.saturating_sub(1);
1714 }
1715 canonical_assistant_texts.insert(text.trim().to_string());
1716 }
1717 let before = messages.len();
1718 push_codex_item(payload, &mut messages);
1719 total_message_count += messages.len().saturating_sub(before);
1720 stamp_new_codex_messages(&mut messages, before, line_ts);
1721 restore_single_grok_message(payload, &mut messages[before..]);
1722 }
1723 Some("event_msg")
1724 if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1725 {
1726 let text = agent_message_text(payload);
1727 if !text.trim().is_empty() && !canonical_assistant_texts.contains(text.trim()) {
1728 let before = messages.len();
1729 push_assistant(&mut messages, text, Vec::new());
1730 total_message_count += 1;
1731 if let Some(last) = messages.last_mut() {
1732 last.metadata
1733 .insert("codex_event_message".to_string(), "true".to_string());
1734 if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1735 last.metadata.insert("phase".to_string(), phase.to_string());
1736 }
1737 }
1738 stamp_new_codex_messages(&mut messages, before, line_ts);
1739 }
1740 }
1741 // `compacted` changes continuation context, not what was
1742 // already visible in scrollback. Other event records are UI
1743 // lifecycle noise or duplicate canonical response items.
1744 _ => {}
1745 }
1746 if messages.len() > retain {
1747 let remove = messages.len() - retain;
1748 for message in messages.drain(..remove) {
1749 if message.role == Role::User {
1750 preceding_users.push(message);
1751 if preceding_users.len() > 2 {
1752 preceding_users.remove(0);
1753 }
1754 }
1755 }
1756 }
1757 }
1758
1759 for message in &mut messages {
1760 message.metadata.remove("__codex_open_turn");
1761 message.metadata.remove("codex_event_message");
1762 if message
1763 .metadata
1764 .remove("__grok_remove_synthetic_turn_id")
1765 .is_some()
1766 {
1767 message.metadata.remove("turn_id");
1768 }
1769 }
1770 truncate_messages_with_anchor(&mut messages, message_limit, preceding_users);
1771 let imported_message_count = Some(total_message_count);
1772 Ok(Session {
1773 meta,
1774 messages,
1775 subagents: Vec::new(),
1776 // Preserve the cheap count without retaining hundreds of
1777 // megabytes of source lines in a display-only value.
1778 raw: vec![String::new(); record_count],
1779 raw_trailing_newline: jsonl.ends_with('\n'),
1780 imported_message_count,
1781 raw_is_verbatim: false,
1782 parse_error_lines,
1783 load_residue: vec![
1784 "display history is a bounded native-record projection, not resumable model context"
1785 .to_string(),
1786 ],
1787 })
1788 }
1789
1790 /// Load a Pi session from a file.
1791 pub fn from_pi(path: impl AsRef<Path>) -> Result<Session> {
1792 Self::from_pi_str(&std::fs::read_to_string(path.as_ref())?)
1793 }
1794
1795 /// Parse a Pi session (`docs/interop/opencode-pi-spec.md` §1.1,
1796 /// `docs/interop/research/pi-fields.md`) from an in-memory JSONL string.
1797 ///
1798 /// Line 1 is the `session` header; every other line is one `SessionEntry`
1799 /// in a tree keyed by `id`/`parentId` — file order is append order, not
1800 /// tree order. `raw` captures every line verbatim (byte-lossless T1,
1801 /// exactly like Claude Code/Codex). `messages` is the **active path
1802 /// only**: pi's own leaf rule is "the last entry in file order"
1803 /// (`pi-fields.md` `sm:897`), so this walks `parentId` from there back to
1804 /// the root and linearizes root→leaf. Non-active branches, `label`s, and
1805 /// state records (`thinking_level_change`/`model_change`/`custom`/
1806 /// `session_info`) are never visited by that walk — they survive in
1807 /// `raw` only, pi's defining residue (§1.1).
1808 ///
1809 /// `message.role` is an OPEN union upstream (§1.1 S6): a role outside the
1810 /// five modeled here (`user`/`assistant`/`toolResult`/`bashExecution`/
1811 /// `custom`) produces no canonical message — raw-only survival, never a
1812 /// panic — and the Pi corpus audit turns that into a
1813 /// visible coverage failure rather than a silent drop.
1814 ///
1815 /// Same fail-loud discipline applies to `ImageContent` blocks
1816 /// (`user`/`toolResult`/`custom*` content, see `pi_image_shape`): the
1817 /// assumed `{mimeType, data}` shape is UNVERIFIED against real pi output
1818 /// (`pi-fields.md` doesn't enumerate `ImageContent`'s own fields, only
1819 /// cites the containing union) — a follow-up TR tracks confirming it
1820 /// against a real corpus. Until then, an image block that doesn't match
1821 /// that shape never gets silently synthesized as an empty/corrupt
1822 /// `image_url` part; the containing message survives in `raw` only and
1823 /// trips `crate::audit::Corpus::Pi`'s `message/UnknownImageShape` bucket.
1824 pub fn from_pi_str(jsonl: &str) -> Result<Session> {
1825 let mut meta = SessionMeta::new(SessionSource::Pi);
1826 // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
1827 // blank-skipping PARSE walk (`lines_v`) below, which must keep
1828 // skipping blank/whitespace-only lines when it looks for `SessionEntry`
1829 // records (a blank line is never a record, on either view).
1830 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1831 let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1832 let non_empty_line_count = non_empty_lines(jsonl).count();
1833 let lines_v: Vec<Value> = non_empty_lines(jsonl)
1834 .filter_map(|l| serde_json::from_str(l).ok())
1835 .collect();
1836 // PARITY-15: every line that failed to even deserialize as JSON at
1837 // all (never mind whether it then parsed as a recognized
1838 // `SessionEntry` shape) — see `from_claude_code_str`'s identical
1839 // counter.
1840 let parse_error_lines = non_empty_line_count.saturating_sub(lines_v.len());
1841
1842 if let Some(header) = lines_v.first() {
1843 capture_pi_header(header, &mut meta)?;
1844 }
1845
1846 // Every non-header entry that parses as an object carrying an `id`.
1847 // (A line that fails to parse, or a header re-parsed as an entry,
1848 // simply never enters `by_id` — it survives in `raw` only, exactly
1849 // like a malformed/non-conversational line in the other loaders.)
1850 struct PiEntry {
1851 id: String,
1852 parent_id: Option<String>,
1853 value: Value,
1854 }
1855 let mut entries: Vec<PiEntry> = Vec::new();
1856 let mut by_id: HashMap<String, usize> = HashMap::new();
1857 for v in lines_v.iter().skip(1) {
1858 let Some(id) = v.get("id").and_then(Value::as_str) else {
1859 continue;
1860 };
1861 let parent_id = v
1862 .get("parentId")
1863 .and_then(Value::as_str)
1864 .map(str::to_string);
1865 by_id.insert(id.to_string(), entries.len());
1866 entries.push(PiEntry {
1867 id: id.to_string(),
1868 parent_id,
1869 value: v.clone(),
1870 });
1871 }
1872
1873 if entries.is_empty() {
1874 return Ok(Session {
1875 meta,
1876 messages: Vec::new(),
1877 subagents: Vec::new(),
1878 raw,
1879 raw_trailing_newline,
1880 imported_message_count: Some(0),
1881 // Pi is line-oriented: `raw` is split directly out of the
1882 // source text (strict-verbatim, IX-1), even for this
1883 // no-entries early return.
1884 raw_is_verbatim: true,
1885 parse_error_lines,
1886 load_residue: Vec::new(),
1887 });
1888 }
1889
1890 // Leaf = the last entry in file order (pi's own rule, `sm:897`), NOT
1891 // necessarily a `message` entry — a trailing `label`/`session_info`
1892 // still anchors the walk correctly since the walk just follows
1893 // `parentId` regardless of the leaf's own type.
1894 let leaf_idx = entries.len() - 1;
1895 let mut chain_rev: Vec<usize> = Vec::new();
1896 let mut cur: Option<String> = Some(entries[leaf_idx].id.clone());
1897 let mut guard = 0usize;
1898 while let Some(id) = cur {
1899 let Some(&idx) = by_id.get(&id) else { break };
1900 chain_rev.push(idx);
1901 cur = entries[idx].parent_id.clone();
1902 guard += 1;
1903 if guard > entries.len() + 1 {
1904 break; // cycle guard — malformed parentId chain
1905 }
1906 }
1907 chain_rev.reverse();
1908 let active = chain_rev; // indices into `entries`, root..leaf order
1909
1910 let pos_in_active: HashMap<&str, usize> = active
1911 .iter()
1912 .enumerate()
1913 .map(|(pos, &idx)| (entries[idx].id.as_str(), pos))
1914 .collect();
1915
1916 // First pass: compaction discipline (§2.1 S3) — every message from an
1917 // entry before the LATEST `firstKeptEntryId` on the active path is
1918 // excluded from replay (`compacted_out`), mirroring pi's own
1919 // `buildContextEntries` slice (`sm:414-450`).
1920 let mut kept_from_pos = 0usize;
1921 for &idx in &active {
1922 let e = &entries[idx];
1923 if e.value.get("type").and_then(Value::as_str) == Some("compaction") {
1924 if let Some(fk) = e.value.get("firstKeptEntryId").and_then(Value::as_str) {
1925 if let Some(&p) = pos_in_active.get(fk) {
1926 kept_from_pos = kept_from_pos.max(p);
1927 }
1928 }
1929 }
1930 }
1931
1932 let mut messages = Vec::new();
1933 let mut current_model: Option<String> = None;
1934 for (pos, &idx) in active.iter().enumerate() {
1935 let e = &entries[idx];
1936 let v = &e.value;
1937 let entry_ts = v
1938 .get("timestamp")
1939 .and_then(Value::as_str)
1940 .map(str::to_string);
1941 let before = messages.len();
1942 match v.get("type").and_then(Value::as_str) {
1943 Some("message") => {
1944 let msg_v = v.get("message").cloned().unwrap_or(Value::Null);
1945 match msg_v.get("role").and_then(Value::as_str) {
1946 Some("user") => push_pi_user(&msg_v, &mut messages),
1947 Some("assistant") => {
1948 push_pi_assistant(&msg_v, &mut messages);
1949 if let Some(m) = msg_v.get("model").and_then(Value::as_str) {
1950 current_model = Some(m.to_string());
1951 }
1952 }
1953 Some("toolResult") => push_pi_tool_result(&msg_v, &mut messages),
1954 Some("bashExecution") => push_pi_bash(&msg_v, &mut messages),
1955 Some("custom") => push_pi_custom_common(&msg_v, &mut messages),
1956 // OPEN UNION (S6): any other role — raw-only survival.
1957 _ => {}
1958 }
1959 }
1960 Some("custom_message") => push_pi_custom_common(v, &mut messages),
1961 Some("compaction") => push_pi_compaction(v, &mut messages),
1962 Some("branch_summary") => push_pi_branch_summary(v, &mut messages),
1963 Some("model_change") => {
1964 if let Some(m) = v.get("modelId").and_then(Value::as_str) {
1965 current_model = Some(m.to_string());
1966 }
1967 }
1968 Some("session_info") => {
1969 if let Some(name) = v.get("name").and_then(Value::as_str) {
1970 if !name.is_empty() {
1971 meta.lineage
1972 .insert("session_name".to_string(), name.to_string());
1973 }
1974 }
1975 }
1976 // thinking_level_change, custom (entry-level state), label —
1977 // no clean home, raw-only (§2.3).
1978 _ => {}
1979 }
1980 let is_summary = matches!(
1981 v.get("type").and_then(Value::as_str),
1982 Some("compaction") | Some("branch_summary")
1983 );
1984 for m in &mut messages[before..] {
1985 m.metadata.insert("pi_entry_id".to_string(), e.id.clone());
1986 if let Some(p) = &e.parent_id {
1987 m.metadata.insert("pi_parent_id".to_string(), p.clone());
1988 }
1989 if let Some(ts) = &entry_ts {
1990 m.metadata
1991 .entry("timestamp".to_string())
1992 .or_insert_with(|| ts.clone());
1993 }
1994 // WAVE-2 item 1 fallback: the entry-level `timestamp` above
1995 // is pi's authoritative, always-monotonic-in-file-order
1996 // wall-clock (mandatory on every entry) and wins whenever
1997 // present. The nested `message.timestamp` (unix-ms) is only
1998 // reached here — via `entry(...).or_insert_with`, so it
1999 // never overwrites the entry-level value — in the rare case
2000 // an entry lacks its own `timestamp`. This intentionally
2001 // does NOT prefer the msg-level field even though it LOOKS
2002 // more precise: unlike the entry-level timestamp, it is not
2003 // guaranteed monotonic with this loader's root->leaf
2004 // linearization (e.g. a rewound-branch entry can carry an
2005 // earlier msg-level clock reading than its file-order
2006 // neighbors), and OpenCode's own loader re-sorts messages by
2007 // this canonical timestamp — a non-monotonic source would
2008 // silently scramble replay order on a pi->opencode hop.
2009 if let Some(ms) = v
2010 .get("message")
2011 .and_then(|mm| mm.get("timestamp"))
2012 .and_then(Value::as_u64)
2013 {
2014 m.metadata
2015 .entry("timestamp".to_string())
2016 .or_insert_with(|| crate::sidecar::ms_to_rfc3339(ms as i64));
2017 }
2018 // A compaction/branch-summary message IS the retained marker
2019 // — never mark it excluded, regardless of its own position.
2020 if !is_summary && pos < kept_from_pos {
2021 m.metadata
2022 .insert("compacted_out".to_string(), "true".to_string());
2023 }
2024 }
2025 restore_single_grok_message(v, &mut messages[before..]);
2026 for message in &mut messages[before..] {
2027 restore_tool_outcome_extension(v, message);
2028 }
2029 }
2030
2031 meta.model = current_model;
2032 ensure_tool_results_paired(&mut messages);
2033 let imported_message_count = Some(messages.len());
2034 Ok(Session {
2035 meta,
2036 messages,
2037 subagents: Vec::new(),
2038 raw,
2039 raw_trailing_newline,
2040 imported_message_count,
2041 // Pi is line-oriented: `raw` is split directly out of the
2042 // source text (strict-verbatim, IX-1).
2043 raw_is_verbatim: true,
2044 parse_error_lines,
2045 load_residue: Vec::new(),
2046 })
2047 }
2048
2049 /// Load Grok's resumable `chat_history.jsonl` transcript.
2050 ///
2051 /// The surrounding session directory carries the session id, workspace,
2052 /// and `summary.json`; [`Self::from_grok_str`] handles the transcript
2053 /// itself while this path-aware entry point overlays that directory
2054 /// metadata.
2055 pub fn from_grok(path: impl AsRef<Path>) -> Result<Session> {
2056 let path = path.as_ref();
2057 let mut session = Self::from_grok_str(&std::fs::read_to_string(path)?)?;
2058 session.capture_grok_path_metadata(path);
2059 Ok(session)
2060 }
2061
2062 /// Parse Grok's line-oriented `chat_history.jsonl` format.
2063 ///
2064 /// Conversational records are `user`, `assistant`, and `tool_result`.
2065 /// `system` is the regenerated base prompt and is retained in
2066 /// [`SessionMeta::system_prompt`]; encrypted reasoning and backend-only
2067 /// state remain byte-exact in [`Session::raw`] but are intentionally not
2068 /// replayed as chat turns.
2069 pub fn from_grok_str(jsonl: &str) -> Result<Session> {
2070 let mut meta = SessionMeta::new(SessionSource::Grok);
2071 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2072 let raw: Vec<String> = raw_lines.iter().map(|line| line.to_string()).collect();
2073 let mut messages = Vec::new();
2074 let mut parse_error_lines = 0usize;
2075 let mut tool_names: HashMap<String, String> = HashMap::new();
2076
2077 for line in non_empty_lines(jsonl) {
2078 let value: Value = match serde_json::from_str(line) {
2079 Ok(value) => value,
2080 Err(_) => {
2081 parse_error_lines += 1;
2082 continue;
2083 }
2084 };
2085 restore_codex_provenance_from_top_level(&value, &mut meta)?;
2086 match value.get("type").and_then(Value::as_str) {
2087 Some("system") => {
2088 if meta.system_prompt.is_none() {
2089 meta.system_prompt = value
2090 .get("content")
2091 .and_then(Value::as_str)
2092 .map(str::to_string);
2093 }
2094 }
2095 Some("user") => {
2096 let content = extract_text_content(value.get("content"));
2097 let role = if value.get("synthetic_reason").and_then(Value::as_str)
2098 == Some("supercode_system_event")
2099 {
2100 Role::System
2101 } else {
2102 Role::User
2103 };
2104 let content = if role == Role::User {
2105 match grok_human_user_text(&content) {
2106 Some(content) => content,
2107 None if value.get(SUPERCODE_GROK_MESSAGE_KEY).is_some() => {
2108 String::new()
2109 }
2110 None => continue,
2111 }
2112 } else {
2113 content
2114 };
2115 let mut message = ChatMessage {
2116 role,
2117 content: Some(content),
2118 content_parts: None,
2119 tool_calls: None,
2120 tool_call_id: None,
2121 name: None,
2122 metadata: Default::default(),
2123 };
2124 capture_grok_scalar_metadata(
2125 &value,
2126 &mut message,
2127 &["prompt_index", "prior_turn_interrupt", "synthetic_reason"],
2128 );
2129 restore_grok_message_extension(&value, &mut message);
2130 messages.push(message);
2131 }
2132 Some("assistant") => {
2133 let calls: Vec<ToolCall> = value
2134 .get("tool_calls")
2135 .and_then(Value::as_array)
2136 .into_iter()
2137 .flatten()
2138 .filter_map(|call| {
2139 let id = call.get("id")?.as_str()?.to_string();
2140 let name = call.get("name")?.as_str()?.to_string();
2141 let arguments = call
2142 .get("arguments")
2143 .map(value_to_arg_string)
2144 .unwrap_or_else(|| "{}".to_string());
2145 tool_names.insert(id.clone(), name.clone());
2146 Some(function_call(&id, &name, arguments))
2147 })
2148 .collect();
2149 let content = value
2150 .get("content")
2151 .and_then(Value::as_str)
2152 .filter(|content| !content.is_empty())
2153 .map(str::to_string);
2154 let mut message = ChatMessage {
2155 role: Role::Assistant,
2156 content,
2157 content_parts: None,
2158 tool_calls: (!calls.is_empty()).then_some(calls),
2159 tool_call_id: None,
2160 name: None,
2161 metadata: Default::default(),
2162 };
2163 capture_grok_scalar_metadata(
2164 &value,
2165 &mut message,
2166 &["model_id", "model_fingerprint", "reasoning_effort"],
2167 );
2168 if let Some(model) = value.get("model_id").and_then(Value::as_str) {
2169 meta.model = Some(model.to_string());
2170 }
2171 restore_grok_message_extension(&value, &mut message);
2172 messages.push(message);
2173 }
2174 Some("tool_result") => {
2175 let id = value
2176 .get("tool_call_id")
2177 .and_then(Value::as_str)
2178 .unwrap_or_default();
2179 let content = value
2180 .get("content")
2181 .map(|value| match value {
2182 Value::String(text) => text.clone(),
2183 other => extract_text_content(Some(other)),
2184 })
2185 .unwrap_or_default();
2186 let mut message = tool_message(id, content);
2187 message.name = tool_names.get(id).cloned();
2188 restore_grok_message_extension(&value, &mut message);
2189 messages.push(message);
2190 }
2191 // `reasoning` contains encrypted chain-of-thought and
2192 // `backend_tool_call` is execution bookkeeping. Both survive
2193 // verbatim in raw without being replayed to another model.
2194 _ => {}
2195 }
2196 }
2197
2198 ensure_tool_results_paired(&mut messages);
2199 let imported_message_count = Some(messages.len());
2200 Ok(Session {
2201 meta,
2202 messages,
2203 subagents: Vec::new(),
2204 raw,
2205 raw_trailing_newline,
2206 imported_message_count,
2207 raw_is_verbatim: true,
2208 parse_error_lines,
2209 load_residue: Vec::new(),
2210 })
2211 }
2212
2213 fn capture_grok_path_metadata(&mut self, transcript: &Path) {
2214 let Some(session_dir) = transcript.parent() else {
2215 return;
2216 };
2217 self.meta.session_id = session_dir
2218 .file_name()
2219 .and_then(|name| name.to_str())
2220 .map(str::to_string);
2221 self.meta.cwd = session_dir
2222 .parent()
2223 .and_then(Path::file_name)
2224 .and_then(|name| name.to_str())
2225 .and_then(percent_decode_path)
2226 .map(PathBuf::from);
2227
2228 let Ok(summary_text) = std::fs::read_to_string(session_dir.join("summary.json")) else {
2229 return;
2230 };
2231 let Ok(summary) = serde_json::from_str::<Value>(&summary_text) else {
2232 return;
2233 };
2234 if let Some(model) = summary.get("current_model_id").and_then(Value::as_str) {
2235 self.meta.model = Some(model.to_string());
2236 }
2237 for (source, target) in [
2238 ("generated_title", "session_name"),
2239 ("created_at", "created_at"),
2240 ("updated_at", "updated_at"),
2241 ("chat_format_version", "grok_chat_format_version"),
2242 ] {
2243 if let Some(value) = summary.get(source) {
2244 self.meta.lineage.insert(
2245 target.to_string(),
2246 value
2247 .as_str()
2248 .map(str::to_string)
2249 .unwrap_or_else(|| value.to_string()),
2250 );
2251 }
2252 }
2253 }
2254
2255 /// Load a Gemini CLI transcript from disk.
2256 pub fn from_gemini(path: impl AsRef<Path>) -> Result<Session> {
2257 Self::from_gemini_str(&std::fs::read_to_string(path.as_ref())?)
2258 }
2259
2260 /// Parse Gemini CLI's line-oriented session format.
2261 ///
2262 /// Gemini stores a header without a `type`, followed by `user` and
2263 /// `gemini` records. Function calls are embedded in assistant content
2264 /// parts and function responses in user content parts. Unknown records
2265 /// remain byte-exact in [`Session::raw`] instead of silently entering the
2266 /// replay conversation.
2267 pub fn from_gemini_str(jsonl: &str) -> Result<Session> {
2268 let mut meta = SessionMeta::new(SessionSource::Gemini);
2269 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2270 let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2271 let mut messages = Vec::new();
2272 let mut parse_error_lines = 0usize;
2273 let mut pending_by_name: HashMap<String, Vec<String>> = HashMap::new();
2274
2275 for (line_index, line) in non_empty_lines(jsonl).enumerate() {
2276 let value: Value = match serde_json::from_str(line) {
2277 Ok(value) => value,
2278 Err(_) => {
2279 parse_error_lines += 1;
2280 continue;
2281 }
2282 };
2283 let kind = value.get("type").and_then(Value::as_str);
2284 if kind.is_none() {
2285 if meta.session_id.is_none() {
2286 meta.session_id = value
2287 .get("sessionId")
2288 .and_then(Value::as_str)
2289 .map(str::to_string);
2290 }
2291 for (source, target) in [
2292 ("projectHash", "gemini_project_hash"),
2293 ("startTime", "created_at"),
2294 ("lastUpdated", "updated_at"),
2295 ("kind", "gemini_session_kind"),
2296 ] {
2297 if let Some(raw) = value.get(source) {
2298 meta.lineage.insert(
2299 target.to_string(),
2300 raw.as_str()
2301 .map(str::to_string)
2302 .unwrap_or_else(|| raw.to_string()),
2303 );
2304 }
2305 }
2306 continue;
2307 }
2308 if kind != Some("user") && kind != Some("gemini") {
2309 continue;
2310 }
2311
2312 let timestamp = value.get("timestamp").and_then(Value::as_str);
2313 let model = value.get("model").and_then(Value::as_str);
2314 if let Some(model) = model {
2315 meta.model = Some(model.to_string());
2316 }
2317 let content = value.get("content").unwrap_or(&Value::Null);
2318 let parts = content.as_array();
2319 let text = match content {
2320 Value::String(text) => text.clone(),
2321 Value::Array(parts) => parts
2322 .iter()
2323 .filter_map(|part| part.get("text").and_then(Value::as_str))
2324 .collect::<Vec<_>>()
2325 .join(" ")
2326 .trim()
2327 .to_string(),
2328 _ => String::new(),
2329 };
2330
2331 if kind == Some("gemini") {
2332 let legacy_calls = parts
2333 .into_iter()
2334 .flatten()
2335 .filter_map(|part| part.get("functionCall"));
2336 let native_calls = value
2337 .get("toolCalls")
2338 .and_then(Value::as_array)
2339 .into_iter()
2340 .flatten();
2341 let calls = native_calls
2342 .chain(legacy_calls)
2343 .enumerate()
2344 .filter_map(|(call_index, call)| {
2345 let name = call.get("name")?.as_str()?.to_string();
2346 let id = call
2347 .get("id")
2348 .and_then(Value::as_str)
2349 .map(str::to_string)
2350 .unwrap_or_else(|| format!("gemini-{line_index}-{call_index}"));
2351 pending_by_name
2352 .entry(name.clone())
2353 .or_default()
2354 .push(id.clone());
2355 let arguments = call
2356 .get("args")
2357 .map(value_to_arg_string)
2358 .unwrap_or_else(|| "{}".to_string());
2359 Some(function_call(&id, &name, arguments))
2360 })
2361 .collect::<Vec<_>>();
2362 let mut message = ChatMessage {
2363 role: Role::Assistant,
2364 content: (!text.is_empty()).then_some(text),
2365 content_parts: None,
2366 tool_calls: (!calls.is_empty()).then_some(calls),
2367 tool_call_id: None,
2368 name: None,
2369 metadata: Default::default(),
2370 };
2371 if let Some(timestamp) = timestamp {
2372 message
2373 .metadata
2374 .insert("timestamp".into(), timestamp.into());
2375 }
2376 if let Some(model) = model {
2377 message.metadata.insert("gemini_model".into(), model.into());
2378 }
2379 if let Some(thoughts) = value.get("thoughts").filter(|value| !value.is_null()) {
2380 message
2381 .metadata
2382 .insert("gemini_thoughts".into(), thoughts.to_string());
2383 }
2384 restore_gemini_message_extension(&value, &mut message);
2385 if message.content.is_some() || message.tool_calls.is_some() {
2386 messages.push(message);
2387 }
2388 continue;
2389 }
2390
2391 let mut user_parts = Vec::new();
2392 if let Some(parts) = parts {
2393 for part in parts {
2394 if let Some(response) = part.get("functionResponse") {
2395 push_gemini_user_parts(
2396 &mut messages,
2397 std::mem::take(&mut user_parts),
2398 timestamp,
2399 &value,
2400 );
2401 let name = response
2402 .get("name")
2403 .and_then(Value::as_str)
2404 .unwrap_or("tool")
2405 .to_string();
2406 let explicit_id = response
2407 .get("id")
2408 .and_then(Value::as_str)
2409 .map(str::to_string);
2410 if let Some(id) = explicit_id.as_deref() {
2411 if let Some(ids) = pending_by_name.get_mut(&name) {
2412 if let Some(position) = ids.iter().position(|pending| pending == id)
2413 {
2414 ids.remove(position);
2415 }
2416 }
2417 }
2418 let id = explicit_id
2419 .or_else(|| {
2420 pending_by_name
2421 .get_mut(&name)
2422 .and_then(|ids| (!ids.is_empty()).then(|| ids.remove(0)))
2423 })
2424 .unwrap_or_else(|| format!("gemini-{line_index}-response"));
2425 let output = response
2426 .get("response")
2427 .and_then(|response| response.get("output"))
2428 .map(|output| {
2429 output
2430 .as_str()
2431 .map(str::to_string)
2432 .unwrap_or_else(|| output.to_string())
2433 })
2434 .or_else(|| response.get("response").map(Value::to_string))
2435 .unwrap_or_default();
2436 let mut message = tool_message(&id, output);
2437 message.name = Some(name);
2438 if let Some(timestamp) = timestamp {
2439 message
2440 .metadata
2441 .insert("timestamp".into(), timestamp.into());
2442 }
2443 restore_gemini_message_extension(&value, &mut message);
2444 messages.push(message);
2445 continue;
2446 }
2447 if let Some(text) = part.get("text").and_then(Value::as_str) {
2448 user_parts.push(serde_json::json!({"type": "text", "text": text}));
2449 continue;
2450 }
2451 if let Some(inline) = part.get("inlineData") {
2452 let Some(data) = inline.get("data").and_then(Value::as_str) else {
2453 continue;
2454 };
2455 let media_type = inline
2456 .get("mimeType")
2457 .and_then(Value::as_str)
2458 .unwrap_or("application/octet-stream");
2459 user_parts.push(serde_json::json!({
2460 "type": "image_url",
2461 "image_url": {"url": format!("data:{media_type};base64,{data}")},
2462 }));
2463 }
2464 }
2465 } else if !text.is_empty() {
2466 user_parts.push(serde_json::json!({"type": "text", "text": text}));
2467 }
2468 push_gemini_user_parts(&mut messages, user_parts, timestamp, &value);
2469 }
2470
2471 ensure_tool_results_paired(&mut messages);
2472 let imported_message_count = Some(messages.len());
2473 Ok(Session {
2474 meta,
2475 messages,
2476 subagents: Vec::new(),
2477 raw,
2478 raw_trailing_newline,
2479 imported_message_count,
2480 raw_is_verbatim: true,
2481 parse_error_lines,
2482 load_residue: Vec::new(),
2483 })
2484 }
2485
2486 /// Load a Goose session-export JSON document from disk.
2487 pub fn from_goose(path: impl AsRef<Path>) -> Result<Session> {
2488 Self::from_goose_str(&std::fs::read_to_string(path.as_ref())?)
2489 }
2490
2491 /// Parse Goose's official native import/export document.
2492 ///
2493 /// Goose's durable store is SQLite, but its own
2494 /// `_goose/unstable/session/export` and `/session/import` boundary is one
2495 /// JSON object containing a `conversation` array. Unknown native content
2496 /// blocks are retained on the first canonical message in a namespaced
2497 /// portability envelope; unchanged same-format exports replay the exact
2498 /// source bytes.
2499 pub fn from_goose_str(json: &str) -> Result<Session> {
2500 let document: Value = serde_json::from_str(json).map_err(crate::Error::Decode)?;
2501 let object = document.as_object().ok_or_else(|| {
2502 Error::InvalidSession("Goose session export must be a JSON object".to_string())
2503 })?;
2504 let conversation = object
2505 .get("conversation")
2506 .and_then(Value::as_array)
2507 .ok_or_else(|| {
2508 Error::InvalidSession(
2509 "Goose session export must contain a conversation array".to_string(),
2510 )
2511 })?;
2512
2513 let mut meta = SessionMeta::new(SessionSource::Goose);
2514 meta.session_id = object.get("id").and_then(Value::as_str).map(str::to_string);
2515 meta.cwd = object
2516 .get("working_dir")
2517 .or_else(|| object.get("workingDir"))
2518 .and_then(Value::as_str)
2519 .map(PathBuf::from);
2520 meta.model = object
2521 .get("model_config")
2522 .or_else(|| object.get("modelConfig"))
2523 .and_then(|model| model.get("model_name").or_else(|| model.get("modelName")))
2524 .and_then(Value::as_str)
2525 .map(str::to_string);
2526 for (source, target) in [
2527 ("name", "session_name"),
2528 ("created_at", "created_at"),
2529 ("updated_at", "updated_at"),
2530 ("session_type", "goose_session_type"),
2531 ("goose_mode", "goose_mode"),
2532 ("provider_name", "goose_provider_name"),
2533 ("parent_session_id", "parent_session_id"),
2534 ] {
2535 if let Some(value) = object.get(source) {
2536 meta.lineage.insert(
2537 target.to_string(),
2538 value
2539 .as_str()
2540 .map(str::to_string)
2541 .unwrap_or_else(|| value.to_string()),
2542 );
2543 }
2544 }
2545 let mut header = document.clone();
2546 if let Some(header) = header.as_object_mut() {
2547 header.remove("conversation");
2548 }
2549 meta.goose_header = Some(header.clone());
2550
2551 let mut messages = Vec::new();
2552 for (native_index, native) in conversation.iter().enumerate() {
2553 let before = messages.len();
2554 normalize_goose_message(native, native_index, &mut messages);
2555 if let Some(first) = messages.get_mut(before) {
2556 first
2557 .metadata
2558 .insert("goose_native_message".to_string(), native.to_string());
2559 first
2560 .metadata
2561 .insert("goose_native_index".to_string(), native_index.to_string());
2562 if native_index == 0 {
2563 first
2564 .metadata
2565 .insert("goose_session_header".to_string(), header.to_string());
2566 }
2567 restore_grok_message_extension(native, first);
2568 }
2569 for message in messages.iter_mut().skip(before + 1) {
2570 message
2571 .metadata
2572 .insert("goose_native_index".to_string(), native_index.to_string());
2573 }
2574 }
2575 ensure_tool_results_paired(&mut messages);
2576
2577 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(json);
2578 let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2579 let imported_message_count = Some(messages.len());
2580 Ok(Session {
2581 meta,
2582 messages,
2583 subagents: Vec::new(),
2584 raw,
2585 raw_trailing_newline,
2586 imported_message_count,
2587 raw_is_verbatim: true,
2588 parse_error_lines: 0,
2589 load_residue: Vec::new(),
2590 })
2591 }
2592
2593 /// Load one Goose session directly from its native SQLite store.
2594 ///
2595 /// The selector is Goose's stable `sessions.id`. The reconstructed JSON
2596 /// uses Goose's own public export shape, so the ordinary Goose codec is
2597 /// the single normalization boundary for both files and the live store.
2598 pub fn from_goose_sqlite(db_path: &Path, session_id: &str) -> Result<Session> {
2599 Self::from_goose_sqlite_with_limit(db_path, session_id, None)
2600 }
2601
2602 /// Bounded Goose store read for transcript UI surfaces. The inner query
2603 /// selects only the newest native rows; the outer query restores their
2604 /// chronological order. Export/continue callers deliberately use the
2605 /// unbounded public loader above.
2606 #[doc(hidden)]
2607 pub fn from_goose_sqlite_display(
2608 db_path: &Path,
2609 session_id: &str,
2610 message_limit: usize,
2611 ) -> Result<Session> {
2612 Self::from_goose_sqlite_with_limit(db_path, session_id, Some(message_limit.max(1)))
2613 }
2614
2615 fn from_goose_sqlite_with_limit(
2616 db_path: &Path,
2617 session_id: &str,
2618 message_limit: Option<usize>,
2619 ) -> Result<Session> {
2620 let connection = Connection::open_with_flags(
2621 db_path,
2622 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2623 )
2624 .map_err(|error| Error::Other(format!("failed to open Goose SQLite store: {error}")))?;
2625 let mut statement = connection
2626 .prepare(
2627 "SELECT id, name, working_dir, created_at, updated_at, session_type, \
2628 extension_data, goose_mode, provider_name, model_config_json \
2629 FROM sessions WHERE id = ?1",
2630 )
2631 .map_err(|error| Error::Other(format!("failed to query Goose sessions: {error}")))?;
2632 let mut document = statement
2633 .query_row([session_id], |row| {
2634 let extension_data: Option<String> = row.get(6)?;
2635 let model_config: Option<String> = row.get(9)?;
2636 Ok(serde_json::json!({
2637 "id": row.get::<_, String>(0)?,
2638 "working_dir": row.get::<_, String>(2)?,
2639 "name": row.get::<_, String>(1)?,
2640 "user_set_name": false,
2641 "session_type": row.get::<_, String>(5)?,
2642 "created_at": row.get::<_, String>(3)?,
2643 "updated_at": row.get::<_, String>(4)?,
2644 "extension_data": extension_data
2645 .as_deref()
2646 .and_then(|value| serde_json::from_str::<Value>(value).ok())
2647 .unwrap_or_else(|| serde_json::json!({})),
2648 "usage": {},
2649 "accumulated_usage": {},
2650 "accumulated_cost": Value::Null,
2651 "schedule_id": Value::Null,
2652 "recipe": Value::Null,
2653 "user_recipe_values": Value::Null,
2654 "conversation": [],
2655 "message_count": 0,
2656 "last_message_at": Value::Null,
2657 "provider_name": row.get::<_, Option<String>>(8)?,
2658 "model_config": model_config
2659 .as_deref()
2660 .and_then(|value| serde_json::from_str::<Value>(value).ok()),
2661 "goose_mode": row.get::<_, String>(7)?,
2662 "archived_at": Value::Null,
2663 "project_id": Value::Null,
2664 "parent_session_id": Value::Null,
2665 "last_message_snippet": Value::Null,
2666 }))
2667 })
2668 .map_err(|error| Error::Other(format!("failed to load Goose session: {error}")))?;
2669
2670 let message_query = message_limit.map_or_else(
2671 || {
2672 "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2673 FROM messages WHERE session_id = ?1 ORDER BY created_timestamp, id"
2674 .to_string()
2675 },
2676 |limit| {
2677 format!(
2678 "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2679 FROM (SELECT id AS native_row_id, message_id, role, content_json, \
2680 created_timestamp, metadata_json \
2681 FROM messages WHERE session_id = ?1 \
2682 ORDER BY created_timestamp DESC, id DESC LIMIT {limit}) \
2683 ORDER BY created_timestamp, native_row_id"
2684 )
2685 },
2686 );
2687 let mut message_statement = connection
2688 .prepare(&message_query)
2689 .map_err(|error| Error::Other(format!("failed to query Goose messages: {error}")))?;
2690 let rows = message_statement
2691 .query_map([session_id], |row| {
2692 let content: String = row.get(2)?;
2693 let metadata: Option<String> = row.get(4)?;
2694 Ok(serde_json::json!({
2695 "id": row.get::<_, Option<String>>(0)?,
2696 "role": row.get::<_, String>(1)?,
2697 "created": row.get::<_, i64>(3)?,
2698 "content": serde_json::from_str::<Value>(&content)
2699 .unwrap_or_else(|_| Value::Array(Vec::new())),
2700 "metadata": metadata
2701 .as_deref()
2702 .and_then(|value| serde_json::from_str::<Value>(value).ok())
2703 .unwrap_or_else(|| serde_json::json!({
2704 "userVisible": true,
2705 "agentVisible": true
2706 })),
2707 }))
2708 })
2709 .map_err(|error| Error::Other(format!("failed to load Goose messages: {error}")))?;
2710 let conversation = rows
2711 .collect::<std::result::Result<Vec<_>, _>>()
2712 .map_err(|error| Error::Other(format!("failed to decode Goose messages: {error}")))?;
2713 document["message_count"] = Value::from(conversation.len());
2714 document["conversation"] = Value::Array(conversation);
2715 let json = serde_json::to_string_pretty(&document).map_err(crate::Error::Decode)?;
2716 let mut session = Self::from_goose_str(&json)?;
2717 // SQLite was reconstructed through values, not captured byte-for-byte.
2718 session.raw_is_verbatim = false;
2719 Ok(session)
2720 }
2721
2722 /// Load an OpenCode session from a file — either read surface, see
2723 /// [`Self::from_opencode_str`].
2724 pub fn from_opencode(path: impl AsRef<Path>) -> Result<Session> {
2725 Self::from_opencode_str(&std::fs::read_to_string(path.as_ref())?)
2726 }
2727
2728 /// Load an OpenCode session from a real SQLite store (PARITY-3): opens
2729 /// `db_path`, resolves `session_id` (explicit, or — when `None` — the
2730 /// most-recently-updated top-level session, see
2731 /// `opencode_sqlite_primary_session_id`) and reconstructs the SAME
2732 /// envelope form [`Self::from_opencode_str`] already parses for the
2733 /// JSON-tree surfaces — so canonicalization (§2.1 mapping, compaction
2734 /// discipline, S1 tool-output masking, …) is shared code, not
2735 /// reimplemented here. See `docs/interop/opencode-pi-spec.md` §1.2/S9c
2736 /// for the envelope-construction rules this follows (all-columns rule,
2737 /// raw `revert` column carried verbatim).
2738 pub fn from_opencode_sqlite(db_path: &Path, session_id: Option<&str>) -> Result<Session> {
2739 let conn = opencode_sqlite_open(db_path)?;
2740 let id = match session_id {
2741 Some(id) => id.to_string(),
2742 None => opencode_sqlite_primary_session_id(&conn)?,
2743 };
2744 let lines = opencode_sqlite_session_envelope_lines(&conn, db_path, &id)?;
2745 let mut text = lines.join("\n");
2746 text.push('\n');
2747 let mut session = Self::from_opencode_str(&text)?;
2748 // D4: `text` above is a SYNTHESIZED reconstruction from SQL rows —
2749 // not the original source bytes (a binary `.db` file has no
2750 // "verbatim" line-oriented form to begin with). `from_opencode_str`
2751 // defaults `raw_is_verbatim` to `true` because for its OTHER two
2752 // callers (an actual envelope-form file's own text, an actual
2753 // export-document's text) that really is the source. It is NEVER
2754 // true for this diagonal — mirrors the export-document fix just
2755 // above for the same reason (`from_opencode_export_doc`, `false`).
2756 // `convert opencode.db --to opencode` must not claim byte-identical.
2757 session.raw_is_verbatim = false;
2758 Ok(session)
2759 }
2760
2761 /// Parse an OpenCode session from either of its two frozen **read
2762 /// surfaces** (`docs/interop/opencode-pi-spec.md` §1.2, `S9a`,
2763 /// `opencode-fields.md`):
2764 ///
2765 /// - the **envelope form**: each line is
2766 /// `{"key":[<storage key path>],"value":<record>}`, minified — the
2767 /// synthesized raw-capture unit for the JSON-tree/SQLite storage
2768 /// generations;
2769 /// - the **export-document form**: a single pretty-printed JSON document
2770 /// `{info: SessionInfo, messages:[{info: Message, parts:[Part]}, …]}`
2771 /// — the `opencode export`/`import` interchange shape, and EXACTLY
2772 /// what the OpenCode writer emits.
2773 ///
2774 /// Both forms are parsed into the same `(session_info, side_records,
2775 /// Vec<OcMsg>)` shape and funnel through the SAME shared canonicalizer,
2776 /// `opencode_session_from_records` — so the same underlying records
2777 /// produce identical `messages` regardless of which surface carried
2778 /// them in. This is what makes `load(to_opencode_jsonl(S))` round-trip
2779 /// (§4.1's OpenCode diagonal, the exact circuit the fidelity matrix
2780 /// exercises): previously this function parsed the envelope form only
2781 /// and silently returned an empty-but-`Ok` `Session` for an export
2782 /// document — the confirmed footgun this now closes.
2783 ///
2784 /// Record classification (envelope form) is driven by the envelope
2785 /// `key`'s first component (`"session"` / `"message"` / `"part"` /
2786 /// `"session_diff"` / `"todo"`) — the frozen key scheme for all three
2787 /// storage generations plus SQLite rows (§1.2 S9c: a SQLite row's
2788 /// envelope synthesizes `key:["<table>","<ses>",...ids]` and MUST carry
2789 /// every column, `data` and non-`data` alike — e.g. the `session` row's
2790 /// `revert` column under the V2 `Revert.State` schema, whose extra
2791 /// `files` field the CLI's own row→V1 reconstruction drops; the
2792 /// envelope's `raw` capture keeps that raw column value regardless of
2793 /// what this loader's canonicalization understands).
2794 ///
2795 /// Mapping to canonical `messages` (§2.1, shared by both forms via
2796 /// `push_opencode_user`/`push_opencode_assistant`): `User`/`Assistant`
2797 /// text parts → `content`; a `User` `file` part whose `mime` is an image
2798 /// and whose `url` is a `data:` URI → an `image_url` `content_parts`
2799 /// entry; an `Assistant` `tool` part's `callID` + `state.*.input` → a
2800 /// `ToolCall`, and the SAME part's `state.completed.output` /
2801 /// `state.error.error` → a paired `Tool` message split by `callID`
2802 /// (opencode keeps call+result on one record; this loader splits it
2803 /// into the two OpenAI-shape messages the other loaders already
2804 /// produce).
2805 ///
2806 /// **S1 (`time.compacted`):** when a `tool` part's
2807 /// `state.completed.time.compacted` is set, the emitted `Tool`
2808 /// message's `content` is the placeholder
2809 /// [`OPENCODE_COMPACTED_TOOL_PLACEHOLDER`] — mirroring what opencode's
2810 /// own `toModelMessage` replays — while the REAL output survives in
2811 /// `raw` (always) and in `metadata["oc_tool_output_compacted"]` (full
2812 /// text) + `metadata["oc_tool_time_compacted"]` (the mask timestamp), so
2813 /// it is reversible, never actually lost.
2814 ///
2815 /// **Compaction boundary:** a `compaction` part's `tail_start_id` marks
2816 /// every message strictly before that message id
2817 /// `metadata["compacted_out"]="true"` (honored uniformly by
2818 /// `is_replay_excluded`) — except a `summary:true` `Assistant`
2819 /// message, which opencode itself hoists in FRONT of the retained tail
2820 /// on replay (`message-v2.ts:521-572`) and so must never be excluded
2821 /// regardless of its position, mirroring pi's identical exemption for
2822 /// its own compaction/branch-summary entries.
2823 ///
2824 /// **Unknown part `type` or unknown `tool.state.status`:** never
2825 /// canonicalized — raw-only survival, exactly like an unmodeled Pi
2826 /// `message.role` (S6-style fail-loud). The OpenCode corpus audit
2827 /// is what turns that into a visible coverage failure rather than a
2828 /// silent drop.
2829 ///
2830 /// **Export-document `raw`:** an export document is a single
2831 /// pretty-printed JSON value with no per-line envelope structure of its
2832 /// own to capture verbatim, so `raw` here is RE-SYNTHESIZED — one
2833 /// envelope line per `session`/`message`/`part` record found in the
2834 /// document, in the exact `{"key":[...],"value":...}` shape the native
2835 /// envelope form uses — so every native/T1-value-tier path
2836 /// (`to_native_jsonl`, `opencode_records_from_raw`, the
2837 /// splice/direct-write writers) stays consistent regardless of which
2838 /// read surface produced this `Session`.
2839 ///
2840 /// **Malformed input:** input that reaches this function non-empty but
2841 /// yields zero session/message/part records under EITHER form returns a
2842 /// clear `Err` rather than a silently-empty `Ok(Session)` — the
2843 /// confirmed footgun (`supercode resume`/`convert`/`inspect` on such
2844 /// input must not silently succeed with an empty session). A
2845 /// legitimately-empty session — a real `session` record with zero
2846 /// messages, or a valid export document with an empty `messages` array
2847 /// — is not an error.
2848 pub fn from_opencode_str(text: &str) -> Result<Session> {
2849 let trimmed = text.trim();
2850
2851 // Export-document form first (§1.2/S9a) — mirrors `detect_source`'s
2852 // own precedence: try the whole-text parse before the per-line
2853 // envelope loop below, since a pretty-printed multi-line document
2854 // has no individually-valid-JSON lines for that loop to match.
2855 if let Ok(doc) = serde_json::from_str::<Value>(trimmed) {
2856 if doc.get("info").is_some() && doc.get("messages").and_then(Value::as_array).is_some()
2857 {
2858 return Self::from_opencode_export_doc(&doc);
2859 }
2860 }
2861
2862 // Envelope form: each line `{"key":[<storage key path>],"value":<record>}`.
2863 // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
2864 // blank-skipping PARSE walk just below, which keeps skipping
2865 // blank/whitespace-only lines when it looks for envelope records.
2866 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(text);
2867 let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
2868 let mut session_info: Option<Value> = None;
2869 let mut side_records: Vec<Value> = Vec::new();
2870 let mut msgs: Vec<OcMsg> = Vec::new();
2871 let mut msg_index: HashMap<String, usize> = HashMap::new();
2872 // PARITY-15: see `from_claude_code_str`'s identical counter — only
2873 // a genuinely malformed line (fails to deserialize as JSON at all),
2874 // not a well-formed envelope this loader simply doesn't recognize.
2875 let mut parse_error_lines = 0usize;
2876
2877 for line in non_empty_lines(text) {
2878 let Ok(env) = serde_json::from_str::<Value>(line) else {
2879 parse_error_lines += 1;
2880 continue; // malformed line — raw-only, exactly like the other loaders
2881 };
2882 let Some(key) = env.get("key").and_then(Value::as_array) else {
2883 continue; // not an envelope record — raw-only
2884 };
2885 let value = env.get("value").cloned().unwrap_or(Value::Null);
2886 match key.first().and_then(Value::as_str) {
2887 Some("session") => session_info = Some(value),
2888 Some("message") => {
2889 let Some(id) = value.get("id").and_then(Value::as_str) else {
2890 continue;
2891 };
2892 let time_created = value
2893 .get("time")
2894 .and_then(|t| t.get("created"))
2895 .and_then(Value::as_i64)
2896 .unwrap_or(0);
2897 msg_index.insert(id.to_string(), msgs.len());
2898 msgs.push(OcMsg {
2899 id: id.to_string(),
2900 time_created,
2901 value,
2902 parts: Vec::new(),
2903 });
2904 }
2905 Some("part") => {
2906 if let Some(msg_id) = value.get("messageID").and_then(Value::as_str) {
2907 if let Some(&idx) = msg_index.get(msg_id) {
2908 msgs[idx].parts.push(value);
2909 }
2910 // A part whose message wasn't captured (out-of-order
2911 // envelope) — still fully present in `raw`, just not
2912 // attached to a canonical message.
2913 }
2914 }
2915 Some("session_diff") | Some("todo") => {
2916 side_records.push(serde_json::json!({"key": key, "value": value}));
2917 }
2918 _ => {} // unrecognized top-level key — raw-only
2919 }
2920 }
2921
2922 opencode_guard_against_silent_empty(
2923 !trimmed.is_empty(),
2924 &session_info,
2925 &msgs,
2926 &side_records,
2927 )?;
2928 opencode_session_from_records(
2929 session_info,
2930 side_records,
2931 msgs,
2932 raw,
2933 raw_trailing_newline,
2934 // Envelope form: `raw` is split directly out of the source text
2935 // (strict-verbatim, IX-1) — genuinely reproduces the original
2936 // bytes on replay.
2937 true,
2938 parse_error_lines,
2939 )
2940 }
2941
2942 /// The **export-document** read surface of [`Self::from_opencode_str`]
2943 /// — see that function's doc comment for the shared canonicalization
2944 /// and the `raw` re-synthesis this performs. `doc` is already known to
2945 /// have the `{info, messages:[...]}` shape (the caller checks this,
2946 /// matching `detect_source`'s own S9a check) before calling this.
2947 fn from_opencode_export_doc(doc: &Value) -> Result<Session> {
2948 let session_info = doc.get("info").cloned().filter(|v| !v.is_null());
2949 let messages_arr = doc
2950 .get("messages")
2951 .and_then(Value::as_array)
2952 .cloned()
2953 .unwrap_or_default();
2954
2955 let session_id = session_info
2956 .as_ref()
2957 .and_then(|si| si.get("id"))
2958 .and_then(Value::as_str)
2959 .unwrap_or("ses_unknown")
2960 .to_string();
2961 let project_id = session_info
2962 .as_ref()
2963 .and_then(|si| si.get("projectID"))
2964 .and_then(Value::as_str)
2965 .unwrap_or("global")
2966 .to_string();
2967
2968 // Re-synthesize one envelope line per record — see the doc comment
2969 // on `from_opencode_str` ("Export-document `raw`").
2970 let mut raw: Vec<String> = Vec::new();
2971 if let Some(si) = &session_info {
2972 raw.push(
2973 serde_json::json!({"key": ["session", project_id, session_id], "value": si})
2974 .to_string(),
2975 );
2976 }
2977
2978 let mut msgs: Vec<OcMsg> = Vec::new();
2979 for entry in &messages_arr {
2980 let Some(info) = entry.get("info") else {
2981 continue; // malformed message entry — no clean home, raw-only
2982 };
2983 let Some(id) = info.get("id").and_then(Value::as_str) else {
2984 continue;
2985 };
2986 let time_created = info
2987 .get("time")
2988 .and_then(|t| t.get("created"))
2989 .and_then(Value::as_i64)
2990 .unwrap_or(0);
2991 let parts: Vec<Value> = entry
2992 .get("parts")
2993 .and_then(Value::as_array)
2994 .cloned()
2995 .unwrap_or_default();
2996
2997 raw.push(
2998 serde_json::json!({"key": ["message", session_id, id], "value": info}).to_string(),
2999 );
3000 for p in &parts {
3001 let part_id = p.get("id").and_then(Value::as_str).unwrap_or("");
3002 raw.push(serde_json::json!({"key": ["part", id, part_id], "value": p}).to_string());
3003 }
3004
3005 msgs.push(OcMsg {
3006 id: id.to_string(),
3007 time_created,
3008 value: info.clone(),
3009 parts,
3010 });
3011 }
3012
3013 opencode_guard_against_silent_empty(true, &session_info, &msgs, &[])?;
3014 opencode_session_from_records(
3015 session_info,
3016 Vec::new(),
3017 msgs,
3018 raw,
3019 // Synthesized `raw` (§1.2's "Export-document raw" — one envelope
3020 // line re-derived per record, no real per-line source bytes to
3021 // measure) — matches the historical always-newline-terminated
3022 // behavior; see `Session::raw_trailing_newline`'s doc comment.
3023 true,
3024 // Export-document form: `raw` above is RE-SYNTHESIZED, one
3025 // envelope line derived per record — not the original document's
3026 // bytes (see this function's doc comment). `convert`'s
3027 // byte-identical claim must not fire on this diagonal.
3028 false,
3029 // PARITY-15: a pretty-printed export document is parsed WHOLE
3030 // (the caller's `if let Ok(doc) = serde_json::from_str(...)`) —
3031 // there's no per-line parse-loss concept here; a malformed
3032 // document fails that top-level parse and never reaches this
3033 // function at all.
3034 0,
3035 )
3036 }
3037
3038 /// P5-5 (design §2 module 21 `session.tree`, §2.1 D-6 "session.tree →
3039 /// core.session(tree-addressable transcript)"): materialize this
3040 /// session's linear [`Self::messages`] into a native in-place
3041 /// [`crate::session_tree::SessionTree`] — the bridge a caller uses the
3042 /// FIRST time it wants to run a tree operation (rewind/branch/label)
3043 /// against an otherwise-linear [`Session`]. `created_at_ms` stamps every
3044 /// synthesized node (see
3045 /// [`crate::session_tree::SessionTree::from_linear`]'s doc comment for
3046 /// why a single timestamp is used: the source linear messages carry no
3047 /// per-turn timestamp of their own here).
3048 ///
3049 /// This does not mutate `self` or persist anything — see
3050 /// the composition layer's session-store tree writer for persistence, and
3051 /// [`Self::apply_session_tree`] for the inverse bridge.
3052 pub fn to_session_tree(&self, created_at_ms: i64) -> crate::session_tree::SessionTree {
3053 crate::session_tree::SessionTree::from_linear(&self.messages, created_at_ms)
3054 }
3055
3056 /// P5-5: the inverse of [`Self::to_session_tree`] — overwrite
3057 /// [`Self::messages`] with `tree`'s ACTIVE branch's linear projection
3058 /// (C7's "tree-with-linear-projection": this is exactly what keeps every
3059 /// existing linear consumer — the agent loop, exporters — working
3060 /// unchanged after a tree operation runs). Nothing else on `self`
3061 /// (`meta`, `raw`, ...) is touched.
3062 ///
3063 /// **Fail-closed.** Propagates [`crate::session_tree::SessionTree::linear_projection`]'s
3064 /// `Err` rather than applying anything — a structurally-corrupt tree
3065 /// (a cycle, a dangling leaf, an active branch pointing at nothing) must
3066 /// error, not silently overwrite [`Self::messages`] with an empty `Vec`.
3067 /// `self` is left untouched on `Err` (the assignment only happens after
3068 /// the projection has already succeeded).
3069 pub fn apply_session_tree(&mut self, tree: &crate::session_tree::SessionTree) -> Result<()> {
3070 self.messages = tree.linear_projection()?;
3071 Ok(())
3072 }
3073}
3074
3075/// One opencode `message` record plus its `part` children, gathered from
3076/// EITHER read surface (envelope-form records or export-document
3077/// `{info, parts}` entries) before the shared per-record canonicalization
3078/// in [`opencode_session_from_records`].
3079struct OcMsg {
3080 id: String,
3081 time_created: i64,
3082 value: Value,
3083 parts: Vec<Value>,
3084}
3085
3086const OPENCODE_SUPERCODE_MESSAGE_POSITION: &str = "_supercode_message_position";
3087const OPENCODE_SUPERCODE_RESULT_POSITION: &str = "_supercode_result_position";
3088const OPENCODE_INTERNAL_ORIGINAL_POSITION: &str = "__supercode_original_position";
3089
3090/// Guard against the confirmed footgun: input that reached
3091/// [`Session::from_opencode_str`] non-empty but produced no
3092/// session/message/part record under either read surface returns `Err`
3093/// instead of a silently-empty `Ok(Session)`. A legitimately-empty session
3094/// (a real session record with zero messages, or a valid empty `messages`
3095/// array) is not an error — only genuinely unparseable content is.
3096fn opencode_guard_against_silent_empty(
3097 non_empty_input: bool,
3098 session_info: &Option<Value>,
3099 msgs: &[OcMsg],
3100 side_records: &[Value],
3101) -> Result<()> {
3102 let has_any_record = session_info.as_ref().is_some_and(|v| !v.is_null())
3103 || !msgs.is_empty()
3104 || !side_records.is_empty();
3105 if non_empty_input && !has_any_record {
3106 return Err(crate::Error::Other(
3107 "opencode input was recognized as an OpenCode source (envelope or \
3108 export-document form) but no session/message/part record could be parsed from \
3109 it — refusing to silently return an empty session"
3110 .to_string(),
3111 ));
3112 }
3113 Ok(())
3114}
3115
3116/// The shared per-record canonicalization for BOTH of
3117/// [`Session::from_opencode_str`]'s read surfaces (envelope form and
3118/// export-document form): frozen ordering, `SessionMeta` capture, the
3119/// compaction boundary pass, and the `User`/`Assistant` → `messages`
3120/// mapping via [`push_opencode_user`]/[`push_opencode_assistant`]. Fed the
3121/// same underlying `(session_info, side_records, msgs)` regardless of which
3122/// surface produced them, this produces byte-for-byte identical `messages`
3123/// — the invariant that makes `load(to_opencode_jsonl(S))` round-trip.
3124fn opencode_session_from_records(
3125 session_info: Option<Value>,
3126 side_records: Vec<Value>,
3127 mut msgs: Vec<OcMsg>,
3128 raw: Vec<String>,
3129 raw_trailing_newline: bool,
3130 raw_is_verbatim: bool,
3131 parse_error_lines: usize,
3132) -> Result<Session> {
3133 let mut meta = SessionMeta::new(SessionSource::OpenCode);
3134
3135 // `msg_index` is captured BEFORE the frozen-order sort below, mapping
3136 // each message id to its PRE-sort position — used only to resolve a
3137 // `tail_start_id` reference in the compaction-boundary pass further
3138 // down. In every real opencode session (either surface) records
3139 // already arrive/are listed in creation order, so pre- and post-sort
3140 // positions coincide; this mirrors the original envelope-only
3141 // implementation's behavior exactly (not a new invariant introduced by
3142 // sharing this code across both surfaces).
3143 let msg_index: HashMap<String, usize> = msgs
3144 .iter()
3145 .enumerate()
3146 .map(|(i, m)| (m.id.clone(), i))
3147 .collect();
3148
3149 // Frozen order (§1.2): messages by (time.created, id); each
3150 // message's parts by id.
3151 msgs.sort_by(|a, b| {
3152 a.time_created
3153 .cmp(&b.time_created)
3154 .then_with(|| a.id.cmp(&b.id))
3155 });
3156 for m in &mut msgs {
3157 m.parts.sort_by(|a, b| {
3158 let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
3159 let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
3160 ai.cmp(bi)
3161 });
3162 }
3163
3164 meta.opencode_headers
3165 .push(session_info.clone().unwrap_or(Value::Null));
3166 meta.opencode_headers.extend(side_records);
3167 if let Some(si) = &session_info {
3168 capture_opencode_session_info(si, &mut meta)?;
3169 }
3170
3171 // Compaction boundary (§2.1/§2.2 S3): the LATEST `tail_start_id`
3172 // seen — mirrors pi's `kept_from_pos` discipline (there is only one
3173 // active path in opencode's own linear message list, so no branch
3174 // walk is needed the way pi's tree requires).
3175 let mut tail_start_pos: Option<usize> = None;
3176 for m in &msgs {
3177 for p in &m.parts {
3178 if p.get("type").and_then(Value::as_str) == Some("compaction") {
3179 if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
3180 if let Some(&tp) = msg_index.get(t) {
3181 tail_start_pos = Some(tail_start_pos.map_or(tp, |cur| cur.max(tp)));
3182 }
3183 }
3184 }
3185 }
3186 }
3187
3188 let mut messages = Vec::new();
3189 let mut first_system_seen = false;
3190 for (pos, m) in msgs.iter().enumerate() {
3191 let before = messages.len();
3192 match m.value.get("role").and_then(Value::as_str) {
3193 // B4: a `User` message that's actually
3194 // `append_synthesized_opencode_messages`'s own re-materialized
3195 // Claude `system` record (one `synthetic: true` text part
3196 // carrying the supercode marker key — see
3197 // `opencode_claude_system_subtype`'s doc comment) restores
3198 // `Role::System`, not a genuine user turn.
3199 Some("user") => match opencode_claude_system_subtype(&m.parts) {
3200 Some(subtype) => {
3201 push_opencode_claude_system(&m.value, &m.parts, subtype, &mut messages)
3202 }
3203 None => push_opencode_user(
3204 &m.value,
3205 &m.parts,
3206 &mut messages,
3207 &mut meta,
3208 &mut first_system_seen,
3209 ),
3210 },
3211 Some("assistant") => {
3212 push_opencode_assistant(&m.value, &m.parts, &mut messages, &mut meta)
3213 }
3214 // Unrecognized/missing role — raw-only survival;
3215 // `audit::Corpus::OpenCode` scores this as Unmodeled.
3216 _ => {}
3217 }
3218 if let Some(original_position) = m
3219 .value
3220 .get(OPENCODE_SUPERCODE_MESSAGE_POSITION)
3221 .and_then(Value::as_u64)
3222 {
3223 if let Some(message) = messages[before..]
3224 .iter_mut()
3225 .find(|message| message.role != Role::Tool)
3226 {
3227 message.metadata.insert(
3228 OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
3229 original_position.to_string(),
3230 );
3231 }
3232 }
3233 for msg in &mut messages[before..] {
3234 let is_summary = msg.metadata.get("is_summary").map(String::as_str) == Some("true");
3235 if !is_summary {
3236 if let Some(tsp) = tail_start_pos {
3237 if pos < tsp {
3238 msg.metadata
3239 .insert("compacted_out".to_string(), "true".to_string());
3240 }
3241 }
3242 }
3243 }
3244 }
3245
3246 let marked_slots = messages
3247 .iter()
3248 .enumerate()
3249 .filter_map(|(index, message)| {
3250 message
3251 .metadata
3252 .contains_key(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3253 .then_some(index)
3254 })
3255 .collect::<Vec<_>>();
3256 if !marked_slots.is_empty() {
3257 // A spliced OpenCode export can contain an unmarked native prefix
3258 // followed by a marked synthesized tail. Reorder only among the
3259 // marked slots so the tail never jumps in front of its raw prefix.
3260 let mut marked_messages = marked_slots
3261 .iter()
3262 .map(|index| messages[*index].clone())
3263 .collect::<Vec<_>>();
3264 marked_messages.sort_by_key(|message| {
3265 message
3266 .metadata
3267 .get(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3268 .and_then(|position| position.parse::<usize>().ok())
3269 .unwrap_or(usize::MAX)
3270 });
3271 for (slot, message) in marked_slots.into_iter().zip(marked_messages) {
3272 messages[slot] = message;
3273 }
3274 for message in &mut messages {
3275 message.metadata.remove(OPENCODE_INTERNAL_ORIGINAL_POSITION);
3276 }
3277 }
3278 ensure_tool_results_paired(&mut messages);
3279 let imported_message_count = Some(messages.len());
3280 Ok(Session {
3281 meta,
3282 messages,
3283 subagents: Vec::new(),
3284 raw,
3285 raw_trailing_newline,
3286 imported_message_count,
3287 raw_is_verbatim,
3288 parse_error_lines,
3289 load_residue: Vec::new(),
3290 })
3291}
3292
3293/// Resolve each opencode subagent (`task`) child session's
3294/// `meta.parent_tool_use_id` from its parent's own `task` tool part
3295/// `callID` (`docs/interop/opencode-pi-spec.md` §2.1 — the analogue of
3296/// Claude Code's `agentId`/`parent_tool_use_id` linkage,
3297/// `opencode-fields.md` `task.ts:145,171-176`).
3298///
3299/// Nesting itself needs no opencode-specific pass:
3300/// `capture_opencode_session_info` already mirrors `SessionInfo.parentID`
3301/// into `lineage["parent_thread_id"]` (the same key Codex subagents use),
3302/// so the existing generic [`Session::reconstruct_tree`] nests these
3303/// sessions correctly on its own. Call this FIRST — it only reads
3304/// `session_id`/`messages`/metadata, never reorders `sessions` — then feed
3305/// the same `Vec` to `reconstruct_tree`.
3306pub fn resolve_opencode_parent_tool_use_ids(sessions: &mut [Session]) {
3307 let ids: Vec<Option<String>> = sessions.iter().map(|s| s.meta.session_id.clone()).collect();
3308 for i in 0..sessions.len() {
3309 let child_id = sessions[i].meta.session_id.clone();
3310 let parent_id = sessions[i].meta.lineage.get("parent_session_id").cloned();
3311 let (Some(child_id), Some(parent_id)) = (child_id, parent_id) else {
3312 continue;
3313 };
3314 let Some(parent_idx) = ids
3315 .iter()
3316 .position(|id| id.as_deref() == Some(parent_id.as_str()))
3317 else {
3318 continue;
3319 };
3320 for m in &sessions[parent_idx].messages {
3321 for (k, v) in &m.metadata {
3322 if let Some(call_id) = k.strip_prefix("oc_task_child_session_id__") {
3323 if v == &child_id {
3324 sessions[i].meta.parent_tool_use_id = Some(call_id.to_string());
3325 }
3326 }
3327 }
3328 }
3329 }
3330}
3331
3332/// D7: repair tool-result/tool-call ORDERING for the Claude Code loader.
3333///
3334/// Real Claude Code transcripts are well-formed by `uuid`/`parentUuid` tree
3335/// structure but are NOT guaranteed to be well-formed in raw file order: async
3336/// tool-result delivery (or an edited turn) can write a `tool_result` `user`
3337/// line BEFORE the assistant `tool_use` line that owns it, even though the
3338/// parent/child tree itself is fine. The active-branch projection restores
3339/// parent-before-child order, but a result can still trail a later assistant
3340/// turn instead of sitting next to the assistant that owns it. Both OpenAI-
3341/// and Anthropic-shaped wire APIs reject either shape on `resume`. Measured
3342/// on a real corpus: 21/450 sessions (4.7%), 89 inverted pairs.
3343///
3344/// This reorders `messages` so every OWNED `Role::Tool` result (its
3345/// `tool_call_id` matches a `tool_calls` entry on some `Role::Assistant`
3346/// message anywhere in the list) sits immediately after the `Role::Assistant`
3347/// message that owns it, while leaving every other message's relative order
3348/// untouched. Orphan tool results — no matching call anywhere in the list —
3349/// are left in their ORIGINAL position, untouched; they are never moved. It
3350/// is a pure reorder: same message count, same multiset of messages, in/out.
3351///
3352/// Tool-call ids (`toolu_*`) are globally unique within a Claude Code session
3353/// — each appears exactly once as a call and once as its result — so a
3354/// simple id -> owning-assistant map is sufficient; no special-casing is
3355/// needed for sidechain (`isSidechain`) messages, since `push_claude_*`
3356/// already pushes those inline with their own distinct ids.
3357///
3358/// Results whose matching call is missing entirely (no owner found) are left
3359/// in place untouched — `ensure_tool_results_paired` (which runs right after
3360/// this) is responsible for synthesizing a placeholder result for any call
3361/// that ends up unanswered; this pass never drops or fabricates anything.
3362fn reorder_tool_results_after_calls(messages: &mut Vec<ChatMessage>) {
3363 // 0. First pass: which tool_call_ids are actually "owned" — emitted by
3364 // some assistant message anywhere in the list — and the position of
3365 // that owning assistant. Owned as `String` (not borrowed) so this map
3366 // can outlive the later `messages.drain(..)`.
3367 let mut owner_positions: HashMap<String, usize> = HashMap::new();
3368 for (index, m) in messages.iter().enumerate() {
3369 if m.role == Role::Assistant {
3370 for c in m.tool_calls() {
3371 if !c.id.is_empty() {
3372 owner_positions.entry(c.id.clone()).or_insert(index);
3373 }
3374 }
3375 }
3376 }
3377
3378 // Fast, cheap detection of "nothing to do": every owned result must be
3379 // in the contiguous tool-result block immediately following its owning
3380 // assistant. Checking only result-before-owner inversions is insufficient
3381 // after Claude's active-branch projection: that projection can put the
3382 // owner first while leaving its result behind a later assistant turn.
3383 // Mere orphans never set this flag. A canonical session returns with
3384 // `messages` byte-for-byte unchanged, mirroring
3385 // `ensure_tool_results_paired`'s own no-op guard.
3386 let mut contiguous_owner = None;
3387 let needs_reorder =
3388 messages
3389 .iter()
3390 .enumerate()
3391 .any(|(message_index, message)| match message.role {
3392 Role::Assistant => {
3393 contiguous_owner = Some(message_index);
3394 false
3395 }
3396 Role::Tool => match message
3397 .tool_call_id
3398 .as_deref()
3399 .and_then(|id| owner_positions.get(id))
3400 .copied()
3401 {
3402 Some(owner) => Some(owner) != contiguous_owner,
3403 None => {
3404 // An orphan or unlinked tool message interrupts the
3405 // owner's contiguous result block but never moves by
3406 // itself.
3407 contiguous_owner = None;
3408 false
3409 }
3410 },
3411 _ => {
3412 contiguous_owner = None;
3413 false
3414 }
3415 });
3416 if !needs_reorder {
3417 return;
3418 }
3419
3420 // 1. Second pass: route messages into the "spine" (everything that stays
3421 // at its own position — non-tool messages AND orphan tool results)
3422 // versus owned tool results (pulled out, to be reattached right after
3423 // their owner). Record, for each spine index that's an assistant, the
3424 // set of tool_call_ids it owns.
3425 let mut spine: Vec<ChatMessage> = Vec::with_capacity(messages.len());
3426 let mut call_owner: HashMap<String, usize> = HashMap::new();
3427 // Buffer of (original_position, message) for every OWNED tool result,
3428 // built alongside the spine; a result can reference a call emitted later
3429 // in file order, so owner spine-index is resolved in a later step once
3430 // `call_owner` is complete.
3431 let mut owned_results: Vec<(usize, ChatMessage)> = Vec::new();
3432
3433 let drained: Vec<ChatMessage> = std::mem::take(messages);
3434 for (orig_pos, msg) in drained.into_iter().enumerate() {
3435 if msg.role == Role::Tool {
3436 let is_owned = msg
3437 .tool_call_id
3438 .as_deref()
3439 .map(|id| !id.is_empty() && owner_positions.contains_key(id))
3440 .unwrap_or(false);
3441 if is_owned {
3442 owned_results.push((orig_pos, msg));
3443 continue;
3444 }
3445 // Orphan: no matching call anywhere. Treat exactly like a
3446 // non-tool message for placement — it joins the spine at its
3447 // current position and is never moved.
3448 spine.push(msg);
3449 continue;
3450 }
3451 if msg.role == Role::Assistant {
3452 let spine_idx = spine.len();
3453 for c in msg.tool_calls() {
3454 if !c.id.is_empty() {
3455 call_owner.entry(c.id.clone()).or_insert(spine_idx);
3456 }
3457 }
3458 }
3459 spine.push(msg);
3460 }
3461
3462 // 2. Resolve each owned result's owner spine-index now that `call_owner`
3463 // is complete, then bucket results by owner spine-index. Every result
3464 // here was routed as "owned" because its id was found in `owned_ids`,
3465 // which was built from the exact same `tool_calls()` scan that
3466 // populates `call_owner` below, so the lookup is guaranteed to hit.
3467 let mut buckets: HashMap<usize, Vec<(usize, ChatMessage)>> = HashMap::new();
3468 for (orig_pos, msg) in owned_results.into_iter() {
3469 let id = msg
3470 .tool_call_id
3471 .as_deref()
3472 .filter(|id| !id.is_empty())
3473 .expect("routed as owned, so tool_call_id must be a non-empty owned id");
3474 let idx = *call_owner
3475 .get(id)
3476 .expect("owned id must have an owning assistant in call_owner");
3477 buckets.entry(idx).or_default().push((orig_pos, msg));
3478 }
3479 // Keep each bucket's results in their original relative file order.
3480 for v in buckets.values_mut() {
3481 v.sort_by_key(|(pos, _)| *pos);
3482 }
3483
3484 // 3. Rebuild: emit each spine message (which now includes orphans at
3485 // their original position, untouched) in order; immediately after
3486 // emitting an assistant message that owns one or more tool results,
3487 // emit its owned results, in original relative order.
3488 let mut out: Vec<ChatMessage> = Vec::with_capacity(spine.len() + buckets.len());
3489 for (idx, msg) in spine.into_iter().enumerate() {
3490 out.push(msg);
3491 if let Some(results) = buckets.remove(&idx) {
3492 for (_, r) in results {
3493 out.push(r);
3494 }
3495 }
3496 }
3497 *messages = out;
3498}
3499
3500/// Guarantee every assistant `tool_calls` entry is answered by a following tool
3501/// result. Interrupted/aborted turns leave a tool call with no result, which
3502/// many chat-completions endpoints reject when the conversation is replayed.
3503/// We insert a synthetic placeholder result immediately after the assistant
3504/// turn so the transcript stays valid for continuation. (Orphan results — a
3505/// tool message with no preceding call — do not occur in practice and are left
3506/// untouched.)
3507fn ensure_tool_results_paired(messages: &mut Vec<ChatMessage>) {
3508 let answered: HashSet<String> = messages
3509 .iter()
3510 .filter(|m| m.role == Role::Tool)
3511 .filter_map(|m| m.tool_call_id.clone())
3512 .collect();
3513
3514 // Nothing missing? Leave the vector byte-for-byte unchanged.
3515 let any_missing = messages.iter().any(|m| {
3516 m.tool_calls()
3517 .iter()
3518 .any(|c| !c.id.is_empty() && !answered.contains(&c.id))
3519 });
3520 if !any_missing {
3521 return;
3522 }
3523
3524 let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len() + 4);
3525 for msg in messages.drain(..) {
3526 let synth: Vec<ChatMessage> = msg
3527 .tool_calls()
3528 .iter()
3529 .filter(|c| !c.id.is_empty() && !answered.contains(&c.id))
3530 .map(|c| {
3531 let mut m = ChatMessage::tool_result(
3532 c.id.clone(),
3533 c.function.name.clone(),
3534 "[no tool result recorded — turn interrupted]".to_string(),
3535 );
3536 // TR-10: an interrupted call never executed to completion —
3537 // never a candidate for `ReductionKind::ToolInputElided`
3538 // (the "still-pending calls are never input-elided"
3539 // boundary).
3540 crate::mark_tool_error(&mut m);
3541 m
3542 })
3543 .collect();
3544 out.push(msg);
3545 out.extend(synth);
3546 }
3547 *messages = out;
3548}
3549
3550/// Whether `msg` is excluded from every replay/export path — the frozen
3551/// cross-format discipline (`docs/interop/opencode-pi-spec.md` §2.1/§2.2/§6):
3552/// a message marked `compacted_out` (pre-compaction history a source harness
3553/// itself no longer replays) or `pi_exclude_from_context` (a pi `!!` bash
3554/// escape) must never reach `to_jsonl`/`to_jsonl_spliced` for ANY target
3555/// format, not just the one that produced the marker — so a translated
3556/// compacted session replays the same sliced context the source harness
3557/// would, instead of double-including history plus its own summary.
3558fn is_replay_excluded(msg: &ChatMessage) -> bool {
3559 msg.metadata.get("compacted_out").map(String::as_str) == Some("true")
3560 || msg
3561 .metadata
3562 .get("pi_exclude_from_context")
3563 .map(String::as_str)
3564 == Some("true")
3565}
3566
3567// ---- detection ------------------------------------------------------------
3568
3569fn detect_source(text: &str) -> Option<SessionSource> {
3570 // OpenCode export-document form (`docs/interop/opencode-pi-spec.md`
3571 // §1.2/S9a): `{info: SessionInfo, messages: [...]}` — a single
3572 // pretty-printed, MULTI-LINE JSON document, unlike every other format
3573 // this crate reads. It cannot be recognized by the per-line loop below
3574 // (no individual line of a pretty-printed document is itself valid
3575 // JSON), so it gets its own whole-text parse attempt up front. Cheap to
3576 // attempt: a real JSONL file (many newline-separated objects) fails this
3577 // parse immediately (trailing-data error) and falls through unaffected.
3578 if let Ok(v) = serde_json::from_str::<Value>(text.trim()) {
3579 if v.get("conversation").and_then(Value::as_array).is_some()
3580 && (v.get("working_dir").is_some() || v.get("workingDir").is_some())
3581 {
3582 return Some(SessionSource::Goose);
3583 }
3584 if v.get("info").is_some() && v.get("messages").and_then(Value::as_array).is_some() {
3585 return Some(SessionSource::OpenCode);
3586 }
3587 }
3588 for line in non_empty_lines(text) {
3589 // Tolerate a corrupt/truncated line (e.g. a partial first line) rather
3590 // than abandoning detection — the loaders themselves skip bad lines, so
3591 // bailing here would silently misroute an otherwise-valid Codex file.
3592 let Ok(v) = serde_json::from_str::<Value>(line) else {
3593 continue;
3594 };
3595 // OpenCode's frozen envelope form (§1.2): `{"key":[...],"value":...}`,
3596 // one record per line — the synthesized raw-capture unit for the
3597 // JSON-tree/SQLite generations alike. No other format's lines carry
3598 // both a top-level `key` ARRAY and a `value` field, so this is
3599 // unambiguous against Codex/Pi/Claude Code.
3600 if v.get("key").and_then(Value::as_array).is_some() && v.get("value").is_some() {
3601 return Some(SessionSource::OpenCode);
3602 }
3603 // Codex envelopes always carry a `payload`; Claude Code lines never do.
3604 if v.get("payload").is_some() {
3605 return Some(SessionSource::Codex);
3606 }
3607 // Gemini CLI starts with an untyped session header. Its project hash
3608 // and timestamps distinguish it from Claude Code records that also
3609 // carry `sessionId`.
3610 if v.get("sessionId").and_then(Value::as_str).is_some()
3611 && (v.get("projectHash").is_some()
3612 || v.get("startTime").is_some()
3613 || v.get("lastUpdated").is_some())
3614 && v.get("type").is_none()
3615 {
3616 return Some(SessionSource::Gemini);
3617 }
3618 // Grok's resumable `chat_history.jsonl` stores the role/type and
3619 // content directly on each record. Claude Code uses a nested
3620 // `message` envelope for the overlapping `user`/`assistant` tags.
3621 let tag = v.get("type").and_then(Value::as_str);
3622 if tag == Some("gemini") && v.get("content").is_some() {
3623 return Some(SessionSource::Gemini);
3624 }
3625 if v.get("message").is_none()
3626 && v.get("uuid").is_none()
3627 && v.get("sessionId").is_none()
3628 && matches!(
3629 tag,
3630 Some(
3631 "system"
3632 | "user"
3633 | "assistant"
3634 | "tool_result"
3635 | "reasoning"
3636 | "backend_tool_call"
3637 )
3638 )
3639 && (v.get("content").is_some()
3640 || v.get("tool_calls").is_some()
3641 || v.get("tool_call_id").is_some()
3642 || v.get("encrypted_content").is_some()
3643 || v.get("kind").is_some())
3644 {
3645 return Some(SessionSource::Grok);
3646 }
3647 // Pi's `SessionHeader` (line 1, always `type:"session"`): a bare `id`
3648 // (the session id) with no `message`/`uuid` — Claude Code's own
3649 // `type`-bearing lines always carry one or the other, never a
3650 // `type:"session"` header shape (`docs/interop/research/pi-fields.md`
3651 // §1).
3652 if v.get("type").and_then(Value::as_str) == Some("session")
3653 && v.get("id").and_then(Value::as_str).is_some()
3654 && v.get("message").is_none()
3655 && v.get("uuid").is_none()
3656 {
3657 return Some(SessionSource::Pi);
3658 }
3659 if v.get("type").is_some() || v.get("message").is_some() {
3660 return Some(SessionSource::ClaudeCode);
3661 }
3662 }
3663 None
3664}
3665
3666/// Which on-disk OpenCode storage surface is present under a data root
3667/// (`docs/interop/opencode-pi-spec.md` §1.2/S9a): SQLite `opencode*.db` (or
3668/// `$OPENCODE_DB`), legacy JSON tree generation B, or legacy JSON tree
3669/// generation A. This is a **filesystem classifier only** — it answers
3670/// "which generation is this?" for a corpus-discovery tool; it does not
3671/// itself read/parse the surface. See [`Session::from_opencode_str`]'s docs
3672/// for the envelope form any of these three surfaces synthesizes into, and
3673/// [`Session::from_opencode_sqlite`] (PARITY-3/PARITY-16) for the `rusqlite`
3674/// reader that reconstructs that same envelope form from `Sqlite`'s rows —
3675/// `JsonTreeA`/`JsonTreeB` remain classifier-only (their `session_diff`
3676/// round-trips via the JSON store per upstream's own behavior even on a
3677/// SQLite install, so nothing is silently lost by not reading the legacy
3678/// trees directly).
3679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3680pub enum OpenCodeStorageSurface {
3681 /// SQLite `<data>/opencode*.db` (or `$OPENCODE_DB` override). Read by
3682 /// [`Session::from_opencode_sqlite`] / [`opencode_sqlite_store_stats`] /
3683 /// [`opencode_sqlite_corpus_envelope_text`].
3684 Sqlite,
3685 /// Legacy JSON tree, generation B: `<data>/storage/{session,message,part,session_diff}/…`,
3686 /// marker file `storage/migration`.
3687 JsonTreeB,
3688 /// Legacy JSON tree, generation A: `<data>/project/<slug>/storage/session/{info,message,part}/…`.
3689 JsonTreeA,
3690}
3691
3692/// Probe `data_root` (e.g. `~/.local/share/opencode`) for the OpenCode
3693/// storage surface present, per the discovery rules frozen in
3694/// `docs/interop/opencode-pi-spec.md` §1.2/S9a: `$OPENCODE_DB` wins outright;
3695/// otherwise glob `opencode*.db` (not just `opencode.db` — dev/beta channels
3696/// suffix the filename, `database.ts:43-55`); otherwise look for the JSON
3697/// tree generation-B marker (`storage/migration`); otherwise generation-A's
3698/// `project/` subtree. Returns `None` if nothing is found.
3699pub fn detect_opencode_storage_surface(
3700 data_root: &Path,
3701) -> Option<(OpenCodeStorageSurface, PathBuf)> {
3702 if let Ok(p) = std::env::var("OPENCODE_DB") {
3703 let pb = PathBuf::from(p);
3704 if pb.is_file() {
3705 return Some((OpenCodeStorageSurface::Sqlite, pb));
3706 }
3707 }
3708 if let Ok(entries) = std::fs::read_dir(data_root) {
3709 // D8: `std::fs::read_dir`'s iteration order is filesystem-dependent,
3710 // NOT deterministic — a store with both a default-channel
3711 // `opencode.db` and a channel-suffixed `opencode-dev.db` (S9a: both
3712 // are legal, e.g. after switching install channels) previously
3713 // returned "whichever the OS happened to list first", which could
3714 // differ between two `inspect`/`audit`/`convert` runs against the
3715 // exact same directory. Collect every `opencode*.db` candidate and
3716 // pick deterministically: the exact `opencode.db` name wins if
3717 // present (the default/most-common channel); otherwise the
3718 // lexicographically-smallest match, so repeated runs always agree.
3719 let mut candidates: Vec<PathBuf> = entries
3720 .flatten()
3721 .map(|entry| entry.path())
3722 .filter(|p| {
3723 p.file_name()
3724 .and_then(|n| n.to_str())
3725 .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
3726 })
3727 .collect();
3728 candidates.sort();
3729 if let Some(exact) = candidates
3730 .iter()
3731 .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("opencode.db"))
3732 {
3733 return Some((OpenCodeStorageSurface::Sqlite, exact.clone()));
3734 }
3735 if let Some(first) = candidates.into_iter().next() {
3736 return Some((OpenCodeStorageSurface::Sqlite, first));
3737 }
3738 }
3739 let storage = data_root.join("storage");
3740 if storage.join("migration").is_file() {
3741 return Some((OpenCodeStorageSurface::JsonTreeB, storage));
3742 }
3743 let project_dir = data_root.join("project");
3744 if project_dir.is_dir() {
3745 return Some((OpenCodeStorageSurface::JsonTreeA, project_dir));
3746 }
3747 None
3748}
3749
3750/// STRICT-VERBATIM line split for `raw` capture (IX-1) — the counterpart to
3751/// [`non_empty_lines`]'s blank-skipping/trimming PARSE view.
3752///
3753/// Splits only on `\n` (never treats `\r\n` specially), so a CRLF source's
3754/// `\r` survives as part of the returned line's own content; blank lines and
3755/// trailing-whitespace-only lines are kept verbatim rather than dropped or
3756/// trimmed. This is what makes `Session.raw` — populated from this at every
3757/// raw-capture site — byte-for-byte faithful to the source for ANY input, not
3758/// just well-formed LF JSONL with no blank lines.
3759///
3760/// Returns `(lines, ends_with_newline)`. `lines.join("\n")` alone cannot
3761/// distinguish a source that ended with a trailing newline from one that
3762/// didn't (both split into the same line list), so `ends_with_newline`
3763/// records that fact out-of-band — [`join_lines_verbatim`] is the exact
3764/// inverse, given both. `text == ""` yields `(vec![], false)`: an empty
3765/// source has zero lines, not one blank line.
3766fn split_lines_verbatim(text: &str) -> (Vec<&str>, bool) {
3767 if text.is_empty() {
3768 return (Vec::new(), false);
3769 }
3770 let ends_with_newline = text.ends_with('\n');
3771 let body = if ends_with_newline {
3772 &text[..text.len() - 1]
3773 } else {
3774 text
3775 };
3776 (body.split('\n').collect(), ends_with_newline)
3777}
3778
3779/// The exact inverse of [`split_lines_verbatim`]: reconstruct the original
3780/// source bytes from its verbatim lines plus the trailing-newline flag.
3781fn join_lines_verbatim(lines: &[String], ends_with_newline: bool) -> String {
3782 let mut out = lines.join("\n");
3783 if ends_with_newline {
3784 out.push('\n');
3785 }
3786 out
3787}
3788
3789// ---- OpenCode SQLite (PARITY-3/PARITY-4/PARITY-16) -------------------------
3790//
3791// Reads a real `opencode*.db` store directly with `rusqlite` (bundled
3792// SQLite — no system library dependency) and reconstructs the SAME envelope
3793// form `{"key":[...],"value":...}` that `Session::from_opencode_str` already
3794// parses for the JSON-tree surfaces (`docs/interop/opencode-pi-spec.md`
3795// §1.2/S9c). Row -> envelope reconstruction mirrors the real app's own
3796// `session.ts` `fromRow` (session table: columnar fields recombined into the
3797// camelCase `SessionInfo` shape) with ONE deliberate divergence: `revert` is
3798// carried as the RAW column value, not upstream's own `fromRow`
3799// reconstruction — which silently drops the V2 `Revert.State` schema's extra
3800// `files` field (`session.ts:70-76`, `revert.ts:18-24`, spec finding S9c).
3801// `message`/`part` rows are simpler: their `data` column is already the V1
3802// `Info`/`Part` JSON minus the id columns hoisted out by the schema
3803// (`session/sql.ts:68-98`), so reconstruction is just re-injecting
3804// `id`/`sessionID`(/`messageID`).
3805
3806/// First 16 bytes of every SQLite database file — the format's own magic,
3807/// independent of file extension.
3808const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
3809
3810/// Whether `path` should be routed to the OpenCode SQLite loader instead of
3811/// the UTF-8 text path (PARITY-16): true when the file's first 16 bytes are
3812/// the SQLite magic, OR its extension is `.db` — the latter so a
3813/// corrupted/truncated `opencode*.db` still gets `rusqlite`'s own "not a
3814/// database" diagnostic (PARITY-3 AC03) instead of a confusing UTF-8 error.
3815/// A non-existent path is NOT considered SQLite here — the missing-file
3816/// diagnostic in that case comes from the normal load path (`with_context`
3817/// at the CLI call sites), which already names the path clearly.
3818pub fn looks_like_sqlite(path: &Path) -> bool {
3819 if !path.is_file() {
3820 return false;
3821 }
3822 if path.extension().and_then(|e| e.to_str()) == Some("db") {
3823 return true;
3824 }
3825 use std::io::Read;
3826 let Ok(mut f) = std::fs::File::open(path) else {
3827 return false;
3828 };
3829 let mut buf = [0u8; 16];
3830 f.read_exact(&mut buf).is_ok() && &buf == SQLITE_MAGIC
3831}
3832
3833/// Read `path` as UTF-8 text, translating a non-UTF-8 failure into a clear,
3834/// format-aware diagnostic (PARITY-16) instead of the raw "stream did not
3835/// contain valid UTF-8" `io::Error` — named path, and what supercode DOES
3836/// accept there. Binary SQLite input never reaches this function: callers
3837/// check [`looks_like_sqlite`] first and route to
3838/// [`Session::from_opencode_sqlite`] instead.
3839fn read_utf8_or_diagnose(path: &Path) -> Result<String> {
3840 let bytes = std::fs::read(path)?;
3841 String::from_utf8(bytes).map_err(|_| {
3842 crate::Error::Other(format!(
3843 "{} is not valid UTF-8 text, and is not a recognized OpenCode SQLite store \
3844 (no `SQLite format 3` header) — supercode reads Claude Code / Codex / Pi / \
3845 OpenCode session logs as UTF-8 JSONL, or an OpenCode `opencode*.db` SQLite file",
3846 path.display()
3847 ))
3848 })
3849}
3850
3851/// Read only the portion of a JSONL transcript a bounded scrollback can use.
3852///
3853/// The first record carries durable session metadata (especially for Codex),
3854/// while the trailing window carries the messages the viewport will render.
3855/// Full lossless loaders intentionally continue to read every byte.
3856fn read_display_jsonl(
3857 path: &Path,
3858 message_limit: usize,
3859) -> Result<(Option<SessionSource>, String, bool)> {
3860 const MIN_TAIL_BYTES: u64 = 4 * 1024 * 1024;
3861 const MAX_TAIL_BYTES: u64 = 64 * 1024 * 1024;
3862 const BYTES_PER_MESSAGE: u64 = 16 * 1024;
3863
3864 let mut first = String::new();
3865 BufReader::new(std::fs::File::open(path)?).read_line(&mut first)?;
3866 let source = detect_source(&first);
3867 if !matches!(
3868 source,
3869 Some(SessionSource::ClaudeCode | SessionSource::Codex | SessionSource::Gemini)
3870 ) {
3871 let text = read_utf8_or_diagnose(path)?;
3872 return Ok((detect_source(&text), text, false));
3873 }
3874
3875 let mut file = std::fs::File::open(path)?;
3876 let file_len = file.metadata()?.len();
3877 let requested = (message_limit.max(1) as u64)
3878 .saturating_mul(BYTES_PER_MESSAGE)
3879 .clamp(MIN_TAIL_BYTES, MAX_TAIL_BYTES);
3880 if file_len <= requested {
3881 let text = read_utf8_or_diagnose(path)?;
3882 return Ok((source, text, false));
3883 }
3884
3885 let start = file_len - requested;
3886 file.seek(SeekFrom::Start(start))?;
3887 let mut bytes = Vec::with_capacity(requested as usize);
3888 file.read_to_end(&mut bytes)?;
3889 // The window normally starts in the middle of a JSON record. Discard that
3890 // partial prefix so every line passed to the existing parsers is valid.
3891 if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
3892 bytes.drain(..=newline);
3893 }
3894 let mut tail = String::from_utf8(bytes).map_err(|_| {
3895 crate::Error::Other(format!(
3896 "{} contains non-UTF-8 data in its display window",
3897 path.display()
3898 ))
3899 })?;
3900 if start > 0 {
3901 // Always recover the human boundary immediately before the byte
3902 // window, even when the window already contains newer prompts. A
3903 // long run of large tool records can otherwise make the numeric tail
3904 // begin in one old turn while its only retained users belong to much
3905 // newer turns. The display projector then (correctly) hides the
3906 // orphaned activity, making pagination appear inert.
3907 //
3908 // Search backward independently of the render window and retain only
3909 // two complete human JSONL records. The search grows geometrically but
3910 // never reads more than the same 64 MiB hard ceiling as the display
3911 // window, and none of the intervening tool bytes are normalized or
3912 // sent over RPC.
3913 let max_search_bytes = start.min(MAX_TAIL_BYTES);
3914 let mut search_bytes = requested.min(max_search_bytes);
3915 let anchors = loop {
3916 let search_start = start - search_bytes;
3917 file.seek(SeekFrom::Start(search_start))?;
3918 let mut search = Vec::with_capacity(search_bytes as usize);
3919 (&mut file).take(search_bytes).read_to_end(&mut search)?;
3920 if search_start > 0 {
3921 if let Some(newline) = search.iter().position(|byte| *byte == b'\n') {
3922 search.drain(..=newline);
3923 } else {
3924 search.clear();
3925 }
3926 }
3927 // `start` normally cuts the record whose remainder the tail
3928 // reader discarded. Exclude its incomplete prefix here too.
3929 if let Some(newline) = search.iter().rposition(|byte| *byte == b'\n') {
3930 search.truncate(newline + 1);
3931 } else {
3932 search.clear();
3933 }
3934 let anchors = std::str::from_utf8(&search)
3935 .ok()
3936 .map(|search| {
3937 let mut found = search
3938 .lines()
3939 .rev()
3940 .filter(|line| native_display_human_line(line, source))
3941 .take(2)
3942 .map(str::to_string)
3943 .collect::<Vec<_>>();
3944 found.reverse();
3945 found
3946 })
3947 .unwrap_or_default();
3948 if anchors.len() >= 2 || search_start == 0 || search_bytes == max_search_bytes {
3949 break anchors;
3950 }
3951 search_bytes = search_bytes.saturating_mul(2).min(max_search_bytes);
3952 };
3953 if !anchors.is_empty() {
3954 tail = format!("{}\n{tail}", anchors.join("\n"));
3955 }
3956 }
3957 let text = if matches!(source, Some(SessionSource::Codex | SessionSource::Gemini)) {
3958 format!("{first}{tail}")
3959 } else {
3960 tail
3961 };
3962 Ok((source, text, true))
3963}
3964
3965fn native_display_human_line(line: &str, source: Option<SessionSource>) -> bool {
3966 if !line
3967 .as_bytes()
3968 .windows(6)
3969 .any(|window| window == b"\"user\"")
3970 {
3971 return false;
3972 }
3973 let Ok(value) = serde_json::from_str::<Value>(line) else {
3974 return false;
3975 };
3976 match source {
3977 Some(SessionSource::Codex) => {
3978 value.get("type").and_then(Value::as_str) == Some("response_item")
3979 && value
3980 .get("payload")
3981 .and_then(|payload| payload.get("type"))
3982 .and_then(Value::as_str)
3983 == Some("message")
3984 && value
3985 .get("payload")
3986 .and_then(|payload| payload.get("role"))
3987 .and_then(Value::as_str)
3988 == Some("user")
3989 }
3990 Some(SessionSource::ClaudeCode) => {
3991 value.get("type").and_then(Value::as_str) == Some("user")
3992 && value
3993 .get("message")
3994 .and_then(|message| message.get("content"))
3995 .is_some_and(|content| match content {
3996 Value::String(text) => !text.trim().is_empty(),
3997 Value::Array(parts) => parts.iter().any(|part| {
3998 part.get("type").and_then(Value::as_str) == Some("text")
3999 && part
4000 .get("text")
4001 .and_then(Value::as_str)
4002 .is_some_and(|text| !text.trim().is_empty())
4003 }),
4004 _ => false,
4005 })
4006 }
4007 Some(SessionSource::Gemini) => {
4008 value.get("type").and_then(Value::as_str) == Some("user")
4009 && value.get("content").is_some_and(|content| match content {
4010 Value::String(text) => !text.trim().is_empty(),
4011 Value::Array(parts) => parts.iter().any(|part| {
4012 part.get("text")
4013 .and_then(Value::as_str)
4014 .is_some_and(|text| !text.trim().is_empty())
4015 }),
4016 _ => false,
4017 })
4018 }
4019 _ => false,
4020 }
4021}
4022
4023fn opencode_sql_err(e: rusqlite::Error, context: &str) -> crate::Error {
4024 crate::Error::Other(format!("OpenCode SQLite error while {context}: {e}"))
4025}
4026
4027/// Open `db_path` read-only and confirm it carries the expected V1 schema
4028/// (a `session` table) — the shared entry point for every SQLite read below,
4029/// so every caller gets the SAME clear diagnostics (PARITY-3 AC03): missing
4030/// path, not-a-database, and wrong/unsupported schema are each named
4031/// distinctly rather than surfacing later as "zero sessions" or a generic
4032/// parse failure.
4033fn opencode_sqlite_open(db_path: &Path) -> Result<Connection> {
4034 if !db_path.is_file() {
4035 return Err(crate::Error::Other(format!(
4036 "OpenCode SQLite store not found at {} — expected an `opencode*.db` file \
4037 (see `docs/interop/opencode-pi-spec.md` §1.2)",
4038 db_path.display()
4039 )));
4040 }
4041 let conn = Connection::open_with_flags(
4042 db_path,
4043 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
4044 )
4045 .map_err(|e| {
4046 crate::Error::Other(format!(
4047 "{} does not look like a valid OpenCode SQLite database: {e}",
4048 db_path.display()
4049 ))
4050 })?;
4051 let has_session_table: i64 = conn
4052 .query_row(
4053 "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='session'",
4054 [],
4055 |r| r.get(0),
4056 )
4057 .map_err(|e| {
4058 crate::Error::Other(format!(
4059 "failed to read the OpenCode SQLite schema at {}: {e}",
4060 db_path.display()
4061 ))
4062 })?;
4063 if has_session_table == 0 {
4064 return Err(crate::Error::Other(format!(
4065 "{} is a SQLite database but has no `session` table — not a recognized \
4066 OpenCode store (wrong file, or an unsupported/pre-SQLite OpenCode version)",
4067 db_path.display()
4068 )));
4069 }
4070 Ok(conn)
4071}
4072
4073/// Parse a nullable JSON-typed SQLite column (`metadata`, `revert`, `model`,
4074/// …). D7: an unparseable non-empty column previously degraded to
4075/// `Value::Null` with NO diagnostic — indistinguishable from a genuinely
4076/// absent/NULL column, so a corrupt `data`/`metadata` value silently
4077/// vanished (e.g. a message whose `data` fails to parse loses its entire
4078/// canonical content with no trace). A `tracing::warn!` now surfaces the
4079/// column name and context (session/record id) whenever this happens, so
4080/// the failure is diagnosable — `Value::Null` is kept as the parsed VALUE
4081/// (still the least-wrong placeholder for a broken column; changing it to a
4082/// sentinel would risk misleading every legitimate `.is_null()` check
4083/// elsewhere) but the frontend/log now knows it happened.
4084fn opencode_json_col(s: Option<String>, col: &str, context: &str) -> Value {
4085 match s.as_deref() {
4086 None => Value::Null,
4087 Some(t) => match serde_json::from_str::<Value>(t) {
4088 Ok(v) => v,
4089 Err(e) => {
4090 tracing::warn!(
4091 column = col,
4092 context,
4093 error = %e,
4094 "opencode SQLite column failed to parse as JSON — treating as absent (D7)"
4095 );
4096 Value::Null
4097 }
4098 },
4099 }
4100}
4101
4102/// Columns the `session` table has in a GIVEN store, read once per session
4103/// load via `PRAGMA table_info` (D3: real-world stores vary — a lean/older
4104/// `opencode` generation may lack columns the newest schema added, e.g.
4105/// `workspace_id`; rusqlite's `Row::get(name)` hard-errors
4106/// "Invalid column name" on an absent column, so callers must check
4107/// membership before reading a not-guaranteed column instead of reading it
4108/// unconditionally).
4109fn opencode_session_columns(
4110 conn: &Connection,
4111) -> rusqlite::Result<std::collections::HashSet<String>> {
4112 let mut stmt = conn.prepare("PRAGMA table_info(session)")?;
4113 let names = stmt.query_map([], |r| r.get::<_, String>(1))?; // column 1 = name
4114 names.collect()
4115}
4116
4117/// Reconstruct one `session` row into the camelCase `SessionInfo` envelope
4118/// value [`Session::from_opencode_str`]'s `capture_opencode_session_info`
4119/// already parses — mirrors upstream's own `session.ts` `fromRow`, EXCEPT
4120/// `revert` carries the raw column value verbatim rather than upstream's
4121/// field-selecting reconstruction (spec S9c: that reconstruction silently
4122/// drops the V2 `Revert.State` schema's extra `files` field).
4123///
4124/// D3: not every column this loader would like to read is guaranteed to
4125/// exist — a real, older/leaner `opencode` install's `session` table (e.g.
4126/// v1.2.15) lacks `workspace_id`/`path`/`metadata`/`cost`/`tokens_*`/
4127/// `agent`/`model` entirely. Those are read defensively (guarded by
4128/// [`opencode_session_columns`]); columns present in EVERY `opencode`
4129/// generation this loader has ever targeted are still read unconditionally.
4130fn opencode_row_session_info(conn: &Connection, session_id: &str) -> Result<Value> {
4131 let cols = opencode_session_columns(conn)
4132 .map_err(|e| opencode_sql_err(e, &format!("reading session `{session_id}` schema")))?;
4133 let has = |name: &str| cols.contains(name);
4134
4135 conn.query_row("SELECT * FROM session WHERE id = ?1", [session_id], |r| {
4136 let id: String = r.get("id")?;
4137 let project_id: String = r.get("project_id")?;
4138 let workspace_id: Option<String> = if has("workspace_id") {
4139 r.get("workspace_id")?
4140 } else {
4141 None
4142 };
4143 let parent_id: Option<String> = r.get("parent_id")?;
4144 let slug: String = r.get("slug")?;
4145 let directory: String = r.get("directory")?;
4146 let path: Option<String> = if has("path") { r.get("path")? } else { None };
4147 let title: String = r.get("title")?;
4148 let version: String = r.get("version")?;
4149 let share_url: Option<String> = r.get("share_url")?;
4150 let summary_additions: Option<i64> = r.get("summary_additions")?;
4151 let summary_deletions: Option<i64> = r.get("summary_deletions")?;
4152 let summary_files: Option<i64> = r.get("summary_files")?;
4153 let summary_diffs: Option<String> = r.get("summary_diffs")?;
4154 let metadata: Option<String> = if has("metadata") {
4155 r.get("metadata")?
4156 } else {
4157 None
4158 };
4159 let cost: f64 = if has("cost") { r.get("cost")? } else { 0.0 };
4160 let tokens_input: i64 = if has("tokens_input") {
4161 r.get("tokens_input")?
4162 } else {
4163 0
4164 };
4165 let tokens_output: i64 = if has("tokens_output") {
4166 r.get("tokens_output")?
4167 } else {
4168 0
4169 };
4170 let tokens_reasoning: i64 = if has("tokens_reasoning") {
4171 r.get("tokens_reasoning")?
4172 } else {
4173 0
4174 };
4175 let tokens_cache_read: i64 = if has("tokens_cache_read") {
4176 r.get("tokens_cache_read")?
4177 } else {
4178 0
4179 };
4180 let tokens_cache_write: i64 = if has("tokens_cache_write") {
4181 r.get("tokens_cache_write")?
4182 } else {
4183 0
4184 };
4185 let revert: Option<String> = r.get("revert")?;
4186 let permission: Option<String> = if has("permission") {
4187 r.get("permission")?
4188 } else {
4189 None
4190 };
4191 let agent: Option<String> = if has("agent") { r.get("agent")? } else { None };
4192 let model: Option<String> = if has("model") { r.get("model")? } else { None };
4193 let time_created: i64 = r.get("time_created")?;
4194 let time_updated: i64 = r.get("time_updated")?;
4195 let time_compacting: Option<i64> = if has("time_compacting") {
4196 r.get("time_compacting")?
4197 } else {
4198 None
4199 };
4200 let time_archived: Option<i64> = if has("time_archived") {
4201 r.get("time_archived")?
4202 } else {
4203 None
4204 };
4205
4206 let summary =
4207 (summary_additions.is_some() || summary_deletions.is_some() || summary_files.is_some())
4208 .then(|| {
4209 serde_json::json!({
4210 "additions": summary_additions.unwrap_or(0),
4211 "deletions": summary_deletions.unwrap_or(0),
4212 "files": summary_files.unwrap_or(0),
4213 "diffs": opencode_json_col(summary_diffs, "summary_diffs", session_id),
4214 })
4215 });
4216 let share = share_url.map(|u| serde_json::json!({"url": u}));
4217
4218 Ok(serde_json::json!({
4219 "id": id,
4220 "slug": slug,
4221 "projectID": project_id,
4222 "workspaceID": workspace_id,
4223 "directory": directory,
4224 "path": path,
4225 "parentID": parent_id,
4226 "summary": summary,
4227 "cost": cost,
4228 "tokens": {
4229 "input": tokens_input,
4230 "output": tokens_output,
4231 "reasoning": tokens_reasoning,
4232 "cache": {"read": tokens_cache_read, "write": tokens_cache_write},
4233 },
4234 "share": share,
4235 "title": title,
4236 "agent": agent,
4237 "model": opencode_json_col(model, "model", session_id),
4238 "version": version,
4239 "metadata": opencode_json_col(metadata, "metadata", session_id),
4240 "time": {
4241 "created": time_created,
4242 "updated": time_updated,
4243 "compacting": time_compacting,
4244 "archived": time_archived,
4245 },
4246 "permission": opencode_json_col(permission, "permission", session_id),
4247 // S9c: raw column value, not a field-selecting reconstruction —
4248 // see this function's doc comment.
4249 "revert": opencode_json_col(revert, "revert", session_id),
4250 }))
4251 })
4252 .map_err(|e| match e {
4253 rusqlite::Error::QueryReturnedNoRows => crate::Error::Other(format!(
4254 "OpenCode session `{session_id}` not found in this SQLite store"
4255 )),
4256 e => opencode_sql_err(e, &format!("reading session `{session_id}`")),
4257 })
4258}
4259
4260/// `message.data` / `part.data` already ARE the V1 `Info`/`Part` JSON minus
4261/// the id columns the schema hoists out (`session/sql.ts:68-98`) — just
4262/// re-inject them, matching what a JSON-tree file (or the export document)
4263/// carries at this same key. Also re-injects the row's own `time_created`/
4264/// `time_updated` columns (D2/S9c: these live OUTSIDE `data` in the real
4265/// schema — `Timestamps` in `database/schema.sql.ts` — and MUST be carried
4266/// in the envelope so `raw` is value-complete and re-writable without
4267/// re-minting timestamps; distinct keys from `data`'s own `"time"` object,
4268/// which is a different, in-schema field with different semantics).
4269fn opencode_row_message_value(
4270 id: &str,
4271 session_id: &str,
4272 data_json: &str,
4273 time_created: i64,
4274 time_updated: i64,
4275) -> Value {
4276 let mut v = opencode_json_col(Some(data_json.to_string()), "message.data", id);
4277 if let Value::Object(map) = &mut v {
4278 map.insert("id".to_string(), Value::String(id.to_string()));
4279 map.insert(
4280 "sessionID".to_string(),
4281 Value::String(session_id.to_string()),
4282 );
4283 map.insert("time_created".to_string(), Value::from(time_created));
4284 map.insert("time_updated".to_string(), Value::from(time_updated));
4285 }
4286 v
4287}
4288
4289fn opencode_row_part_value(
4290 id: &str,
4291 session_id: &str,
4292 message_id: &str,
4293 data_json: &str,
4294 time_created: i64,
4295 time_updated: i64,
4296) -> Value {
4297 let mut v = opencode_json_col(Some(data_json.to_string()), "part.data", id);
4298 if let Value::Object(map) = &mut v {
4299 map.insert("id".to_string(), Value::String(id.to_string()));
4300 map.insert(
4301 "sessionID".to_string(),
4302 Value::String(session_id.to_string()),
4303 );
4304 map.insert(
4305 "messageID".to_string(),
4306 Value::String(message_id.to_string()),
4307 );
4308 map.insert("time_created".to_string(), Value::from(time_created));
4309 map.insert("time_updated".to_string(), Value::from(time_updated));
4310 }
4311 v
4312}
4313
4314/// Ordered envelope lines for one session's `session`/`message`/`part`/`todo`
4315/// records (`docs/interop/opencode-pi-spec.md` §1.2's frozen order): session
4316/// info first, then each message (by `time_created, id`) immediately
4317/// followed by its own parts (by `id`) — parts MUST directly follow their
4318/// owning message line, since `Session::from_opencode_str`'s envelope parser
4319/// attaches a `part` line to whichever message id is already in its index
4320/// and silently leaves an out-of-order part `raw`-only otherwise — then
4321/// `todo` side-records, then a `session_diff` side-record if the JSON
4322/// sidecar file for this session exists (order-independent).
4323///
4324/// `db_path` is needed only for the `session_diff` sidecar (D3): §1.3 says
4325/// it "is still JSON-written even on SQLite installs" — verified against
4326/// `packages/opencode/src/session/revert.ts:76` /
4327/// `packages/opencode/src/storage/storage.ts:192` at the pinned schema
4328/// commit, which write it to `<data>/storage/session_diff/<session>.json`
4329/// (`<data>` being `db_path`'s parent directory) on every revert, entirely
4330/// separate from the `session.revert` DB column this loader already
4331/// captures. Without this, revert diffs vanish from `raw` and audit
4332/// under-counts `session_diff` records for real reverted sessions.
4333fn opencode_sqlite_session_envelope_lines(
4334 conn: &Connection,
4335 db_path: &Path,
4336 session_id: &str,
4337) -> Result<Vec<String>> {
4338 let mut lines = Vec::new();
4339
4340 let session_info = opencode_row_session_info(conn, session_id)?;
4341 let project_id = session_info
4342 .get("projectID")
4343 .and_then(Value::as_str)
4344 .unwrap_or("global")
4345 .to_string();
4346 lines.push(
4347 serde_json::json!({"key": ["session", project_id, session_id], "value": session_info})
4348 .to_string(),
4349 );
4350
4351 let mut msg_stmt = conn
4352 .prepare(
4353 "SELECT id, data, time_created, time_updated FROM message \
4354 WHERE session_id = ?1 ORDER BY time_created, id",
4355 )
4356 .map_err(|e| opencode_sql_err(e, "preparing the message query"))?;
4357 let msg_rows = msg_stmt
4358 .query_map([session_id], |r| {
4359 let id: String = r.get("id")?;
4360 let data: String = r.get("data")?;
4361 let time_created: i64 = r.get("time_created")?;
4362 let time_updated: i64 = r.get("time_updated")?;
4363 Ok((id, data, time_created, time_updated))
4364 })
4365 .map_err(|e| opencode_sql_err(e, "querying messages"))?;
4366
4367 let mut part_stmt = conn
4368 .prepare(
4369 "SELECT id, data, time_created, time_updated FROM part \
4370 WHERE message_id = ?1 ORDER BY id",
4371 )
4372 .map_err(|e| opencode_sql_err(e, "preparing the part query"))?;
4373
4374 for row in msg_rows {
4375 let (msg_id, data, msg_time_created, msg_time_updated) =
4376 row.map_err(|e| opencode_sql_err(e, "reading a message row"))?;
4377 let msg_value = opencode_row_message_value(
4378 &msg_id,
4379 session_id,
4380 &data,
4381 msg_time_created,
4382 msg_time_updated,
4383 );
4384 lines.push(
4385 serde_json::json!({"key": ["message", session_id, msg_id], "value": msg_value})
4386 .to_string(),
4387 );
4388
4389 let part_rows = part_stmt
4390 .query_map([&msg_id], |r| {
4391 let id: String = r.get("id")?;
4392 let data: String = r.get("data")?;
4393 let time_created: i64 = r.get("time_created")?;
4394 let time_updated: i64 = r.get("time_updated")?;
4395 Ok((id, data, time_created, time_updated))
4396 })
4397 .map_err(|e| opencode_sql_err(e, "querying parts"))?;
4398 for prow in part_rows {
4399 let (part_id, pdata, part_time_created, part_time_updated) =
4400 prow.map_err(|e| opencode_sql_err(e, "reading a part row"))?;
4401 let part_value = opencode_row_part_value(
4402 &part_id,
4403 session_id,
4404 &msg_id,
4405 &pdata,
4406 part_time_created,
4407 part_time_updated,
4408 );
4409 lines.push(
4410 serde_json::json!({"key": ["part", msg_id, part_id], "value": part_value})
4411 .to_string(),
4412 );
4413 }
4414 }
4415
4416 let mut todo_stmt = conn
4417 .prepare(
4418 "SELECT content, status, priority, position, time_created, time_updated \
4419 FROM todo WHERE session_id = ?1 ORDER BY position",
4420 )
4421 .map_err(|e| opencode_sql_err(e, "preparing the todo query"))?;
4422 let todo_rows = todo_stmt
4423 .query_map([session_id], |r| {
4424 let content: String = r.get("content")?;
4425 let status: String = r.get("status")?;
4426 let priority: String = r.get("priority")?;
4427 let position: i64 = r.get("position")?;
4428 let time_created: i64 = r.get("time_created")?;
4429 let time_updated: i64 = r.get("time_updated")?;
4430 Ok(serde_json::json!({
4431 "sessionID": session_id,
4432 "content": content,
4433 "status": status,
4434 "priority": priority,
4435 "position": position,
4436 "time": {"created": time_created, "updated": time_updated},
4437 }))
4438 })
4439 .map_err(|e| opencode_sql_err(e, "querying todos"))?;
4440 for trow in todo_rows {
4441 let tv = trow.map_err(|e| opencode_sql_err(e, "reading a todo row"))?;
4442 let position = tv.get("position").cloned().unwrap_or(Value::Null);
4443 lines.push(
4444 serde_json::json!({"key": ["todo", session_id, position], "value": tv}).to_string(),
4445 );
4446 }
4447
4448 if let Some(diff_value) = opencode_read_session_diff_sidecar(db_path, session_id) {
4449 lines.push(
4450 serde_json::json!({"key": ["session_diff", session_id], "value": diff_value})
4451 .to_string(),
4452 );
4453 }
4454
4455 Ok(lines)
4456}
4457
4458/// D3: read the `session_diff` JSON sidecar file for `session_id`, if
4459/// present, from `<db_path's parent>/storage/session_diff/<session_id>.json`
4460/// — the real on-disk location (`storage.ts`'s `Global.Path.data` +
4461/// `"storage"`, with `db_path` itself living at `Global.Path.data/opencode.db`
4462/// or `opencode-<channel>.db`). Best-effort: a missing file is the common
4463/// case (most sessions never revert) and is not an error; an existing-but-
4464/// unparseable file surfaces a diagnostic (D7-style) rather than silently
4465/// vanishing.
4466fn opencode_read_session_diff_sidecar(db_path: &Path, session_id: &str) -> Option<Value> {
4467 let dir = db_path.parent()?;
4468 let sidecar = dir
4469 .join("storage")
4470 .join("session_diff")
4471 .join(format!("{session_id}.json"));
4472 let text = std::fs::read_to_string(&sidecar).ok()?;
4473 match serde_json::from_str::<Value>(&text) {
4474 Ok(v) => Some(v),
4475 Err(e) => {
4476 tracing::warn!(
4477 path = %sidecar.display(),
4478 error = %e,
4479 "opencode session_diff sidecar failed to parse as JSON — skipping (D7)"
4480 );
4481 None
4482 }
4483 }
4484}
4485
4486/// Pick the "primary" session for a bare `.db` path with no explicit session
4487/// id (`Session::load`'s auto-detect entry point): the most-recently-updated
4488/// TOP-LEVEL session (`parent_id IS NULL` sorts first, then `time_updated`
4489/// descending) — a subagent/task child session is never picked over an
4490/// available root session, mirroring `most_recent_session`'s "latest wins"
4491/// convention used elsewhere in this crate for supercode's own store.
4492fn opencode_sqlite_primary_session_id(conn: &Connection) -> Result<String> {
4493 conn.query_row(
4494 "SELECT id FROM session ORDER BY (parent_id IS NULL) DESC, time_updated DESC LIMIT 1",
4495 [],
4496 |r| r.get::<_, String>(0),
4497 )
4498 .map_err(|e| match e {
4499 rusqlite::Error::QueryReturnedNoRows => {
4500 crate::Error::Other("OpenCode SQLite store contains no sessions".to_string())
4501 }
4502 e => opencode_sql_err(e, "selecting the primary session"),
4503 })
4504}
4505
4506fn opencode_sqlite_all_session_ids(conn: &Connection, limit: Option<usize>) -> Result<Vec<String>> {
4507 let mut stmt = conn
4508 .prepare("SELECT id FROM session ORDER BY time_created, id")
4509 .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4510 let rows = stmt
4511 .query_map([], |r| r.get::<_, String>(0))
4512 .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4513 let mut ids = Vec::new();
4514 for row in rows {
4515 ids.push(row.map_err(|e| opencode_sql_err(e, "reading a session id"))?);
4516 if limit.is_some_and(|n| ids.len() >= n) {
4517 break;
4518 }
4519 }
4520 Ok(ids)
4521}
4522
4523/// D6: list every session id in an OpenCode SQLite store (oldest first) —
4524/// exposed so CLI callers (`convert`, `inspect`) can detect a multi-session
4525/// store and warn before [`Session::load`]/[`Session::from_opencode_sqlite`]
4526/// silently picks just the primary one. Previously nothing surfaced this:
4527/// `convert opencode.db --to X` converted 1-of-N sessions with no warning
4528/// and no way to name a different one.
4529pub fn opencode_sqlite_session_ids(db_path: &Path) -> Result<Vec<String>> {
4530 let conn = opencode_sqlite_open(db_path)?;
4531 opencode_sqlite_all_session_ids(&conn, None)
4532}
4533
4534/// D6: the same "most-recently-updated top-level session" selection
4535/// [`Session::load`]/[`Session::from_opencode_sqlite`] make by default when
4536/// no explicit session id is given — exposed so a CLI-level warning can name
4537/// which one was chosen.
4538pub fn opencode_sqlite_primary_id(db_path: &Path) -> Result<String> {
4539 let conn = opencode_sqlite_open(db_path)?;
4540 opencode_sqlite_primary_session_id(&conn)
4541}
4542
4543/// Cheap store-level counts — `COUNT(*)` only, no row hydration — for
4544/// `inspect`'s "reports the audited real store's sessions, messages, and
4545/// parts" summary (PARITY-3 AC01).
4546#[derive(Debug, Clone, Copy, Default)]
4547#[non_exhaustive]
4548pub struct OpenCodeSqliteStoreStats {
4549 /// Row count of the `session` table.
4550 pub sessions: u64,
4551 /// Row count of the `message` table.
4552 pub messages: u64,
4553 /// Row count of the `part` table.
4554 pub parts: u64,
4555 /// Row count of the `todo` table.
4556 pub todos: u64,
4557}
4558
4559/// Count sessions/messages/parts/todos in a real OpenCode SQLite store
4560/// without loading any of them (PARITY-3 AC01).
4561pub fn opencode_sqlite_store_stats(db_path: &Path) -> Result<OpenCodeSqliteStoreStats> {
4562 let conn = opencode_sqlite_open(db_path)?;
4563 let count = |table: &str| -> Result<u64> {
4564 let sql = format!("SELECT count(*) FROM {table}");
4565 conn.query_row(&sql, [], |r| r.get::<_, i64>(0))
4566 .map(|n| n.max(0) as u64)
4567 .map_err(|e| opencode_sql_err(e, &format!("counting `{table}` rows")))
4568 };
4569 Ok(OpenCodeSqliteStoreStats {
4570 sessions: count("session")?,
4571 messages: count("message")?,
4572 parts: count("part")?,
4573 todos: count("todo")?,
4574 })
4575}
4576
4577/// Combined envelope text spanning every session in `db_path` (or up to
4578/// `limit_sessions`) — for corpus-style scanning
4579/// (the OpenCode SQLite corpus-audit path, PARITY-4).
4580/// Safe to concatenate multiple sessions' records into one text even though
4581/// [`Session::from_opencode_str`] itself only keeps the LAST `session` line
4582/// (single-session semantics) — the audit line-classifier
4583/// (`audit_opencode_line`) scores each line independently and doesn't care
4584/// about session boundaries. Use [`Session::from_opencode_sqlite`] to load
4585/// one session as a real [`Session`].
4586pub fn opencode_sqlite_corpus_envelope_text(
4587 db_path: &Path,
4588 limit_sessions: Option<usize>,
4589) -> Result<String> {
4590 let conn = opencode_sqlite_open(db_path)?;
4591 let ids = opencode_sqlite_all_session_ids(&conn, limit_sessions)?;
4592 let mut out = String::new();
4593 for id in ids {
4594 for line in opencode_sqlite_session_envelope_lines(&conn, db_path, &id)? {
4595 out.push_str(&line);
4596 out.push('\n');
4597 }
4598 }
4599 Ok(out)
4600}
4601
4602/// PARSE-iteration view (blank/whitespace-only lines skipped, each line
4603/// trimmed, `\r` line endings normalized away by [`str::lines`]) — used
4604/// everywhere a loader walks lines looking for JSON *records*, where a blank
4605/// line is simply not a record and must not become a spurious parse
4606/// failure/empty entry. Deliberately NOT used for `raw` capture any more
4607/// (IX-1) — see [`split_lines_verbatim`] for that.
4608fn non_empty_lines(text: &str) -> impl Iterator<Item = &str> {
4609 text.lines().map(str::trim).filter(|l| !l.is_empty())
4610}
4611
4612// ---- Claude Code ----------------------------------------------------------
4613
4614/// The `subagents/` directory for a Claude Code transcript at `<dir>/<stem>.jsonl`
4615/// is `<dir>/<stem>/subagents/`. Returns it only if it exists.
4616fn subagents_dir_for(main_path: &Path) -> Option<PathBuf> {
4617 let dir = main_path.parent()?;
4618 let stem = main_path.file_stem()?.to_str()?;
4619 let candidate = dir.join(stem).join("subagents");
4620 candidate.is_dir().then_some(candidate)
4621}
4622
4623/// The first `agentId` recorded in a subagent transcript.
4624fn first_agent_id(jsonl: &str) -> Option<String> {
4625 for line in non_empty_lines(jsonl) {
4626 if let Ok(v) = serde_json::from_str::<Value>(line) {
4627 if let Some(id) = v.get("agentId").and_then(Value::as_str) {
4628 return Some(id.to_string());
4629 }
4630 }
4631 }
4632 None
4633}
4634
4635/// Find the `tool_use_id` of each parent `Task` call that spawned one of
4636/// `agent_ids`, by locating the parent transcript's `tool_result` whose
4637/// serialized content mentions the agent id. Best effort: an id with no
4638/// qualifying match is simply absent from the returned map.
4639///
4640/// Single pass over `main_text` — each line is parsed at most once,
4641/// regardless of how many agent ids are being sought — with each id's result
4642/// reproducing exactly what a per-id scan-and-parse-per-hit-line search would
4643/// return: the first line (in file order) whose raw text contains the id and
4644/// which — the first qualifying `tool_result` block in that line, in block
4645/// order — has a string `tool_use_id` and a serialized form that also
4646/// contains the id. A `tool_result` block matching on raw-line/serialized
4647/// containment but lacking a `tool_use_id` yields nothing for that id and
4648/// does not shadow a later match.
4649fn parent_tool_use_index(main_text: &str, agent_ids: &[String]) -> HashMap<String, String> {
4650 let mut index: HashMap<String, String> = HashMap::new();
4651 if agent_ids.is_empty() {
4652 return index;
4653 }
4654
4655 for line in non_empty_lines(main_text) {
4656 if index.len() == agent_ids.len() {
4657 break;
4658 }
4659 // Cheap prefilter: every match this function can ever return comes
4660 // from a block whose raw line carries the literal JSON string value
4661 // `tool_result` (no JSON-escape variants of that ASCII literal).
4662 if !line.contains("tool_result") {
4663 continue;
4664 }
4665 let still_unmapped: Vec<&String> = agent_ids
4666 .iter()
4667 .filter(|id| !index.contains_key(id.as_str()))
4668 .collect();
4669 if still_unmapped.is_empty() {
4670 break;
4671 }
4672 let Ok(v) = serde_json::from_str::<Value>(line) else {
4673 continue;
4674 };
4675 let content = v.get("message").and_then(|m| m.get("content"));
4676 let Some(Value::Array(blocks)) = content else {
4677 continue;
4678 };
4679 for b in blocks {
4680 if b.get("type").and_then(Value::as_str) != Some("tool_result") {
4681 continue;
4682 }
4683 let Some(tool_use_id) = b.get("tool_use_id").and_then(Value::as_str) else {
4684 continue;
4685 };
4686 let block_str = b.to_string();
4687 for id in &still_unmapped {
4688 if index.contains_key(id.as_str()) {
4689 continue;
4690 }
4691 if line.contains(id.as_str()) && block_str.contains(id.as_str()) {
4692 index.insert((*id).clone(), tool_use_id.to_string());
4693 }
4694 }
4695 }
4696 }
4697
4698 index
4699}
4700
4701#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4702enum ClaudeReplayKind {
4703 User,
4704 Assistant,
4705 Attachment,
4706 System,
4707}
4708
4709impl ClaudeReplayKind {
4710 fn is_conversation(self) -> bool {
4711 matches!(self, Self::User | Self::Assistant)
4712 }
4713}
4714
4715#[derive(Debug, Clone)]
4716struct ClaudeReplayNode {
4717 line_index: usize,
4718 uuid: String,
4719 parent_uuid: Option<String>,
4720 kind: ClaudeReplayKind,
4721 is_sidechain: bool,
4722 assistant_message_id: Option<String>,
4723 is_tool_result: bool,
4724 compact: Option<ClaudeCompactBoundary>,
4725}
4726
4727#[derive(Debug, Clone)]
4728struct ClaudeCompactBoundary {
4729 anchor_uuid: Option<String>,
4730 preserved_uuids: Vec<String>,
4731 preserved_segment: Option<(String, String)>,
4732}
4733
4734/// One projection of a Claude transcript graph: the source lines to replay,
4735/// plus whatever the projection had to give up to produce them (always empty
4736/// below [`Fidelity::Semantic`], which is the only level that degrades
4737/// instead of failing).
4738#[derive(Debug, Default)]
4739struct ClaudeReplaySelection {
4740 lines: Vec<usize>,
4741 residue: Vec<String>,
4742}
4743
4744#[derive(Debug, Default)]
4745struct ClaudeReplayIndex {
4746 nodes: Vec<ClaudeReplayNode>,
4747 by_uuid: HashMap<String, usize>,
4748 segment_anchors: HashSet<String>,
4749 last_prompt: Option<(String, bool)>,
4750 linear_lines: Vec<usize>,
4751}
4752
4753impl ClaudeReplayIndex {
4754 fn observe(&mut self, line_index: usize, v: &Value) -> Result<()> {
4755 if v.get("type").and_then(Value::as_str) == Some("last-prompt") {
4756 if let Some(leaf) = v.get("leafUuid").and_then(Value::as_str) {
4757 self.last_prompt = Some((
4758 leaf.to_string(),
4759 v.get("explicit").and_then(Value::as_bool) == Some(true),
4760 ));
4761 }
4762 return Ok(());
4763 }
4764
4765 // A fork-context-ref is a real Claude graph anchor, but not a replay
4766 // message. Its child is the first conversational record in the
4767 // exported fork, so reaching this UUID terminates the locally
4768 // replayable segment rather than indicating a broken parent edge.
4769 if v.get("type").and_then(Value::as_str) == Some("fork-context-ref") {
4770 if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
4771 self.segment_anchors.insert(uuid.to_string());
4772 }
4773 return Ok(());
4774 }
4775
4776 let kind = match v.get("type").and_then(Value::as_str) {
4777 Some("user") => ClaudeReplayKind::User,
4778 Some("assistant") => ClaudeReplayKind::Assistant,
4779 Some("attachment") => ClaudeReplayKind::Attachment,
4780 Some("system") => ClaudeReplayKind::System,
4781 _ => return Ok(()),
4782 };
4783 self.linear_lines.push(line_index);
4784 let Some(uuid) = v.get("uuid").and_then(Value::as_str) else {
4785 return Ok(());
4786 };
4787 if self.by_uuid.contains_key(uuid) {
4788 return Err(claude_replay_error(format!(
4789 "duplicate uuid `{uuid}` in Claude transcript"
4790 )));
4791 }
4792
4793 let compact = (kind == ClaudeReplayKind::System
4794 && v.get("subtype").and_then(Value::as_str) == Some("compact_boundary"))
4795 .then(|| ClaudeCompactBoundary::from_value(v));
4796 let assistant_message_id = (kind == ClaudeReplayKind::Assistant)
4797 .then(|| claude_assistant_message_id(v).map(str::to_string))
4798 .flatten();
4799 let is_tool_result = kind == ClaudeReplayKind::User
4800 && v.get("message")
4801 .and_then(|m| m.get("content"))
4802 .and_then(Value::as_array)
4803 .is_some_and(|blocks| {
4804 blocks
4805 .iter()
4806 .any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result"))
4807 });
4808 let node = ClaudeReplayNode {
4809 line_index,
4810 uuid: uuid.to_string(),
4811 parent_uuid: v
4812 .get("parentUuid")
4813 .and_then(Value::as_str)
4814 .map(str::to_string),
4815 kind,
4816 is_sidechain: v.get("isSidechain").and_then(Value::as_bool) == Some(true),
4817 assistant_message_id,
4818 is_tool_result,
4819 compact,
4820 };
4821 self.by_uuid.insert(uuid.to_string(), self.nodes.len());
4822 self.nodes.push(node);
4823 Ok(())
4824 }
4825
4826 /// Project the transcript at `fidelity`.
4827 ///
4828 /// Below [`Fidelity::Semantic`] this is the STRICT projection every
4829 /// continuation, transfer and export path depends on: reconstruct
4830 /// Claude's own single active post-compaction branch, or fail naming what
4831 /// could not be reconstructed.
4832 ///
4833 /// [`Fidelity::Semantic`] is the read-only VIEW mode. A Claude transcript
4834 /// that has been compacted, summarized, or resumed across files routinely
4835 /// contains a live record whose `parentUuid` names a record that is no
4836 /// longer on disk. Strict projection rightly refuses — a continuation
4837 /// built on a guessed graph is silent loss — but a VIEW does not need a
4838 /// continuation, so this mode anchors each dangling edge as a segment
4839 /// root, projects every severed segment exactly as the active branch is
4840 /// projected, splices them back together in transcript order, and names
4841 /// every degradation in the returned residue instead of erroring.
4842 fn select_lines(mut self, fidelity: Fidelity) -> Result<ClaudeReplaySelection> {
4843 let lenient = fidelity.tolerates_residue();
4844 let mut residue = Vec::new();
4845 if self.nodes.is_empty() {
4846 // Older exports and many hand-authored compatibility fixtures do
4847 // not carry Claude's `uuid`/`parentUuid` graph fields. There is no
4848 // branch information to project in that shape, so preserve the
4849 // historical linear normalization behavior. Native graph-bearing
4850 // transcripts always take the projection below.
4851 return Ok(ClaudeReplaySelection {
4852 lines: self.linear_lines,
4853 residue,
4854 });
4855 }
4856 if lenient {
4857 self.anchor_dangling_parents(&mut residue);
4858 }
4859 // Last resort for a VIEW: a transcript whose graph is unprojectable
4860 // for some OTHER reason (a cycle, an unresolvable compact boundary)
4861 // still renders as the file's own record order. A read-only mirror
4862 // that cannot open a session at all is the defect this mode exists
4863 // to remove, so `Semantic` never returns an error.
4864 let fallback = lenient.then(|| self.linear_lines.clone());
4865 match self.project(lenient, &mut residue) {
4866 Ok(lines) => Ok(ClaudeReplaySelection { lines, residue }),
4867 Err(error) => match fallback {
4868 Some(lines) => {
4869 residue.push(format!(
4870 "the Claude record graph could not be projected ({error}); \
4871 every record was stitched in transcript order instead"
4872 ));
4873 Ok(ClaudeReplaySelection { lines, residue })
4874 }
4875 None => Err(error),
4876 },
4877 }
4878 }
4879
4880 /// Turn every edge that points outside the transcript into a segment
4881 /// root, naming the dangling uuids as residue.
4882 ///
4883 /// A `fork-context-ref` anchor is already a declared segment boundary,
4884 /// not a break, so it is left alone.
4885 fn anchor_dangling_parents(&mut self, residue: &mut Vec<String>) {
4886 let mut dangling = Vec::new();
4887 for idx in 0..self.nodes.len() {
4888 let Some(parent) = self.nodes[idx].parent_uuid.as_deref() else {
4889 continue;
4890 };
4891 if self.by_uuid.contains_key(parent) || self.segment_anchors.contains(parent) {
4892 continue;
4893 }
4894 dangling.push(format!("`{}` → `{parent}`", self.nodes[idx].uuid));
4895 self.nodes[idx].parent_uuid = None;
4896 }
4897 if dangling.is_empty() {
4898 return;
4899 }
4900 const NAMED: usize = 8;
4901 let total = dangling.len();
4902 let overflow = total.saturating_sub(NAMED);
4903 dangling.truncate(NAMED);
4904 let mut listed = dangling.join(", ");
4905 if overflow > 0 {
4906 listed.push_str(&format!(", and {overflow} more"));
4907 }
4908 residue.push(format!(
4909 "{total} Claude record(s) reference a parentUuid absent from the transcript and were \
4910 anchored as segment roots: {listed}"
4911 ));
4912 }
4913
4914 fn project(&mut self, lenient: bool, residue: &mut Vec<String>) -> Result<Vec<usize>> {
4915 let mut retained = vec![true; self.nodes.len()];
4916 if let Some(boundary_index) = self.nodes.iter().rposition(|n| n.compact.is_some()) {
4917 let parents: Option<Vec<Option<String>>> = lenient.then(|| {
4918 self.nodes
4919 .iter()
4920 .map(|node| node.parent_uuid.clone())
4921 .collect()
4922 });
4923 if let Err(error) = self.apply_latest_compaction(boundary_index, &mut retained) {
4924 let Some(parents) = parents else {
4925 return Err(error);
4926 };
4927 // The boundary rewrites parents as it goes, so restore the
4928 // graph it half-edited before continuing without it.
4929 for (node, parent) in self.nodes.iter_mut().zip(parents) {
4930 node.parent_uuid = parent;
4931 }
4932 retained.iter_mut().for_each(|keep| *keep = true);
4933 residue.push(format!(
4934 "the latest Claude compact boundary could not be projected ({error}); \
4935 no pre-compaction record was pruned from this view"
4936 ));
4937 }
4938 }
4939 let sidechain_only = self
4940 .nodes
4941 .iter()
4942 .enumerate()
4943 .filter(|(idx, node)| retained[*idx] && node.kind.is_conversation())
4944 .all(|(_, node)| node.is_sidechain);
4945
4946 let explicit_leaf = self
4947 .last_prompt
4948 .as_ref()
4949 .filter(|(_, explicit)| *explicit)
4950 .and_then(|(uuid, _)| self.by_uuid.get(uuid).copied())
4951 .filter(|idx| retained[*idx]);
4952 let newest_non_sidechain = self
4953 .nodes
4954 .iter()
4955 .enumerate()
4956 .rev()
4957 .find(|(idx, node)| retained[*idx] && !node.is_sidechain)
4958 .map(|(idx, _)| idx);
4959 // Dedicated Claude subagent transcripts are sidechains by design:
4960 // every record, including their root user prompt, has
4961 // `isSidechain:true`. When there is no main-chain candidate, resume
4962 // the newest retained sidechain leaf instead of rejecting the child.
4963 let newest_sidechain = self
4964 .nodes
4965 .iter()
4966 .enumerate()
4967 .rev()
4968 .find(|(idx, node)| retained[*idx] && node.is_sidechain && node.kind.is_conversation())
4969 .map(|(idx, _)| idx);
4970 let mut active = explicit_leaf
4971 .or(newest_non_sidechain)
4972 .or(newest_sidechain)
4973 .ok_or_else(|| claude_replay_error("no Claude record remains after compaction"))?;
4974
4975 // Metadata descendants such as turn_duration are leaves in the raw
4976 // graph. Claude resumes from their nearest user/assistant ancestor,
4977 // then appends those descendants to the reconstructed chain.
4978 let mut seeking = HashSet::new();
4979 while !self.nodes[active].kind.is_conversation() {
4980 if !seeking.insert(active) {
4981 return Err(claude_replay_error(
4982 "cycle while resolving active Claude leaf",
4983 ));
4984 }
4985 active = self.parent_index(active, &retained)?;
4986 }
4987
4988 let mut segments =
4989 vec![self.project_segment(active, &retained, sidechain_only, lenient)?];
4990 if lenient {
4991 for leaf in self.severed_segment_leaves(active, &retained) {
4992 segments.push(self.project_segment(leaf, &retained, sidechain_only, lenient)?);
4993 }
4994 if segments.len() > 1 {
4995 residue.push(format!(
4996 "{} conversation segments were stitched in transcript order because the \
4997 Claude record graph is severed",
4998 segments.len()
4999 ));
5000 }
5001 }
5002 // Each segment keeps its own reconstructed order; the segments
5003 // themselves are spliced by where they start in the file.
5004 segments.retain(|segment| !segment.is_empty());
5005 segments.sort_by_key(|segment| {
5006 segment
5007 .iter()
5008 .map(|idx| self.nodes[*idx].line_index)
5009 .min()
5010 .unwrap_or(usize::MAX)
5011 });
5012 let mut ordered = Vec::new();
5013 let mut placed = HashSet::new();
5014 for idx in segments.into_iter().flatten() {
5015 if placed.insert(idx) {
5016 ordered.push(idx);
5017 }
5018 }
5019
5020 self.recover_parallel_assistant_chunks(ordered, &retained)
5021 .map(|indices| {
5022 indices
5023 .into_iter()
5024 .map(|idx| self.nodes[idx].line_index)
5025 .collect()
5026 })
5027 }
5028
5029 /// Reconstruct one segment: `leaf`'s parent chain, oldest-first, plus the
5030 /// non-conversation descendants rooted at it.
5031 fn project_segment(
5032 &self,
5033 leaf: usize,
5034 retained: &[bool],
5035 sidechain_only: bool,
5036 lenient: bool,
5037 ) -> Result<Vec<usize>> {
5038 let mut reversed = Vec::new();
5039 let mut seen = HashSet::new();
5040 let mut cursor = Some(leaf);
5041 while let Some(idx) = cursor {
5042 if !seen.insert(idx) {
5043 return Err(claude_replay_error(format!(
5044 "cycle in active Claude parentUuid chain at `{}`",
5045 self.nodes[idx].uuid
5046 )));
5047 }
5048 reversed.push(idx);
5049 cursor = match self.nodes[idx].parent_uuid.as_deref() {
5050 Some(parent) => match self.by_uuid.get(parent).copied() {
5051 Some(parent) => Some(parent),
5052 None if self.segment_anchors.contains(parent) => None,
5053 // Claude can resume a background child in-place while
5054 // retaining only the new segment in that child's JSONL.
5055 // Its first record then points to a UUID not present in
5056 // the sidechain file. That external edge is a segment
5057 // boundary, not corruption; the complete source remains
5058 // available byte-for-byte in `raw`.
5059 None if sidechain_only => None,
5060 None => {
5061 return Err(claude_replay_error(format!(
5062 "active Claude record `{}` has missing parentUuid `{parent}`",
5063 self.nodes[idx].uuid
5064 )));
5065 }
5066 },
5067 None => None,
5068 };
5069 if cursor.is_some_and(|parent| !retained[parent]) {
5070 if lenient {
5071 // A compaction boundary is where this segment ends; the
5072 // records it pruned stay pruned.
5073 break;
5074 }
5075 return Err(claude_replay_error(format!(
5076 "active Claude chain crosses an excluded compaction record from `{}`",
5077 self.nodes[idx].uuid
5078 )));
5079 }
5080 }
5081 reversed.reverse();
5082
5083 // Include non-conversation descendants rooted at the segment's leaf
5084 // (turn_duration, attachments, etc.), matching Claude's QYH/n1T.
5085 let mut descendants = Vec::new();
5086 let mut frontier = vec![leaf];
5087 let mut head = 0;
5088 while head < frontier.len() {
5089 let parent = frontier[head];
5090 head += 1;
5091 for (idx, node) in self.nodes.iter().enumerate() {
5092 if !retained[idx]
5093 || node.kind.is_conversation()
5094 || seen.contains(&idx)
5095 || node.parent_uuid.as_deref() != Some(self.nodes[parent].uuid.as_str())
5096 {
5097 continue;
5098 }
5099 seen.insert(idx);
5100 descendants.push(idx);
5101 frontier.push(idx);
5102 }
5103 }
5104 descendants.sort_by_key(|idx| self.nodes[*idx].line_index);
5105 reversed.extend(descendants);
5106 Ok(reversed)
5107 }
5108
5109 /// The newest retained conversation record of every component the active
5110 /// leaf's own component cannot reach.
5111 ///
5112 /// Only a severed graph produces any: a healthy transcript is one
5113 /// component, so the abandoned branches a rewind left behind stay
5114 /// abandoned here exactly as they do under strict projection.
5115 fn severed_segment_leaves(&self, active: usize, retained: &[bool]) -> Vec<usize> {
5116 let active_root = self.component_root(active, retained);
5117 let mut newest_by_root: BTreeMap<usize, usize> = BTreeMap::new();
5118 for idx in 0..self.nodes.len() {
5119 if !retained[idx] || !self.nodes[idx].kind.is_conversation() {
5120 continue;
5121 }
5122 let Some(root) = self.component_root(idx, retained) else {
5123 continue;
5124 };
5125 if Some(root) == active_root {
5126 continue;
5127 }
5128 let newest = newest_by_root.entry(root).or_insert(idx);
5129 if self.nodes[idx].line_index > self.nodes[*newest].line_index {
5130 *newest = idx;
5131 }
5132 }
5133 newest_by_root.into_values().collect()
5134 }
5135
5136 /// Walk `idx` up to the record that anchors its component, stopping at a
5137 /// root, an edge that leaves the transcript, or a pruned parent. `None`
5138 /// when the walk cycles.
5139 fn component_root(&self, idx: usize, retained: &[bool]) -> Option<usize> {
5140 let mut cursor = idx;
5141 let mut seen = HashSet::new();
5142 loop {
5143 if !seen.insert(cursor) {
5144 return None;
5145 }
5146 let next = self.nodes[cursor]
5147 .parent_uuid
5148 .as_deref()
5149 .and_then(|parent| self.by_uuid.get(parent).copied())
5150 .filter(|parent| retained[*parent]);
5151 match next {
5152 Some(parent) => cursor = parent,
5153 None => return Some(cursor),
5154 }
5155 }
5156 }
5157
5158 fn apply_latest_compaction(
5159 &mut self,
5160 boundary_index: usize,
5161 retained: &mut [bool],
5162 ) -> Result<()> {
5163 let compact = self.nodes[boundary_index]
5164 .compact
5165 .clone()
5166 .expect("called with compact boundary");
5167 let mut preserved = compact.preserved_uuids;
5168 if preserved.is_empty() {
5169 if let Some((head, tail)) = compact.preserved_segment {
5170 preserved = self.walk_preserved_segment(&head, &tail)?;
5171 }
5172 }
5173
5174 let preserved_set: HashSet<String> = preserved.iter().cloned().collect();
5175 for uuid in &preserved {
5176 if !self.by_uuid.contains_key(uuid) {
5177 return Err(claude_replay_error(format!(
5178 "latest compact boundary references missing preserved uuid `{uuid}`"
5179 )));
5180 }
5181 }
5182
5183 let removed_uuids: HashSet<String> = self
5184 .nodes
5185 .iter()
5186 .enumerate()
5187 .filter(|(idx, node)| *idx < boundary_index && !preserved_set.contains(&node.uuid))
5188 .map(|(_, node)| node.uuid.clone())
5189 .collect();
5190 for (idx, node) in self.nodes.iter().enumerate() {
5191 if idx < boundary_index && !preserved_set.contains(&node.uuid) {
5192 retained[idx] = false;
5193 }
5194 }
5195
5196 if preserved.is_empty() {
5197 return Ok(());
5198 }
5199 let anchor = compact.anchor_uuid.ok_or_else(|| {
5200 claude_replay_error("preserved compact boundary is missing anchorUuid")
5201 })?;
5202 if !self.by_uuid.contains_key(&anchor) {
5203 return Err(claude_replay_error(format!(
5204 "latest compact boundary references missing anchor uuid `{anchor}`"
5205 )));
5206 }
5207 let tail = preserved.last().cloned().expect("non-empty preserved list");
5208 let mut parent = anchor.clone();
5209 for uuid in &preserved {
5210 let idx = self.by_uuid[uuid];
5211 self.nodes[idx].parent_uuid = Some(parent);
5212 parent = uuid.clone();
5213 }
5214 let first = &preserved[0];
5215 for node in &mut self.nodes {
5216 if node.parent_uuid.as_deref() == Some(anchor.as_str()) && node.uuid != *first {
5217 node.parent_uuid = Some(tail.clone());
5218 }
5219 }
5220 for node in &mut self.nodes {
5221 if node.kind.is_conversation()
5222 && node
5223 .parent_uuid
5224 .as_ref()
5225 .is_some_and(|parent| removed_uuids.contains(parent))
5226 {
5227 node.parent_uuid = Some(tail.clone());
5228 }
5229 }
5230 Ok(())
5231 }
5232
5233 fn walk_preserved_segment(&self, head: &str, tail: &str) -> Result<Vec<String>> {
5234 let mut reversed = Vec::new();
5235 let mut seen = HashSet::new();
5236 let mut cursor = tail;
5237 loop {
5238 if !seen.insert(cursor.to_string()) {
5239 return Err(claude_replay_error("cycle in compact preservedSegment"));
5240 }
5241 let idx = *self.by_uuid.get(cursor).ok_or_else(|| {
5242 claude_replay_error(format!(
5243 "compact preservedSegment references missing uuid `{cursor}`"
5244 ))
5245 })?;
5246 reversed.push(cursor.to_string());
5247 if cursor == head {
5248 reversed.reverse();
5249 return Ok(reversed);
5250 }
5251 cursor = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5252 claude_replay_error(format!(
5253 "compact preservedSegment tail `{tail}` does not reach head `{head}`"
5254 ))
5255 })?;
5256 }
5257 }
5258
5259 fn parent_index(&self, idx: usize, retained: &[bool]) -> Result<usize> {
5260 let parent = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5261 claude_replay_error(format!(
5262 "Claude record `{}` has no conversational ancestor",
5263 self.nodes[idx].uuid
5264 ))
5265 })?;
5266 let parent_idx = *self.by_uuid.get(parent).ok_or_else(|| {
5267 claude_replay_error(format!(
5268 "Claude record `{}` has missing parentUuid `{parent}`",
5269 self.nodes[idx].uuid
5270 ))
5271 })?;
5272 if !retained[parent_idx] {
5273 return Err(claude_replay_error(format!(
5274 "Claude record `{}` points into compacted-out history",
5275 self.nodes[idx].uuid
5276 )));
5277 }
5278 Ok(parent_idx)
5279 }
5280
5281 fn recover_parallel_assistant_chunks(
5282 &self,
5283 base: Vec<usize>,
5284 retained: &[bool],
5285 ) -> Result<Vec<usize>> {
5286 let selected: HashSet<usize> = base.iter().copied().collect();
5287 let mut replacements: HashMap<usize, Vec<usize>> = HashMap::new();
5288 let mut skipped_positions = HashSet::new();
5289 let mut handled_ids = HashSet::new();
5290
5291 for (base_pos, idx) in base.iter().copied().enumerate() {
5292 let Some(message_id) = self.nodes[idx].assistant_message_id.as_deref() else {
5293 continue;
5294 };
5295 if !handled_ids.insert(message_id.to_string()) {
5296 continue;
5297 }
5298 let base_positions: Vec<usize> = base
5299 .iter()
5300 .enumerate()
5301 .filter(|(_, candidate)| {
5302 self.nodes[**candidate].assistant_message_id.as_deref() == Some(message_id)
5303 })
5304 .map(|(pos, _)| pos)
5305 .collect();
5306 let anchor_pos = base_positions.first().copied().unwrap_or(base_pos);
5307 skipped_positions.extend(base_positions.iter().copied().skip(1));
5308
5309 // A streamed Anthropic response can be stored as sibling records
5310 // rather than a literal parent chain. Reassemble every chunk at
5311 // the first active occurrence and restore raw chunk order before
5312 // the normalizer coalesces their content blocks.
5313 let mut chunks: Vec<usize> = self
5314 .nodes
5315 .iter()
5316 .enumerate()
5317 .filter(|(candidate, node)| {
5318 retained[*candidate] && node.assistant_message_id.as_deref() == Some(message_id)
5319 })
5320 .map(|(candidate, _)| candidate)
5321 .collect();
5322 chunks.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5323
5324 let assistant_uuids: HashSet<&str> = self
5325 .nodes
5326 .iter()
5327 .filter(|node| node.assistant_message_id.as_deref() == Some(message_id))
5328 .map(|node| node.uuid.as_str())
5329 .collect();
5330 let mut results: Vec<usize> = self
5331 .nodes
5332 .iter()
5333 .enumerate()
5334 .filter(|(candidate, node)| {
5335 retained[*candidate]
5336 && !selected.contains(candidate)
5337 && node.is_tool_result
5338 && node
5339 .parent_uuid
5340 .as_deref()
5341 .is_some_and(|parent| assistant_uuids.contains(parent))
5342 })
5343 .map(|(candidate, _)| candidate)
5344 .collect();
5345 results.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5346 chunks.extend(results);
5347 replacements.insert(anchor_pos, chunks);
5348 }
5349
5350 let mut out = Vec::with_capacity(selected.len());
5351 for (pos, idx) in base.into_iter().enumerate() {
5352 if let Some(replacement) = replacements.remove(&pos) {
5353 out.extend(replacement);
5354 } else if !skipped_positions.contains(&pos) {
5355 out.push(idx);
5356 }
5357 }
5358 Ok(out)
5359 }
5360}
5361
5362impl ClaudeCompactBoundary {
5363 fn from_value(v: &Value) -> Self {
5364 let metadata = v.get("compactMetadata");
5365 let preserved_messages = metadata.and_then(|m| m.get("preservedMessages"));
5366 let anchor_uuid = preserved_messages
5367 .and_then(|p| p.get("anchorUuid"))
5368 .and_then(Value::as_str)
5369 .or_else(|| {
5370 metadata
5371 .and_then(|m| m.get("preservedSegment"))
5372 .and_then(|p| p.get("anchorUuid"))
5373 .and_then(Value::as_str)
5374 })
5375 .map(str::to_string);
5376 let preserved_uuids = preserved_messages
5377 .and_then(|p| p.get("uuids"))
5378 .and_then(Value::as_array)
5379 .map(|uuids| {
5380 uuids
5381 .iter()
5382 .filter_map(Value::as_str)
5383 .map(str::to_string)
5384 .collect()
5385 })
5386 .unwrap_or_default();
5387 let preserved_segment =
5388 metadata
5389 .and_then(|m| m.get("preservedSegment"))
5390 .and_then(|segment| {
5391 Some((
5392 segment.get("headUuid")?.as_str()?.to_string(),
5393 segment.get("tailUuid")?.as_str()?.to_string(),
5394 ))
5395 });
5396 Self {
5397 anchor_uuid,
5398 preserved_uuids,
5399 preserved_segment,
5400 }
5401 }
5402}
5403
5404fn claude_replay_error(message: impl Into<String>) -> crate::Error {
5405 crate::Error::Other(format!(
5406 "cannot reconstruct lossless Claude continuation: {}",
5407 message.into()
5408 ))
5409}
5410
5411fn claude_assistant_message_id(v: &Value) -> Option<&str> {
5412 v.get("message")
5413 .and_then(|message| message.get("id"))
5414 .and_then(Value::as_str)
5415}
5416
5417fn merge_claude_assistant_chunk(target: &mut Value, chunk: &Value) {
5418 let Some(target_message) = target.get_mut("message") else {
5419 return;
5420 };
5421 let Some(chunk_message) = chunk.get("message") else {
5422 return;
5423 };
5424 let mut content = target_message
5425 .get("content")
5426 .and_then(Value::as_array)
5427 .cloned()
5428 .unwrap_or_default();
5429 if let Some(blocks) = chunk_message.get("content").and_then(Value::as_array) {
5430 content.extend(blocks.iter().cloned());
5431 }
5432 let mut merged_message = chunk_message.clone();
5433 merged_message["content"] = Value::Array(content);
5434 *target_message = merged_message;
5435}
5436
5437fn flush_claude_assistant(pending: &mut Option<Value>, out: &mut Vec<ChatMessage>) {
5438 let Some(v) = pending.take() else {
5439 return;
5440 };
5441 let reasoning_only = claude_assistant_message_id(&v).is_some()
5442 && v.get("message")
5443 .and_then(|message| message.get("content"))
5444 .and_then(Value::as_array)
5445 .is_some_and(|blocks| {
5446 !blocks.is_empty()
5447 && blocks.iter().all(|block| {
5448 matches!(
5449 block.get("type").and_then(Value::as_str),
5450 Some("thinking" | "redacted_thinking")
5451 )
5452 })
5453 });
5454 if reasoning_only {
5455 return;
5456 }
5457 let before = out.len();
5458 push_claude_assistant(&v, out);
5459 capture_claude_record_provenance(&v, &mut out[before..]);
5460 restore_single_grok_message(&v, &mut out[before..]);
5461}
5462
5463/// Attach the record identity, clock, and actual assistant model to every
5464/// canonical message produced from one Claude JSONL record. These fields are
5465/// deliberately per-message: a continued transcript can cross a provider
5466/// boundary, so the session-level source model is not authoritative for its
5467/// appended tail.
5468fn capture_claude_record_provenance(v: &Value, messages: &mut [ChatMessage]) {
5469 let timestamp = v.get("timestamp").and_then(Value::as_str);
5470 let uuid = v.get("uuid").and_then(Value::as_str);
5471 let model = v
5472 .get("message")
5473 .and_then(|message| message.get("model"))
5474 .and_then(Value::as_str);
5475 for message in messages {
5476 if let Some(timestamp) = timestamp {
5477 message
5478 .metadata
5479 .entry("timestamp".to_string())
5480 .or_insert_with(|| timestamp.to_string());
5481 }
5482 if let Some(uuid) = uuid {
5483 message
5484 .metadata
5485 .entry("claude_uuid".to_string())
5486 .or_insert_with(|| uuid.to_string());
5487 }
5488 if let Some(model) = model {
5489 message
5490 .metadata
5491 .entry("model".to_string())
5492 .or_insert_with(|| model.to_string());
5493 }
5494 }
5495}
5496
5497fn capture_claude_meta(v: &Value, meta: &mut SessionMeta, raw_line: &str) -> Result<()> {
5498 restore_codex_provenance_from_top_level(v, meta)?;
5499 if meta.session_id.is_none() {
5500 if let Some(id) = v.get("sessionId").and_then(Value::as_str) {
5501 meta.session_id = Some(id.to_string());
5502 }
5503 }
5504 if meta.cwd.is_none() {
5505 if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
5506 meta.cwd = Some(PathBuf::from(cwd));
5507 }
5508 }
5509 if meta.model.is_none() {
5510 if let Some(model) = v
5511 .get("message")
5512 .and_then(|m| m.get("model"))
5513 .and_then(Value::as_str)
5514 {
5515 meta.model = Some(model.to_string());
5516 }
5517 }
5518 // PARITY-10 (provenance P010): `fork-context-ref` is an extremely rare
5519 // real Claude Code record with no confirmed field shape (see
5520 // `ClaudeRecord::ForkContextRef`'s doc comment) — rather than guess at
5521 // named fields and risk silently mis-modeling it, stash the WHOLE raw
5522 // line verbatim under a lineage key. `write_claude_code_records` (below)
5523 // re-emits it byte-for-byte, so the record survives the Claude Code
5524 // semantic writer (not just the CLI's raw-passthrough diagonal path) —
5525 // and `to_codex_jsonl`'s synthesized header carries a namespaced copy
5526 // so a Claude -> Codex -> Claude round trip can still reconstruct it
5527 // (dev/03). A session can only fork from one context, so the first one
5528 // seen wins, matching every other "first wins" field above.
5529 //
5530 // D4 (Fable-5 review, confirmed): this used to store `v.to_string()` —
5531 // a RE-SERIALIZATION of the parsed `Value`, not the original source
5532 // text. `serde_json::Value` here has no `preserve_order` feature (see
5533 // `Cargo.toml`), so its object keys are a `BTreeMap` and get silently
5534 // REORDERED (alphabetized) on re-emit — the "byte-for-byte" claim in
5535 // this very comment was false. Fixed the cheap+honest way: store the
5536 // caller's own already-verbatim source `raw_line` text instead of
5537 // re-serializing `v` at all, so this is now ACTUALLY byte-for-byte
5538 // (key order, spacing, everything) rather than merely
5539 // structurally-equivalent JSON.
5540 if v.get("type").and_then(Value::as_str) == Some("fork-context-ref")
5541 && !meta.lineage.contains_key("claude_fork_context_ref_raw")
5542 {
5543 meta.lineage.insert(
5544 "claude_fork_context_ref_raw".to_string(),
5545 raw_line.to_string(),
5546 );
5547 }
5548 Ok(())
5549}
5550
5551fn push_claude_user(v: &Value, out: &mut Vec<ChatMessage>) {
5552 let content = v.get("message").and_then(|m| m.get("content"));
5553 let provenance = claude_user_provenance(v);
5554 match content {
5555 Some(Value::String(s)) => {
5556 if !s.trim().is_empty() {
5557 out.push(ChatMessage::user(s.clone()).with_metas(&provenance));
5558 }
5559 }
5560 Some(Value::Array(blocks)) => {
5561 let mut text = String::new();
5562 // IX-5: image blocks alongside/instead of text — collected
5563 // separately (never synthesized on a malformed shape, see
5564 // `claude_image_block_to_part`) so a multimodal user turn
5565 // survives as `content_parts` instead of the image silently
5566 // vanishing.
5567 let mut images: Vec<Value> = Vec::new();
5568 // D5: an `image` block whose `source` isn't base64/url (e.g. a
5569 // Files-API `{"source":{"type":"file","file_id":..}}`
5570 // reference) makes `claude_image_block_to_part` return `None` —
5571 // track that it was SEEN even though it couldn't be converted,
5572 // so an image-ONLY record (no text, no convertible image) isn't
5573 // silently dropped below (the same vanishing-record bug-class
5574 // PARITY-11 fixed for reasoning-only turns).
5575 let mut saw_unconvertible_image = false;
5576 for b in blocks {
5577 match b.get("type").and_then(Value::as_str) {
5578 Some("text") => push_text(&mut text, b.get("text")),
5579 Some("tool_result") => {
5580 let id = b
5581 .get("tool_use_id")
5582 .and_then(Value::as_str)
5583 .unwrap_or_default();
5584 // PARITY-11 (nested images): `extract_tool_result_content`
5585 // captures any `image` blocks nested inside this
5586 // `tool_result` into `content_parts` (via
5587 // `claude_image_block_to_part`, the same conversion the
5588 // top-level `image` block path already uses) instead of
5589 // flattening them to the bare `[image]` marker text the
5590 // old `extract_tool_result` emitted — the everyday
5591 // "Read a PNG / screenshot tool output" shape.
5592 let (result, images) =
5593 extract_tool_result_content(b.get("content"), v.get("toolUseResult"));
5594 let mut msg = tool_message(id, result);
5595 if !images.is_empty() {
5596 // D-mix (Fable review, must-fix): `content_parts`
5597 // is a self-contained contract — the pi writer
5598 // (`pi_content_value`) reads ONLY `content_parts`
5599 // for a `Role::Tool` message and never falls back
5600 // to `msg.content`, so on a MIXED text+image
5601 // tool_result a bare `content_parts: [image]`
5602 // silently drops the sibling text on `convert
5603 // --to pi` (a regression vs. the pre-PARITY-11
5604 // baseline, which at least preserved the text).
5605 // Prepend the text as part 0, exactly mirroring
5606 // `pi_content_to_text_and_parts` and
5607 // `push_opencode_user`'s identical
5608 // self-contained-parts construction. `msg.content`
5609 // keeps the text too (unchanged) for the writers
5610 // that read text from `msg.content` and only scan
5611 // `content_parts` for `image_url` entries
5612 // (`claude_tool_result_content_value`,
5613 // `codex_tool_output_text`, the opencode
5614 // assistant writer) — those already filter
5615 // strictly on `image_url`/text-typed lookups, so
5616 // this text part is never double-counted.
5617 let mut parts = Vec::new();
5618 if let Some(t) = &msg.content {
5619 if !t.is_empty() {
5620 parts.push(serde_json::json!({"type": "text", "text": t}));
5621 }
5622 }
5623 parts.extend(images);
5624 msg.content_parts = Some(parts);
5625 }
5626 // The assistant turn that issued this tool call — the
5627 // tool-pairing graph edge (parallel to parentUuid).
5628 if let Some(src) = v.get("sourceToolAssistantUUID").and_then(Value::as_str)
5629 {
5630 msg.metadata
5631 .insert("sourceToolAssistantUUID".to_string(), src.to_string());
5632 }
5633 // TR-10: preserve the Claude wire `is_error` flag so
5634 // the reduction layer's success/failure boundary
5635 // (`ReductionKind::ToolInputElided` must never target
5636 // an errored call) survives import — `ChatMessage`
5637 // otherwise has no structural slot for it.
5638 if b.get("is_error").and_then(Value::as_bool) == Some(true) {
5639 crate::mark_tool_error(&mut msg);
5640 } else {
5641 restore_tool_outcome_extension(v, &mut msg);
5642 }
5643 out.push(msg);
5644 }
5645 Some("image") => match claude_image_block_to_part(b) {
5646 Some(part) => images.push(part),
5647 None => saw_unconvertible_image = true,
5648 },
5649 _ => {} // document / unknown — skip
5650 }
5651 }
5652 // D5: nothing convertible landed in `text`/`images` but an
5653 // image block WAS present — fold in the same short bracketed
5654 // marker convention already used for `[web_search]`/`[model
5655 // fallback: ...]` rather than letting the record vanish.
5656 if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
5657 push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
5658 }
5659 let before = out.len();
5660 if !images.is_empty() {
5661 let mut parts = Vec::new();
5662 if !text.trim().is_empty() {
5663 parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
5664 }
5665 parts.extend(images);
5666 out.push(
5667 ChatMessage {
5668 role: Role::User,
5669 content: None,
5670 content_parts: Some(parts),
5671 tool_calls: None,
5672 tool_call_id: None,
5673 name: None,
5674 metadata: Default::default(),
5675 }
5676 .with_metas(&provenance),
5677 );
5678 } else if !text.trim().is_empty() {
5679 out.push(ChatMessage::user(text).with_metas(&provenance));
5680 }
5681 if saw_unconvertible_image && out.len() > before {
5682 if let Some(msg) = out.last_mut() {
5683 msg.metadata
5684 .insert("image_source_unconvertible".to_string(), "true".to_string());
5685 }
5686 }
5687 }
5688 _ => {}
5689 }
5690}
5691
5692/// D5 (Fable-5 review): the bracketed marker folded into `text` when an
5693/// `image` block is SEEN but [`claude_image_block_to_part`] can't convert it
5694/// (e.g. a Files-API `{"source":{"type":"file",...}}` reference) and nothing
5695/// else in the record survives either — matches the existing
5696/// `[web_search]`/`[model fallback: ...]` convention rather than letting the
5697/// whole record vanish (`push_claude_user`/`push_claude_assistant`).
5698const UNCONVERTIBLE_IMAGE_MARKER: &str =
5699 "[image: source not captured — unsupported/unconvertible image reference]";
5700
5701/// Parse a Claude Code user-turn `image` content block
5702/// (`{"type":"image","source":{"type":"base64","media_type":..,"data":..}}`
5703/// or `{"type":"image","source":{"type":"url","url":..}}`) into a
5704/// `content_parts` `image_url` entry (a `data:` URI for the base64 form, the
5705/// bare URL for the url form) — the inverse of
5706/// [`claude_user_content_value`]'s emission. Only a well-formed source
5707/// (non-empty `media_type`/`data`, or non-empty `url`) is recognized;
5708/// anything else — including a well-formed but unconvertible source like a
5709/// Files-API `{"type":"file",...}` reference (D5) — is left as raw-only
5710/// residue rather than synthesizing a corrupt/empty part (mirrors the
5711/// pi/opencode loaders' `pi_image_shape`/`opencode_file_image_part`
5712/// discipline). Callers must not let that turn the record invisible though:
5713/// see [`UNCONVERTIBLE_IMAGE_MARKER`].
5714fn claude_image_block_to_part(b: &Value) -> Option<Value> {
5715 let source = b.get("source")?;
5716 match source.get("type").and_then(Value::as_str) {
5717 Some("base64") => {
5718 let mime = source.get("media_type").and_then(Value::as_str)?;
5719 let data = source.get("data").and_then(Value::as_str)?;
5720 if mime.is_empty() || data.is_empty() {
5721 return None;
5722 }
5723 Some(serde_json::json!({
5724 "type": "image_url",
5725 "image_url": {"url": format!("data:{mime};base64,{data}")},
5726 }))
5727 }
5728 Some("url") => {
5729 let url = source.get("url").and_then(Value::as_str)?;
5730 if url.is_empty() {
5731 return None;
5732 }
5733 Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
5734 }
5735 _ => None,
5736 }
5737}
5738
5739/// Rebuild a Claude Code user-turn `message.content` value from a
5740/// `ChatMessage` — the inverse of `push_claude_user`'s parse (text blocks +
5741/// [`claude_image_block_to_part`]). When `content_parts` is absent this
5742/// MUST reproduce the historical plain-string `content` exactly (IX-5's
5743/// overriding constraint: a text-only message's export stays byte-identical)
5744/// — only a multimodal message (`content_parts` present, e.g. imported from
5745/// Pi/OpenCode or a real Claude Code image turn) gets the Anthropic
5746/// content-array shape, one `text` block (if any non-empty text part) plus
5747/// one `image` block per `image_url` part (`data:` URI → `source.base64`;
5748/// any other URL → `source.url`).
5749fn claude_user_content_value(msg: &ChatMessage) -> Value {
5750 match &msg.content_parts {
5751 Some(parts) => {
5752 let mut blocks = Vec::new();
5753 for p in parts {
5754 match p.get("type").and_then(Value::as_str) {
5755 Some("text") => {
5756 if let Some(t) = p.get("text").and_then(Value::as_str) {
5757 if !t.is_empty() {
5758 blocks.push(serde_json::json!({"type": "text", "text": t}));
5759 }
5760 }
5761 }
5762 Some("image_url") => {
5763 if let Some(url) = p
5764 .get("image_url")
5765 .and_then(|u| u.get("url"))
5766 .and_then(Value::as_str)
5767 {
5768 blocks.push(match parse_data_uri(url) {
5769 Some((mime, data)) => serde_json::json!({
5770 "type": "image",
5771 "source": {"type": "base64", "media_type": mime, "data": data},
5772 }),
5773 None => serde_json::json!({
5774 "type": "image",
5775 "source": {"type": "url", "url": url},
5776 }),
5777 });
5778 }
5779 }
5780 _ => {}
5781 }
5782 }
5783 Value::Array(blocks)
5784 }
5785 None => Value::String(msg.content.clone().unwrap_or_default()),
5786 }
5787}
5788
5789/// Rebuild a Claude Code `tool_result` block's `content` value from a `Tool`
5790/// `ChatMessage` — the inverse of [`extract_tool_result_content`]'s parse
5791/// (PARITY-11). When `content_parts` is absent (or empty) this MUST reproduce
5792/// the historical plain-string `content` exactly (same IX-5-style constraint
5793/// `claude_user_content_value` follows) — only a `tool_result` that actually
5794/// carries a captured nested image gets the Anthropic content-array shape,
5795/// one `text` block (the existing `msg.content`, if any) plus one `image`
5796/// block per `image_url` part (mirrors `claude_user_content_value`'s
5797/// `data:` URI -> `source.base64` / other URL -> `source.url` mapping).
5798fn claude_tool_result_content_value(msg: &ChatMessage) -> Value {
5799 match &msg.content_parts {
5800 Some(parts) if !parts.is_empty() => {
5801 let mut blocks = Vec::new();
5802 if let Some(t) = &msg.content {
5803 if !t.is_empty() {
5804 blocks.push(serde_json::json!({"type": "text", "text": t}));
5805 }
5806 }
5807 for p in parts {
5808 if p.get("type").and_then(Value::as_str) == Some("image_url") {
5809 if let Some(url) = p
5810 .get("image_url")
5811 .and_then(|u| u.get("url"))
5812 .and_then(Value::as_str)
5813 {
5814 blocks.push(match parse_data_uri(url) {
5815 Some((mime, data)) => serde_json::json!({
5816 "type": "image",
5817 "source": {"type": "base64", "media_type": mime, "data": data},
5818 }),
5819 None => serde_json::json!({
5820 "type": "image",
5821 "source": {"type": "url", "url": url},
5822 }),
5823 });
5824 }
5825 }
5826 }
5827 Value::Array(blocks)
5828 }
5829 _ => Value::String(msg.content.clone().unwrap_or_default()),
5830 }
5831}
5832
5833/// Collect the Claude Code user-turn provenance fields that distinguish real
5834/// human input from system-injected turns and record replay-relevant state.
5835pub(crate) fn claude_user_provenance(v: &Value) -> Vec<(String, String)> {
5836 let mut out = Vec::new();
5837 let mut take_str = |key: &str| {
5838 if let Some(s) = v.get(key).and_then(Value::as_str) {
5839 out.push((key.to_string(), s.to_string()));
5840 }
5841 };
5842 take_str("promptSource"); // typed | queued | system | sdk
5843 take_str("interruptedMessageId");
5844 take_str("sourceToolUseID");
5845 for flag in ["isMeta", "isCompactSummary", "isVisibleInTranscriptOnly"] {
5846 if v.get(flag).and_then(Value::as_bool) == Some(true) {
5847 out.push((flag.to_string(), "true".to_string()));
5848 }
5849 }
5850 if let Some(n) = v.get("queuePriority").and_then(Value::as_i64) {
5851 out.push(("queuePriority".to_string(), n.to_string()));
5852 }
5853 // `origin` is an object like {"kind":"task-notification"} — keep its kind.
5854 if let Some(kind) = v
5855 .get("origin")
5856 .and_then(|o| o.get("kind"))
5857 .and_then(Value::as_str)
5858 {
5859 out.push(("origin".to_string(), kind.to_string()));
5860 }
5861 out
5862}
5863
5864/// Content-bearing Claude `system` events (`scheduled_task_fire`,
5865/// `local_command`, `away_summary`) carry real text that's part of the
5866/// interaction; fold them in as system context. Marker/metric subtypes
5867/// (`turn_duration`, `compact_boundary`, `api_error`, `stop_hook_summary`) carry
5868/// no conversational content and are skipped.
5869fn push_claude_system(v: &Value, out: &mut Vec<ChatMessage>) {
5870 let keep = matches!(
5871 v.get("subtype").and_then(Value::as_str),
5872 Some("scheduled_task_fire") | Some("local_command") | Some("away_summary")
5873 );
5874 if !keep {
5875 return;
5876 }
5877 if let Some(content) = v.get("content").and_then(Value::as_str) {
5878 if !content.trim().is_empty() {
5879 let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("system");
5880 out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
5881 }
5882 }
5883}
5884
5885/// Fold content-bearing Claude Code `attachment` records into the conversation
5886/// as user-role messages. Most attachment subtypes (`task_reminder`,
5887/// `deferred_tools_delta`, `skill_listing`, `hook_*`, `command_permissions`, …)
5888/// are regenerable system injections and are skipped; only the four that carry
5889/// non-regenerable user/external content are kept.
5890fn push_claude_attachment(v: &Value, out: &mut Vec<ChatMessage>) {
5891 let att = match v.get("attachment") {
5892 Some(a) => a,
5893 None => return,
5894 };
5895 let kind = match att.get("type").and_then(Value::as_str) {
5896 Some(kind) => kind,
5897 None => return,
5898 };
5899 let text = match kind {
5900 // A queued prompt. `commandMode` says whose: `prompt` is the person's
5901 // own text, `task-notification` is the runtime reporting a finished
5902 // background task. Kept verbatim below.
5903 "queued_command" => att
5904 .get("prompt")
5905 .and_then(Value::as_str)
5906 .map(str::to_string),
5907 // A file the user attached: header + contents.
5908 "file" => attachment_with_path(att, "attached file", "filename", "content"),
5909 // A user-edited file snippet.
5910 "edited_text_file" => attachment_with_path(att, "edited file", "filename", "snippet"),
5911 // Injected project memory (CLAUDE.md), point-in-time.
5912 "nested_memory" => attachment_with_path(att, "project memory", "path", "content"),
5913 _ => None, // regenerable system injection — skip
5914 };
5915 let Some(text) = text else { return };
5916 if text.trim().is_empty() {
5917 return;
5918 }
5919 // An attachment record wears the user's ROLE, but the record itself says
5920 // who actually spoke — and that fact is lost the moment the attachment is
5921 // flattened to `[label: path]` text, so carry it as metadata the way
5922 // `push_claude_system` carries `systemSubtype`. Two fields, both verbatim:
5923 //
5924 // `attachmentType` the subtype. `file` / `edited_text_file` /
5925 // `nested_memory` are envelopes the runtime built
5926 // around a file body; a frontend that trusts the role
5927 // shows the reader a numbered source listing in a
5928 // chat bubble apparently sent by themselves.
5929 // `commandMode` present on `queued_command` only, and the whole
5930 // story for it. Measured over the local Claude Code
5931 // corpus (2,512 `queued_command` attachments): 926
5932 // `prompt`, every one of them plain human text, and
5933 // 1,586 `task-notification`, every one of them a
5934 // `<task-notification>` frame — the same text Claude
5935 // Code also writes as a `type:"user"` record stamped
5936 // `origin.kind = "task-notification"`.
5937 //
5938 // Presentation policy (which of these a frontend hides) belongs to the
5939 // frontend — `isContextMessage` in `sdk/client/client.mjs`. The loader's
5940 // job is to stop discarding the producer's own answer.
5941 let mut message = ChatMessage::user(text).with_meta("attachmentType", kind);
5942 if let Some(mode) = att.get("commandMode").and_then(Value::as_str) {
5943 message = message.with_meta("commandMode", mode);
5944 }
5945 out.push(message);
5946}
5947
5948/// Format an attachment as `[<label>: <path>]\n<body>`.
5949fn attachment_with_path(
5950 att: &Value,
5951 label: &str,
5952 path_key: &str,
5953 body_key: &str,
5954) -> Option<String> {
5955 let body = att.get(body_key).and_then(Value::as_str)?;
5956 let path = att
5957 .get(path_key)
5958 .or_else(|| att.get("displayPath"))
5959 .and_then(Value::as_str)
5960 .unwrap_or("");
5961 Some(format!("[{label}: {path}]\n{body}"))
5962}
5963
5964fn push_str_field(buf: &mut String, s: &str) {
5965 if !buf.is_empty() {
5966 buf.push('\n');
5967 }
5968 buf.push_str(s);
5969}
5970
5971/// N3: build a synthesized message for reasoning that could not attach to a
5972/// following assistant turn — either interrupted mid-stream by a
5973/// non-assistant item, or dangling at EOF (an aborted-turn shape). Drains
5974/// the three pending buffers (all empty/`false` afterward) so callers don't
5975/// separately have to remember to clear them.
5976fn orphaned_reasoning_message(
5977 reasoning: &mut String,
5978 reasoning_content: &mut String,
5979 encrypted: &mut bool,
5980) -> ChatMessage {
5981 let mut msg = ChatMessage::system("[reasoning] (turn ended without a reply)".to_string());
5982 if !reasoning.is_empty() {
5983 msg = msg.with_meta("reasoning", std::mem::take(reasoning));
5984 }
5985 if !reasoning_content.is_empty() {
5986 msg = msg.with_meta("reasoning_content", std::mem::take(reasoning_content));
5987 }
5988 if *encrypted {
5989 msg = msg.with_meta("reasoning_encrypted", "true");
5990 *encrypted = false;
5991 }
5992 msg
5993}
5994
5995fn push_claude_assistant(v: &Value, out: &mut Vec<ChatMessage>) {
5996 let content = v.get("message").and_then(|m| m.get("content"));
5997 let mut text = String::new();
5998 let mut calls: Vec<ToolCall> = Vec::new();
5999 // Legacy singular fields — kept for backward compatibility with every
6000 // existing consumer of `metadata["thinking"]`/`["thinking_signature"]`/
6001 // `["redacted_thinking"]` (a concatenation of all thinking text, and the
6002 // LAST block's signature/data). D8 (Fable-5 review, confirmed): when a
6003 // message carries MULTIPLE `thinking` blocks, collapsing them down to
6004 // these singular fields silently drops every signature but the last
6005 // one's — a real Anthropic `thinking` block's `signature` cryptographically
6006 // covers ONLY that block's own text, so re-emitting block 1's text under
6007 // block 2's signature (or vice versa) produces a signature that will
6008 // never verify. `thinking_blocks` below is the fix: every block
6009 // preserved SEPARATELY, in order, each with its own (optional)
6010 // signature/data — the writer prefers it over the legacy fields
6011 // whenever present.
6012 let mut thinking = String::new();
6013 let mut signature: Option<String> = None;
6014 // PARITY-11: real Claude corpora also carry `redacted_thinking` and
6015 // `image` assistant blocks, and (rarely) a `fallback` model-routing
6016 // marker — none handled before, all silently vanishing (audit's own
6017 // census: 132,927 `thinking` / 67 `redacted_thinking` / 9 `image` / 4
6018 // `fallback` blocks in the reference corpus).
6019 //
6020 // D8: `redacted_thinking` is real data ONLY — never a fabricated
6021 // placeholder. The pre-fix code defaulted a missing `data` field to the
6022 // literal string `"<redacted>"`, which is indistinguishable from an
6023 // actual (if oddly-named) opaque payload on re-emit — a caller reading
6024 // it back has no way to tell "no data was ever captured" from "the
6025 // provider's own opaque blob happens to be the string `<redacted>`".
6026 // `redacted_thinking_seen` tracks block PRESENCE independently of
6027 // whether it had real data, so the reasoning-only-turn rescue below
6028 // still fires even when no block had a `data` field at all.
6029 let mut redacted_thinking: Option<String> = None;
6030 let mut redacted_thinking_seen = false;
6031 let mut images: Vec<Value> = Vec::new();
6032 // Real Anthropic `thinking` blocks very commonly carry an EMPTY
6033 // `thinking` string alongside a real `signature` (the summarized/
6034 // redacted-in-the-clear-but-replayable case) — `thinking.trim()` alone
6035 // would miss those, so track "a thinking block existed at all"
6036 // separately from whether it had visible text.
6037 let mut thinking_block_seen = false;
6038 // D8: every `thinking`/`redacted_thinking` block, preserved SEPARATELY
6039 // and IN ORDER — see the comment on `thinking`/`redacted_thinking`
6040 // above. Serialized as a single JSON-array metadata string
6041 // (`ChatMessage::metadata` is a flat string map) under
6042 // `"thinking_blocks"`.
6043 let mut thinking_blocks: Vec<Value> = Vec::new();
6044 // D5: mirrors `push_claude_user`'s tracking — an `image` block whose
6045 // source isn't base64/url (e.g. Files-API `{"type":"file",...}`) must
6046 // not silently vanish the whole record when nothing else survives.
6047 let mut saw_unconvertible_image = false;
6048
6049 match content {
6050 Some(Value::String(s)) => push_text(&mut text, Some(&Value::String(s.clone()))),
6051 Some(Value::Array(blocks)) => {
6052 for b in blocks {
6053 match b.get("type").and_then(Value::as_str) {
6054 Some("text") => push_text(&mut text, b.get("text")),
6055 Some("tool_use") => {
6056 let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
6057 let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
6058 let args = b
6059 .get("input")
6060 .map(|i| i.to_string())
6061 .unwrap_or_else(|| "{}".to_string());
6062 calls.push(function_call(id, name, args));
6063 }
6064 // Thinking is not replayed across providers, but retain it in
6065 // (skip-serialized) metadata so a same-model continuation can
6066 // re-inject it. See P3.
6067 Some("thinking") => {
6068 thinking_block_seen = true;
6069 let t = b.get("thinking").and_then(Value::as_str).unwrap_or("");
6070 if !t.is_empty() {
6071 push_str_field(&mut thinking, t); // legacy concatenated field
6072 }
6073 let sig = b.get("signature").and_then(Value::as_str);
6074 if let Some(s) = sig {
6075 signature = Some(s.to_string()); // legacy last-wins field
6076 }
6077 // D8: this block's OWN text + signature, not folded
6078 // into the running concatenation above.
6079 let mut block = serde_json::json!({"type": "thinking", "thinking": t});
6080 if let Some(s) = sig {
6081 block["signature"] = Value::String(s.to_string());
6082 }
6083 thinking_blocks.push(block);
6084 }
6085 // Anthropic's redacted reasoning: an opaque, provider-private
6086 // payload (flagged content the API declines to show in the
6087 // clear). Like `thinking`, it's not replayable, but the raw
6088 // `data` is retained in metadata rather than silently
6089 // vanishing — a same-model continuation can still replay it
6090 // verbatim even though supercode never renders it.
6091 Some("redacted_thinking") => {
6092 redacted_thinking_seen = true;
6093 let data = b.get("data").and_then(Value::as_str);
6094 // D8: no fabricated fallback — `data` is only ever
6095 // the real captured payload, or genuinely absent.
6096 if let Some(d) = data {
6097 redacted_thinking = Some(d.to_string()); // legacy last-wins field
6098 }
6099 let mut block = serde_json::json!({"type": "redacted_thinking"});
6100 if let Some(d) = data {
6101 block["data"] = Value::String(d.to_string());
6102 }
6103 thinking_blocks.push(block);
6104 }
6105 // An assistant-emitted image block (e.g. a generated
6106 // image) — collected exactly like `push_claude_user`'s
6107 // user-turn image handling (`claude_image_block_to_part`
6108 // is role-general), so it survives as `content_parts`
6109 // instead of vanishing.
6110 Some("image") => match claude_image_block_to_part(b) {
6111 Some(part) => images.push(part),
6112 None => saw_unconvertible_image = true,
6113 },
6114 // A provider-routing note (real shape:
6115 // `{"type":"fallback","from":{"model":..},"to":{"model":..}}`
6116 // — a mid-generation model swap, e.g. an overloaded model
6117 // falling back to another). Carries no replayable
6118 // conversational content, but folding it into `text` as a
6119 // short bracketed marker — the same convention the Codex
6120 // loader already uses for `[web_search]`/
6121 // `[image_generation] ...` — keeps it visible instead of
6122 // silently vanishing, including the case where it's the
6123 // ONLY block in the turn (see the reasoning-only-turn fix
6124 // below: before this, that shape dropped the entire
6125 // message).
6126 Some("fallback") => {
6127 let from = b
6128 .get("from")
6129 .and_then(|f| f.get("model"))
6130 .and_then(Value::as_str)
6131 .unwrap_or("?");
6132 let to = b
6133 .get("to")
6134 .and_then(|t| t.get("model"))
6135 .and_then(Value::as_str)
6136 .unwrap_or("?");
6137 push_str_field(&mut text, &format!("[model fallback: {from} -> {to}]"));
6138 }
6139 _ => {}
6140 }
6141 }
6142 }
6143 _ => {}
6144 }
6145
6146 // D5: nothing convertible landed in `text`/`images` but an image block
6147 // WAS present — fold in the same bracketed-marker convention `fallback`
6148 // uses above, so a genuinely image-only (unconvertible source) turn
6149 // doesn't vanish (mirrors `push_claude_user`'s identical fix).
6150 if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
6151 push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
6152 }
6153
6154 let before = out.len();
6155 if !images.is_empty() {
6156 let mut parts = Vec::new();
6157 if !text.trim().is_empty() {
6158 parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6159 }
6160 parts.extend(images);
6161 out.push(ChatMessage {
6162 role: Role::Assistant,
6163 content: None,
6164 content_parts: Some(parts),
6165 tool_calls: (!calls.is_empty()).then_some(calls),
6166 tool_call_id: None,
6167 name: None,
6168 metadata: Default::default(),
6169 });
6170 } else {
6171 push_assistant(out, text, calls);
6172 // A recognized native assistant record remains transcript state even
6173 // when its content array is empty (for example, an interrupted model
6174 // turn). Force a bare message whenever `push_assistant` had nothing
6175 // to emit. This includes the reasoning-only case and also preserves
6176 // genuinely part-less records instead of silently changing turn
6177 // count/order during translation.
6178 if out.len() == before {
6179 let mut empty = ChatMessage {
6180 role: Role::Assistant,
6181 content: None,
6182 content_parts: None,
6183 tool_calls: None,
6184 tool_call_id: None,
6185 name: None,
6186 metadata: Default::default(),
6187 };
6188 if !thinking_block_seen && !redacted_thinking_seen {
6189 empty
6190 .metadata
6191 .insert("empty_assistant_record".to_string(), "true".to_string());
6192 }
6193 out.push(empty);
6194 }
6195 }
6196 // Attach retained reasoning + attribution to the message we just produced.
6197 if out.len() > before {
6198 if let Some(msg) = out.last_mut() {
6199 // Insert "thinking" (even as an empty string) whenever a
6200 // `thinking` block was actually seen, not just when it had
6201 // visible text — a real `thinking` block commonly carries an
6202 // empty `thinking` string alongside a real `signature` (the
6203 // summarized-away-but-still-replayable case), and the writer
6204 // below keys its re-emission decision off this metadata key's
6205 // PRESENCE, not its content.
6206 if thinking_block_seen {
6207 msg.metadata.insert("thinking".to_string(), thinking);
6208 }
6209 if let Some(sig) = signature {
6210 msg.metadata.insert("thinking_signature".to_string(), sig);
6211 }
6212 if let Some(rt) = redacted_thinking {
6213 msg.metadata.insert("redacted_thinking".to_string(), rt);
6214 }
6215 // D8: exact per-block re-emission list — every `thinking`/
6216 // `redacted_thinking` block preserved separately, in order, each
6217 // with its own (optional) signature/data. The writer prefers
6218 // this over the legacy singular fields above whenever present,
6219 // so a multi-block message round-trips losslessly instead of
6220 // collapsing to one block under one (now-unverifiable)
6221 // signature.
6222 if !thinking_blocks.is_empty() {
6223 msg.metadata.insert(
6224 "thinking_blocks".to_string(),
6225 Value::Array(thinking_blocks).to_string(),
6226 );
6227 }
6228 // D5: honest signal that this message contained an image block
6229 // whose source this loader couldn't convert — the actual image
6230 // content is NOT captured, only a marker/partial record.
6231 if saw_unconvertible_image {
6232 msg.metadata
6233 .insert("image_source_unconvertible".to_string(), "true".to_string());
6234 }
6235 // Attribution: which skill / subagent / MCP server+tool produced
6236 // this turn, plus the model `slug`.
6237 for key in [
6238 "attributionSkill",
6239 "attributionAgent",
6240 "attributionMcpServer",
6241 "attributionMcpTool",
6242 "slug",
6243 ] {
6244 if let Some(s) = v.get(key).and_then(Value::as_str) {
6245 msg.metadata.insert(key.to_string(), s.to_string());
6246 }
6247 }
6248 }
6249 }
6250}
6251
6252// ---- Codex ----------------------------------------------------------------
6253
6254const SUPERCODE_CODEX_PROVENANCE_KEY: &str = "_supercode_codex_provenance";
6255
6256fn codex_provenance_kind(record: &Value) -> Option<&str> {
6257 match record.get("type").and_then(Value::as_str) {
6258 Some("session_meta") => Some("session_meta"),
6259 Some("turn_context") => Some("turn_context"),
6260 Some("compacted") => Some("compacted"),
6261 Some("event_msg") => match record
6262 .get("payload")
6263 .and_then(|payload| payload.get("type"))
6264 .and_then(Value::as_str)
6265 {
6266 Some("thread_rolled_back") => Some("event_msg/thread_rolled_back"),
6267 Some("thread_goal_updated") => Some("event_msg/thread_goal_updated"),
6268 Some("entered_review_mode") => Some("event_msg/entered_review_mode"),
6269 Some("exited_review_mode") => Some("event_msg/exited_review_mode"),
6270 _ => None,
6271 },
6272 _ => None,
6273 }
6274}
6275
6276fn capture_codex_provenance_record(
6277 meta: &mut SessionMeta,
6278 record_index: usize,
6279 raw_line: &str,
6280 record: &Value,
6281) {
6282 let Some(kind) = codex_provenance_kind(record) else {
6283 return;
6284 };
6285 meta.codex_provenance.push(serde_json::json!({
6286 "record_index": record_index,
6287 "kind": kind,
6288 "raw": raw_line,
6289 }));
6290}
6291
6292fn codex_provenance_envelope(meta: &SessionMeta) -> Option<Value> {
6293 (!meta.codex_provenance.is_empty()).then(|| {
6294 serde_json::json!({
6295 "version": 1,
6296 "records": &meta.codex_provenance,
6297 })
6298 })
6299}
6300
6301fn restore_codex_provenance(extension: &Value, meta: &mut SessionMeta) -> Result<bool> {
6302 if extension.get("version").and_then(Value::as_u64) != Some(1) {
6303 return Err(Error::InvalidSession(
6304 "invalid portable Codex provenance: expected version 1".to_string(),
6305 ));
6306 }
6307 let Some(records) = extension.get("records").and_then(Value::as_array) else {
6308 return Err(Error::InvalidSession(
6309 "invalid portable Codex provenance: `records` must be an array".to_string(),
6310 ));
6311 };
6312 if records.is_empty() {
6313 return Err(Error::InvalidSession(
6314 "invalid portable Codex provenance: `records` must not be empty".to_string(),
6315 ));
6316 }
6317 let mut restored = Vec::with_capacity(records.len());
6318 for entry in records {
6319 let Some(_record_index) = entry.get("record_index").and_then(Value::as_u64) else {
6320 return Err(Error::InvalidSession(
6321 "invalid portable Codex provenance: record_index must be an integer".to_string(),
6322 ));
6323 };
6324 let Some(kind) = entry.get("kind").and_then(Value::as_str) else {
6325 return Err(Error::InvalidSession(
6326 "invalid portable Codex provenance: kind must be a string".to_string(),
6327 ));
6328 };
6329 let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6330 return Err(Error::InvalidSession(
6331 "invalid portable Codex provenance: raw must be a string".to_string(),
6332 ));
6333 };
6334 let Ok(record) = serde_json::from_str::<Value>(raw) else {
6335 return Err(Error::InvalidSession(
6336 "invalid portable Codex provenance: raw is not valid JSON".to_string(),
6337 ));
6338 };
6339 if codex_provenance_kind(&record) != Some(kind) {
6340 return Err(Error::InvalidSession(format!(
6341 "invalid portable Codex provenance: kind `{kind}` does not match raw record"
6342 )));
6343 }
6344 restored.push(entry.clone());
6345 }
6346 meta.codex_provenance = restored;
6347 meta.codex_headers.clear();
6348 for entry in &meta.codex_provenance {
6349 let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6350 continue;
6351 };
6352 let Ok(record) = serde_json::from_str::<Value>(raw) else {
6353 continue;
6354 };
6355 if matches!(
6356 record.get("type").and_then(Value::as_str),
6357 Some("session_meta") | Some("turn_context")
6358 ) {
6359 meta.codex_headers.push(record);
6360 }
6361 }
6362 Ok(true)
6363}
6364
6365fn restore_codex_provenance_from_top_level(record: &Value, meta: &mut SessionMeta) -> Result<bool> {
6366 match record.get(SUPERCODE_CODEX_PROVENANCE_KEY) {
6367 Some(extension) => restore_codex_provenance(extension, meta),
6368 None => Ok(false),
6369 }
6370}
6371
6372fn inject_first_jsonl_top_level(out: &mut String, key: &str, extension: Value) {
6373 let Some(line_end) = out.find('\n') else {
6374 return;
6375 };
6376 let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6377 return;
6378 };
6379 let Some(object) = record.as_object_mut() else {
6380 return;
6381 };
6382 object.insert(key.to_string(), extension);
6383 out.replace_range(..line_end, &record.to_string());
6384}
6385
6386fn inject_codex_provenance(out: &mut String, extension: Value) {
6387 let Some(line_end) = out.find('\n') else {
6388 return;
6389 };
6390 let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6391 return;
6392 };
6393 if record.get("type").and_then(Value::as_str) != Some("session_meta") {
6394 return;
6395 }
6396 let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) else {
6397 return;
6398 };
6399 payload.insert(SUPERCODE_CODEX_PROVENANCE_KEY.to_string(), extension);
6400 out.replace_range(..line_end, &record.to_string());
6401}
6402
6403/// Remove the last conversational turn from `messages`: everything from the
6404/// last `user` message to the end (the user prompt plus the assistant's
6405/// response and any tool calls/results it triggered).
6406fn remove_last_turn(messages: &mut Vec<ChatMessage>) {
6407 if let Some(idx) = messages.iter().rposition(|m| m.role == Role::User) {
6408 messages.truncate(idx);
6409 } else {
6410 messages.clear();
6411 }
6412 // IX-6 fix: the new tail exposed by `truncate` may still carry
6413 // `__codex_open_turn` from when it was marked (it was NOT the last
6414 // message at that time — items after it, now removed by the rollback,
6415 // intervened). A bare `function_call` arriving after the rollback is a
6416 // genuinely NEW turn and must get its own message, not merge into this
6417 // stale marked tail — close it out here so `push_codex_item`'s
6418 // adjacency check (`out.last()` + marker) can't be fooled by the
6419 // truncation re-exposing it.
6420 if let Some(last) = messages.last_mut() {
6421 last.metadata.remove("__codex_open_turn");
6422 }
6423}
6424
6425fn truncate_messages(messages: &mut Vec<ChatMessage>, message_limit: usize) {
6426 truncate_messages_with_anchor(messages, message_limit, Vec::new());
6427}
6428
6429fn truncate_messages_with_anchor(
6430 messages: &mut Vec<ChatMessage>,
6431 message_limit: usize,
6432 preceding_users: Vec<ChatMessage>,
6433) {
6434 let limit = message_limit.max(1);
6435 // Anchor the numeric tail at its own human boundary. Choosing the newest
6436 // users anywhere in the window is not enough: after several short prompts
6437 // followed by a tool-heavy turn, those users can all sit near the end while
6438 // the tail begins in older orphaned tool activity. The UI then correctly
6439 // hides that activity, so increasing `tail_messages` appears to load
6440 // nothing and the next prompt can collapse the visible history.
6441 //
6442 // Keep up to two users from before the raw tail boundary. The nearest one
6443 // makes the retained activity a real turn; the preceding one preserves
6444 // overlap when one turn alone exceeds the whole numeric window.
6445 let raw_tail_start = messages.len().saturating_sub(limit);
6446 let anchor_limit = if messages.len() < limit {
6447 limit.saturating_sub(messages.len()).min(2)
6448 } else {
6449 limit.min(2)
6450 };
6451 let mut anchor_candidates = preceding_users;
6452 anchor_candidates.extend(
6453 messages[..raw_tail_start]
6454 .iter()
6455 .filter(|message| message.role == Role::User)
6456 .cloned(),
6457 );
6458 let mut anchors = anchor_candidates
6459 .into_iter()
6460 .rev()
6461 .take(anchor_limit)
6462 .collect::<Vec<_>>();
6463 anchors.reverse();
6464
6465 if messages.len() <= limit && anchors.is_empty() {
6466 return;
6467 }
6468 let tail_count = limit.saturating_sub(anchors.len()).min(messages.len());
6469 let tail_start = messages.len() - tail_count;
6470 let mut selected = Vec::with_capacity(anchors.len() + tail_count);
6471 selected.append(&mut anchors);
6472 selected.extend(messages[tail_start..].iter().cloned());
6473 debug_assert_eq!(selected.len(), limit.min(messages.len() + anchor_limit));
6474 *messages = selected;
6475}
6476
6477fn truncate_session_messages(session: &mut Session, message_limit: usize) {
6478 truncate_messages(&mut session.messages, message_limit);
6479}
6480
6481/// The text of a Codex `agent_message` event. `message` is usually a string but
6482/// can be a structured object (e.g. review output) — fall back to its JSON.
6483fn agent_message_text(payload: &Value) -> String {
6484 match payload.get("message") {
6485 Some(Value::String(s)) => s.clone(),
6486 Some(other) => extract_text_content(Some(other)),
6487 None => String::new(),
6488 }
6489}
6490
6491/// Trimmed texts of all assistant messages present as `response_item` — the
6492/// dedup set for recovering collab-only `agent_message` narration.
6493fn collect_codex_assistant_texts(jsonl: &str) -> std::collections::HashSet<String> {
6494 let mut set = std::collections::HashSet::new();
6495 for line in non_empty_lines(jsonl) {
6496 let Ok(v) = serde_json::from_str::<Value>(line) else {
6497 continue;
6498 };
6499 if v.get("type").and_then(Value::as_str) != Some("response_item") {
6500 continue;
6501 }
6502 let payload = v.get("payload").unwrap_or(&Value::Null);
6503 if payload.get("type").and_then(Value::as_str) == Some("message")
6504 && payload.get("role").and_then(Value::as_str) == Some("assistant")
6505 {
6506 let text = extract_text_content(payload.get("content"));
6507 if !text.trim().is_empty() {
6508 set.insert(text.trim().to_string());
6509 }
6510 }
6511 }
6512 set
6513}
6514
6515fn capture_codex_session_meta(payload: &Value, meta: &mut SessionMeta) {
6516 if meta.session_id.is_none() {
6517 if let Some(id) = payload.get("id").and_then(Value::as_str) {
6518 meta.session_id = Some(id.to_string());
6519 }
6520 }
6521 if meta.cwd.is_none() {
6522 if let Some(cwd) = payload.get("cwd").and_then(Value::as_str) {
6523 meta.cwd = Some(PathBuf::from(cwd));
6524 }
6525 }
6526 if meta.system_prompt.is_none() {
6527 // `base_instructions` may be a string or `{ "text": "..." }`.
6528 let bi = payload.get("base_instructions");
6529 let text = match bi {
6530 Some(Value::String(s)) => Some(s.clone()),
6531 Some(Value::Object(_)) => bi
6532 .and_then(|b| b.get("text"))
6533 .and_then(Value::as_str)
6534 .map(str::to_string),
6535 _ => None,
6536 };
6537 meta.system_prompt = text;
6538 }
6539 if meta.model.is_none() {
6540 if let Some(m) = payload.get("model").and_then(Value::as_str) {
6541 meta.model = Some(m.to_string());
6542 }
6543 }
6544 // Cross-file lineage keys for multi-agent / forked sessions.
6545 let mut put = |key: &str, v: Option<&Value>| {
6546 if let Some(s) = v.and_then(Value::as_str) {
6547 meta.lineage.insert(key.to_string(), s.to_string());
6548 }
6549 };
6550 put("parent_thread_id", payload.get("parent_thread_id"));
6551 put("forked_from_id", payload.get("forked_from_id"));
6552 put("thread_source", payload.get("thread_source"));
6553 // PARITY-10 dev/03: the other half of `write_synthesized_codex_header`'s
6554 // passthrough — restores a captured Claude `fork-context-ref` so a
6555 // Claude -> Codex -> Claude round trip reconstructs the original record
6556 // (`to_claude_code_jsonl` re-emits whatever lands in this lineage key).
6557 if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
6558 if let Some(v) = payload.get("claude_fork_context_ref") {
6559 meta.lineage
6560 .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
6561 }
6562 }
6563 if let Some(spawn) = payload
6564 .get("source")
6565 .and_then(|s| s.get("subagent"))
6566 .and_then(|s| s.get("thread_spawn"))
6567 {
6568 // parent_thread_id can also live here (preferred when both present).
6569 if let Some(p) = spawn.get("parent_thread_id").and_then(Value::as_str) {
6570 meta.lineage
6571 .insert("parent_thread_id".to_string(), p.to_string());
6572 }
6573 for k in ["agent_role", "agent_nickname"] {
6574 if let Some(s) = spawn.get(k).and_then(Value::as_str) {
6575 meta.lineage.insert(k.to_string(), s.to_string());
6576 }
6577 }
6578 if let Some(d) = spawn.get("depth").and_then(Value::as_i64) {
6579 meta.lineage.insert("depth".to_string(), d.to_string());
6580 }
6581 }
6582}
6583
6584/// Depth of a node in the parent forest (root = 0), bounded against cycles.
6585fn depth_of(mut i: usize, parent_of: &[Option<usize>]) -> usize {
6586 let mut d = 0;
6587 let mut guard = 0;
6588 while let Some(p) = parent_of[i] {
6589 if p == i || guard > parent_of.len() {
6590 break;
6591 }
6592 i = p;
6593 d += 1;
6594 guard += 1;
6595 }
6596 d
6597}
6598
6599/// Codex per-turn grouping id, stored under `payload.metadata.turn_id`.
6600fn codex_turn_id(payload: &Value) -> Option<&str> {
6601 payload
6602 .get("metadata")
6603 .and_then(|m| m.get("turn_id"))
6604 .and_then(Value::as_str)
6605}
6606
6607/// N2 (spliced-export hardening): every Codex group id already present in
6608/// `raw_prefix` — the verbatim RAW lines [`Session::to_codex_jsonl_spliced`]
6609/// replays ahead of the appended tail it synthesizes via
6610/// `Session::write_codex_records`. This is the GROUND TRUTH of what
6611/// physically lands in the exported `out` string for the prefix: each line
6612/// is parsed as a Codex envelope and its own `payload.metadata.turn_id` (the
6613/// exact field `codex_turn_id` reads, whether it's a real native `turn_id`
6614/// or one of OUR OWN fabricated `sc-grp-N`/`<real>~dupN` ids from a prior
6615/// export) is extracted directly — no re-derivation from `self.messages`
6616/// needed (that would have to reconstruct which ids the ORIGINAL export
6617/// happened to assign, which this sidesteps entirely by reading them back
6618/// out of the bytes themselves). A line that fails to parse, isn't a
6619/// `response_item`, or carries no `turn_id` contributes nothing — headers
6620/// and non-message/call records (e.g. `session_meta`, `function_call_output`)
6621/// never carry this field to begin with.
6622fn collect_codex_group_ids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
6623 let mut ids = HashSet::new();
6624 for line in raw_prefix {
6625 if let Ok(v) = serde_json::from_str::<Value>(line) {
6626 if let Some(payload) = v.get("payload") {
6627 if let Some(tid) = codex_turn_id(payload) {
6628 ids.insert(tid.to_string());
6629 }
6630 }
6631 }
6632 }
6633 ids
6634}
6635
6636/// Stamp every `ChatMessage` appended to `messages` since index `from` with
6637/// `ts` (a Codex record's own top-level `timestamp`, ISO-8601) as the
6638/// canonical `metadata["timestamp"]` (WAVE-2 item 1) — the same
6639/// `entry(...).or_insert_with` discipline the pi/Claude loaders use, so a
6640/// message that already carries a more specific timestamp of its own is
6641/// never overwritten (none currently do on the Codex side, but this keeps
6642/// every loader consistent). A no-op when `ts` is `None` (a line with no
6643/// `timestamp` field) or `from >= messages.len()` (nothing new was pushed).
6644fn stamp_new_codex_messages(messages: &mut [ChatMessage], from: usize, ts: Option<&str>) {
6645 let Some(ts) = ts else { return };
6646 let Some(slice) = messages.get_mut(from..) else {
6647 return;
6648 };
6649 for m in slice {
6650 m.metadata
6651 .entry("timestamp".to_string())
6652 .or_insert_with(|| ts.to_string());
6653 }
6654}
6655
6656fn push_codex_item(payload: &Value, out: &mut Vec<ChatMessage>) {
6657 match payload.get("type").and_then(Value::as_str) {
6658 Some("message") => {
6659 let role = match payload.get("role").and_then(Value::as_str) {
6660 Some("user") => Role::User,
6661 Some("assistant") => Role::Assistant,
6662 // "developer" and "system" both carry operator instructions.
6663 _ => Role::System,
6664 };
6665 let content = payload.get("content");
6666 let text = extract_text_content(content);
6667 // IX-5: `input_image` blocks alongside/instead of text — see
6668 // `codex_extract_images`. A text-only message (no image blocks)
6669 // takes the historical `content: Some(text)` shape unchanged.
6670 let images = codex_extract_images(content);
6671 let is_empty_assistant =
6672 role == Role::Assistant && text.trim().is_empty() && images.is_empty();
6673 if !text.trim().is_empty() || !images.is_empty() || is_empty_assistant {
6674 let content_parts = if images.is_empty() {
6675 None
6676 } else {
6677 let mut parts = Vec::new();
6678 if !text.trim().is_empty() {
6679 parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6680 }
6681 parts.extend(images);
6682 Some(parts)
6683 };
6684 let mut msg = ChatMessage {
6685 role,
6686 content: if content_parts.is_some() || text.is_empty() {
6687 None
6688 } else {
6689 Some(text)
6690 },
6691 content_parts,
6692 tool_calls: None,
6693 tool_call_id: None,
6694 name: None,
6695 metadata: Default::default(),
6696 };
6697 // Preserve the assistant `phase` (commentary vs final_answer) so
6698 // a reloaded transcript can distinguish narration from the answer.
6699 if role == Role::Assistant {
6700 if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
6701 msg.metadata.insert("phase".to_string(), phase.to_string());
6702 }
6703 // IX-6: mark this as an open, mergeable combined-turn
6704 // candidate — a `function_call` response_item found
6705 // immediately after (still `out.last()` when reached,
6706 // i.e. no other item intervened) merges into this SAME
6707 // `ChatMessage` instead of splitting into a second one,
6708 // matching how Claude's parser keeps a text+tool_use
6709 // turn together. Stripped again before the loaded
6710 // `Session` is returned (`from_codex_str`), so it never
6711 // leaks as visible metadata.
6712 msg.metadata
6713 .insert("__codex_open_turn".to_string(), "true".to_string());
6714 }
6715 // The per-turn grouping key (Codex batches items by turn_id).
6716 if let Some(tid) = codex_turn_id(payload) {
6717 msg.metadata.insert("turn_id".to_string(), tid.to_string());
6718 }
6719 // PARITY-6 dev/02: restore the original Claude
6720 // `systemSubtype` for a `developer`/`system` message that
6721 // was itself synthesized FROM a real Claude system record
6722 // (`write_codex_records`'s `Role::System` arm stamps
6723 // `claude_system_subtype`) — the exact inverse, so
6724 // `write_claude_code_records`'s `Role::System` arm can
6725 // re-materialize the real Claude `type: "system"` record
6726 // faithfully on a Codex -> Claude Code hop instead of
6727 // guessing a fallback subtype.
6728 if role == Role::System {
6729 if let Some(subtype) = payload
6730 .get("metadata")
6731 .and_then(|m| m.get("claude_system_subtype"))
6732 .and_then(Value::as_str)
6733 {
6734 msg.metadata
6735 .insert("systemSubtype".to_string(), subtype.to_string());
6736 }
6737 }
6738 if is_empty_assistant {
6739 msg.metadata
6740 .insert("empty_assistant_record".to_string(), "true".to_string());
6741 }
6742 out.push(msg);
6743 }
6744 }
6745 Some("function_call") => {
6746 let id = payload
6747 .get("call_id")
6748 .and_then(Value::as_str)
6749 .unwrap_or_default();
6750 let raw_name = payload
6751 .get("name")
6752 .and_then(Value::as_str)
6753 .unwrap_or_default();
6754 // Preserve the MCP `namespace` by qualifying the tool name
6755 // (`<namespace>__<name>`, matching the mcp__server__tool convention),
6756 // so the tool identity isn't ambiguous on round-trip.
6757 let qualified;
6758 let name = match payload.get("namespace").and_then(Value::as_str) {
6759 Some(ns) if !ns.is_empty() && !raw_name.starts_with(ns) => {
6760 qualified = format!("{ns}__{raw_name}");
6761 qualified.as_str()
6762 }
6763 _ => raw_name,
6764 };
6765 let args = payload
6766 .get("arguments")
6767 .map(value_to_arg_string)
6768 .unwrap_or_else(|| "{}".to_string());
6769 let call = function_call(id, name, args);
6770 // IX-6: a `function_call` immediately after an assistant `message`
6771 // in the SAME turn (still `out.last()`, marked `__codex_open_turn`
6772 // by the "message" arm above, and not yet closed by anything else)
6773 // merges into that ONE `ChatMessage` — text→`content`,
6774 // call→`tool_calls` — instead of splitting into a second message.
6775 // A bare `function_call` with no such preceding turn (the marker
6776 // absent, or `out.last()` not an assistant message) is unaffected:
6777 // it still gets its own synthesized message, exactly as before.
6778 //
6779 // Belt-and-suspenders (PARITY-6/7 tightened): if this
6780 // `function_call` response_item itself carries a `turn_id` (rare
6781 // in observed real-native-Codex corpora — Codex usually only
6782 // stamps it on `message` payloads — but ALWAYS present on OUR
6783 // OWN synthesized export whenever a `ChatMessage`'s own tool
6784 // calls need merge disambiguation, see `write_codex_records`),
6785 // it must match the marked assistant message's recorded
6786 // `turn_id` EXACTLY — including "the marked message has none at
6787 // all" counting as a mismatch. That's exactly the shape of two
6788 // genuinely separate, adjacent `ChatMessage`s (an unrelated
6789 // text-only turn immediately followed by a different,
6790 // tool-call-only turn): the tool-only turn's own `function_call`s
6791 // carry a synthetic id while the unrelated preceding text
6792 // message carries none, so this correctly refuses the merge
6793 // instead of falling through to a permissive default. Only when
6794 // this `function_call` carries NO `turn_id` at all (the ordinary
6795 // real-native-Codex shape) does this fall back to the original
6796 // permissive "adjacency + open marker is enough" rule —
6797 // unchanged from before for the vast majority of real Codex
6798 // data. The truncation/clear strip above is what actually closes
6799 // the marker across rollback/compaction boundaries; this is only
6800 // an extra guard for the case where a stale-but-unstripped
6801 // marker and a turn_id mismatch coincide.
6802 let can_merge = out.last().is_some_and(|last| {
6803 last.role == Role::Assistant
6804 && last.metadata.contains_key("__codex_open_turn")
6805 && match codex_turn_id(payload) {
6806 Some(fc_tid) => {
6807 last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6808 }
6809 None => true,
6810 }
6811 });
6812 if can_merge {
6813 out.last_mut()
6814 .expect("can_merge implies out.last() is Some")
6815 .tool_calls
6816 .get_or_insert_with(Vec::new)
6817 .push(call);
6818 } else {
6819 push_assistant(out, String::new(), vec![call]);
6820 // PARITY-6/7: a BARE tool-call turn (no preceding `message`
6821 // in this turn, so nothing set `__codex_open_turn` above) can
6822 // still be the FIRST of several tool calls that all belong to
6823 // the SAME original `ChatMessage` (`write_codex_records`
6824 // stamps every one of a message's own tool calls with the
6825 // identical synthetic `turn_id`). Re-open THIS freshly
6826 // created message — but ONLY when a real `turn_id` is
6827 // present — so the NEXT `function_call` in the same group
6828 // merges into it instead of becoming its own message too.
6829 // Gated on `codex_turn_id(payload).is_some()` (not the bare
6830 // default `true` the belt-and-suspenders check above uses)
6831 // so real native Codex data — which almost never carries
6832 // this field on `function_call` payloads (see the comment
6833 // above) — keeps its existing "every bare tool call is its
6834 // own turn" behavior exactly as before.
6835 if let Some(tid) = codex_turn_id(payload) {
6836 if let Some(last) = out.last_mut() {
6837 last.metadata
6838 .insert("__codex_open_turn".to_string(), "true".to_string());
6839 last.metadata.insert("turn_id".to_string(), tid.to_string());
6840 }
6841 }
6842 }
6843 }
6844 Some("function_call_output") => {
6845 let id = payload
6846 .get("call_id")
6847 .and_then(Value::as_str)
6848 .unwrap_or_default();
6849 let result = match payload.get("output") {
6850 Some(Value::String(s)) => s.clone(),
6851 Some(v) => extract_text_content(Some(v)),
6852 None => String::new(),
6853 };
6854 let mut message = tool_message(id, result);
6855 // TR-13: Codex v1 exposes no structured success/error field on
6856 // this record. Free-text output is not a safe classifier, so the
6857 // reduction engine must treat the outcome as explicitly unknown
6858 // and fail closed on both success-only and error-only pruning.
6859 crate::mark_tool_outcome_unknown(&mut message);
6860 out.push(message);
6861 }
6862 // Custom / MCP tool calls are shaped like function calls but carry their
6863 // arguments under `input` (a JSON-encoded string). Normalize them the
6864 // same way so MCP-using sessions don't lose those turns.
6865 Some("custom_tool_call") => {
6866 let id = payload
6867 .get("call_id")
6868 .and_then(Value::as_str)
6869 .unwrap_or_default();
6870 let name = payload
6871 .get("name")
6872 .and_then(Value::as_str)
6873 .unwrap_or_default();
6874 // Unlike `function_call.arguments`, Codex custom tools accept a
6875 // free-form `input` string (apply_patch is the common case).
6876 // Canonical `FunctionCall::arguments` must remain valid JSON, so
6877 // retain the input's JSON type instead of treating a free-form
6878 // string as if it were already a JSON document. This lets every
6879 // target harness carry the value rather than silently replacing
6880 // it with `{}` when `parsed_arguments()` fails.
6881 let args = payload
6882 .get("input")
6883 .map(Value::to_string)
6884 .unwrap_or_else(|| "{}".to_string());
6885 push_assistant(out, String::new(), vec![function_call(id, name, args)]);
6886 if let Some(message) = out.last_mut() {
6887 message.metadata.insert(
6888 "codex_custom_tool_call_ids".to_string(),
6889 serde_json::json!([id]).to_string(),
6890 );
6891 }
6892 }
6893 Some("custom_tool_call_output") => {
6894 let id = payload
6895 .get("call_id")
6896 .and_then(Value::as_str)
6897 .unwrap_or_default();
6898 let result = match payload.get("output") {
6899 Some(Value::String(s)) => s.clone(),
6900 Some(v) => extract_text_content(Some(v)),
6901 None => String::new(),
6902 };
6903 let mut message = tool_message(id, result);
6904 crate::mark_tool_outcome_unknown(&mut message);
6905 out.push(message);
6906 }
6907 // Tool-search is a clean call/output pair keyed by call_id.
6908 //
6909 // D1: this arm used to ALWAYS start a brand-new `ChatMessage`,
6910 // ignoring the `turn_id` merge stamps `write_codex_records` puts on
6911 // its own synthesized `tool_search_call` records (see the PARITY-6/7
6912 // comment there and on `codex_turn_id`/the `function_call` arm
6913 // above). That left the same bug-class the turn_id work fixed for
6914 // `function_call` half-done here: a single Claude assistant message
6915 // containing text + a `tool_search` block reloaded as 2 messages
6916 // (1 -> 2 inflation), and a message with 2 `tool_search` blocks
6917 // reloaded as 3. Mirror the `function_call` arm's merge check
6918 // exactly so a `tool_search_call` immediately following an open
6919 // assistant turn (or another tool call sharing the same `turn_id`)
6920 // merges into that SAME `ChatMessage` instead of splitting.
6921 Some("tool_search_call") => {
6922 let id = payload
6923 .get("call_id")
6924 .and_then(Value::as_str)
6925 .unwrap_or_default();
6926 let args = payload
6927 .get("arguments")
6928 .map(value_to_arg_string)
6929 .unwrap_or_else(|| "{}".to_string());
6930 let call = function_call(id, "tool_search", args);
6931 let can_merge = out.last().is_some_and(|last| {
6932 last.role == Role::Assistant
6933 && last.metadata.contains_key("__codex_open_turn")
6934 && match codex_turn_id(payload) {
6935 Some(fc_tid) => {
6936 last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6937 }
6938 None => true,
6939 }
6940 });
6941 if can_merge {
6942 out.last_mut()
6943 .expect("can_merge implies out.last() is Some")
6944 .tool_calls
6945 .get_or_insert_with(Vec::new)
6946 .push(call);
6947 } else {
6948 push_assistant(out, String::new(), vec![call]);
6949 // Re-open the freshly created message so a FOLLOWING
6950 // `function_call`/`tool_search_call` sharing this same
6951 // `turn_id` merges into it too — matching the bare
6952 // `function_call` case's own re-open logic above.
6953 if let Some(tid) = codex_turn_id(payload) {
6954 if let Some(last) = out.last_mut() {
6955 last.metadata
6956 .insert("__codex_open_turn".to_string(), "true".to_string());
6957 last.metadata.insert("turn_id".to_string(), tid.to_string());
6958 }
6959 }
6960 }
6961 }
6962 Some("tool_search_output") => {
6963 let id = payload
6964 .get("call_id")
6965 .and_then(Value::as_str)
6966 .unwrap_or_default();
6967 let result = payload
6968 .get("tools")
6969 .map(value_to_arg_string)
6970 .unwrap_or_default();
6971 out.push(tool_message(id, result));
6972 }
6973 // Web-search / image-generation response_items carry no paired output
6974 // here (results live in event_msg), so emit an assistant marker rather
6975 // than a dangling unanswered tool call.
6976 Some("web_search_call") => {
6977 push_assistant(out, "[web_search]".to_string(), Vec::new());
6978 }
6979 Some("image_generation_call") => {
6980 let prompt = payload
6981 .get("revised_prompt")
6982 .and_then(Value::as_str)
6983 .unwrap_or("");
6984 push_assistant(
6985 out,
6986 format!("[image_generation] {prompt}").trim().to_string(),
6987 Vec::new(),
6988 );
6989 }
6990 // "reasoning" and anything else — dropped.
6991 _ => {}
6992 }
6993}
6994
6995// ---- Grok -------------------------------------------------------------
6996
6997const SUPERCODE_GEMINI_MESSAGE_KEY: &str = "_supercode_gemini_message";
6998
6999fn set_gemini_message_extension(value: &mut Value, message: &ChatMessage) {
7000 value[SUPERCODE_GEMINI_MESSAGE_KEY] = serde_json::json!({
7001 "schema": 1,
7002 "role": message.role,
7003 "content": message.content,
7004 "content_parts": message.content_parts,
7005 "tool_calls": message.tool_calls,
7006 "tool_call_id": message.tool_call_id,
7007 "name": message.name,
7008 "metadata": message.metadata,
7009 });
7010}
7011
7012fn restore_gemini_message_extension(value: &Value, message: &mut ChatMessage) {
7013 let Some(extension) = value.get(SUPERCODE_GEMINI_MESSAGE_KEY) else {
7014 return;
7015 };
7016 if extension.get("schema").and_then(Value::as_u64) != Some(1) {
7017 return;
7018 }
7019 if let Some(role) = extension
7020 .get("role")
7021 .and_then(|value| serde_json::from_value(value.clone()).ok())
7022 {
7023 message.role = role;
7024 }
7025 message.content = extension
7026 .get("content")
7027 .and_then(Value::as_str)
7028 .map(str::to_string);
7029 message.content_parts = extension
7030 .get("content_parts")
7031 .and_then(|value| serde_json::from_value(value.clone()).ok());
7032 message.tool_calls = extension
7033 .get("tool_calls")
7034 .and_then(|value| serde_json::from_value(value.clone()).ok());
7035 message.tool_call_id = extension
7036 .get("tool_call_id")
7037 .and_then(Value::as_str)
7038 .map(str::to_string);
7039 message.name = extension
7040 .get("name")
7041 .and_then(Value::as_str)
7042 .map(str::to_string);
7043 message.metadata.clear();
7044 if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
7045 for (key, value) in metadata {
7046 if let Some(value) = value.as_str() {
7047 message.metadata.insert(key.clone(), value.to_string());
7048 }
7049 }
7050 }
7051}
7052
7053fn capture_grok_scalar_metadata(value: &Value, message: &mut ChatMessage, keys: &[&str]) {
7054 for key in keys {
7055 if let Some(value) = value.get(*key) {
7056 message.metadata.insert(
7057 format!("grok_{key}"),
7058 value
7059 .as_str()
7060 .map(str::to_string)
7061 .unwrap_or_else(|| value.to_string()),
7062 );
7063 }
7064 }
7065}
7066
7067fn grok_human_user_text(raw: &str) -> Option<String> {
7068 let text = raw.trim();
7069 if text.is_empty() || text.starts_with("<user_info>") || text.starts_with("<system-reminder>") {
7070 return None;
7071 }
7072 let unwrapped = text
7073 .strip_prefix("<user_query>")
7074 .and_then(|value| value.strip_suffix("</user_query>"))
7075 .map(str::trim)
7076 .unwrap_or(text);
7077 (!unwrapped.is_empty()).then(|| unwrapped.to_string())
7078}
7079
7080/// Portable extension for messages whose canonical fields cannot be expressed
7081/// by the target's stock schema. It was introduced for Grok and retains that
7082/// on-disk key for compatibility. Gemini has the same need: Claude Code and
7083/// Codex have no native slot for a tool-result name or Gemini-only metadata.
7084/// Their readers tolerate unknown namespaced fields, so forwarding this
7085/// adapter-owned envelope keeps those cross-format hops reversible without
7086/// pretending the stock schemas represent the fields directly.
7087const SUPERCODE_GROK_MESSAGE_KEY: &str = "_supercode_grok_message";
7088
7089/// Namespaced line-level extension carrying the one tool-result outcome state
7090/// Claude cannot represent natively. Keeping this narrower than the full Grok
7091/// portability envelope avoids changing unrelated target-message projection.
7092const SUPERCODE_TOOL_OUTCOME_KEY: &str = "_supercode_tool_outcome";
7093
7094fn restore_tool_outcome_extension(value: &Value, message: &mut ChatMessage) {
7095 if !crate::is_tool_error(message)
7096 && value
7097 .get(SUPERCODE_TOOL_OUTCOME_KEY)
7098 .and_then(Value::as_str)
7099 == Some("unknown")
7100 {
7101 crate::mark_tool_outcome_unknown(message);
7102 }
7103}
7104
7105fn grok_message_extension(source: SessionSource, message: &ChatMessage) -> Option<Value> {
7106 let metadata = message
7107 .metadata
7108 .iter()
7109 .filter(|(key, _)| {
7110 key.starts_with("gemini_") || key.starts_with("grok_") || key.starts_with("goose_")
7111 })
7112 .map(|(key, value)| (key.clone(), Value::String(value.clone())))
7113 .collect::<serde_json::Map<_, _>>();
7114
7115 // `meta.source` changes after every reload. Keying portability only on
7116 // the immediate source therefore made Grok metadata survive one hop but
7117 // disappear on A -> B -> C translations. Once Grok-owned fields are
7118 // present, keep forwarding them regardless of the current container.
7119 let has_portable_fields =
7120 !metadata.is_empty() || message.metadata.contains_key("codex_custom_tool_call_ids");
7121 (matches!(
7122 source,
7123 SessionSource::Gemini | SessionSource::Grok | SessionSource::Goose
7124 ) || has_portable_fields
7125 || message.content_parts.is_some())
7126 .then(|| {
7127 serde_json::json!({
7128 "schema": 2,
7129 "role": message.role,
7130 "content": message.content,
7131 "content_parts": message.content_parts,
7132 "tool_calls": message.tool_calls,
7133 "tool_call_id": message.tool_call_id,
7134 "name": message.name,
7135 "metadata": message.metadata,
7136 })
7137 })
7138}
7139
7140fn set_grok_target_message_extension(value: &mut Value, message: &ChatMessage) {
7141 value[SUPERCODE_GROK_MESSAGE_KEY] = serde_json::json!({
7142 "schema": 2,
7143 "role": message.role,
7144 "content": message.content,
7145 "content_parts": message.content_parts,
7146 "tool_calls": message.tool_calls,
7147 "tool_call_id": message.tool_call_id,
7148 "name": message.name,
7149 "metadata": message.metadata,
7150 });
7151}
7152
7153fn set_grok_message_extension(value: &mut Value, source: SessionSource, message: &ChatMessage) {
7154 if let Some(extension) = grok_message_extension(source, message) {
7155 value[SUPERCODE_GROK_MESSAGE_KEY] = extension;
7156 }
7157}
7158
7159fn restore_grok_message_extension(value: &Value, message: &mut ChatMessage) {
7160 let Some(extension) = value.get(SUPERCODE_GROK_MESSAGE_KEY) else {
7161 return;
7162 };
7163 // Codex temporarily marks a text assistant item so immediately-following
7164 // function-call items can merge back into the same canonical turn. The
7165 // portable envelope must not erase that loader-private marker before the
7166 // merge happens; `from_codex_str` removes it before returning.
7167 let codex_open_turn = message.metadata.get("__codex_open_turn").cloned();
7168 let codex_turn_id = message.metadata.get("turn_id").cloned();
7169 let extension_has_turn_id = extension
7170 .get("metadata")
7171 .and_then(Value::as_object)
7172 .is_some_and(|metadata| metadata.contains_key("turn_id"));
7173 if extension.get("schema").and_then(Value::as_u64) == Some(2) {
7174 if let Some(role) = extension
7175 .get("role")
7176 .and_then(|value| serde_json::from_value(value.clone()).ok())
7177 {
7178 message.role = role;
7179 }
7180 message.content = extension
7181 .get("content")
7182 .and_then(Value::as_str)
7183 .map(str::to_string);
7184 message.content_parts = extension
7185 .get("content_parts")
7186 .and_then(|value| serde_json::from_value(value.clone()).ok());
7187 // Tool calls are shared native structure in every supported format.
7188 // Keep the loader's reconstruction instead of restoring this copy:
7189 // Codex stores a combined text+tool turn across multiple records, so
7190 // eagerly restoring calls on its text record would duplicate them
7191 // when the following function-call records merge.
7192 message.tool_call_id = extension
7193 .get("tool_call_id")
7194 .and_then(Value::as_str)
7195 .map(str::to_string);
7196 message.name = extension
7197 .get("name")
7198 .and_then(Value::as_str)
7199 .map(str::to_string);
7200 message.metadata.clear();
7201 }
7202 if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
7203 for (key, value) in metadata {
7204 if let Some(value) = value.as_str() {
7205 message.metadata.insert(key.clone(), value.to_string());
7206 }
7207 }
7208 }
7209 if let Some(name) = extension.get("name").and_then(Value::as_str) {
7210 message.name = Some(name.to_string());
7211 }
7212 if let Some(marker) = codex_open_turn {
7213 message
7214 .metadata
7215 .insert("__codex_open_turn".to_string(), marker);
7216 }
7217 if let Some(turn_id) = codex_turn_id {
7218 message.metadata.insert("turn_id".to_string(), turn_id);
7219 if !extension_has_turn_id {
7220 message.metadata.insert(
7221 "__grok_remove_synthetic_turn_id".to_string(),
7222 "true".to_string(),
7223 );
7224 }
7225 }
7226}
7227
7228fn restore_single_grok_message(value: &Value, messages: &mut [ChatMessage]) {
7229 if let [message] = messages {
7230 restore_grok_message_extension(value, message);
7231 }
7232}
7233
7234fn normalize_goose_message(native: &Value, native_index: usize, out: &mut Vec<ChatMessage>) {
7235 let role = match native.get("role").and_then(Value::as_str) {
7236 Some("assistant") => Role::Assistant,
7237 _ => Role::User,
7238 };
7239 let created = native.get("created").and_then(Value::as_i64);
7240 let native_id = native.get("id").and_then(Value::as_str);
7241 let mut text = Vec::new();
7242 let mut content_parts = Vec::new();
7243 let mut tool_calls = Vec::new();
7244 let mut tool_results = Vec::new();
7245
7246 for (block_index, block) in native
7247 .get("content")
7248 .and_then(Value::as_array)
7249 .into_iter()
7250 .flatten()
7251 .enumerate()
7252 {
7253 match block.get("type").and_then(Value::as_str) {
7254 Some("text") => {
7255 if let Some(value) = block.get("text").and_then(Value::as_str) {
7256 text.push(value.to_string());
7257 content_parts.push(serde_json::json!({"type": "text", "text": value}));
7258 }
7259 }
7260 Some("image") => {
7261 let data = block
7262 .get("data")
7263 .and_then(Value::as_str)
7264 .unwrap_or_default();
7265 let media_type = block
7266 .get("mimeType")
7267 .or_else(|| block.get("mime_type"))
7268 .and_then(Value::as_str)
7269 .unwrap_or("application/octet-stream");
7270 content_parts.push(serde_json::json!({
7271 "type": "image_url",
7272 "image_url": {"url": format!("data:{media_type};base64,{data}")},
7273 }));
7274 }
7275 Some("toolRequest" | "frontendToolRequest") => {
7276 let id = block
7277 .get("id")
7278 .and_then(Value::as_str)
7279 .map(str::to_string)
7280 .unwrap_or_else(|| format!("goose-{native_index}-{block_index}"));
7281 let call = block
7282 .get("toolCall")
7283 .and_then(|call| {
7284 (call.get("status").and_then(Value::as_str) == Some("success"))
7285 .then(|| call.get("value"))
7286 .flatten()
7287 })
7288 .or_else(|| block.get("toolCall"));
7289 let Some(call) = call else { continue };
7290 let name = call.get("name").and_then(Value::as_str).unwrap_or("tool");
7291 let arguments = call
7292 .get("arguments")
7293 .map(value_to_arg_string)
7294 .unwrap_or_else(|| "{}".to_string());
7295 tool_calls.push(function_call(&id, name, arguments));
7296 }
7297 Some("toolResponse") => tool_results.push(block.clone()),
7298 _ => {}
7299 }
7300 }
7301
7302 if !text.is_empty() || !content_parts.is_empty() || !tool_calls.is_empty() {
7303 let has_non_text = content_parts
7304 .iter()
7305 .any(|part| part.get("type").and_then(Value::as_str) != Some("text"));
7306 let mut message = ChatMessage {
7307 role,
7308 content: (!text.is_empty()).then(|| text.join("\n")),
7309 content_parts: has_non_text.then_some(content_parts),
7310 tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
7311 tool_call_id: None,
7312 name: None,
7313 metadata: Default::default(),
7314 };
7315 capture_goose_message_metadata(native, created, native_id, &mut message);
7316 out.push(message);
7317 }
7318
7319 for (result_index, block) in tool_results.into_iter().enumerate() {
7320 let id = block
7321 .get("id")
7322 .and_then(Value::as_str)
7323 .map(str::to_string)
7324 .unwrap_or_else(|| format!("goose-{native_index}-result-{result_index}"));
7325 let result = block.get("toolResult").unwrap_or(&Value::Null);
7326 let status_error = result.get("status").and_then(Value::as_str) == Some("error");
7327 let value = result.get("value").unwrap_or(result);
7328 let is_error = status_error || value.get("isError").and_then(Value::as_bool) == Some(true);
7329 let output = if status_error {
7330 result
7331 .get("error")
7332 .and_then(Value::as_str)
7333 .unwrap_or("Goose tool call failed")
7334 .to_string()
7335 } else {
7336 value
7337 .get("content")
7338 .and_then(Value::as_array)
7339 .map(|content| {
7340 content
7341 .iter()
7342 .filter_map(|part| {
7343 part.get("text")
7344 .and_then(Value::as_str)
7345 .map(str::to_string)
7346 .or_else(|| Some(part.to_string()))
7347 })
7348 .collect::<Vec<_>>()
7349 .join("\n")
7350 })
7351 .unwrap_or_else(|| value.to_string())
7352 };
7353 let mut message = tool_message(&id, output);
7354 if is_error {
7355 crate::mark_tool_error(&mut message);
7356 }
7357 capture_goose_message_metadata(native, created, native_id, &mut message);
7358 out.push(message);
7359 }
7360}
7361
7362fn capture_goose_message_metadata(
7363 native: &Value,
7364 created: Option<i64>,
7365 native_id: Option<&str>,
7366 message: &mut ChatMessage,
7367) {
7368 if let Some(created) = created {
7369 message
7370 .metadata
7371 .insert("goose_created".to_string(), created.to_string());
7372 }
7373 if let Some(native_id) = native_id {
7374 message
7375 .metadata
7376 .insert("goose_message_id".to_string(), native_id.to_string());
7377 }
7378 if let Some(metadata) = native.get("metadata") {
7379 message
7380 .metadata
7381 .insert("goose_metadata".to_string(), metadata.to_string());
7382 }
7383}
7384
7385#[doc(hidden)]
7386pub fn percent_decode_path(encoded: &str) -> Option<String> {
7387 fn hex(byte: u8) -> Option<u8> {
7388 match byte {
7389 b'0'..=b'9' => Some(byte - b'0'),
7390 b'a'..=b'f' => Some(byte - b'a' + 10),
7391 b'A'..=b'F' => Some(byte - b'A' + 10),
7392 _ => None,
7393 }
7394 }
7395
7396 let bytes = encoded.as_bytes();
7397 let mut decoded = Vec::with_capacity(bytes.len());
7398 let mut index = 0usize;
7399 while index < bytes.len() {
7400 if bytes[index] == b'%' {
7401 let high = *bytes.get(index + 1)?;
7402 let low = *bytes.get(index + 2)?;
7403 decoded.push(hex(high)? * 16 + hex(low)?);
7404 index += 3;
7405 } else {
7406 decoded.push(bytes[index]);
7407 index += 1;
7408 }
7409 }
7410 String::from_utf8(decoded).ok()
7411}
7412
7413// ---- Pi ---------------------------------------------------------------
7414
7415fn capture_pi_header(v: &Value, meta: &mut SessionMeta) -> Result<()> {
7416 restore_codex_provenance_from_top_level(v, meta)?;
7417 if let Some(id) = v.get("id").and_then(Value::as_str) {
7418 meta.session_id = Some(id.to_string());
7419 }
7420 if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
7421 meta.cwd = Some(PathBuf::from(cwd));
7422 }
7423 // Absent `version` means a pre-v3 file (`pi-fields.md` §1: "absent = v1").
7424 let version = v
7425 .get("version")
7426 .and_then(Value::as_u64)
7427 .map(|n| n.to_string())
7428 .unwrap_or_else(|| "1".to_string());
7429 meta.lineage.insert("pi_version".to_string(), version);
7430 if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
7431 meta.lineage
7432 .insert("created_at".to_string(), ts.to_string());
7433 }
7434 if let Some(ps) = v.get("parentSession").and_then(Value::as_str) {
7435 meta.lineage
7436 .insert("parent_session_path".to_string(), ps.to_string());
7437 }
7438 // D7: the other half of `push_pi_header`'s passthrough — restores a
7439 // captured Claude `fork-context-ref` so a Claude -> Pi -> Claude round
7440 // trip reconstructs the original record (mirrors
7441 // `capture_codex_session_meta`'s identical `claude_fork_context_ref`
7442 // restore for the Codex hop).
7443 if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
7444 if let Some(v) = v.get("claude_fork_context_ref") {
7445 meta.lineage
7446 .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
7447 }
7448 }
7449 Ok(())
7450}
7451
7452/// Validate one `ImageContent` block's `{mimeType, data}` shape and return
7453/// `(mime, data)` when it looks like a real image payload.
7454///
7455/// **This shape is UNVERIFIED against real pi output**: `pi-fields.md` cites
7456/// `ai:316-350` for the `ImageContent` content-block union but does not
7457/// enumerate `ImageContent`'s own fields (only `TextContent`/`ThinkingContent`/
7458/// `ToolCall` are itemized there) — `{mimeType, data}` (mirroring the OpenAI/
7459/// Anthropic multimodal wire shape) is this loader's best guess, not a
7460/// frozen-spec fact. A follow-up TR tracks confirming/correcting this shape
7461/// against a real pi corpus. Until then this function VALIDATES rather than
7462/// assumes: both fields must be present, non-empty strings, and `data` must
7463/// look like base64 (only the base64 alphabet, incl. `=` padding) — anything
7464/// else is an unknown/unexpected image shape, and the caller must route the
7465/// whole message to raw-only survival (S6-style fail loud) instead of
7466/// silently synthesizing a corrupt/empty `image_url` part.
7467fn pi_image_shape(item: &Value) -> Option<(String, String)> {
7468 let mime = item.get("mimeType").and_then(Value::as_str)?;
7469 let data = item.get("data").and_then(Value::as_str)?;
7470 if mime.is_empty() || data.is_empty() {
7471 return None;
7472 }
7473 if !data
7474 .bytes()
7475 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
7476 {
7477 return None;
7478 }
7479 Some((mime.to_string(), data.to_string()))
7480}
7481
7482/// True if `content` (a pi content value: bare string or
7483/// `(TextContent|ImageContent)[]`) contains at least one `type:"image"` block
7484/// that does not match [`pi_image_shape`] — shared by the loader (which
7485/// routes such a message to raw-only survival, never a synthesized-empty
7486/// part) and the coverage audit (`audit::Corpus::Pi`), which scores it as
7487/// `message/UnknownImageShape` instead of silently `Normalized`, so the shape
7488/// mismatch surfaces as a coverage FAILURE rather than vanishing.
7489#[doc(hidden)]
7490pub fn pi_content_has_unknown_image_shape(content: Option<&Value>) -> bool {
7491 let Some(Value::Array(items)) = content else {
7492 return false;
7493 };
7494 items.iter().any(|item| {
7495 item.get("type").and_then(Value::as_str) == Some("image") && pi_image_shape(item).is_none()
7496 })
7497}
7498
7499/// Split a pi `(TextContent|ImageContent)[]` (or bare string) content value
7500/// into concatenated text plus, when a WELL-FORMED image block is present,
7501/// the full `content_parts` array (leading text block + one `image_url` part
7502/// per image, its `data:` URI carrying the exact `mimeType`/`data` bytes pi
7503/// stored) — shared by `user`/`toolResult`/`custom*` content, which all use
7504/// the identical union (`pi-fields.md` §3a/§3c/§3e).
7505///
7506/// Returns `(text, parts, unknown_image_shape)`. When an image block does NOT
7507/// match [`pi_image_shape`] (missing/empty `data`/`mimeType`, or a `data`
7508/// value that isn't recognizable base64), this NEVER synthesizes an empty/
7509/// corrupt `image_url` part — it reports `unknown_image_shape = true` and
7510/// every caller must treat that as raw-only survival for the whole message
7511/// (mirroring the unknown-`message.role` rule, S6), so a shape this loader
7512/// guessed wrong fails loud instead of silently dropping/corrupting the
7513/// image.
7514fn pi_content_to_text_and_parts(content: Option<&Value>) -> (String, Option<Vec<Value>>, bool) {
7515 match content {
7516 Some(Value::String(s)) => (s.clone(), None, false),
7517 Some(Value::Array(items)) => {
7518 let mut text = String::new();
7519 let mut parts: Vec<Value> = Vec::new();
7520 let mut has_image = false;
7521 let mut unknown_image_shape = false;
7522 for item in items {
7523 match item.get("type").and_then(Value::as_str) {
7524 Some("text") => {
7525 if let Some(t) = item.get("text").and_then(Value::as_str) {
7526 push_str_field(&mut text, t);
7527 }
7528 }
7529 Some("image") => {
7530 has_image = true;
7531 match pi_image_shape(item) {
7532 Some((mime, data)) => {
7533 parts.push(serde_json::json!({
7534 "type": "image_url",
7535 "image_url": {"url": format!("data:{mime};base64,{data}")},
7536 }));
7537 }
7538 None => unknown_image_shape = true,
7539 }
7540 }
7541 _ => {}
7542 }
7543 }
7544 if unknown_image_shape {
7545 // Never synthesize an empty/corrupt part for a shape we
7546 // don't recognize — raw-only survival for the whole message;
7547 // the coverage guard is what turns this into a visible
7548 // failure (S6-style).
7549 return (String::new(), None, true);
7550 }
7551 if has_image {
7552 if !text.trim().is_empty() {
7553 parts.insert(0, serde_json::json!({"type": "text", "text": text.clone()}));
7554 }
7555 (text, Some(parts), false)
7556 } else {
7557 (text, None, false)
7558 }
7559 }
7560 _ => (String::new(), None, false),
7561 }
7562}
7563
7564fn push_pi_user(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7565 let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7566 // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7567 // survival, never a synthesized-empty part. `audit::Corpus::Pi`'s
7568 // `message/UnknownImageShape` bucket is what turns this into a visible
7569 // coverage failure.
7570 if unknown_image_shape {
7571 return;
7572 }
7573 if text.trim().is_empty() && parts.is_none() {
7574 return;
7575 }
7576 let mut msg = match parts {
7577 Some(parts) => ChatMessage {
7578 role: Role::User,
7579 content: None,
7580 content_parts: Some(parts),
7581 tool_calls: None,
7582 tool_call_id: None,
7583 name: None,
7584 metadata: Default::default(),
7585 },
7586 None => ChatMessage::user(text),
7587 };
7588 // WAVE-2 fidelity fix: pi's message-level unix-ms clock
7589 // (`message.timestamp`) is a DISTINCT field from the canonical
7590 // entry-level ISO `metadata["timestamp"]` WAVE-2 item 1 wires — the two
7591 // carry genuinely different values in real corpora (the fixture's are
7592 // ~6 months apart). Preserve it separately so it isn't silently lost for
7593 // every pi session; see `msg_pi_native_timestamp_ms` (the pi writer's
7594 // native round-trip consumer) and the INHERENT residue note on
7595 // `pi_dropped_keys_cross` in `interop_fidelity_matrix.rs`.
7596 if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7597 msg.metadata
7598 .insert("pi_msg_timestamp".to_string(), ts.to_string());
7599 }
7600 out.push(msg);
7601}
7602
7603fn push_pi_assistant(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7604 let mut text = String::new();
7605 let mut calls: Vec<ToolCall> = Vec::new();
7606 let mut thinking = String::new();
7607 let mut thinking_seen = false;
7608 let mut thinking_sig: Option<String> = None;
7609 let mut thinking_redacted = false;
7610 let mut text_sig: Option<String> = None;
7611 let mut thought_sig: Option<String> = None;
7612
7613 if let Some(Value::Array(blocks)) = msg_v.get("content") {
7614 for b in blocks {
7615 match b.get("type").and_then(Value::as_str) {
7616 Some("text") => {
7617 if let Some(t) = b.get("text").and_then(Value::as_str) {
7618 push_str_field(&mut text, t);
7619 }
7620 if let Some(sig) = b.get("textSignature") {
7621 text_sig = Some(match sig {
7622 Value::String(s) => s.clone(),
7623 other => other.to_string(),
7624 });
7625 }
7626 }
7627 Some("thinking") => {
7628 thinking_seen = true;
7629 if let Some(t) = b.get("thinking").and_then(Value::as_str) {
7630 push_str_field(&mut thinking, t);
7631 }
7632 if let Some(sig) = b.get("thinkingSignature").and_then(Value::as_str) {
7633 thinking_sig = Some(sig.to_string());
7634 }
7635 if b.get("redacted").and_then(Value::as_bool) == Some(true) {
7636 thinking_redacted = true;
7637 }
7638 }
7639 Some("toolCall") => {
7640 let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
7641 let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
7642 // `arguments` is a JSON OBJECT on pi's wire, not a string
7643 // (`pi-fields.md` §3b open question 4) — serialize to the
7644 // string `FunctionCall::arguments` expects.
7645 let args = b
7646 .get("arguments")
7647 .cloned()
7648 .unwrap_or_else(|| Value::Object(Default::default()));
7649 calls.push(function_call(id, name, args.to_string()));
7650 if let Some(sig) = b.get("thoughtSignature").and_then(Value::as_str) {
7651 thought_sig = Some(sig.to_string());
7652 }
7653 }
7654 _ => {}
7655 }
7656 }
7657 }
7658
7659 let before = out.len();
7660 push_assistant(out, text, calls);
7661 // A recognized native assistant entry remains transcript state even
7662 // when its content array is empty, except Pi's explicit empty error
7663 // response: that record has no replayable content and is established
7664 // raw-only residue (`pi_real_corpus_error_retry`). Preserve empty
7665 // non-error turns and Pi's standalone thinking-block shape.
7666 let is_empty_error =
7667 !thinking_seen && msg_v.get("stopReason").and_then(Value::as_str) == Some("error");
7668 if out.len() == before && !is_empty_error {
7669 let mut empty = ChatMessage {
7670 role: Role::Assistant,
7671 content: None,
7672 content_parts: None,
7673 tool_calls: None,
7674 tool_call_id: None,
7675 name: None,
7676 metadata: Default::default(),
7677 };
7678 if !thinking_seen {
7679 empty
7680 .metadata
7681 .insert("empty_assistant_record".to_string(), "true".to_string());
7682 }
7683 out.push(empty);
7684 }
7685 if out.len() > before {
7686 let msg = out.last_mut().expect("just pushed");
7687 if thinking_seen {
7688 msg.metadata.insert("thinking".to_string(), thinking);
7689 }
7690 if let Some(s) = thinking_sig {
7691 msg.metadata.insert("thinking_signature".to_string(), s);
7692 }
7693 if thinking_redacted {
7694 msg.metadata
7695 .insert("pi_thinking_redacted".to_string(), "true".to_string());
7696 }
7697 if let Some(s) = text_sig {
7698 msg.metadata.insert("pi_text_signature".to_string(), s);
7699 }
7700 if let Some(s) = thought_sig {
7701 msg.metadata.insert("pi_thought_signature".to_string(), s);
7702 }
7703 for (key, field) in [
7704 ("pi_api", "api"),
7705 ("pi_provider", "provider"),
7706 ("pi_response_model", "responseModel"),
7707 ("pi_response_id", "responseId"),
7708 ("pi_stop_reason", "stopReason"),
7709 ("pi_error_message", "errorMessage"),
7710 ] {
7711 if let Some(s) = msg_v.get(field).and_then(Value::as_str) {
7712 msg.metadata.insert(key.to_string(), s.to_string());
7713 }
7714 }
7715 if let Some(diag) = msg_v.get("diagnostics") {
7716 if !diag.is_null() {
7717 msg.metadata
7718 .insert("pi_diagnostics".to_string(), diag.to_string());
7719 }
7720 }
7721 if let Some(usage) = msg_v.get("usage") {
7722 if !usage.is_null() {
7723 msg.metadata
7724 .insert("pi_usage".to_string(), usage.to_string());
7725 }
7726 }
7727 // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7728 // separately from the canonical entry-level ISO `timestamp` — see
7729 // `push_pi_user`.
7730 if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7731 msg.metadata
7732 .insert("pi_msg_timestamp".to_string(), ts.to_string());
7733 }
7734 }
7735}
7736
7737fn push_pi_tool_result(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7738 let id = msg_v
7739 .get("toolCallId")
7740 .and_then(Value::as_str)
7741 .unwrap_or_default();
7742 let name = msg_v
7743 .get("toolName")
7744 .and_then(Value::as_str)
7745 .unwrap_or_default();
7746 let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7747 // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7748 // survival, never a synthesized-empty part. Dropping the toolResult
7749 // message here leaves its `toolCallId` unanswered, which
7750 // `ensure_tool_results_paired` already turns into a visible
7751 // "[no tool result recorded — turn interrupted]" placeholder — a loud
7752 // failure mode, not a silent one.
7753 if unknown_image_shape {
7754 return;
7755 }
7756 let mut msg = ChatMessage {
7757 role: Role::Tool,
7758 content: Some(text),
7759 content_parts: parts,
7760 tool_calls: None,
7761 tool_call_id: Some(id.to_string()),
7762 name: Some(name.to_string()),
7763 metadata: Default::default(),
7764 };
7765 if let Some(details) = msg_v.get("details") {
7766 if !details.is_null() {
7767 msg.metadata
7768 .insert("pi_tool_details".to_string(), details.to_string());
7769 }
7770 }
7771 let is_error = msg_v
7772 .get("isError")
7773 .and_then(Value::as_bool)
7774 .unwrap_or(false);
7775 msg.metadata
7776 .insert("pi_is_error".to_string(), is_error.to_string());
7777 if is_error {
7778 crate::mark_tool_error(&mut msg);
7779 }
7780 // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7781 // separately from the canonical entry-level ISO `timestamp` — see
7782 // `push_pi_user`.
7783 if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7784 msg.metadata
7785 .insert("pi_msg_timestamp".to_string(), ts.to_string());
7786 }
7787 out.push(msg);
7788}
7789
7790/// Render a pi `bashExecution` message (`!`/`!!` shell escape) to the text
7791/// pi itself sends the model, mirroring `bashExecutionToText`
7792/// (`pi-fields.md` §3d, `msg:82-98`). The exact upstream string constants
7793/// aren't reproduced in the frozen research doc (only cited by file:line),
7794/// so this is a faithful, clearly-labeled reconstruction — every structured
7795/// field is additionally preserved verbatim in `metadata`/`raw` regardless.
7796fn push_pi_bash(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7797 let command = msg_v.get("command").and_then(Value::as_str).unwrap_or("");
7798 let output = msg_v.get("output").and_then(Value::as_str).unwrap_or("");
7799 let exit_code = msg_v.get("exitCode").and_then(Value::as_i64);
7800 let cancelled = msg_v
7801 .get("cancelled")
7802 .and_then(Value::as_bool)
7803 .unwrap_or(false);
7804 let truncated = msg_v
7805 .get("truncated")
7806 .and_then(Value::as_bool)
7807 .unwrap_or(false);
7808
7809 let mut text = format!("$ {command}\n{output}");
7810 if let Some(code) = exit_code {
7811 if code != 0 {
7812 text.push_str(&format!("\n[exit code: {code}]"));
7813 }
7814 }
7815 if cancelled {
7816 text.push_str("\n[cancelled]");
7817 }
7818 if truncated {
7819 text.push_str("\n[truncated]");
7820 }
7821
7822 let mut msg = ChatMessage::user(text);
7823 msg.metadata
7824 .insert("pi_bash_command".to_string(), command.to_string());
7825 msg.metadata
7826 .insert("pi_bash_output".to_string(), output.to_string());
7827 if let Some(code) = exit_code {
7828 msg.metadata
7829 .insert("pi_bash_exit_code".to_string(), code.to_string());
7830 }
7831 msg.metadata
7832 .insert("pi_bash_cancelled".to_string(), cancelled.to_string());
7833 msg.metadata
7834 .insert("pi_bash_truncated".to_string(), truncated.to_string());
7835 if let Some(p) = msg_v.get("fullOutputPath").and_then(Value::as_str) {
7836 msg.metadata
7837 .insert("pi_bash_full_output_path".to_string(), p.to_string());
7838 }
7839 // `!!` — hidden from the model context; honored by `is_replay_excluded`
7840 // on every writer, not just pi's own (§2.2).
7841 if msg_v.get("excludeFromContext").and_then(Value::as_bool) == Some(true) {
7842 msg.metadata
7843 .insert("pi_exclude_from_context".to_string(), "true".to_string());
7844 }
7845 // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7846 // separately from the canonical entry-level ISO `timestamp` — see
7847 // `push_pi_user`.
7848 if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7849 msg.metadata
7850 .insert("pi_msg_timestamp".to_string(), ts.to_string());
7851 }
7852 out.push(msg);
7853}
7854
7855/// B4: the `customType` marker `write_pi_entries`'s `Role::System` arm
7856/// stamps on a re-materialized content-bearing Claude `system` record (see
7857/// that arm's doc comment). Namespaced (`supercode_`-prefixed) so it can
7858/// never collide with a real pi `CustomMessage.customType` — pi's own
7859/// hook-injected custom types are hook/extension names (e.g. `hookMessage`
7860/// migration targets), never this literal string.
7861const PI_CLAUDE_SYSTEM_CUSTOM_TYPE: &str = "supercode_claude_system";
7862
7863/// Shared mapping for pi's `role:"custom"` message (§3e) and top-level
7864/// `custom_message` entries (§9) — both enter context as a `User` message
7865/// with the same `customType`/`display`/`details` residue.
7866///
7867/// B4 exception: when `customType` is [`PI_CLAUDE_SYSTEM_CUSTOM_TYPE`] (our
7868/// own marker — see `write_pi_entries`'s `Role::System` arm), this is
7869/// actually a re-materialized content-bearing Claude `system` record round-
7870/// tripping through pi, not a genuine pi extension message — restore
7871/// `Role::System` + `metadata["systemSubtype"]` (from `details.
7872/// claude_system_subtype`, falling back to `local_command` — still one of
7873/// `push_claude_system`'s own keep subtypes — exactly like
7874/// `write_codex_records`'s Codex-leg fallback) instead of the generic
7875/// `Role::User` path below, so a Claude -> Pi -> Claude round trip restores
7876/// the exact original role, not just the text.
7877fn push_pi_custom_common(v: &Value, out: &mut Vec<ChatMessage>) {
7878 if v.get("customType").and_then(Value::as_str) == Some(PI_CLAUDE_SYSTEM_CUSTOM_TYPE) {
7879 let content = v.get("content").and_then(Value::as_str).unwrap_or("");
7880 if content.trim().is_empty() {
7881 return;
7882 }
7883 let subtype = v
7884 .get("details")
7885 .and_then(|d| d.get("claude_system_subtype"))
7886 .and_then(Value::as_str)
7887 .unwrap_or("local_command");
7888 out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
7889 return;
7890 }
7891 let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(v.get("content"));
7892 // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7893 // survival, never a synthesized-empty part.
7894 if unknown_image_shape {
7895 return;
7896 }
7897 if text.trim().is_empty() && parts.is_none() {
7898 return;
7899 }
7900 let mut msg = match parts {
7901 Some(parts) => ChatMessage {
7902 role: Role::User,
7903 content: None,
7904 content_parts: Some(parts),
7905 tool_calls: None,
7906 tool_call_id: None,
7907 name: None,
7908 metadata: Default::default(),
7909 },
7910 None => ChatMessage::user(text),
7911 };
7912 if let Some(ct) = v.get("customType").and_then(Value::as_str) {
7913 msg.metadata
7914 .insert("pi_custom_type".to_string(), ct.to_string());
7915 }
7916 if let Some(d) = v.get("display").and_then(Value::as_bool) {
7917 msg.metadata.insert("pi_display".to_string(), d.to_string());
7918 }
7919 if let Some(details) = v.get("details") {
7920 if !details.is_null() {
7921 msg.metadata
7922 .insert("pi_details".to_string(), details.to_string());
7923 }
7924 }
7925 // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7926 // separately from the canonical entry-level ISO `timestamp` — see
7927 // `push_pi_user`. `v` here is the `message` object for the `role:
7928 // "custom"` case; for the top-level `custom_message` case `v` is the
7929 // entry itself, whose `timestamp` is the entry-level ISO string (not a
7930 // u64), so this is a no-op there — matching pre-WAVE-2 behavior.
7931 if let Some(ts) = v.get("timestamp").and_then(Value::as_u64) {
7932 msg.metadata
7933 .insert("pi_msg_timestamp".to_string(), ts.to_string());
7934 }
7935 out.push(msg);
7936}
7937
7938/// pi's own prefix-wrapped user text for a `compaction` entry summary
7939/// (§2.1: "compaction/branch summaries (pi's own prefix-wrapped user text)").
7940/// The exact upstream wrapper string is cited (`msg:11-17`) but not
7941/// reproduced in the frozen research doc; this is a clearly-labeled
7942/// reconstruction, not pi's literal bytes — see build-report ambiguity note.
7943fn push_pi_compaction(entry_v: &Value, out: &mut Vec<ChatMessage>) {
7944 let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
7945 if summary.trim().is_empty() {
7946 return;
7947 }
7948 let mut msg = ChatMessage::user(format!("[compaction summary]\n{summary}"));
7949 msg.metadata
7950 .insert("pi_type".to_string(), "compaction".to_string());
7951 if let Some(fk) = entry_v.get("firstKeptEntryId").and_then(Value::as_str) {
7952 msg.metadata
7953 .insert("pi_first_kept_entry_id".to_string(), fk.to_string());
7954 }
7955 if let Some(tb) = entry_v.get("tokensBefore").and_then(Value::as_u64) {
7956 msg.metadata
7957 .insert("pi_tokens_before".to_string(), tb.to_string());
7958 }
7959 if let Some(d) = entry_v.get("details") {
7960 if !d.is_null() {
7961 msg.metadata.insert("pi_details".to_string(), d.to_string());
7962 }
7963 }
7964 if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
7965 msg.metadata
7966 .insert("pi_from_hook".to_string(), "true".to_string());
7967 }
7968 out.push(msg);
7969}
7970
7971/// pi's own prefix-wrapped user text for a `branch_summary` entry (a
7972/// rewind-with-summary) — same reconstruction caveat as
7973/// [`push_pi_compaction`].
7974fn push_pi_branch_summary(entry_v: &Value, out: &mut Vec<ChatMessage>) {
7975 let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
7976 if summary.trim().is_empty() {
7977 return;
7978 }
7979 let mut msg = ChatMessage::user(format!("[branch summary]\n{summary}"));
7980 msg.metadata
7981 .insert("pi_type".to_string(), "branch_summary".to_string());
7982 if let Some(f) = entry_v.get("fromId").and_then(Value::as_str) {
7983 msg.metadata.insert("pi_from_id".to_string(), f.to_string());
7984 }
7985 if let Some(d) = entry_v.get("details") {
7986 if !d.is_null() {
7987 msg.metadata.insert("pi_details".to_string(), d.to_string());
7988 }
7989 }
7990 if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
7991 msg.metadata
7992 .insert("pi_from_hook".to_string(), "true".to_string());
7993 }
7994 out.push(msg);
7995}
7996
7997// ---- OpenCode ---------------------------------------------------------
7998
7999/// The placeholder opencode's own replay substitutes for a `tool` part's
8000/// output once `state.completed.time.compacted` is set
8001/// (`message-v2.ts:293-296 @fd9ee43`) — the REAL output is never actually
8002/// erased from the record (S1); it survives in `raw` and in this loader's
8003/// `metadata["oc_tool_output_compacted"]`.
8004pub const OPENCODE_COMPACTED_TOOL_PLACEHOLDER: &str = "[Old tool result content cleared]";
8005
8006fn capture_opencode_session_info(si: &Value, meta: &mut SessionMeta) -> Result<()> {
8007 restore_codex_provenance_from_top_level(si, meta)?;
8008 if let Some(id) = si.get("id").and_then(Value::as_str) {
8009 meta.session_id = Some(id.to_string());
8010 }
8011 if let Some(dir) = si.get("directory").and_then(Value::as_str) {
8012 meta.cwd = Some(PathBuf::from(dir));
8013 }
8014 if let Some(agent) = si.get("agent").and_then(Value::as_str) {
8015 meta.agent_id = Some(agent.to_string());
8016 }
8017 if let Some(model) = si.get("model") {
8018 let provider = model.get("providerID").and_then(Value::as_str);
8019 let id = model.get("id").and_then(Value::as_str);
8020 if let (Some(p), Some(i)) = (provider, id) {
8021 meta.model = Some(format!("{p}/{i}"));
8022 }
8023 }
8024 if let Some(project_id) = si.get("projectID").and_then(Value::as_str) {
8025 meta.lineage
8026 .insert("projectID".to_string(), project_id.to_string());
8027 }
8028 if let Some(slug) = si.get("slug").and_then(Value::as_str) {
8029 meta.lineage.insert("slug".to_string(), slug.to_string());
8030 }
8031 if let Some(ws) = si.get("workspaceID").and_then(Value::as_str) {
8032 meta.lineage
8033 .insert("workspaceID".to_string(), ws.to_string());
8034 }
8035 if let Some(parent) = si.get("parentID").and_then(Value::as_str) {
8036 meta.lineage
8037 .insert("parent_session_id".to_string(), parent.to_string());
8038 // Mirrored under the Codex-originated lineage key so the existing
8039 // generic `Session::reconstruct_tree` nests opencode subagent
8040 // sessions too, with no format-specific nesting pass (§2.1: "child
8041 // session's parentID ... → drives reconstruct_tree").
8042 meta.lineage
8043 .insert("parent_thread_id".to_string(), parent.to_string());
8044 }
8045 // D7: the other half of `synthesized_opencode_info`'s passthrough —
8046 // restores a captured Claude `fork-context-ref` so a Claude -> OpenCode
8047 // -> Claude round trip reconstructs the original record (mirrors
8048 // `capture_codex_session_meta`/`capture_pi_header`'s identical
8049 // `claude_fork_context_ref` restore for the Codex/Pi hops).
8050 if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
8051 if let Some(v) = si.get("claude_fork_context_ref") {
8052 meta.lineage
8053 .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
8054 }
8055 }
8056 Ok(())
8057}
8058
8059/// An opencode `User`/`Assistant` `file` part's image data-URI →
8060/// `content_parts` `image_url` entry (§2.1). Only `data:` URIs with an
8061/// `image/*` mime are mapped ("T3 clean for images; non-media residue") —
8062/// a bare filesystem path, an `https:` link, or a non-image mime is left as
8063/// raw-only residue (§2.3), never a corrupt/guessed `image_url`. `pub(crate)`
8064/// so [`crate::audit::audit_opencode_line`] can classify a `file` part's
8065/// coverage with the SAME test this loader uses to canonicalize it (D5) —
8066/// one definition of "is this file part actually replayed", not two.
8067#[doc(hidden)]
8068pub fn opencode_file_image_part(part: &Value) -> Option<Value> {
8069 let mime = part.get("mime").and_then(Value::as_str)?;
8070 let url = part.get("url").and_then(Value::as_str)?;
8071 if !mime.starts_with("image/") || !url.starts_with("data:") {
8072 return None;
8073 }
8074 Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
8075}
8076
8077/// B4: the part-`metadata` key `append_synthesized_opencode_messages`'s
8078/// `Role::System` arm stamps on the one `synthetic: true` text part of a
8079/// re-materialized content-bearing Claude `system` record (see that arm's
8080/// doc comment). Namespaced (`supercode_`-prefixed) so it can never collide
8081/// with real opencode/provider part metadata (e.g. `anthropic.signature`).
8082const OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: &str = "supercode_claude_system_subtype";
8083
8084/// Detect `append_synthesized_opencode_messages`'s own marker shape: a
8085/// `User` message with EXACTLY one `synthetic: true` text part carrying
8086/// [`OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY`] in its part-level `metadata`. Real
8087/// opencode data is never misclassified — a genuine opencode `synthetic`
8088/// text part never carries this supercode-namespaced key, and a real
8089/// multi-part user message (text + an attached file, say) never matches
8090/// (`parts.len() != 1` bails). Returns the original Claude `systemSubtype`
8091/// (e.g. `local_command`) on a match.
8092fn opencode_claude_system_subtype(parts: &[Value]) -> Option<String> {
8093 let [part] = parts else { return None };
8094 if part.get("type").and_then(Value::as_str) != Some("text") {
8095 return None;
8096 }
8097 if part.get("synthetic").and_then(Value::as_bool) != Some(true) {
8098 return None;
8099 }
8100 part.get("metadata")
8101 .and_then(|m| m.get(OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY))
8102 .and_then(Value::as_str)
8103 .map(str::to_string)
8104}
8105
8106/// The [`opencode_claude_system_subtype`] match arm: restores `Role::System`
8107/// and `metadata["systemSubtype"]` from the marked text part instead of
8108/// `push_opencode_user`'s generic `Role::User` path, so a Claude ->
8109/// OpenCode -> Claude round trip restores the exact original role, not just
8110/// the text. Content is never fabricated — only emitted when non-empty.
8111fn push_opencode_claude_system(
8112 msg_value: &Value,
8113 parts: &[Value],
8114 subtype: String,
8115 out: &mut Vec<ChatMessage>,
8116) {
8117 let Some(text) = parts
8118 .first()
8119 .and_then(|p| p.get("text"))
8120 .and_then(Value::as_str)
8121 else {
8122 return;
8123 };
8124 if text.trim().is_empty() {
8125 return;
8126 }
8127 let mut msg = ChatMessage::system(text.to_string()).with_meta("systemSubtype", subtype);
8128 set_opencode_msg_timestamp(&mut msg, msg_value);
8129 out.push(msg);
8130}
8131
8132/// Map an opencode `User` message (`msg_value`) + its parts to zero or one
8133/// canonical `Role::User` `ChatMessage` (§2.1). `text` parts concatenate
8134/// (an `ignored` one is never included — §2.2, "must not be re-emitted to
8135/// the model"); `file` parts with a recognized image shape become
8136/// `content_parts`. `System` (opencode's per-turn `User.system`) fills
8137/// `SessionMeta.system_prompt` on the first turn that carries it, and
8138/// `metadata["system"]` on every turn that does (§2.1: "System prompt is
8139/// per-user-message, not per-session").
8140/// Fold an opencode message envelope's `time.created` (unix-ms) into the
8141/// canonical `metadata["timestamp"]` (ISO-8601, WAVE-2 item 1) — the same
8142/// field claude/codex/pi loaders populate. Lossless to millisecond precision
8143/// (opencode's own wire granularity); a `None`/malformed `time.created`
8144/// leaves `metadata["timestamp"]` unset, so the writer falls back to
8145/// `SYNTH_TS`/`SYNTH_TS_MS`.
8146fn set_opencode_msg_timestamp(msg: &mut ChatMessage, msg_value: &Value) {
8147 if let Some(ms) = msg_value
8148 .get("time")
8149 .and_then(|t| t.get("created"))
8150 .and_then(Value::as_i64)
8151 {
8152 msg.metadata
8153 .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8154 }
8155}
8156
8157fn push_opencode_user(
8158 msg_value: &Value,
8159 parts: &[Value],
8160 out: &mut Vec<ChatMessage>,
8161 meta: &mut SessionMeta,
8162 first_system_seen: &mut bool,
8163) {
8164 let mut text = String::new();
8165 let mut image_parts: Vec<Value> = Vec::new();
8166 let mut has_ignored = false;
8167 for p in parts {
8168 match p.get("type").and_then(Value::as_str) {
8169 Some("text") => {
8170 if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8171 has_ignored = true;
8172 continue; // must never be replayed (§2.2)
8173 }
8174 if let Some(t) = p.get("text").and_then(Value::as_str) {
8175 push_str_field(&mut text, t);
8176 }
8177 }
8178 Some("file") => {
8179 if let Some(img) = opencode_file_image_part(p) {
8180 image_parts.push(img);
8181 }
8182 }
8183 // reasoning/tool never appear on a User message; step-start,
8184 // step-finish, snapshot, patch, agent, subtask, retry have no
8185 // clean home (§2.3); compaction is read separately by the
8186 // caller (tail_start_id) and tagged onto the message below.
8187 _ => {}
8188 }
8189 }
8190
8191 let has_images = !image_parts.is_empty();
8192 if text.trim().is_empty() && !has_images {
8193 return;
8194 }
8195 let mut msg = if has_images {
8196 let mut all = Vec::new();
8197 if !text.trim().is_empty() {
8198 all.push(serde_json::json!({"type": "text", "text": text.clone()}));
8199 }
8200 all.extend(image_parts);
8201 ChatMessage {
8202 role: Role::User,
8203 content: None,
8204 content_parts: Some(all),
8205 tool_calls: None,
8206 tool_call_id: None,
8207 name: None,
8208 metadata: Default::default(),
8209 }
8210 } else {
8211 ChatMessage::user(text)
8212 };
8213
8214 if has_ignored {
8215 msg.metadata
8216 .insert("oc_has_ignored_part".to_string(), "true".to_string());
8217 }
8218 if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8219 msg.metadata
8220 .insert("oc_message_id".to_string(), id.to_string());
8221 }
8222 if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8223 msg.metadata.insert("agent".to_string(), agent.to_string());
8224 }
8225 if let Some(model) = msg_value.get("model") {
8226 if !model.is_null() {
8227 msg.metadata.insert("model".to_string(), model.to_string());
8228 }
8229 }
8230 if let Some(system) = msg_value.get("system").and_then(Value::as_str) {
8231 if !*first_system_seen {
8232 meta.system_prompt = Some(system.to_string());
8233 *first_system_seen = true;
8234 }
8235 msg.metadata
8236 .insert("system".to_string(), system.to_string());
8237 }
8238 for p in parts {
8239 if p.get("type").and_then(Value::as_str) == Some("compaction") {
8240 msg.metadata
8241 .insert("phase".to_string(), "compaction".to_string());
8242 if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
8243 msg.metadata
8244 .insert("tail_start_id".to_string(), t.to_string());
8245 }
8246 }
8247 }
8248 set_opencode_msg_timestamp(&mut msg, msg_value);
8249 restore_grok_message_extension(msg_value, &mut msg);
8250 out.push(msg);
8251}
8252
8253/// Map an opencode `Assistant` message + its parts to a canonical
8254/// `Role::Assistant` `ChatMessage` (text + `tool_calls`), followed
8255/// immediately by one `Role::Tool` `ChatMessage` per `tool` part that
8256/// reached `completed`/`error` — the split-by-`callID` opencode's single
8257/// part→two-messages mapping (§2.1). `pending`/`running` calls (an
8258/// interrupted turn) synthesize no tool call/result of their own here; the
8259/// shared [`ensure_tool_results_paired`] fills that gap uniformly, exactly
8260/// like the other three loaders. A `tool` part whose `state.status` is none
8261/// of the four known values is skipped entirely — raw-only survival, never
8262/// guessed — so [`crate::audit::Corpus::OpenCode`] can flag it.
8263fn push_opencode_assistant(
8264 msg_value: &Value,
8265 parts: &[Value],
8266 out: &mut Vec<ChatMessage>,
8267 meta: &mut SessionMeta,
8268) {
8269 let mut text = String::new();
8270 let mut calls: Vec<ToolCall> = Vec::new();
8271 let mut thinking = String::new();
8272 let mut reasoning_seen = false;
8273 let mut thinking_sig: Option<String> = None;
8274 // (call_id, tool_name, the tool part itself) — deferred so the
8275 // assistant message (carrying `tool_calls`) is pushed FIRST, matching
8276 // every other loader's message ordering (call, then result).
8277 let mut tool_results: Vec<(String, String, Value)> = Vec::new();
8278
8279 for p in parts {
8280 match p.get("type").and_then(Value::as_str) {
8281 Some("text") => {
8282 if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8283 continue;
8284 }
8285 if let Some(t) = p.get("text").and_then(Value::as_str) {
8286 push_str_field(&mut text, t);
8287 }
8288 }
8289 Some("reasoning") => {
8290 reasoning_seen = true;
8291 if let Some(t) = p.get("text").and_then(Value::as_str) {
8292 push_str_field(&mut thinking, t);
8293 }
8294 if let Some(sig) = p
8295 .get("metadata")
8296 .and_then(|m| m.get("anthropic"))
8297 .and_then(|a| a.get("signature"))
8298 .and_then(Value::as_str)
8299 {
8300 thinking_sig = Some(sig.to_string());
8301 }
8302 }
8303 Some("tool") => {
8304 let call_id = p.get("callID").and_then(Value::as_str).unwrap_or_default();
8305 let tool_name = p.get("tool").and_then(Value::as_str).unwrap_or_default();
8306 let status = p
8307 .get("state")
8308 .and_then(|s| s.get("status"))
8309 .and_then(Value::as_str);
8310 let known_status = matches!(
8311 status,
8312 Some("pending") | Some("running") | Some("completed") | Some("error")
8313 );
8314 if call_id.is_empty() || !known_status {
8315 // Unknown/unrecognized status, or a malformed part with
8316 // no callID — raw-only survival, never synthesized.
8317 continue;
8318 }
8319 let input = p
8320 .get("state")
8321 .and_then(|s| s.get("input"))
8322 .cloned()
8323 .unwrap_or_else(|| Value::Object(Default::default()));
8324 calls.push(function_call(call_id, tool_name, input.to_string()));
8325 if matches!(status, Some("completed") | Some("error")) {
8326 tool_results.push((call_id.to_string(), tool_name.to_string(), p.clone()));
8327 }
8328 }
8329 // file/step-start/step-finish/snapshot/patch/agent/subtask/retry
8330 // — no clean home on an Assistant turn (§2.3).
8331 _ => {}
8332 }
8333 }
8334
8335 let before = out.len();
8336 push_assistant(out, text, calls);
8337 // A native OpenCode assistant record is transcript state even when it
8338 // has no parts. Real stores contain these after an interrupted/empty
8339 // model turn; dropping the record here loses its id, timestamp, model,
8340 // token/cost metadata, and shifts the conversation on every export.
8341 // Keep one empty canonical assistant message so all target writers can
8342 // preserve the turn. This also covers reasoning-only records (whose
8343 // reasoning payload is attached as metadata just below).
8344 if out.len() == before {
8345 let mut empty = ChatMessage {
8346 role: Role::Assistant,
8347 content: None,
8348 content_parts: None,
8349 tool_calls: None,
8350 tool_call_id: None,
8351 name: None,
8352 metadata: Default::default(),
8353 };
8354 if !reasoning_seen {
8355 empty
8356 .metadata
8357 .insert("empty_assistant_record".to_string(), "true".to_string());
8358 }
8359 out.push(empty);
8360 }
8361 if out.len() > before {
8362 let msg = out.last_mut().expect("just pushed");
8363 if reasoning_seen {
8364 msg.metadata.insert("thinking".to_string(), thinking);
8365 }
8366 if let Some(sig) = thinking_sig {
8367 msg.metadata.insert("thinking_signature".to_string(), sig);
8368 }
8369 if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8370 msg.metadata
8371 .insert("oc_message_id".to_string(), id.to_string());
8372 }
8373 if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8374 msg.metadata.insert("agent".to_string(), agent.to_string());
8375 if meta.agent_id.is_none() {
8376 meta.agent_id = Some(agent.to_string());
8377 }
8378 }
8379 let provider = msg_value.get("providerID").and_then(Value::as_str);
8380 let model_id = msg_value.get("modelID").and_then(Value::as_str);
8381 if let (Some(p), Some(i)) = (provider, model_id) {
8382 let full = format!("{p}/{i}");
8383 msg.metadata.insert("model".to_string(), full.clone());
8384 if meta.model.is_none() {
8385 meta.model = Some(full);
8386 }
8387 }
8388 if let Some(cwd) = msg_value
8389 .get("path")
8390 .and_then(|p| p.get("cwd"))
8391 .and_then(Value::as_str)
8392 {
8393 if meta.cwd.is_none() {
8394 meta.cwd = Some(PathBuf::from(cwd));
8395 }
8396 }
8397 if msg_value.get("summary").and_then(Value::as_bool) == Some(true) {
8398 msg.metadata
8399 .insert("is_summary".to_string(), "true".to_string());
8400 }
8401 for (key, field) in [
8402 ("finish", "finish"),
8403 ("variant", "variant"),
8404 ("mode", "mode"),
8405 ] {
8406 if let Some(s) = msg_value.get(field).and_then(Value::as_str) {
8407 msg.metadata.insert(key.to_string(), s.to_string());
8408 }
8409 }
8410 for (key, field) in [
8411 ("cost", "cost"),
8412 ("tokens", "tokens"),
8413 ("error", "error"),
8414 ("structured", "structured"),
8415 ] {
8416 if let Some(v) = msg_value.get(field) {
8417 if !v.is_null() {
8418 msg.metadata.insert(key.to_string(), v.to_string());
8419 }
8420 }
8421 }
8422 // Subagent linkage (§2.1): a `task` tool's own `metadata` carries
8423 // the spawned child session id — keyed by callID so multiple `task`
8424 // calls in one message never collide.
8425 // `resolve_opencode_parent_tool_use_ids` reads these back once a
8426 // whole session set is loaded.
8427 for p in parts {
8428 if p.get("type").and_then(Value::as_str) == Some("tool")
8429 && p.get("tool").and_then(Value::as_str) == Some("task")
8430 {
8431 if let (Some(call_id), Some(child)) = (
8432 p.get("callID").and_then(Value::as_str),
8433 p.get("metadata")
8434 .and_then(|m| m.get("sessionId"))
8435 .and_then(Value::as_str),
8436 ) {
8437 msg.metadata.insert(
8438 format!("oc_task_child_session_id__{call_id}"),
8439 child.to_string(),
8440 );
8441 }
8442 }
8443 }
8444 set_opencode_msg_timestamp(msg, msg_value);
8445 restore_grok_message_extension(msg_value, msg);
8446 }
8447
8448 // Second pass: the paired Tool-role message for each completed/error
8449 // tool part, split by callID (§2.1 — "the SAME part carries call and
8450 // result").
8451 for (call_id, tool_name, part) in tool_results {
8452 let status = part
8453 .get("state")
8454 .and_then(|s| s.get("status"))
8455 .and_then(Value::as_str);
8456 let compacted_at = part
8457 .get("state")
8458 .and_then(|s| s.get("time"))
8459 .and_then(|t| t.get("compacted"))
8460 .and_then(Value::as_i64);
8461 let real_output = part
8462 .get("state")
8463 .and_then(|s| s.get("output"))
8464 .and_then(Value::as_str)
8465 .unwrap_or("")
8466 .to_string();
8467 let (content, is_error) = match status {
8468 Some("completed") => {
8469 if compacted_at.is_some() {
8470 (OPENCODE_COMPACTED_TOOL_PLACEHOLDER.to_string(), false)
8471 } else {
8472 (real_output.clone(), false)
8473 }
8474 }
8475 Some("error") => {
8476 let err = part
8477 .get("state")
8478 .and_then(|s| s.get("error"))
8479 .and_then(Value::as_str)
8480 .unwrap_or("")
8481 .to_string();
8482 (err, true)
8483 }
8484 _ => (String::new(), false),
8485 };
8486 let mut tmsg = ChatMessage {
8487 role: Role::Tool,
8488 content: Some(content),
8489 content_parts: None,
8490 tool_calls: None,
8491 tool_call_id: Some(call_id),
8492 name: Some(tool_name),
8493 metadata: Default::default(),
8494 };
8495 if let Some(original_position) = part
8496 .get(OPENCODE_SUPERCODE_RESULT_POSITION)
8497 .and_then(Value::as_u64)
8498 {
8499 tmsg.metadata.insert(
8500 OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
8501 original_position.to_string(),
8502 );
8503 }
8504 if is_error {
8505 crate::mark_tool_error(&mut tmsg);
8506 }
8507 restore_tool_outcome_extension(&part, &mut tmsg);
8508 if let Some(ts) = compacted_at {
8509 // S1: the real output is preserved — reversible, never erased.
8510 tmsg.metadata
8511 .insert("oc_tool_output_compacted".to_string(), real_output);
8512 tmsg.metadata
8513 .insert("oc_tool_time_compacted".to_string(), ts.to_string());
8514 }
8515 if status == Some("completed") {
8516 if let Some(atts) = part
8517 .get("state")
8518 .and_then(|s| s.get("attachments"))
8519 .and_then(Value::as_array)
8520 {
8521 let images: Vec<Value> = atts.iter().filter_map(opencode_file_image_part).collect();
8522 if !images.is_empty() {
8523 // D-mix consistency fix (Fable-recommended, same
8524 // pattern as `push_claude_user`'s tool_result arm above):
8525 // a completed opencode tool part with BOTH `state.output`
8526 // text and `state.attachments` images is the same
8527 // non-self-contained hybrid shape — `content_parts` here
8528 // used to hold images only, so opencode -> pi silently
8529 // dropped the output text (`pi_content_value` reads
8530 // `content_parts` exclusively for `Role::Tool`). Prepend
8531 // the text as part 0 so `content_parts` is
8532 // self-contained; `tmsg.content` keeps the text too,
8533 // unchanged, for writers that read it from there and
8534 // only scan `content_parts` for `image_url` entries.
8535 let mut parts = Vec::new();
8536 if let Some(t) = &tmsg.content {
8537 if !t.is_empty() {
8538 parts.push(serde_json::json!({"type": "text", "text": t}));
8539 }
8540 }
8541 parts.extend(images);
8542 tmsg.content_parts = Some(parts);
8543 }
8544 }
8545 }
8546 if let Some(id) = part.get("id").and_then(Value::as_str) {
8547 tmsg.metadata
8548 .insert("oc_part_id".to_string(), id.to_string());
8549 }
8550 // WAVE-2 item 1: a tool part's own `state.time.{end,start}` (unix-ms,
8551 // real opencode wire shape — `pi-fields.md`/`opencode-pi-spec.md`
8552 // cite `state.time.compacted`, but the SAME object also carries
8553 // `start`/`end` on every completed/error call) is this Tool
8554 // message's real source timestamp; prefer `end` (completion, closer
8555 // to when the RESULT — this message's content — was produced) and
8556 // fall back to `start` when only that is present.
8557 let tool_ts = part
8558 .get("state")
8559 .and_then(|s| s.get("time"))
8560 .and_then(|t| t.get("end").or_else(|| t.get("start")))
8561 .and_then(Value::as_i64);
8562 if let Some(ms) = tool_ts {
8563 tmsg.metadata
8564 .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8565 }
8566 // OpenCode folds a canonical tool result into the assistant's tool
8567 // part. Restore the portable envelope from that part after native
8568 // fields have been captured so A -> OpenCode -> A retains fields
8569 // OpenCode does not model independently (for example Goose's
8570 // message-level metadata and an intentionally absent tool name).
8571 restore_grok_message_extension(&part, &mut tmsg);
8572 out.push(tmsg);
8573 }
8574}
8575
8576// ---- shared helpers -------------------------------------------------------
8577
8578fn push_text(buf: &mut String, v: Option<&Value>) {
8579 if let Some(Value::String(s)) = v {
8580 if !buf.is_empty() {
8581 buf.push('\n');
8582 }
8583 buf.push_str(s);
8584 }
8585}
8586
8587/// Extract a Claude `tool_result` block's content, preserving non-text items
8588/// instead of silently dropping them:
8589///
8590/// - text blocks are concatenated;
8591/// - PARITY-11 (nested images, real-session-confirmed — the everyday "Read a
8592/// PNG / screenshot tool output" shape): `image` blocks are captured into
8593/// the returned `content_parts`-shaped `Vec<Value>` via
8594/// [`claude_image_block_to_part`] — the SAME base64/url conversion the
8595/// top-level `image` content-block path (`push_claude_user`) already uses
8596/// — instead of being flattened to the bare `[image]` marker text that used
8597/// to make the data unrecoverable from every writer. An unconvertible
8598/// source (D5 discipline — a Files-API `{"type":"file",...}` reference,
8599/// etc.) folds [`UNCONVERTIBLE_IMAGE_MARKER`] into the text instead of
8600/// vanishing, exactly like the top-level path;
8601/// - `tool_reference` blocks become `[tool_reference: <name>]`;
8602///
8603/// and if the block yields no text/images at all, fall back to the record's
8604/// `toolUseResult` field (string used directly, structured value serialized),
8605/// which is where Claude Code stores the actual result in many cases.
8606///
8607/// Returns `(text, images)`; callers that only need the old text-only
8608/// behavior can ignore the second element — every caller MUST fold non-empty
8609/// `images` into the resulting `ChatMessage.content_parts` themselves (this
8610/// function has no `ChatMessage` to attach to).
8611fn extract_tool_result_content(
8612 content: Option<&Value>,
8613 tool_use_result: Option<&Value>,
8614) -> (String, Vec<Value>) {
8615 let mut parts: Vec<String> = Vec::new();
8616 let mut images: Vec<Value> = Vec::new();
8617 match content {
8618 Some(Value::String(s)) => {
8619 if !s.is_empty() {
8620 parts.push(s.clone());
8621 }
8622 }
8623 Some(Value::Array(items)) => {
8624 for item in items {
8625 match item.get("type").and_then(Value::as_str) {
8626 Some("text") => {
8627 if let Some(t) = item.get("text").and_then(Value::as_str) {
8628 parts.push(t.to_string());
8629 }
8630 }
8631 Some("image") => match claude_image_block_to_part(item) {
8632 Some(part) => images.push(part),
8633 None => parts.push(UNCONVERTIBLE_IMAGE_MARKER.to_string()),
8634 },
8635 Some("tool_reference") => {
8636 let name = item.get("tool_name").and_then(Value::as_str).unwrap_or("?");
8637 parts.push(format!("[tool_reference: {name}]"));
8638 }
8639 _ => {
8640 if let Some(s) = item.as_str() {
8641 parts.push(s.to_string());
8642 }
8643 }
8644 }
8645 }
8646 }
8647 Some(other) => parts.push(other.to_string()),
8648 None => {}
8649 }
8650
8651 let joined = parts.join("\n");
8652 if !joined.trim().is_empty() || !images.is_empty() {
8653 return (joined, images);
8654 }
8655 // Empty tool_result content — recover from toolUseResult.
8656 match tool_use_result {
8657 Some(Value::String(s)) => (s.clone(), images),
8658 Some(v) => (v.to_string(), images),
8659 None => (joined, images),
8660 }
8661}
8662
8663/// Pull readable text out of a content value that may be a plain string or an
8664/// array of `{ "text": "..." }`-bearing blocks (any block type).
8665fn extract_text_content(v: Option<&Value>) -> String {
8666 match v {
8667 Some(Value::String(s)) => s.clone(),
8668 Some(Value::Array(items)) => {
8669 let mut parts = Vec::new();
8670 for item in items {
8671 if let Some(t) = item.get("text").and_then(Value::as_str) {
8672 parts.push(t.to_string());
8673 } else if let Some(s) = item.as_str() {
8674 parts.push(s.to_string());
8675 }
8676 }
8677 parts.join("\n")
8678 }
8679 Some(other) => other.to_string(),
8680 None => String::new(),
8681 }
8682}
8683
8684/// Extract Codex `input_image` content blocks from a `message` response_item's
8685/// `content` value into `content_parts` `image_url` entries — the inverse of
8686/// [`codex_message_content_blocks`]'s `input_image` emission (IX-5). Only a
8687/// block whose `image_url` is a non-empty string is recognized; anything else
8688/// (a missing/malformed `image_url`, a `file_id`-only reference) is left as
8689/// raw-only residue rather than synthesizing a corrupt/empty part — mirroring
8690/// the pi/opencode/Claude loaders' image-shape discipline.
8691fn codex_extract_images(content: Option<&Value>) -> Vec<Value> {
8692 let Some(Value::Array(items)) = content else {
8693 return Vec::new();
8694 };
8695 items
8696 .iter()
8697 .filter(|item| item.get("type").and_then(Value::as_str) == Some("input_image"))
8698 .filter_map(|item| {
8699 let url = item.get("image_url").and_then(Value::as_str)?;
8700 if url.is_empty() {
8701 return None;
8702 }
8703 Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
8704 })
8705 .collect()
8706}
8707
8708fn value_to_arg_string(v: &Value) -> String {
8709 match v {
8710 Value::String(s) => s.clone(),
8711 other => other.to_string(),
8712 }
8713}
8714
8715fn push_gemini_user_parts(
8716 messages: &mut Vec<ChatMessage>,
8717 content_parts: Vec<Value>,
8718 timestamp: Option<&str>,
8719 source: &Value,
8720) {
8721 if content_parts.is_empty() {
8722 return;
8723 }
8724 let mut message = ChatMessage {
8725 role: Role::User,
8726 content: None,
8727 content_parts: Some(content_parts),
8728 tool_calls: None,
8729 tool_call_id: None,
8730 name: None,
8731 metadata: Default::default(),
8732 };
8733 if let Some(timestamp) = timestamp {
8734 message
8735 .metadata
8736 .insert("timestamp".into(), timestamp.into());
8737 }
8738 restore_gemini_message_extension(source, &mut message);
8739 messages.push(message);
8740}
8741
8742fn function_call(id: &str, name: &str, arguments: String) -> ToolCall {
8743 ToolCall {
8744 id: id.to_string(),
8745 kind: "function".to_string(),
8746 function: FunctionCall {
8747 name: name.to_string(),
8748 arguments,
8749 },
8750 }
8751}
8752
8753fn tool_message(tool_call_id: &str, content: String) -> ChatMessage {
8754 ChatMessage {
8755 role: Role::Tool,
8756 content: Some(content),
8757 content_parts: None,
8758 tool_calls: None,
8759 tool_call_id: Some(tool_call_id.to_string()),
8760 name: None,
8761 metadata: Default::default(),
8762 }
8763}
8764
8765/// Emit a single assistant message combining accumulated text and tool calls.
8766/// A turn with neither (e.g. thinking-only) produces nothing.
8767fn push_assistant(out: &mut Vec<ChatMessage>, text: String, calls: Vec<ToolCall>) {
8768 let has_text = !text.trim().is_empty();
8769 if !has_text && calls.is_empty() {
8770 return;
8771 }
8772 out.push(ChatMessage {
8773 role: Role::Assistant,
8774 content: has_text.then_some(text),
8775 content_parts: None,
8776 tool_calls: (!calls.is_empty()).then_some(calls),
8777 tool_call_id: None,
8778 name: None,
8779 metadata: Default::default(),
8780 });
8781}
8782
8783// ---- writers --------------------------------------------------------------
8784
8785/// A placeholder timestamp for SYNTHESIZED records only — WAVE-2 item 1's
8786/// fallback (`docs/interop` build brief): every writer now emits a message's
8787/// REAL source timestamp (`metadata["timestamp"]`, the canonical ISO-8601
8788/// field every loader populates) when one is present. `SYNTH_TS` fires only
8789/// for a message with no source timestamp at all — a turn synthesized/
8790/// appended after import (the live agent loop, a splice's appended tail,
8791/// ...), which was never loaded from a real per-message timestamp to begin
8792/// with. Both tools tolerate identical timestamps; callers that need real
8793/// ones for a synthesized turn can post-process.
8794const SYNTH_TS: &str = "2026-01-01T00:00:00.000Z";
8795
8796/// The unix-millisecond twin of [`SYNTH_TS`], for OpenCode's numeric
8797/// `time.created`/`time.updated` fields.
8798const SYNTH_TS_MS: i64 = 1_767_225_600_000;
8799
8800/// `msg`'s real source timestamp (`metadata["timestamp"]`, canonical
8801/// ISO-8601 — WAVE-2 item 1) when present AND well-formed, else the
8802/// [`SYNTH_TS`] fallback. Validated via [`crate::sidecar::rfc3339_to_ms`] (a
8803/// parse, not just a presence check) so an absent, empty, or malformed
8804/// source value all degrade to the same documented fallback rather than
8805/// propagating garbage verbatim. Used by every writer that emits an
8806/// ISO-8601 timestamp field
8807/// (Claude Code, Codex, pi's entry-level `timestamp`).
8808fn msg_timestamp_or_synth(msg: &ChatMessage) -> &str {
8809 match msg.metadata.get("timestamp") {
8810 Some(ts) if crate::sidecar::rfc3339_to_ms(ts).is_some() => ts.as_str(),
8811 _ => SYNTH_TS,
8812 }
8813}
8814
8815/// OpenCode reloads an export document by sorting messages on
8816/// `time.created`, so a timestamp-less appended continuation cannot reuse
8817/// the fixed historical [`SYNTH_TS_MS`] fallback when its imported prefix is
8818/// newer. Advance a deterministic cursor for synthesized clocks while still
8819/// preserving every real source timestamp verbatim.
8820fn opencode_message_timestamp(msg: &ChatMessage, cursor: &mut i64) -> Result<i64> {
8821 if let Some(real) = msg
8822 .metadata
8823 .get("timestamp")
8824 .and_then(|s| crate::sidecar::rfc3339_to_ms(s))
8825 {
8826 // A NativeTurn timestamp is durable provenance minted by supercode,
8827 // not an OpenCode source clock that must be replayed verbatim.
8828 // Multiple turns may be recorded in the same millisecond, while
8829 // OpenCode sorts solely by `time.created`; allocate such turns after
8830 // the existing cursor so their persisted order cannot collapse. This
8831 // also preserves the fail-closed i64::MAX exhaustion behavior.
8832 if msg.metadata.contains_key("supercode_native_uuid") && real <= *cursor {
8833 *cursor = cursor.checked_add(1).ok_or_else(|| {
8834 crate::Error::Other(
8835 "cannot synthesize an OpenCode continuation timestamp after i64::MAX"
8836 .to_string(),
8837 )
8838 })?;
8839 return Ok(*cursor);
8840 }
8841 *cursor = (*cursor).max(real);
8842 return Ok(real);
8843 }
8844 let next = cursor.checked_add(1).ok_or_else(|| {
8845 crate::Error::Other(
8846 "cannot synthesize an OpenCode continuation timestamp after i64::MAX".to_string(),
8847 )
8848 })?;
8849 *cursor = next.max(SYNTH_TS_MS);
8850 Ok(*cursor)
8851}
8852
8853/// Largest integer nested under any OpenCode `time` object. Imported
8854/// prefixes carry more clocks than `message.time.created` (assistant
8855/// completion, tool start/end, session updated); a synthesized continuation
8856/// must follow all of them, not merely sort after message creation times.
8857fn opencode_max_timestamp(value: &Value) -> Option<i64> {
8858 fn max_number(value: &Value) -> Option<i64> {
8859 match value {
8860 Value::Number(n) => n.as_i64(),
8861 Value::Array(values) => values.iter().filter_map(max_number).max(),
8862 Value::Object(fields) => fields.values().filter_map(max_number).max(),
8863 _ => None,
8864 }
8865 }
8866
8867 match value {
8868 Value::Array(values) => values.iter().filter_map(opencode_max_timestamp).max(),
8869 Value::Object(fields) => fields
8870 .iter()
8871 .filter_map(|(key, value)| {
8872 if key == "time" {
8873 max_number(value)
8874 } else {
8875 opencode_max_timestamp(value)
8876 }
8877 })
8878 .max(),
8879 _ => None,
8880 }
8881}
8882
8883/// pi's own message-level unix-ms clock (`metadata["pi_msg_timestamp"]`,
8884/// restored by the pi loader's `push_pi_*` helpers) — DISTINCT from the
8885/// canonical entry-level ISO `metadata["timestamp"]` [`msg_timestamp_or_synth`]
8886/// reads. The two carry genuinely different values in real pi corpora (a
8887/// message-level clock reading vs. the entry's own wall-clock stamp), so this
8888/// is intentionally its own accessor. Used ONLY by [`Session::write_pi_entries`]'s
8889/// nested `message.timestamp` field, so a pi -> pi native round-trip
8890/// preserves the source message-level clock value-exact instead of deriving
8891/// it from the (distinct) entry-level timestamp. Falls back to
8892/// [`SYNTH_TS_MS`] for a message that never carried a pi message-level clock
8893/// reading (non-pi-sourced, or a synthesized/appended turn).
8894fn msg_pi_native_timestamp_ms(msg: &ChatMessage) -> i64 {
8895 msg.metadata
8896 .get("pi_msg_timestamp")
8897 .and_then(|s| s.parse::<i64>().ok())
8898 .unwrap_or(SYNTH_TS_MS)
8899}
8900
8901/// A format-valid (v4-shaped) but deterministic uuid, derived from a counter.
8902fn synth_uuid(n: usize) -> String {
8903 format!("00000000-0000-4000-8000-{n:012x}")
8904}
8905
8906/// R1 (Skeptic B, spliced-export uuid-collision hardening — the same bug
8907/// class N2 closed for the Codex spliced path's group ids, see
8908/// `collect_codex_group_ids_from_raw`/`write_codex_records`): mint the next
8909/// `synth_uuid`, skipping any value already present in `seed_used_ids` — the
8910/// uuids [`collect_claude_uuids_from_raw`] found already sitting in the
8911/// verbatim raw prefix [`Session::to_claude_code_jsonl_spliced`] replays
8912/// ahead of the tail this counter mints. Without this, re-splicing a
8913/// previously-exported-then-reimported session (export -> reimport -> append
8914/// -> export again) restarts `counter` at 1 with no memory of the prior
8915/// export's tail uuids now sitting in the prefix, so the second tail
8916/// fabricates the SAME `00000000-0000-4000-8000-…` values the first one did
8917/// — a uuid collision across prefix and tail that can mis-link any
8918/// uuid-keyed consumer (fork/tree lineage, `parentUuid`). `counter` keeps
8919/// climbing monotonically even across skips. `used_ids` is also updated for
8920/// each minted or metadata-backed identity, so collisions are prevented both
8921/// against the replayed prefix and within the appended tail.
8922fn next_claude_uuid(counter: &mut usize, used_ids: &mut HashSet<String>) -> String {
8923 loop {
8924 let candidate = synth_uuid(*counter);
8925 *counter += 1;
8926 if used_ids.insert(candidate.clone()) {
8927 return candidate;
8928 }
8929 }
8930}
8931
8932/// Reuse a message's durable native/source UUID when available, falling back
8933/// to the deterministic synthesized sequence only for hand-built or legacy
8934/// messages that never carried identity metadata.
8935fn claude_message_uuid(
8936 msg: &ChatMessage,
8937 counter: &mut usize,
8938 used_ids: &mut HashSet<String>,
8939) -> String {
8940 for key in ["claude_uuid", "supercode_native_uuid"] {
8941 if let Some(candidate) = msg.metadata.get(key) {
8942 if !candidate.is_empty() && used_ids.insert(candidate.clone()) {
8943 return candidate.clone();
8944 }
8945 }
8946 }
8947 next_claude_uuid(counter, used_ids)
8948}
8949
8950/// Companion to [`next_claude_uuid`]: every `uuid` already present in
8951/// `raw_prefix` — the verbatim RAW lines
8952/// [`Session::to_claude_code_jsonl_spliced`] replays ahead of the appended
8953/// tail it synthesizes via [`Session::write_claude_code_records`]. This is
8954/// the GROUND TRUTH of what physically lands in the exported `out` string
8955/// for the prefix (mirrors `collect_codex_group_ids_from_raw`'s approach on
8956/// the Codex side): each line is parsed as a Claude Code JSONL record and
8957/// its own top-level `uuid` field is read back out of the bytes directly, no
8958/// re-derivation from `self.messages` needed. A line that fails to parse, or
8959/// parses but carries no `uuid` (e.g. a trailing `file-history-snapshot`
8960/// record), contributes nothing.
8961fn collect_claude_uuids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
8962 let mut ids = HashSet::new();
8963 for line in raw_prefix {
8964 if let Ok(v) = serde_json::from_str::<Value>(line) {
8965 if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
8966 ids.insert(uuid.to_string());
8967 }
8968 }
8969 }
8970 ids
8971}
8972
8973fn push_jsonl(out: &mut String, value: &Value) {
8974 out.push_str(&value.to_string());
8975 out.push('\n');
8976}
8977
8978/// Replay a raw (verbatim) JSONL `line` into `out`, rewriting `key` to
8979/// `new_id` when the line parses as a JSON object carrying that key — used
8980/// by A12's Claude Code splice, where the session id lives at the top level
8981/// of (almost) every record under `key = "sessionId"`. A line that fails to
8982/// parse, or parses but lacks `key`, is copied through byte-for-byte
8983/// (nothing to patch, so nothing is reserialized).
8984fn push_spliced_line(out: &mut String, line: &str, new_id: Option<&str>, key: &str) {
8985 if let Some(new_id) = new_id {
8986 if let Ok(mut v) = serde_json::from_str::<Value>(line) {
8987 if v.get(key).is_some() {
8988 v[key] = Value::String(new_id.to_string());
8989 out.push_str(&v.to_string());
8990 out.push('\n');
8991 return;
8992 }
8993 }
8994 }
8995 out.push_str(line);
8996 out.push('\n');
8997}
8998
8999impl Session {
9000 fn cwd_string(&self) -> String {
9001 self.meta
9002 .cwd
9003 .as_ref()
9004 .map(|p| p.to_string_lossy().into_owned())
9005 .unwrap_or_else(|| ".".to_string())
9006 }
9007
9008 /// `(raw_prefix_len, message_prefix_len)` for A12 splicing: how many
9009 /// leading `raw` lines / `messages` came from the imported log, as
9010 /// opposed to being appended after import.
9011 ///
9012 /// `imported_message_count` (see its doc comment) pins the message-side
9013 /// boundary directly. The raw-side boundary isn't separately tracked —
9014 /// [`Self::from_native_str`]'s turn-appending loop pushes exactly one
9015 /// `raw` line per appended message, so the two lists grow by the same
9016 /// `appended_count` from the same starting point, and
9017 /// `raw.len() - appended_count` recovers it without a second counter.
9018 fn spliced_prefix_lens(&self) -> (usize, usize) {
9019 let message_prefix_len = self
9020 .imported_message_count
9021 .unwrap_or(self.messages.len())
9022 .min(self.messages.len());
9023 let appended_count = self.messages.len() - message_prefix_len;
9024 let raw_prefix_len = self.raw.len().saturating_sub(appended_count);
9025 (raw_prefix_len, message_prefix_len)
9026 }
9027
9028 /// Synthesize a Claude Code transcript.
9029 ///
9030 /// Claude Code transcripts have no slot for the *session-level system
9031 /// prompt* (that lives in the CLI, not the log) — but PARITY-6 dev/02
9032 /// (Fable-5 corpus audit) surfaced that content-bearing `System`
9033 /// `ChatMessage`s (Claude's own `type: "system"` records with a
9034 /// content-bearing `subtype` like `local_command`/`scheduled_task_fire`/
9035 /// `away_summary` — see `push_claude_system`, the exact inverse of what
9036 /// this writer now does) DO have a first-class slot: the real `type:
9037 /// "system"` record itself. This function used to unconditionally drop
9038 /// every `System` message, silently losing e.g. a real
9039 /// `<local-command-stdout>` record on any format -> Claude Code hop
9040 /// (confirmed on a real 2,982-message corpus session: Codex -> Claude
9041 /// Code dropped its one surviving `system` message, 2215 -> 2214, with
9042 /// no loss manifest). `write_claude_code_records`'s `Role::System` arm
9043 /// now re-materializes it instead.
9044 fn to_claude_code_jsonl(&self) -> String {
9045 let session_id = self
9046 .meta
9047 .session_id
9048 .clone()
9049 .unwrap_or_else(|| synth_uuid(0));
9050 let cwd = self.cwd_string();
9051 let mut out = String::new();
9052 // PARITY-10: a captured `fork-context-ref` (see `capture_claude_meta`)
9053 // re-emitted byte-for-byte, ahead of the conversation it applies to —
9054 // this is what makes the record survive the SEMANTIC Claude Code
9055 // writer (the raw-passthrough diagonal in `crates/cli` already
9056 // preserves it by construction; this covers the library `to_jsonl`
9057 // path too, e.g. a `--session-id` override that forces the semantic
9058 // writer).
9059 if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
9060 out.push_str(raw);
9061 out.push('\n');
9062 }
9063 // Full synthesis: `out` at this point has no raw prefix ahead of it
9064 // (unlike the A12 splice below), so there are no uuids yet in play
9065 // to seed against — see `next_claude_uuid`'s doc comment.
9066 self.write_claude_code_records(
9067 &mut out,
9068 &self.messages,
9069 &session_id,
9070 &cwd,
9071 None,
9072 1,
9073 &HashSet::new(),
9074 );
9075 if let Some(extension) = codex_provenance_envelope(&self.meta) {
9076 if out.is_empty() {
9077 push_jsonl(
9078 &mut out,
9079 &serde_json::json!({
9080 "type": "file-history-snapshot",
9081 "messageId": synth_uuid(1),
9082 "snapshot": {},
9083 "sessionId": session_id,
9084 "cwd": cwd,
9085 "timestamp": SYNTH_TS,
9086 }),
9087 );
9088 }
9089 inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
9090 }
9091 out
9092 }
9093
9094 /// Synthesize Claude Code records for `messages` (a full session or an
9095 /// appended tail — A12's [`Self::to_jsonl_spliced`] reuses this for just
9096 /// the latter), starting the `parentUuid` chain at `parent` and the
9097 /// `synth_uuid` counter at `counter`. Factored out of
9098 /// [`Self::to_claude_code_jsonl`] so the record shape is defined once.
9099 ///
9100 /// `seed_used_ids` primes [`next_claude_uuid`]'s collision guard with
9101 /// every uuid that will ALREADY be present in `out` before this call
9102 /// ever runs — see that function's doc comment for why the A12 splice
9103 /// path needs this and full synthesis doesn't.
9104 // R1: this was already at clippy's `too_many_arguments` threshold (7,
9105 // including `&self`) before the fix; the added `seed_used_ids` param
9106 // pushes it to 8. Every argument here is independently meaningful (two
9107 // record-shape inputs, two id/parent-chain threading values, and now
9108 // the collision seed) — bundling them into a params struct is a larger
9109 // refactor of this already-widely-called private helper than the R1 fix
9110 // warrants, so this is allowed rather than restructured.
9111 #[allow(clippy::too_many_arguments)]
9112 fn write_claude_code_records(
9113 &self,
9114 out: &mut String,
9115 messages: &[ChatMessage],
9116 session_id: &str,
9117 cwd: &str,
9118 mut parent: Option<String>,
9119 mut counter: usize,
9120 seed_used_ids: &HashSet<String>,
9121 ) {
9122 let mut used_ids = seed_used_ids.clone();
9123 for msg in messages {
9124 if is_replay_excluded(msg) {
9125 continue;
9126 }
9127 let blocks: Vec<Value> = match msg.role {
9128 // PARITY-6 dev/02: re-materialize a content-bearing System
9129 // `ChatMessage` as a real Claude Code `type: "system"`
9130 // record — the exact inverse of `push_claude_system`, which
9131 // is what produced it in the first place for a message
9132 // loaded FROM a real Claude Code transcript. `subtype`
9133 // prefers the original `systemSubtype` metadata
9134 // (`push_claude_system`'s `.with_meta`, round-tripped
9135 // through the Codex hop via `write_codex_records`'s
9136 // `claude_system_subtype` metadata channel and restored by
9137 // `push_codex_item`); when that channel didn't carry it
9138 // (e.g. a genuinely native, non-Claude-origin developer
9139 // message), fall back to `local_command` — the observed
9140 // common case, and still one of `push_claude_system`'s own
9141 // `keep` subtypes, so the record survives a *subsequent*
9142 // reload rather than being silently re-dropped. This never
9143 // fabricates content: the real text is always carried
9144 // verbatim, only the subtype label is a best-effort guess
9145 // when the true one wasn't recoverable.
9146 Role::System => {
9147 let content = msg.content.clone().unwrap_or_default();
9148 if content.trim().is_empty() {
9149 continue;
9150 }
9151 let subtype = msg
9152 .metadata
9153 .get("systemSubtype")
9154 .cloned()
9155 .unwrap_or_else(|| "local_command".to_string());
9156 // R1/B3 union: this mint must ALSO route through
9157 // `next_claude_uuid` + `seed_used_ids` like the other
9158 // three arms below — otherwise this System arm (added by
9159 // B3 after R1 landed) mints a raw `synth_uuid` that can
9160 // collide with a uuid already sitting in the A12 splice's
9161 // raw prefix (see `next_claude_uuid`'s doc comment).
9162 let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9163 let mut line = serde_json::json!({
9164 "parentUuid": parent,
9165 "type": "system",
9166 "subtype": subtype,
9167 "content": content,
9168 "uuid": uuid,
9169 "sessionId": session_id,
9170 "cwd": cwd,
9171 "timestamp": msg_timestamp_or_synth(msg),
9172 });
9173 set_grok_message_extension(&mut line, self.meta.source, msg);
9174 push_jsonl(out, &line);
9175 parent = Some(uuid);
9176 continue;
9177 }
9178 Role::User => {
9179 let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9180 let mut line = serde_json::json!({
9181 "parentUuid": parent,
9182 "type": "user",
9183 "message": {
9184 "role": "user",
9185 "content": claude_user_content_value(msg),
9186 },
9187 "uuid": uuid,
9188 "sessionId": session_id,
9189 "cwd": cwd,
9190 "timestamp": msg_timestamp_or_synth(msg),
9191 });
9192 set_grok_message_extension(&mut line, self.meta.source, msg);
9193 push_jsonl(out, &line);
9194 parent = Some(uuid);
9195 continue;
9196 }
9197 Role::Tool => {
9198 let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9199 let mut line = serde_json::json!({
9200 "parentUuid": parent,
9201 "type": "user",
9202 "message": {
9203 "role": "user",
9204 "content": [{
9205 "type": "tool_result",
9206 "tool_use_id": msg.tool_call_id.clone().unwrap_or_default(),
9207 "content": claude_tool_result_content_value(msg),
9208 }],
9209 },
9210 "uuid": uuid,
9211 "sessionId": session_id,
9212 "cwd": cwd,
9213 "timestamp": msg_timestamp_or_synth(msg),
9214 });
9215 if crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
9216 line[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
9217 }
9218 set_grok_message_extension(&mut line, self.meta.source, msg);
9219 push_jsonl(out, &line);
9220 parent = Some(uuid);
9221 continue;
9222 }
9223 Role::Assistant => {
9224 let mut blocks = Vec::new();
9225 // PARITY-16 (found via the REAL pi corpus, PARITY-5
9226 // dev/01): thinking/redacted_thinking must be re-emitted
9227 // BEFORE text/tool_use, unconditionally whenever
9228 // retained metadata is present — not only when `blocks`
9229 // is otherwise empty. The previous `if blocks.is_empty()`
9230 // gate (now below, applied unconditionally instead)
9231 // meant a turn that thinks AND THEN answers/calls a tool
9232 // in the SAME turn — pi's own default emission shape,
9233 // and the overwhelmingly common real-world case for any
9234 // reasoning model, not the rare reasoning-only edge case
9235 // this gate's comment described — silently dropped its
9236 // entire `thinking` block on Pi -> Claude Code export. A
9237 // genuine multi-turn pi session driven through pi's own
9238 // real Agent loop (faux provider, see
9239 // `pi_interop.rs`'s live-corpus tests) exposed this: its
9240 // thinking+text turns lost the thinking block entirely.
9241 // D8: prefer the exact per-block list when present —
9242 // every `thinking`/`redacted_thinking` block re-emitted
9243 // SEPARATELY with its own signature/data, exactly as
9244 // captured (`push_claude_assistant`), instead of the
9245 // legacy singular fields' lossy collapse (which drops
9246 // every signature but the last one's on a multi-block
9247 // message). Falls back to the legacy fields only for a
9248 // `Session` that never populated `thinking_blocks` (e.g.
9249 // hand-constructed in another loader/test, or loaded
9250 // from a non-Claude-Code source like Pi).
9251 match msg
9252 .metadata
9253 .get("thinking_blocks")
9254 .and_then(|s| serde_json::from_str::<Value>(s).ok())
9255 .and_then(|v| v.as_array().cloned())
9256 {
9257 Some(saved_blocks) => blocks.extend(saved_blocks),
9258 None => {
9259 if let Some(t) = msg.metadata.get("thinking") {
9260 let mut block =
9261 serde_json::json!({"type": "thinking", "thinking": t});
9262 if let Some(sig) = msg.metadata.get("thinking_signature") {
9263 block["signature"] = Value::String(sig.clone());
9264 }
9265 blocks.push(block);
9266 }
9267 if let Some(rt) = msg.metadata.get("redacted_thinking") {
9268 blocks.push(
9269 serde_json::json!({"type": "redacted_thinking", "data": rt}),
9270 );
9271 }
9272 }
9273 }
9274 if let Some(t) = &msg.content {
9275 if !t.is_empty() {
9276 blocks.push(serde_json::json!({"type": "text", "text": t}));
9277 }
9278 }
9279 // PARITY-11: an assistant-emitted image (`content_parts`,
9280 // e.g. a generated image — `push_claude_assistant`'s
9281 // load-side counterpart) has no slot in `msg.content`;
9282 // without this, `blocks` stayed empty for an image-only
9283 // turn and the whole message vanished on Claude Code
9284 // semantic export, same failure mode the IX-6 Codex
9285 // writer fix already closed on that side.
9286 if let Some(parts) = &msg.content_parts {
9287 for p in parts {
9288 if p.get("type").and_then(Value::as_str) == Some("image_url") {
9289 if let Some(url) = p
9290 .get("image_url")
9291 .and_then(|u| u.get("url"))
9292 .and_then(Value::as_str)
9293 {
9294 blocks.push(match parse_data_uri(url) {
9295 Some((mime, data)) => serde_json::json!({
9296 "type": "image",
9297 "source": {"type": "base64", "media_type": mime, "data": data},
9298 }),
9299 None => serde_json::json!({
9300 "type": "image",
9301 "source": {"type": "url", "url": url},
9302 }),
9303 });
9304 }
9305 }
9306 }
9307 }
9308 for tc in msg.tool_calls() {
9309 let input = tc
9310 .function
9311 .parsed_arguments()
9312 .unwrap_or_else(|_| Value::Object(Default::default()));
9313 blocks.push(serde_json::json!({
9314 "type": "tool_use",
9315 "id": tc.id,
9316 "name": tc.function.name,
9317 "input": input,
9318 }));
9319 }
9320 // PARITY-11 / PARITY-16: a genuinely reasoning-only turn
9321 // (no text, no tool_use, no image) still doesn't vanish
9322 // — the thinking/redacted_thinking prepend above already
9323 // ran unconditionally, so `blocks` is non-empty here
9324 // whenever any of those were present.
9325 blocks
9326 }
9327 };
9328
9329 // An empty assistant content array is a valid native interrupted
9330 // turn and must remain a record. Every non-assistant arm above
9331 // already `continue`s after writing its own shape, so an empty
9332 // `blocks` value here belongs specifically to that assistant.
9333 let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9334 let mut message = serde_json::json!({"role": "assistant", "content": blocks});
9335 if let Some(model) = msg.metadata.get("model").or(self.meta.model.as_ref()) {
9336 message["model"] = Value::String(model.clone());
9337 }
9338 let mut line = serde_json::json!({
9339 "parentUuid": parent,
9340 "type": "assistant",
9341 "message": message,
9342 "uuid": uuid,
9343 "sessionId": session_id,
9344 "cwd": cwd,
9345 "timestamp": msg_timestamp_or_synth(msg),
9346 });
9347 set_grok_message_extension(&mut line, self.meta.source, msg);
9348 push_jsonl(out, &line);
9349 parent = Some(uuid);
9350 }
9351 }
9352
9353 /// A12 splice: replay the imported Claude Code `raw` prefix verbatim
9354 /// (patching `sessionId` on each line when `session_id` is `Some`), then
9355 /// synthesize records only for the appended tail, via
9356 /// [`Self::write_claude_code_records`] — chaining `parentUuid` from the
9357 /// last original `uuid` found anywhere in the raw prefix (not just its
9358 /// final line: a trailing loader-skipped record, e.g.
9359 /// `file-history-snapshot`, may carry no `uuid` of its own).
9360 fn to_claude_code_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9361 let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9362 let sid = session_id
9363 .map(str::to_string)
9364 .or_else(|| self.meta.session_id.clone())
9365 .unwrap_or_else(|| synth_uuid(0));
9366 let cwd = self.cwd_string();
9367
9368 let mut out = String::new();
9369 let mut parent: Option<String> = None;
9370 for line in &self.raw[..raw_prefix_len] {
9371 push_spliced_line(&mut out, line, session_id, "sessionId");
9372 if let Ok(v) = serde_json::from_str::<Value>(line) {
9373 if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
9374 parent = Some(uuid.to_string());
9375 }
9376 }
9377 }
9378
9379 // R1 (spliced-path hardening, mirrors the Codex N2 fix above): seed
9380 // the tail's collision guard with every uuid the just-replayed RAW
9381 // prefix already carries, so `write_claude_code_records` never
9382 // fabricates a `synth_uuid` for the appended tail that collides with
9383 // one already sitting in the prefix (see `next_claude_uuid`'s and
9384 // `collect_claude_uuids_from_raw`'s doc comments).
9385 let seed_used_ids = collect_claude_uuids_from_raw(&self.raw[..raw_prefix_len]);
9386 self.write_claude_code_records(
9387 &mut out,
9388 &self.messages[message_prefix_len..],
9389 &sid,
9390 &cwd,
9391 parent,
9392 1,
9393 &seed_used_ids,
9394 );
9395 out
9396 }
9397
9398 /// Synthesize a Codex rollout.
9399 fn to_codex_jsonl(&self) -> String {
9400 let mut out = String::new();
9401
9402 if self.meta.codex_headers.is_empty() {
9403 self.write_synthesized_codex_header(&mut out);
9404 } else {
9405 // Replay the exact header records the original tool wrote — Codex's
9406 // reader validates the header shape strictly — overriding only the
9407 // session id when the caller changed it.
9408 for header in &self.meta.codex_headers {
9409 let mut header = header.clone();
9410 if header.get("type").and_then(Value::as_str) == Some("session_meta") {
9411 if let Some(id) = &self.meta.session_id {
9412 if let Some(payload) = header.get_mut("payload") {
9413 payload["id"] = Value::String(id.clone());
9414 }
9415 }
9416 }
9417 push_jsonl(&mut out, &header);
9418 }
9419 }
9420
9421 // Full synthesis: `out` at this point is only the header, so there
9422 // are no group ids yet in play to seed against (see
9423 // `write_codex_records`'s doc comment).
9424 self.write_codex_records(&mut out, &self.messages, &std::collections::HashSet::new());
9425 if let Some(extension) = codex_provenance_envelope(&self.meta) {
9426 inject_codex_provenance(&mut out, extension);
9427 }
9428 out
9429 }
9430
9431 /// Synthesize Codex `response_item` records for `messages` (a full
9432 /// session or an appended tail — A12's [`Self::to_jsonl_spliced`] reuses
9433 /// this for just the latter). Factored out of [`Self::to_codex_jsonl`] so
9434 /// the record shape is defined once; `tool_search_call_ids` pairing is
9435 /// scoped to this call's `messages`, matching the header-replay
9436 /// contract that only appended records need synthesizing.
9437 ///
9438 /// `seed_used_ids` primes the N2 collision guard below with every group
9439 /// id that will ALREADY be present in `out` before this call ever runs —
9440 /// [`Self::to_codex_jsonl`] (full synthesis, `out` starts as just the
9441 /// header) passes an empty set, since every group id in that case is
9442 /// assigned by this very loop. [`Self::to_codex_jsonl_spliced`] (the A12
9443 /// splice) passes the ids already used by the verbatim RAW prefix it
9444 /// replayed into `out` just before calling this for the appended tail —
9445 /// without that seed, the tail's own `used_group_ids`/`next_group_id`
9446 /// start blind to the prefix and can fabricate/reuse a group id that
9447 /// COLLIDES with one still "open" at the end of the prefix, letting
9448 /// reimport's merge check (`can_merge`, `push_codex_item`) wrongly splice
9449 /// an unrelated appended message into a historical one — the same
9450 /// bug-class N2 closed for full synthesis, reopened here because the
9451 /// spliced tail's tracking set used to always start empty regardless of
9452 /// what the replayed prefix already contained.
9453 fn write_codex_records(
9454 &self,
9455 out: &mut String,
9456 messages: &[ChatMessage],
9457 seed_used_ids: &std::collections::HashSet<String>,
9458 ) {
9459 // Call ids of assistant `tool_search` calls (B6 agent intrinsic), so
9460 // the matching tool result below can be emitted as the paired
9461 // `tool_search_output` record rather than a generic
9462 // `function_call_output` — the exact inverse of the importer's
9463 // `tool_search_call`/`tool_search_output` normalization
9464 // (`push_codex_item`, above).
9465 let mut tool_search_call_ids: std::collections::HashSet<String> = Default::default();
9466 // PARITY-6/7: two ADJACENT but genuinely SEPARATE Claude assistant
9467 // records (e.g. a text-only narration turn immediately followed by a
9468 // bare tool-call turn, no user turn between — a real, common Claude
9469 // Code shape) each become their own Codex `message`/`function_call`
9470 // response_item(s) here. Codex's own reader (`push_codex_item`, IX-6)
9471 // opportunistically RE-MERGES an assistant `message` immediately
9472 // followed by a `function_call` back into ONE `ChatMessage`, to match
9473 // how a genuinely single Claude turn (text+tool_use in the SAME
9474 // record) round-trips — but with no distinguishing signal, it can't
9475 // tell that case apart from two originally-separate records that
9476 // just happen to be adjacent, so it wrongly recombines them too,
9477 // silently shrinking the message count on every Claude -> Codex ->
9478 // (inspect) hop. Fix: stamp a synthetic `metadata.turn_id` — unique
9479 // per ORIGINAL `ChatMessage` — onto the `message` record AND every
9480 // `function_call`/`tool_search_call` record THAT SAME `ChatMessage`
9481 // itself emits. `push_codex_item`'s merge already treats a turn_id
9482 // mismatch as "different turn, do not merge" (the pre-existing
9483 // belt-and-suspenders check); real native Codex data almost never
9484 // carries this field (per that check's own comment), so this is a
9485 // no-op there and only sharpens fidelity for OUR OWN synthesized
9486 // export.
9487 let mut next_group_id: u64 = 0;
9488 // N2 (Fable-5 review, turn_id-collision hardening): every group id
9489 // this export has already assigned — whether REUSED from a real
9490 // `turn_id` or FABRICATED as `sc-grp-N` — so a later assistant
9491 // `ChatMessage` never emits one that's already in use. Two concrete
9492 // mis-merge scenarios motivate this:
9493 //
9494 // (a) Claude->Codex export fabricates `sc-grp-0` for msg A (msg A's
9495 // own text+tool_use); reload makes A carry REAL turn_id
9496 // `sc-grp-0`. A bare tool-call msg B (metadata has no turn_id of
9497 // its own) is then appended. Re-export: A reuses its real
9498 // `sc-grp-0`, but B independently fabricates a FRESH id starting
9499 // from `next_group_id == 0` again (nothing bumped it when A's id
9500 // was reused rather than fabricated) — also `sc-grp-0`.
9501 // Collision. If A's call has no output (interrupted session),
9502 // reimport sees message(sc-grp-0)+call(sc-grp-0)+call(sc-grp-0)
9503 // adjacent with nothing to break the run and merges all three
9504 // into ONE message (2 -> 1).
9505 // (b) Native Codex: `message(turn-7)` opens the merge marker, a
9506 // truncation/clear event strips `__codex_open_turn` (closing the
9507 // turn without changing the id), then `function_call(turn-7)`
9508 // loads as a SECOND, separate `ChatMessage` that still carries
9509 // the SAME real `turn_id` (the reopen step in `push_codex_item`
9510 // restamps it). Full-synthesis export naively reuses `turn-7`
9511 // verbatim for BOTH messages (they're two different loop
9512 // iterations, each independently reusing its own `real_turn_id`)
9513 // and emits them adjacent — reimport's merge check can't tell
9514 // this apart from a single message's own multi-call turn and
9515 // recombines them (2 -> 1).
9516 //
9517 // Fix: the fabricated-id counter is advanced (skipped) past any id
9518 // already in `used_group_ids`, AND a real id that's already been
9519 // used gets disambiguated (`<real>~dupN`) instead of reused verbatim
9520 // — never letting two DIFFERENT `ChatMessage`s in this export share
9521 // one group id, since `push_codex_item`'s merge check treats a
9522 // shared id as "same turn, merge". A single `ChatMessage`'s own
9523 // message record + its own tool call records still share ONE group
9524 // id (computed once per loop iteration below, before insertion), so
9525 // the D1 tool_search merge and ordinary same-turn multi-call
9526 // grouping are unaffected — this only stops REUSE across iterations.
9527 //
9528 // Seeded from `seed_used_ids` (see this fn's doc comment) so the
9529 // spliced-export tail is likewise blind-proof against the prefix it
9530 // doesn't itself write.
9531 let mut used_group_ids: std::collections::HashSet<String> = seed_used_ids.clone();
9532
9533 for msg in messages {
9534 if is_replay_excluded(msg) {
9535 continue;
9536 }
9537 // D3 (Fable-5 review): a message loaded FROM real native Codex
9538 // carries its OWN real `turn_id` in `msg.metadata["turn_id"]`
9539 // (`push_codex_item`'s "message" arm stamps it whenever the
9540 // source record itself has one). The group-id logic below used
9541 // to ALWAYS fabricate a fresh `"sc-grp-N"` value regardless,
9542 // silently overwriting/discarding that real id on any
9543 // native-Codex -> load -> export-Codex hop. Reuse it verbatim
9544 // when present; only fabricate a synthetic id as a fallback for
9545 // our own merge-disambiguation need (PARITY-6/7) when the
9546 // message has no real one of its own.
9547 let real_turn_id = msg.metadata.get("turn_id").map(String::as_str);
9548 match msg.role {
9549 Role::System => {
9550 // PARITY-6 dev/02: carry the original Claude
9551 // `systemSubtype` (`push_claude_system`'s `.with_meta`)
9552 // through as `metadata.claude_system_subtype`, so
9553 // `push_codex_item`'s reverse load can restore it and
9554 // `write_claude_code_records`'s `Role::System` arm can
9555 // re-materialize the EXACT original subtype rather than
9556 // guessing on a Codex -> Claude hop.
9557 let subtype_meta = msg
9558 .metadata
9559 .get("systemSubtype")
9560 .map(|s| ("claude_system_subtype", s.as_str()));
9561 self.push_codex_message(
9562 out,
9563 "developer",
9564 "input_text",
9565 msg,
9566 real_turn_id,
9567 subtype_meta,
9568 )
9569 }
9570 Role::User => {
9571 self.push_codex_message(out, "user", "input_text", msg, real_turn_id, None)
9572 }
9573 Role::Assistant => {
9574 // Emit the message record whenever there is text OR
9575 // content_parts (IX-6 follow-up): an image-only assistant
9576 // message has `content: None, content_parts:
9577 // Some([image])` (the loader's `codex_extract_images` is
9578 // role-general, so this shape can occur on the assistant
9579 // side too) — gating on `msg.content` alone silently
9580 // dropped the whole message, image included. A
9581 // text-only message (content_parts: None) keeps taking
9582 // the historical byte-identical path via
9583 // `codex_message_content_blocks`'s `None` arm. A real
9584 // empty native assistant record carries the
9585 // loader's explicit marker and must also be emitted.
9586 // Reasoning-only cross-provider turns deliberately lack
9587 // that marker and keep the documented Codex residue.
9588 let has_text = msg.content.as_deref().is_some_and(|t| !t.is_empty());
9589 let has_message_record = has_text
9590 || msg.content_parts.is_some()
9591 || msg.metadata.contains_key("empty_assistant_record");
9592 // Only assign a synthetic group id when there's actual
9593 // merge ambiguity to resolve (a message AND its own tool
9594 // calls, or 2+ of this message's own tool calls) — a
9595 // pure-text message with no tool calls, or a lone tool
9596 // call with nothing else from the same `ChatMessage`,
9597 // has nothing to disambiguate, so it keeps the exact
9598 // historical byte shape (no `metadata` key at all).
9599 let group_id: Option<String> = if let Some(real) = real_turn_id {
9600 if used_group_ids.contains(real) {
9601 // N2: this real turn_id was already used by an
9602 // earlier (now-closed) `ChatMessage` in this same
9603 // export — reusing it verbatim would let the
9604 // reimport merge check recombine two originally
9605 // separate messages (see the doc comment above).
9606 let mut n = 1u64;
9607 let mut candidate = format!("{real}~dup{n}");
9608 while used_group_ids.contains(&candidate) {
9609 n += 1;
9610 candidate = format!("{real}~dup{n}");
9611 }
9612 Some(candidate)
9613 } else {
9614 Some(real.to_string())
9615 }
9616 } else if !msg.tool_calls().is_empty() {
9617 // N2: skip past any id already used (e.g. a REAL
9618 // turn_id that happens to look like `sc-grp-N`, or an
9619 // id an earlier reused-real case landed on).
9620 let mut candidate = format!("sc-grp-{next_group_id}");
9621 next_group_id += 1;
9622 while used_group_ids.contains(&candidate) {
9623 candidate = format!("sc-grp-{next_group_id}");
9624 next_group_id += 1;
9625 }
9626 Some(candidate)
9627 } else {
9628 None
9629 };
9630 if let Some(g) = &group_id {
9631 used_group_ids.insert(g.clone());
9632 }
9633 if has_message_record {
9634 self.push_codex_message(
9635 out,
9636 "assistant",
9637 "output_text",
9638 msg,
9639 group_id.as_deref(),
9640 None,
9641 );
9642 }
9643 for tc in msg.tool_calls() {
9644 let custom_tool_call = msg
9645 .metadata
9646 .get("codex_custom_tool_call_ids")
9647 .and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
9648 .is_some_and(|ids| ids.iter().any(|id| id == &tc.id));
9649 if custom_tool_call {
9650 let input = tc
9651 .function
9652 .parsed_arguments()
9653 .unwrap_or_else(|_| Value::String(tc.function.arguments.clone()));
9654 let mut payload = with_turn_id(
9655 serde_json::json!({
9656 "type": "custom_tool_call",
9657 "name": tc.function.name,
9658 "input": input,
9659 "call_id": tc.id,
9660 }),
9661 group_id.as_deref(),
9662 );
9663 set_grok_message_extension(&mut payload, self.meta.source, msg);
9664 push_jsonl(
9665 out,
9666 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9667 );
9668 } else if tc.function.name == "tool_search" {
9669 tool_search_call_ids.insert(tc.id.clone());
9670 let mut payload = with_turn_id(
9671 serde_json::json!({
9672 "type": "tool_search_call",
9673 "arguments": tc.function.arguments,
9674 "call_id": tc.id,
9675 }),
9676 group_id.as_deref(),
9677 );
9678 set_grok_message_extension(&mut payload, self.meta.source, msg);
9679 push_jsonl(
9680 out,
9681 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9682 );
9683 } else {
9684 let mut payload = with_turn_id(
9685 serde_json::json!({
9686 "type": "function_call",
9687 "name": tc.function.name,
9688 "arguments": tc.function.arguments,
9689 "call_id": tc.id,
9690 }),
9691 group_id.as_deref(),
9692 );
9693 set_grok_message_extension(&mut payload, self.meta.source, msg);
9694 push_jsonl(
9695 out,
9696 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9697 );
9698 }
9699 }
9700 // PARITY-11: a genuinely reasoning-only turn (Claude
9701 // `thinking`/`redacted_thinking` with no text, tool_use,
9702 // or image — `push_claude_assistant`'s load-side fix for
9703 // the ~21% of real assistant records that are exactly
9704 // this shape) has no message record and no tool calls,
9705 // so nothing above writes anything for it. This is
9706 // DELIBERATE, not a residual gap: Codex's `reasoning`
9707 // response_item is understood on import (see the
9708 // `response_item`/`"reasoning"` arm above), but its
9709 // real-native semantics is "the reasoning immediately
9710 // BEFORE the next turn" — the reader attaches it to
9711 // whatever response_item comes next, unconditionally.
9712 // For a genuinely standalone Claude reasoning-only turn
9713 // (no related turn follows in Codex's export at all),
9714 // emitting one here would get silently misattributed as
9715 // belonging to some later, unrelated turn instead —
9716 // strictly worse than the current honest, accounted-for
9717 // absence (thinking/redacted_thinking is provider-
9718 // private and "not replayed across providers" by
9719 // original design; the audit correctly classifies it
9720 // `Coverage::Dropped`, not `Unmodeled`). See the
9721 // PARITY-6/7 corpus test's `is_replayable` filter for
9722 // why this doesn't count as a message-count regression.
9723 }
9724 Role::Tool
9725 if msg
9726 .tool_call_id
9727 .as_deref()
9728 .is_some_and(|id| tool_search_call_ids.contains(id)) =>
9729 {
9730 let content = msg.content.clone().unwrap_or_default();
9731 let tools =
9732 serde_json::from_str::<Value>(&content).unwrap_or(Value::String(content));
9733 let mut payload = serde_json::json!({
9734 "type": "tool_search_output",
9735 "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9736 "tools": tools,
9737 });
9738 set_grok_message_extension(&mut payload, self.meta.source, msg);
9739 push_jsonl(
9740 out,
9741 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9742 );
9743 }
9744 Role::Tool => {
9745 let mut payload = serde_json::json!({
9746 "type": "function_call_output",
9747 "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9748 "output": codex_tool_output_text(msg),
9749 });
9750 set_grok_message_extension(&mut payload, self.meta.source, msg);
9751 push_jsonl(
9752 out,
9753 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9754 );
9755 }
9756 }
9757 }
9758 }
9759
9760 /// A12 splice: replay the imported Codex `raw` prefix verbatim — every
9761 /// line, not just the `session_meta`/`turn_context` headers
9762 /// [`Self::to_codex_jsonl`] replays — overriding only
9763 /// `session_meta.payload.id` when `session_id` is `Some` (every other
9764 /// line, including `response_item`s the stock synthesis would otherwise
9765 /// rebuild from scratch, is untouched byte-for-byte). Then synthesizes
9766 /// `response_item` records only for the appended tail, via
9767 /// [`Self::write_codex_records`].
9768 fn to_codex_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9769 let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9770
9771 let mut out = String::new();
9772 for line in &self.raw[..raw_prefix_len] {
9773 match session_id {
9774 Some(id) => {
9775 let patched = serde_json::from_str::<Value>(line)
9776 .ok()
9777 .filter(|v| v.get("type").and_then(Value::as_str) == Some("session_meta"))
9778 .map(|mut v| {
9779 if let Some(payload) = v.get_mut("payload") {
9780 payload["id"] = Value::String(id.to_string());
9781 }
9782 v.to_string()
9783 });
9784 out.push_str(patched.as_deref().unwrap_or(line));
9785 }
9786 None => out.push_str(line),
9787 }
9788 out.push('\n');
9789 }
9790
9791 // N2 (spliced-path hardening): seed the tail's collision guard with
9792 // every group id the just-replayed RAW prefix already carries, so
9793 // `write_codex_records` never fabricates/reuses an id for the
9794 // appended tail that collides with one still open at the end of the
9795 // prefix (see that fn's doc comment, and
9796 // `collect_codex_group_ids_from_raw`'s).
9797 let mut seed_used_ids = collect_codex_group_ids_from_raw(&self.raw[..raw_prefix_len]);
9798 // Belt-and-suspenders: also union in the prefix `messages`' own
9799 // recorded `turn_id` metadata. In the ordinary case this is already
9800 // a subset of what the raw-line scan above found (the loader stamps
9801 // `metadata["turn_id"]` from the very same `payload.metadata.turn_id`
9802 // field the scan reads) — but scanning `messages` too costs nothing
9803 // and means this stays correct even if some future loader path ever
9804 // derives a message's `turn_id` by some means other than a literal
9805 // `payload.metadata.turn_id` copy.
9806 for msg in &self.messages[..message_prefix_len] {
9807 if let Some(tid) = msg.metadata.get("turn_id") {
9808 seed_used_ids.insert(tid.clone());
9809 }
9810 }
9811 self.write_codex_records(
9812 &mut out,
9813 &self.messages[message_prefix_len..],
9814 &seed_used_ids,
9815 );
9816 out
9817 }
9818
9819 /// Build a Codex header from scratch (used when converting from another
9820 /// format, where no original Codex header exists to replay). Emits the
9821 /// fields Codex requires on `session_meta`.
9822 fn write_synthesized_codex_header(&self, out: &mut String) {
9823 let mut meta_payload = serde_json::json!({
9824 "id": self.meta.session_id.clone().unwrap_or_else(|| synth_uuid(0)),
9825 "timestamp": SYNTH_TS,
9826 "cwd": self.cwd_string(),
9827 "originator": "supercode",
9828 "cli_version": env!("CARGO_PKG_VERSION"),
9829 "source": "exec",
9830 "thread_source": "user",
9831 "model_provider": "openai",
9832 });
9833 if let Some(sp) = &self.meta.system_prompt {
9834 meta_payload["base_instructions"] = serde_json::json!({"text": sp});
9835 }
9836 // PARITY-10 dev/03: carry a captured Claude `fork-context-ref` (see
9837 // `capture_claude_meta`) through the Codex hop under a clearly
9838 // namespaced custom field — real Codex tooling ignores unknown
9839 // `session_meta.payload` keys, and `capture_codex_session_meta`
9840 // reads this same key back on import, so a Claude -> Codex -> Claude
9841 // round trip still reconstructs the original record instead of
9842 // silently losing the lineage note on the cross-format hop.
9843 if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
9844 meta_payload["claude_fork_context_ref"] =
9845 serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
9846 }
9847 push_jsonl(
9848 out,
9849 &serde_json::json!({"timestamp": SYNTH_TS, "type": "session_meta", "payload": meta_payload}),
9850 );
9851 if let Some(model) = &self.meta.model {
9852 push_jsonl(
9853 out,
9854 &serde_json::json!({
9855 "timestamp": SYNTH_TS,
9856 "type": "turn_context",
9857 "payload": {"model": model, "cwd": self.cwd_string()},
9858 }),
9859 );
9860 }
9861 }
9862
9863 /// `turn_id`: see the PARITY-6/7 (and D3) comment on
9864 /// [`Self::write_codex_records`] — `Some` when the source message
9865 /// carries its own REAL `turn_id` (a native-Codex round-trip), or
9866 /// (assistant only) a synthetic disambiguation id when it owns tool
9867 /// calls needing merge disambiguation and has no real id of its own;
9868 /// `None` reproduces the exact historical shape (no `metadata` key at
9869 /// all).
9870 /// `extra_metadata`: PARITY-6 dev/02 — an additional `(key, value)`
9871 /// pair folded into `payload.metadata` alongside `turn_id` (used by the
9872 /// `Role::System` case in [`Self::write_codex_records`] to carry
9873 /// `claude_system_subtype`, so a Claude `<local-command-stdout>`-style
9874 /// system record's subtype survives the Claude -> Codex -> Claude round
9875 /// trip instead of only its text; `None` for every other caller,
9876 /// preserving the exact historical shape).
9877 fn push_codex_message(
9878 &self,
9879 out: &mut String,
9880 role: &str,
9881 text_type: &str,
9882 msg: &ChatMessage,
9883 turn_id: Option<&str>,
9884 extra_metadata: Option<(&str, &str)>,
9885 ) {
9886 let mut payload = with_turn_id(
9887 serde_json::json!({
9888 "type": "message",
9889 "role": role,
9890 "content": codex_message_content_blocks(text_type, msg),
9891 }),
9892 turn_id,
9893 );
9894 if let Some((k, v)) = extra_metadata {
9895 if payload.get("metadata").is_none() {
9896 payload["metadata"] = serde_json::json!({});
9897 }
9898 payload["metadata"][k] = serde_json::json!(v);
9899 }
9900 set_grok_message_extension(&mut payload, self.meta.source, msg);
9901 push_jsonl(
9902 out,
9903 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9904 );
9905 }
9906
9907 /// Synthesize a fresh pi v3 session from the canonical `messages`
9908 /// (T3 cross-format/full synthesis — `to_jsonl(other)`'s "view round-trip"
9909 /// tier, §3/§4.1: this is NOT the byte-lossless native path, which goes
9910 /// through `raw` + `to_native_jsonl(_v2)` instead).
9911 fn to_pi_jsonl(&self) -> String {
9912 let session_id = self
9913 .meta
9914 .session_id
9915 .clone()
9916 .unwrap_or_else(|| synth_uuid(0));
9917 let cwd = self.cwd_string();
9918 let mut out = String::new();
9919 push_pi_header(
9920 &mut out,
9921 &session_id,
9922 &cwd,
9923 self.meta
9924 .lineage
9925 .get("parent_session_path")
9926 .map(String::as_str),
9927 self.meta.lineage.get("created_at").map(String::as_str),
9928 // D7: carry a captured Claude `fork-context-ref` (see
9929 // `capture_claude_meta`) through the Pi hop too — mirrors the
9930 // Codex hop's `claude_fork_context_ref` passthrough
9931 // (`write_synthesized_codex_header`) so a Claude -> Pi -> Claude
9932 // round trip doesn't silently lose fork lineage just because Pi
9933 // has no native slot for it.
9934 self.meta
9935 .lineage
9936 .get("claude_fork_context_ref_raw")
9937 .map(String::as_str),
9938 );
9939 let mut used_ids: HashSet<String> = HashSet::new();
9940 let mut counter: u64 = 0;
9941 self.write_pi_entries(&mut out, &self.messages, None, &mut used_ids, &mut counter);
9942 if let Some(extension) = codex_provenance_envelope(&self.meta) {
9943 inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
9944 }
9945 out
9946 }
9947
9948 /// Synthesize pi `message` entries for `messages` (a full session, or —
9949 /// for [`Self::to_pi_jsonl_spliced`] — just the appended tail), chaining
9950 /// `parentId` from `parent` and drawing fresh ids from `used_ids`/
9951 /// `counter`. Skips [`is_replay_excluded`] messages (`compacted_out`,
9952 /// `pi_exclude_from_context`) exactly like the Claude/Codex writers.
9953 fn write_pi_entries(
9954 &self,
9955 out: &mut String,
9956 messages: &[ChatMessage],
9957 mut parent: Option<String>,
9958 used_ids: &mut HashSet<String>,
9959 counter: &mut u64,
9960 ) {
9961 // Claude Code and Codex do not repeat the tool name on their native
9962 // tool-result records. Recover that redundant Pi field from the
9963 // paired assistant call when a cross-format round trip therefore
9964 // returns a canonical Tool message with `name == None`.
9965 let mut paired_tool_names = HashMap::<String, String>::new();
9966 for msg in messages {
9967 if is_replay_excluded(msg) {
9968 continue;
9969 }
9970 for call in msg.tool_calls() {
9971 paired_tool_names.insert(call.id.clone(), call.function.name.clone());
9972 }
9973 let id = pi_fresh_id(used_ids, counter);
9974 let mut entry = match msg.role {
9975 // B4: pi has no session-level system/developer PROMPT slot
9976 // (§1.1: "no system-prompt... rebuilt at runtime"), but a
9977 // content-bearing `Role::System` message loaded from a real
9978 // Claude Code `type: "system"` record (`push_claude_system`'s
9979 // keep-listed subtypes: `local_command`, `scheduled_task_fire`,
9980 // `away_summary`) is NOT a system prompt — it's a real,
9981 // non-regenerable transcript event. Pi's own `role:"custom"`
9982 // `CustomMessage` (§3e: "extension-injected... sent to the LLM
9983 // as a user message") is the closest existing, non-fabricated
9984 // slot pi's own parser already understands, so this
9985 // re-materializes the record there instead of silently
9986 // dropping it — the exact allowance push_claude_system's own
9987 // doc comment describes in reverse. `customType` is a
9988 // supercode-namespaced marker (`push_pi_custom_common`
9989 // recognizes it on reload and restores `Role::System` +
9990 // `metadata["systemSubtype"]`, exactly like `push_claude_system`
9991 // produced in the first place); a real pi customType never
9992 // collides with this name. `details.claude_system_subtype`
9993 // carries the original subtype losslessly through the pi leg
9994 // (mirrors `write_codex_records`'s `claude_system_subtype`
9995 // metadata channel on the Codex leg, PARITY-6 dev/02). Content
9996 // is never fabricated — only emitted when non-empty.
9997 Role::System => {
9998 let content = msg.content.clone().unwrap_or_default();
9999 if content.trim().is_empty() {
10000 continue;
10001 }
10002 let subtype = msg
10003 .metadata
10004 .get("systemSubtype")
10005 .cloned()
10006 .unwrap_or_else(|| "local_command".to_string());
10007 serde_json::json!({
10008 "type": "message",
10009 "id": id,
10010 "parentId": parent,
10011 "timestamp": msg_timestamp_or_synth(msg),
10012 "message": {
10013 "role": "custom",
10014 "customType": PI_CLAUDE_SYSTEM_CUSTOM_TYPE,
10015 "content": content,
10016 "display": true,
10017 "details": {"claude_system_subtype": subtype},
10018 "timestamp": msg_pi_native_timestamp_ms(msg),
10019 },
10020 })
10021 }
10022 Role::User => serde_json::json!({
10023 "type": "message",
10024 "id": id,
10025 "parentId": parent,
10026 "timestamp": msg_timestamp_or_synth(msg),
10027 "message": {
10028 "role": "user",
10029 "content": pi_content_value(msg),
10030 "timestamp": msg_pi_native_timestamp_ms(msg),
10031 },
10032 }),
10033 Role::Assistant => {
10034 let api = msg
10035 .metadata
10036 .get("pi_api")
10037 .cloned()
10038 .unwrap_or_else(|| "anthropic-messages".to_string());
10039 let provider = msg
10040 .metadata
10041 .get("pi_provider")
10042 .cloned()
10043 .unwrap_or_else(|| "anthropic".to_string());
10044 let model = self
10045 .meta
10046 .model
10047 .clone()
10048 .unwrap_or_else(|| "unknown".to_string());
10049 let usage = msg
10050 .metadata
10051 .get("pi_usage")
10052 .and_then(|s| serde_json::from_str::<Value>(s).ok())
10053 .unwrap_or_else(default_pi_usage);
10054 let stop_reason = msg
10055 .metadata
10056 .get("pi_stop_reason")
10057 .cloned()
10058 .unwrap_or_else(|| "stop".to_string());
10059 serde_json::json!({
10060 "type": "message",
10061 "id": id,
10062 "parentId": parent,
10063 "timestamp": msg_timestamp_or_synth(msg),
10064 "message": {
10065 "role": "assistant",
10066 "content": pi_assistant_content_value(msg),
10067 "api": api,
10068 "provider": provider,
10069 "model": model,
10070 "usage": usage,
10071 "stopReason": stop_reason,
10072 "timestamp": msg_pi_native_timestamp_ms(msg),
10073 },
10074 })
10075 }
10076 Role::Tool => serde_json::json!({
10077 "type": "message",
10078 "id": id,
10079 "parentId": parent,
10080 "timestamp": msg_timestamp_or_synth(msg),
10081 "message": {
10082 "role": "toolResult",
10083 "toolCallId": msg.tool_call_id.clone().unwrap_or_default(),
10084 "toolName": msg.name.as_deref().or_else(|| {
10085 msg.tool_call_id
10086 .as_deref()
10087 .and_then(|id| paired_tool_names.get(id).map(String::as_str))
10088 }).unwrap_or_default(),
10089 "content": pi_content_value(msg),
10090 "isError": is_tool_error_flag(msg),
10091 "timestamp": msg_pi_native_timestamp_ms(msg),
10092 },
10093 }),
10094 };
10095 if msg.role == Role::Tool && crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
10096 entry[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
10097 }
10098 set_grok_message_extension(&mut entry, self.meta.source, msg);
10099 push_jsonl(out, &entry);
10100 parent = Some(id);
10101 if msg.role == Role::Tool {
10102 if let Some(call_id) = msg.tool_call_id.as_deref() {
10103 paired_tool_names.remove(call_id);
10104 }
10105 }
10106 }
10107 }
10108
10109 /// A12-style splice for pi (§1.1/§4.2): replay the imported `raw` prefix
10110 /// **verbatim** — the header line always has its `version` normalized to
10111 /// 3 (§1.3: pi rewrites any pre-v3 file in place on load, destroying
10112 /// byte-identity, so the writer never re-emits one; this intentionally
10113 /// breaks byte-identity for pre-v3 originals only, the accepted
10114 /// trade-off) and its `id` overridden when `session_id` is `Some`. Every
10115 /// other raw line — every entry — is untouched (pi repeats the session
10116 /// id on no other line, `pi-fields.md` §1). Then synthesizes `message`
10117 /// entries only for the appended tail via [`Self::write_pi_entries`],
10118 /// chaining from the last entry `id` found in the raw prefix.
10119 fn to_pi_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
10120 let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10121 if raw_prefix_len == 0 {
10122 return Ok(self.to_pi_jsonl());
10123 }
10124
10125 let mut out = String::new();
10126 let mut used_ids: HashSet<String> = HashSet::new();
10127 let mut leaf: Option<String> = None;
10128 for (i, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10129 if i == 0 {
10130 if let Ok(v) = serde_json::from_str::<Value>(line) {
10131 if v.get("type").and_then(Value::as_str) == Some("session") {
10132 let needs_v3 = v.get("version").and_then(Value::as_u64) != Some(3);
10133 // Only reparse+reserialize the header when something
10134 // actually needs to change — this crate doesn't
10135 // enable serde_json's `preserve_order`, so a no-op
10136 // round-trip through `Value` would reorder keys
10137 // alphabetically and silently break the "prefix
10138 // bytes unchanged" splice guarantee for the (common)
10139 // already-v3, no-override case.
10140 if needs_v3 || session_id.is_some() {
10141 let mut v = v;
10142 v["version"] = serde_json::json!(3);
10143 if let Some(new_id) = session_id {
10144 v["id"] = Value::String(new_id.to_string());
10145 }
10146 out.push_str(&v.to_string());
10147 out.push('\n');
10148 continue;
10149 }
10150 }
10151 }
10152 }
10153 out.push_str(line);
10154 out.push('\n');
10155 if let Ok(v) = serde_json::from_str::<Value>(line) {
10156 if let Some(id) = v.get("id").and_then(Value::as_str) {
10157 used_ids.insert(id.to_string());
10158 leaf = Some(id.to_string());
10159 }
10160 }
10161 }
10162
10163 let mut counter: u64 = 0;
10164 self.write_pi_entries(
10165 &mut out,
10166 &self.messages[message_prefix_len..],
10167 leaf,
10168 &mut used_ids,
10169 &mut counter,
10170 );
10171 Ok(out)
10172 }
10173
10174 // ---- Grok writers -----------------------------------------------
10175
10176 /// Synthesize Grok's resumable `chat_history.jsonl` transcript.
10177 fn to_grok_jsonl(&self) -> String {
10178 let mut out = String::new();
10179 if let Some(prompt) = self
10180 .meta
10181 .system_prompt
10182 .as_deref()
10183 .filter(|prompt| !prompt.is_empty())
10184 {
10185 push_jsonl(
10186 &mut out,
10187 &serde_json::json!({
10188 "type": "system",
10189 "content": prompt,
10190 }),
10191 );
10192 }
10193 self.write_grok_records(&mut out, &self.messages);
10194 if let Some(extension) = codex_provenance_envelope(&self.meta) {
10195 if out.is_empty() {
10196 push_jsonl(
10197 &mut out,
10198 &serde_json::json!({"type": "system", "content": ""}),
10199 );
10200 }
10201 inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
10202 }
10203 out
10204 }
10205
10206 fn write_grok_records(&self, out: &mut String, messages: &[ChatMessage]) {
10207 for message in messages {
10208 if is_replay_excluded(message) {
10209 continue;
10210 }
10211 let mut value = match message.role {
10212 Role::System => serde_json::json!({
10213 "type": "user",
10214 "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10215 "synthetic_reason": "supercode_system_event",
10216 }),
10217 Role::User => {
10218 let mut value = serde_json::json!({
10219 "type": "user",
10220 "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10221 });
10222 if let Some(object) = value.as_object_mut() {
10223 for (metadata, field) in [
10224 ("grok_prompt_index", "prompt_index"),
10225 ("grok_prior_turn_interrupt", "prior_turn_interrupt"),
10226 ("grok_synthetic_reason", "synthetic_reason"),
10227 ] {
10228 if let Some(raw) = message.metadata.get(metadata) {
10229 object.insert(
10230 field.to_string(),
10231 serde_json::from_str(raw)
10232 .unwrap_or_else(|_| Value::String(raw.clone())),
10233 );
10234 }
10235 }
10236 }
10237 value
10238 }
10239 Role::Assistant => {
10240 let calls = message
10241 .tool_calls()
10242 .iter()
10243 .map(|call| {
10244 serde_json::json!({
10245 "id": call.id,
10246 "name": call.function.name,
10247 "arguments": call.function.arguments,
10248 })
10249 })
10250 .collect::<Vec<_>>();
10251 let mut value = serde_json::json!({
10252 "type": "assistant",
10253 "content": message.content.clone().unwrap_or_default(),
10254 "tool_calls": calls,
10255 "model_id": message.metadata.get("grok_model_id")
10256 .or(self.meta.model.as_ref())
10257 .cloned()
10258 .unwrap_or_else(|| "unknown".to_string()),
10259 });
10260 if let Some(object) = value.as_object_mut() {
10261 for (metadata, field) in [
10262 ("grok_model_fingerprint", "model_fingerprint"),
10263 ("grok_reasoning_effort", "reasoning_effort"),
10264 ] {
10265 if let Some(raw) = message.metadata.get(metadata) {
10266 object.insert(field.to_string(), Value::String(raw.clone()));
10267 }
10268 }
10269 }
10270 value
10271 }
10272 Role::Tool => serde_json::json!({
10273 "type": "tool_result",
10274 "tool_call_id": message.tool_call_id.clone().unwrap_or_default(),
10275 "content": message.content.clone().unwrap_or_default(),
10276 }),
10277 };
10278 set_grok_target_message_extension(&mut value, message);
10279 push_jsonl(out, &value);
10280 }
10281 }
10282
10283 /// Replay a Grok imported prefix verbatim, then append newly-created
10284 /// canonical turns. Grok stores the session id in the directory name,
10285 /// not in transcript records, so there is no in-file id to rewrite.
10286 fn to_grok_jsonl_spliced(&self) -> String {
10287 let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10288 if raw_prefix_len == 0 {
10289 return self.to_grok_jsonl();
10290 }
10291 let mut out = String::new();
10292 for line in &self.raw[..raw_prefix_len] {
10293 out.push_str(line);
10294 out.push('\n');
10295 }
10296 self.write_grok_records(&mut out, &self.messages[message_prefix_len..]);
10297 out
10298 }
10299
10300 // ---- Gemini writers ---------------------------------------------
10301
10302 fn to_gemini_jsonl(&self) -> String {
10303 let mut out = String::new();
10304 self.write_gemini_header(&mut out, self.meta.session_id.as_deref());
10305 self.write_gemini_records(&mut out, &self.messages);
10306 push_jsonl(
10307 &mut out,
10308 &serde_json::json!({
10309 "$set": {"lastUpdated": SYNTH_TS}
10310 }),
10311 );
10312 out
10313 }
10314
10315 fn write_gemini_header(&self, out: &mut String, session_id: Option<&str>) {
10316 push_jsonl(
10317 out,
10318 &serde_json::json!({
10319 "sessionId": session_id.unwrap_or("supercode-gemini-session"),
10320 "projectHash": self.meta.lineage.get("gemini_project_hash")
10321 .cloned().unwrap_or_else(|| "supercode".to_string()),
10322 "startTime": self.meta.lineage.get("created_at")
10323 .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10324 "lastUpdated": self.meta.lineage.get("updated_at")
10325 .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10326 "kind": self.meta.lineage.get("gemini_session_kind")
10327 .cloned().unwrap_or_else(|| "main".to_string()),
10328 }),
10329 );
10330 }
10331
10332 fn write_gemini_records(&self, out: &mut String, messages: &[ChatMessage]) {
10333 let mut call_names = HashMap::new();
10334 for (index, message) in messages.iter().enumerate() {
10335 if is_replay_excluded(message) {
10336 continue;
10337 }
10338 let timestamp = message
10339 .metadata
10340 .get("timestamp")
10341 .cloned()
10342 .unwrap_or_else(|| SYNTH_TS.to_string());
10343 match message.role {
10344 Role::System | Role::User => {
10345 let mut parts = Vec::new();
10346 let text = message.content.clone().or_else(|| {
10347 message.content_parts.as_ref().and_then(|parts| {
10348 let text = parts
10349 .iter()
10350 .filter_map(|part| part.get("text").and_then(Value::as_str))
10351 .collect::<Vec<_>>()
10352 .join(" ");
10353 (!text.is_empty()).then_some(text)
10354 })
10355 });
10356 if let Some(text) = text {
10357 let text = if message.role == Role::System {
10358 format!("[System] {text}")
10359 } else {
10360 text
10361 };
10362 parts.push(serde_json::json!({"text": text}));
10363 }
10364 if let Some(content_parts) = &message.content_parts {
10365 for part in content_parts {
10366 let Some(url) = part
10367 .get("image_url")
10368 .and_then(|value| value.get("url"))
10369 .and_then(Value::as_str)
10370 else {
10371 continue;
10372 };
10373 let Some(rest) = url.strip_prefix("data:") else {
10374 continue;
10375 };
10376 let Some((media_type, data)) = rest.split_once(";base64,") else {
10377 continue;
10378 };
10379 parts.push(serde_json::json!({
10380 "inlineData": {"mimeType": media_type, "data": data}
10381 }));
10382 }
10383 }
10384 if !parts.is_empty() {
10385 let mut value = serde_json::json!({
10386 "id": format!("supercode-user-{index}"),
10387 "timestamp": timestamp,
10388 "type": "user",
10389 "content": parts,
10390 });
10391 set_gemini_message_extension(&mut value, message);
10392 push_jsonl(out, &value);
10393 }
10394 }
10395 Role::Assistant => {
10396 let mut tool_calls = Vec::new();
10397 for call in message.tool_calls() {
10398 call_names.insert(call.id.clone(), call.function.name.clone());
10399 let args = serde_json::from_str::<Value>(&call.function.arguments)
10400 .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10401 tool_calls.push(serde_json::json!({
10402 "id": call.id,
10403 "name": call.function.name,
10404 "args": args,
10405 }));
10406 }
10407 let mut value = serde_json::json!({
10408 "id": format!("supercode-gemini-{index}"),
10409 "timestamp": timestamp,
10410 "type": "gemini",
10411 "content": message.content.clone().unwrap_or_default(),
10412 "model": message.metadata.get("gemini_model")
10413 .or(self.meta.model.as_ref())
10414 .cloned().unwrap_or_else(|| "unknown".to_string()),
10415 });
10416 if !tool_calls.is_empty() {
10417 value["toolCalls"] = Value::Array(tool_calls);
10418 }
10419 if let Some(thoughts) = message.metadata.get("gemini_thoughts") {
10420 value["thoughts"] = serde_json::from_str(thoughts)
10421 .unwrap_or_else(|_| Value::String(thoughts.clone()));
10422 }
10423 set_gemini_message_extension(&mut value, message);
10424 push_jsonl(out, &value);
10425 }
10426 Role::Tool => {
10427 let id = message.tool_call_id.clone().unwrap_or_default();
10428 let name = message
10429 .name
10430 .clone()
10431 .or_else(|| call_names.get(&id).cloned())
10432 .unwrap_or_else(|| "tool".to_string());
10433 let output = message.content.clone().unwrap_or_else(|| {
10434 message
10435 .content_parts
10436 .as_ref()
10437 .map(|parts| Value::Array(parts.clone()))
10438 .map(|value| value.to_string())
10439 .unwrap_or_default()
10440 });
10441 let mut value = serde_json::json!({
10442 "id": format!("supercode-tool-{index}"),
10443 "timestamp": timestamp,
10444 "type": "user",
10445 "content": [{
10446 "functionResponse": {
10447 "id": id,
10448 "name": name,
10449 "response": {"output": output}
10450 }
10451 }],
10452 });
10453 set_gemini_message_extension(&mut value, message);
10454 push_jsonl(out, &value);
10455 }
10456 }
10457 }
10458 }
10459
10460 fn to_gemini_jsonl_spliced(&self, session_id: Option<&str>) -> String {
10461 let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10462 if raw_prefix_len == 0 {
10463 let mut out = String::new();
10464 self.write_gemini_header(&mut out, session_id.or(self.meta.session_id.as_deref()));
10465 self.write_gemini_records(&mut out, &self.messages);
10466 return out;
10467 }
10468 let mut out = String::new();
10469 for (index, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10470 if index == 0 && session_id.is_some() {
10471 if let Ok(mut value) = serde_json::from_str::<Value>(line) {
10472 if value.get("type").is_none() && value.get("sessionId").is_some() {
10473 value["sessionId"] =
10474 Value::String(session_id.unwrap_or_default().to_string());
10475 push_jsonl(&mut out, &value);
10476 continue;
10477 }
10478 }
10479 }
10480 out.push_str(line);
10481 out.push('\n');
10482 }
10483 self.write_gemini_records(&mut out, &self.messages[message_prefix_len..]);
10484 out
10485 }
10486
10487 // ---- Goose writers ----------------------------------------------
10488
10489 fn to_goose_json(&self) -> String {
10490 if self.meta.source == SessionSource::Goose
10491 && !self.raw.is_empty()
10492 && self.imported_message_count == Some(self.messages.len())
10493 {
10494 return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10495 }
10496 self.synthesized_goose_document(None, &self.messages)
10497 }
10498
10499 fn to_goose_json_spliced(&self, session_id: Option<&str>) -> String {
10500 let message_prefix_len = self
10501 .imported_message_count
10502 .unwrap_or(self.messages.len())
10503 .min(self.messages.len());
10504 if self.meta.source == SessionSource::Goose && !self.raw.is_empty() {
10505 if session_id.is_none() && message_prefix_len == self.messages.len() {
10506 return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10507 }
10508 let source = join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10509 if let Ok(mut document) = serde_json::from_str::<Value>(&source) {
10510 if let Some(session_id) = session_id {
10511 document["id"] = Value::String(session_id.to_string());
10512 }
10513 let appended = self.goose_conversation(&self.messages[message_prefix_len..]);
10514 if let Some(conversation) = document
10515 .get_mut("conversation")
10516 .and_then(Value::as_array_mut)
10517 {
10518 conversation.extend(appended);
10519 document["message_count"] = Value::from(conversation.len());
10520 }
10521 return serde_json::to_string_pretty(&document).unwrap_or_else(|_| {
10522 self.synthesized_goose_document(session_id, &self.messages)
10523 });
10524 }
10525 }
10526 self.synthesized_goose_document(session_id, &self.messages)
10527 }
10528
10529 fn synthesized_goose_document(
10530 &self,
10531 session_id: Option<&str>,
10532 messages: &[ChatMessage],
10533 ) -> String {
10534 let mut document = self
10535 .meta
10536 .goose_header
10537 .clone()
10538 .or_else(|| {
10539 self.messages.iter().find_map(|message| {
10540 message
10541 .metadata
10542 .get("goose_session_header")
10543 .and_then(|value| serde_json::from_str(value).ok())
10544 })
10545 })
10546 .unwrap_or_else(|| {
10547 serde_json::json!({
10548 "id": "supercode-goose-session",
10549 "working_dir": self.cwd_string(),
10550 "name": "supercode export",
10551 "user_set_name": false,
10552 "session_type": "user",
10553 "created_at": SYNTH_TS,
10554 "updated_at": SYNTH_TS,
10555 "extension_data": {},
10556 "usage": {},
10557 "accumulated_usage": {},
10558 "accumulated_cost": Value::Null,
10559 "schedule_id": Value::Null,
10560 "recipe": Value::Null,
10561 "user_recipe_values": Value::Null,
10562 "message_count": 0,
10563 "last_message_at": Value::Null,
10564 "provider_name": Value::Null,
10565 "model_config": Value::Null,
10566 "goose_mode": "auto",
10567 "archived_at": Value::Null,
10568 "project_id": Value::Null,
10569 "parent_session_id": Value::Null,
10570 "last_message_snippet": Value::Null,
10571 })
10572 });
10573 document["id"] = Value::String(
10574 session_id
10575 .map(str::to_string)
10576 .or_else(|| self.meta.session_id.clone())
10577 .unwrap_or_else(|| "supercode-goose-session".to_string()),
10578 );
10579 document["working_dir"] = Value::String(self.cwd_string());
10580 let conversation = self.goose_conversation(messages);
10581 document["message_count"] = Value::from(conversation.len());
10582 document["conversation"] = Value::Array(conversation);
10583 serde_json::to_string_pretty(&document).unwrap_or_else(|_| "{}".to_string())
10584 }
10585
10586 fn goose_conversation(&self, messages: &[ChatMessage]) -> Vec<Value> {
10587 let mut out = Vec::new();
10588 let mut last_native_index: Option<String> = None;
10589 let mut tool_names = HashMap::<String, String>::new();
10590 for (index, message) in messages.iter().enumerate() {
10591 if is_replay_excluded(message) {
10592 continue;
10593 }
10594 if let Some(native_index) = message.metadata.get("goose_native_index") {
10595 if last_native_index.as_ref() == Some(native_index) {
10596 continue;
10597 }
10598 last_native_index = Some(native_index.clone());
10599 if let Some(native) = message
10600 .metadata
10601 .get("goose_native_message")
10602 .and_then(|value| serde_json::from_str::<Value>(value).ok())
10603 {
10604 out.push(native);
10605 continue;
10606 }
10607 } else {
10608 last_native_index = None;
10609 }
10610
10611 for call in message.tool_calls() {
10612 tool_names.insert(call.id.clone(), call.function.name.clone());
10613 }
10614 let created = message
10615 .metadata
10616 .get("goose_created")
10617 .and_then(|value| value.parse::<i64>().ok())
10618 .unwrap_or(SYNTH_TS_MS / 1000 + index as i64);
10619 let role = match message.role {
10620 Role::Assistant => "assistant",
10621 _ => "user",
10622 };
10623 let mut content = Vec::new();
10624 // A Goose tool response carries its output inside
10625 // `toolResult.value.content`; duplicating it as a sibling text
10626 // block makes the loader normalize one Tool message twice.
10627 if message.role != Role::Tool {
10628 if let Some(text) = &message.content {
10629 let text = if message.role == Role::System {
10630 format!("[System] {text}")
10631 } else {
10632 text.clone()
10633 };
10634 content.push(serde_json::json!({"type": "text", "text": text}));
10635 }
10636 if let Some(parts) = &message.content_parts {
10637 for part in parts {
10638 if let Some(text) = part.get("text").and_then(Value::as_str) {
10639 if message.content.is_none() {
10640 content.push(serde_json::json!({"type": "text", "text": text}));
10641 }
10642 }
10643 let Some(url) = part
10644 .get("image_url")
10645 .and_then(|image| image.get("url"))
10646 .and_then(Value::as_str)
10647 else {
10648 continue;
10649 };
10650 let Some(data) = url.strip_prefix("data:") else {
10651 continue;
10652 };
10653 let Some((media_type, data)) = data.split_once(";base64,") else {
10654 continue;
10655 };
10656 content.push(serde_json::json!({
10657 "type": "image",
10658 "data": data,
10659 "mimeType": media_type,
10660 }));
10661 }
10662 }
10663 }
10664 for call in message.tool_calls() {
10665 let arguments = serde_json::from_str::<Value>(&call.function.arguments)
10666 .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10667 content.push(serde_json::json!({
10668 "type": "toolRequest",
10669 "id": call.id,
10670 "toolCall": {
10671 "status": "success",
10672 "value": {"name": call.function.name, "arguments": arguments}
10673 }
10674 }));
10675 }
10676 if message.role == Role::Tool {
10677 let id = message.tool_call_id.clone().unwrap_or_default();
10678 let output = message.content.clone().unwrap_or_else(|| {
10679 message
10680 .content_parts
10681 .as_ref()
10682 .map(|parts| Value::Array(parts.clone()).to_string())
10683 .unwrap_or_default()
10684 });
10685 let tool_result = if crate::is_tool_error(message) {
10686 serde_json::json!({"status": "error", "error": output})
10687 } else {
10688 serde_json::json!({
10689 "status": "success",
10690 "value": {
10691 "content": [{"type": "text", "text": output}],
10692 "isError": false
10693 }
10694 })
10695 };
10696 content.push(serde_json::json!({
10697 "type": "toolResponse",
10698 "id": id,
10699 "toolResult": tool_result,
10700 "metadata": {
10701 "toolName": message.name.as_ref()
10702 .or_else(|| tool_names.get(&id))
10703 }
10704 }));
10705 }
10706 if content.is_empty() {
10707 continue;
10708 }
10709 let metadata = message
10710 .metadata
10711 .get("goose_metadata")
10712 .and_then(|value| serde_json::from_str::<Value>(value).ok())
10713 .unwrap_or_else(|| {
10714 serde_json::json!({
10715 "userVisible": true,
10716 "agentVisible": true
10717 })
10718 });
10719 let mut native = serde_json::json!({
10720 "id": message.metadata.get("goose_message_id")
10721 .cloned().unwrap_or_else(|| format!("supercode-goose-{index}")),
10722 "role": role,
10723 "created": created,
10724 "content": content,
10725 "metadata": metadata,
10726 });
10727 // Goose tolerates unknown top-level fields on a conversation
10728 // message. Always carry the canonical envelope when Goose is
10729 // the TARGET so metadata absent from Goose's stock schema can
10730 // make a later Goose -> source round trip without residue.
10731 set_grok_target_message_extension(&mut native, message);
10732 out.push(native);
10733 }
10734 out
10735 }
10736
10737 // ---- OpenCode writers ---------------------------------------------
10738
10739 /// Re-derive the structured OpenCode records (`SessionInfo`, an ordered
10740 /// `(message value, part values)` list) directly from `self.raw`'s
10741 /// envelope lines — the same classification
10742 /// [`Self::from_opencode_str`] performs, but returning the raw VALUES
10743 /// rather than canonical `ChatMessage`s. Used by
10744 /// [`Self::to_opencode_jsonl_spliced`] (§4.2 S5: the imported prefix
10745 /// must be VALUE-EQUAL at its position, not re-synthesized from lossy
10746 /// `messages`) and [`Self::to_opencode_direct_write`] (S5: the fidelity
10747 /// path for excess keys/timestamps/side-records `opencode import`
10748 /// cannot restore).
10749 fn opencode_records_from_raw(&self) -> (Option<Value>, Vec<(Value, Vec<Value>)>) {
10750 let mut session_info: Option<Value> = None;
10751 let mut msg_order: Vec<String> = Vec::new();
10752 let mut msg_values: HashMap<String, Value> = HashMap::new();
10753 let mut msg_parts: HashMap<String, Vec<Value>> = HashMap::new();
10754 for line in &self.raw {
10755 let Ok(env) = serde_json::from_str::<Value>(line) else {
10756 continue;
10757 };
10758 let Some(key) = env.get("key").and_then(Value::as_array) else {
10759 continue;
10760 };
10761 let value = env.get("value").cloned().unwrap_or(Value::Null);
10762 match key.first().and_then(Value::as_str) {
10763 Some("session") => session_info = Some(value),
10764 Some("message") => {
10765 if let Some(id) = value.get("id").and_then(Value::as_str) {
10766 if !msg_values.contains_key(id) {
10767 msg_order.push(id.to_string());
10768 }
10769 msg_values.insert(id.to_string(), value);
10770 }
10771 }
10772 Some("part") => {
10773 if let Some(mid) = value.get("messageID").and_then(Value::as_str) {
10774 msg_parts.entry(mid.to_string()).or_default().push(value);
10775 }
10776 }
10777 _ => {}
10778 }
10779 }
10780 let mut ordered: Vec<(String, i64)> = msg_order
10781 .iter()
10782 .map(|id| {
10783 let tc = msg_values
10784 .get(id)
10785 .and_then(|v| v.get("time"))
10786 .and_then(|t| t.get("created"))
10787 .and_then(Value::as_i64)
10788 .unwrap_or(0);
10789 (id.clone(), tc)
10790 })
10791 .collect();
10792 ordered.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
10793 let mut out = Vec::new();
10794 for (id, _) in ordered {
10795 let mut parts = msg_parts.remove(&id).unwrap_or_default();
10796 parts.sort_by(|a, b| {
10797 let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
10798 let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
10799 ai.cmp(bi)
10800 });
10801 if let Some(v) = msg_values.remove(&id) {
10802 out.push((v, parts));
10803 }
10804 }
10805 (session_info, out)
10806 }
10807
10808 /// Best-effort `SessionInfo` synthesized from `self.meta` — used when no
10809 /// `raw` prefix exists to replay (a fresh/cross-format-converted
10810 /// session). T3 tier: only what `SessionMeta` carries survives.
10811 fn synthesized_opencode_info(&self) -> Value {
10812 let id = self
10813 .meta
10814 .session_id
10815 .clone()
10816 .unwrap_or_else(|| "ses_supercode00000000000001".to_string());
10817 let mut info = serde_json::json!({
10818 "id": id,
10819 "projectID": self.meta.lineage.get("projectID").cloned().unwrap_or_else(|| "global".to_string()),
10820 // OpenCode 1.2.15's import path writes this into a NOT NULL
10821 // SQLite column. Preserve a real source slug when available and
10822 // mint a stable, human-readable fallback for foreign sessions.
10823 "slug": self.meta.lineage.get("slug").cloned().unwrap_or_else(|| "supercode-export".to_string()),
10824 "directory": self.cwd_string(),
10825 "title": "supercode export",
10826 "version": env!("CARGO_PKG_VERSION"),
10827 "time": {"created": SYNTH_TS_MS, "updated": SYNTH_TS_MS},
10828 });
10829 if let Some(agent) = &self.meta.agent_id {
10830 info["agent"] = Value::String(agent.clone());
10831 }
10832 if let Some(model) = &self.meta.model {
10833 if let Some((provider, mid)) = model.split_once('/') {
10834 info["model"] = serde_json::json!({"providerID": provider, "id": mid});
10835 }
10836 }
10837 if let Some(parent) = self.meta.lineage.get("parent_session_id") {
10838 info["parentID"] = Value::String(parent.clone());
10839 }
10840 // D7: carry a captured Claude `fork-context-ref` through the
10841 // OpenCode hop too, exactly like the Codex (`claude_fork_context_ref`
10842 // on `session_meta.payload`) and Pi (`claude_fork_context_ref` on
10843 // the `session` header) hops already do — namespaced so real
10844 // OpenCode tooling ignores it, and `capture_opencode_session_info`
10845 // reads this same key back on import so a Claude -> OpenCode ->
10846 // Claude round trip doesn't silently lose fork lineage either.
10847 //
10848 // DOCUMENTED LIMITATION (D7 caveat, per the frozen interop spec —
10849 // `interop-research-spec@f168465`, `opencode-pi-spec.md` ~line 123):
10850 // this `claude_fork_context_ref` key on `SessionInfo` survives
10851 // supercode's OWN round-trip (write here, read back by
10852 // `capture_opencode_session_info` above) but NOT a real upstream
10853 // `opencode import` ingestion — that path decodes with
10854 // `Schema.decodeUnknownSync`, which strips any key its schema
10855 // doesn't declare. The direct-file/DB fallback (bypassing
10856 // `opencode import` entirely) is the per-spec fidelity path for
10857 // this lineage to actually reach real OpenCode.
10858 if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
10859 info["claude_fork_context_ref"] =
10860 serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
10861 }
10862 if let Some(extension) = codex_provenance_envelope(&self.meta) {
10863 info[SUPERCODE_CODEX_PROVENANCE_KEY] = extension;
10864 }
10865 info
10866 }
10867
10868 /// OpenCode uses `SessionInfo.time.updated` for recency selection. Any
10869 /// synthesized continuation message therefore has to advance the
10870 /// session clock along with its own `time.created` value.
10871 fn touch_opencode_session_updated(info: &mut Value, timestamp: i64) {
10872 if !info.get("time").is_some_and(Value::is_object) {
10873 info["time"] = serde_json::json!({});
10874 }
10875 info["time"]["updated"] = serde_json::json!(timestamp);
10876 }
10877
10878 /// Synthesize opencode `{info, parts}` message objects for `messages`
10879 /// (T3 tier — full synthesis from canonical `ChatMessage`s, the inverse
10880 /// of [`push_opencode_user`]/[`push_opencode_assistant`]), appending
10881 /// them to `out`. Every `Tool` message anywhere in `messages` is folded
10882 /// back into its call's assistant `tool` part (match by
10883 /// `tool_call_id`/`callID`, pairing repeated ids by occurrence over the
10884 /// whole slice)
10885 /// — the exact inverse of the loader's call/result split. This is a
10886 /// GLOBAL match, not a scan of the contiguous run of `Tool` messages
10887 /// immediately following each assistant: two-or-more consecutive
10888 /// assistant-with-tool-call messages before their results (streamed /
10889 /// parallel tool calls) otherwise strand the earlier call's real result
10890 /// behind a later assistant message, silently downgrading it to
10891 /// "pending" and losing the recorded output. Skips [`is_replay_excluded`]
10892 /// messages exactly like every other writer.
10893 fn append_synthesized_opencode_messages(
10894 &self,
10895 out: &mut Vec<Value>,
10896 messages: &[ChatMessage],
10897 session_id: &str,
10898 counter: &mut u64,
10899 timestamp_cursor: &mut i64,
10900 ) -> Result<()> {
10901 // Pair each Tool message to its call GLOBALLY by `tool_call_id`,
10902 // over the ENTIRE slice being processed, rather than by scanning
10903 // only the contiguous run of `Role::Tool` messages immediately
10904 // following a given assistant message. Two-or-more consecutive
10905 // assistant-with-tool-call messages before their results (streamed
10906 // / parallel tool calls — extremely common in real Claude Code and
10907 // Codex sessions) break the contiguous-run assumption: the first
10908 // assistant's own result(s) land AFTER a second assistant message,
10909 // not immediately after the first, so a contiguous scan starting
10910 // right after the first assistant finds nothing and silently drops
10911 // its real tool output into the `None => "pending"` branch below.
10912 // A single `id -> result` map is still insufficient: long real
10913 // sessions can reuse provider call ids. Last-write-wins then attaches
10914 // the final output to every earlier occurrence. Collect calls and
10915 // results independently and zip their occurrences in transcript
10916 // order, giving every concrete call position its own result.
10917 let mut calls_by_id: HashMap<&str, Vec<(usize, usize)>> = HashMap::new();
10918 let mut results_by_id: HashMap<&str, Vec<(usize, &ChatMessage)>> = HashMap::new();
10919 for (message_index, message) in messages.iter().enumerate() {
10920 if message.role == Role::Assistant {
10921 for (tool_index, call) in message.tool_calls().iter().enumerate() {
10922 calls_by_id
10923 .entry(call.id.as_str())
10924 .or_default()
10925 .push((message_index, tool_index));
10926 }
10927 } else if message.role == Role::Tool {
10928 if let Some(id) = &message.tool_call_id {
10929 results_by_id
10930 .entry(id.as_str())
10931 .or_default()
10932 .push((message_index, message));
10933 }
10934 }
10935 }
10936 let mut paired_results: HashMap<(usize, usize), (usize, &ChatMessage)> = HashMap::new();
10937 for (id, calls) in calls_by_id {
10938 let Some(results) = results_by_id.get(id) else {
10939 continue;
10940 };
10941 for (call_position, result) in calls.into_iter().zip(results.iter().copied()) {
10942 paired_results.insert(call_position, result);
10943 }
10944 }
10945 let mut i = 0;
10946 while i < messages.len() {
10947 let msg = &messages[i];
10948 if is_replay_excluded(msg) {
10949 i += 1;
10950 continue;
10951 }
10952 match msg.role {
10953 // B4: opencode V1 has no session-level system-PROMPT slot
10954 // either — `User.system` is a per-turn system-PROMPT
10955 // OVERRIDE (§2.1), a different thing from a content-bearing
10956 // `Role::System` message loaded from a real Claude `type:
10957 // "system"` record (`push_claude_system`'s keep-listed
10958 // subtypes). Stuffing real transcript content into
10959 // `User.system` would be a genuine misuse — it overrides the
10960 // replayed system prompt, not just annotates a turn — so
10961 // this instead reuses opencode's own `text` part `synthetic`
10962 // flag (§3.1: "injected by opencode, not typed by user"),
10963 // which is EXACTLY the right existing, non-fabricated
10964 // semantic for "system-originated content presented as a
10965 // user turn": a dedicated `User` message with one
10966 // `synthetic: true` text part, tagged with a
10967 // supercode-namespaced part-`metadata` key so
10968 // `opencode_claude_system_subtype`/`push_opencode_claude_system`
10969 // recognize it on reload and restore `Role::System` +
10970 // `metadata["systemSubtype"]` rather than treating it as a
10971 // real user turn. Content is never fabricated — only
10972 // emitted when non-empty.
10973 Role::System => {
10974 let content = msg.content.clone().unwrap_or_default();
10975 if content.trim().is_empty() {
10976 i += 1;
10977 continue;
10978 }
10979 let subtype = msg
10980 .metadata
10981 .get("systemSubtype")
10982 .cloned()
10983 .unwrap_or_else(|| "local_command".to_string());
10984 let msg_id = opencode_fresh_id("msg", counter);
10985 let part_id = opencode_fresh_id("prt", counter);
10986 let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10987 let mut info = serde_json::json!({
10988 "id": msg_id,
10989 "sessionID": session_id,
10990 "role": "user",
10991 "time": {"created": timestamp},
10992 });
10993 info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
10994 let parts = vec![serde_json::json!({
10995 "id": part_id,
10996 "sessionID": session_id,
10997 "messageID": msg_id,
10998 "type": "text",
10999 "text": content,
11000 "synthetic": true,
11001 "metadata": {OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: subtype},
11002 })];
11003 out.push(serde_json::json!({"info": info, "parts": parts}));
11004 i += 1;
11005 }
11006 Role::User => {
11007 let msg_id = opencode_fresh_id("msg", counter);
11008 let parts = opencode_user_parts_from_message(msg, &msg_id, session_id, counter);
11009 let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
11010 let mut info = serde_json::json!({
11011 "id": msg_id,
11012 "sessionID": session_id,
11013 "role": "user",
11014 "time": {"created": timestamp},
11015 });
11016 info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
11017 opencode_restore_agent_model_fields(
11018 &mut info, msg, /* is_assistant */ false,
11019 );
11020 set_grok_message_extension(&mut info, self.meta.source, msg);
11021 out.push(serde_json::json!({
11022 "info": info,
11023 "parts": parts,
11024 }));
11025 i += 1;
11026 }
11027 Role::Assistant => {
11028 let msg_id = opencode_fresh_id("msg", counter);
11029 let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
11030 let mut parts = Vec::new();
11031 if let Some(thinking) = msg.metadata.get("thinking") {
11032 let mut part = serde_json::json!({
11033 "id": opencode_fresh_id("prt", counter),
11034 "sessionID": session_id,
11035 "messageID": msg_id,
11036 "type": "reasoning",
11037 "text": thinking,
11038 // Required by OpenCode V1's native reasoning
11039 // schema. A synthesized part has no distinct
11040 // stream start/end, so the source message clock
11041 // is the honest zero-duration span.
11042 "time": {"start": timestamp, "end": timestamp},
11043 });
11044 if let Some(signature) = msg.metadata.get("thinking_signature") {
11045 part["metadata"] = serde_json::json!({
11046 "anthropic": {"signature": signature},
11047 });
11048 }
11049 parts.push(part);
11050 }
11051 if let Some(t) = &msg.content {
11052 if !t.is_empty() {
11053 parts.push(serde_json::json!({
11054 "id": opencode_fresh_id("prt", counter),
11055 "sessionID": session_id,
11056 "messageID": msg_id,
11057 "type": "text",
11058 "text": t,
11059 }));
11060 }
11061 }
11062 // Fold each tool call's result back into ONE `tool`
11063 // part, matched by tool_call_id via the GLOBAL
11064 // `all_results` map built above (not a contiguous scan)
11065 // — a result may be many messages away when other
11066 // assistant turns with their own pending calls
11067 // intervene before it appears.
11068 for (tool_index, tc) in msg.tool_calls().iter().enumerate() {
11069 let input = tc
11070 .function
11071 .parsed_arguments()
11072 .unwrap_or_else(|_| Value::Object(Default::default()));
11073 let paired_result = paired_results.get(&(i, tool_index)).copied();
11074 let state = match paired_result {
11075 Some((_, result)) if crate::is_tool_error(result) => {
11076 let result_timestamp =
11077 opencode_message_timestamp(result, timestamp_cursor)?;
11078 serde_json::json!({
11079 "status": "error",
11080 "input": input,
11081 "error": result.content.clone().unwrap_or_default(),
11082 "time": {"end": result_timestamp},
11083 })
11084 }
11085 Some((_, result)) => {
11086 let result_timestamp =
11087 opencode_message_timestamp(result, timestamp_cursor)?;
11088 let mut s = serde_json::json!({
11089 "status": "completed",
11090 "input": input,
11091 "output": result.content.clone().unwrap_or_default(),
11092 "title": tc.function.name,
11093 "time": {"end": result_timestamp},
11094 });
11095 // PARITY-11 (nested images): the LOADER already
11096 // reads a completed tool part's
11097 // `state.attachments` back into `content_parts`
11098 // (`opencode_file_image_part`, above) — this is
11099 // the missing WRITE-side inverse. Without it, a
11100 // Claude `tool_result`'s nested image (now
11101 // captured into `content_parts` by
11102 // `extract_tool_result_content`) reached
11103 // `content_parts` on the canonical `ChatMessage`
11104 // but was silently dropped again on re-export to
11105 // OpenCode, because nothing ever read it back
11106 // out. `mime`/`url` shape matches exactly what
11107 // `opencode_file_image_part` expects on reload.
11108 if let Some(cps) = &result.content_parts {
11109 let atts: Vec<Value> = cps
11110 .iter()
11111 .filter(|p| {
11112 p.get("type").and_then(Value::as_str)
11113 == Some("image_url")
11114 })
11115 .filter_map(|p| {
11116 let url = p
11117 .get("image_url")
11118 .and_then(|u| u.get("url"))
11119 .and_then(Value::as_str)?;
11120 let mime = url
11121 .strip_prefix("data:")
11122 .and_then(|r| r.split_once(','))
11123 .map(|(m, _)| m.trim_end_matches(";base64"))
11124 .unwrap_or("application/octet-stream");
11125 Some(serde_json::json!({
11126 "mime": mime,
11127 "url": url,
11128 }))
11129 })
11130 .collect();
11131 if !atts.is_empty() {
11132 s["attachments"] = Value::Array(atts);
11133 }
11134 }
11135 s
11136 }
11137 None => serde_json::json!({"status": "pending", "input": input}),
11138 };
11139 let mut part = serde_json::json!({
11140 "id": opencode_fresh_id("prt", counter),
11141 "sessionID": session_id,
11142 "messageID": msg_id,
11143 "type": "tool",
11144 "callID": tc.id,
11145 "tool": tc.function.name,
11146 "state": state,
11147 });
11148 if let Some((result_position, _)) = paired_result {
11149 part[OPENCODE_SUPERCODE_RESULT_POSITION] =
11150 serde_json::json!(result_position);
11151 }
11152 if paired_result.is_some_and(|(_, result)| {
11153 crate::tool_outcome(result) == crate::ToolOutcome::Unknown
11154 }) {
11155 part[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
11156 }
11157 if let Some((_, result)) = paired_result {
11158 set_grok_message_extension(&mut part, self.meta.source, result);
11159 }
11160 parts.push(part);
11161 }
11162 let mut info = serde_json::json!({
11163 "id": msg_id,
11164 "sessionID": session_id,
11165 "role": "assistant",
11166 "time": {"created": timestamp},
11167 });
11168 info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
11169 opencode_restore_agent_model_fields(
11170 &mut info, msg, /* is_assistant */ true,
11171 );
11172 set_grok_message_extension(&mut info, self.meta.source, msg);
11173 out.push(serde_json::json!({
11174 "info": info,
11175 "parts": parts,
11176 }));
11177 i += 1;
11178 }
11179 // A Tool message is always folded into its call's assistant
11180 // `tool` part above (via occurrence-aware global pairing, not
11181 // positional adjacency), so it never needs its own entry
11182 // here — just advance past it.
11183 Role::Tool => i += 1,
11184 }
11185 }
11186 Ok(())
11187 }
11188
11189 /// Synthesize a fresh OpenCode export DOCUMENT from the canonical
11190 /// `messages` (T3 cross-format/full synthesis tier — mirrors
11191 /// [`Self::to_pi_jsonl`]'s doc comment: this is NOT the value-lossless
11192 /// native path, which goes through `raw` + `to_native_jsonl(_v2)`
11193 /// instead). Shape: `{info: SessionInfo, messages: [{info, parts}, …]}`
11194 /// (§1.2 — the `opencode export`/`import` interchange shape).
11195 fn to_opencode_jsonl(&self) -> Result<String> {
11196 let mut info = self.synthesized_opencode_info();
11197 let ses_id = info
11198 .get("id")
11199 .and_then(Value::as_str)
11200 .unwrap_or("ses_new")
11201 .to_string();
11202 let mut messages_json: Vec<Value> = Vec::new();
11203 let mut counter: u64 = 0;
11204 let mut timestamp_cursor =
11205 opencode_max_timestamp(&info).unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11206 self.append_synthesized_opencode_messages(
11207 &mut messages_json,
11208 &self.messages,
11209 &ses_id,
11210 &mut counter,
11211 &mut timestamp_cursor,
11212 )?;
11213 if !messages_json.is_empty() {
11214 Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11215 }
11216 let doc = serde_json::json!({"info": info, "messages": messages_json});
11217 Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11218 }
11219
11220 /// A12-style splice for OpenCode (§1.2/§4.2 point 1, S5): replay the
11221 /// imported records **value-equal at their position** in the export
11222 /// doc's `messages[]`/`parts[]` — reconstructed directly from `self.raw`
11223 /// via [`Self::opencode_records_from_raw`], never re-derived from the
11224 /// lossy canonical `messages` — then append freshly synthesized
11225 /// `{info, parts}` objects for the tail via
11226 /// [`Self::append_synthesized_opencode_messages`]. Unlike the
11227 /// line-oriented formats' splice, `out` here is a single export
11228 /// DOCUMENT, not a line stream (§4.2 restates the "prefix verbatim"
11229 /// assertion accordingly: value-equality at position, not byte
11230 /// equality of a line range).
11231 fn to_opencode_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
11232 if self.raw.is_empty() {
11233 return self.to_opencode_jsonl();
11234 }
11235 let (session_info, records) = self.opencode_records_from_raw();
11236 let (_, message_prefix_len) = self.spliced_prefix_lens();
11237
11238 let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11239 if let Some(id) = session_id {
11240 info["id"] = Value::String(id.to_string());
11241 }
11242 let ses_id_for_new = info
11243 .get("id")
11244 .and_then(Value::as_str)
11245 .unwrap_or("ses_new")
11246 .to_string();
11247
11248 let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11249 .chain(records.iter().flat_map(|(msg, parts)| {
11250 std::iter::once(opencode_max_timestamp(msg))
11251 .chain(parts.iter().map(opencode_max_timestamp))
11252 }))
11253 .flatten()
11254 .max()
11255 .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11256
11257 let mut messages_json: Vec<Value> = records
11258 .into_iter()
11259 .map(|(msg, parts)| serde_json::json!({"info": msg, "parts": parts}))
11260 .collect();
11261 let imported_len = messages_json.len();
11262
11263 let mut counter: u64 = 0;
11264 self.append_synthesized_opencode_messages(
11265 &mut messages_json,
11266 &self.messages[message_prefix_len..],
11267 &ses_id_for_new,
11268 &mut counter,
11269 &mut timestamp_cursor,
11270 )?;
11271 if messages_json.len() > imported_len {
11272 Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11273 }
11274
11275 let doc = serde_json::json!({"info": info, "messages": messages_json});
11276 Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11277 }
11278
11279 /// The **required** direct-write fallback (S5): write the imported
11280 /// OpenCode records **verbatim** — excess/unknown keys, part-row
11281 /// timestamps, and `session_diff`/`todo` side-records intact — to a
11282 /// generation-B JSON-file storage tree
11283 /// (`docs/interop/opencode-pi-spec.md` §1.2), the fidelity path
11284 /// `opencode import` cannot provide (S5: import re-decodes through a
11285 /// strict schema and STRIPS excess keys; inserts part rows without
11286 /// `time_created`/`time_updated`, so those reset to `Date.now()`; and
11287 /// has no ingestion path for `session_diff`/`todo` at all).
11288 ///
11289 /// Writes the JSON-FILE layout rather than a live SQLite write
11290 /// specifically to avoid a new `rusqlite`-class dependency on this
11291 /// build's memory-constrained box (see the build report); `session_diff`
11292 /// itself is still JSON-written by upstream even on SQLite installs
11293 /// (§1.3), so this is a real fidelity path, not a fictional one.
11294 ///
11295 /// Returns the `storage/session/<projectID>/` directory written to.
11296 pub fn to_opencode_direct_write(&self, data_root: &Path) -> Result<PathBuf> {
11297 let (session_info, mut records) = self.opencode_records_from_raw();
11298 let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11299 let ses_id = info
11300 .get("id")
11301 .and_then(Value::as_str)
11302 .unwrap_or("ses_new")
11303 .to_string();
11304 if info.get("id").is_none() {
11305 info["id"] = Value::String(ses_id.clone());
11306 }
11307 let project_id = info
11308 .get("projectID")
11309 .and_then(Value::as_str)
11310 .unwrap_or("global")
11311 .to_string();
11312
11313 // Appended tail (messages produced after import): synthesize fresh
11314 // message/part VALUES via the same T3 synthesis the splice writer
11315 // uses, so continuation turns get files too. Do this BEFORE creating
11316 // any directories: timestamp exhaustion must fail atomically rather
11317 // than leave a partial direct-write tree behind.
11318 let (_, message_prefix_len) = self.spliced_prefix_lens();
11319 let mut counter: u64 = 0;
11320 let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11321 .chain(records.iter().flat_map(|(msg, parts)| {
11322 std::iter::once(opencode_max_timestamp(msg))
11323 .chain(parts.iter().map(opencode_max_timestamp))
11324 }))
11325 .flatten()
11326 .max()
11327 .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11328 let mut appended_json: Vec<Value> = Vec::new();
11329 self.append_synthesized_opencode_messages(
11330 &mut appended_json,
11331 &self.messages[message_prefix_len..],
11332 &ses_id,
11333 &mut counter,
11334 &mut timestamp_cursor,
11335 )?;
11336 if !appended_json.is_empty() {
11337 Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11338 }
11339 for entry in appended_json {
11340 let msg = entry.get("info").cloned().unwrap_or(Value::Null);
11341 let parts = entry
11342 .get("parts")
11343 .and_then(Value::as_array)
11344 .cloned()
11345 .unwrap_or_default();
11346 records.push((msg, parts));
11347 }
11348
11349 let storage = data_root.join("storage");
11350 let session_dir = storage.join("session").join(&project_id);
11351 std::fs::create_dir_all(&session_dir)?;
11352 std::fs::write(
11353 session_dir.join(format!("{ses_id}.json")),
11354 serde_json::to_string_pretty(&info).unwrap_or_default(),
11355 )?;
11356
11357 let message_dir = storage.join("message").join(&ses_id);
11358 let part_dir = storage.join("part");
11359 std::fs::create_dir_all(&message_dir)?;
11360
11361 for (msg, parts) in &records {
11362 let Some(msg_id) = msg.get("id").and_then(Value::as_str) else {
11363 continue;
11364 };
11365 std::fs::write(
11366 message_dir.join(format!("{msg_id}.json")),
11367 serde_json::to_string_pretty(msg).unwrap_or_default(),
11368 )?;
11369 let this_part_dir = part_dir.join(msg_id);
11370 std::fs::create_dir_all(&this_part_dir)?;
11371 for part in parts {
11372 let Some(part_id) = part.get("id").and_then(Value::as_str) else {
11373 continue;
11374 };
11375 std::fs::write(
11376 this_part_dir.join(format!("{part_id}.json")),
11377 serde_json::to_string_pretty(part).unwrap_or_default(),
11378 )?;
11379 }
11380 }
11381
11382 // Side-records (S5c): session_diff / todo have NO ingestion path via
11383 // `opencode import` at all — the direct write is their only
11384 // fidelity path.
11385 for header in &self.meta.opencode_headers {
11386 let Some(key) = header.get("key").and_then(Value::as_array) else {
11387 continue;
11388 };
11389 let Some(kind) = key.first().and_then(Value::as_str) else {
11390 continue;
11391 };
11392 let value = header.get("value").cloned().unwrap_or(Value::Null);
11393 if !matches!(kind, "session_diff" | "todo") {
11394 continue;
11395 }
11396 let dir = storage.join(kind);
11397 std::fs::create_dir_all(&dir)?;
11398 std::fs::write(
11399 dir.join(format!("{ses_id}.json")),
11400 serde_json::to_string_pretty(&value).unwrap_or_default(),
11401 )?;
11402 }
11403
11404 Ok(session_dir)
11405 }
11406}
11407
11408fn opencode_fresh_id(prefix: &str, counter: &mut u64) -> String {
11409 *counter += 1;
11410 format!("{prefix}_synth{counter:06}")
11411}
11412
11413/// WAVE-2 fidelity item 2: restore the `agent`/`cost`/`finish`/`is_summary`/
11414/// `model`/`tokens` fields onto a synthesized OpenCode `info` record, in the
11415/// EXACT native shape opencode's own loaders (`push_opencode_user` /
11416/// `push_opencode_assistant`, above) parse back out — so an opencode->opencode
11417/// export-doc round-trip (write here, reload via [`Session::from_opencode_str`])
11418/// reconstructs value-identical `ChatMessage.metadata`. Each field is emitted
11419/// ONLY when its metadata key is present (a synthesized continuation turn, or
11420/// a User message that never carried `agent`, stays clean — no spurious
11421/// null/empty fields).
11422///
11423/// - `agent`: plain string on BOTH User (`v1/session.ts:346`) and Assistant
11424/// (`:465`) — `metadata["agent"]` is inserted verbatim by both loaders from
11425/// `msg_value.get("agent")`, so it's re-emitted verbatim here too.
11426/// - `model`: the loaders capture it in TWO DIFFERENT shapes depending on
11427/// role (`docs/interop/research/opencode-fields.md` §3 2a/2b), so the
11428/// inverse must match per-role:
11429/// - User: `push_opencode_user` stores `metadata["model"]` as the
11430/// STRINGIFIED `{providerID, modelID, variant?}` object
11431/// (`msg_value.get("model")...to_string()`) — re-parsed and re-emitted
11432/// as that same object under `"model"`.
11433/// - Assistant: `push_opencode_assistant` stores `metadata["model"]` as
11434/// `"{providerID}/{modelID}"` (from the top-level `providerID`/`modelID`
11435/// fields, joined) — split back on the FIRST `/` (matching `format!`'s
11436/// join; a `modelID` containing further `/`s round-trips correctly since
11437/// `split_once` only consumes the first) and re-emitted as the two
11438/// top-level `providerID`/`modelID` fields the loader actually reads.
11439/// - `is_summary`/`cost`/`finish`/`tokens`: Assistant-only concepts (no such
11440/// fields exist on opencode's `User` schema) — `is_summary` re-expands
11441/// `"true"` back to the native `summary: true` bool (the loader only ever
11442/// sets the metadata key on `Some(true)`, never on absent/false, so the
11443/// inverse never needs to emit `false`); `finish` is a plain string;
11444/// `cost`/`tokens` were captured via `v.to_string()` on the raw JSON
11445/// `Value` (a number and an object respectively), so they're re-parsed
11446/// from that stringified form and re-emitted as the native JSON value —
11447/// NOT as strings — matching `msg_value.get(field)` shape exactly.
11448fn opencode_restore_agent_model_fields(info: &mut Value, msg: &ChatMessage, is_assistant: bool) {
11449 if let Some(agent) = msg.metadata.get("agent") {
11450 info["agent"] = Value::String(agent.clone());
11451 }
11452 if let Some(model) = msg.metadata.get("model") {
11453 if is_assistant {
11454 if let Some((provider, model_id)) = model.split_once('/') {
11455 info["providerID"] = Value::String(provider.to_string());
11456 info["modelID"] = Value::String(model_id.to_string());
11457 }
11458 } else if let Ok(v) = serde_json::from_str::<Value>(model) {
11459 info["model"] = v;
11460 }
11461 }
11462 if !is_assistant {
11463 return;
11464 }
11465 if msg.metadata.get("is_summary").map(String::as_str) == Some("true") {
11466 info["summary"] = Value::Bool(true);
11467 }
11468 if let Some(finish) = msg.metadata.get("finish") {
11469 info["finish"] = Value::String(finish.clone());
11470 }
11471 if let Some(cost) = msg.metadata.get("cost") {
11472 if let Ok(v) = serde_json::from_str::<Value>(cost) {
11473 info["cost"] = v;
11474 }
11475 }
11476 if let Some(tokens) = msg.metadata.get("tokens") {
11477 if let Ok(v) = serde_json::from_str::<Value>(tokens) {
11478 info["tokens"] = v;
11479 }
11480 }
11481}
11482
11483fn opencode_user_parts_from_message(
11484 msg: &ChatMessage,
11485 msg_id: &str,
11486 session_id: &str,
11487 counter: &mut u64,
11488) -> Vec<Value> {
11489 let mut parts = Vec::new();
11490 if let Some(cps) = &msg.content_parts {
11491 for p in cps {
11492 match p.get("type").and_then(Value::as_str) {
11493 Some("text") => {
11494 if let Some(t) = p.get("text").and_then(Value::as_str) {
11495 parts.push(serde_json::json!({
11496 "id": opencode_fresh_id("prt", counter),
11497 "sessionID": session_id,
11498 "messageID": msg_id,
11499 "type": "text",
11500 "text": t,
11501 }));
11502 }
11503 }
11504 Some("image_url") => {
11505 if let Some(url) = p
11506 .get("image_url")
11507 .and_then(|u| u.get("url"))
11508 .and_then(Value::as_str)
11509 {
11510 let mime = url
11511 .strip_prefix("data:")
11512 .and_then(|r| r.split_once(','))
11513 .map(|(m, _)| m.trim_end_matches(";base64"))
11514 .unwrap_or("application/octet-stream");
11515 parts.push(serde_json::json!({
11516 "id": opencode_fresh_id("prt", counter),
11517 "sessionID": session_id,
11518 "messageID": msg_id,
11519 "type": "file",
11520 "mime": mime,
11521 "url": url,
11522 }));
11523 }
11524 }
11525 _ => {}
11526 }
11527 }
11528 } else if let Some(t) = &msg.content {
11529 if !t.is_empty() {
11530 parts.push(serde_json::json!({
11531 "id": opencode_fresh_id("prt", counter),
11532 "sessionID": session_id,
11533 "messageID": msg_id,
11534 "type": "text",
11535 "text": t,
11536 }));
11537 }
11538 }
11539 parts
11540}
11541
11542fn codex_response_item(payload: Value, ts: &str) -> Value {
11543 serde_json::json!({"timestamp": ts, "type": "response_item", "payload": payload})
11544}
11545
11546/// Stamp `payload.metadata.turn_id` when `turn_id` is `Some` (PARITY-6/7,
11547/// see [`Session::write_codex_records`]); a no-op returning `payload`
11548/// untouched when `None`, so the historical byte shape is preserved for
11549/// every record that has no merge ambiguity to disambiguate.
11550fn with_turn_id(mut payload: Value, turn_id: Option<&str>) -> Value {
11551 if let Some(tid) = turn_id {
11552 payload["metadata"] = serde_json::json!({"turn_id": tid});
11553 }
11554 payload
11555}
11556
11557/// Build a Codex `message` response_item's `content` block array from a
11558/// `ChatMessage` — the inverse of [`codex_extract_images`]/`extract_text_content`'s
11559/// parse. When `content_parts` is `None` this MUST reproduce the historical
11560/// single-block shape exactly (IX-5's overriding constraint: a text-only
11561/// message's export stays byte-identical) — only a multimodal message gets
11562/// one `{text_type}` block per non-empty text part plus one native Codex
11563/// `input_image` block (`{"type":"input_image","image_url":<data:URI or
11564/// URL>}` — the Responses-API-shaped image content Codex's own `input_text`/
11565/// `output_text` blocks already follow the family of) per `image_url` part.
11566fn codex_message_content_blocks(text_type: &str, msg: &ChatMessage) -> Value {
11567 match &msg.content_parts {
11568 Some(parts) => {
11569 let mut blocks = Vec::new();
11570 for p in parts {
11571 match p.get("type").and_then(Value::as_str) {
11572 Some("text") => {
11573 if let Some(t) = p.get("text").and_then(Value::as_str) {
11574 if !t.is_empty() {
11575 blocks.push(serde_json::json!({"type": text_type, "text": t}));
11576 }
11577 }
11578 }
11579 Some("image_url") => {
11580 if let Some(url) = p
11581 .get("image_url")
11582 .and_then(|u| u.get("url"))
11583 .and_then(Value::as_str)
11584 {
11585 blocks.push(serde_json::json!({
11586 "type": "input_image",
11587 "image_url": url,
11588 }));
11589 }
11590 }
11591 _ => {}
11592 }
11593 }
11594 Value::Array(blocks)
11595 }
11596 None => {
11597 let text = msg.content.clone().unwrap_or_default();
11598 Value::Array(vec![serde_json::json!({"type": text_type, "text": text})])
11599 }
11600 }
11601}
11602
11603/// PARITY-11 (nested images, honest-residue side): a Codex
11604/// `function_call_output` response_item's `output` field is a BARE STRING
11605/// (real `codex-rs` protocol shape — unlike a `message` response_item, it has
11606/// no structured content array, so [`codex_message_content_blocks`]'s
11607/// `input_image` slot genuinely does not apply here). A nested image captured
11608/// off a Claude `tool_result` (`extract_tool_result_content`,
11609/// `content_parts`) therefore CANNOT be carried through this hop — but rather
11610/// than silently re-emitting the old bare `[image]` marker (indistinguishable
11611/// from a real, intentional annotation and impossible to tell apart from
11612/// "the data survived") or dropping it with zero trace, fold in an honest,
11613/// countable disclosure of exactly how many images were dropped and why —
11614/// same bracketed-note convention as [`UNCONVERTIBLE_IMAGE_MARKER`], applied
11615/// on the WRITE side instead of the read side. `content_parts` being `None`
11616/// (every pre-existing call site, and any tool result with no nested image)
11617/// reproduces the historical `msg.content` text byte-for-byte.
11618fn codex_tool_output_text(msg: &ChatMessage) -> String {
11619 let mut text = msg.content.clone().unwrap_or_default();
11620 if let Some(parts) = &msg.content_parts {
11621 let n = parts
11622 .iter()
11623 .filter(|p| p.get("type").and_then(Value::as_str) == Some("image_url"))
11624 .count();
11625 if n > 0 {
11626 if !text.is_empty() {
11627 text.push('\n');
11628 }
11629 text.push_str(&format!(
11630 "[image: {n} nested image(s) dropped — codex tool output has no \
11631 structured content slot to carry them]"
11632 ));
11633 }
11634 }
11635 text
11636}
11637
11638// ---- Pi writer helpers -----------------------------------------------------
11639
11640/// Emit the pi v3 `session` header line. Frozen (§1.1/§1.3): `version` is
11641/// ALWAYS 3, never a lower value, so pi never rewrites a supercode-emitted
11642/// file in place on first resume (`pi-fields.md` sm:848-850).
11643fn push_pi_header(
11644 out: &mut String,
11645 id: &str,
11646 cwd: &str,
11647 parent_session: Option<&str>,
11648 created_at: Option<&str>,
11649 claude_fork_context_ref: Option<&str>,
11650) {
11651 let mut header = serde_json::json!({
11652 "type": "session",
11653 "version": 3,
11654 "id": id,
11655 "timestamp": created_at.unwrap_or(SYNTH_TS),
11656 "cwd": cwd,
11657 });
11658 if let Some(ps) = parent_session {
11659 header["parentSession"] = Value::String(ps.to_string());
11660 }
11661 // D7: namespaced passthrough field, exactly like the Codex writer's
11662 // `session_meta.payload.claude_fork_context_ref` — pi tolerates unknown
11663 // header keys, and `capture_pi_header` reads this same key back on
11664 // import, so a Claude -> Pi -> Claude round trip still reconstructs the
11665 // fork-context-ref record instead of silently losing it on this hop.
11666 if let Some(raw) = claude_fork_context_ref {
11667 header["claude_fork_context_ref"] =
11668 serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()));
11669 }
11670 push_jsonl(out, &header);
11671}
11672
11673/// A fresh 8-hex entry id, collision-checked against `used` (mirrors pi's own
11674/// `randomUUID().slice(0,8)` + collision check, `pi-fields.md` sm:216-224 —
11675/// deterministic here rather than random, which still satisfies "fresh,
11676/// collision-free" without an extra RNG dependency).
11677fn pi_fresh_id(used: &mut HashSet<String>, counter: &mut u64) -> String {
11678 loop {
11679 *counter += 1;
11680 let h = counter.wrapping_mul(0x9E3779B97F4A7C15);
11681 let id = format!("{:08x}", (h >> 32) as u32);
11682 if used.insert(id.clone()) {
11683 return id;
11684 }
11685 }
11686}
11687
11688/// Parse a `data:<mime>[;base64],<data>` URI back into `(mime, data)` — the
11689/// inverse of the loader's `data:{mime};base64,{data}` construction.
11690fn parse_data_uri(url: &str) -> Option<(String, String)> {
11691 let rest = url.strip_prefix("data:")?;
11692 let (meta, data) = rest.split_once(',')?;
11693 let mime = meta.strip_suffix(";base64").unwrap_or(meta);
11694 Some((mime.to_string(), data.to_string()))
11695}
11696
11697/// Rebuild a pi `(TextContent|ImageContent)[]` (or bare string) content value
11698/// from a `ChatMessage`'s `content`/`content_parts` — shared by `user` and
11699/// `toolResult` entries (both use the identical union on the wire).
11700fn pi_content_value(msg: &ChatMessage) -> Value {
11701 if let Some(parts) = &msg.content_parts {
11702 let mut arr = Vec::new();
11703 for p in parts {
11704 match p.get("type").and_then(Value::as_str) {
11705 Some("text") => {
11706 if let Some(t) = p.get("text").and_then(Value::as_str) {
11707 arr.push(serde_json::json!({"type": "text", "text": t}));
11708 }
11709 }
11710 Some("image_url") => {
11711 if let Some(url) = p
11712 .get("image_url")
11713 .and_then(|u| u.get("url"))
11714 .and_then(Value::as_str)
11715 {
11716 if let Some((mime, data)) = parse_data_uri(url) {
11717 arr.push(
11718 serde_json::json!({"type": "image", "mimeType": mime, "data": data}),
11719 );
11720 }
11721 }
11722 }
11723 _ => {}
11724 }
11725 }
11726 Value::Array(arr)
11727 } else {
11728 Value::String(msg.content.clone().unwrap_or_default())
11729 }
11730}
11731
11732fn pi_assistant_content_value(msg: &ChatMessage) -> Value {
11733 let mut arr = Vec::new();
11734 if let Some(thinking) = msg.metadata.get("thinking") {
11735 let mut block = serde_json::json!({"type": "thinking", "thinking": thinking});
11736 if let Some(sig) = msg.metadata.get("thinking_signature") {
11737 block["thinkingSignature"] = Value::String(sig.clone());
11738 }
11739 if msg.metadata.get("pi_thinking_redacted").map(String::as_str) == Some("true") {
11740 block["redacted"] = Value::Bool(true);
11741 }
11742 arr.push(block);
11743 }
11744 if let Some(text) = &msg.content {
11745 if !text.is_empty() {
11746 let mut block = serde_json::json!({"type": "text", "text": text});
11747 if let Some(sig) = msg.metadata.get("pi_text_signature") {
11748 block["textSignature"] = Value::String(sig.clone());
11749 }
11750 arr.push(block);
11751 }
11752 }
11753 for tc in msg.tool_calls() {
11754 let args = tc
11755 .function
11756 .parsed_arguments()
11757 .unwrap_or_else(|_| Value::Object(Default::default()));
11758 let mut block = serde_json::json!({
11759 "type": "toolCall",
11760 "id": tc.id,
11761 "name": tc.function.name,
11762 "arguments": args,
11763 });
11764 if let Some(sig) = msg.metadata.get("pi_thought_signature") {
11765 block["thoughtSignature"] = Value::String(sig.clone());
11766 }
11767 arr.push(block);
11768 }
11769 Value::Array(arr)
11770}
11771
11772fn default_pi_usage() -> Value {
11773 serde_json::json!({
11774 "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0,
11775 "cost": {"input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.0},
11776 })
11777}
11778
11779fn is_tool_error_flag(msg: &ChatMessage) -> bool {
11780 msg.metadata.get("pi_is_error").map(String::as_str) == Some("true") || crate::is_tool_error(msg)
11781}
11782
11783#[cfg(test)]
11784mod tests {
11785 use super::{
11786 opencode_message_timestamp, parent_tool_use_index, read_display_jsonl,
11787 truncate_messages_with_anchor, Session, SessionFormat,
11788 };
11789 use crate::message::ChatMessage;
11790 use crate::{Fidelity, Role};
11791
11792 #[test]
11793 fn a_new_user_message_slides_the_display_tail_without_becoming_its_only_anchor() {
11794 let mut messages = vec![
11795 ChatMessage::user("original prompt"),
11796 ChatMessage::assistant("one"),
11797 ChatMessage::assistant("two"),
11798 ChatMessage::assistant("three"),
11799 ChatMessage::assistant("four"),
11800 ChatMessage::assistant("five"),
11801 ChatMessage::user("new prompt"),
11802 ];
11803
11804 truncate_messages_with_anchor(&mut messages, 4, Vec::new());
11805
11806 assert_eq!(messages.len(), 4);
11807 assert_eq!(messages[0].content.as_deref(), Some("original prompt"));
11808 assert_eq!(messages[3].content.as_deref(), Some("new prompt"));
11809 }
11810
11811 #[test]
11812 fn a_tool_heavy_current_turn_keeps_the_previous_and_current_user_anchors() {
11813 let mut messages = vec![
11814 ChatMessage::user("previous prompt"),
11815 ChatMessage::assistant("previous answer"),
11816 ChatMessage::user("current prompt"),
11817 ChatMessage::assistant("tool one"),
11818 ChatMessage::assistant("tool two"),
11819 ChatMessage::assistant("tool three"),
11820 ChatMessage::assistant("tool four"),
11821 ChatMessage::assistant("tool five"),
11822 ];
11823
11824 truncate_messages_with_anchor(&mut messages, 4, Vec::new());
11825
11826 assert_eq!(messages.len(), 4);
11827 assert_eq!(messages[0].content.as_deref(), Some("previous prompt"));
11828 assert_eq!(messages[1].content.as_deref(), Some("current prompt"));
11829 assert_eq!(messages[3].content.as_deref(), Some("tool five"));
11830 }
11831
11832 #[test]
11833 fn a_preceding_user_anchor_is_restored_when_dedup_shrinks_below_the_limit() {
11834 let mut messages = vec![
11835 ChatMessage::user("current prompt"),
11836 ChatMessage::assistant("tool one"),
11837 ChatMessage::assistant("tool two"),
11838 ];
11839
11840 truncate_messages_with_anchor(&mut messages, 4, vec![ChatMessage::user("previous prompt")]);
11841
11842 assert_eq!(messages.len(), 4);
11843 assert_eq!(messages[0].content.as_deref(), Some("previous prompt"));
11844 assert_eq!(messages[1].content.as_deref(), Some("current prompt"));
11845 }
11846
11847 #[test]
11848 fn a_tool_heavy_turn_restores_two_users_that_both_left_the_retention_buffer() {
11849 let mut messages = vec![
11850 ChatMessage::assistant("tool one"),
11851 ChatMessage::assistant("tool two"),
11852 ChatMessage::assistant("tool three"),
11853 ChatMessage::assistant("tool four"),
11854 ];
11855
11856 truncate_messages_with_anchor(
11857 &mut messages,
11858 4,
11859 vec![
11860 ChatMessage::user("previous prompt"),
11861 ChatMessage::user("current prompt"),
11862 ],
11863 );
11864
11865 assert_eq!(messages.len(), 4);
11866 assert_eq!(messages[0].content.as_deref(), Some("previous prompt"));
11867 assert_eq!(messages[1].content.as_deref(), Some("current prompt"));
11868 assert_eq!(messages[3].content.as_deref(), Some("tool four"));
11869 }
11870
11871 #[test]
11872 fn a_loaded_boundary_anchor_survives_several_newer_user_turns() {
11873 let mut messages = vec![
11874 ChatMessage::assistant("older tool one"),
11875 ChatMessage::assistant("older tool two"),
11876 ChatMessage::user("recent prompt one"),
11877 ChatMessage::assistant("recent answer one"),
11878 ChatMessage::user("recent prompt two"),
11879 ChatMessage::assistant("recent answer two"),
11880 ChatMessage::user("current prompt"),
11881 ChatMessage::assistant("current tool"),
11882 ];
11883
11884 truncate_messages_with_anchor(
11885 &mut messages,
11886 6,
11887 vec![ChatMessage::user("loaded earlier boundary")],
11888 );
11889
11890 assert_eq!(messages.len(), 6);
11891 assert_eq!(
11892 messages[0].content.as_deref(),
11893 Some("loaded earlier boundary"),
11894 "newer user prompts must not replace the prompt that owns the retained activity",
11895 );
11896 assert_eq!(messages[4].content.as_deref(), Some("current prompt"));
11897 assert_eq!(messages[5].content.as_deref(), Some("current tool"));
11898 }
11899
11900 #[test]
11901 fn a_bounded_byte_window_recovers_preceding_users_even_when_its_tail_has_users() {
11902 let nonce = std::time::SystemTime::now()
11903 .duration_since(std::time::UNIX_EPOCH)
11904 .unwrap()
11905 .as_nanos();
11906 let path = std::env::temp_dir().join(format!(
11907 "supercode-display-boundary-{}-{nonce}.jsonl",
11908 std::process::id()
11909 ));
11910 let user = |text: &str| {
11911 format!(
11912 r#"{{"type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"{text}"}}]}}}}"#,
11913 )
11914 };
11915 let lines = [
11916 r#"{"type":"session_meta","payload":{"id":"session-1"}}"#.to_string(),
11917 user("preceding boundary"),
11918 format!(
11919 r#"{{"type":"event_msg","payload":{{"type":"token_count","noise":"{}"}}}}"#,
11920 "x".repeat(5 * 1024 * 1024)
11921 ),
11922 user("newer prompt one"),
11923 user("newer prompt two"),
11924 ];
11925 std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
11926
11927 let (_, text, omitted_prefix) = read_display_jsonl(&path, 120).unwrap();
11928 std::fs::remove_file(&path).unwrap();
11929
11930 assert!(omitted_prefix);
11931 assert!(text.contains("preceding boundary"));
11932 assert!(text.contains("newer prompt one"));
11933 assert!(text.contains("newer prompt two"));
11934 }
11935
11936 #[test]
11937 fn bounded_codex_file_expands_past_tool_noise_and_loads_real_earlier_history() {
11938 let nonce = std::time::SystemTime::now()
11939 .duration_since(std::time::UNIX_EPOCH)
11940 .unwrap()
11941 .as_nanos();
11942 let path = std::env::temp_dir().join(format!(
11943 "supercode-display-history-{}-{nonce}.jsonl",
11944 std::process::id()
11945 ));
11946 let user = |text: &str| {
11947 format!(
11948 r#"{{"type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"{text}"}}]}}}}"#,
11949 )
11950 };
11951 let assistant = |index: usize| {
11952 format!(
11953 r#"{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"answer-{index}"}}]}}}}"#,
11954 )
11955 };
11956 let mut lines = vec![
11957 r#"{"type":"session_meta","payload":{"id":"session-1"}}"#.to_string(),
11958 user("earlier prompt"),
11959 format!(
11960 r#"{{"type":"event_msg","payload":{{"type":"token_count","noise":"{}"}}}}"#,
11961 "x".repeat(5 * 1024 * 1024)
11962 ),
11963 user("latest prompt"),
11964 ];
11965 lines.extend((0..130).map(assistant));
11966 std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
11967
11968 let initial = Session::load_display_view(&path, Fidelity::Semantic, 120).unwrap();
11969 let expanded = Session::load_display_view(&path, Fidelity::Semantic, 240).unwrap();
11970 std::fs::remove_file(&path).unwrap();
11971
11972 let initial_users = initial
11973 .messages
11974 .iter()
11975 .filter(|message| message.role == Role::User)
11976 .filter_map(|message| message.content.as_deref())
11977 .collect::<Vec<_>>();
11978 assert_eq!(initial.messages.len(), 120);
11979 assert_eq!(initial_users, ["earlier prompt", "latest prompt"]);
11980 assert!(
11981 initial.imported_message_count.unwrap() > initial.messages.len(),
11982 "a bounded initial page must truthfully report earlier history"
11983 );
11984 assert_eq!(expanded.messages.len(), 132);
11985 assert_eq!(expanded.imported_message_count, Some(132));
11986 }
11987
11988 #[test]
11989 fn bounded_codex_display_history_reports_the_unbounded_message_total() {
11990 let jsonl = (0..6)
11991 .map(|index| {
11992 let role = if index % 2 == 0 { "user" } else { "assistant" };
11993 format!(
11994 r#"{{"type":"response_item","payload":{{"type":"message","role":"{role}","content":[{{"type":"input_text","text":"message-{index}"}}]}}}}"#,
11995 )
11996 })
11997 .collect::<Vec<_>>()
11998 .join("\n");
11999
12000 let session = Session::from_codex_display_str(&jsonl, 2).unwrap();
12001
12002 assert_eq!(session.messages.len(), 2);
12003 assert_eq!(session.imported_message_count, Some(6));
12004 }
12005
12006 #[test]
12007 fn malformed_discriminated_native_turn_is_counted_as_parse_loss() {
12008 let base = Session::from_native_messages(Vec::new());
12009 let mut native = base.to_native_jsonl_v2(&[]);
12010 native.push_str("{\"supercode_turn\":1}\n");
12011
12012 let parsed = Session::from_native_str(&native).unwrap();
12013 assert_eq!(parsed.parse_error_lines, 1);
12014 assert!(parsed.messages.is_empty());
12015 assert_eq!(
12016 parsed.raw.last().map(String::as_str),
12017 Some("{\"supercode_turn\":1}")
12018 );
12019 }
12020
12021 #[test]
12022 fn spliced_export_refuses_a_native_wrapper_with_parse_loss() {
12023 let imported = Session::from_claude_code_str(
12024 r#"{"type":"user","sessionId":"s","cwd":"/tmp","message":{"role":"user","content":"hi"}}"#,
12025 )
12026 .unwrap();
12027 let mut native = imported.to_native_jsonl_v2(&[ChatMessage::assistant("continued")]);
12028 native.push_str("{\"supercode_turn\":1}\n");
12029
12030 let parsed = Session::from_native_str(&native).unwrap();
12031 let error = parsed
12032 .to_jsonl_spliced(SessionFormat::ClaudeCode, None)
12033 .unwrap_err();
12034 assert!(error.to_string().contains("parse loss"), "{error}");
12035 }
12036
12037 #[test]
12038 fn sidecar_loader_requires_a_supported_native_header() {
12039 for malformed in [
12040 "",
12041 "not-json\n",
12042 "{}\n",
12043 "{\"supercode_native\":2}\n",
12044 "{\"supercode_native\":99,\"source\":\"native\"}\n",
12045 ] {
12046 let error = Session::from_sidecar_str(malformed).unwrap_err();
12047 assert!(error.to_string().contains("sidecar header"), "{error}");
12048 }
12049 }
12050
12051 #[test]
12052 fn gemini_user_parts_preserve_text_media_and_response_order() {
12053 let session = Session::from_gemini_str(
12054 r#"{"sessionId":"11111111-1111-4111-8111-111111111111","projectHash":"p"}
12055{"type":"gemini","content":"calling","toolCalls":[{"id":"a","name":"read","args":{}},{"id":"b","name":"read","args":{}}]}
12056{"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"}]}
12057"#,
12058 )
12059 .unwrap();
12060
12061 assert_eq!(session.messages.len(), 6);
12062 assert_eq!(
12063 session.messages[1].content_parts.as_ref().unwrap()[0]["text"],
12064 "before"
12065 );
12066 assert_eq!(session.messages[2].tool_call_id.as_deref(), Some("b"));
12067 assert!(
12068 session.messages[3].content_parts.as_ref().unwrap()[0]["image_url"]["url"]
12069 .as_str()
12070 .unwrap()
12071 .starts_with("data:image/png;base64,")
12072 );
12073 assert_eq!(session.messages[4].tool_call_id.as_deref(), Some("a"));
12074 assert_eq!(
12075 session.messages[5].content_parts.as_ref().unwrap()[0]["text"],
12076 "after"
12077 );
12078 }
12079
12080 #[test]
12081 fn opencode_synthetic_timestamp_fails_closed_at_i64_max() {
12082 let msg = ChatMessage::user("continuation");
12083 let mut cursor = i64::MAX - 1;
12084 assert_eq!(
12085 opencode_message_timestamp(&msg, &mut cursor).unwrap(),
12086 i64::MAX
12087 );
12088 let err = opencode_message_timestamp(&msg, &mut cursor).unwrap_err();
12089 assert!(err.to_string().contains("after i64::MAX"));
12090 assert_eq!(cursor, i64::MAX, "overflow must not wrap or mutate state");
12091 }
12092
12093 /// Pin of the single-pass indexer against the relevant Claude tool-result
12094 /// shape (SUP-21). An id absent from the transcript must map to nothing.
12095 #[test]
12096 fn parent_tool_use_index_matches_known_fixture_linkage() {
12097 let main_text = r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SYjhg9qRCzUWY2GTa3iazQ","type":"tool_result","content":[{"type":"text","text":"agentId: ad8dc6cf98b49eea6"}]}]},"toolUseResult":{"agentId":"ad8dc6cf98b49eea6"}}"#;
12098
12099 let ids = vec![
12100 "ad8dc6cf98b49eea6".to_string(),
12101 "no-such-agent-id".to_string(),
12102 ];
12103 let index = parent_tool_use_index(main_text, &ids);
12104
12105 assert_eq!(
12106 index.get("ad8dc6cf98b49eea6").map(String::as_str),
12107 Some("toolu_01SYjhg9qRCzUWY2GTa3iazQ"),
12108 "known agent id must resolve to the pinned parent tool_use_id"
12109 );
12110 assert_eq!(
12111 index.get("no-such-agent-id"),
12112 None,
12113 "unknown agent id must yield no entry (best-effort None)"
12114 );
12115 }
12116
12117 #[test]
12118 fn parent_tool_use_index_empty_ids_returns_empty_map() {
12119 let index = parent_tool_use_index("irrelevant text", &[]);
12120 assert!(index.is_empty());
12121 }
12122}