1use std::{
10 collections::{HashMap, HashSet},
11 hash::{Hash, Hasher},
12 path::{Path, PathBuf},
13};
14
15use anyhow::Context as _;
16use chrono::{DateTime, SecondsFormat, Utc};
17use serde_json::{Value, json};
18
19use crate::{
20 sessions::IngestEvent,
21 wire::{FileData, Message, Part, PartKind, Provenance, ProviderOptions, Session},
22};
23
24use super::{
25 Adapter, AdapterError, AdapterFactory, AdapterYieldStream, DiscoverFuture, Env,
26 RestoreFidelity, RestoredFile, SkipOracle, by_timestamp_then_id, compact_json, config_path,
27 empty_options,
28 extract::{
29 Extracted, Source, extract_compact_repr, extract_raw_record, extract_self_str, extract_str,
30 },
31 extracted_text,
32 jsonl::{
33 BoundedRow, JsonlTree, TAIL_CAP, jsonl_tree_discover, jsonl_tree_events, peek_last_mapped,
34 source_line,
35 },
36 jsonl_bytes, part_id, part_ordinal, raw_record,
37};
38
39#[derive(Debug, Default)]
61pub(crate) struct FileState {
62 seen_records: HashSet<(String, u64)>,
63 tool_call_names: HashMap<String, Extracted<String>>,
64}
65
66const NAME: &str = "claude-code";
70
71pub struct ClaudeCodeFactory;
74
75impl AdapterFactory for ClaudeCodeFactory {
76 fn name(&self) -> &'static str {
77 NAME
78 }
79
80 fn open(&self, config: Value) -> Result<Box<dyn Adapter>, AdapterError> {
81 Ok(Box::new(ClaudeCodeAdapter::new(config_path(NAME, config)?)))
82 }
83
84 fn probe_default(&self, env: &Env) -> Option<Value> {
85 let path = env.home.join(".claude").join("projects");
86 path.exists().then(|| json!({ "path": path }))
87 }
88
89 fn serialize(
90 &self,
91 session: &crate::sessions::SessionWithMessages,
92 fidelity: RestoreFidelity,
93 ) -> Result<Vec<RestoredFile>, AdapterError> {
94 serialize_session(session, fidelity)
95 }
96}
97
98fn serialize_session(
99 session: &crate::sessions::SessionWithMessages,
100 fidelity: RestoreFidelity,
101) -> Result<Vec<RestoredFile>, AdapterError> {
102 claude_serialize(NAME, session, fidelity, claude_relative_path(session))
103}
104
105pub(crate) fn claude_serialize(
112 adapter: &'static str,
113 session: &crate::sessions::SessionWithMessages,
114 fidelity: RestoreFidelity,
115 transcript_path: PathBuf,
116) -> Result<Vec<RestoredFile>, AdapterError> {
117 let mut messages = session.messages.clone();
118 if fidelity == RestoreFidelity::Native {
119 messages.sort_by(|left, right| {
120 source_line(left.message.options())
121 .cmp(&source_line(right.message.options()))
122 .then_with(|| by_timestamp_then_id(left, right))
123 });
124 } else {
125 messages.sort_by(by_timestamp_then_id);
126 }
127 let mut records = Vec::with_capacity(messages.len());
131 let mut parent_uuid = None::<String>;
132 for message in &messages {
133 if fidelity == RestoreFidelity::Native
134 && let Some(raw) = raw_record(message.message.options())
135 {
136 parent_uuid = raw
137 .get("uuid")
138 .and_then(Value::as_str)
139 .map(ToOwned::to_owned)
140 .or(parent_uuid);
141 records.push(raw);
142 continue;
143 }
144 let Some(record) = claude_record(session, message, parent_uuid.as_deref()) else {
147 continue;
148 };
149 parent_uuid = record
150 .get("uuid")
151 .and_then(Value::as_str)
152 .map(ToOwned::to_owned);
153 records.push(record);
154 }
155
156 let mut files = vec![RestoredFile::new(
157 transcript_path,
158 jsonl_bytes(adapter, &records)?,
159 fidelity,
160 )];
161 if session.session.parent_session_id.is_some()
162 && let Some(meta) = subagent_meta_record(session)
163 {
164 let mut meta_path = files[0].relative_path.clone();
165 meta_path.set_extension("meta.json");
166 files.push(RestoredFile::new(
167 meta_path,
168 serde_json::to_vec(&meta).map_err(|err| {
169 AdapterError::schema(
170 adapter,
171 &session.session.id,
172 format!("json encode failed: {err}"),
173 )
174 })?,
175 fidelity,
176 ));
177 }
178 Ok(files)
179}
180
181fn claude_relative_path(session: &crate::sessions::SessionWithMessages) -> PathBuf {
182 let encoded_project = session
183 .session
184 .options
185 .get("source")
186 .and_then(|source| source.get("project_dir"))
187 .and_then(Value::as_str)
188 .map(ToOwned::to_owned)
189 .unwrap_or_else(|| encode_project(&session.session.project));
190 if let Some(parent) = &session.session.parent_session_id {
191 let child_suffix = session
196 .session
197 .id
198 .strip_prefix(&format!("{parent}/"))
199 .unwrap_or(&session.session.id);
200 return PathBuf::from(encoded_project)
201 .join(parent)
202 .join("subagents")
203 .join(format!("{child_suffix}.jsonl"));
204 }
205 PathBuf::from(encoded_project).join(format!("{}.jsonl", session.session.id))
206}
207
208fn encode_project(project: &str) -> String {
209 project.replace(['/', '.'], "-")
210}
211
212pub(crate) fn map_row_events(
217 session_id: &str,
218 created_at: DateTime<Utc>,
219 row: &BoundedRow,
220 state: &mut FileState,
221) -> Result<Vec<IngestEvent>, String> {
222 if let Some(uuid) = row.value.get("uuid").and_then(Value::as_str)
223 && !state
224 .seen_records
225 .insert((uuid.to_owned(), source_record_hash(&row.value)))
226 {
227 return Ok(Vec::new());
228 }
229 capture_tool_call_names(&row.value, &mut state.tool_call_names);
230 events_from_row(session_id, row.line, &row.value, created_at, state)
231}
232
233pub(crate) fn claude_peek_watermark(path: &Path) -> crate::adapter::SourceWatermark {
238 if let Some(ts) = peek_last_mapped(path, |line| {
246 let row: Value = serde_json::from_str(line).ok()?;
247 Some(parse_timestamp(&row).ok()?.timestamp_micros())
248 }) {
249 return crate::adapter::SourceWatermark::At(ts);
250 }
251 match std::fs::metadata(path) {
255 Ok(meta) if meta.len() <= TAIL_CAP => crate::adapter::SourceWatermark::Empty,
256 _ => crate::adapter::SourceWatermark::Opaque,
257 }
258}
259
260pub(crate) fn subagent_meta_record(
261 session: &crate::sessions::SessionWithMessages,
262) -> Option<Value> {
263 let meta = session.session.options.get("subagent")?.get("meta")?;
267 meta.is_object().then(|| meta.clone())
268}
269
270fn claude_record(
271 session: &crate::sessions::SessionWithMessages,
272 message: &crate::sessions::MessageWithParts,
273 parent_uuid: Option<&str>,
274) -> Option<Value> {
275 let row_role = match &message.message {
283 Message::System { .. } => return None,
284 Message::User { .. } | Message::Tool { .. } => "user",
285 Message::Assistant { .. } => "assistant",
286 };
287 let mut envelope = serde_json::Map::new();
288 envelope.insert("role".to_owned(), Value::String(row_role.to_owned()));
289 if row_role == "assistant" {
290 envelope.insert("type".to_owned(), Value::String("message".to_owned()));
293 }
294 envelope.insert(
295 "content".to_owned(),
296 Value::Array(message.parts.iter().map(claude_part).collect()),
297 );
298 Some(json!({
299 "parentUuid": parent_uuid,
300 "isSidechain": false,
301 "userType": "external",
302 "cwd": &*session.session.project,
303 "sessionId": &session.session.id,
304 "type": row_role,
305 "message": Value::Object(envelope),
306 "uuid": message.message.id(),
307 "timestamp": message.message.timestamp().to_rfc3339_opts(SecondsFormat::Millis, true),
308 }))
309}
310
311fn claude_part(part: &Part) -> Value {
312 match &part.kind {
313 PartKind::Text { text } => json!({"type": "text", "text": extracted_text(text)}),
314 PartKind::Reasoning { text } => {
315 json!({"type": "thinking", "thinking": extracted_text(text)})
316 }
317 PartKind::ToolCall {
318 call_id,
319 name,
320 params,
321 provider_executed,
322 } => json!({
323 "type": if *provider_executed { "server_tool_use" } else { "tool_use" },
324 "id": extracted_text(call_id),
325 "name": extracted_text(name),
326 "input": params,
327 }),
328 PartKind::ToolResult {
329 call_id,
330 is_failure,
331 result,
332 ..
333 } => json!({
334 "type": "tool_result",
335 "tool_use_id": extracted_text(call_id),
336 "is_error": is_failure,
337 "content": result,
338 }),
339 PartKind::File {
340 media_type,
341 file_name,
342 data,
343 } => json!({
344 "type": "file",
345 "media_type": media_type,
346 "file_name": file_name,
347 "source": file_source(data),
348 }),
349 other => {
350 json!({"type": "text", "text": compact_json(&serde_json::to_value(other).unwrap_or(Value::Null))})
351 }
352 }
353}
354
355fn file_source(data: &FileData) -> Value {
356 match data {
357 FileData::String(value) => json!({"type": "text", "data": value}),
358 FileData::Bytes(value) => json!({"type": "base64", "data": value}),
359 FileData::Url(value) => json!({"type": "url", "url": value}),
360 }
361}
362
363#[derive(Debug, Clone)]
366pub struct ClaudeCodeAdapter {
367 root: PathBuf,
368}
369
370impl ClaudeCodeAdapter {
371 pub fn new(root: impl Into<PathBuf>) -> Self {
372 Self { root: root.into() }
373 }
374}
375
376impl Adapter for ClaudeCodeAdapter {
377 fn discover(&self) -> DiscoverFuture<'_> {
378 jsonl_tree_discover(self)
379 }
380
381 fn events_with<'a>(&'a self, oracle: &'a dyn SkipOracle) -> AdapterYieldStream<'a> {
382 jsonl_tree_events(self, oracle)
383 }
384
385 fn plan<'a>(&'a self, oracle: &'a dyn SkipOracle) -> crate::adapter::PlanFuture<'a> {
386 crate::adapter::jsonl::jsonl_tree_plan(self, oracle)
387 }
388}
389
390impl JsonlTree for ClaudeCodeAdapter {
391 type State = FileState;
392
393 fn name(&self) -> &'static str {
394 NAME
395 }
396
397 fn root(&self) -> &Path {
398 &self.root
399 }
400
401 fn peek_session_id(&self, path: &Path, first_line: &str) -> Option<String> {
402 if subagents_dir(path).is_some() {
409 let (parent_uuid, child_suffix, _) = subagent_ids(path)?;
410 return Some(format!("{parent_uuid}/{child_suffix}"));
411 }
412 let row: Value = serde_json::from_str(first_line).ok()?;
413 row.get("sessionId")?.as_str().map(ToOwned::to_owned)
414 }
415
416 fn peek_watermark(&self, path: &Path) -> crate::adapter::SourceWatermark {
417 claude_peek_watermark(path)
418 }
419
420 fn session(&self, path: &Path, rows: &[BoundedRow]) -> Result<Session, AdapterError> {
421 session_from_rows(path, rows)
422 }
423
424 fn events_from_row(
425 &self,
426 session: &Session,
427 row: &BoundedRow,
428 state: &mut Self::State,
429 ) -> Result<Vec<IngestEvent>, String> {
430 map_row_events(&session.id, session.created_at, row, state)
431 }
432
433 fn unsupported_reason(&self, path: &Path) -> Option<String> {
434 subagent_unsupported_reason(path)
438 }
439
440 fn skip_source(&self, path: &Path) -> bool {
441 is_workflow_control_file(path)
442 }
443}
444
445fn source_record_hash(value: &Value) -> u64 {
449 let mut hasher = std::collections::hash_map::DefaultHasher::new();
450 let pick = |path: &[&str]| -> &Value {
451 let mut cur = value;
452 for key in path {
453 match cur.get(*key) {
454 Some(next) => cur = next,
455 None => return &Value::Null,
456 }
457 }
458 cur
459 };
460 for path in [
461 &["type"][..],
462 &["parentUuid"][..],
463 &["message", "role"][..],
464 &["message", "content"][..],
465 &["toolUseResult"][..],
466 ] {
467 compact_json(pick(path)).hash(&mut hasher);
468 }
469 hasher.finish()
470}
471
472pub(crate) fn is_workflow_control_file(path: &Path) -> bool {
481 subagents_dir(path).is_some()
482 && path.file_name().and_then(|n| n.to_str()) == Some("journal.jsonl")
483}
484
485pub(crate) fn subagent_unsupported_reason(path: &Path) -> Option<String> {
492 if subagents_dir(path).is_some() && subagent_ids(path).is_none() {
493 return Some(format!(
494 "{}: subagent transcript layout not recognized by this pond version; \
495 skipped so it is not merged into the parent session - update pond and \
496 re-run `pond sync`",
497 path.display()
498 ));
499 }
500 None
501}
502
503pub(crate) fn unresolved_subagent_error(
506 adapter: &'static str,
507 path: &Path,
508) -> Option<AdapterError> {
509 if subagents_dir(path).is_some() && subagent_ids(path).is_none() {
510 return Some(AdapterError::schema(
511 adapter,
512 path.display().to_string(),
513 "sidecar/control file under subagents/ has no session of its own",
514 ));
515 }
516 None
517}
518
519fn capture_tool_call_names(row: &Value, map: &mut HashMap<String, Extracted<String>>) {
524 let Some(items) = row
525 .get("message")
526 .and_then(|message| message.get("content"))
527 .and_then(Value::as_array)
528 else {
529 return;
530 };
531 for item in items {
532 let kind = item.get("type").and_then(Value::as_str);
533 if !matches!(kind, Some("tool_use") | Some("server_tool_use")) {
534 continue;
535 }
536 let (Some(id), Some(name)) = (item.str_field("id"), extract_str(item, "name")) else {
537 continue;
538 };
539 map.insert(id.to_owned(), name);
540 }
541}
542
543fn session_from_rows(path: &Path, rows: &[BoundedRow]) -> Result<Session, AdapterError> {
544 let path_display = path.display().to_string();
545 if let Some(error) = unresolved_subagent_error(NAME, path) {
550 return Err(error);
551 }
552 let mut created_at = None;
553 let mut project: Option<Extracted<String>> = None;
554 let mut version = None;
555 for row in rows {
556 if created_at.is_none() {
557 created_at = parse_timestamp(&row.value).ok();
558 }
559 if project.is_none() {
560 project = extract_str(&row.value, "cwd");
561 }
562 if version.is_none() {
563 version = row
564 .value
565 .get("version")
566 .and_then(Value::as_str)
567 .map(ToOwned::to_owned);
568 }
569 }
570
571 let first = rows
572 .first()
573 .ok_or_else(|| AdapterError::schema(NAME, path_display.clone(), "empty jsonl session"))?;
574 let at_first = format!("{path_display}:{}", first.line);
575 let raw_session_id = rows
583 .iter()
584 .find_map(|row| row.value.get("sessionId").and_then(Value::as_str))
585 .map(ToOwned::to_owned);
586 let created_at = created_at.ok_or_else(|| {
587 AdapterError::schema(NAME, at_first.clone(), "session has no parseable timestamp")
588 })?;
589
590 let subagent = subagent_descriptor(path);
603 let project_dir = source_project_dir(path, subagent.is_some());
604 let (session_id, parent_session_id, source_agent, subagent_options) = match subagent {
605 Some(SubagentDescriptor {
606 parent_uuid,
607 child_suffix,
608 agent_hash,
609 agent_type,
610 meta,
611 }) => {
612 let child_id = format!("{parent_uuid}/{child_suffix}");
613 let agent_label = agent_type
614 .as_deref()
615 .map(|t| format!("claude-code/{t}"))
616 .unwrap_or_else(|| "claude-code/subagent".to_owned());
617 let metadata = json!({
621 "hash": agent_hash,
622 "raw_session_id": raw_session_id,
623 "meta": meta,
624 });
625 (child_id, Some(parent_uuid), agent_label, Some(metadata))
626 }
627 None => {
628 let id = raw_session_id.ok_or_else(|| {
631 AdapterError::schema(
632 NAME,
633 at_first,
634 format!("line {} missing sessionId", first.line),
635 )
636 })?;
637 (id, None, "claude-code".to_owned(), None)
638 }
639 };
640
641 let project = match project {
642 Some(value) => value,
643 None => {
644 let decoded = path
645 .parent()
646 .and_then(|p| p.file_name())
647 .and_then(|n| n.to_str())
648 .map(|s| s.replace('-', "/"))
649 .ok_or_else(|| {
650 AdapterError::schema(
651 NAME,
652 path_display.clone(),
653 "no `cwd` field in any row and source path is not UTF-8",
654 )
655 })?;
656 extract_self_str(&Value::String(decoded)).ok_or_else(|| {
657 AdapterError::schema(
658 NAME,
659 path_display.clone(),
660 "internal: Value::String produced None from Source::as_str",
661 )
662 })?
663 }
664 };
665
666 let mut options = ProviderOptions::new();
667 options.insert(
668 "source".to_owned(),
669 json!({
670 "adapter": "claude-code",
671 "version": version,
672 "project_dir": project_dir,
673 "workspace_path": &*project,
674 }),
675 );
676 if let Some(metadata) = subagent_options {
677 options.insert("subagent".to_owned(), metadata);
678 }
679
680 Ok(Session {
681 id: session_id,
682 parent_session_id,
683 parent_message_id: None,
684 source_agent,
685 created_at,
686 project,
687 options,
688 })
689}
690
691pub(crate) fn source_project_dir(path: &Path, is_subagent: bool) -> Option<String> {
692 let project_dir = if is_subagent {
697 subagents_dir(path)?.parent()?.parent()
698 } else {
699 path.parent()
700 };
701 project_dir
702 .and_then(|p| p.file_name())
703 .and_then(|n| n.to_str())
704 .map(ToOwned::to_owned)
705}
706
707pub(crate) fn subagents_dir(path: &Path) -> Option<&Path> {
712 let mut cur = path.parent();
713 while let Some(dir) = cur {
714 if dir.file_name().and_then(|n| n.to_str()) == Some("subagents") {
715 return Some(dir);
716 }
717 cur = dir.parent();
718 }
719 None
720}
721
722pub(crate) struct SubagentDescriptor {
728 pub(crate) parent_uuid: String,
729 pub(crate) child_suffix: String,
730 pub(crate) agent_hash: String,
731 pub(crate) agent_type: Option<String>,
732 pub(crate) meta: Option<Value>,
733}
734
735pub(crate) fn subagent_ids(path: &Path) -> Option<(String, String, String)> {
743 let file_name = path.file_name()?.to_str()?;
744 let agent_hash = file_name
745 .strip_prefix("agent-")?
746 .strip_suffix(".jsonl")?
747 .to_owned();
748 let subagents = subagents_dir(path)?;
749 let parent_uuid = subagents.parent()?.file_name()?.to_str()?.to_owned();
750 let child_suffix = path
754 .strip_prefix(subagents)
755 .ok()?
756 .with_extension("")
757 .to_str()?
758 .replace(std::path::MAIN_SEPARATOR, "/");
759 Some((parent_uuid, child_suffix, agent_hash))
760}
761
762pub(crate) fn subagent_descriptor(path: &Path) -> Option<SubagentDescriptor> {
765 let (parent_uuid, child_suffix, agent_hash) = subagent_ids(path)?;
766 let meta_path = path.parent()?.join(format!("agent-{agent_hash}.meta.json"));
767 let (agent_type, meta) = match std::fs::read(&meta_path) {
768 Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
769 Ok(value) => (
770 value
771 .get("agentType")
772 .and_then(Value::as_str)
773 .map(ToOwned::to_owned),
774 Some(value),
775 ),
776 Err(error) => {
777 tracing::debug!(
778 target: "pond::adapter::claude_code",
779 meta = %meta_path.display(),
780 %error,
781 "subagent .meta.json present but unparseable; falling back to 'claude-code/subagent'",
782 );
783 (None, None)
784 }
785 },
786 Err(error) if error.kind() == std::io::ErrorKind::NotFound => (None, None),
787 Err(error) => {
788 tracing::debug!(
789 target: "pond::adapter::claude_code",
790 meta = %meta_path.display(),
791 %error,
792 "subagent .meta.json IO error; falling back to 'claude-code/subagent'",
793 );
794 (None, None)
795 }
796 };
797
798 Some(SubagentDescriptor {
799 parent_uuid,
800 child_suffix,
801 agent_hash,
802 agent_type,
803 meta,
804 })
805}
806
807fn events_from_row(
808 session_id: &str,
809 line: usize,
810 row: &Value,
811 default_timestamp: DateTime<Utc>,
812 state: &FileState,
813) -> Result<Vec<IngestEvent>, String> {
814 let timestamp = parse_timestamp(row).unwrap_or(default_timestamp);
815 let uuid = row
816 .get("uuid")
817 .and_then(Value::as_str)
818 .map_or_else(|| format!("{session_id}:{line}"), ToOwned::to_owned);
819
820 if let Some(message_value) = row.get("message") {
821 return message_events(
822 session_id,
823 &uuid,
824 timestamp,
825 row,
826 message_value,
827 state,
828 line,
829 );
830 }
831
832 let raw_type = row.get("type").and_then(Value::as_str);
839 let content = if raw_type == Some("attachment") {
840 row.get("attachment")
841 .and_then(attachment_content)
842 .or_else(|| Some(extract_compact_repr(row)))
843 } else {
844 extract_str(row, "subtype").or_else(|| extract_str(row, "type"))
845 };
846 let message = Message::System {
847 id: uuid,
848 session_id: session_id.to_owned(),
849 timestamp,
850 content,
851 options: row_options(row, line),
852 };
853 Ok(vec![IngestEvent::Message(message)])
854}
855
856fn message_events(
857 session_id: &str,
858 uuid: &str,
859 timestamp: DateTime<Utc>,
860 row: &Value,
861 message_value: &Value,
862 state: &FileState,
863 line: usize,
864) -> Result<Vec<IngestEvent>, String> {
865 let role = message_value
866 .get("role")
867 .and_then(Value::as_str)
868 .ok_or_else(|| "message missing role".to_owned())?;
869 let content = message_value.get("content").unwrap_or(&Value::Null);
870 let mut parts = Vec::new();
871 let message = match (role, content) {
872 ("user", Value::String(text)) => {
873 let provenance = user_text_provenance(row, text);
877 parts.push(text_part(
878 session_id,
879 uuid,
880 0,
881 extract_self_str(content),
882 provenance,
883 ));
884 Message::User {
885 id: uuid.to_owned(),
886 session_id: session_id.to_owned(),
887 timestamp,
888 options: row_options(row, line),
889 }
890 }
891 ("user", Value::Array(items)) if items.iter().all(is_tool_result) => {
892 let source_tool_result = row.get("toolUseResult").cloned();
893 parts.extend(items.iter().enumerate().map(|(ordinal, item)| {
894 tool_result_part(
895 session_id,
896 uuid,
897 ordinal,
898 item,
899 source_tool_result.as_ref(),
900 state,
901 )
902 }));
903 Message::Tool {
904 id: uuid.to_owned(),
905 session_id: session_id.to_owned(),
906 timestamp,
907 options: row_options(row, line),
908 }
909 }
910 ("user", Value::Array(items)) => {
911 let provenance = user_array_provenance(row, items);
914 parts.extend(items.iter().enumerate().map(|(ordinal, item)| {
915 user_part(session_id, uuid, ordinal, item, state, provenance)
916 }));
917 Message::User {
918 id: uuid.to_owned(),
919 session_id: session_id.to_owned(),
920 timestamp,
921 options: row_options(row, line),
922 }
923 }
924 ("assistant", Value::Array(items)) => {
925 parts.extend(
926 items
927 .iter()
928 .enumerate()
929 .map(|(ordinal, item)| assistant_part(session_id, uuid, ordinal, item)),
930 );
931 Message::Assistant {
932 id: uuid.to_owned(),
933 session_id: session_id.to_owned(),
934 timestamp,
935 options: assistant_options(row, message_value, line),
936 }
937 }
938 ("system", Value::String(_)) => Message::System {
939 id: uuid.to_owned(),
940 session_id: session_id.to_owned(),
941 timestamp,
942 content: extract_self_str(content),
943 options: row_options(row, line),
944 },
945 ("system", _) => Message::System {
946 id: uuid.to_owned(),
947 session_id: session_id.to_owned(),
948 timestamp,
949 content: Some(extract_compact_repr(message_value)),
954 options: row_options(row, line),
955 },
956 _ => Message::System {
960 id: uuid.to_owned(),
961 session_id: session_id.to_owned(),
962 timestamp,
963 content: Some(extract_compact_repr(message_value)),
964 options: row_options(row, line),
965 },
966 };
967
968 let mut events = Vec::with_capacity(parts.len() + 1);
969 events.push(IngestEvent::Message(message));
970 events.extend(parts.into_iter().map(IngestEvent::Part));
971 Ok(events)
972}
973
974fn text_part(
975 session_id: &str,
976 message_id: &str,
977 ordinal: usize,
978 text: Option<Extracted<String>>,
979 provenance: Provenance,
980) -> Part {
981 Part {
982 session_id: session_id.to_owned(),
983 id: part_id(message_id, ordinal),
984 message_id: message_id.to_owned(),
985 ordinal: part_ordinal(ordinal),
986 provenance,
987 options: empty_options(),
988 kind: PartKind::Text { text },
989 }
990}
991
992fn user_part(
993 session_id: &str,
994 message_id: &str,
995 ordinal: usize,
996 value: &Value,
997 state: &FileState,
998 provenance: Provenance,
999) -> Part {
1000 match value.get("type").and_then(Value::as_str) {
1001 Some("text") => text_part(
1002 session_id,
1003 message_id,
1004 ordinal,
1005 extract_str(value, "text"),
1006 provenance,
1007 ),
1008 Some("image") | Some("file") => {
1009 file_part(session_id, message_id, ordinal, value, provenance)
1010 }
1011 Some("tool_result") => {
1012 tool_result_part(session_id, message_id, ordinal, value, None, state)
1013 }
1014 _ => text_part(
1018 session_id,
1019 message_id,
1020 ordinal,
1021 Some(extract_compact_repr(value)),
1022 provenance,
1023 ),
1024 }
1025}
1026
1027fn assistant_part(session_id: &str, message_id: &str, ordinal: usize, value: &Value) -> Part {
1028 match value.get("type").and_then(Value::as_str) {
1032 Some("text") => text_part(
1033 session_id,
1034 message_id,
1035 ordinal,
1036 extract_str(value, "text"),
1037 Provenance::Conversational,
1038 ),
1039 Some("thinking") => Part {
1040 session_id: session_id.to_owned(),
1041 id: part_id(message_id, ordinal),
1042 message_id: message_id.to_owned(),
1043 ordinal: part_ordinal(ordinal),
1044 provenance: Provenance::Conversational,
1045 options: signature_options(value),
1046 kind: PartKind::Reasoning {
1047 text: extract_str(value, "thinking"),
1048 },
1049 },
1050 Some("tool_use") => Part {
1051 session_id: session_id.to_owned(),
1052 id: part_id(message_id, ordinal),
1053 message_id: message_id.to_owned(),
1054 ordinal: part_ordinal(ordinal),
1055 provenance: Provenance::Conversational,
1056 options: empty_options(),
1057 kind: PartKind::ToolCall {
1058 call_id: extract_str(value, "id"),
1059 name: extract_str(value, "name"),
1060 params: value.get("input").cloned().unwrap_or(Value::Null),
1061 provider_executed: false,
1062 },
1063 },
1064 Some("server_tool_use") => Part {
1065 session_id: session_id.to_owned(),
1066 id: part_id(message_id, ordinal),
1067 message_id: message_id.to_owned(),
1068 ordinal: part_ordinal(ordinal),
1069 provenance: Provenance::Conversational,
1070 options: empty_options(),
1071 kind: PartKind::ToolCall {
1072 call_id: extract_str(value, "id"),
1073 name: extract_str(value, "name"),
1074 params: value.get("input").cloned().unwrap_or(Value::Null),
1075 provider_executed: true,
1076 },
1077 },
1078 Some("image") | Some("file") => file_part(
1079 session_id,
1080 message_id,
1081 ordinal,
1082 value,
1083 Provenance::Conversational,
1084 ),
1085 _ => text_part(
1088 session_id,
1089 message_id,
1090 ordinal,
1091 Some(extract_compact_repr(value)),
1092 Provenance::Conversational,
1093 ),
1094 }
1095}
1096
1097fn tool_result_part(
1098 session_id: &str,
1099 message_id: &str,
1100 ordinal: usize,
1101 value: &Value,
1102 source_tool_result: Option<&Value>,
1103 state: &FileState,
1104) -> Part {
1105 let call_id = extract_str(value, "tool_use_id");
1106 let name = value
1112 .str_field("tool_use_id")
1113 .and_then(|id| state.tool_call_names.get(id))
1114 .cloned();
1115 let result = value
1116 .get("content")
1117 .cloned()
1118 .or_else(|| source_tool_result.cloned())
1119 .unwrap_or(Value::Null);
1120 Part {
1121 session_id: session_id.to_owned(),
1122 id: part_id(message_id, ordinal),
1123 message_id: message_id.to_owned(),
1124 ordinal: part_ordinal(ordinal),
1125 provenance: Provenance::Injected,
1128 options: empty_options(),
1129 kind: PartKind::ToolResult {
1130 call_id,
1131 name,
1132 is_failure: value
1133 .get("is_error")
1134 .and_then(Value::as_bool)
1135 .unwrap_or(false),
1136 result,
1137 },
1138 }
1139}
1140
1141fn file_part(
1142 session_id: &str,
1143 message_id: &str,
1144 ordinal: usize,
1145 value: &Value,
1146 provenance: Provenance,
1147) -> Part {
1148 let media_type = value
1149 .get("media_type")
1150 .or_else(|| value.get("mime_type"))
1151 .and_then(Value::as_str)
1152 .map(ToOwned::to_owned);
1153 let file_name = value
1154 .get("file_name")
1155 .or_else(|| value.get("name"))
1156 .and_then(Value::as_str)
1157 .map(ToOwned::to_owned);
1158 let data = if let Some(source) = value.get("source") {
1159 if let Some(url) = source.get("url").and_then(Value::as_str) {
1160 FileData::Url(url.to_owned())
1161 } else if let Some(bytes) = source.get("data").and_then(Value::as_str) {
1162 FileData::String(bytes.to_owned())
1163 } else {
1164 FileData::String(compact_json(source))
1165 }
1166 } else if let Some(url) = value.get("url").and_then(Value::as_str) {
1167 FileData::Url(url.to_owned())
1168 } else {
1169 FileData::String(compact_json(value))
1170 };
1171
1172 Part {
1173 session_id: session_id.to_owned(),
1174 id: part_id(message_id, ordinal),
1175 message_id: message_id.to_owned(),
1176 ordinal: part_ordinal(ordinal),
1177 provenance,
1178 options: empty_options(),
1179 kind: PartKind::File {
1180 media_type,
1181 file_name,
1182 data,
1183 },
1184 }
1185}
1186
1187fn row_options(row: &Value, line: usize) -> ProviderOptions {
1188 let mut options = ProviderOptions::new();
1189 let source = json!({
1190 "line": line,
1191 "parent_uuid": row.get("parentUuid"),
1192 "is_sidechain": row.get("isSidechain"),
1193 "user_type": row.get("userType"),
1194 "entrypoint": row.get("entrypoint"),
1195 "cwd": row.get("cwd"),
1196 "version": row.get("version"),
1197 "git_branch": row.get("gitBranch"),
1198 "request_id": row.get("requestId"),
1199 "raw_type": row.get("type"),
1200 "raw_record": extract_raw_record(row),
1201 });
1202 options.insert("source".to_owned(), source);
1203 options
1204}
1205
1206fn assistant_options(row: &Value, message_value: &Value, line: usize) -> ProviderOptions {
1207 let mut options = row_options(row, line);
1208 let anthropic = json!({
1209 "id": message_value.get("id"),
1210 "model": message_value.get("model"),
1211 "stop_reason": message_value.get("stop_reason"),
1212 "stop_sequence": message_value.get("stop_sequence"),
1213 "usage": message_value.get("usage"),
1214 });
1215 options.insert("anthropic".to_owned(), anthropic);
1216 options
1217}
1218
1219fn signature_options(value: &Value) -> ProviderOptions {
1220 let mut options = ProviderOptions::new();
1221 if let Some(signature) = value.get("signature").and_then(Value::as_str) {
1222 options.insert("anthropic".to_owned(), json!({"signature": signature}));
1223 }
1224 options
1225}
1226
1227fn attachment_content(value: &Value) -> Option<Extracted<String>> {
1228 extract_str(value, "content").or_else(|| extract_str(value, "stdout"))
1229}
1230
1231pub(crate) fn parse_timestamp(value: &Value) -> anyhow::Result<DateTime<Utc>> {
1232 let timestamp = value
1233 .get("timestamp")
1234 .and_then(Value::as_str)
1235 .context("missing timestamp")?;
1236 Ok(DateTime::parse_from_rfc3339(timestamp)
1237 .context("invalid timestamp")?
1238 .with_timezone(&Utc))
1239}
1240
1241fn is_tool_result(value: &Value) -> bool {
1242 value.get("type").and_then(Value::as_str) == Some("tool_result")
1243}
1244
1245fn is_meta_row(row: &Value) -> bool {
1248 row.get("isMeta").and_then(Value::as_bool) == Some(true)
1249}
1250
1251fn is_injected_user_text(text: &str) -> bool {
1255 let trimmed = text.trim_start();
1256 trimmed.starts_with("<task-notification>")
1257 || trimmed.starts_with("<command-name>")
1258 || trimmed.starts_with("<command-message>")
1259 || trimmed.starts_with("<command-args>")
1260 || trimmed.starts_with("<local-command-caveat>")
1261 || trimmed.starts_with("<local-command-stdout>")
1262 || trimmed.starts_with("[Request interrupted by user")
1263}
1264
1265fn user_text_provenance(row: &Value, text: &str) -> Provenance {
1268 if is_meta_row(row) || is_injected_user_text(text) {
1269 Provenance::Injected
1270 } else {
1271 Provenance::Conversational
1272 }
1273}
1274
1275fn user_array_provenance(row: &Value, items: &[Value]) -> Provenance {
1279 if is_meta_row(row) {
1280 return Provenance::Injected;
1281 }
1282 let wrapped = items.iter().any(|item| {
1283 item.get("type").and_then(Value::as_str) == Some("text")
1284 && item
1285 .get("text")
1286 .and_then(Value::as_str)
1287 .is_some_and(is_injected_user_text)
1288 });
1289 if wrapped {
1290 Provenance::Injected
1291 } else {
1292 Provenance::Conversational
1293 }
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298 #![allow(clippy::expect_used, clippy::unwrap_used)]
1306
1307 use super::*;
1308 use crate::{handlers::ingest_adapter, sessions::Store, wire::PartKind};
1309 use tempfile::TempDir;
1310
1311 const FIXTURE_ROOT: &str = concat!(
1314 env!("CARGO_MANIFEST_DIR"),
1315 "/tests/fixtures/adapter/claude_code/projects"
1316 );
1317
1318 #[test]
1319 fn probe_default_finds_claude_projects_under_home() -> anyhow::Result<()> {
1320 crate::adapter::test_support::assert_probe_default(
1321 &ClaudeCodeFactory,
1322 &[".claude", "projects"],
1323 )
1324 }
1325
1326 #[test]
1331 fn source_record_hash_ignores_noise_keeps_semantic_diffs() {
1332 let base = serde_json::json!({
1333 "uuid": "u1",
1334 "type": "user",
1335 "parentUuid": null,
1336 "message": {"role": "user", "content": "hi"},
1337 "timestamp": "2026-06-17T00:00:00Z",
1338 "requestId": "req-A",
1339 "isMeta": false,
1340 "gitBranch": "main",
1341 "version": "2.1.56",
1342 });
1343 let noise_diff = serde_json::json!({
1344 "uuid": "u1",
1345 "type": "user",
1346 "parentUuid": null,
1347 "message": {"role": "user", "content": "hi"},
1348 "timestamp": "2026-06-17T00:00:05Z",
1349 "requestId": "req-B",
1350 "isMeta": true,
1351 "gitBranch": "feat/x",
1352 "version": "2.1.57",
1353 });
1354 let content_diff = serde_json::json!({
1355 "uuid": "u1",
1356 "type": "user",
1357 "parentUuid": null,
1358 "message": {"role": "user", "content": "different"},
1359 "timestamp": "2026-06-17T00:00:00Z",
1360 });
1361 assert_eq!(
1362 source_record_hash(&base),
1363 source_record_hash(&noise_diff),
1364 "noise-field differences must dedupe",
1365 );
1366 assert_ne!(
1367 source_record_hash(&base),
1368 source_record_hash(&content_diff),
1369 "semantic content differences must not dedupe",
1370 );
1371 }
1372
1373 #[tokio::test(flavor = "multi_thread")]
1374 async fn native_restore_is_value_equal_to_fixture_corpus() -> anyhow::Result<()> {
1375 let adapter = ClaudeCodeAdapter::new(FIXTURE_ROOT);
1376 crate::adapter::test_support::assert_native_restore(
1377 &ClaudeCodeFactory,
1378 &adapter,
1379 std::path::Path::new(FIXTURE_ROOT),
1380 )
1381 .await
1382 }
1383
1384 #[tokio::test(flavor = "multi_thread")]
1389 async fn plan_classifies_fresh_vs_pending_without_decoding() -> anyhow::Result<()> {
1390 use crate::adapter::{Adapter, test_support::MaxWatermarkOracle};
1391
1392 let adapter = ClaudeCodeAdapter::new(FIXTURE_ROOT);
1393 let first_sync = adapter
1394 .plan(&crate::adapter::NoopOracle)
1395 .await?
1396 .expect("jsonl-tree adapters support plan");
1397 assert!(first_sync.sessions > 0);
1398 assert_eq!(first_sync.pending, first_sync.sessions);
1399 assert_eq!(first_sync.fresh, 0);
1400
1401 let caught_up = adapter
1402 .plan(&MaxWatermarkOracle)
1403 .await?
1404 .expect("jsonl-tree adapters support plan");
1405 assert_eq!(caught_up.sessions, first_sync.sessions);
1406 assert!(caught_up.fresh > 0, "fixture sessions must gate as fresh");
1407 assert_eq!(caught_up.fresh + caught_up.pending, caught_up.sessions);
1408 Ok(())
1409 }
1410
1411 #[tokio::test(flavor = "multi_thread")]
1418 async fn plan_gates_keyless_sessions_empty_and_excludes_journals() -> anyhow::Result<()> {
1419 use crate::adapter::{Adapter, test_support::MaxWatermarkOracle};
1420
1421 let corpus = TempDir::new()?;
1422 let project_dir = corpus.path().join("-tmp-pond-test");
1423 let session_uuid = "99999999-9999-9999-9999-999999999999";
1424 let wf_dir = project_dir
1425 .join(session_uuid)
1426 .join("subagents")
1427 .join("workflows")
1428 .join("wf_11111111-abc");
1429 std::fs::create_dir_all(&wf_dir)?;
1430
1431 let session_row = serde_json::json!({
1432 "type": "user",
1433 "uuid": "u-1",
1434 "sessionId": session_uuid,
1435 "cwd": "/tmp/pond-test",
1436 "timestamp": "2026-06-04T00:00:00.000Z",
1437 "message": {"role": "user", "content": "hi"},
1438 });
1439 std::fs::write(
1440 project_dir.join(format!("{session_uuid}.jsonl")),
1441 format!("{session_row}\n"),
1442 )?;
1443 std::fs::write(project_dir.join("empty-session.jsonl"), "")?;
1444 let title_row = serde_json::json!({
1445 "type": "ai-title",
1446 "aiTitle": "title only",
1447 "sessionId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
1448 });
1449 std::fs::write(
1450 project_dir.join("title-only.jsonl"),
1451 format!("{title_row}\n"),
1452 )?;
1453 std::fs::write(wf_dir.join("journal.jsonl"), "{\"type\":\"started\"}\n")?;
1454
1455 let adapter = ClaudeCodeAdapter::new(corpus.path());
1456 let plan = adapter
1457 .plan(&MaxWatermarkOracle)
1458 .await?
1459 .expect("jsonl-tree adapters support plan");
1460 assert_eq!(
1461 plan.sessions, 3,
1462 "journal.jsonl must not count as a session"
1463 );
1464 assert_eq!(
1465 plan.fresh, 3,
1466 "keyless sessions gate Empty and count as fresh",
1467 );
1468 assert_eq!(plan.pending, 0, "a clean corpus must read as fully synced");
1469 Ok(())
1470 }
1471
1472 #[tokio::test(flavor = "multi_thread")]
1478 async fn keyless_file_peeks_empty_and_ingests_nothing() -> anyhow::Result<()> {
1479 use crate::adapter::SourceWatermark;
1480
1481 let corpus = TempDir::new()?;
1482 let project_dir = corpus.path().join("-tmp-pond-test");
1483 std::fs::create_dir_all(&project_dir)?;
1484 let title_row = serde_json::json!({
1485 "type": "ai-title",
1486 "aiTitle": "title only",
1487 "sessionId": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
1488 });
1489 let path = project_dir.join("title-only.jsonl");
1490 std::fs::write(&path, format!("{title_row}\n"))?;
1491
1492 let adapter = ClaudeCodeAdapter::new(corpus.path());
1493 assert_eq!(
1494 adapter.peek_watermark(&path),
1495 SourceWatermark::Empty,
1496 "a whole-file scan with no timestamped row is a proof of emptiness",
1497 );
1498
1499 let store_dir = TempDir::new()?;
1500 let store = Store::open_local(store_dir.path()).await?;
1501 let summary = ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
1502 assert_eq!(
1503 summary.accepted(),
1504 0,
1505 "an Empty-judged file must ingest nothing - if this fails, ingest \
1506 learned to anchor keyless files and peek_watermark must be updated",
1507 );
1508 assert!(
1509 store.session_ids().await?.is_empty(),
1510 "no session row may land from an Empty-judged file",
1511 );
1512 Ok(())
1513 }
1514
1515 #[tokio::test(flavor = "multi_thread")]
1523 async fn subagent_file_derives_child_session_with_parent_link() -> anyhow::Result<()> {
1524 let corpus = TempDir::new()?;
1525 let project_dir = corpus.path().join("-tmp-pond-test");
1526 let parent_uuid = "11111111-1111-1111-1111-111111111111";
1527 let agent_hash = "abc123def456";
1528 std::fs::create_dir_all(project_dir.join(parent_uuid).join("subagents"))?;
1529
1530 let parent_row = serde_json::json!({
1532 "type": "user",
1533 "uuid": "u-parent-1",
1534 "sessionId": parent_uuid,
1535 "cwd": "/tmp/pond-test",
1536 "timestamp": "2026-05-16T00:00:00.000Z",
1537 "version": "2.1.121",
1538 "message": {"role": "user", "content": "hi parent"},
1539 });
1540 std::fs::write(
1541 project_dir.join(format!("{parent_uuid}.jsonl")),
1542 format!("{parent_row}\n"),
1543 )?;
1544
1545 let subagent_row = serde_json::json!({
1548 "type": "user",
1549 "uuid": "u-sub-1",
1550 "sessionId": parent_uuid,
1551 "cwd": "/tmp/pond-test",
1552 "isSidechain": true,
1553 "agentId": agent_hash,
1554 "timestamp": "2026-05-16T00:01:00.000Z",
1555 "version": "2.1.121",
1556 "message": {"role": "user", "content": "subagent prompt"},
1557 });
1558 std::fs::write(
1559 project_dir
1560 .join(parent_uuid)
1561 .join("subagents")
1562 .join(format!("agent-{agent_hash}.jsonl")),
1563 format!("{subagent_row}\n"),
1564 )?;
1565 std::fs::write(
1566 project_dir
1567 .join(parent_uuid)
1568 .join("subagents")
1569 .join(format!("agent-{agent_hash}.meta.json")),
1570 r#"{"agentType":"general-purpose","description":"do a thing"}"#,
1571 )?;
1572
1573 let store_dir = TempDir::new()?;
1574 let store = Store::open_local(store_dir.path()).await?;
1575 let adapter = ClaudeCodeAdapter::new(corpus.path());
1576
1577 let summary = ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
1578 assert_eq!(
1579 summary.dropped_sessions, 0,
1580 "subagent file must NOT collide with parent (pre-fix this was the project-immutable rejection)"
1581 );
1582
1583 let parent = store
1584 .get_session(parent_uuid)
1585 .await?
1586 .expect("parent session should ingest as the bare uuid");
1587 assert_eq!(parent.session.source_agent, "claude-code");
1588 assert_eq!(parent.session.parent_session_id, None);
1589
1590 let child_id = format!("{parent_uuid}/agent-{agent_hash}");
1591 let child = store
1592 .get_session(&child_id)
1593 .await?
1594 .expect("subagent session must surface under the derived id");
1595 assert_eq!(
1596 child.session.source_agent, "claude-code/general-purpose",
1597 "agent_type from .meta.json should suffix the source_agent label"
1598 );
1599 assert_eq!(
1600 child.session.parent_session_id.as_deref(),
1601 Some(parent_uuid),
1602 "subagent must link back to parent via parent_session_id",
1603 );
1604 let subagent_meta = child
1605 .session
1606 .options
1607 .get("subagent")
1608 .expect("options.subagent must carry the hash + verbatim meta.json");
1609 assert_eq!(subagent_meta["hash"], serde_json::json!(agent_hash));
1610 assert_eq!(
1611 subagent_meta["meta"]["agentType"],
1612 serde_json::json!("general-purpose")
1613 );
1614 assert_eq!(
1615 subagent_meta["meta"]["description"],
1616 serde_json::json!("do a thing")
1617 );
1618 Ok(())
1619 }
1620
1621 #[tokio::test(flavor = "multi_thread")]
1629 async fn fork_subagent_transcript_ingests_despite_headerless_first_row() -> anyhow::Result<()> {
1630 let corpus = TempDir::new()?;
1631 let project_dir = corpus.path().join("-tmp-pond-test");
1632 let parent_uuid = "33333333-3333-3333-3333-333333333333";
1633 let agent_hash = "afork0001";
1634 std::fs::create_dir_all(project_dir.join(parent_uuid).join("subagents"))?;
1635
1636 let parent_row = serde_json::json!({
1638 "type": "user",
1639 "uuid": "u-parent-1",
1640 "sessionId": parent_uuid,
1641 "cwd": "/tmp/pond-test",
1642 "timestamp": "2026-06-10T00:00:00.000Z",
1643 "version": "2.1.170",
1644 "message": {"role": "user", "content": "hi parent"},
1645 });
1646 std::fs::write(
1647 project_dir.join(format!("{parent_uuid}.jsonl")),
1648 format!("{parent_row}\n"),
1649 )?;
1650
1651 let header = serde_json::json!({
1654 "type": "fork-context-ref",
1655 "agentId": agent_hash,
1656 "parentSessionId": parent_uuid,
1657 "parentLastUuid": "u-parent-1",
1658 "contextLength": 74,
1659 });
1660 let user_row = serde_json::json!({
1661 "type": "user",
1662 "uuid": "u-fork-1",
1663 "sessionId": parent_uuid,
1664 "cwd": "/tmp/pond-test",
1665 "isSidechain": true,
1666 "agentId": agent_hash,
1667 "timestamp": "2026-06-10T00:01:00.000Z",
1668 "version": "2.1.170",
1669 "message": {"role": "user", "content": "do the fork task"},
1670 });
1671 let assistant_row = serde_json::json!({
1672 "type": "assistant",
1673 "uuid": "a-fork-1",
1674 "sessionId": parent_uuid,
1675 "cwd": "/tmp/pond-test",
1676 "isSidechain": true,
1677 "agentId": agent_hash,
1678 "timestamp": "2026-06-10T00:01:05.000Z",
1679 "version": "2.1.170",
1680 "message": {"role": "assistant", "content": [{"type": "text", "text": "done"}]},
1681 });
1682 std::fs::write(
1683 project_dir
1684 .join(parent_uuid)
1685 .join("subagents")
1686 .join(format!("agent-{agent_hash}.jsonl")),
1687 format!("{header}\n{user_row}\n{assistant_row}\n"),
1688 )?;
1689 std::fs::write(
1690 project_dir
1691 .join(parent_uuid)
1692 .join("subagents")
1693 .join(format!("agent-{agent_hash}.meta.json")),
1694 r#"{"agentType":"fork","description":"do the fork task"}"#,
1695 )?;
1696
1697 let store_dir = TempDir::new()?;
1698 let store = Store::open_local(store_dir.path()).await?;
1699 let adapter = ClaudeCodeAdapter::new(corpus.path());
1700
1701 let summary = ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
1702 assert_eq!(
1703 summary.dropped_sessions, 0,
1704 "fork transcript must ingest, not drop on the headerless first row"
1705 );
1706
1707 let child_id = format!("{parent_uuid}/agent-{agent_hash}");
1708 let child = store
1709 .get_session(&child_id)
1710 .await?
1711 .expect("forked subagent must surface under the path-derived child id");
1712 assert_eq!(
1713 child.session.parent_session_id.as_deref(),
1714 Some(parent_uuid),
1715 "fork must link back to its parent",
1716 );
1717 assert_eq!(
1718 child.session.source_agent, "claude-code/fork",
1719 "agent_type `fork` from .meta.json should suffix the source_agent label",
1720 );
1721 assert!(
1724 child
1725 .messages
1726 .iter()
1727 .any(|m| matches!(m.message, Message::User { .. })),
1728 "the fork's user turn must persist",
1729 );
1730 assert!(
1731 child
1732 .messages
1733 .iter()
1734 .any(|m| matches!(m.message, Message::Assistant { .. })),
1735 "the fork's assistant turn must persist",
1736 );
1737 Ok(())
1738 }
1739
1740 #[tokio::test(flavor = "multi_thread")]
1744 async fn subagent_without_meta_falls_back_to_generic_label() -> anyhow::Result<()> {
1745 let corpus = TempDir::new()?;
1746 let project_dir = corpus.path().join("-tmp-pond-test");
1747 let parent_uuid = "22222222-2222-2222-2222-222222222222";
1748 let agent_hash = "deadbeef";
1749 std::fs::create_dir_all(project_dir.join(parent_uuid).join("subagents"))?;
1750 let row = serde_json::json!({
1751 "type": "user",
1752 "uuid": "u-sub-only",
1753 "sessionId": parent_uuid,
1754 "cwd": "/tmp/pond-test",
1755 "timestamp": "2026-05-16T00:00:00.000Z",
1756 "message": {"role": "user", "content": "no meta sibling here"},
1757 });
1758 std::fs::write(
1759 project_dir
1760 .join(parent_uuid)
1761 .join("subagents")
1762 .join(format!("agent-{agent_hash}.jsonl")),
1763 format!("{row}\n"),
1764 )?;
1765
1766 let store_dir = TempDir::new()?;
1767 let store = Store::open_local(store_dir.path()).await?;
1768 let adapter = ClaudeCodeAdapter::new(corpus.path());
1769 let _summary =
1770 ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
1771
1772 let child = store
1773 .get_session(&format!("{parent_uuid}/agent-{agent_hash}"))
1774 .await?
1775 .expect("derived child id even without meta");
1776 assert_eq!(child.session.source_agent, "claude-code/subagent");
1777 Ok(())
1778 }
1779
1780 #[tokio::test(flavor = "multi_thread")]
1788 async fn workflow_nested_subagent_derives_distinct_child_not_parent_collision()
1789 -> anyhow::Result<()> {
1790 let corpus = TempDir::new()?;
1791 let project_dir = corpus.path().join("-tmp-pond-test");
1792 let parent_uuid = "44444444-4444-4444-4444-444444444444";
1793 let wf_id = "wf_abcd1234-ef0";
1794 let agent_hash = "cafef00dbaadf00d1";
1795 let wf_dir = project_dir
1796 .join(parent_uuid)
1797 .join("subagents")
1798 .join("workflows")
1799 .join(wf_id);
1800 std::fs::create_dir_all(&wf_dir)?;
1801
1802 let parent_row = serde_json::json!({
1803 "type": "user",
1804 "uuid": "u-parent-1",
1805 "sessionId": parent_uuid,
1806 "cwd": "/tmp/pond-test",
1807 "timestamp": "2026-05-20T00:00:00.000Z",
1808 "message": {"role": "user", "content": "hi parent"},
1809 });
1810 std::fs::write(
1811 project_dir.join(format!("{parent_uuid}.jsonl")),
1812 format!("{parent_row}\n"),
1813 )?;
1814
1815 let subagent_row = serde_json::json!({
1817 "type": "user",
1818 "uuid": "u-wf-sub-1",
1819 "sessionId": parent_uuid,
1820 "cwd": "/tmp/pond-test/packages/sub",
1821 "isSidechain": true,
1822 "agentId": agent_hash,
1823 "timestamp": "2026-05-20T00:01:00.000Z",
1824 "message": {"role": "user", "content": "workflow subagent prompt"},
1825 });
1826 std::fs::write(
1827 wf_dir.join(format!("agent-{agent_hash}.jsonl")),
1828 format!("{subagent_row}\n"),
1829 )?;
1830 std::fs::write(
1831 wf_dir.join(format!("agent-{agent_hash}.meta.json")),
1832 r#"{"agentType":"general-purpose","description":"workflow child"}"#,
1833 )?;
1834
1835 let store_dir = TempDir::new()?;
1836 let store = Store::open_local(store_dir.path()).await?;
1837 let adapter = ClaudeCodeAdapter::new(corpus.path());
1838 let summary = ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
1839 assert_eq!(
1840 summary.dropped_sessions, 0,
1841 "nested workflow subagent must NOT collide with the parent project",
1842 );
1843
1844 let parent = store
1845 .get_session(parent_uuid)
1846 .await?
1847 .expect("parent session ingests under the bare uuid");
1848 assert_eq!(&*parent.session.project, "/tmp/pond-test");
1849 assert_eq!(parent.session.parent_session_id, None);
1850
1851 let child_id = format!("{parent_uuid}/workflows/{wf_id}/agent-{agent_hash}");
1852 let child = store
1853 .get_session(&child_id)
1854 .await?
1855 .expect("workflow subagent surfaces under the full nested child id");
1856 assert_eq!(child.session.source_agent, "claude-code/general-purpose");
1857 assert_eq!(
1858 child.session.parent_session_id.as_deref(),
1859 Some(parent_uuid)
1860 );
1861 assert_eq!(
1862 &*child.session.project, "/tmp/pond-test/packages/sub",
1863 "child keeps its own cwd-derived project, distinct from the parent",
1864 );
1865 let subagent_meta = child
1866 .session
1867 .options
1868 .get("subagent")
1869 .expect("options.subagent present");
1870 assert_eq!(subagent_meta["hash"], serde_json::json!(agent_hash));
1871 Ok(())
1872 }
1873
1874 #[tokio::test(flavor = "multi_thread")]
1880 async fn unrecognized_subagents_file_fails_visibly_not_merged() -> anyhow::Result<()> {
1881 let corpus = TempDir::new()?;
1882 let project_dir = corpus.path().join("-tmp-pond-test");
1883 let parent_uuid = "55555555-5555-5555-5555-555555555555";
1884 let unknown_dir = project_dir
1885 .join(parent_uuid)
1886 .join("subagents")
1887 .join("workflows")
1888 .join("wf_future01-aaa");
1889 std::fs::create_dir_all(&unknown_dir)?;
1890
1891 let parent_row = serde_json::json!({
1892 "type": "user",
1893 "uuid": "u-parent-only",
1894 "sessionId": parent_uuid,
1895 "cwd": "/tmp/pond-test",
1896 "timestamp": "2026-05-20T00:00:00.000Z",
1897 "message": {"role": "user", "content": "parent message"},
1898 });
1899 std::fs::write(
1900 project_dir.join(format!("{parent_uuid}.jsonl")),
1901 format!("{parent_row}\n"),
1902 )?;
1903
1904 let unknown_row = serde_json::json!({
1907 "type": "user",
1908 "uuid": "u-should-not-merge",
1909 "sessionId": parent_uuid,
1910 "cwd": "/tmp/pond-test",
1911 "timestamp": "2026-05-20T00:02:00.000Z",
1912 "message": {"role": "user", "content": "must not land under parent"},
1913 });
1914 std::fs::write(
1915 unknown_dir.join("transcript-001.jsonl"),
1916 format!("{unknown_row}\n"),
1917 )?;
1918
1919 let store_dir = TempDir::new()?;
1920 let store = Store::open_local(store_dir.path()).await?;
1921 let adapter = ClaudeCodeAdapter::new(corpus.path());
1922 let summary = ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
1923
1924 assert_eq!(
1925 summary.skipped_files, 1,
1926 "the unrecognized subagents/ transcript must be a visible, counted skip",
1927 );
1928 let parent = store
1929 .get_session(parent_uuid)
1930 .await?
1931 .expect("parent session ingests");
1932 assert_eq!(
1933 parent.messages.len(),
1934 1,
1935 "the unrecognized file's row must NOT be merged into the parent session",
1936 );
1937 assert!(
1938 parent
1939 .messages
1940 .iter()
1941 .all(|m| m.message.id() != "u-should-not-merge"),
1942 "parent must not absorb the unrecognized file's message",
1943 );
1944 Ok(())
1945 }
1946
1947 #[tokio::test(flavor = "multi_thread")]
1955 async fn unrecognized_subagents_file_stays_visible_under_parent_watermark() -> anyhow::Result<()>
1956 {
1957 struct ParentAlreadyFresh;
1958 impl crate::adapter::SkipOracle for ParentAlreadyFresh {
1959 fn session_max_ts(&self, _session_id: &str) -> Option<i64> {
1960 Some(i64::MAX)
1964 }
1965 fn is_empty(&self) -> bool {
1966 false
1967 }
1968 }
1969
1970 let corpus = TempDir::new()?;
1971 let project_dir = corpus.path().join("-tmp-pond-test");
1972 let parent_uuid = "66666666-6666-6666-6666-666666666666";
1973 let unknown_dir = project_dir
1974 .join(parent_uuid)
1975 .join("subagents")
1976 .join("workflows")
1977 .join("wf_future02-bbb");
1978 std::fs::create_dir_all(&unknown_dir)?;
1979
1980 let parent_row = serde_json::json!({
1981 "type": "user",
1982 "uuid": "u-parent-fresh",
1983 "sessionId": parent_uuid,
1984 "cwd": "/tmp/pond-test",
1985 "timestamp": "2026-05-20T00:00:00.000Z",
1986 "message": {"role": "user", "content": "parent message"},
1987 });
1988 std::fs::write(
1989 project_dir.join(format!("{parent_uuid}.jsonl")),
1990 format!("{parent_row}\n"),
1991 )?;
1992
1993 let unknown_row = serde_json::json!({
1996 "type": "user",
1997 "uuid": "u-resync-should-stay-visible",
1998 "sessionId": parent_uuid,
1999 "cwd": "/tmp/pond-test",
2000 "timestamp": "2026-05-20T00:02:00.000Z",
2001 "message": {"role": "user", "content": "must stay visible"},
2002 });
2003 std::fs::write(
2004 unknown_dir.join("transcript-002.jsonl"),
2005 format!("{unknown_row}\n"),
2006 )?;
2007
2008 let store_dir = TempDir::new()?;
2009 let store = Store::open_local(store_dir.path()).await?;
2010 let adapter = ClaudeCodeAdapter::new(corpus.path());
2011 let summary = ingest_adapter(&store, &adapter, &ParentAlreadyFresh, |_| {}).await?;
2012
2013 assert_eq!(
2014 summary.skipped_files, 1,
2015 "the unrecognized transcript must stay a visible Unsupported skip, not be fresh-skipped under the parent's watermark",
2016 );
2017 assert_eq!(
2020 summary.skipped_fresh, 1,
2021 "only the parent may fresh-skip; the unrecognized file must not borrow its watermark",
2022 );
2023 Ok(())
2024 }
2025
2026 #[tokio::test(flavor = "multi_thread")]
2031 async fn replay_duplicates_are_dedup_at_adapter_layer() -> anyhow::Result<()> {
2032 let corpus = TempDir::new()?;
2033 let project_dir = corpus.path().join("-tmp-pond-test");
2034 std::fs::create_dir_all(&project_dir)?;
2035 let session_uuid = "33333333-3333-3333-3333-333333333333";
2036 let dup_uuid = "u-shared-1";
2037 let row = serde_json::json!({
2038 "type": "user",
2039 "uuid": dup_uuid,
2040 "sessionId": session_uuid,
2041 "cwd": "/tmp/pond-test",
2042 "timestamp": "2026-05-16T00:00:00.000Z",
2043 "message": {"role": "user", "content": "replayed three times"},
2044 });
2045 let body = format!("{row}\n{row}\n{row}\n");
2047 std::fs::write(project_dir.join(format!("{session_uuid}.jsonl")), body)?;
2048
2049 let store_dir = TempDir::new()?;
2050 let store = Store::open_local(store_dir.path()).await?;
2051 let adapter = ClaudeCodeAdapter::new(corpus.path());
2052 let summary = ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2053
2054 assert_eq!(
2055 summary.dropped_events, 0,
2056 "adapter must dedupe replays before they reach the validator"
2057 );
2058 assert!(
2059 !summary
2060 .drop_reasons
2061 .contains_key(crate::sessions::DROP_REASON_DUPLICATE_MESSAGE_ID),
2062 "duplicate_message_id bucket stays empty when adapter does its job"
2063 );
2064 Ok(())
2065 }
2066
2067 #[tokio::test(flavor = "multi_thread")]
2068 async fn same_uuid_different_content_is_visible_duplicate_not_adapter_drop()
2069 -> anyhow::Result<()> {
2070 let corpus = TempDir::new()?;
2071 let project_dir = corpus.path().join("-tmp-pond-test");
2072 std::fs::create_dir_all(&project_dir)?;
2073 let session_uuid = "33333333-3333-3333-3333-333333333334";
2074 let dup_uuid = "u-shared-different";
2075 let first = serde_json::json!({
2076 "type": "user",
2077 "uuid": dup_uuid,
2078 "sessionId": session_uuid,
2079 "cwd": "/tmp/pond-test",
2080 "timestamp": "2026-05-16T00:00:00.000Z",
2081 "message": {"role": "user", "content": "first content"},
2082 });
2083 let second = serde_json::json!({
2084 "type": "user",
2085 "uuid": dup_uuid,
2086 "sessionId": session_uuid,
2087 "cwd": "/tmp/pond-test",
2088 "timestamp": "2026-05-16T00:00:01.000Z",
2089 "message": {"role": "user", "content": "changed content"},
2090 });
2091 std::fs::write(
2092 project_dir.join(format!("{session_uuid}.jsonl")),
2093 format!("{first}\n{second}\n"),
2094 )?;
2095
2096 let store_dir = TempDir::new()?;
2097 let store = Store::open_local(store_dir.path()).await?;
2098 let adapter = ClaudeCodeAdapter::new(corpus.path());
2099 let summary = ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2100
2101 assert_eq!(
2102 summary
2103 .drop_reasons
2104 .get(crate::sessions::DROP_REASON_DUPLICATE_MESSAGE_ID)
2105 .copied(),
2106 Some(1),
2107 "same uuid with changed content must reach the visible duplicate-id path",
2108 );
2109 Ok(())
2110 }
2111
2112 #[tokio::test(flavor = "multi_thread")]
2113 async fn session_row_without_messages_does_not_fresh_skip_source() -> anyhow::Result<()> {
2114 let corpus = TempDir::new()?;
2115 let project_dir = corpus.path().join("-tmp-pond-test");
2116 std::fs::create_dir_all(&project_dir)?;
2117 let session_uuid = "33333333-3333-3333-3333-333333333335";
2118 let row = serde_json::json!({
2119 "type": "user",
2120 "uuid": "u-after-partial",
2121 "sessionId": session_uuid,
2122 "cwd": "/tmp/pond-test",
2123 "timestamp": "2026-05-16T00:00:00.000Z",
2124 "message": {"role": "user", "content": "healed by replay"},
2125 });
2126 std::fs::write(
2127 project_dir.join(format!("{session_uuid}.jsonl")),
2128 format!("{row}\n"),
2129 )?;
2130
2131 let store_dir = TempDir::new()?;
2132 let store = Store::open_local(store_dir.path()).await?;
2133 store
2134 .upsert_sessions(&[Session {
2135 id: session_uuid.to_owned(),
2136 parent_session_id: None,
2137 parent_message_id: None,
2138 source_agent: "claude-code".to_owned(),
2139 created_at: DateTime::parse_from_rfc3339("2026-05-16T00:00:00.000Z")?
2140 .with_timezone(&Utc),
2141 project: Extracted::from_test_value("/tmp/pond-test".to_owned()),
2142 options: ProviderOptions::new(),
2143 }])
2144 .await?;
2145
2146 let last_ids = store.session_last_message_ids().await?;
2147 assert!(
2148 !last_ids.contains_key(session_uuid),
2149 "a session row without messages must not produce a freshness key",
2150 );
2151 let adapter = ClaudeCodeAdapter::new(corpus.path());
2152 let summary = ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2153 assert_eq!(summary.skipped_fresh, 0);
2154 let session = store
2155 .get_session(session_uuid)
2156 .await?
2157 .expect("session row exists");
2158 assert_eq!(session.messages.len(), 1, "replay must heal messages");
2159 Ok(())
2160 }
2161
2162 #[test]
2168 fn peek_watermark_walks_back_past_trailing_metadata_rows() {
2169 let corpus = TempDir::new().unwrap();
2170 let project_dir = corpus.path().join("-tmp-pond-test");
2171 std::fs::create_dir_all(&project_dir).unwrap();
2172 let session_uuid = "44444444-4444-4444-4444-444444444444";
2173 let message = serde_json::json!({
2174 "type": "user",
2175 "uuid": "u-1",
2176 "sessionId": session_uuid,
2177 "cwd": "/tmp/pond-test",
2178 "timestamp": "2026-05-16T00:00:00.000Z",
2179 "message": {"role": "user", "content": "hello"},
2180 });
2181 let last_prompt =
2183 serde_json::json!({"type": "last-prompt", "sessionId": session_uuid, "prompt": "hi"});
2184 let permission = serde_json::json!({"type": "permission-mode", "sessionId": session_uuid});
2185 let path = project_dir.join(format!("{session_uuid}.jsonl"));
2186 std::fs::write(&path, format!("{message}\n{last_prompt}\n{permission}\n")).unwrap();
2187
2188 let adapter = ClaudeCodeAdapter::new(corpus.path());
2189 let expected = DateTime::parse_from_rfc3339("2026-05-16T00:00:00.000Z")
2190 .unwrap()
2191 .timestamp_micros();
2192 assert_eq!(
2193 adapter.peek_watermark(&path),
2194 crate::adapter::SourceWatermark::At(expected),
2195 "walk back past trailing metadata to the last message's timestamp",
2196 );
2197 }
2198
2199 #[tokio::test(flavor = "multi_thread")]
2203 async fn tool_result_name_resolves_from_prior_tool_use_in_same_file() -> anyhow::Result<()> {
2204 let corpus = TempDir::new()?;
2205 let project_dir = corpus.path().join("-tmp-pond-test");
2206 std::fs::create_dir_all(&project_dir)?;
2207 let session_uuid = "44444444-4444-4444-4444-444444444444";
2208 let call_id = "toolu_test_01";
2209
2210 let tool_use_row = serde_json::json!({
2211 "type": "assistant",
2212 "uuid": "u-call",
2213 "sessionId": session_uuid,
2214 "cwd": "/tmp/pond-test",
2215 "timestamp": "2026-05-16T00:00:00.000Z",
2216 "message": {
2217 "role": "assistant",
2218 "content": [{
2219 "type": "tool_use",
2220 "id": call_id,
2221 "name": "Edit",
2222 "input": {"file_path": "/tmp/foo"},
2223 }],
2224 },
2225 });
2226 let tool_result_row = serde_json::json!({
2227 "type": "user",
2228 "uuid": "u-result",
2229 "sessionId": session_uuid,
2230 "cwd": "/tmp/pond-test",
2231 "timestamp": "2026-05-16T00:00:01.000Z",
2232 "message": {
2233 "role": "user",
2234 "content": [{
2235 "type": "tool_result",
2236 "tool_use_id": call_id,
2237 "content": "ok",
2238 }],
2239 },
2240 });
2241 std::fs::write(
2242 project_dir.join(format!("{session_uuid}.jsonl")),
2243 format!("{tool_use_row}\n{tool_result_row}\n"),
2244 )?;
2245
2246 let store_dir = TempDir::new()?;
2247 let store = Store::open_local(store_dir.path()).await?;
2248 let adapter = ClaudeCodeAdapter::new(corpus.path());
2249 let _summary =
2250 ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2251 let session = store
2252 .get_session(session_uuid)
2253 .await?
2254 .expect("session ingests");
2255
2256 let mut saw_call = false;
2257 let mut saw_result = false;
2258 for stored in &session.messages {
2259 for part in &stored.parts {
2260 match &part.kind {
2261 PartKind::ToolCall {
2262 call_id: cid, name, ..
2263 } => {
2264 assert_eq!(cid.as_ref().map(|e| e.as_str()), Some(call_id));
2265 assert_eq!(
2266 name.as_ref().map(|e| e.as_str()),
2267 Some("Edit"),
2268 "tool_use carries the name directly"
2269 );
2270 saw_call = true;
2271 }
2272 PartKind::ToolResult {
2273 call_id: cid, name, ..
2274 } => {
2275 assert_eq!(cid.as_ref().map(|e| e.as_str()), Some(call_id));
2276 assert_eq!(
2277 name.as_ref().map(|e| e.as_str()),
2278 Some("Edit"),
2279 "tool_result resolves the name via the per-file map (was 'unknown' pre-2026-05-16)"
2280 );
2281 saw_result = true;
2282 }
2283 _ => {}
2284 }
2285 }
2286 }
2287 assert!(saw_call && saw_result, "both parts must be present");
2288 Ok(())
2289 }
2290
2291 #[test]
2295 fn user_text_provenance_separates_prompts_from_harness_injection() {
2296 let prompt = json!({"type": "user", "uuid": "u1"});
2297 assert_eq!(
2298 user_text_provenance(&prompt, "please refactor the parser"),
2299 Provenance::Conversational,
2300 );
2301
2302 let notification = json!({"type": "user", "uuid": "u2"});
2303 assert_eq!(
2304 user_text_provenance(
2305 ¬ification,
2306 "<task-notification>background task done</task-notification>",
2307 ),
2308 Provenance::Injected,
2309 );
2310
2311 let meta = json!({"type": "user", "uuid": "u3", "isMeta": true});
2312 assert_eq!(
2313 user_text_provenance(&meta, "expanded skill body"),
2314 Provenance::Injected,
2315 );
2316 }
2317
2318 #[tokio::test(flavor = "multi_thread")]
2322 async fn task_notification_message_yields_injected_parts() -> anyhow::Result<()> {
2323 let corpus = TempDir::new()?;
2324 let project_dir = corpus.path().join("-tmp-pond-test");
2325 std::fs::create_dir_all(&project_dir)?;
2326 let session_uuid = "66666666-6666-6666-6666-666666666666";
2327 let prompt = serde_json::json!({
2328 "type": "user",
2329 "uuid": "u-prompt",
2330 "sessionId": session_uuid,
2331 "cwd": "/tmp/pond-test",
2332 "timestamp": "2026-05-16T00:00:00.000Z",
2333 "message": {"role": "user", "content": "genuine human prompt"},
2334 });
2335 let notification = serde_json::json!({
2336 "type": "user",
2337 "uuid": "u-notify",
2338 "sessionId": session_uuid,
2339 "cwd": "/tmp/pond-test",
2340 "timestamp": "2026-05-16T00:00:01.000Z",
2341 "message": {
2342 "role": "user",
2343 "content": "<task-notification>a background task finished</task-notification>",
2344 },
2345 });
2346 std::fs::write(
2347 project_dir.join(format!("{session_uuid}.jsonl")),
2348 format!("{prompt}\n{notification}\n"),
2349 )?;
2350
2351 let store_dir = TempDir::new()?;
2352 let store = Store::open_local(store_dir.path()).await?;
2353 let adapter = ClaudeCodeAdapter::new(corpus.path());
2354 ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2355
2356 let session = store
2357 .get_session(session_uuid)
2358 .await?
2359 .expect("session ingests");
2360 let mut saw_prompt = false;
2361 let mut saw_notification = false;
2362 for stored in &session.messages {
2363 for part in &stored.parts {
2364 if stored.message.id() == "u-prompt" {
2365 assert_eq!(part.provenance, crate::wire::Provenance::Conversational);
2366 saw_prompt = true;
2367 }
2368 if stored.message.id() == "u-notify" {
2369 assert_eq!(part.provenance, crate::wire::Provenance::Injected);
2370 saw_notification = true;
2371 }
2372 }
2373 }
2374 assert!(saw_prompt && saw_notification, "both messages present");
2375 Ok(())
2376 }
2377
2378 #[tokio::test(flavor = "multi_thread")]
2382 async fn orphan_tool_result_yields_name_none_not_unknown_sentinel() -> anyhow::Result<()> {
2383 let corpus = TempDir::new()?;
2384 let project_dir = corpus.path().join("-tmp-pond-test");
2385 std::fs::create_dir_all(&project_dir)?;
2386 let session_uuid = "55555555-5555-5555-5555-555555555555";
2387
2388 let row = serde_json::json!({
2390 "type": "user",
2391 "uuid": "u-orphan",
2392 "sessionId": session_uuid,
2393 "cwd": "/tmp/pond-test",
2394 "timestamp": "2026-05-16T00:00:00.000Z",
2395 "message": {
2396 "role": "user",
2397 "content": [{
2398 "type": "tool_result",
2399 "tool_use_id": "toolu_orphan",
2400 "content": "result body, no matching call",
2401 }],
2402 },
2403 });
2404 std::fs::write(
2405 project_dir.join(format!("{session_uuid}.jsonl")),
2406 format!("{row}\n"),
2407 )?;
2408
2409 let store_dir = TempDir::new()?;
2410 let store = Store::open_local(store_dir.path()).await?;
2411 let adapter = ClaudeCodeAdapter::new(corpus.path());
2412 let _summary =
2413 ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2414 let session = store
2415 .get_session(session_uuid)
2416 .await?
2417 .expect("session ingests");
2418 let mut found = false;
2419 for stored in &session.messages {
2420 for part in &stored.parts {
2421 if let PartKind::ToolResult { name, call_id, .. } = &part.kind {
2422 assert_eq!(call_id.as_ref().map(|e| e.as_str()), Some("toolu_orphan"));
2423 assert!(
2424 name.is_none(),
2425 "orphan tool_result must be name=None, not synthesized 'unknown'",
2426 );
2427 found = true;
2428 }
2429 }
2430 }
2431 assert!(found, "orphan tool_result part must be present");
2432 Ok(())
2434 }
2435
2436 #[tokio::test(flavor = "multi_thread")]
2437 async fn unknown_message_role_becomes_lossless_carrier() -> anyhow::Result<()> {
2438 let corpus = TempDir::new()?;
2439 let project_dir = corpus.path().join("-tmp-pond-test");
2440 std::fs::create_dir_all(&project_dir)?;
2441 let session_uuid = "66666666-6666-6666-6666-666666666666";
2442
2443 let row = serde_json::json!({
2446 "type": "user",
2447 "uuid": "u-future",
2448 "sessionId": session_uuid,
2449 "cwd": "/tmp/pond-test",
2450 "timestamp": "2026-05-16T00:00:00.000Z",
2451 "message": {
2452 "role": "future_role",
2453 "content": "keep me",
2454 },
2455 });
2456 std::fs::write(
2457 project_dir.join(format!("{session_uuid}.jsonl")),
2458 format!("{row}\n"),
2459 )?;
2460
2461 let store_dir = TempDir::new()?;
2462 let store = Store::open_local(store_dir.path()).await?;
2463 let adapter = ClaudeCodeAdapter::new(corpus.path());
2464 let summary = ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2465 assert!(
2466 summary.drop_reasons.is_empty(),
2467 "an unknown role must be carried, not dropped: {:?}",
2468 summary.drop_reasons,
2469 );
2470 let session = store
2471 .get_session(session_uuid)
2472 .await?
2473 .expect("session with the carried record ingests");
2474 let carrier = session
2475 .messages
2476 .iter()
2477 .find(|stored| stored.message.id() == "u-future")
2478 .expect("the unknown-role record lands as a message");
2479 assert!(
2480 matches!(&carrier.message, Message::System { content, .. }
2481 if content.as_deref().is_some_and(|c| c.contains("future_role"))),
2482 "unmapped role must become a System carrier preserving the record",
2483 );
2484 Ok(())
2485 }
2486
2487 #[test]
2491 fn workflow_journal_is_excluded_from_the_walk() {
2492 let adapter = ClaudeCodeAdapter::new("/tmp/pond-test-root");
2493 let journal = std::path::Path::new(
2494 "/root/-proj/55555555-5555-5555-5555-555555555555/subagents/workflows/wf_030e6487-da6/journal.jsonl",
2495 );
2496 assert!(is_workflow_control_file(journal));
2497 assert!(
2498 adapter.skip_source(journal),
2499 "journal.jsonl is a known control file, excluded from the walk",
2500 );
2501 }
2502
2503 #[test]
2507 fn unknown_subagents_leaf_is_still_unsupported() {
2508 let adapter = ClaudeCodeAdapter::new("/tmp/pond-test-root");
2509 let unknown = std::path::Path::new(
2510 "/root/-proj/PARENT/subagents/workflows/wf_x/transcript-001.jsonl",
2511 );
2512 assert!(
2513 adapter.unsupported_reason(unknown).is_some(),
2514 "an unrecognized non-agent, non-journal leaf must still fail visibly",
2515 );
2516 assert!(!is_workflow_control_file(unknown));
2517 assert!(
2518 !adapter.skip_source(unknown),
2519 "only the exact journal.jsonl leaf may leave the walk - an unknown \
2520 leaf stays in so its unsupported skip stays visible",
2521 );
2522
2523 let agent = std::path::Path::new("/root/-proj/PARENT/subagents/agent-abc123def456.jsonl");
2524 assert!(
2525 adapter.unsupported_reason(agent).is_none(),
2526 "a recognized agent transcript is resolvable, not unsupported",
2527 );
2528 }
2529
2530 #[tokio::test(flavor = "multi_thread")]
2535 async fn workflow_journal_excluded_while_sibling_agent_ingests() -> anyhow::Result<()> {
2536 let corpus = TempDir::new()?;
2537 let project_dir = corpus.path().join("-tmp-pond-test");
2538 let parent_uuid = "77777777-7777-7777-7777-777777777777";
2539 let wf_id = "wf_030e6487-da6";
2540 let agent_hash = "a38f4724ef3864da8";
2541 let wf_dir = project_dir
2542 .join(parent_uuid)
2543 .join("subagents")
2544 .join("workflows")
2545 .join(wf_id);
2546 std::fs::create_dir_all(&wf_dir)?;
2547
2548 let parent_row = serde_json::json!({
2549 "type": "user",
2550 "uuid": "u-parent-1",
2551 "sessionId": parent_uuid,
2552 "cwd": "/tmp/pond-test",
2553 "timestamp": "2026-06-04T00:00:00.000Z",
2554 "message": {"role": "user", "content": "hi parent"},
2555 });
2556 std::fs::write(
2557 project_dir.join(format!("{parent_uuid}.jsonl")),
2558 format!("{parent_row}\n"),
2559 )?;
2560
2561 let agent_row = serde_json::json!({
2562 "type": "user",
2563 "uuid": "u-agent-1",
2564 "sessionId": parent_uuid,
2565 "cwd": "/tmp/pond-test",
2566 "timestamp": "2026-06-04T00:01:00.000Z",
2567 "message": {"role": "user", "content": "workflow agent prompt"},
2568 });
2569 std::fs::write(
2570 wf_dir.join(format!("agent-{agent_hash}.jsonl")),
2571 format!("{agent_row}\n"),
2572 )?;
2573
2574 std::fs::write(
2576 wf_dir.join("journal.jsonl"),
2577 "{\"type\":\"started\",\"key\":\"v2:abc\",\"agentId\":\"a38f\"}\n\
2578 {\"type\":\"result\",\"key\":\"v2:abc\",\"agentId\":\"a38f\",\"result\":{}}\n",
2579 )?;
2580
2581 let store_dir = TempDir::new()?;
2582 let store = Store::open_local(store_dir.path()).await?;
2583 let adapter = ClaudeCodeAdapter::new(corpus.path());
2584 let summary = ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2585 assert_eq!(
2586 summary.skipped_files, 0,
2587 "journal.jsonl is a control file excluded from the walk, not an unsupported failure",
2588 );
2589
2590 let child = store
2591 .get_session(&format!(
2592 "{parent_uuid}/workflows/{wf_id}/agent-{agent_hash}"
2593 ))
2594 .await?
2595 .expect("the sibling agent transcript still ingests as a child session");
2596 assert_eq!(
2597 child.session.parent_session_id.as_deref(),
2598 Some(parent_uuid)
2599 );
2600
2601 let parent = store
2602 .get_session(parent_uuid)
2603 .await?
2604 .expect("parent session ingests");
2605 assert_eq!(
2606 parent.messages.len(),
2607 1,
2608 "journal rows must NOT merge into the parent session",
2609 );
2610 Ok(())
2611 }
2612
2613 #[tokio::test(flavor = "multi_thread")]
2617 async fn workflow_journal_with_parent_sessionid_still_not_merged() -> anyhow::Result<()> {
2618 let corpus = TempDir::new()?;
2619 let project_dir = corpus.path().join("-tmp-pond-test");
2620 let parent_uuid = "88888888-8888-8888-8888-888888888888";
2621 let wf_dir = project_dir
2622 .join(parent_uuid)
2623 .join("subagents")
2624 .join("workflows")
2625 .join("wf_abc01234-def");
2626 std::fs::create_dir_all(&wf_dir)?;
2627
2628 let parent_row = serde_json::json!({
2629 "type": "user",
2630 "uuid": "u-parent",
2631 "sessionId": parent_uuid,
2632 "cwd": "/tmp/pond-test",
2633 "timestamp": "2026-06-04T00:00:00.000Z",
2634 "message": {"role": "user", "content": "parent only"},
2635 });
2636 std::fs::write(
2637 project_dir.join(format!("{parent_uuid}.jsonl")),
2638 format!("{parent_row}\n"),
2639 )?;
2640
2641 let journal_row = serde_json::json!({
2644 "type": "started",
2645 "key": "v2:abc",
2646 "agentId": "a1",
2647 "sessionId": parent_uuid,
2648 "message": {"role": "user", "content": "must not merge"},
2649 });
2650 std::fs::write(wf_dir.join("journal.jsonl"), format!("{journal_row}\n"))?;
2651
2652 let store_dir = TempDir::new()?;
2653 let store = Store::open_local(store_dir.path()).await?;
2654 let adapter = ClaudeCodeAdapter::new(corpus.path());
2655 let summary = ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
2656 assert_eq!(
2657 summary.skipped_files, 0,
2658 "journal is excluded from the walk, not an unsupported failure",
2659 );
2660 let parent = store
2661 .get_session(parent_uuid)
2662 .await?
2663 .expect("parent session ingests");
2664 assert_eq!(
2665 parent.messages.len(),
2666 1,
2667 "journal row must NOT merge even when it carries the parent sessionId",
2668 );
2669 Ok(())
2670 }
2671}