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