Skip to main content

pond/adapter/
claude_code.rs

1//! Claude Code CLI adapter.
2//!
3//! Source path: `~/.claude/projects/<encoded-project-path>/<session-uuid>.jsonl`.
4//! Each `.jsonl` file is one session; lines are typed entries linked via a
5//! `parentUuid` -> `uuid` chain. Tool results arrive as `user` entries whose
6//! `message.content[]` contains `tool_result` blocks with a parallel
7//! `toolUseResult` field carrying structured data.
8
9use 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/// Per-file streaming state that persists across rows of one JSONL file.
40/// Lives inside [`Adapter::events`]'s per-file loop and is reset whenever
41/// the loop advances to the next file.
42///
43/// Two responsibilities:
44///
45/// 1. **Replay dedup.** Claude Code's `/resume` and `/compact` paths
46///    occasionally re-emit byte-identical rows with the same `uuid` (the
47///    stale-`messageSet`-cache bug in claude-code, see
48///    `utils/sessionStorage.ts`). The adapter dedupes only byte-identical
49///    replays; same-uuid/different-content reaches the validator visibly
50///    (spec.md#adapter-integrity-dedup).
51///
52/// 2. **`tool_use_id -> tool name` resolution.** The raw `tool_result` row
53///    carries only `tool_use_id`, not the tool name; the name lives on the
54///    prior `tool_use` row in the same file. We populate this map when we
55///    see a `tool_use` part, then look it up when we see the matching
56///    `tool_result` part. Misses (e.g. compaction pruned the tool_use)
57///    surface as `name: None` in `PartKind::ToolResult` rather than the
58///    old `"unknown"` sentinel - faithful to the source rather than
59///    inventing a value.
60#[derive(Debug, Default)]
61pub(crate) struct FileState {
62    seen_records: HashSet<(String, u64)>,
63    tool_call_names: HashMap<String, Extracted<String>>,
64}
65
66/// Stable adapter name. Surfaces as the `[adapters.claude-code]` config key,
67/// the `pond sync claude-code` CLI arg, and `Session.source_agent` on every
68/// emitted row.
69const NAME: &str = "claude-code";
70
71/// Stateless factory: opens [`ClaudeCodeAdapter`] instances from config and
72/// probes for the canonical install location under `~/.claude/projects`.
73pub 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
105/// Shared claude-JSONL restore: build the transcript records (native replays
106/// the stored `raw_record`s, foreign re-derives via [`claude_record`]) and write
107/// them to `transcript_path`, plus the subagent `.meta.json` sidecar when the
108/// session carries one. nanoclaw rides this with its own layout prefix; only
109/// `transcript_path` and the error-attribution `adapter` differ between the two
110/// claude-JSONL sources.
111pub(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    // Native replays the verbatim `options.source.raw_record`; `claude_record`
128    // below is foreign-only. Replay echoes a frozen snapshot - safe only while
129    // canonical is append-only (spec.md#adapter-integrity-additive-sync).
130    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        // `claude_record` returns `None` for a dropped System message;
145        // `parent_uuid` then stays put so the chain skips over the gap.
146        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        // The child id is `<parent>/<child_suffix>`; the suffix is the file's
192        // path under `subagents/` (`agent-<hash>` flat, or
193        // `workflows/<wf-id>/agent-<hash>` nested), so stripping the parent
194        // prefix reconstructs the on-disk path verbatim.
195        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
212/// Shared claude-JSONL row mapping: replay dedup, `tool_use_id -> name`
213/// capture, then the per-row canonical events. nanoclaw reuses it verbatim
214/// (same transcript family) with its own session identity supplied via
215/// `session_id`/`created_at`.
216pub(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
233/// Shared claude-JSONL freshness watermark: the latest timestamped row from a
234/// bounded tail peek, or [`SourceWatermark::Empty`] when a whole-file scan finds
235/// no timestamped row (that file cannot anchor a session, so it ingests
236/// nothing). See the trait doc on [`JsonlTree::peek_watermark`].
237pub(crate) fn claude_peek_watermark(path: &Path) -> crate::adapter::SourceWatermark {
238    // Claude Code appends trailing metadata rows (`last-prompt`,
239    // `permission-mode`, `bridge-session`, ...) with no timestamp after the
240    // conversation, so the literal last line is usually not a message. Walk
241    // back to the latest row that carries a timestamp - the real watermark.
242    // Taking only the last line stranded ~2k sessions perpetually un-fresh,
243    // re-decoding ~1.2M already-stored rows every sync; the Empty proof below is
244    // locked to real ingest by `keyless_file_peeks_empty_and_ingests_nothing`.
245    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    // No timestamped row found. A whole-file scan (len <= TAIL_CAP) proving no
252    // timestamped row is a proof of emptiness (session anchoring runs the same
253    // `parse_timestamp`); a larger file's scan is a window, so it stays opaque.
254    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    // Restore the sidecar `.meta.json` verbatim from the stored copy. A
264    // subagent ingested without a meta file stored `meta: null` - nothing
265    // to write back.
266    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    // Foreign restore into Claude Code (native restore re-emits the stored
276    // `raw_record` and never reaches here). Claude Code's transcript has only
277    // `user` and `assistant` rows: a tool result is a `user` row, and there
278    // is no in-transcript system turn - a System message (a rule-3 carrier or
279    // a source's own system/developer turn) has no idiomatic home and is
280    // dropped; the content stays in canonical (spec.md#adapter-native-restore-lossless,
281    // foreign clause).
282    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        // `type:"message"` is the Anthropic Messages API object discriminator
291        // - a constant, always present on a real assistant row.
292        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/// Configured claude-code reader. Walks a tree of `*.jsonl` files under
364/// [`Self::root`] and yields canonical events in source order per session.
365#[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        // A file under `subagents/` takes its id from the path, never from the
403        // row's content `sessionId` (that's the parent's). A recognized child
404        // peeks to its child id; an unrecognized one returns `None` so it stays
405        // out of the freshness gate and its `unsupported_reason` failure
406        // re-surfaces on every sync rather than being skipped as `Fresh` under
407        // the parent's borrowed watermark. See spec.md#datasets.
408        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        // The Workflow runner's `journal.jsonl` never reaches this check -
435        // `skip_source` excludes it from the walk - and if it ever did, a visible
436        // skip is the safe answer. See spec.md#datasets.
437        subagent_unsupported_reason(path)
438    }
439
440    fn skip_source(&self, path: &Path) -> bool {
441        is_workflow_control_file(path)
442    }
443}
444
445// spec.md#adapter-integrity-dedup: hash only semantic fields so noise-field
446// replays (timestamp, requestId, isMeta, gitBranch, version, ...) dedupe;
447// real content diffs still reach the validator.
448fn 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
472/// The Workflow runner writes `journal.jsonl` (its resume/cache journal of agent
473/// `started`/`result` events) beside the `agent-<hash>.jsonl` transcripts under
474/// `subagents/workflows/<wf-id>/`. It carries no `sessionId` and only duplicates
475/// content already in those transcripts, so it is a control file excluded from
476/// the walk outright (`skip_source`): never a source, never read, never pending.
477/// One accumulates per Workflow run, and none can ever earn a freshness key -
478/// left in the walk they'd grow `pond status`'s pending count without bound.
479/// See spec.md#datasets.
480pub(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
485/// A `.jsonl` under a `subagents/` ancestor whose leaf we can't resolve to a
486/// child id (it isn't `agent-<hash>.jsonl`) must NOT fall back to its content
487/// `sessionId` - that id is the parent's, so it would silently merge into the
488/// parent session. Every claude-JSONL source (claude_code, nanoclaw) refuses it
489/// identically; only the recovery surface differs. `unsupported_reason` returns
490/// the user-facing skip message; the ingest guard returns the typed refusal.
491pub(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
503/// The ingest-time analog of [`subagent_unsupported_reason`]: the typed schema
504/// error for an unresolved `subagents/` leaf, attributed to `adapter`.
505pub(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
519/// Walk one raw row's `message.content[]` array (if any) and stash every
520/// `tool_use` part's `id -> name` mapping into the per-file map. Idempotent
521/// and safe to call on every row regardless of role; non-assistant rows
522/// just don't contribute entries.
523fn 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    // A non-agent leaf under `subagents/` (e.g. the Workflow runner's
546    // journal.jsonl) would borrow the parent's content `sessionId` and silently
547    // merge; refuse structurally rather than rely on the row lacking one.
548    // spec.md#datasets.
549    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    // A forked subagent transcript (Claude Code >= 2.1.117 `/fork`) opens with a
576    // `fork-context-ref` header row that carries no `sessionId` - the id first
577    // appears on the following message row. Scan for it rather than demanding it
578    // on record 0, or the whole transcript is dropped
579    // (spec.md#adapter-integrity-no-silent-drops). A subagent derives its id from
580    // the path regardless, so it tolerates the id being absent entirely; only a
581    // top-level session (below) genuinely requires one.
582    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    // Subagent detection. Claude Code stores each subagent's transcript under
591    // the session's `subagents/` sidecar - either flat
592    // (`<parent_dir>/<parent_uuid>/subagents/agent-<hash>.jsonl`) or, for the
593    // workflow runner, nested
594    // (`.../subagents/workflows/<wf-id>/agent-<hash>.jsonl`) - with a sibling
595    // `agent-<hash>.meta.json` carrying `{agentType, description}`. Every such
596    // file shares the parent's `sessionId` in row content, so ingesting it under
597    // that id collides with the parent (the validator's "project is immutable"
598    // rule rejects a cwd-shifted one, and a same-cwd one silently merges). The
599    // fix is to derive a child id from the path - keyed off the `subagents/`
600    // ancestor at any depth - and link back via `parent_session_id`. See
601    // spec.md#datasets.
602    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            // `meta` is the verbatim `.meta.json`; `hash` and `raw_session_id`
618            // are pond-derived (filename hash + parent sessionId). Storing the
619            // whole meta keeps native restore of the sidecar lossless.
620            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            // A top-level session has no path-derived id, so it genuinely
629            // requires a `sessionId` somewhere in the file.
630            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    // The project dir is the grandparent of `subagents/` regardless of how
693    // deeply the transcript nests below it (`.../<project>/<parent_uuid>/
694    // subagents/...`), so climb from the `subagents/` ancestor rather than a
695    // fixed number of `.parent()` hops.
696    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
707/// The `subagents/` directory in `path`'s ancestry, if any. Depth-independent:
708/// matches both the flat `<parent_uuid>/subagents/agent-<hash>.jsonl` and the
709/// nested workflow `<parent_uuid>/subagents/workflows/<wf-id>/agent-<hash>.jsonl`
710/// layouts. The directory directly above it is the parent session uuid.
711pub(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
722/// Resolved metadata for one subagent JSONL file. `agent_type` is read from
723/// the sibling `.meta.json` for the `source_agent` label; `meta` keeps that
724/// file's full verbatim content so native restore reproduces it
725/// (spec.md#adapter-native-restore-lossless). Both are `None` when the meta file is
726/// absent or unreadable (the label falls back to `claude-code/subagent`).
727pub(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
735/// `(parent_uuid, child_suffix, agent_hash)` for a subagent transcript, or
736/// `None` for any path without a `subagents/` ancestor or a non-`agent-<hash>`
737/// leaf (the common case: top-level session files). `child_suffix` is the file's
738/// path relative to its `subagents/` ancestor with `.jsonl` stripped -
739/// `agent-<hash>` flat, `workflows/<wf-id>/agent-<hash>` nested - so the derived
740/// child id `<parent_uuid>/<child_suffix>` round-trips back to the on-disk path
741/// on native restore. `agent_hash` keys the sibling `.meta.json` lookup.
742pub(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    // The child id must be `/`-canonical on every platform (the rest of the
751    // adapter and `claude_relative_path` assume `/`), but a relative path carries
752    // the OS separator - normalize it. No-op on POSIX.
753    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
762/// [`subagent_ids`] plus the sibling `agent-<hash>.meta.json` - `agentType` for
763/// the `source_agent` label, the whole file for lossless sidecar restore.
764pub(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    // Rows with no `message` field are session-metadata records:
833    // `queue-operation`, `permission-mode`, `last-prompt`, `attachment`,
834    // `progress`, `system`, `custom-title`, etc. We preserve them as
835    // System messages with the row's compact JSON in `content` so a future
836    // exporter could reconstruct the original transcript; the `subtype`
837    // becomes the human label via `options.source.raw_type`.
838    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            // spec.md#model-part-provenance: a user-slot turn is conversation only
874            // when it is a genuine human prompt; harness-injected wrappers and
875            // `isMeta` rows are scaffolding.
876            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            // Classify the whole user message once: v1 claude-code never mixes
912            // provenance within a single message (spec.md#model-part-provenance).
913            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            // Fallback for system messages without a string content: serialize
950            // the structured body as JSON. This is not a synthesized value
951            // (the row genuinely had this content), just a lossless string
952            // encoding of structured data.
953            content: Some(extract_compact_repr(message_value)),
954            options: row_options(row, line),
955        },
956        // spec.md#adapters rule-3: a record that maps to no typed Message is
957        // carried whole as a system-role Message, not rejected, so an unknown or
958        // future role stays lossless (the full row lives in options.raw_record).
959        _ => 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        // Unknown user part shapes: preserve the raw JSON in the Text slot
1015        // rather than dropping. This is not a synthesized value - it's a
1016        // lossless encoding of structured data the schema doesn't model.
1017        _ => 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    // spec.md#model-part-provenance: assistant content - text, reasoning, tool calls -
1029    // is model-authored, hence conversational. `tool_result` parts never appear
1030    // on an assistant message.
1031    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        // Same rationale as `user_part`'s fallback: lossless encoding of
1086        // an unrecognised structured shape, not synthesised data.
1087        _ => 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    // `tool_result` source rows don't carry the tool name; it's resolved
1107    // via the per-file `tool_use_id -> name` map. Misses (compaction pruned
1108    // the originating `tool_use`) surface as `None` per spec.md#model-no-synthesis
1109    // (schema-honesty: the field is `Option<Extracted<T>>`, not a fabricated
1110    // string).
1111    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        // spec.md#model-part-provenance: tool output is runtime-produced, not
1126        // conversation.
1127        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
1245/// True when the row carries `isMeta: true` - claude-code's marker for an
1246/// expanded skill or command body injected into a user slot.
1247fn is_meta_row(row: &Value) -> bool {
1248    row.get("isMeta").and_then(Value::as_bool) == Some(true)
1249}
1250
1251/// Harness-injected wrappers claude-code places inside a user-slot turn
1252/// (spec.md#model-part-provenance): task notifications, slash-command echoes,
1253/// local-command caveats, interrupt notices.
1254fn 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
1265/// Provenance of a string-content user message: `injected` for an `isMeta`
1266/// row or a harness wrapper, `conversational` for a genuine human prompt.
1267fn 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
1275/// Provenance of an array-content user message. `isMeta` flags the whole row;
1276/// otherwise a leading text item carrying a harness wrapper marks it injected.
1277/// v1 claude-code never interleaves both within one message.
1278fn 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    //! Conformance tests for the claude-code adapter's data-shape contract:
1299    //! subagent path derivation, replay dedup, tool-name resolution, and the
1300    //! "no synthesized values" invariant (spec.md#model-no-synthesis, spec.md#model-schema-honesty, and spec.md#model-lossless-projection).
1301    //!
1302    //! Each test builds a tiny synthetic corpus under a `TempDir` so the
1303    //! assertions exercise the real adapter end-to-end without depending on
1304    //! committed fixtures.
1305    #![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    // Manifest-dir anchored: unit tests must not depend on the process cwd
1312    // (figment::Jail chdirs the whole test process while config tests run).
1313    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    /// `source_record_hash` must dedupe noise-field replays (whitespace,
1327    /// `timestamp`, `requestId`) and let semantic-content differences through
1328    /// so a same-uuid row with a different `message.content` still reaches
1329    /// the validator (spec.md#adapter-integrity-dedup).
1330    #[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    /// `plan` is the events_with freshness gate run standalone: an empty
1385    /// oracle marks everything pending (walk cost only), a saturated oracle
1386    /// marks every readable-id session fresh, and the counts always partition
1387    /// `sessions`.
1388    #[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    /// Sessions that can never earn a stored watermark - a zero-byte file, a
1412    /// metadata-only transcript with no timestamped row - gate `Empty` (proven
1413    /// nothing to ingest), and a workflow `journal.jsonl` leaves the walk
1414    /// entirely. Otherwise a store that syncs clean reports as forever out of
1415    /// date. The Empty proof is locked to real ingest below
1416    /// (`keyless_file_peeks_empty_and_ingests_nothing`).
1417    #[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    /// The `Empty` proof's lock: a file the peek judges `Empty` (no timestamped
1473    /// row in a whole-file scan) MUST ingest zero rows through the real ingest
1474    /// path - peek and session anchoring share `parse_timestamp`, and this test
1475    /// fails the moment ingest learns to anchor such files without the peek
1476    /// being updated in the same change (spec.md#session-movement-complete).
1477    #[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    /// `<root>/<encoded-cwd>/<parent_uuid>.jsonl` plus
1516    /// `<root>/<encoded-cwd>/<parent_uuid>/subagents/agent-<hash>.jsonl` plus
1517    /// `agent-<hash>.meta.json`. The subagent file must:
1518    ///   - emit a Session whose `id = "{parent_uuid}/agent-{hash}"`
1519    ///   - have `parent_session_id = Some(parent_uuid)`
1520    ///   - have `source_agent = "claude-code/{agentType}"` from the meta file
1521    ///   - have `options.subagent` carrying the hash + agent_type + description
1522    #[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        // Parent session file (one user row to anchor a Session).
1531        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        // Subagent file + sibling meta. Carries the SAME sessionId as the parent
1546        // in row content; the adapter must derive a child id from the path.
1547        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    /// A forked subagent transcript (Claude Code >= 2.1.117 `/fork`) opens with
1622    /// a `fork-context-ref` header row that carries no `sessionId`; the id first
1623    /// appears on the following message row. The whole transcript must still
1624    /// ingest - id derived from the path, header preserved as a System message,
1625    /// the conversation turns landing as their own messages - not be dropped as
1626    /// "line 1 missing sessionId" (that pre-fix drop silently lost every forked
1627    /// subagent's conversation). Fails on `main`; passes with the fix.
1628    #[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        // Parent anchor.
1637        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        // Fork transcript: a `fork-context-ref` header (NO sessionId, NO
1652        // timestamp) followed by the inherited-context conversation turns.
1653        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        // The inherited-context conversation must survive - this is the data the
1722        // pre-fix drop was losing.
1723        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    /// Subagent file present but the sibling `.meta.json` is missing. The
1741    /// adapter must still derive a child session (so it doesn't collide with
1742    /// the parent) and fall back to `source_agent = "claude-code/subagent"`.
1743    #[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    /// Nested workflow-runner subagent:
1781    ///   `<parent_uuid>/subagents/workflows/<wf-id>/agent-<hash>.jsonl`.
1782    /// Same parent `sessionId` in row content AND a shifted `cwd`. The adapter
1783    /// must derive a distinct child id from the FULL path under `subagents/`
1784    /// (not collapse onto the parent), so it neither collides on the immutable
1785    /// `project` nor silently merges into the parent. Regression for the
1786    /// workflow-layout sync rejection. See spec.md#datasets.
1787    #[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        // Shifted cwd: pre-fix this collided with the parent's immutable project.
1816        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    /// A `.jsonl` under `subagents/` whose leaf is NOT `agent-<hash>.jsonl` (a
1875    /// layout this pond version doesn't understand) must FAIL VISIBLY rather
1876    /// than fall back to its content `sessionId` (the parent's) and silently
1877    /// merge into the parent session. It is counted as an unsupported skip and
1878    /// contributes no rows. See spec.md#datasets.
1879    #[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        // Same parent sessionId AND same cwd: pre-guard this would have merged
1905        // silently into the parent. The leaf name is not `agent-<hash>.jsonl`.
1906        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    /// Re-sync visibility: an unrecognized `subagents/` file must STILL surface as
1948    /// a visible `Unsupported` skip when the parent already carries a freshness
1949    /// watermark. Its content `sessionId` is the parent's, so peeking it would let
1950    /// the freshness gate skip the file as `Fresh` under the parent's watermark and
1951    /// hide the failure. `peek_session_id` returns `None` for it instead, keeping
1952    /// it out of the gate. Regression for the re-sync visibility leak. See
1953    /// spec.md#datasets.
1954    #[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                // Far-future watermark: the parent file WOULD trip the freshness
1961                // gate (source ts <= watermark). The guard must keep the
1962                // unrecognized file out of the gate regardless.
1963                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        // Same parent sessionId, leaf not `agent-<hash>.jsonl`: pre-fix this would
1994        // peek the parent's id and be fresh-skipped under the far-future watermark.
1995        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        // The parent file legitimately fresh-skips under the far-future watermark;
2018        // the unrecognized file must NOT join it (pre-fix `skipped_fresh` would be 2).
2019        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    /// Three rows with the same `uuid` (the claude-code `/resume` replay
2027    /// pattern). The adapter must dedupe at the file-state level so the
2028    /// validator never sees the duplicates; `dropped_events` stays 0 and
2029    /// `inserted` covers the single canonical row.
2030    #[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        // Three identical rows back-to-back, same uuid.
2046        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    /// Claude Code appends trailing metadata rows (`last-prompt`,
2163    /// `permission-mode`, ...) with no timestamp after the conversation. The
2164    /// freshness peek must walk back past them to the last real message's
2165    /// timestamp - taking only the literal last line returned None and stranded
2166    /// ~2k sessions perpetually un-fresh, re-decoding ~1.2M stored rows every sync.
2167    #[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        // Metadata rows Claude Code writes after the conversation - no timestamp.
2182        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    /// One assistant `tool_use` followed by a user `tool_result` in the same
2200    /// file. The adapter's per-file `tool_use_id -> name` map must resolve the
2201    /// result's tool name to the call's name. Pre-fix: synthesized `"unknown"`.
2202    #[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    /// spec.md#model-part-provenance: a genuine human prompt classifies
2292    /// `conversational`; a harness `<task-notification>` user-slot turn and an
2293    /// `isMeta` row classify `injected`.
2294    #[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                &notification,
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    /// Ingest a session carrying a `<task-notification>` user message and a
2319    /// genuine prompt; the notification's part must be `injected` and the
2320    /// prompt's `conversational` (spec.md#model-part-provenance).
2321    #[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    /// Orphan tool_result with no earlier tool_use in the same file: the
2379    /// per-file map can't resolve. The adapter must emit `name: None`, NOT
2380    /// the old `"unknown"` sentinel. Invariant 15 (no synthesized values).
2381    #[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        // tool_result with no earlier tool_use (simulates a compaction-pruned call).
2389        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        // Sanity: even an orphan should not be reported as a drop.
2433        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        // A role pond has no typed variant for must be carried whole, not
2444        // rejected (spec.md#adapters rule-3).
2445        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    /// The Workflow runner's `journal.jsonl` under `subagents/workflows/<wf>/`
2488    /// is a known control file: `skip_source` excludes it from the walk so it
2489    /// is never a session, never read, and never counted pending.
2490    #[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    /// Regression guard against narrowing the net too far: a genuinely unknown
2504    /// leaf under `subagents/` is still flagged unsupported, while a recognized
2505    /// `agent-<hash>.jsonl` is not.
2506    #[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    /// End-to-end: a workflow dir holding both a real `agent-<hash>.jsonl`
2531    /// transcript and the runner's `journal.jsonl`. The agent transcript
2532    /// ingests as a child session; the journal is excluded from the walk (no
2533    /// `skipped_files` failure) and its rows never merge into the parent.
2534    #[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        // The Workflow journal: control events only, no sessionId.
2575        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    /// Hardening: even a journal.jsonl whose rows DO carry the parent
2614    /// `sessionId` must not merge - the guard is structural, not contingent on
2615    /// the journal lacking one.
2616    #[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        // A journal carrying the PARENT sessionId (hypothetical future shape):
2642        // the structural guard must still refuse to merge it.
2643        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}