Skip to main content

supercode_interchange/session/
claude_code.rs

1//! Claude Code session codec: loaders, writers and native-record helpers.
2
3use super::*;
4
5mod read_index;
6pub use read_index::ClaudeReadIndex;
7mod append;
8pub(crate) use append::ClaudeAppendState;
9
10impl Session {
11    /// Load a Claude Code transcript from a file, attaching any subagent
12    /// (`Task`) sub-conversations stored alongside it.
13    pub fn from_claude_code(path: impl AsRef<Path>) -> Result<Session> {
14        Self::from_claude_code_with_fidelity(path, Fidelity::ByteLossless)
15    }
16
17    /// [`Session::from_claude_code`] at a declared [`Fidelity`] — see
18    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
19    pub fn from_claude_code_with_fidelity(
20        path: impl AsRef<Path>,
21        fidelity: Fidelity,
22    ) -> Result<Session> {
23        let text = std::fs::read_to_string(path.as_ref())?;
24        let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
25        session.attach_claude_subagents(path.as_ref(), &text, fidelity)?;
26        Ok(session)
27    }
28
29    /// Discover and load `<session>/subagents/agent-*.jsonl` files for a
30    /// Claude Code transcript at `main_path`, linking each back to the parent
31    /// `Task` tool call via the agent id embedded in the parent's tool result.
32    pub(super) fn attach_claude_subagents(
33        &mut self,
34        main_path: &Path,
35        main_text: &str,
36        fidelity: Fidelity,
37    ) -> Result<()> {
38        let Some(dir) = subagents_dir_for(main_path) else {
39            return Ok(());
40        };
41        let entries = std::fs::read_dir(&dir).map_err(|error| {
42            crate::Error::Other(format!(
43                "failed to enumerate Claude subagents at {}: {error}",
44                dir.display()
45            ))
46        })?;
47        let mut files = Vec::new();
48        for entry in entries {
49            let entry = entry.map_err(|error| {
50                crate::Error::Other(format!(
51                    "failed to enumerate Claude subagents at {}: {error}",
52                    dir.display()
53                ))
54            })?;
55            let path = entry.path();
56            if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl") {
57                files.push(path);
58            }
59        }
60        files.sort();
61
62        // Phase 1 — collect each subagent + its recovered agent id, without
63        // touching the main transcript yet.
64        let mut collected: Vec<(Session, Option<String>)> = Vec::with_capacity(files.len());
65        for file in files {
66            let text = read_utf8_or_diagnose(&file).map_err(|error| {
67                crate::Error::Other(format!(
68                    "failed to read Claude subagent {}: {error}",
69                    file.display()
70                ))
71            })?;
72            let sub = match Self::from_claude_code_str_with_fidelity(&text, fidelity) {
73                Ok(sub) => sub,
74                // A read-only VIEW keeps the main conversation rather than
75                // losing the whole session to one unreconstructable child;
76                // the skip is named, not silent. Every stricter fidelity
77                // still propagates the child's failure.
78                Err(error) if fidelity.tolerates_residue() => {
79                    self.load_residue.push(format!(
80                        "Claude subagent {} could not be reconstructed ({error}); it was omitted from this view",
81                        file.display()
82                    ));
83                    continue;
84                }
85                Err(error) => {
86                    return Err(crate::Error::Other(format!(
87                        "failed to reconstruct Claude subagent {}: {error}",
88                        file.display()
89                    )))
90                }
91            };
92            // agentId: prefer the file's own record, fall back to the filename stem.
93            let agent_id = first_agent_id(&text).or_else(|| {
94                file.file_stem()
95                    .and_then(|s| s.to_str())
96                    .map(|s| s.trim_start_matches("agent-").to_string())
97            });
98            collected.push((sub, agent_id));
99        }
100
101        // Phase 2 — single pass over the main transcript to index every
102        // requested agent id at once, then assign each subagent's parent by
103        // an O(1) lookup.
104        let agent_ids: Vec<String> = collected.iter().filter_map(|(_, id)| id.clone()).collect();
105        let index = parent_tool_use_index(main_text, &agent_ids);
106
107        for (mut sub, agent_id) in collected {
108            sub.meta.parent_tool_use_id = agent_id.as_ref().and_then(|id| index.get(id).cloned());
109            sub.meta.agent_id = agent_id;
110            self.subagents.push(sub);
111        }
112        Ok(())
113    }
114
115    /// Parse a Claude Code transcript from an in-memory JSONL string.
116    pub fn from_claude_code_str(jsonl: &str) -> Result<Session> {
117        Self::from_claude_code_str_with_fidelity(jsonl, Fidelity::ByteLossless)
118    }
119
120    /// [`Session::from_claude_code_str`] at a declared [`Fidelity`] — see
121    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
122    pub fn from_claude_code_str_with_fidelity(jsonl: &str, fidelity: Fidelity) -> Result<Session> {
123        let mut meta = SessionMeta::new(SessionSource::ClaudeCode);
124        let mut messages = Vec::new();
125        // IX-1: `raw` is captured STRICT-VERBATIM (blank lines, CRLF, trailing
126        // whitespace all preserved) — separate from the blank-skipping
127        // `non_empty_lines` walk just below, which still parses records only
128        // (a blank line is not a JSON record and must not become one).
129        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
130        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
131        // PARITY-15: a malformed/truncated line is still tolerated (a
132        // single bad line must not make an otherwise-healthy multi-
133        // thousand-line session unloadable) — but it's no longer INVISIBLE.
134        let mut parse_error_lines = 0usize;
135        let mut index = ClaudeReplayIndex::default();
136        let mut residue_lines = Vec::new();
137
138        // Claude transcripts are append-only trees, not linear chat logs.
139        // Build a lightweight graph index first so normalization sees the
140        // same single active, post-compaction branch Claude Code would
141        // resume. `raw` above deliberately remains the complete source.
142        for (line_index, line) in raw_lines.iter().enumerate() {
143            if line.trim().is_empty() {
144                continue;
145            }
146            let v: Value = match serde_json::from_str(line) {
147                Ok(v) => v,
148                Err(_) => {
149                    parse_error_lines += 1; // tolerate stray/corrupt lines
150                    continue;
151                }
152            };
153            capture_claude_meta(&v, &mut meta, line)?;
154            index.observe(line_index, &v)?;
155            if claude_residue_kind(&v).is_some() {
156                residue_lines.push(line_index);
157            }
158        }
159
160        let ClaudeReplaySelection {
161            lines: replay_lines,
162            residue: load_residue,
163        } = index.select_lines(fidelity)?;
164        let mut pending_assistant: Option<Value> = None;
165
166        // PARITY-23: candidates cover ALL raw records, not just the active
167        // replay branch. Classify during the first decode to avoid decoding
168        // every conversation/tool payload again merely to rule it out here.
169        // Capture must still follow ALL metadata restoration: a later foreign
170        // envelope suppresses local residue, while a same-source envelope is
171        // restored before local records are appended. Retain indexes, not Values.
172        for record_index in residue_lines {
173            let line = raw_lines[record_index];
174            if let Ok(record) = serde_json::from_str::<Value>(line) {
175                capture_claude_residue(&mut meta, record_index, line, &record);
176            }
177        }
178
179        for line_index in replay_lines {
180            let line = raw_lines[line_index];
181            let v: Value = serde_json::from_str(line).map_err(crate::Error::Decode)?;
182
183            if v.get("type").and_then(Value::as_str) == Some("assistant") {
184                if v.get("isApiErrorMessage").and_then(Value::as_bool) == Some(true) {
185                    flush_claude_assistant(&mut pending_assistant, &mut messages);
186                    continue;
187                }
188                if let Some(pending) = pending_assistant.as_mut() {
189                    if claude_assistant_message_id(pending).is_some_and(|message_id| {
190                        claude_assistant_message_id(&v) == Some(message_id)
191                    }) {
192                        merge_claude_assistant_chunk(pending, &v);
193                        continue;
194                    }
195                    flush_claude_assistant(&mut pending_assistant, &mut messages);
196                }
197                pending_assistant = Some(v);
198                continue;
199            }
200
201            flush_claude_assistant(&mut pending_assistant, &mut messages);
202
203            // WAVE-2 item 1: every Claude Code record carries a real
204            // top-level `timestamp` (ISO-8601) — provenance stamping below
205            // attaches it to every canonical `ChatMessage` this line
206            // produces, together with the record UUID and assistant model.
207            // `entry(...).or_insert_with` preserves any more-precise value a
208            // role-specific loader already supplied.
209            let before = messages.len();
210            match v.get("type").and_then(Value::as_str) {
211                Some("user") => push_claude_user(&v, &mut messages),
212                Some("assistant") => push_claude_assistant(&v, &mut messages),
213                Some("attachment") => push_claude_attachment(&v, &mut messages),
214                Some("system") => push_claude_system(&v, &mut messages),
215                _ => {} // mode, queue-operation, ... — skip
216            }
217            // UUID/model provenance remains meaningful even for legacy
218            // records that predate Claude Code's timestamp field.
219            capture_claude_record_provenance(&v, &mut messages[before..]);
220            restore_single_grok_message(&v, &mut messages[before..]);
221        }
222        flush_claude_assistant(&mut pending_assistant, &mut messages);
223
224        reorder_tool_results_after_calls(&mut messages);
225        ensure_tool_results_paired(&mut messages);
226        // Each message's creating record, by the record uuid the loader stamped on it.
227        let record_of_uuid: HashMap<&str, usize> = raw_lines
228            .iter()
229            .enumerate()
230            .filter_map(|(index, line)| {
231                let start = line.find("\"uuid\":\"")? + 8;
232                let end = start + line[start..].find('"')?;
233                Some((&line[start..end], index))
234            })
235            .collect();
236        meta.message_records = messages
237            .iter()
238            .map(|message| {
239                message
240                    .metadata
241                    .get("claude_uuid")
242                    .and_then(|uuid| record_of_uuid.get(uuid.as_str()).copied())
243            })
244            .collect();
245        let imported_message_count = Some(messages.len());
246        Ok(Session {
247            meta,
248            messages,
249            subagents: Vec::new(),
250            raw,
251            raw_trailing_newline,
252            imported_message_count,
253            // Claude Code is line-oriented: `raw` is split directly out of
254            // the source text (strict-verbatim, IX-1).
255            raw_is_verbatim: true,
256            parse_error_lines,
257            load_residue,
258        })
259    }
260}
261
262// ---- Claude Code ----------------------------------------------------------
263
264/// The `subagents/` directory for a Claude Code transcript at `<dir>/<stem>.jsonl`
265/// is `<dir>/<stem>/subagents/`. Returns it only if it exists.
266fn subagents_dir_for(main_path: &Path) -> Option<PathBuf> {
267    let dir = main_path.parent()?;
268    let stem = main_path.file_stem()?.to_str()?;
269    let candidate = dir.join(stem).join("subagents");
270    candidate.is_dir().then_some(candidate)
271}
272
273/// The first `agentId` recorded in a subagent transcript.
274fn first_agent_id(jsonl: &str) -> Option<String> {
275    for line in non_empty_lines(jsonl) {
276        if let Ok(v) = serde_json::from_str::<Value>(line) {
277            if let Some(id) = v.get("agentId").and_then(Value::as_str) {
278                return Some(id.to_string());
279            }
280        }
281    }
282    None
283}
284
285/// Find the `tool_use_id` of each parent `Task` call that spawned one of
286/// `agent_ids`, by locating the parent transcript's `tool_result` whose
287/// serialized content mentions the agent id. Best effort: an id with no
288/// qualifying match is simply absent from the returned map.
289///
290/// Single pass over `main_text` — each line is parsed at most once,
291/// regardless of how many agent ids are being sought — with each id's result
292/// reproducing exactly what a per-id scan-and-parse-per-hit-line search would
293/// return: the first line (in file order) whose raw text contains the id and
294/// which — the first qualifying `tool_result` block in that line, in block
295/// order — has a string `tool_use_id` and a serialized form that also
296/// contains the id. A `tool_result` block matching on raw-line/serialized
297/// containment but lacking a `tool_use_id` yields nothing for that id and
298/// does not shadow a later match.
299pub(super) fn parent_tool_use_index(
300    main_text: &str,
301    agent_ids: &[String],
302) -> HashMap<String, String> {
303    let mut index: HashMap<String, String> = HashMap::new();
304    if agent_ids.is_empty() {
305        return index;
306    }
307
308    for line in non_empty_lines(main_text) {
309        if index.len() == agent_ids.len() {
310            break;
311        }
312        // Cheap prefilter: every match this function can ever return comes
313        // from a block whose raw line carries the literal JSON string value
314        // `tool_result` (no JSON-escape variants of that ASCII literal).
315        if !line.contains("tool_result") {
316            continue;
317        }
318        let still_unmapped: Vec<&String> = agent_ids
319            .iter()
320            .filter(|id| !index.contains_key(id.as_str()))
321            .collect();
322        if still_unmapped.is_empty() {
323            break;
324        }
325        let Ok(v) = serde_json::from_str::<Value>(line) else {
326            continue;
327        };
328        let content = v.get("message").and_then(|m| m.get("content"));
329        let Some(Value::Array(blocks)) = content else {
330            continue;
331        };
332        for b in blocks {
333            if b.get("type").and_then(Value::as_str) != Some("tool_result") {
334                continue;
335            }
336            let Some(tool_use_id) = b.get("tool_use_id").and_then(Value::as_str) else {
337                continue;
338            };
339            let block_str = b.to_string();
340            for id in &still_unmapped {
341                if index.contains_key(id.as_str()) {
342                    continue;
343                }
344                if line.contains(id.as_str()) && block_str.contains(id.as_str()) {
345                    index.insert((*id).clone(), tool_use_id.to_string());
346                }
347            }
348        }
349    }
350
351    index
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355enum ClaudeReplayKind {
356    User,
357    Assistant,
358    Attachment,
359    System,
360}
361
362impl ClaudeReplayKind {
363    fn is_conversation(self) -> bool {
364        matches!(self, Self::User | Self::Assistant)
365    }
366}
367
368#[derive(Debug, Clone)]
369struct ClaudeReplayNode {
370    line_index: usize,
371    uuid: String,
372    parent_uuid: Option<String>,
373    kind: ClaudeReplayKind,
374    is_sidechain: bool,
375    assistant_message_id: Option<String>,
376    is_tool_result: bool,
377    compact: Option<ClaudeCompactBoundary>,
378}
379
380#[derive(Debug, Clone)]
381struct ClaudeCompactBoundary {
382    anchor_uuid: Option<String>,
383    preserved_uuids: Vec<String>,
384    preserved_segment: Option<(String, String)>,
385}
386
387/// One projection of a Claude transcript graph: the source lines to replay,
388/// plus whatever the projection had to give up to produce them (always empty
389/// below [`Fidelity::Semantic`], which is the only level that degrades
390/// instead of failing).
391#[derive(Debug, Default)]
392struct ClaudeReplaySelection {
393    lines: Vec<usize>,
394    residue: Vec<String>,
395}
396
397#[derive(Debug, Default, Clone)]
398struct ClaudeReplayIndex {
399    nodes: Vec<ClaudeReplayNode>,
400    by_uuid: HashMap<String, usize>,
401    segment_anchors: HashSet<String>,
402    last_prompt: Option<(String, bool)>,
403    linear_lines: Vec<usize>,
404}
405
406impl ClaudeReplayIndex {
407    fn observe(&mut self, line_index: usize, v: &Value) -> Result<()> {
408        if v.get("type").and_then(Value::as_str) == Some("last-prompt") {
409            if let Some(leaf) = v.get("leafUuid").and_then(Value::as_str) {
410                self.last_prompt = Some((
411                    leaf.to_string(),
412                    v.get("explicit").and_then(Value::as_bool) == Some(true),
413                ));
414            }
415            return Ok(());
416        }
417
418        // A fork-context-ref is a real Claude graph anchor, but not a replay
419        // message. Its child is the first conversational record in the
420        // exported fork, so reaching this UUID terminates the locally
421        // replayable segment rather than indicating a broken parent edge.
422        if v.get("type").and_then(Value::as_str) == Some("fork-context-ref") {
423            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
424                self.segment_anchors.insert(uuid.to_string());
425            }
426            return Ok(());
427        }
428
429        let kind = match v.get("type").and_then(Value::as_str) {
430            Some("user") => ClaudeReplayKind::User,
431            Some("assistant") => ClaudeReplayKind::Assistant,
432            Some("attachment") => ClaudeReplayKind::Attachment,
433            Some("system") => ClaudeReplayKind::System,
434            _ => return Ok(()),
435        };
436        self.linear_lines.push(line_index);
437        let Some(uuid) = v.get("uuid").and_then(Value::as_str) else {
438            return Ok(());
439        };
440        if self.by_uuid.contains_key(uuid) {
441            return Err(claude_replay_error(format!(
442                "duplicate uuid `{uuid}` in Claude transcript"
443            )));
444        }
445
446        let compact = (kind == ClaudeReplayKind::System
447            && v.get("subtype").and_then(Value::as_str) == Some("compact_boundary"))
448        .then(|| ClaudeCompactBoundary::from_value(v));
449        let assistant_message_id = (kind == ClaudeReplayKind::Assistant)
450            .then(|| claude_assistant_message_id(v).map(str::to_string))
451            .flatten();
452        let is_tool_result = kind == ClaudeReplayKind::User
453            && v.get("message")
454                .and_then(|m| m.get("content"))
455                .and_then(Value::as_array)
456                .is_some_and(|blocks| {
457                    blocks
458                        .iter()
459                        .any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result"))
460                });
461        let node = ClaudeReplayNode {
462            line_index,
463            uuid: uuid.to_string(),
464            parent_uuid: v
465                .get("parentUuid")
466                .and_then(Value::as_str)
467                .map(str::to_string),
468            kind,
469            is_sidechain: v.get("isSidechain").and_then(Value::as_bool) == Some(true),
470            assistant_message_id,
471            is_tool_result,
472            compact,
473        };
474        self.by_uuid.insert(uuid.to_string(), self.nodes.len());
475        self.nodes.push(node);
476        Ok(())
477    }
478
479    /// Project the transcript at `fidelity`.
480    ///
481    /// Below [`Fidelity::Semantic`] this is the STRICT projection every
482    /// continuation, transfer and export path depends on: reconstruct
483    /// Claude's own single active post-compaction branch, or fail naming what
484    /// could not be reconstructed.
485    ///
486    /// [`Fidelity::Semantic`] is the read-only VIEW mode. A Claude transcript
487    /// that has been compacted, summarized, or resumed across files routinely
488    /// contains a live record whose `parentUuid` names a record that is no
489    /// longer on disk. Strict projection rightly refuses — a continuation
490    /// built on a guessed graph is silent loss — but a VIEW does not need a
491    /// continuation, so this mode anchors each dangling edge as a segment
492    /// root, projects every severed segment exactly as the active branch is
493    /// projected, splices them back together in transcript order, and names
494    /// every degradation in the returned residue instead of erroring.
495    fn select_lines(mut self, fidelity: Fidelity) -> Result<ClaudeReplaySelection> {
496        let lenient = fidelity.tolerates_residue();
497        let mut residue = Vec::new();
498        // Older exports and many hand-authored compatibility fixtures do not carry Claude's
499        // `uuid`/`parentUuid` graph fields, and a graph with no user/assistant record (a
500        // session that only ran `/model`, or only hook attachments) has no leaf to resume
501        // from. Either way there is no branch to project: the records are the whole
502        // history, in file order.
503        if self.nodes.iter().all(|node| !node.kind.is_conversation()) {
504            return Ok(ClaudeReplaySelection {
505                lines: self.linear_lines,
506                residue,
507            });
508        }
509        if lenient {
510            self.anchor_dangling_parents(&mut residue);
511        }
512        // Last resort for a VIEW: a transcript whose graph is unprojectable
513        // for some OTHER reason (a cycle, an unresolvable compact boundary)
514        // still renders as the file's own record order. A read-only mirror
515        // that cannot open a session at all is the defect this mode exists
516        // to remove, so `Semantic` never returns an error.
517        let fallback = lenient.then(|| self.linear_lines.clone());
518        match self.project(lenient, &mut residue) {
519            Ok(lines) => Ok(ClaudeReplaySelection { lines, residue }),
520            Err(error) => match fallback {
521                Some(lines) => {
522                    residue.push(format!(
523                        "the Claude record graph could not be projected ({error}); \
524                         every record was stitched in transcript order instead"
525                    ));
526                    Ok(ClaudeReplaySelection { lines, residue })
527                }
528                None => Err(error),
529            },
530        }
531    }
532
533    /// Turn every edge that points outside the transcript into a segment
534    /// root, naming the dangling uuids as residue.
535    ///
536    /// A `fork-context-ref` anchor is already a declared segment boundary,
537    /// not a break, so it is left alone.
538    fn anchor_dangling_parents(&mut self, residue: &mut Vec<String>) {
539        let mut dangling = Vec::new();
540        for idx in 0..self.nodes.len() {
541            let Some(parent) = self.nodes[idx].parent_uuid.as_deref() else {
542                continue;
543            };
544            if self.by_uuid.contains_key(parent) || self.segment_anchors.contains(parent) {
545                continue;
546            }
547            dangling.push(format!("`{}` → `{parent}`", self.nodes[idx].uuid));
548            self.nodes[idx].parent_uuid = None;
549        }
550        if dangling.is_empty() {
551            return;
552        }
553        const NAMED: usize = 8;
554        let total = dangling.len();
555        let overflow = total.saturating_sub(NAMED);
556        dangling.truncate(NAMED);
557        let mut listed = dangling.join(", ");
558        if overflow > 0 {
559            listed.push_str(&format!(", and {overflow} more"));
560        }
561        residue.push(format!(
562            "{total} Claude record(s) reference a parentUuid absent from the transcript and were \
563             anchored as segment roots: {listed}"
564        ));
565    }
566
567    fn project(&mut self, lenient: bool, residue: &mut Vec<String>) -> Result<Vec<usize>> {
568        let mut retained = vec![true; self.nodes.len()];
569        if let Some(boundary_index) = self.nodes.iter().rposition(|n| n.compact.is_some()) {
570            let parents: Option<Vec<Option<String>>> = lenient.then(|| {
571                self.nodes
572                    .iter()
573                    .map(|node| node.parent_uuid.clone())
574                    .collect()
575            });
576            if let Err(error) = self.apply_latest_compaction(boundary_index, &mut retained) {
577                let Some(parents) = parents else {
578                    return Err(error);
579                };
580                // The boundary rewrites parents as it goes, so restore the
581                // graph it half-edited before continuing without it.
582                for (node, parent) in self.nodes.iter_mut().zip(parents) {
583                    node.parent_uuid = parent;
584                }
585                retained.iter_mut().for_each(|keep| *keep = true);
586                residue.push(format!(
587                    "the latest Claude compact boundary could not be projected ({error}); \
588                     no pre-compaction record was pruned from this view"
589                ));
590            }
591        }
592        let sidechain_only = self
593            .nodes
594            .iter()
595            .enumerate()
596            .filter(|(idx, node)| retained[*idx] && node.kind.is_conversation())
597            .all(|(_, node)| node.is_sidechain);
598
599        let explicit_leaf = self
600            .last_prompt
601            .as_ref()
602            .filter(|(_, explicit)| *explicit)
603            .and_then(|(uuid, _)| self.by_uuid.get(uuid).copied())
604            .filter(|idx| retained[*idx]);
605        let newest_non_sidechain = self
606            .nodes
607            .iter()
608            .enumerate()
609            .rev()
610            .find(|(idx, node)| retained[*idx] && !node.is_sidechain)
611            .map(|(idx, _)| idx);
612        // Dedicated Claude subagent transcripts are sidechains by design:
613        // every record, including their root user prompt, has
614        // `isSidechain:true`. When there is no main-chain candidate, resume
615        // the newest retained sidechain leaf instead of rejecting the child.
616        let newest_sidechain = self
617            .nodes
618            .iter()
619            .enumerate()
620            .rev()
621            .find(|(idx, node)| retained[*idx] && node.is_sidechain && node.kind.is_conversation())
622            .map(|(idx, _)| idx);
623        let mut active = explicit_leaf
624            .or(newest_non_sidechain)
625            .or(newest_sidechain)
626            .ok_or_else(|| claude_replay_error("no Claude record remains after compaction"))?;
627
628        // Metadata descendants such as turn_duration are leaves in the raw
629        // graph. Claude resumes from their nearest user/assistant ancestor,
630        // then appends those descendants to the reconstructed chain.
631        let mut seeking = HashSet::new();
632        while !self.nodes[active].kind.is_conversation() {
633            if !seeking.insert(active) {
634                return Err(claude_replay_error(
635                    "cycle while resolving active Claude leaf",
636                ));
637            }
638            active = self.parent_index(active, &retained)?;
639        }
640
641        let mut segments =
642            vec![self.project_segment(active, &retained, sidechain_only, lenient)?];
643        if lenient {
644            for leaf in self.severed_segment_leaves(active, &retained) {
645                segments.push(self.project_segment(leaf, &retained, sidechain_only, lenient)?);
646            }
647            if segments.len() > 1 {
648                residue.push(format!(
649                    "{} conversation segments were stitched in transcript order because the \
650                     Claude record graph is severed",
651                    segments.len()
652                ));
653            }
654        }
655        // Each segment keeps its own reconstructed order; the segments
656        // themselves are spliced by where they start in the file.
657        segments.retain(|segment| !segment.is_empty());
658        segments.sort_by_key(|segment| {
659            segment
660                .iter()
661                .map(|idx| self.nodes[*idx].line_index)
662                .min()
663                .unwrap_or(usize::MAX)
664        });
665        let mut ordered = Vec::new();
666        let mut placed = HashSet::new();
667        for idx in segments.into_iter().flatten() {
668            if placed.insert(idx) {
669                ordered.push(idx);
670            }
671        }
672
673        self.recover_parallel_assistant_chunks(ordered, &retained)
674            .map(|indices| {
675                indices
676                    .into_iter()
677                    .map(|idx| self.nodes[idx].line_index)
678                    .collect()
679            })
680    }
681
682    /// Reconstruct one segment: `leaf`'s parent chain, oldest-first, plus the
683    /// non-conversation descendants rooted at it.
684    fn project_segment(
685        &self,
686        leaf: usize,
687        retained: &[bool],
688        sidechain_only: bool,
689        lenient: bool,
690    ) -> Result<Vec<usize>> {
691        let mut reversed = Vec::new();
692        let mut seen = HashSet::new();
693        let mut cursor = Some(leaf);
694        while let Some(idx) = cursor {
695            if !seen.insert(idx) {
696                return Err(claude_replay_error(format!(
697                    "cycle in active Claude parentUuid chain at `{}`",
698                    self.nodes[idx].uuid
699                )));
700            }
701            reversed.push(idx);
702            cursor = match self.nodes[idx].parent_uuid.as_deref() {
703                Some(parent) => match self.by_uuid.get(parent).copied() {
704                    Some(parent) => Some(parent),
705                    None if self.segment_anchors.contains(parent) => None,
706                    // Claude can resume a background child in-place while
707                    // retaining only the new segment in that child's JSONL.
708                    // Its first record then points to a UUID not present in
709                    // the sidechain file. That external edge is a segment
710                    // boundary, not corruption; the complete source remains
711                    // available byte-for-byte in `raw`.
712                    None if sidechain_only => None,
713                    None => {
714                        return Err(claude_replay_error(format!(
715                            "active Claude record `{}` has missing parentUuid `{parent}`",
716                            self.nodes[idx].uuid
717                        )));
718                    }
719                },
720                None => None,
721            };
722            if cursor.is_some_and(|parent| !retained[parent]) {
723                if lenient {
724                    // A compaction boundary is where this segment ends; the
725                    // records it pruned stay pruned.
726                    break;
727                }
728                return Err(claude_replay_error(format!(
729                    "active Claude chain crosses an excluded compaction record from `{}`",
730                    self.nodes[idx].uuid
731                )));
732            }
733        }
734        reversed.reverse();
735
736        // Include non-conversation descendants rooted at the segment's leaf
737        // (turn_duration, attachments, etc.), matching Claude's QYH/n1T.
738        let mut descendants = Vec::new();
739        let mut frontier = vec![leaf];
740        let mut head = 0;
741        while head < frontier.len() {
742            let parent = frontier[head];
743            head += 1;
744            for (idx, node) in self.nodes.iter().enumerate() {
745                if !retained[idx]
746                    || node.kind.is_conversation()
747                    || seen.contains(&idx)
748                    || node.parent_uuid.as_deref() != Some(self.nodes[parent].uuid.as_str())
749                {
750                    continue;
751                }
752                seen.insert(idx);
753                descendants.push(idx);
754                frontier.push(idx);
755            }
756        }
757        descendants.sort_by_key(|idx| self.nodes[*idx].line_index);
758        reversed.extend(descendants);
759        Ok(reversed)
760    }
761
762    /// The newest retained conversation record of every component the active
763    /// leaf's own component cannot reach.
764    ///
765    /// Only a severed graph produces any: a healthy transcript is one
766    /// component, so the abandoned branches a rewind left behind stay
767    /// abandoned here exactly as they do under strict projection.
768    fn severed_segment_leaves(&self, active: usize, retained: &[bool]) -> Vec<usize> {
769        let active_root = self.component_root(active, retained);
770        let mut newest_by_root: BTreeMap<usize, usize> = BTreeMap::new();
771        for idx in 0..self.nodes.len() {
772            if !retained[idx] || !self.nodes[idx].kind.is_conversation() {
773                continue;
774            }
775            let Some(root) = self.component_root(idx, retained) else {
776                continue;
777            };
778            if Some(root) == active_root {
779                continue;
780            }
781            let newest = newest_by_root.entry(root).or_insert(idx);
782            if self.nodes[idx].line_index > self.nodes[*newest].line_index {
783                *newest = idx;
784            }
785        }
786        newest_by_root.into_values().collect()
787    }
788
789    /// Walk `idx` up to the record that anchors its component, stopping at a
790    /// root, an edge that leaves the transcript, or a pruned parent. `None`
791    /// when the walk cycles.
792    fn component_root(&self, idx: usize, retained: &[bool]) -> Option<usize> {
793        let mut cursor = idx;
794        let mut seen = HashSet::new();
795        loop {
796            if !seen.insert(cursor) {
797                return None;
798            }
799            let next = self.nodes[cursor]
800                .parent_uuid
801                .as_deref()
802                .and_then(|parent| self.by_uuid.get(parent).copied())
803                .filter(|parent| retained[*parent]);
804            match next {
805                Some(parent) => cursor = parent,
806                None => return Some(cursor),
807            }
808        }
809    }
810
811    fn apply_latest_compaction(
812        &mut self,
813        boundary_index: usize,
814        retained: &mut [bool],
815    ) -> Result<()> {
816        let compact = self.nodes[boundary_index]
817            .compact
818            .clone()
819            .expect("called with compact boundary");
820        let mut preserved = compact.preserved_uuids;
821        if preserved.is_empty() {
822            if let Some((head, tail)) = compact.preserved_segment {
823                preserved = self.walk_preserved_segment(&head, &tail)?;
824            }
825        }
826
827        let preserved_set: HashSet<String> = preserved.iter().cloned().collect();
828        for uuid in &preserved {
829            if !self.by_uuid.contains_key(uuid) {
830                return Err(claude_replay_error(format!(
831                    "latest compact boundary references missing preserved uuid `{uuid}`"
832                )));
833            }
834        }
835
836        let removed_uuids: HashSet<String> = self
837            .nodes
838            .iter()
839            .enumerate()
840            .filter(|(idx, node)| *idx < boundary_index && !preserved_set.contains(&node.uuid))
841            .map(|(_, node)| node.uuid.clone())
842            .collect();
843        for (idx, node) in self.nodes.iter().enumerate() {
844            if idx < boundary_index && !preserved_set.contains(&node.uuid) {
845                retained[idx] = false;
846            }
847        }
848
849        if preserved.is_empty() {
850            return Ok(());
851        }
852        let anchor = compact.anchor_uuid.ok_or_else(|| {
853            claude_replay_error("preserved compact boundary is missing anchorUuid")
854        })?;
855        if !self.by_uuid.contains_key(&anchor) {
856            return Err(claude_replay_error(format!(
857                "latest compact boundary references missing anchor uuid `{anchor}`"
858            )));
859        }
860        let tail = preserved.last().cloned().expect("non-empty preserved list");
861        let mut parent = anchor.clone();
862        for uuid in &preserved {
863            let idx = self.by_uuid[uuid];
864            self.nodes[idx].parent_uuid = Some(parent);
865            parent = uuid.clone();
866        }
867        let first = &preserved[0];
868        for node in &mut self.nodes {
869            if node.parent_uuid.as_deref() == Some(anchor.as_str()) && node.uuid != *first {
870                node.parent_uuid = Some(tail.clone());
871            }
872        }
873        for node in &mut self.nodes {
874            if node.kind.is_conversation()
875                && node
876                    .parent_uuid
877                    .as_ref()
878                    .is_some_and(|parent| removed_uuids.contains(parent))
879            {
880                node.parent_uuid = Some(tail.clone());
881            }
882        }
883        Ok(())
884    }
885
886    fn walk_preserved_segment(&self, head: &str, tail: &str) -> Result<Vec<String>> {
887        let mut reversed = Vec::new();
888        let mut seen = HashSet::new();
889        let mut cursor = tail;
890        loop {
891            if !seen.insert(cursor.to_string()) {
892                return Err(claude_replay_error("cycle in compact preservedSegment"));
893            }
894            let idx = *self.by_uuid.get(cursor).ok_or_else(|| {
895                claude_replay_error(format!(
896                    "compact preservedSegment references missing uuid `{cursor}`"
897                ))
898            })?;
899            reversed.push(cursor.to_string());
900            if cursor == head {
901                reversed.reverse();
902                return Ok(reversed);
903            }
904            cursor = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
905                claude_replay_error(format!(
906                    "compact preservedSegment tail `{tail}` does not reach head `{head}`"
907                ))
908            })?;
909        }
910    }
911
912    fn parent_index(&self, idx: usize, retained: &[bool]) -> Result<usize> {
913        let parent = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
914            claude_replay_error(format!(
915                "Claude record `{}` has no conversational ancestor",
916                self.nodes[idx].uuid
917            ))
918        })?;
919        let parent_idx = *self.by_uuid.get(parent).ok_or_else(|| {
920            claude_replay_error(format!(
921                "Claude record `{}` has missing parentUuid `{parent}`",
922                self.nodes[idx].uuid
923            ))
924        })?;
925        if !retained[parent_idx] {
926            return Err(claude_replay_error(format!(
927                "Claude record `{}` points into compacted-out history",
928                self.nodes[idx].uuid
929            )));
930        }
931        Ok(parent_idx)
932    }
933
934    fn recover_parallel_assistant_chunks(
935        &self,
936        base: Vec<usize>,
937        retained: &[bool],
938    ) -> Result<Vec<usize>> {
939        let selected: HashSet<usize> = base.iter().copied().collect();
940        let mut replacements: HashMap<usize, Vec<usize>> = HashMap::new();
941        let mut skipped_positions = HashSet::new();
942        let mut handled_ids = HashSet::new();
943
944        for (base_pos, idx) in base.iter().copied().enumerate() {
945            let Some(message_id) = self.nodes[idx].assistant_message_id.as_deref() else {
946                continue;
947            };
948            if !handled_ids.insert(message_id.to_string()) {
949                continue;
950            }
951            let base_positions: Vec<usize> = base
952                .iter()
953                .enumerate()
954                .filter(|(_, candidate)| {
955                    self.nodes[**candidate].assistant_message_id.as_deref() == Some(message_id)
956                })
957                .map(|(pos, _)| pos)
958                .collect();
959            let anchor_pos = base_positions.first().copied().unwrap_or(base_pos);
960            skipped_positions.extend(base_positions.iter().copied().skip(1));
961
962            // A streamed Anthropic response can be stored as sibling records
963            // rather than a literal parent chain. Reassemble every chunk at
964            // the first active occurrence and restore raw chunk order before
965            // the normalizer coalesces their content blocks.
966            let mut chunks: Vec<usize> = self
967                .nodes
968                .iter()
969                .enumerate()
970                .filter(|(candidate, node)| {
971                    retained[*candidate] && node.assistant_message_id.as_deref() == Some(message_id)
972                })
973                .map(|(candidate, _)| candidate)
974                .collect();
975            chunks.sort_by_key(|candidate| self.nodes[*candidate].line_index);
976
977            let assistant_uuids: HashSet<&str> = self
978                .nodes
979                .iter()
980                .filter(|node| node.assistant_message_id.as_deref() == Some(message_id))
981                .map(|node| node.uuid.as_str())
982                .collect();
983            let mut results: Vec<usize> = self
984                .nodes
985                .iter()
986                .enumerate()
987                .filter(|(candidate, node)| {
988                    retained[*candidate]
989                        && !selected.contains(candidate)
990                        && node.is_tool_result
991                        && node
992                            .parent_uuid
993                            .as_deref()
994                            .is_some_and(|parent| assistant_uuids.contains(parent))
995                })
996                .map(|(candidate, _)| candidate)
997                .collect();
998            results.sort_by_key(|candidate| self.nodes[*candidate].line_index);
999            chunks.extend(results);
1000            replacements.insert(anchor_pos, chunks);
1001        }
1002
1003        let mut out = Vec::with_capacity(selected.len());
1004        for (pos, idx) in base.into_iter().enumerate() {
1005            if let Some(replacement) = replacements.remove(&pos) {
1006                out.extend(replacement);
1007            } else if !skipped_positions.contains(&pos) {
1008                out.push(idx);
1009            }
1010        }
1011        Ok(out)
1012    }
1013}
1014
1015impl ClaudeCompactBoundary {
1016    fn from_value(v: &Value) -> Self {
1017        let metadata = v.get("compactMetadata");
1018        let preserved_messages = metadata.and_then(|m| m.get("preservedMessages"));
1019        let anchor_uuid = preserved_messages
1020            .and_then(|p| p.get("anchorUuid"))
1021            .and_then(Value::as_str)
1022            .or_else(|| {
1023                metadata
1024                    .and_then(|m| m.get("preservedSegment"))
1025                    .and_then(|p| p.get("anchorUuid"))
1026                    .and_then(Value::as_str)
1027            })
1028            .map(str::to_string);
1029        let preserved_uuids = preserved_messages
1030            .and_then(|p| p.get("uuids"))
1031            .and_then(Value::as_array)
1032            .map(|uuids| {
1033                uuids
1034                    .iter()
1035                    .filter_map(Value::as_str)
1036                    .map(str::to_string)
1037                    .collect()
1038            })
1039            .unwrap_or_default();
1040        let preserved_segment =
1041            metadata
1042                .and_then(|m| m.get("preservedSegment"))
1043                .and_then(|segment| {
1044                    Some((
1045                        segment.get("headUuid")?.as_str()?.to_string(),
1046                        segment.get("tailUuid")?.as_str()?.to_string(),
1047                    ))
1048                });
1049        Self {
1050            anchor_uuid,
1051            preserved_uuids,
1052            preserved_segment,
1053        }
1054    }
1055}
1056
1057fn claude_replay_error(message: impl Into<String>) -> crate::Error {
1058    crate::Error::Other(format!(
1059        "cannot reconstruct lossless Claude continuation: {}",
1060        message.into()
1061    ))
1062}
1063
1064fn claude_assistant_message_id(v: &Value) -> Option<&str> {
1065    v.get("message")
1066        .and_then(|message| message.get("id"))
1067        .and_then(Value::as_str)
1068}
1069
1070fn merge_claude_assistant_chunk(target: &mut Value, chunk: &Value) {
1071    let Some(target_message) = target.get_mut("message") else {
1072        return;
1073    };
1074    let Some(chunk_message) = chunk.get("message") else {
1075        return;
1076    };
1077    let mut content = target_message
1078        .get("content")
1079        .and_then(Value::as_array)
1080        .cloned()
1081        .unwrap_or_default();
1082    if let Some(blocks) = chunk_message.get("content").and_then(Value::as_array) {
1083        content.extend(blocks.iter().cloned());
1084    }
1085    let mut merged_message = chunk_message.clone();
1086    merged_message["content"] = Value::Array(content);
1087    *target_message = merged_message;
1088}
1089
1090fn flush_claude_assistant(pending: &mut Option<Value>, out: &mut Vec<ChatMessage>) {
1091    let Some(v) = pending.take() else {
1092        return;
1093    };
1094    let reasoning_only = claude_assistant_message_id(&v).is_some()
1095        && v.get("message")
1096            .and_then(|message| message.get("content"))
1097            .and_then(Value::as_array)
1098            .is_some_and(|blocks| {
1099                !blocks.is_empty()
1100                    && blocks.iter().all(|block| {
1101                        matches!(
1102                            block.get("type").and_then(Value::as_str),
1103                            Some("thinking" | "redacted_thinking")
1104                        )
1105                    })
1106            });
1107    if reasoning_only {
1108        return;
1109    }
1110    let before = out.len();
1111    push_claude_assistant(&v, out);
1112    capture_claude_record_provenance(&v, &mut out[before..]);
1113    restore_single_grok_message(&v, &mut out[before..]);
1114}
1115
1116/// Attach the record identity, clock, and actual assistant model to every
1117/// canonical message produced from one Claude JSONL record. These fields are
1118/// deliberately per-message: a continued transcript can cross a provider
1119/// boundary, so the session-level source model is not authoritative for its
1120/// appended tail.
1121fn capture_claude_record_provenance(v: &Value, messages: &mut [ChatMessage]) {
1122    let timestamp = v.get("timestamp").and_then(Value::as_str);
1123    let uuid = v.get("uuid").and_then(Value::as_str);
1124    let model = v
1125        .get("message")
1126        .and_then(|message| message.get("model"))
1127        .and_then(Value::as_str);
1128    for message in messages {
1129        if let Some(timestamp) = timestamp {
1130            message
1131                .metadata
1132                .entry("timestamp".to_string())
1133                .or_insert_with(|| timestamp.to_string());
1134        }
1135        if let Some(uuid) = uuid {
1136            message
1137                .metadata
1138                .entry("claude_uuid".to_string())
1139                .or_insert_with(|| uuid.to_string());
1140        }
1141        if let Some(model) = model {
1142            message
1143                .metadata
1144                .entry("model".to_string())
1145                .or_insert_with(|| model.to_string());
1146        }
1147    }
1148}
1149
1150fn capture_claude_meta(v: &Value, meta: &mut SessionMeta, raw_line: &str) -> Result<()> {
1151    restore_codex_provenance_from_top_level(v, meta)?;
1152    if meta.session_id.is_none() {
1153        if let Some(id) = v.get("sessionId").and_then(Value::as_str) {
1154            meta.session_id = Some(id.to_string());
1155        }
1156    }
1157    if meta.cwd.is_none() {
1158        if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
1159            meta.cwd = Some(PathBuf::from(cwd));
1160        }
1161    }
1162    if meta.model.is_none() {
1163        if let Some(model) = v
1164            .get("message")
1165            .and_then(|m| m.get("model"))
1166            .and_then(Value::as_str)
1167        {
1168            meta.model = Some(model.to_string());
1169        }
1170    }
1171    // PARITY-10 (provenance P010): `fork-context-ref` is an extremely rare
1172    // real Claude Code record with no confirmed field shape (see
1173    // `ClaudeRecord::ForkContextRef`'s doc comment) — rather than guess at
1174    // named fields and risk silently mis-modeling it, stash the WHOLE raw
1175    // line verbatim under a lineage key. `write_claude_code_records` (below)
1176    // re-emits it byte-for-byte, so the record survives the Claude Code
1177    // semantic writer (not just the CLI's raw-passthrough diagonal path) —
1178    // and `to_codex_jsonl`'s synthesized header carries a namespaced copy
1179    // so a Claude -> Codex -> Claude round trip can still reconstruct it
1180    // (dev/03). A session can only fork from one context, so the first one
1181    // seen wins, matching every other "first wins" field above.
1182    //
1183    // D4 (Fable-5 review, confirmed): this used to store `v.to_string()` —
1184    // a RE-SERIALIZATION of the parsed `Value`, not the original source
1185    // text. `serde_json::Value` here has no `preserve_order` feature (see
1186    // `Cargo.toml`), so its object keys are a `BTreeMap` and get silently
1187    // REORDERED (alphabetized) on re-emit — the "byte-for-byte" claim in
1188    // this very comment was false. Fixed the cheap+honest way: store the
1189    // caller's own already-verbatim source `raw_line` text instead of
1190    // re-serializing `v` at all, so this is now ACTUALLY byte-for-byte
1191    // (key order, spacing, everything) rather than merely
1192    // structurally-equivalent JSON.
1193    if v.get("type").and_then(Value::as_str) == Some("fork-context-ref")
1194        && !meta.lineage.contains_key("claude_fork_context_ref_raw")
1195    {
1196        meta.lineage.insert(
1197            "claude_fork_context_ref_raw".to_string(),
1198            raw_line.to_string(),
1199        );
1200    }
1201    Ok(())
1202}
1203
1204fn push_claude_user(v: &Value, out: &mut Vec<ChatMessage>) {
1205    let content = v.get("message").and_then(|m| m.get("content"));
1206    let provenance = claude_user_provenance(v);
1207    match content {
1208        Some(Value::String(s)) => {
1209            if !s.trim().is_empty() {
1210                out.push(ChatMessage::user(s.clone()).with_metas(&provenance));
1211            }
1212        }
1213        Some(Value::Array(blocks)) => {
1214            let mut text = String::new();
1215            // IX-5: image blocks alongside/instead of text — collected
1216            // separately (never synthesized on a malformed shape, see
1217            // `claude_image_block_to_part`) so a multimodal user turn
1218            // survives as `content_parts` instead of the image silently
1219            // vanishing.
1220            let mut images: Vec<Value> = Vec::new();
1221            // D5: an `image` block whose `source` isn't base64/url (e.g. a
1222            // Files-API `{"source":{"type":"file","file_id":..}}`
1223            // reference) makes `claude_image_block_to_part` return `None` —
1224            // track that it was SEEN even though it couldn't be converted,
1225            // so an image-ONLY record (no text, no convertible image) isn't
1226            // silently dropped below (the same vanishing-record bug-class
1227            // PARITY-11 fixed for reasoning-only turns).
1228            let mut saw_unconvertible_image = false;
1229            for b in blocks {
1230                match b.get("type").and_then(Value::as_str) {
1231                    Some("text") => push_text(&mut text, b.get("text")),
1232                    Some("tool_result") => {
1233                        let id = b
1234                            .get("tool_use_id")
1235                            .and_then(Value::as_str)
1236                            .unwrap_or_default();
1237                        // PARITY-11 (nested images): `extract_tool_result_content`
1238                        // captures any `image` blocks nested inside this
1239                        // `tool_result` into `content_parts` (via
1240                        // `claude_image_block_to_part`, the same conversion the
1241                        // top-level `image` block path already uses) instead of
1242                        // flattening them to the bare `[image]` marker text the
1243                        // old `extract_tool_result` emitted — the everyday
1244                        // "Read a PNG / screenshot tool output" shape.
1245                        let (result, images) =
1246                            extract_tool_result_content(b.get("content"), v.get("toolUseResult"));
1247                        let mut msg = tool_message(id, result);
1248                        if !images.is_empty() {
1249                            // D-mix (Fable review, must-fix): `content_parts`
1250                            // is a self-contained contract — the pi writer
1251                            // (`pi_content_value`) reads ONLY `content_parts`
1252                            // for a `Role::Tool` message and never falls back
1253                            // to `msg.content`, so on a MIXED text+image
1254                            // tool_result a bare `content_parts: [image]`
1255                            // silently drops the sibling text on `convert
1256                            // --to pi` (a regression vs. the pre-PARITY-11
1257                            // baseline, which at least preserved the text).
1258                            // Prepend the text as part 0, exactly mirroring
1259                            // `pi_content_to_text_and_parts` and
1260                            // `push_opencode_user`'s identical
1261                            // self-contained-parts construction. `msg.content`
1262                            // keeps the text too (unchanged) for the writers
1263                            // that read text from `msg.content` and only scan
1264                            // `content_parts` for `image_url` entries
1265                            // (`claude_tool_result_content_value`,
1266                            // `codex_tool_output_value`, the opencode
1267                            // assistant writer) — those already filter
1268                            // strictly on `image_url`/text-typed lookups, so
1269                            // this text part is never double-counted.
1270                            let mut parts = Vec::new();
1271                            if let Some(t) = &msg.content {
1272                                if !t.is_empty() {
1273                                    parts.push(serde_json::json!({"type": "text", "text": t}));
1274                                }
1275                            }
1276                            parts.extend(images);
1277                            msg.content_parts = Some(parts);
1278                        }
1279                        // The assistant turn that issued this tool call — the
1280                        // tool-pairing graph edge (parallel to parentUuid).
1281                        if let Some(src) = v.get("sourceToolAssistantUUID").and_then(Value::as_str)
1282                        {
1283                            msg.metadata
1284                                .insert("sourceToolAssistantUUID".to_string(), src.to_string());
1285                        }
1286                        // TR-10: preserve the Claude wire `is_error` flag so
1287                        // the reduction layer's success/failure boundary
1288                        // (`ReductionKind::ToolInputElided` must never target
1289                        // an errored call) survives import — `ChatMessage`
1290                        // otherwise has no structural slot for it.
1291                        if b.get("is_error").and_then(Value::as_bool) == Some(true) {
1292                            crate::mark_tool_error(&mut msg);
1293                        } else {
1294                            restore_tool_outcome_extension(v, &mut msg);
1295                        }
1296                        out.push(msg);
1297                    }
1298                    Some("image") => match claude_image_block_to_part(b) {
1299                        Some(part) => images.push(part),
1300                        None => saw_unconvertible_image = true,
1301                    },
1302                    _ => {} // document / unknown — skip
1303                }
1304            }
1305            // D5: nothing convertible landed in `text`/`images` but an
1306            // image block WAS present — fold in the same short bracketed
1307            // marker convention already used for `[web_search]`/`[model
1308            // fallback: ...]` rather than letting the record vanish.
1309            if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
1310                push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
1311            }
1312            let before = out.len();
1313            if !images.is_empty() {
1314                let mut parts = Vec::new();
1315                if !text.trim().is_empty() {
1316                    parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
1317                }
1318                parts.extend(images);
1319                out.push(
1320                    ChatMessage {
1321                        role: Role::User,
1322                        content: None,
1323                        content_parts: Some(parts),
1324                        tool_calls: None,
1325                        tool_call_id: None,
1326                        name: None,
1327                        metadata: Default::default(),
1328                    }
1329                    .with_metas(&provenance),
1330                );
1331            } else if !text.trim().is_empty() {
1332                out.push(ChatMessage::user(text).with_metas(&provenance));
1333            }
1334            if saw_unconvertible_image && out.len() > before {
1335                if let Some(msg) = out.last_mut() {
1336                    msg.metadata
1337                        .insert("image_source_unconvertible".to_string(), "true".to_string());
1338                }
1339            }
1340        }
1341        _ => {}
1342    }
1343}
1344
1345/// D5 (Fable-5 review): the bracketed marker folded into `text` when an
1346/// `image` block is SEEN but [`claude_image_block_to_part`] can't convert it
1347/// (e.g. a Files-API `{"source":{"type":"file",...}}` reference) and nothing
1348/// else in the record survives either — matches the existing
1349/// `[web_search]`/`[model fallback: ...]` convention rather than letting the
1350/// whole record vanish (`push_claude_user`/`push_claude_assistant`).
1351pub(super) const UNCONVERTIBLE_IMAGE_MARKER: &str =
1352    "[image: source not captured — unsupported/unconvertible image reference]";
1353
1354/// Parse a Claude Code user-turn `image` content block
1355/// (`{"type":"image","source":{"type":"base64","media_type":..,"data":..}}`
1356/// or `{"type":"image","source":{"type":"url","url":..}}`) into a
1357/// `content_parts` `image_url` entry (a `data:` URI for the base64 form, the
1358/// bare URL for the url form) — the inverse of
1359/// [`claude_user_content_value`]'s emission. Only a well-formed source
1360/// (non-empty `media_type`/`data`, or non-empty `url`) is recognized;
1361/// anything else — including a well-formed but unconvertible source like a
1362/// Files-API `{"type":"file",...}` reference (D5) — is left as raw-only
1363/// residue rather than synthesizing a corrupt/empty part (mirrors the
1364/// pi/opencode loaders' `pi_image_shape`/`opencode_file_image_part`
1365/// discipline). Callers must not let that turn the record invisible though:
1366/// see [`UNCONVERTIBLE_IMAGE_MARKER`].
1367pub(super) fn claude_image_block_to_part(b: &Value) -> Option<Value> {
1368    let source = b.get("source")?;
1369    match source.get("type").and_then(Value::as_str) {
1370        Some("base64") => {
1371            let mime = source.get("media_type").and_then(Value::as_str)?;
1372            let data = source.get("data").and_then(Value::as_str)?;
1373            if mime.is_empty() || data.is_empty() {
1374                return None;
1375            }
1376            Some(serde_json::json!({
1377                "type": "image_url",
1378                "image_url": {"url": format!("data:{mime};base64,{data}")},
1379            }))
1380        }
1381        Some("url") => {
1382            let url = source.get("url").and_then(Value::as_str)?;
1383            if url.is_empty() {
1384                return None;
1385            }
1386            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
1387        }
1388        _ => None,
1389    }
1390}
1391
1392/// Rebuild a Claude Code user-turn `message.content` value from a
1393/// `ChatMessage` — the inverse of `push_claude_user`'s parse (text blocks +
1394/// [`claude_image_block_to_part`]). When `content_parts` is absent this
1395/// MUST reproduce the historical plain-string `content` exactly (IX-5's
1396/// overriding constraint: a text-only message's export stays byte-identical)
1397/// — only a multimodal message (`content_parts` present, e.g. imported from
1398/// Pi/OpenCode or a real Claude Code image turn) gets the Anthropic
1399/// content-array shape, one `text` block (if any non-empty text part) plus
1400/// one `image` block per `image_url` part (`data:` URI → `source.base64`;
1401/// any other URL → `source.url`).
1402fn claude_user_content_value(msg: &ChatMessage) -> Value {
1403    match &msg.content_parts {
1404        Some(parts) => {
1405            let mut blocks = Vec::new();
1406            for p in parts {
1407                match p.get("type").and_then(Value::as_str) {
1408                    Some("text") => {
1409                        if let Some(t) = p.get("text").and_then(Value::as_str) {
1410                            if !t.is_empty() {
1411                                blocks.push(serde_json::json!({"type": "text", "text": t}));
1412                            }
1413                        }
1414                    }
1415                    Some("image_url") => {
1416                        if let Some(url) = p
1417                            .get("image_url")
1418                            .and_then(|u| u.get("url"))
1419                            .and_then(Value::as_str)
1420                        {
1421                            blocks.push(match parse_data_uri(url) {
1422                                Some((mime, data)) => serde_json::json!({
1423                                    "type": "image",
1424                                    "source": {"type": "base64", "media_type": mime, "data": data},
1425                                }),
1426                                None => serde_json::json!({
1427                                    "type": "image",
1428                                    "source": {"type": "url", "url": url},
1429                                }),
1430                            });
1431                        }
1432                    }
1433                    _ => {}
1434                }
1435            }
1436            Value::Array(blocks)
1437        }
1438        None => Value::String(msg.content.clone().unwrap_or_default()),
1439    }
1440}
1441
1442/// Rebuild a Claude Code `tool_result` block's `content` value from a `Tool`
1443/// `ChatMessage` — the inverse of [`extract_tool_result_content`]'s parse
1444/// (PARITY-11). When `content_parts` is absent (or empty) this MUST reproduce
1445/// the historical plain-string `content` exactly (same IX-5-style constraint
1446/// `claude_user_content_value` follows) — only a `tool_result` that actually
1447/// carries a captured nested image gets the Anthropic content-array shape,
1448/// one `text` block (the existing `msg.content`, if any) plus one `image`
1449/// block per `image_url` part (mirrors `claude_user_content_value`'s
1450/// `data:` URI -> `source.base64` / other URL -> `source.url` mapping).
1451fn claude_tool_result_content_value(msg: &ChatMessage) -> Value {
1452    match &msg.content_parts {
1453        Some(parts) if !parts.is_empty() => {
1454            let mut blocks = Vec::new();
1455            if let Some(t) = &msg.content {
1456                if !t.is_empty() {
1457                    blocks.push(serde_json::json!({"type": "text", "text": t}));
1458                }
1459            }
1460            for p in parts {
1461                if p.get("type").and_then(Value::as_str) == Some("image_url") {
1462                    if let Some(url) = p
1463                        .get("image_url")
1464                        .and_then(|u| u.get("url"))
1465                        .and_then(Value::as_str)
1466                    {
1467                        blocks.push(match parse_data_uri(url) {
1468                            Some((mime, data)) => serde_json::json!({
1469                                "type": "image",
1470                                "source": {"type": "base64", "media_type": mime, "data": data},
1471                            }),
1472                            None => serde_json::json!({
1473                                "type": "image",
1474                                "source": {"type": "url", "url": url},
1475                            }),
1476                        });
1477                    }
1478                }
1479            }
1480            Value::Array(blocks)
1481        }
1482        _ => Value::String(msg.content.clone().unwrap_or_default()),
1483    }
1484}
1485
1486/// Collect the Claude Code user-turn provenance fields that distinguish real
1487/// human input from system-injected turns and record replay-relevant state.
1488pub(crate) fn claude_user_provenance(v: &Value) -> Vec<(String, String)> {
1489    let mut out = Vec::new();
1490    let mut take_str = |key: &str| {
1491        if let Some(s) = v.get(key).and_then(Value::as_str) {
1492            out.push((key.to_string(), s.to_string()));
1493        }
1494    };
1495    take_str("promptSource"); // typed | queued | system | sdk
1496    take_str("interruptedMessageId");
1497    take_str("sourceToolUseID");
1498    for flag in ["isMeta", "isCompactSummary", "isVisibleInTranscriptOnly"] {
1499        if v.get(flag).and_then(Value::as_bool) == Some(true) {
1500            out.push((flag.to_string(), "true".to_string()));
1501        }
1502    }
1503    if let Some(n) = v.get("queuePriority").and_then(Value::as_i64) {
1504        out.push(("queuePriority".to_string(), n.to_string()));
1505    }
1506    // `origin` is an object like {"kind":"task-notification"} — keep its kind.
1507    if let Some(kind) = v
1508        .get("origin")
1509        .and_then(|o| o.get("kind"))
1510        .and_then(Value::as_str)
1511    {
1512        out.push(("origin".to_string(), kind.to_string()));
1513    }
1514    out
1515}
1516
1517/// Content-bearing Claude `system` events (`scheduled_task_fire`,
1518/// `local_command`, `away_summary`) carry real text that's part of the
1519/// interaction; fold them in as system context. Marker/metric subtypes
1520/// (`turn_duration`, `compact_boundary`, `api_error`, `stop_hook_summary`) carry
1521/// no conversational content and are skipped.
1522fn push_claude_system(v: &Value, out: &mut Vec<ChatMessage>) {
1523    let keep = matches!(
1524        v.get("subtype").and_then(Value::as_str),
1525        Some("scheduled_task_fire") | Some("local_command") | Some("away_summary")
1526    );
1527    if !keep {
1528        return;
1529    }
1530    if let Some(content) = v.get("content").and_then(Value::as_str) {
1531        if !content.trim().is_empty() {
1532            let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("system");
1533            out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
1534        }
1535    }
1536}
1537
1538/// Fold content-bearing Claude Code `attachment` records into the conversation
1539/// as user-role messages. Most attachment subtypes (`task_reminder`,
1540/// `deferred_tools_delta`, `skill_listing`, `hook_*`, `command_permissions`, …)
1541/// are regenerable system injections and are skipped; only the four that carry
1542/// non-regenerable user/external content are kept.
1543fn push_claude_attachment(v: &Value, out: &mut Vec<ChatMessage>) {
1544    let att = match v.get("attachment") {
1545        Some(a) => a,
1546        None => return,
1547    };
1548    let kind = match att.get("type").and_then(Value::as_str) {
1549        Some(kind) => kind,
1550        None => return,
1551    };
1552    let text = match kind {
1553        // A queued prompt. `commandMode` says whose: `prompt` is the person's
1554        // own text, `task-notification` is the runtime reporting a finished
1555        // background task. Kept verbatim below.
1556        "queued_command" => att
1557            .get("prompt")
1558            .and_then(Value::as_str)
1559            .map(str::to_string),
1560        // A file the user attached: header + contents.
1561        "file" => attachment_with_path(att, "attached file", "filename", "content"),
1562        // A user-edited file snippet.
1563        "edited_text_file" => attachment_with_path(att, "edited file", "filename", "snippet"),
1564        // Injected project memory (CLAUDE.md), point-in-time.
1565        "nested_memory" => attachment_with_path(att, "project memory", "path", "content"),
1566        _ => None, // regenerable system injection — skip
1567    };
1568    let Some(text) = text else { return };
1569    if text.trim().is_empty() {
1570        return;
1571    }
1572    // An attachment record wears the user's ROLE, but the record itself says
1573    // who actually spoke — and that fact is lost the moment the attachment is
1574    // flattened to `[label: path]` text, so carry it as metadata the way
1575    // `push_claude_system` carries `systemSubtype`. Two fields, both verbatim:
1576    //
1577    //   `attachmentType`  the subtype. `file` / `edited_text_file` /
1578    //                     `nested_memory` are envelopes the runtime built
1579    //                     around a file body; a frontend that trusts the role
1580    //                     shows the reader a numbered source listing in a
1581    //                     chat bubble apparently sent by themselves.
1582    //   `commandMode`     present on `queued_command` only, and the whole
1583    //                     story for it. Measured over the local Claude Code
1584    //                     corpus (2,512 `queued_command` attachments): 926
1585    //                     `prompt`, every one of them plain human text, and
1586    //                     1,586 `task-notification`, every one of them a
1587    //                     `<task-notification>` frame — the same text Claude
1588    //                     Code also writes as a `type:"user"` record stamped
1589    //                     `origin.kind = "task-notification"`.
1590    //
1591    // Presentation policy (which of these a frontend hides) belongs to the
1592    // frontend — `isContextMessage` in `sdk/client/client.mjs`. The loader's
1593    // job is to stop discarding the producer's own answer.
1594    let mut message = ChatMessage::user(text).with_meta("attachmentType", kind);
1595    if let Some(mode) = att.get("commandMode").and_then(Value::as_str) {
1596        message = message.with_meta("commandMode", mode);
1597    }
1598    out.push(message);
1599}
1600
1601/// Format an attachment as `[<label>: <path>]\n<body>`.
1602fn attachment_with_path(
1603    att: &Value,
1604    label: &str,
1605    path_key: &str,
1606    body_key: &str,
1607) -> Option<String> {
1608    let body = att.get(body_key).and_then(Value::as_str)?;
1609    let path = att
1610        .get(path_key)
1611        .or_else(|| att.get("displayPath"))
1612        .and_then(Value::as_str)
1613        .unwrap_or("");
1614    Some(format!("[{label}: {path}]\n{body}"))
1615}
1616
1617pub(super) fn push_str_field(buf: &mut String, s: &str) {
1618    if !buf.is_empty() {
1619        buf.push('\n');
1620    }
1621    buf.push_str(s);
1622}
1623
1624/// N3: build a synthesized message for reasoning that could not attach to a
1625/// following assistant turn — either interrupted mid-stream by a
1626/// non-assistant item, or dangling at EOF (an aborted-turn shape). Drains
1627/// the three pending buffers (all empty/`false` afterward) so callers don't
1628/// separately have to remember to clear them.
1629pub(super) fn orphaned_reasoning_message(
1630    reasoning: &mut String,
1631    reasoning_content: &mut String,
1632    encrypted: &mut bool,
1633) -> ChatMessage {
1634    let mut msg = ChatMessage::system("[reasoning] (turn ended without a reply)".to_string());
1635    if !reasoning.is_empty() {
1636        msg = msg.with_meta("reasoning", std::mem::take(reasoning));
1637    }
1638    if !reasoning_content.is_empty() {
1639        msg = msg.with_meta("reasoning_content", std::mem::take(reasoning_content));
1640    }
1641    if *encrypted {
1642        msg = msg.with_meta("reasoning_encrypted", "true");
1643        *encrypted = false;
1644    }
1645    msg
1646}
1647
1648fn push_claude_assistant(v: &Value, out: &mut Vec<ChatMessage>) {
1649    let content = v.get("message").and_then(|m| m.get("content"));
1650    let mut text = String::new();
1651    let mut calls: Vec<ToolCall> = Vec::new();
1652    // Legacy singular fields — kept for backward compatibility with every
1653    // existing consumer of `metadata["thinking"]`/`["thinking_signature"]`/
1654    // `["redacted_thinking"]` (a concatenation of all thinking text, and the
1655    // LAST block's signature/data). D8 (Fable-5 review, confirmed): when a
1656    // message carries MULTIPLE `thinking` blocks, collapsing them down to
1657    // these singular fields silently drops every signature but the last
1658    // one's — a real Anthropic `thinking` block's `signature` cryptographically
1659    // covers ONLY that block's own text, so re-emitting block 1's text under
1660    // block 2's signature (or vice versa) produces a signature that will
1661    // never verify. `thinking_blocks` below is the fix: every block
1662    // preserved SEPARATELY, in order, each with its own (optional)
1663    // signature/data — the writer prefers it over the legacy fields
1664    // whenever present.
1665    let mut thinking = String::new();
1666    let mut signature: Option<String> = None;
1667    // PARITY-11: real Claude corpora also carry `redacted_thinking` and
1668    // `image` assistant blocks, and (rarely) a `fallback` model-routing
1669    // marker — none handled before, all silently vanishing (audit's own
1670    // census: 132,927 `thinking` / 67 `redacted_thinking` / 9 `image` / 4
1671    // `fallback` blocks in the reference corpus).
1672    //
1673    // D8: `redacted_thinking` is real data ONLY — never a fabricated
1674    // placeholder. The pre-fix code defaulted a missing `data` field to the
1675    // literal string `"<redacted>"`, which is indistinguishable from an
1676    // actual (if oddly-named) opaque payload on re-emit — a caller reading
1677    // it back has no way to tell "no data was ever captured" from "the
1678    // provider's own opaque blob happens to be the string `<redacted>`".
1679    // `redacted_thinking_seen` tracks block PRESENCE independently of
1680    // whether it had real data, so the reasoning-only-turn rescue below
1681    // still fires even when no block had a `data` field at all.
1682    let mut redacted_thinking: Option<String> = None;
1683    let mut redacted_thinking_seen = false;
1684    let mut images: Vec<Value> = Vec::new();
1685    // Real Anthropic `thinking` blocks very commonly carry an EMPTY
1686    // `thinking` string alongside a real `signature` (the summarized/
1687    // redacted-in-the-clear-but-replayable case) — `thinking.trim()` alone
1688    // would miss those, so track "a thinking block existed at all"
1689    // separately from whether it had visible text.
1690    let mut thinking_block_seen = false;
1691    // D8: every `thinking`/`redacted_thinking` block, preserved SEPARATELY
1692    // and IN ORDER — see the comment on `thinking`/`redacted_thinking`
1693    // above. Serialized as a single JSON-array metadata string
1694    // (`ChatMessage::metadata` is a flat string map) under
1695    // `"thinking_blocks"`.
1696    let mut thinking_blocks: Vec<Value> = Vec::new();
1697    // D5: mirrors `push_claude_user`'s tracking — an `image` block whose
1698    // source isn't base64/url (e.g. Files-API `{"type":"file",...}`) must
1699    // not silently vanish the whole record when nothing else survives.
1700    let mut saw_unconvertible_image = false;
1701
1702    match content {
1703        Some(Value::String(s)) => push_text(&mut text, Some(&Value::String(s.clone()))),
1704        Some(Value::Array(blocks)) => {
1705            for b in blocks {
1706                match b.get("type").and_then(Value::as_str) {
1707                    Some("text") => push_text(&mut text, b.get("text")),
1708                    Some("tool_use") => {
1709                        let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
1710                        let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
1711                        let args = b
1712                            .get("input")
1713                            .map(|i| i.to_string())
1714                            .unwrap_or_else(|| "{}".to_string());
1715                        calls.push(function_call(id, name, args));
1716                    }
1717                    // Thinking is not replayed across providers, but retain it in
1718                    // (skip-serialized) metadata so a same-model continuation can
1719                    // re-inject it. See P3.
1720                    Some("thinking") => {
1721                        thinking_block_seen = true;
1722                        let t = b.get("thinking").and_then(Value::as_str).unwrap_or("");
1723                        if !t.is_empty() {
1724                            push_str_field(&mut thinking, t); // legacy concatenated field
1725                        }
1726                        let sig = b.get("signature").and_then(Value::as_str);
1727                        if let Some(s) = sig {
1728                            signature = Some(s.to_string()); // legacy last-wins field
1729                        }
1730                        // D8: this block's OWN text + signature, not folded
1731                        // into the running concatenation above.
1732                        let mut block = serde_json::json!({"type": "thinking", "thinking": t});
1733                        if let Some(s) = sig {
1734                            block["signature"] = Value::String(s.to_string());
1735                        }
1736                        thinking_blocks.push(block);
1737                    }
1738                    // Anthropic's redacted reasoning: an opaque, provider-private
1739                    // payload (flagged content the API declines to show in the
1740                    // clear). Like `thinking`, it's not replayable, but the raw
1741                    // `data` is retained in metadata rather than silently
1742                    // vanishing — a same-model continuation can still replay it
1743                    // verbatim even though supercode never renders it.
1744                    Some("redacted_thinking") => {
1745                        redacted_thinking_seen = true;
1746                        let data = b.get("data").and_then(Value::as_str);
1747                        // D8: no fabricated fallback — `data` is only ever
1748                        // the real captured payload, or genuinely absent.
1749                        if let Some(d) = data {
1750                            redacted_thinking = Some(d.to_string()); // legacy last-wins field
1751                        }
1752                        let mut block = serde_json::json!({"type": "redacted_thinking"});
1753                        if let Some(d) = data {
1754                            block["data"] = Value::String(d.to_string());
1755                        }
1756                        thinking_blocks.push(block);
1757                    }
1758                    // An assistant-emitted image block (e.g. a generated
1759                    // image) — collected exactly like `push_claude_user`'s
1760                    // user-turn image handling (`claude_image_block_to_part`
1761                    // is role-general), so it survives as `content_parts`
1762                    // instead of vanishing.
1763                    Some("image") => match claude_image_block_to_part(b) {
1764                        Some(part) => images.push(part),
1765                        None => saw_unconvertible_image = true,
1766                    },
1767                    // A provider-routing note (real shape:
1768                    // `{"type":"fallback","from":{"model":..},"to":{"model":..}}`
1769                    // — a mid-generation model swap, e.g. an overloaded model
1770                    // falling back to another). Carries no replayable
1771                    // conversational content, but folding it into `text` as a
1772                    // short bracketed marker — the same convention the Codex
1773                    // loader already uses for `[web_search]`/
1774                    // `[image_generation] ...` — keeps it visible instead of
1775                    // silently vanishing, including the case where it's the
1776                    // ONLY block in the turn (see the reasoning-only-turn fix
1777                    // below: before this, that shape dropped the entire
1778                    // message).
1779                    Some("fallback") => {
1780                        let from = b
1781                            .get("from")
1782                            .and_then(|f| f.get("model"))
1783                            .and_then(Value::as_str)
1784                            .unwrap_or("?");
1785                        let to = b
1786                            .get("to")
1787                            .and_then(|t| t.get("model"))
1788                            .and_then(Value::as_str)
1789                            .unwrap_or("?");
1790                        push_str_field(&mut text, &format!("[model fallback: {from} -> {to}]"));
1791                    }
1792                    _ => {}
1793                }
1794            }
1795        }
1796        _ => {}
1797    }
1798
1799    // D5: nothing convertible landed in `text`/`images` but an image block
1800    // WAS present — fold in the same bracketed-marker convention `fallback`
1801    // uses above, so a genuinely image-only (unconvertible source) turn
1802    // doesn't vanish (mirrors `push_claude_user`'s identical fix).
1803    if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
1804        push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
1805    }
1806
1807    let before = out.len();
1808    if !images.is_empty() {
1809        let mut parts = Vec::new();
1810        if !text.trim().is_empty() {
1811            parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
1812        }
1813        parts.extend(images);
1814        out.push(ChatMessage {
1815            role: Role::Assistant,
1816            content: None,
1817            content_parts: Some(parts),
1818            tool_calls: (!calls.is_empty()).then_some(calls),
1819            tool_call_id: None,
1820            name: None,
1821            metadata: Default::default(),
1822        });
1823    } else {
1824        push_assistant(out, text, calls);
1825        // A recognized native assistant record remains transcript state even
1826        // when its content array is empty (for example, an interrupted model
1827        // turn). Force a bare message whenever `push_assistant` had nothing
1828        // to emit. This includes the reasoning-only case and also preserves
1829        // genuinely part-less records instead of silently changing turn
1830        // count/order during translation.
1831        if out.len() == before {
1832            let mut empty = ChatMessage {
1833                role: Role::Assistant,
1834                content: None,
1835                content_parts: None,
1836                tool_calls: None,
1837                tool_call_id: None,
1838                name: None,
1839                metadata: Default::default(),
1840            };
1841            if !thinking_block_seen && !redacted_thinking_seen {
1842                empty
1843                    .metadata
1844                    .insert("empty_assistant_record".to_string(), "true".to_string());
1845            }
1846            out.push(empty);
1847        }
1848    }
1849    // Attach retained reasoning + attribution to the message we just produced.
1850    if out.len() > before {
1851        if let Some(msg) = out.last_mut() {
1852            // Insert "thinking" (even as an empty string) whenever a
1853            // `thinking` block was actually seen, not just when it had
1854            // visible text — a real `thinking` block commonly carries an
1855            // empty `thinking` string alongside a real `signature` (the
1856            // summarized-away-but-still-replayable case), and the writer
1857            // below keys its re-emission decision off this metadata key's
1858            // PRESENCE, not its content.
1859            if thinking_block_seen {
1860                msg.metadata.insert("thinking".to_string(), thinking);
1861            }
1862            if let Some(sig) = signature {
1863                msg.metadata.insert("thinking_signature".to_string(), sig);
1864            }
1865            if let Some(rt) = redacted_thinking {
1866                msg.metadata.insert("redacted_thinking".to_string(), rt);
1867            }
1868            // D8: exact per-block re-emission list — every `thinking`/
1869            // `redacted_thinking` block preserved separately, in order, each
1870            // with its own (optional) signature/data. The writer prefers
1871            // this over the legacy singular fields above whenever present,
1872            // so a multi-block message round-trips losslessly instead of
1873            // collapsing to one block under one (now-unverifiable)
1874            // signature.
1875            if !thinking_blocks.is_empty() {
1876                msg.metadata.insert(
1877                    "thinking_blocks".to_string(),
1878                    Value::Array(thinking_blocks).to_string(),
1879                );
1880            }
1881            // D5: honest signal that this message contained an image block
1882            // whose source this loader couldn't convert — the actual image
1883            // content is NOT captured, only a marker/partial record.
1884            if saw_unconvertible_image {
1885                msg.metadata
1886                    .insert("image_source_unconvertible".to_string(), "true".to_string());
1887            }
1888            // Attribution: which skill / subagent / MCP server+tool produced
1889            // this turn, plus the model `slug`.
1890            for key in [
1891                "attributionSkill",
1892                "attributionAgent",
1893                "attributionMcpServer",
1894                "attributionMcpTool",
1895                "slug",
1896            ] {
1897                if let Some(s) = v.get(key).and_then(Value::as_str) {
1898                    msg.metadata.insert(key.to_string(), s.to_string());
1899                }
1900            }
1901        }
1902    }
1903}
1904
1905/// PARITY-23: Claude Code records that affect replay or carry source-native
1906/// state but have no canonical home — the claude-code residue inventory
1907/// (design doc §per-format). `last-prompt` steers leaf selection,
1908/// `fork-context-ref` anchors the replay graph, `file-history-snapshot` /
1909/// `queue-operation` / `mode` are durable native state.
1910pub(super) fn claude_residue_kind(record: &Value) -> Option<&'static str> {
1911    match record.get("type").and_then(Value::as_str) {
1912        Some("file-history-snapshot") => Some("file-history-snapshot"),
1913        Some("queue-operation") => Some("queue-operation"),
1914        Some("last-prompt") => Some("last-prompt"),
1915        Some("mode") => Some("mode"),
1916        Some("fork-context-ref") => Some("fork-context-ref"),
1917        _ => None,
1918    }
1919}
1920
1921/// Re-emit a claude residue record under the session's CURRENT metadata:
1922/// `sessionId`/`cwd` keys are rewritten only when they exist and differ (an
1923/// explicit `--session-id`/`--cwd` override is a deliberate rewrite request
1924/// that extends to residue). When nothing differs — the plain return
1925/// diagonal — the raw line is emitted byte-exact.
1926fn claude_residue_line_for_emit(raw: &str, session_id: &str, cwd: &str) -> String {
1927    let Ok(mut value) = serde_json::from_str::<Value>(raw) else {
1928        return raw.to_string();
1929    };
1930    let Some(object) = value.as_object_mut() else {
1931        return raw.to_string();
1932    };
1933    let mut changed = false;
1934    for (key, current) in [("sessionId", session_id), ("cwd", cwd)] {
1935        if object
1936            .get(key)
1937            .and_then(Value::as_str)
1938            .is_some_and(|existing| existing != current)
1939        {
1940            object.insert(key.to_string(), Value::String(current.to_string()));
1941            changed = true;
1942        }
1943    }
1944    if changed {
1945        value.to_string()
1946    } else {
1947        raw.to_string()
1948    }
1949}
1950
1951fn capture_claude_residue(
1952    meta: &mut SessionMeta,
1953    record_index: usize,
1954    raw_line: &str,
1955    record: &Value,
1956) {
1957    let Some(kind) = claude_residue_kind(record) else {
1958        return;
1959    };
1960    capture_native_residue(meta, "claude_code", record_index, raw_line, record, kind);
1961}
1962
1963/// R1 (Skeptic B, spliced-export uuid-collision hardening — the same bug
1964/// class N2 closed for the Codex spliced path's group ids, see
1965/// `collect_codex_group_ids_from_raw`/`write_codex_records`): mint the next
1966/// `synth_uuid`, skipping any value already present in `seed_used_ids` — the
1967/// uuids [`collect_claude_uuids_from_raw`] found already sitting in the
1968/// verbatim raw prefix [`Session::to_claude_code_jsonl_spliced`] replays
1969/// ahead of the tail this counter mints. Without this, re-splicing a
1970/// previously-exported-then-reimported session (export -> reimport -> append
1971/// -> export again) restarts `counter` at 1 with no memory of the prior
1972/// export's tail uuids now sitting in the prefix, so the second tail
1973/// fabricates the SAME `00000000-0000-4000-8000-…` values the first one did
1974/// — a uuid collision across prefix and tail that can mis-link any
1975/// uuid-keyed consumer (fork/tree lineage, `parentUuid`). `counter` keeps
1976/// climbing monotonically even across skips. `used_ids` is also updated for
1977/// each minted or metadata-backed identity, so collisions are prevented both
1978/// against the replayed prefix and within the appended tail.
1979fn next_claude_uuid(counter: &mut usize, used_ids: &mut HashSet<String>) -> String {
1980    loop {
1981        let candidate = synth_uuid(*counter);
1982        *counter += 1;
1983        if used_ids.insert(candidate.clone()) {
1984            return candidate;
1985        }
1986    }
1987}
1988
1989/// Reuse a message's durable native/source UUID when available, falling back
1990/// to the deterministic synthesized sequence only for hand-built or legacy
1991/// messages that never carried identity metadata.
1992fn claude_message_uuid(
1993    msg: &ChatMessage,
1994    counter: &mut usize,
1995    used_ids: &mut HashSet<String>,
1996) -> String {
1997    for key in ["claude_uuid", "supercode_native_uuid"] {
1998        if let Some(candidate) = msg.metadata.get(key) {
1999            if !candidate.is_empty() && used_ids.insert(candidate.clone()) {
2000                return candidate.clone();
2001            }
2002        }
2003    }
2004    next_claude_uuid(counter, used_ids)
2005}
2006
2007/// Companion to [`next_claude_uuid`]: every `uuid` already present in
2008/// `raw_prefix` — the verbatim RAW lines
2009/// [`Session::to_claude_code_jsonl_spliced`] replays ahead of the appended
2010/// tail it synthesizes via [`Session::write_claude_code_records`]. This is
2011/// the GROUND TRUTH of what physically lands in the exported `out` string
2012/// for the prefix (mirrors `collect_codex_group_ids_from_raw`'s approach on
2013/// the Codex side): each line is parsed as a Claude Code JSONL record and
2014/// its own top-level `uuid` field is read back out of the bytes directly, no
2015/// re-derivation from `self.messages` needed. A line that fails to parse, or
2016/// parses but carries no `uuid` (e.g. a trailing `file-history-snapshot`
2017/// record), contributes nothing.
2018fn collect_claude_uuids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
2019    let mut ids = HashSet::new();
2020    for line in raw_prefix {
2021        if let Ok(v) = serde_json::from_str::<Value>(line) {
2022            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
2023                ids.insert(uuid.to_string());
2024            }
2025        }
2026    }
2027    ids
2028}
2029
2030impl Session {
2031    /// Synthesize a Claude Code transcript.
2032    ///
2033    /// Claude Code transcripts have no slot for the *session-level system
2034    /// prompt* (that lives in the CLI, not the log) — but PARITY-6 dev/02
2035    /// (Fable-5 corpus audit) surfaced that content-bearing `System`
2036    /// `ChatMessage`s (Claude's own `type: "system"` records with a
2037    /// content-bearing `subtype` like `local_command`/`scheduled_task_fire`/
2038    /// `away_summary` — see `push_claude_system`, the exact inverse of what
2039    /// this writer now does) DO have a first-class slot: the real `type:
2040    /// "system"` record itself. This function used to unconditionally drop
2041    /// every `System` message, silently losing e.g. a real
2042    /// `<local-command-stdout>` record on any format -> Claude Code hop
2043    /// (confirmed on a real 2,982-message corpus session: Codex -> Claude
2044    /// Code dropped its one surviving `system` message, 2215 -> 2214, with
2045    /// no loss manifest). `write_claude_code_records`'s `Role::System` arm
2046    /// now re-materializes it instead.
2047    pub(super) fn to_claude_code_jsonl(&self) -> String {
2048        let session_id = self
2049            .meta
2050            .session_id
2051            .clone()
2052            .unwrap_or_else(|| synth_uuid(0));
2053        let cwd = self.cwd_string();
2054        let mut out = String::new();
2055        // PARITY-10: a captured `fork-context-ref` (see `capture_claude_meta`)
2056        // re-emitted byte-for-byte, ahead of the conversation it applies to —
2057        // this is what makes the record survive the SEMANTIC Claude Code
2058        // writer (the raw-passthrough diagonal in `crates/cli` already
2059        // preserves it by construction; this covers the library `to_jsonl`
2060        // path too, e.g. a `--session-id` override that forces the semantic
2061        // writer).
2062        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
2063            out.push_str(raw);
2064            out.push('\n');
2065        }
2066        // Full synthesis: `out` at this point has no raw prefix ahead of it
2067        // (unlike the A12 splice below), so there are no uuids yet in play
2068        // to seed against — see `next_claude_uuid`'s doc comment.
2069        self.write_claude_code_records(
2070            &mut out,
2071            &self.messages,
2072            &session_id,
2073            &cwd,
2074            None,
2075            1,
2076            &HashSet::new(),
2077        );
2078        // PARITY-23: claude-source residue restored from a foreign hop is
2079        // NATIVE here again — re-emit the exact source records (relative
2080        // order preserved) instead of wrapping them in an envelope.
2081        if self.meta.native_residue_source.as_deref() == Some("claude_code") {
2082            let mut records: Vec<&Value> = self.meta.native_residue.iter().collect();
2083            records.sort_by_key(|entry| {
2084                entry
2085                    .get("record_index")
2086                    .and_then(Value::as_u64)
2087                    .unwrap_or(u64::MAX)
2088            });
2089            for entry in records {
2090                if let Some(raw) = entry.get("raw").and_then(Value::as_str) {
2091                    out.push_str(&claude_residue_line_for_emit(raw, &session_id, &cwd));
2092                    out.push('\n');
2093                }
2094            }
2095        } else if let Some(extension) = native_residue_envelope(&self.meta) {
2096            if out.is_empty() {
2097                push_jsonl(
2098                    &mut out,
2099                    &serde_json::json!({
2100                        "type": "file-history-snapshot",
2101                        "messageId": synth_uuid(1),
2102                        "snapshot": {},
2103                        "sessionId": session_id,
2104                        "cwd": cwd,
2105                        "timestamp": SYNTH_TS,
2106                    }),
2107                );
2108            }
2109            inject_first_jsonl_top_level(
2110                &mut out,
2111                SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY,
2112                native_residue_summary(&extension),
2113            );
2114            inject_first_jsonl_top_level(&mut out, SUPERCODE_NATIVE_RESIDUE_KEY, extension);
2115        }
2116        out
2117    }
2118
2119    /// Synthesize Claude Code records for `messages` (a full session or an
2120    /// appended tail — A12's [`Self::to_jsonl_spliced`] reuses this for just
2121    /// the latter), starting the `parentUuid` chain at `parent` and the
2122    /// `synth_uuid` counter at `counter`. Factored out of
2123    /// [`Self::to_claude_code_jsonl`] so the record shape is defined once.
2124    ///
2125    /// `seed_used_ids` primes [`next_claude_uuid`]'s collision guard with
2126    /// every uuid that will ALREADY be present in `out` before this call
2127    /// ever runs — see that function's doc comment for why the A12 splice
2128    /// path needs this and full synthesis doesn't.
2129    // R1: this was already at clippy's `too_many_arguments` threshold (7,
2130    // including `&self`) before the fix; the added `seed_used_ids` param
2131    // pushes it to 8. Every argument here is independently meaningful (two
2132    // record-shape inputs, two id/parent-chain threading values, and now
2133    // the collision seed) — bundling them into a params struct is a larger
2134    // refactor of this already-widely-called private helper than the R1 fix
2135    // warrants, so this is allowed rather than restructured.
2136    #[allow(clippy::too_many_arguments)]
2137    fn write_claude_code_records(
2138        &self,
2139        out: &mut String,
2140        messages: &[ChatMessage],
2141        session_id: &str,
2142        cwd: &str,
2143        mut parent: Option<String>,
2144        mut counter: usize,
2145        seed_used_ids: &HashSet<String>,
2146    ) {
2147        let mut used_ids = seed_used_ids.clone();
2148        for msg in messages {
2149            if is_replay_excluded(msg) {
2150                continue;
2151            }
2152            let blocks: Vec<Value> = match msg.role {
2153                // PARITY-6 dev/02: re-materialize a content-bearing System
2154                // `ChatMessage` as a real Claude Code `type: "system"`
2155                // record — the exact inverse of `push_claude_system`, which
2156                // is what produced it in the first place for a message
2157                // loaded FROM a real Claude Code transcript. `subtype`
2158                // prefers the original `systemSubtype` metadata
2159                // (`push_claude_system`'s `.with_meta`, round-tripped
2160                // through the Codex hop via `write_codex_records`'s
2161                // `claude_system_subtype` metadata channel and restored by
2162                // `push_codex_item`); when that channel didn't carry it
2163                // (e.g. a genuinely native, non-Claude-origin developer
2164                // message), fall back to `local_command` — the observed
2165                // common case, and still one of `push_claude_system`'s own
2166                // `keep` subtypes, so the record survives a *subsequent*
2167                // reload rather than being silently re-dropped. This never
2168                // fabricates content: the real text is always carried
2169                // verbatim, only the subtype label is a best-effort guess
2170                // when the true one wasn't recoverable.
2171                Role::System => {
2172                    let content = msg.content.clone().unwrap_or_default();
2173                    if content.trim().is_empty() {
2174                        continue;
2175                    }
2176                    let subtype = msg
2177                        .metadata
2178                        .get("systemSubtype")
2179                        .cloned()
2180                        .unwrap_or_else(|| "local_command".to_string());
2181                    // R1/B3 union: this mint must ALSO route through
2182                    // `next_claude_uuid` + `seed_used_ids` like the other
2183                    // three arms below — otherwise this System arm (added by
2184                    // B3 after R1 landed) mints a raw `synth_uuid` that can
2185                    // collide with a uuid already sitting in the A12 splice's
2186                    // raw prefix (see `next_claude_uuid`'s doc comment).
2187                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
2188                    let mut line = serde_json::json!({
2189                        "parentUuid": parent,
2190                        "type": "system",
2191                        "subtype": subtype,
2192                        "content": content,
2193                        "uuid": uuid,
2194                        "sessionId": session_id,
2195                        "cwd": cwd,
2196                        "timestamp": msg_timestamp_or_synth(msg),
2197                    });
2198                    set_grok_message_extension(&mut line, self.meta.source, msg);
2199                    push_jsonl(out, &line);
2200                    parent = Some(uuid);
2201                    continue;
2202                }
2203                Role::User => {
2204                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
2205                    let mut line = serde_json::json!({
2206                        "parentUuid": parent,
2207                        "type": "user",
2208                        "message": {
2209                            "role": "user",
2210                            "content": claude_user_content_value(msg),
2211                        },
2212                        "uuid": uuid,
2213                        "sessionId": session_id,
2214                        "cwd": cwd,
2215                        "timestamp": msg_timestamp_or_synth(msg),
2216                    });
2217                    set_grok_message_extension(&mut line, self.meta.source, msg);
2218                    push_jsonl(out, &line);
2219                    parent = Some(uuid);
2220                    continue;
2221                }
2222                Role::Tool => {
2223                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
2224                    let mut line = serde_json::json!({
2225                        "parentUuid": parent,
2226                        "type": "user",
2227                        "message": {
2228                            "role": "user",
2229                            "content": [{
2230                                "type": "tool_result",
2231                                "tool_use_id": msg.tool_call_id.clone().unwrap_or_default(),
2232                                "content": claude_tool_result_content_value(msg),
2233                            }],
2234                        },
2235                        "uuid": uuid,
2236                        "sessionId": session_id,
2237                        "cwd": cwd,
2238                        "timestamp": msg_timestamp_or_synth(msg),
2239                    });
2240                    if crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
2241                        line[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
2242                    }
2243                    set_grok_message_extension(&mut line, self.meta.source, msg);
2244                    push_jsonl(out, &line);
2245                    parent = Some(uuid);
2246                    continue;
2247                }
2248                Role::Assistant => {
2249                    let mut blocks = Vec::new();
2250                    // PARITY-16 (found via the REAL pi corpus, PARITY-5
2251                    // dev/01): thinking/redacted_thinking must be re-emitted
2252                    // BEFORE text/tool_use, unconditionally whenever
2253                    // retained metadata is present — not only when `blocks`
2254                    // is otherwise empty. The previous `if blocks.is_empty()`
2255                    // gate (now below, applied unconditionally instead)
2256                    // meant a turn that thinks AND THEN answers/calls a tool
2257                    // in the SAME turn — pi's own default emission shape,
2258                    // and the overwhelmingly common real-world case for any
2259                    // reasoning model, not the rare reasoning-only edge case
2260                    // this gate's comment described — silently dropped its
2261                    // entire `thinking` block on Pi -> Claude Code export. A
2262                    // genuine multi-turn pi session driven through pi's own
2263                    // real Agent loop (faux provider, see
2264                    // `pi_interop.rs`'s live-corpus tests) exposed this: its
2265                    // thinking+text turns lost the thinking block entirely.
2266                    // D8: prefer the exact per-block list when present —
2267                    // every `thinking`/`redacted_thinking` block re-emitted
2268                    // SEPARATELY with its own signature/data, exactly as
2269                    // captured (`push_claude_assistant`), instead of the
2270                    // legacy singular fields' lossy collapse (which drops
2271                    // every signature but the last one's on a multi-block
2272                    // message). Falls back to the legacy fields only for a
2273                    // `Session` that never populated `thinking_blocks` (e.g.
2274                    // hand-constructed in another loader/test, or loaded
2275                    // from a non-Claude-Code source like Pi).
2276                    match msg
2277                        .metadata
2278                        .get("thinking_blocks")
2279                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
2280                        .and_then(|v| v.as_array().cloned())
2281                    {
2282                        Some(saved_blocks) => blocks.extend(saved_blocks),
2283                        None => {
2284                            if let Some(t) = msg.metadata.get("thinking") {
2285                                let mut block =
2286                                    serde_json::json!({"type": "thinking", "thinking": t});
2287                                if let Some(sig) = msg.metadata.get("thinking_signature") {
2288                                    block["signature"] = Value::String(sig.clone());
2289                                }
2290                                blocks.push(block);
2291                            }
2292                            if let Some(rt) = msg.metadata.get("redacted_thinking") {
2293                                blocks.push(
2294                                    serde_json::json!({"type": "redacted_thinking", "data": rt}),
2295                                );
2296                            }
2297                        }
2298                    }
2299                    if let Some(t) = &msg.content {
2300                        if !t.is_empty() {
2301                            blocks.push(serde_json::json!({"type": "text", "text": t}));
2302                        }
2303                    }
2304                    // PARITY-11: an assistant-emitted image (`content_parts`,
2305                    // e.g. a generated image — `push_claude_assistant`'s
2306                    // load-side counterpart) has no slot in `msg.content`;
2307                    // without this, `blocks` stayed empty for an image-only
2308                    // turn and the whole message vanished on Claude Code
2309                    // semantic export, same failure mode the IX-6 Codex
2310                    // writer fix already closed on that side.
2311                    if let Some(parts) = &msg.content_parts {
2312                        for p in parts {
2313                            if p.get("type").and_then(Value::as_str) == Some("image_url") {
2314                                if let Some(url) = p
2315                                    .get("image_url")
2316                                    .and_then(|u| u.get("url"))
2317                                    .and_then(Value::as_str)
2318                                {
2319                                    blocks.push(match parse_data_uri(url) {
2320                                        Some((mime, data)) => serde_json::json!({
2321                                            "type": "image",
2322                                            "source": {"type": "base64", "media_type": mime, "data": data},
2323                                        }),
2324                                        None => serde_json::json!({
2325                                            "type": "image",
2326                                            "source": {"type": "url", "url": url},
2327                                        }),
2328                                    });
2329                                }
2330                            }
2331                        }
2332                    }
2333                    for tc in msg.tool_calls() {
2334                        let input = tc
2335                            .function
2336                            .parsed_arguments()
2337                            .unwrap_or_else(|_| Value::Object(Default::default()));
2338                        blocks.push(serde_json::json!({
2339                            "type": "tool_use",
2340                            "id": tc.id,
2341                            "name": tc.function.name,
2342                            "input": input,
2343                        }));
2344                    }
2345                    // PARITY-11 / PARITY-16: a genuinely reasoning-only turn
2346                    // (no text, no tool_use, no image) still doesn't vanish
2347                    // — the thinking/redacted_thinking prepend above already
2348                    // ran unconditionally, so `blocks` is non-empty here
2349                    // whenever any of those were present.
2350                    blocks
2351                }
2352            };
2353
2354            // An empty assistant content array is a valid native interrupted
2355            // turn and must remain a record. Every non-assistant arm above
2356            // already `continue`s after writing its own shape, so an empty
2357            // `blocks` value here belongs specifically to that assistant.
2358            let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
2359            let mut message = serde_json::json!({"role": "assistant", "content": blocks});
2360            if let Some(model) = msg.metadata.get("model").or(self.meta.model.as_ref()) {
2361                message["model"] = Value::String(model.clone());
2362            }
2363            let mut line = serde_json::json!({
2364                "parentUuid": parent,
2365                "type": "assistant",
2366                "message": message,
2367                "uuid": uuid,
2368                "sessionId": session_id,
2369                "cwd": cwd,
2370                "timestamp": msg_timestamp_or_synth(msg),
2371            });
2372            set_grok_message_extension(&mut line, self.meta.source, msg);
2373            push_jsonl(out, &line);
2374            parent = Some(uuid);
2375        }
2376    }
2377
2378    /// A12 splice: replay the imported Claude Code `raw` prefix verbatim
2379    /// (patching `sessionId` on each line when `session_id` is `Some`), then
2380    /// synthesize records only for the appended tail, via
2381    /// [`Self::write_claude_code_records`] — chaining `parentUuid` from the
2382    /// last original `uuid` found anywhere in the raw prefix (not just its
2383    /// final line: a trailing loader-skipped record, e.g.
2384    /// `file-history-snapshot`, may carry no `uuid` of its own).
2385    pub(super) fn to_claude_code_jsonl_spliced(&self, session_id: Option<&str>) -> String {
2386        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
2387        let sid = session_id
2388            .map(str::to_string)
2389            .or_else(|| self.meta.session_id.clone())
2390            .unwrap_or_else(|| synth_uuid(0));
2391        let cwd = self.cwd_string();
2392
2393        let mut out = String::new();
2394        let mut parent: Option<String> = None;
2395        for line in &self.raw[..raw_prefix_len] {
2396            push_spliced_line(&mut out, line, session_id, "sessionId");
2397            if let Ok(v) = serde_json::from_str::<Value>(line) {
2398                if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
2399                    parent = Some(uuid.to_string());
2400                }
2401            }
2402        }
2403
2404        // R1 (spliced-path hardening, mirrors the Codex N2 fix above): seed
2405        // the tail's collision guard with every uuid the just-replayed RAW
2406        // prefix already carries, so `write_claude_code_records` never
2407        // fabricates a `synth_uuid` for the appended tail that collides with
2408        // one already sitting in the prefix (see `next_claude_uuid`'s and
2409        // `collect_claude_uuids_from_raw`'s doc comments).
2410        let seed_used_ids = collect_claude_uuids_from_raw(&self.raw[..raw_prefix_len]);
2411        self.write_claude_code_records(
2412            &mut out,
2413            &self.messages[message_prefix_len..],
2414            &sid,
2415            &cwd,
2416            parent,
2417            1,
2418            &seed_used_ids,
2419        );
2420        out
2421    }
2422}
2423
2424#[cfg(test)]
2425mod tests {
2426    use super::*;
2427
2428    #[test]
2429    fn residue_inventory_preserves_raw_indexes_and_all_native_kinds() {
2430        let lines = [
2431            "",
2432            r#" {"type":"mode","mode":"plan"} "#,
2433            r#"{"type":"file-history-snapshot","snapshot":{}}"#,
2434            r#"{"type":"queue-operation","operation":"dequeue"}"#,
2435            r#"{"type":"fork-context-ref","uuid":"root"}"#,
2436            r#"{"type":"user","uuid":"user","parentUuid":"root","message":{"role":"user","content":"hello"}}"#,
2437            r#"{"type":"last-prompt","leafUuid":"user","explicit":true}"#,
2438            r#"{"type":"progress","data":"not residue"}"#,
2439            "{truncated",
2440        ];
2441        let text = lines.join("\r\n");
2442        let session = Session::from_claude_code_str(&text).unwrap();
2443        assert_eq!(session.parse_error_lines, 1);
2444        assert_eq!(session.messages.len(), 1);
2445        let expected = [
2446            (1, "mode"),
2447            (2, "file-history-snapshot"),
2448            (3, "queue-operation"),
2449            (4, "fork-context-ref"),
2450            (6, "last-prompt"),
2451        ];
2452        assert_eq!(session.meta.native_residue.len(), expected.len());
2453        for (entry, (index, kind)) in session.meta.native_residue.iter().zip(expected) {
2454            assert_eq!(entry["record_index"], index);
2455            assert_eq!(entry["kind"], kind);
2456            assert_eq!(entry["raw"], format!("{}\r", lines[index]));
2457        }
2458        assert_eq!(session.raw.join("\n"), text);
2459    }
2460
2461    #[test]
2462    fn residue_capture_waits_for_late_same_or_foreign_envelope_restore() {
2463        for source in ["claude_code", "grok"] {
2464            let restored_raw = r#"{"type":"mode","mode":"restored"}"#;
2465            let mut restored_meta = SessionMeta::new(SessionSource::ClaudeCode);
2466            capture_native_residue(
2467                &mut restored_meta,
2468                source,
2469                17,
2470                restored_raw,
2471                &serde_json::from_str::<Value>(restored_raw).unwrap(),
2472                "mode",
2473            );
2474            let carrier = serde_json::json!({
2475                "type": "mode",
2476                (SUPERCODE_NATIVE_RESIDUE_KEY): native_residue_envelope(&restored_meta).unwrap(),
2477            });
2478            let before = r#"{"type":"mode","mode":"before"}"#;
2479            let after = r#"{"type":"queue-operation","operation":"after"}"#;
2480            let text = format!(
2481                "{before}\n{carrier}\n{after}\n{}\n",
2482                r#"{"type":"user","message":{"role":"user","content":"hello"}}"#,
2483            );
2484            let session = Session::from_claude_code_str(&text).unwrap();
2485            assert_eq!(session.meta.native_residue_source.as_deref(), Some(source));
2486            assert_eq!(
2487                session.meta.native_residue[0],
2488                restored_meta.native_residue[0]
2489            );
2490            if source == "claude_code" {
2491                assert_eq!(session.meta.native_residue.len(), 3);
2492                assert_eq!(session.meta.native_residue[1]["raw"], before);
2493                assert_eq!(session.meta.native_residue[1]["record_index"], 0);
2494                assert_eq!(session.meta.native_residue[2]["raw"], after);
2495                assert_eq!(session.meta.native_residue[2]["record_index"], 2);
2496            } else {
2497                assert_eq!(session.meta.native_residue, restored_meta.native_residue);
2498            }
2499        }
2500    }
2501
2502    /// Pin of the single-pass indexer against the relevant Claude tool-result
2503    /// shape (SUP-21). An id absent from the transcript must map to nothing.
2504    #[test]
2505    fn parent_tool_use_index_matches_known_fixture_linkage() {
2506        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"}}"#;
2507
2508        let ids = vec![
2509            "ad8dc6cf98b49eea6".to_string(),
2510            "no-such-agent-id".to_string(),
2511        ];
2512        let index = parent_tool_use_index(main_text, &ids);
2513
2514        assert_eq!(
2515            index.get("ad8dc6cf98b49eea6").map(String::as_str),
2516            Some("toolu_01SYjhg9qRCzUWY2GTa3iazQ"),
2517            "known agent id must resolve to the pinned parent tool_use_id"
2518        );
2519        assert_eq!(
2520            index.get("no-such-agent-id"),
2521            None,
2522            "unknown agent id must yield no entry (best-effort None)"
2523        );
2524    }
2525
2526    #[test]
2527    fn parent_tool_use_index_empty_ids_returns_empty_map() {
2528        let index = parent_tool_use_index("irrelevant text", &[]);
2529        assert!(index.is_empty());
2530    }
2531}