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.contains("\"user\"") {
336 return false;
337 }
338 let Ok(value) = serde_json::from_str::<Value>(line) else {
339 return false;
340 };
341 match source {
342 Some(SessionSource::Codex) => {
343 value.get("type").and_then(Value::as_str) == Some("response_item")
344 && value
345 .get("payload")
346 .and_then(|payload| payload.get("type"))
347 .and_then(Value::as_str)
348 == Some("message")
349 && value
350 .get("payload")
351 .and_then(|payload| payload.get("role"))
352 .and_then(Value::as_str)
353 == Some("user")
354 }
355 Some(SessionSource::ClaudeCode) => {
356 value.get("type").and_then(Value::as_str) == Some("user")
357 && value
358 .get("message")
359 .and_then(|message| message.get("content"))
360 .is_some_and(|content| match content {
361 Value::String(text) => !text.trim().is_empty(),
362 Value::Array(parts) => parts.iter().any(|part| {
363 part.get("type").and_then(Value::as_str) == Some("text")
364 && part
365 .get("text")
366 .and_then(Value::as_str)
367 .is_some_and(|text| !text.trim().is_empty())
368 }),
369 _ => false,
370 })
371 }
372 Some(SessionSource::Gemini) => {
373 value.get("type").and_then(Value::as_str) == Some("user")
374 && value.get("content").is_some_and(|content| match content {
375 Value::String(text) => !text.trim().is_empty(),
376 Value::Array(parts) => parts.iter().any(|part| {
377 part.get("text")
378 .and_then(Value::as_str)
379 .is_some_and(|text| !text.trim().is_empty())
380 }),
381 _ => false,
382 })
383 }
384 _ => false,
385 }
386}
387
388pub(super) fn capture_native_residue(
389 meta: &mut SessionMeta,
390 source: &str,
391 record_index: usize,
392 raw_line: &str,
393 record: &Value,
394 kind: &str,
395) {
396 if record.get(SUPERCODE_NATIVE_RESIDUE_KEY).is_some()
398 || record.get(SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY).is_some()
399 || record.get(SUPERCODE_CODEX_PROVENANCE_KEY).is_some()
400 {
401 return;
402 }
403 if meta
407 .native_residue_source
408 .as_deref()
409 .is_some_and(|existing| existing != source)
410 {
411 return;
412 }
413 meta.native_residue.push(serde_json::json!({
414 "record_index": record_index,
415 "kind": kind,
416 "raw": raw_line,
417 }));
418 meta.native_residue_source = Some(source.to_string());
419}
420
421pub(super) const SUPERCODE_NATIVE_RESIDUE_KEY: &str = "_supercode_native_residue";
425
426pub(super) const SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY: &str = "_supercode_native_residue_summary";
433
434pub(super) fn native_residue_summary(envelope: &Value) -> Value {
435 let records = envelope
436 .get("records")
437 .and_then(Value::as_array)
438 .cloned()
439 .unwrap_or_default();
440 let mut kinds: Vec<String> = records
441 .iter()
442 .filter_map(|entry| entry.get("kind").and_then(Value::as_str))
443 .map(str::to_string)
444 .collect();
445 kinds.sort();
446 kinds.dedup();
447 serde_json::json!({
448 "version": 2,
449 "source": envelope.get("source").cloned().unwrap_or(Value::Null),
450 "kinds": kinds,
451 "records": records.len(),
452 "records_sha256": envelope.get("records_sha256").cloned().unwrap_or(Value::Null),
453 })
454}
455
456fn residue_records_sha256(records: &[Value]) -> String {
460 fn canonical(value: &Value, out: &mut String) {
461 match value {
462 Value::Array(items) => {
463 out.push('[');
464 for (index, item) in items.iter().enumerate() {
465 if index > 0 {
466 out.push(',');
467 }
468 canonical(item, out);
469 }
470 out.push(']');
471 }
472 Value::Object(map) => {
473 out.push('{');
474 let mut keys: Vec<&String> = map.keys().collect();
475 keys.sort();
476 for (index, key) in keys.iter().enumerate() {
477 if index > 0 {
478 out.push(',');
479 }
480 out.push_str(&serde_json::to_string(key).unwrap_or_default());
481 out.push(':');
482 canonical(&map[*key], out);
483 }
484 out.push('}');
485 }
486 other => out.push_str(&other.to_string()),
487 }
488 }
489 let mut text = String::from("[");
490 for (index, record) in records.iter().enumerate() {
491 if index > 0 {
492 text.push(',');
493 }
494 canonical(record, &mut text);
495 }
496 text.push(']');
497 let mut hasher = blake3::Hasher::new();
498 hasher.update(text.as_bytes());
499 hasher.finalize().to_hex().to_string()
500}
501
502pub(super) fn native_residue_envelope(meta: &SessionMeta) -> Option<Value> {
506 if !meta.codex_provenance.is_empty() {
507 return Some(serde_json::json!({
508 "version": 2,
509 "source": "codex",
510 "records": &meta.codex_provenance,
511 "records_sha256": residue_records_sha256(&meta.codex_provenance),
512 }));
513 }
514 let source = meta.native_residue_source.as_deref()?;
515 (!meta.native_residue.is_empty()).then(|| {
516 serde_json::json!({
517 "version": 2,
518 "source": source,
519 "records": &meta.native_residue,
520 "records_sha256": residue_records_sha256(&meta.native_residue),
521 })
522 })
523}
524
525pub(super) fn restore_native_residue(extension: &Value, meta: &mut SessionMeta) -> Result<bool> {
530 match extension.get("version").and_then(Value::as_u64) {
531 Some(2) => {}
532 other => {
533 return Err(Error::InvalidSession(format!(
534 "invalid portable native residue: expected version 2, found {other:?} — source-native records cannot be restored from this envelope"
535 )));
536 }
537 }
538 let source = extension.get("source").and_then(Value::as_str);
539 if !matches!(source, Some("codex") | Some("claude_code") | Some("grok")) {
540 return Err(Error::InvalidSession(format!(
541 "invalid portable native residue: unsupported source {source:?} — this build restores codex, claude_code and grok residue; the records are preserved raw but not replayed"
542 )));
543 }
544 let Some(records) = extension.get("records").and_then(Value::as_array) else {
545 return Err(Error::InvalidSession(
546 "invalid portable native residue: `records` must be an array".to_string(),
547 ));
548 };
549 let Some(claimed) = extension.get("records_sha256").and_then(Value::as_str) else {
550 return Err(Error::InvalidSession(
551 "invalid portable native residue: `records_sha256` digest is missing — cannot verify the residue was not tampered with; refusing to restore"
552 .to_string(),
553 ));
554 };
555 let actual = residue_records_sha256(records);
556 if claimed != actual {
557 return Err(Error::InvalidSession(
558 "invalid portable native residue: records digest mismatch — the residue was modified or corrupted after export; refusing to restore source-native records"
559 .to_string(),
560 ));
561 }
562 if let Some(source) = source.filter(|source| *source != "codex") {
563 let mut restored = Vec::with_capacity(records.len());
566 for entry in records {
567 let (Some(_), Some(kind), Some(raw)) = (
568 entry.get("record_index").and_then(Value::as_u64),
569 entry.get("kind").and_then(Value::as_str),
570 entry.get("raw").and_then(Value::as_str),
571 ) else {
572 return Err(Error::InvalidSession(
573 "invalid portable native residue: each record needs record_index/kind/raw"
574 .to_string(),
575 ));
576 };
577 let Ok(record) = serde_json::from_str::<Value>(raw) else {
578 return Err(Error::InvalidSession(
579 "invalid portable native residue: raw is not valid JSON".to_string(),
580 ));
581 };
582 let matches = match source {
583 "claude_code" => claude_residue_kind(&record) == Some(kind),
584 "grok" => grok_residue_kind(&record).as_deref() == Some(kind),
585 _ => unreachable!("source whitelist checked above"),
586 };
587 if !matches {
588 return Err(Error::InvalidSession(format!(
589 "invalid portable native residue: kind `{kind}` does not match raw record"
590 )));
591 }
592 restored.push(entry.clone());
593 }
594 if restored.is_empty() {
595 return Err(Error::InvalidSession(
596 "invalid portable native residue: `records` must not be empty".to_string(),
597 ));
598 }
599 meta.native_residue = restored;
600 meta.native_residue_source = Some(source.to_string());
601 return Ok(true);
602 }
603 restore_codex_provenance(&serde_json::json!({"version": 1, "records": records}), meta)
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 #[test]
613 fn malformed_discriminated_native_turn_is_counted_as_parse_loss() {
614 let base = Session::from_native_messages(Vec::new());
615 let mut native = base.to_native_jsonl_v2(&[]);
616 native.push_str("{\"supercode_turn\":1}\n");
617
618 let parsed = Session::from_native_str(&native).unwrap();
619 assert_eq!(parsed.parse_error_lines, 1);
620 assert!(parsed.messages.is_empty());
621 assert_eq!(
622 parsed.raw.last().map(String::as_str),
623 Some("{\"supercode_turn\":1}")
624 );
625 }
626
627 #[test]
628 fn spliced_export_refuses_a_native_wrapper_with_parse_loss() {
629 let imported = Session::from_claude_code_str(
630 r#"{"type":"user","sessionId":"s","cwd":"/tmp","message":{"role":"user","content":"hi"}}"#,
631 )
632 .unwrap();
633 let mut native = imported.to_native_jsonl_v2(&[ChatMessage::assistant("continued")]);
634 native.push_str("{\"supercode_turn\":1}\n");
635
636 let parsed = Session::from_native_str(&native).unwrap();
637 let error = parsed
638 .to_jsonl_spliced(SessionFormat::ClaudeCode, None)
639 .unwrap_err();
640 assert!(error.to_string().contains("parse loss"), "{error}");
641 }
642
643 #[test]
644 fn sidecar_loader_requires_a_supported_native_header() {
645 for malformed in [
646 "",
647 "not-json\n",
648 "{}\n",
649 "{\"supercode_native\":2}\n",
650 "{\"supercode_native\":99,\"source\":\"native\"}\n",
651 ] {
652 let error = Session::from_sidecar_str(malformed).unwrap_err();
653 assert!(error.to_string().contains("sidecar header"), "{error}");
654 }
655 }
656}