supercode_interchange/session/
native.rs1use super::*;
4
5impl Session {
6 pub fn to_native_jsonl(&self) -> String {
13 let source = match self.meta.source {
14 SessionSource::ClaudeCode => "claude_code",
15 SessionSource::Codex => "codex",
16 SessionSource::Pi => "pi",
17 SessionSource::OpenCode => "opencode",
18 SessionSource::Grok => "grok",
19 SessionSource::Gemini => "gemini",
20 SessionSource::Goose => "goose",
21 SessionSource::OpenClaw => "openclaw",
22 SessionSource::Hermes => "hermes",
23 SessionSource::Native => "native",
26 };
27 let header = serde_json::json!({
28 "supercode_native": 1,
29 "source": source,
30 "raw_trailing_newline": self.raw_trailing_newline,
35 })
36 .to_string();
37 let mut out =
38 String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
39 out.push_str(&header);
40 out.push('\n');
41 for line in &self.raw {
42 out.push_str(line);
43 out.push('\n');
44 }
45 out
46 }
47
48 pub fn to_native_jsonl_v2(&self, appended: &[ChatMessage]) -> String {
61 self.to_native_jsonl_v2_with_timestamp(appended, None)
62 }
63
64 pub(crate) fn to_native_jsonl_v2_with_timestamp(
65 &self,
66 appended: &[ChatMessage],
67 fixed_timestamp: Option<&str>,
68 ) -> String {
69 let source = match self.meta.source {
70 SessionSource::ClaudeCode => "claude_code",
71 SessionSource::Codex => "codex",
72 SessionSource::Pi => "pi",
73 SessionSource::OpenCode => "opencode",
74 SessionSource::Grok => "grok",
75 SessionSource::Gemini => "gemini",
76 SessionSource::Goose => "goose",
77 SessionSource::OpenClaw => "openclaw",
78 SessionSource::Hermes => "hermes",
79 SessionSource::Native => "native",
82 };
83 let mut header_obj = serde_json::json!({
94 "supercode_native": 2,
95 "source": source,
96 "session_id": self.meta.session_id,
97 "created": fixed_timestamp
98 .map(ToOwned::to_owned)
99 .unwrap_or_else(crate::sidecar::now_rfc3339),
100 "raw_trailing_newline": self.raw_trailing_newline,
102 });
103 if let Some(obj) = header_obj.as_object_mut() {
104 if let Some(agent_id) = &self.meta.agent_id {
105 obj.insert("agent_id".to_string(), Value::String(agent_id.clone()));
106 }
107 if let Some(parent_tool_use_id) = &self.meta.parent_tool_use_id {
108 obj.insert(
109 "parent_tool_use_id".to_string(),
110 Value::String(parent_tool_use_id.clone()),
111 );
112 }
113 if !self.meta.lineage.is_empty() {
114 obj.insert(
115 "lineage".to_string(),
116 serde_json::to_value(&self.meta.lineage).unwrap_or(Value::Null),
117 );
118 }
119 }
120 let header = header_obj.to_string();
121 let mut out =
122 String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
123 out.push_str(&header);
124 out.push('\n');
125 for line in &self.raw {
126 out.push_str(line);
127 out.push('\n');
128 }
129 for (turn_index, msg) in appended.iter().enumerate() {
130 let turn = match fixed_timestamp {
131 Some(timestamp) => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
132 msg,
133 timestamp.to_string(),
134 turn_index as u64,
135 ),
136 None => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
137 msg,
138 crate::sidecar::now_rfc3339(),
139 turn_index as u64,
140 ),
141 };
142 out.push_str(&serde_json::to_string(&turn).unwrap_or_default());
143 out.push('\n');
144 }
145 out
146 }
147
148 pub fn from_native_str(jsonl: &str) -> Result<Session> {
158 let (all_lines, _wrapper_trailing_newline) = split_lines_verbatim(jsonl);
169 let mut lines = all_lines.into_iter();
170 let header = lines.next().unwrap_or("");
171 let hv: Value = serde_json::from_str(header).unwrap_or(Value::Null);
172 let source = hv.get("source").and_then(Value::as_str);
173 let raw_trailing_newline = hv
181 .get("raw_trailing_newline")
182 .and_then(Value::as_bool)
183 .unwrap_or(true);
184
185 let mut body_lines: Vec<String> = Vec::new();
189 let mut turn_lines: Vec<&str> = Vec::new();
190 for line in lines {
191 let is_turn = serde_json::from_str::<Value>(line)
192 .ok()
193 .is_some_and(|v| v.get("supercode_turn").is_some());
194 if is_turn {
195 turn_lines.push(line);
196 } else {
197 body_lines.push(line.to_string());
198 }
199 }
200 let body = join_lines_verbatim(&body_lines, raw_trailing_newline);
204
205 let mut session = match source {
207 Some("codex") => Self::from_codex_str(&body)?,
208 Some("claude_code") => Self::from_claude_code_str(&body)?,
209 Some("pi") => Self::from_pi_str(&body)?,
210 Some("opencode") => Self::from_opencode_str(&body)?,
211 Some("grok") => Self::from_grok_str(&body)?,
212 Some("gemini") => Self::from_gemini_str(&body)?,
213 Some("goose") => Self::from_goose_str(&body)?,
214 Some("openclaw") => Self::from_openclaw_str(&body)?,
215 Some("native") => {
223 let mut s = Self::from_claude_code_str(&body)?;
224 s.meta.source = SessionSource::Native;
225 s
226 }
227 _ => match detect_source(&body) {
229 Some(SessionSource::Codex) => Self::from_codex_str(&body)?,
230 Some(SessionSource::Pi) => Self::from_pi_str(&body)?,
231 Some(SessionSource::OpenClaw) => Self::from_openclaw_str(&body)?,
232 Some(SessionSource::OpenCode) => Self::from_opencode_str(&body)?,
233 Some(SessionSource::Grok) => Self::from_grok_str(&body)?,
234 Some(SessionSource::Gemini) => Self::from_gemini_str(&body)?,
235 Some(SessionSource::Goose) => Self::from_goose_str(&body)?,
236 _ => Self::from_claude_code_str(&body)?,
237 },
238 };
239
240 for line in turn_lines {
241 match serde_json::from_str::<crate::sidecar::NativeTurn>(line) {
242 Ok(turn) => {
243 session.raw.push(line.to_string());
244 session.messages.push(turn.into_message());
245 }
246 Err(_) => {
247 session.raw.push(line.to_string());
257 session.parse_error_lines += 1;
258 }
259 }
260 }
261
262 if let Some(agent_id) = hv.get("agent_id").and_then(Value::as_str) {
271 session.meta.agent_id = Some(agent_id.to_string());
272 }
273 if let Some(parent_tool_use_id) = hv.get("parent_tool_use_id").and_then(Value::as_str) {
274 session.meta.parent_tool_use_id = Some(parent_tool_use_id.to_string());
275 }
276 if let Some(lineage) = hv.get("lineage").and_then(Value::as_object) {
277 for (k, v) in lineage {
278 if let Some(s) = v.as_str() {
279 session.meta.lineage.insert(k.clone(), s.to_string());
280 }
281 }
282 }
283
284 Ok(session)
285 }
286
287 pub fn from_sidecar_str(s: &str) -> Result<Session> {
296 let header = s.lines().next().ok_or_else(|| {
297 Error::InvalidSession("sidecar header is missing from an empty artifact".to_string())
298 })?;
299 let value: Value = serde_json::from_str(header).map_err(|error| {
300 Error::InvalidSession(format!("sidecar header is not valid JSON: {error}"))
301 })?;
302 let version = value.get("supercode_native").and_then(Value::as_u64);
303 if !matches!(version, Some(1 | 2)) {
304 return Err(Error::InvalidSession(
305 "sidecar header must declare supported `supercode_native` version 1 or 2"
306 .to_string(),
307 ));
308 }
309 let source = value.get("source").and_then(Value::as_str);
310 if !matches!(
311 source,
312 Some(
313 "native"
314 | "claude_code"
315 | "codex"
316 | "gemini"
317 | "goose"
318 | "opencode"
319 | "pi"
320 | "grok"
321 )
322 ) {
323 return Err(Error::InvalidSession(
324 "sidecar header must declare a supported `source`".to_string(),
325 ));
326 }
327 Self::from_native_str(s)
328 }
329}
330
331pub(super) fn native_display_human_line(line: &str, source: Option<SessionSource>) -> bool {
332 if !line
333 .as_bytes()
334 .windows(6)
335 .any(|window| window == b"\"user\"")
336 {
337 return false;
338 }
339 let Ok(value) = serde_json::from_str::<Value>(line) else {
340 return false;
341 };
342 match source {
343 Some(SessionSource::Codex) => {
344 value.get("type").and_then(Value::as_str) == Some("response_item")
345 && value
346 .get("payload")
347 .and_then(|payload| payload.get("type"))
348 .and_then(Value::as_str)
349 == Some("message")
350 && value
351 .get("payload")
352 .and_then(|payload| payload.get("role"))
353 .and_then(Value::as_str)
354 == Some("user")
355 }
356 Some(SessionSource::ClaudeCode) => {
357 value.get("type").and_then(Value::as_str) == Some("user")
358 && value
359 .get("message")
360 .and_then(|message| message.get("content"))
361 .is_some_and(|content| match content {
362 Value::String(text) => !text.trim().is_empty(),
363 Value::Array(parts) => parts.iter().any(|part| {
364 part.get("type").and_then(Value::as_str) == Some("text")
365 && part
366 .get("text")
367 .and_then(Value::as_str)
368 .is_some_and(|text| !text.trim().is_empty())
369 }),
370 _ => false,
371 })
372 }
373 Some(SessionSource::Gemini) => {
374 value.get("type").and_then(Value::as_str) == Some("user")
375 && value.get("content").is_some_and(|content| match content {
376 Value::String(text) => !text.trim().is_empty(),
377 Value::Array(parts) => parts.iter().any(|part| {
378 part.get("text")
379 .and_then(Value::as_str)
380 .is_some_and(|text| !text.trim().is_empty())
381 }),
382 _ => false,
383 })
384 }
385 _ => false,
386 }
387}
388
389pub(super) fn capture_native_residue(
390 meta: &mut SessionMeta,
391 source: &str,
392 record_index: usize,
393 raw_line: &str,
394 record: &Value,
395 kind: &str,
396) {
397 if record.get(SUPERCODE_NATIVE_RESIDUE_KEY).is_some()
399 || record.get(SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY).is_some()
400 || record.get(SUPERCODE_CODEX_PROVENANCE_KEY).is_some()
401 {
402 return;
403 }
404 if meta
408 .native_residue_source
409 .as_deref()
410 .is_some_and(|existing| existing != source)
411 {
412 return;
413 }
414 meta.native_residue.push(serde_json::json!({
415 "record_index": record_index,
416 "kind": kind,
417 "raw": raw_line,
418 }));
419 meta.native_residue_source = Some(source.to_string());
420}
421
422pub(super) const SUPERCODE_NATIVE_RESIDUE_KEY: &str = "_supercode_native_residue";
426
427pub(super) const SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY: &str = "_supercode_native_residue_summary";
434
435pub(super) fn native_residue_summary(envelope: &Value) -> Value {
436 let records = envelope
437 .get("records")
438 .and_then(Value::as_array)
439 .cloned()
440 .unwrap_or_default();
441 let mut kinds: Vec<String> = records
442 .iter()
443 .filter_map(|entry| entry.get("kind").and_then(Value::as_str))
444 .map(str::to_string)
445 .collect();
446 kinds.sort();
447 kinds.dedup();
448 serde_json::json!({
449 "version": 2,
450 "source": envelope.get("source").cloned().unwrap_or(Value::Null),
451 "kinds": kinds,
452 "records": records.len(),
453 "records_sha256": envelope.get("records_sha256").cloned().unwrap_or(Value::Null),
454 })
455}
456
457fn residue_records_sha256(records: &[Value]) -> String {
461 fn canonical(value: &Value, out: &mut String) {
462 match value {
463 Value::Array(items) => {
464 out.push('[');
465 for (index, item) in items.iter().enumerate() {
466 if index > 0 {
467 out.push(',');
468 }
469 canonical(item, out);
470 }
471 out.push(']');
472 }
473 Value::Object(map) => {
474 out.push('{');
475 let mut keys: Vec<&String> = map.keys().collect();
476 keys.sort();
477 for (index, key) in keys.iter().enumerate() {
478 if index > 0 {
479 out.push(',');
480 }
481 out.push_str(&serde_json::to_string(key).unwrap_or_default());
482 out.push(':');
483 canonical(&map[*key], out);
484 }
485 out.push('}');
486 }
487 other => out.push_str(&other.to_string()),
488 }
489 }
490 let mut text = String::from("[");
491 for (index, record) in records.iter().enumerate() {
492 if index > 0 {
493 text.push(',');
494 }
495 canonical(record, &mut text);
496 }
497 text.push(']');
498 let mut hasher = blake3::Hasher::new();
499 hasher.update(text.as_bytes());
500 hasher.finalize().to_hex().to_string()
501}
502
503pub(super) fn native_residue_envelope(meta: &SessionMeta) -> Option<Value> {
507 if !meta.codex_provenance.is_empty() {
508 return Some(serde_json::json!({
509 "version": 2,
510 "source": "codex",
511 "records": &meta.codex_provenance,
512 "records_sha256": residue_records_sha256(&meta.codex_provenance),
513 }));
514 }
515 let source = meta.native_residue_source.as_deref()?;
516 (!meta.native_residue.is_empty()).then(|| {
517 serde_json::json!({
518 "version": 2,
519 "source": source,
520 "records": &meta.native_residue,
521 "records_sha256": residue_records_sha256(&meta.native_residue),
522 })
523 })
524}
525
526pub(super) fn restore_native_residue(extension: &Value, meta: &mut SessionMeta) -> Result<bool> {
531 match extension.get("version").and_then(Value::as_u64) {
532 Some(2) => {}
533 other => {
534 return Err(Error::InvalidSession(format!(
535 "invalid portable native residue: expected version 2, found {other:?} — source-native records cannot be restored from this envelope"
536 )));
537 }
538 }
539 let source = extension.get("source").and_then(Value::as_str);
540 if !matches!(source, Some("codex") | Some("claude_code") | Some("grok")) {
541 return Err(Error::InvalidSession(format!(
542 "invalid portable native residue: unsupported source {source:?} — this build restores codex, claude_code and grok residue; the records are preserved raw but not replayed"
543 )));
544 }
545 let Some(records) = extension.get("records").and_then(Value::as_array) else {
546 return Err(Error::InvalidSession(
547 "invalid portable native residue: `records` must be an array".to_string(),
548 ));
549 };
550 let Some(claimed) = extension.get("records_sha256").and_then(Value::as_str) else {
551 return Err(Error::InvalidSession(
552 "invalid portable native residue: `records_sha256` digest is missing — cannot verify the residue was not tampered with; refusing to restore"
553 .to_string(),
554 ));
555 };
556 let actual = residue_records_sha256(records);
557 if claimed != actual {
558 return Err(Error::InvalidSession(
559 "invalid portable native residue: records digest mismatch — the residue was modified or corrupted after export; refusing to restore source-native records"
560 .to_string(),
561 ));
562 }
563 if let Some(source) = source.filter(|source| *source != "codex") {
564 let mut restored = Vec::with_capacity(records.len());
567 for entry in records {
568 let (Some(_), Some(kind), Some(raw)) = (
569 entry.get("record_index").and_then(Value::as_u64),
570 entry.get("kind").and_then(Value::as_str),
571 entry.get("raw").and_then(Value::as_str),
572 ) else {
573 return Err(Error::InvalidSession(
574 "invalid portable native residue: each record needs record_index/kind/raw"
575 .to_string(),
576 ));
577 };
578 let Ok(record) = serde_json::from_str::<Value>(raw) else {
579 return Err(Error::InvalidSession(
580 "invalid portable native residue: raw is not valid JSON".to_string(),
581 ));
582 };
583 let matches = match source {
584 "claude_code" => claude_residue_kind(&record) == Some(kind),
585 "grok" => grok_residue_kind(&record).as_deref() == Some(kind),
586 _ => unreachable!("source whitelist checked above"),
587 };
588 if !matches {
589 return Err(Error::InvalidSession(format!(
590 "invalid portable native residue: kind `{kind}` does not match raw record"
591 )));
592 }
593 restored.push(entry.clone());
594 }
595 if restored.is_empty() {
596 return Err(Error::InvalidSession(
597 "invalid portable native residue: `records` must not be empty".to_string(),
598 ));
599 }
600 meta.native_residue = restored;
601 meta.native_residue_source = Some(source.to_string());
602 return Ok(true);
603 }
604 restore_codex_provenance(&serde_json::json!({"version": 1, "records": records}), meta)
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 #[test]
614 fn malformed_discriminated_native_turn_is_counted_as_parse_loss() {
615 let base = Session::from_native_messages(Vec::new());
616 let mut native = base.to_native_jsonl_v2(&[]);
617 native.push_str("{\"supercode_turn\":1}\n");
618
619 let parsed = Session::from_native_str(&native).unwrap();
620 assert_eq!(parsed.parse_error_lines, 1);
621 assert!(parsed.messages.is_empty());
622 assert_eq!(
623 parsed.raw.last().map(String::as_str),
624 Some("{\"supercode_turn\":1}")
625 );
626 }
627
628 #[test]
629 fn spliced_export_refuses_a_native_wrapper_with_parse_loss() {
630 let imported = Session::from_claude_code_str(
631 r#"{"type":"user","sessionId":"s","cwd":"/tmp","message":{"role":"user","content":"hi"}}"#,
632 )
633 .unwrap();
634 let mut native = imported.to_native_jsonl_v2(&[ChatMessage::assistant("continued")]);
635 native.push_str("{\"supercode_turn\":1}\n");
636
637 let parsed = Session::from_native_str(&native).unwrap();
638 let error = parsed
639 .to_jsonl_spliced(SessionFormat::ClaudeCode, None)
640 .unwrap_err();
641 assert!(error.to_string().contains("parse loss"), "{error}");
642 }
643
644 #[test]
645 fn sidecar_loader_requires_a_supported_native_header() {
646 for malformed in [
647 "",
648 "not-json\n",
649 "{}\n",
650 "{\"supercode_native\":2}\n",
651 "{\"supercode_native\":99,\"source\":\"native\"}\n",
652 ] {
653 let error = Session::from_sidecar_str(malformed).unwrap_err();
654 assert!(error.to_string().contains("sidecar header"), "{error}");
655 }
656 }
657}