Skip to main content

zeph_memory/graph/ingest/
adapter.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Sealed [`IngestSourceAdapter`] trait and its MVP implementations (spec-067 §2.3).
5//!
6//! Adapters are pure (no I/O) — they receive raw text (or pre-parsed JSONL) and produce
7//! [`super::IngestDocument`] values. All I/O (disk reads, network) happens at the binary
8//! layer before the adapter is invoked.
9//!
10//! External-agent adapters ([`ClaudeCodeJsonl`], [`CodexJsonl`]) are **strict and
11//! version-pinned**: any record whose structure deviates from the pinned schema causes an
12//! `Err` return rather than silent best-effort parsing. This is spec-067 Phase 3 requirement
13//! D1 — unknown schema versions must fail loud.
14
15use serde::Deserialize;
16use zeph_llm::provider::Message;
17
18use crate::MemoryError;
19use crate::graph::ingest::document::IngestSourceKind;
20use crate::graph::types::{GraphOrigin, GraphProvenance};
21
22use super::document::IngestDocument;
23use super::report::ImportBatchId;
24
25mod sealed {
26    pub trait Sealed {}
27}
28
29/// Build one [`IngestDocument`] per non-empty `texts[i]`, pairing it with `ids[i]` and the
30/// `context_window` preceding texts.
31///
32/// Shared by every [`IngestSourceAdapter`] impl in this module — they differ only in how
33/// `source_uri` is formatted and which [`GraphOrigin`] tags the provenance.
34fn build_ingest_documents(
35    texts: &[String],
36    ids: &[String],
37    context_window: usize,
38    batch_id: &ImportBatchId,
39    origin: GraphOrigin,
40    source_uri: impl Fn(&str) -> String,
41) -> Vec<IngestDocument> {
42    let mut docs = Vec::with_capacity(texts.len());
43    for (i, (content, id)) in texts.iter().zip(ids.iter()).enumerate() {
44        if content.trim().is_empty() {
45            continue;
46        }
47        let start = i.saturating_sub(context_window);
48        let ctx: Vec<String> = texts[start..i].to_vec();
49        let provenance = GraphProvenance {
50            origin,
51            import_batch_id: batch_id.as_str().to_owned(),
52            source_uri: Some(source_uri(id)),
53        };
54        docs.push(IngestDocument::new(content.clone(), ctx, provenance));
55    }
56    docs
57}
58
59/// Adapter that converts raw source material into [`IngestDocument`] values.
60///
61/// The trait is sealed — only implementations in `zeph-memory` are permitted.
62/// This preserves the valid-by-construction invariants of [`IngestDocument`]:
63/// non-empty content, non-empty source URI, and a hash computed from content.
64///
65/// # Design
66///
67/// Adapters are pure: no async, no I/O, no external crate dependencies beyond
68/// `zeph-llm` (which `zeph-memory` already depends on). The binary layer reads
69/// raw bytes off disk and passes the string in via [`IngestSourceAdapter::parse`].
70pub trait IngestSourceAdapter: sealed::Sealed + Send + Sync {
71    /// Parse `raw` source material into a list of [`IngestDocument`] values.
72    ///
73    /// `batch_id` is embedded in every document's provenance so that all documents
74    /// produced in one `ingest_documents` call share the same rollback key.
75    ///
76    /// # Contract
77    ///
78    /// Implementations SHOULD skip malformed individual records (e.g. a single
79    /// unparseable JSONL line) and continue parsing the rest of the input — partial
80    /// parse errors are not fatal.  `Err` is returned only for an irrecoverable
81    /// format error where no documents could be produced from `raw` at all.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`MemoryError::Ingest`] only for irrecoverable format errors where
86    /// no documents could be produced from `raw`. Malformed individual records are
87    /// skipped with a warning and do not cause an `Err` return.
88    fn parse(
89        &self,
90        raw: &str,
91        batch_id: &ImportBatchId,
92    ) -> Result<Vec<IngestDocument>, MemoryError>;
93}
94
95/// A single entry in a subagent JSONL transcript.
96///
97/// The binary (`zeph-subagent`) writes transcripts as one JSON object per line.
98/// Each entry wraps a [`Message`] produced by the LLM or the agent.
99///
100/// Only `message` is used by the adapter — `seq` and `timestamp` are available
101/// for diagnostic purposes but are not stored in the graph.
102#[derive(Debug, Deserialize)]
103pub struct TranscriptEntry {
104    /// Zero-based sequence number within the session.
105    pub seq: u64,
106    /// ISO-8601 timestamp (opaque string, not parsed).
107    pub timestamp: Option<String>,
108    /// The LLM or agent message for this turn.
109    pub message: Message,
110}
111
112/// Adapter for subagent JSONL transcripts.
113///
114/// Each line in the transcript is a [`TranscriptEntry`]. The adapter produces one
115/// [`IngestDocument`] per entry whose flat text content is non-empty. Context for
116/// each entry is the plain text of the `context_window` preceding entries.
117///
118/// # Examples
119///
120/// ```no_run
121/// use zeph_memory::graph::ingest::{SubagentJsonl, IngestSourceAdapter, ImportBatchId};
122///
123/// let raw = r#"{"seq":0,"timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":"hello","parts":[]}}"#;
124/// let adapter = SubagentJsonl::new("task-42");
125/// let batch = ImportBatchId::new();
126/// let docs = adapter.parse(raw, &batch).unwrap();
127/// assert!(!docs.is_empty());
128/// ```
129pub struct SubagentJsonl {
130    task_id: String,
131    /// Number of preceding messages used as extraction context.
132    context_window: usize,
133}
134
135impl SubagentJsonl {
136    /// Creates a new `SubagentJsonl` adapter for the given task ID.
137    ///
138    /// `task_id` is embedded in every document's `source_uri` as
139    /// `"subagent:<task_id>#<seq>"`.
140    ///
141    /// The `context_window` defaults to 3 preceding messages.
142    #[must_use]
143    pub fn new(task_id: impl Into<String>) -> Self {
144        Self {
145            task_id: task_id.into(),
146            context_window: 3,
147        }
148    }
149
150    /// Overrides the context window size.
151    #[must_use]
152    pub fn with_context_window(mut self, n: usize) -> Self {
153        self.context_window = n;
154        self
155    }
156}
157
158impl sealed::Sealed for SubagentJsonl {}
159
160impl IngestSourceAdapter for SubagentJsonl {
161    /// Parse a JSONL transcript string.
162    ///
163    /// Lines that fail to parse are skipped with a warning; entries whose flat
164    /// text content is empty are also skipped (nothing to extract).
165    fn parse(
166        &self,
167        raw: &str,
168        batch_id: &ImportBatchId,
169    ) -> Result<Vec<IngestDocument>, MemoryError> {
170        let entries: Vec<TranscriptEntry> = raw
171            .lines()
172            .filter(|l| !l.trim().is_empty())
173            .filter_map(|line| match serde_json::from_str::<TranscriptEntry>(line) {
174                Ok(e) => Some(e),
175                Err(err) => {
176                    tracing::warn!("SubagentJsonl: skipping malformed line: {err}");
177                    None
178                }
179            })
180            .collect();
181
182        let texts: Vec<String> = entries
183            .iter()
184            .map(|e| e.message.to_llm_content().to_owned())
185            .collect();
186        let ids: Vec<String> = entries.iter().map(|e| e.seq.to_string()).collect();
187
188        Ok(build_ingest_documents(
189            &texts,
190            &ids,
191            self.context_window,
192            batch_id,
193            IngestSourceKind::SubagentTranscript.graph_origin(),
194            |id| format!("subagent:{}#{id}", self.task_id),
195        ))
196    }
197}
198
199// ── Claude Code JSONL adapter ────────────────────────────────────────────────
200
201/// Strict raw-record type for one line in a Claude Code `.jsonl` session file.
202///
203/// Only `type == "user" | "assistant"` lines are accepted; all other types
204/// (mode, permission-mode, file-history-snapshot, …) are silently skipped.
205/// A user/assistant line that lacks `message.role` is a schema-version error.
206#[derive(Debug, Deserialize)]
207struct ClaudeCodeRecord {
208    #[serde(rename = "type")]
209    record_type: String,
210    #[serde(default)]
211    uuid: Option<String>,
212    message: Option<ClaudeCodeMessage>,
213}
214
215/// `message` field inside a Claude Code user/assistant record.
216#[derive(Debug, Deserialize)]
217struct ClaudeCodeMessage {
218    role: String,
219    content: ClaudeCodeContent,
220}
221
222/// Content in a Claude Code message: either a plain string or an array of blocks.
223#[derive(Debug, Deserialize)]
224#[serde(untagged)]
225enum ClaudeCodeContent {
226    Text(String),
227    Blocks(Vec<ClaudeCodeBlock>),
228}
229
230/// One block inside a Claude Code content array.
231#[derive(Debug, Deserialize)]
232struct ClaudeCodeBlock {
233    #[serde(rename = "type")]
234    block_type: String,
235    /// Present in `text` and `thinking` blocks.
236    #[serde(default)]
237    text: Option<String>,
238    /// Present in `tool_use` blocks.
239    #[serde(default)]
240    name: Option<String>,
241}
242
243impl ClaudeCodeContent {
244    /// Flatten all extractable parts into a single string.
245    ///
246    /// Extracts `text` and `thinking` blocks verbatim.
247    /// For `tool_use` blocks, appends a short `<tool: name>` marker so that
248    /// tool-heavy assistant turns are not silently dropped.
249    fn to_text(&self) -> String {
250        match self {
251            Self::Text(s) => s.clone(),
252            Self::Blocks(blocks) => blocks
253                .iter()
254                .filter_map(|b| match b.block_type.as_str() {
255                    "text" | "thinking" => b.text.as_deref().map(str::to_owned),
256                    "tool_use" => Some(format!(
257                        "<tool: {}>",
258                        b.name.as_deref().unwrap_or("unknown")
259                    )),
260                    _ => None,
261                })
262                .collect::<Vec<_>>()
263                .join("\n"),
264        }
265    }
266}
267
268/// Adapter for Claude Code session `.jsonl` files (spec-067 §9 Phase 3).
269///
270/// **Strict and version-pinned**: only `"type": "user"` and `"type": "assistant"` records
271/// are processed. All other record types are silently skipped. A user/assistant record
272/// that lacks the expected `message` structure causes an `Err` — no silent mis-parsing.
273///
274/// Files are expected to be scoped to the current project by the calling binary layer
275/// (path enforcement via `~/.claude/projects/<project-slug>/`). This adapter performs
276/// no I/O and no path checks; it receives raw text from the binary layer.
277///
278/// # Examples
279///
280/// ```no_run
281/// use zeph_memory::graph::ingest::{ClaudeCodeJsonl, IngestSourceAdapter, ImportBatchId};
282///
283/// let raw = r#"{"type":"user","uuid":"abc","message":{"role":"user","content":"hello"}}"#;
284/// let adapter = ClaudeCodeJsonl::new("session-42");
285/// let batch = ImportBatchId::new();
286/// let docs = adapter.parse(raw, &batch).unwrap();
287/// assert!(!docs.is_empty());
288/// ```
289pub struct ClaudeCodeJsonl {
290    session_id: String,
291    context_window: usize,
292}
293
294impl ClaudeCodeJsonl {
295    /// Creates a new `ClaudeCodeJsonl` adapter for the given session ID.
296    ///
297    /// `session_id` is embedded in every document's `source_uri` as
298    /// `"claude-code:<session_id>#<uuid>"`.
299    ///
300    /// The `context_window` defaults to 3 preceding messages.
301    #[must_use]
302    pub fn new(session_id: impl Into<String>) -> Self {
303        Self {
304            session_id: session_id.into(),
305            context_window: 3,
306        }
307    }
308
309    /// Overrides the context window size.
310    #[must_use]
311    pub fn with_context_window(mut self, n: usize) -> Self {
312        self.context_window = n;
313        self
314    }
315}
316
317impl sealed::Sealed for ClaudeCodeJsonl {}
318
319impl IngestSourceAdapter for ClaudeCodeJsonl {
320    /// Parse a Claude Code `.jsonl` session file.
321    ///
322    /// Skips non-user/assistant records silently. A `user` or `assistant` record
323    /// that lacks the expected `message` structure is a schema error and causes `Err`.
324    fn parse(
325        &self,
326        raw: &str,
327        batch_id: &ImportBatchId,
328    ) -> Result<Vec<IngestDocument>, MemoryError> {
329        let mut texts: Vec<String> = Vec::new();
330        let mut uuids: Vec<String> = Vec::new();
331
332        for (lineno, line) in raw.lines().enumerate() {
333            if line.trim().is_empty() {
334                continue;
335            }
336
337            let record: ClaudeCodeRecord = serde_json::from_str(line).map_err(|e| {
338                MemoryError::Ingest(format!(
339                    "ClaudeCodeJsonl: line {lineno} JSON parse error: {e}"
340                ))
341            })?;
342
343            // Skip non-conversation record types silently.
344            if record.record_type != "user" && record.record_type != "assistant" {
345                continue;
346            }
347
348            // A user/assistant record MUST have a valid `message` — fail loud.
349            let msg = record.message.ok_or_else(|| {
350                MemoryError::Ingest(format!(
351                    "ClaudeCodeJsonl: line {lineno} has type={:?} but missing `message` field \
352                     — unexpected schema version",
353                    record.record_type
354                ))
355            })?;
356
357            // role must be "user" or "assistant"
358            if msg.role != "user" && msg.role != "assistant" {
359                return Err(MemoryError::Ingest(format!(
360                    "ClaudeCodeJsonl: line {lineno} unexpected message.role={:?}",
361                    msg.role
362                )));
363            }
364
365            texts.push(msg.content.to_text());
366            uuids.push(record.uuid.unwrap_or_else(|| format!("line{lineno}")));
367        }
368
369        Ok(build_ingest_documents(
370            &texts,
371            &uuids,
372            self.context_window,
373            batch_id,
374            IngestSourceKind::ExternalAgent.graph_origin(),
375            |uuid| format!("claude-code:{}#{uuid}", self.session_id),
376        ))
377    }
378}
379
380// ── Codex JSONL adapter ──────────────────────────────────────────────────────
381
382/// Strict raw-record type for one line in an `OpenAI` Codex CLI `.jsonl` session file.
383///
384/// Only `type == "response_item"` records with `payload.type == "message"` and
385/// `payload.role` in `["user", "assistant"]` are accepted. The `session_meta` record
386/// at the start of each file is used by the binary layer for project-scope enforcement
387/// but is skipped by this adapter. All other record types are silently skipped.
388#[derive(Debug, Deserialize)]
389struct CodexRecord {
390    #[serde(rename = "type")]
391    record_type: String,
392    #[serde(default)]
393    payload: Option<CodexPayload>,
394}
395
396/// `payload` field in a Codex record.
397#[derive(Debug, Deserialize)]
398struct CodexPayload {
399    #[serde(rename = "type")]
400    payload_type: Option<String>,
401    role: Option<String>,
402    content: Option<Vec<CodexContentBlock>>,
403    id: Option<String>,
404}
405
406/// One content block inside a Codex message.
407#[derive(Debug, Deserialize)]
408struct CodexContentBlock {
409    #[serde(rename = "type")]
410    block_type: String,
411    text: Option<String>,
412}
413
414/// Adapter for `OpenAI` Codex CLI session `.jsonl` files (spec-067 §9 Phase 3).
415///
416/// **Strict and version-pinned**: only `"type": "response_item"` records with
417/// `payload.type == "message"` and `payload.role` in `["user", "assistant"]` are
418/// processed. Records with `role == "developer"` (system instructions) are skipped.
419/// Unknown structure in a `response_item` causes an `Err`.
420///
421/// Project-scope enforcement (only files whose `session_meta.payload.cwd` matches the
422/// current project root) is performed by the binary layer, not this adapter.
423///
424/// # Examples
425///
426/// ```no_run
427/// use zeph_memory::graph::ingest::{CodexJsonl, IngestSourceAdapter, ImportBatchId};
428///
429/// let raw = concat!(
430///     r#"{"type":"session_meta","payload":{"id":"s1","cwd":"/project"}}"#, "\n",
431///     r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"i1","content":[{"type":"input_text","text":"hello"}]}}"#
432/// );
433/// let adapter = CodexJsonl::new("session-1");
434/// let batch = ImportBatchId::new();
435/// let docs = adapter.parse(raw, &batch).unwrap();
436/// assert_eq!(docs.len(), 1);
437/// ```
438pub struct CodexJsonl {
439    session_id: String,
440    context_window: usize,
441}
442
443impl CodexJsonl {
444    /// Creates a new `CodexJsonl` adapter for the given session ID.
445    ///
446    /// `session_id` is embedded in every document's `source_uri` as
447    /// `"codex:<session_id>#<item_id>"`.
448    #[must_use]
449    pub fn new(session_id: impl Into<String>) -> Self {
450        Self {
451            session_id: session_id.into(),
452            context_window: 3,
453        }
454    }
455
456    /// Overrides the context window size.
457    #[must_use]
458    pub fn with_context_window(mut self, n: usize) -> Self {
459        self.context_window = n;
460        self
461    }
462}
463
464impl sealed::Sealed for CodexJsonl {}
465
466impl IngestSourceAdapter for CodexJsonl {
467    /// Parse a Codex CLI `.jsonl` session file.
468    ///
469    /// Skips `session_meta`, `developer`-role messages, and all other non-message
470    /// record types silently. A `response_item` record whose `payload` is missing or
471    /// structurally invalid causes `Err`.
472    fn parse(
473        &self,
474        raw: &str,
475        batch_id: &ImportBatchId,
476    ) -> Result<Vec<IngestDocument>, MemoryError> {
477        let mut texts: Vec<String> = Vec::new();
478        let mut item_ids: Vec<String> = Vec::new();
479
480        for (lineno, line) in raw.lines().enumerate() {
481            if line.trim().is_empty() {
482                continue;
483            }
484
485            let record: CodexRecord = serde_json::from_str(line).map_err(|e| {
486                MemoryError::Ingest(format!("CodexJsonl: line {lineno} JSON parse error: {e}"))
487            })?;
488
489            // Skip session_meta and all other non-response_item records.
490            if record.record_type != "response_item" {
491                continue;
492            }
493
494            // response_item MUST have a payload — fail loud.
495            let payload = record.payload.ok_or_else(|| {
496                MemoryError::Ingest(format!(
497                    "CodexJsonl: line {lineno} has type=response_item but missing `payload` \
498                     — unexpected schema version"
499                ))
500            })?;
501
502            // Must be a message payload.
503            let payload_type = payload.payload_type.as_deref().unwrap_or("");
504            if payload_type != "message" {
505                continue; // tool_call, tool_output, etc. — skip silently
506            }
507
508            let role = payload.role.as_deref().unwrap_or("");
509            match role {
510                "user" | "assistant" => {}
511                "developer" => continue, // system-level instructions — skip
512                _ => {
513                    return Err(MemoryError::Ingest(format!(
514                        "CodexJsonl: line {lineno} unexpected payload.role={role:?}"
515                    )));
516                }
517            }
518
519            // Extract text from input_text (user turns) and output_text (assistant turns).
520            let content_text: String = payload
521                .content
522                .as_deref()
523                .unwrap_or_default()
524                .iter()
525                .filter(|b| b.block_type == "input_text" || b.block_type == "output_text")
526                .filter_map(|b| b.text.as_deref())
527                .collect::<Vec<_>>()
528                .join("\n");
529
530            let item_id = payload.id.unwrap_or_else(|| format!("line{lineno}"));
531
532            texts.push(content_text);
533            item_ids.push(item_id);
534        }
535
536        Ok(build_ingest_documents(
537            &texts,
538            &item_ids,
539            self.context_window,
540            batch_id,
541            IngestSourceKind::ExternalAgent.graph_origin(),
542            |item_id| format!("codex:{}#{item_id}", self.session_id),
543        ))
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use crate::graph::types::GraphOrigin;
551
552    fn make_jsonl(entries: &[(u64, &str)]) -> String {
553        entries
554            .iter()
555            .map(|(seq, text)| {
556                format!(
557                    r#"{{"seq":{seq},"timestamp":null,"message":{{"role":"user","content":{text:?},"parts":[]}}}}"#,
558                )
559            })
560            .collect::<Vec<_>>()
561            .join("\n")
562    }
563
564    #[test]
565    fn parse_single_entry() {
566        let raw = make_jsonl(&[(0, "Rust is a systems language")]);
567        let adapter = SubagentJsonl::new("task-1");
568        let batch = ImportBatchId::new();
569        let docs = adapter.parse(&raw, &batch).unwrap();
570        assert_eq!(docs.len(), 1);
571        assert_eq!(docs[0].content(), "Rust is a systems language");
572        assert_eq!(docs[0].source_uri(), "subagent:task-1#0");
573        assert!(!docs[0].content_hash().is_empty());
574    }
575
576    #[test]
577    fn parse_skips_empty_content() {
578        let raw = make_jsonl(&[(0, ""), (1, "non-empty content")]);
579        let adapter = SubagentJsonl::new("task-2");
580        let batch = ImportBatchId::new();
581        let docs = adapter.parse(&raw, &batch).unwrap();
582        assert_eq!(docs.len(), 1);
583        assert_eq!(docs[0].content(), "non-empty content");
584    }
585
586    #[test]
587    fn parse_builds_context_window() {
588        let raw = make_jsonl(&[
589            (0, "first message"),
590            (1, "second message"),
591            (2, "third message"),
592            (3, "fourth message"),
593        ]);
594        let adapter = SubagentJsonl::new("task-3").with_context_window(2);
595        let batch = ImportBatchId::new();
596        let docs = adapter.parse(&raw, &batch).unwrap();
597        assert_eq!(docs.len(), 4);
598        assert!(docs[0].context().is_empty());
599        assert_eq!(docs[1].context().len(), 1);
600        assert_eq!(docs[2].context().len(), 2);
601        assert_eq!(docs[3].context().len(), 2); // capped at window size
602    }
603
604    #[test]
605    fn parse_skips_malformed_lines() {
606        let raw = "not json\n".to_owned() + &make_jsonl(&[(0, "valid message")]);
607        let adapter = SubagentJsonl::new("task-4");
608        let batch = ImportBatchId::new();
609        let docs = adapter.parse(&raw, &batch).unwrap();
610        assert_eq!(docs.len(), 1);
611    }
612
613    #[test]
614    fn parse_embeds_batch_id_in_provenance() {
615        let raw = make_jsonl(&[(0, "content")]);
616        let adapter = SubagentJsonl::new("task-5");
617        let batch = ImportBatchId::new();
618        let docs = adapter.parse(&raw, &batch).unwrap();
619        assert_eq!(docs[0].provenance().import_batch_id, batch.as_str());
620    }
621
622    #[test]
623    fn parse_empty_raw_returns_empty_vec() {
624        let adapter = SubagentJsonl::new("task-6");
625        let batch = ImportBatchId::new();
626        let docs = adapter.parse("", &batch).unwrap();
627        assert!(docs.is_empty());
628    }
629
630    // ── ClaudeCodeJsonl tests ────────────────────────────────────────────────
631
632    fn make_claude_code_user(uuid: &str, text: &str) -> String {
633        format!(
634            r#"{{"type":"user","uuid":"{uuid}","message":{{"role":"user","content":{text:?}}}}}"#
635        )
636    }
637
638    fn make_claude_code_assistant(uuid: &str, text: &str) -> String {
639        format!(
640            r#"{{"type":"assistant","uuid":"{uuid}","message":{{"role":"assistant","content":[{{"type":"text","text":{text:?}}}]}}}}"#
641        )
642    }
643
644    fn make_claude_code_non_conversation(record_type: &str) -> String {
645        format!(r#"{{"type":"{record_type}","sessionId":"s1"}}"#)
646    }
647
648    #[test]
649    fn claude_code_parses_user_message() {
650        let raw = make_claude_code_user("uuid-1", "Hello from user");
651        let adapter = ClaudeCodeJsonl::new("sess-1");
652        let batch = ImportBatchId::new();
653        let docs = adapter.parse(&raw, &batch).unwrap();
654        assert_eq!(docs.len(), 1);
655        assert_eq!(docs[0].content(), "Hello from user");
656        assert_eq!(docs[0].source_uri(), "claude-code:sess-1#uuid-1");
657        assert_eq!(docs[0].provenance().origin, GraphOrigin::ExternalAgent);
658    }
659
660    #[test]
661    fn claude_code_parses_assistant_array_content() {
662        let raw = make_claude_code_assistant("uuid-2", "Hello from assistant");
663        let adapter = ClaudeCodeJsonl::new("sess-2");
664        let batch = ImportBatchId::new();
665        let docs = adapter.parse(&raw, &batch).unwrap();
666        assert_eq!(docs.len(), 1);
667        assert_eq!(docs[0].content(), "Hello from assistant");
668    }
669
670    #[test]
671    fn claude_code_skips_non_conversation_records() {
672        let raw = [
673            make_claude_code_non_conversation("mode"),
674            make_claude_code_non_conversation("permission-mode"),
675            make_claude_code_non_conversation("file-history-snapshot"),
676            make_claude_code_user("uuid-3", "actual content"),
677        ]
678        .join("\n");
679        let adapter = ClaudeCodeJsonl::new("sess-3");
680        let batch = ImportBatchId::new();
681        let docs = adapter.parse(&raw, &batch).unwrap();
682        assert_eq!(docs.len(), 1);
683        assert_eq!(docs[0].content(), "actual content");
684    }
685
686    #[test]
687    fn claude_code_errors_on_missing_message_in_user_record() {
688        let raw = r#"{"type":"user","uuid":"u1"}"#; // no "message" field
689        let adapter = ClaudeCodeJsonl::new("sess-err");
690        let batch = ImportBatchId::new();
691        assert!(adapter.parse(raw, &batch).is_err());
692    }
693
694    #[test]
695    fn claude_code_skips_empty_content() {
696        let raw = [
697            make_claude_code_user("u1", ""),
698            make_claude_code_user("u2", "non-empty"),
699        ]
700        .join("\n");
701        let adapter = ClaudeCodeJsonl::new("sess-4");
702        let batch = ImportBatchId::new();
703        let docs = adapter.parse(&raw, &batch).unwrap();
704        assert_eq!(docs.len(), 1);
705        assert_eq!(docs[0].content(), "non-empty");
706    }
707
708    #[test]
709    fn claude_code_tags_external_agent_origin() {
710        let raw = make_claude_code_user("u1", "some content");
711        let adapter = ClaudeCodeJsonl::new("sess-5");
712        let batch = ImportBatchId::new();
713        let docs = adapter.parse(&raw, &batch).unwrap();
714        assert_eq!(docs[0].provenance().origin, GraphOrigin::ExternalAgent);
715    }
716
717    #[test]
718    fn claude_code_embeds_batch_id_in_provenance() {
719        let raw = make_claude_code_user("u1", "content");
720        let adapter = ClaudeCodeJsonl::new("sess-6");
721        let batch = ImportBatchId::new();
722        let docs = adapter.parse(&raw, &batch).unwrap();
723        assert_eq!(docs[0].provenance().import_batch_id, batch.as_str());
724    }
725
726    #[test]
727    fn claude_code_context_window() {
728        let raw = [
729            make_claude_code_user("u1", "first"),
730            make_claude_code_user("u2", "second"),
731            make_claude_code_user("u3", "third"),
732            make_claude_code_user("u4", "fourth"),
733        ]
734        .join("\n");
735        let adapter = ClaudeCodeJsonl::new("sess-7").with_context_window(2);
736        let batch = ImportBatchId::new();
737        let docs = adapter.parse(&raw, &batch).unwrap();
738        assert_eq!(docs.len(), 4);
739        assert!(docs[0].context().is_empty());
740        assert_eq!(docs[1].context().len(), 1);
741        assert_eq!(docs[2].context().len(), 2);
742        assert_eq!(docs[3].context().len(), 2); // capped at window
743    }
744
745    #[test]
746    fn claude_code_thinking_block_extracted() {
747        // Assistant record with only a thinking block — must not be dropped.
748        let raw = r#"{"type":"assistant","uuid":"u1","message":{"role":"assistant","content":[{"type":"thinking","text":"inner reasoning"}]}}"#;
749        let adapter = ClaudeCodeJsonl::new("sess-think");
750        let batch = ImportBatchId::new();
751        let docs = adapter.parse(raw, &batch).unwrap();
752        assert_eq!(docs.len(), 1);
753        assert_eq!(docs[0].content(), "inner reasoning");
754    }
755
756    #[test]
757    fn claude_code_tool_use_block_produces_marker() {
758        let raw = r#"{"type":"assistant","uuid":"u1","message":{"role":"assistant","content":[{"type":"tool_use","name":"shell","text":null}]}}"#;
759        let adapter = ClaudeCodeJsonl::new("sess-tool");
760        let batch = ImportBatchId::new();
761        let docs = adapter.parse(raw, &batch).unwrap();
762        assert_eq!(docs.len(), 1);
763        assert!(docs[0].content().contains("<tool: shell>"));
764    }
765
766    #[test]
767    fn claude_code_empty_raw() {
768        let adapter = ClaudeCodeJsonl::new("sess-empty");
769        let batch = ImportBatchId::new();
770        let docs = adapter.parse("", &batch).unwrap();
771        assert!(docs.is_empty());
772    }
773
774    // ── CodexJsonl tests ─────────────────────────────────────────────────────
775
776    fn make_codex_session_meta(cwd: &str) -> String {
777        format!(
778            r#"{{"type":"session_meta","payload":{{"id":"s1","cwd":"{cwd}","originator":"codex_cli_rs","cli_version":"1.0.0"}}}}"#
779        )
780    }
781
782    fn make_codex_response_item(role: &str, text: &str, id: &str) -> String {
783        format!(
784            r#"{{"type":"response_item","payload":{{"type":"message","role":"{role}","id":"{id}","content":[{{"type":"input_text","text":{text:?}}}]}}}}"#
785        )
786    }
787
788    fn make_codex_assistant(text: &str, id: &str) -> String {
789        format!(
790            r#"{{"type":"response_item","payload":{{"type":"message","role":"assistant","id":"{id}","content":[{{"type":"output_text","text":{text:?}}}]}}}}"#
791        )
792    }
793
794    #[test]
795    fn codex_parses_user_message() {
796        let raw = [
797            make_codex_session_meta("/project"),
798            make_codex_response_item("user", "Hello", "item-1"),
799        ]
800        .join("\n");
801        let adapter = CodexJsonl::new("sess-c1");
802        let batch = ImportBatchId::new();
803        let docs = adapter.parse(&raw, &batch).unwrap();
804        assert_eq!(docs.len(), 1);
805        assert_eq!(docs[0].content(), "Hello");
806        assert_eq!(docs[0].source_uri(), "codex:sess-c1#item-1");
807        assert_eq!(docs[0].provenance().origin, GraphOrigin::ExternalAgent);
808    }
809
810    #[test]
811    fn codex_skips_developer_role() {
812        let raw = [
813            make_codex_session_meta("/project"),
814            make_codex_response_item("developer", "System instructions", "sys-1"),
815            make_codex_response_item("user", "User message", "u-1"),
816        ]
817        .join("\n");
818        let adapter = CodexJsonl::new("sess-c2");
819        let batch = ImportBatchId::new();
820        let docs = adapter.parse(&raw, &batch).unwrap();
821        assert_eq!(docs.len(), 1);
822        assert_eq!(docs[0].content(), "User message");
823    }
824
825    #[test]
826    fn codex_errors_on_missing_payload_in_response_item() {
827        let raw = r#"{"type":"response_item"}"#; // no payload
828        let adapter = CodexJsonl::new("sess-c-err");
829        let batch = ImportBatchId::new();
830        assert!(adapter.parse(raw, &batch).is_err());
831    }
832
833    #[test]
834    fn codex_tags_external_agent_origin() {
835        let raw = make_codex_response_item("assistant", "answer", "a-1");
836        let adapter = CodexJsonl::new("sess-c3");
837        let batch = ImportBatchId::new();
838        let docs = adapter.parse(&raw, &batch).unwrap();
839        assert_eq!(docs[0].provenance().origin, GraphOrigin::ExternalAgent);
840    }
841
842    #[test]
843    fn codex_extracts_output_text_from_assistant_turns() {
844        // Real Codex assistant turns use output_text, not input_text.
845        let raw = [
846            make_codex_session_meta("/project"),
847            make_codex_assistant("assistant answer", "a-1"),
848        ]
849        .join("\n");
850        let adapter = CodexJsonl::new("sess-c4");
851        let batch = ImportBatchId::new();
852        let docs = adapter.parse(&raw, &batch).unwrap();
853        assert_eq!(docs.len(), 1);
854        assert_eq!(docs[0].content(), "assistant answer");
855    }
856
857    #[test]
858    fn codex_embeds_batch_id_in_provenance() {
859        let raw = make_codex_response_item("user", "hello", "u-1");
860        let adapter = CodexJsonl::new("sess-c5");
861        let batch = ImportBatchId::new();
862        let docs = adapter.parse(&raw, &batch).unwrap();
863        assert_eq!(docs[0].provenance().import_batch_id, batch.as_str());
864    }
865
866    #[test]
867    fn codex_context_window() {
868        let raw = [
869            make_codex_response_item("user", "msg1", "i1"),
870            make_codex_response_item("user", "msg2", "i2"),
871            make_codex_response_item("user", "msg3", "i3"),
872            make_codex_response_item("user", "msg4", "i4"),
873        ]
874        .join("\n");
875        let adapter = CodexJsonl::new("sess-c6").with_context_window(2);
876        let batch = ImportBatchId::new();
877        let docs = adapter.parse(&raw, &batch).unwrap();
878        assert_eq!(docs.len(), 4);
879        assert!(docs[0].context().is_empty());
880        assert_eq!(docs[1].context().len(), 1);
881        assert_eq!(docs[2].context().len(), 2);
882        assert_eq!(docs[3].context().len(), 2);
883    }
884
885    #[test]
886    fn codex_skips_empty_content() {
887        let raw = [
888            make_codex_response_item("user", "", "empty-1"),
889            make_codex_response_item("user", "non-empty", "u-2"),
890        ]
891        .join("\n");
892        let adapter = CodexJsonl::new("sess-c7");
893        let batch = ImportBatchId::new();
894        let docs = adapter.parse(&raw, &batch).unwrap();
895        assert_eq!(docs.len(), 1);
896        assert_eq!(docs[0].content(), "non-empty");
897    }
898}