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}