Skip to main content

pond/adapter/
claude_desktop_app.rs

1//! Claude Desktop app adapter - Cowork / agent-mode sessions only.
2//!
3//! Source path:
4//! `~/Library/Application Support/Claude/local-agent-mode-sessions/<acct>/<workspace>/local_<uuid>/audit.jsonl`
5//! with a sibling `local_<uuid>.json` metadata file. Each `audit.jsonl` is one
6//! session (one JSON object per line); the metadata carries `sessionId`,
7//! `createdAt`, the project folder, and UI fields.
8//!
9//! Scope is deliberately narrow (spec.md#adapters, locked product split):
10//! - It NEVER reads `~/.claude/projects` or the `claude-code-sessions/`
11//!   wrappers - the Desktop "Code" tab writes there in CLI format and is
12//!   covered by `claude-code`.
13//! - It NEVER descends into a session's nested `.claude/` directory. That is
14//!   the inner Claude Code loop the Cowork sandbox runs; `audit.jsonl` already
15//!   represents that conversation, so ingesting the `.claude/projects/**/*.jsonl`
16//!   transcripts under it would double-count the same session. Discovery is
17//!   scoped to `local_*/audit.jsonl` and the walk prunes hidden dirs, so this
18//!   adapter cannot pick up the inner loop. This is why it drives the [`Adapter`]
19//!   seam directly rather than through `JsonlTree` (whose blanket `*.jsonl` walk
20//!   would find the inner transcripts).
21//!
22//! The per-line `message` object is the Anthropic Messages shape (the same
23//! content blocks claude-code carries), so the part mapping mirrors that
24//! adapter. Records that are not a `user`/`assistant` turn (`system`, `result`,
25//! `rate_limit_event`, ...) become System carriers: their `subtype`/`type` is
26//! the content and the verbatim row survives in `options.source.raw_record`, so
27//! native restore reproduces the full `audit.jsonl`. `system/permission_*`
28//! records carry no tool-call id to link an approval to, so they stay System
29//! carriers rather than fabricate the `tool_call_id` a `ToolApproval*` part
30//! requires (spec.md#model-no-synthesis).
31
32use std::{
33    collections::HashMap,
34    path::{Path, PathBuf},
35};
36
37use async_stream::stream;
38use chrono::{DateTime, SecondsFormat, Utc};
39use serde_json::{Value, json};
40use tokio::sync::mpsc;
41use walkdir::WalkDir;
42
43use crate::{
44    sessions::IngestEvent,
45    wire::{FileData, Message, Part, PartKind, Provenance, ProviderOptions, Session},
46};
47
48use super::{
49    Adapter, AdapterError, AdapterFactory, AdapterYield, AdapterYieldStream, DiscoverFuture, Env,
50    RestoreFidelity, RestoredFile, SkipOracle, SkipReason, by_timestamp_then_id, compact_json,
51    config_path, empty_options,
52    extract::{
53        Extracted, Source, bound_value, extract_compact_repr, extract_self_str, extract_str,
54    },
55    extracted_text,
56    jsonl::{RECORD_CAP, peek_last_line},
57    jsonl_bytes, part_id, part_ordinal, raw_record, source_options,
58};
59
60const NAME: &str = "claude-desktop-app";
61
62/// Event-channel bound; doubles as backpressure - the blocking reader parks on
63/// `blocking_send` when the consumer lags.
64const CHANNEL_CAP: usize = 256;
65
66/// The session-store subpath under `~/Library/Application Support/Claude`.
67const SESSIONS_SUBDIR: &str = "local-agent-mode-sessions";
68
69/// Stateless factory: opens [`ClaudeDesktopAppAdapter`] instances and probes for
70/// the Cowork store under `~/Library/Application Support/Claude`.
71pub struct ClaudeDesktopAppFactory;
72
73impl AdapterFactory for ClaudeDesktopAppFactory {
74    fn name(&self) -> &'static str {
75        NAME
76    }
77
78    fn open(&self, config: Value) -> Result<Box<dyn Adapter>, AdapterError> {
79        Ok(Box::new(ClaudeDesktopAppAdapter::new(config_path(
80            NAME, config,
81        )?)))
82    }
83
84    fn probe_default(&self, env: &Env) -> Option<Value> {
85        let path = cowork_root(&env.home);
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
98/// `~/Library/Application Support/Claude/local-agent-mode-sessions`.
99fn cowork_root(home: &Path) -> PathBuf {
100    home.join("Library")
101        .join("Application Support")
102        .join("Claude")
103        .join(SESSIONS_SUBDIR)
104}
105
106/// Configured Cowork reader, rooted at a `local-agent-mode-sessions/` directory.
107#[derive(Debug, Clone)]
108pub struct ClaudeDesktopAppAdapter {
109    root: PathBuf,
110}
111
112impl ClaudeDesktopAppAdapter {
113    pub fn new(root: impl Into<PathBuf>) -> Self {
114        Self { root: root.into() }
115    }
116}
117
118impl Adapter for ClaudeDesktopAppAdapter {
119    fn discover(&self) -> DiscoverFuture<'_> {
120        let root = self.root.clone();
121        Box::pin(async move {
122            tokio::task::spawn_blocking(move || collect_sessions(&root).map(|files| files.len()))
123                .await
124                .map_err(join_error)?
125        })
126    }
127
128    fn plan<'a>(&'a self, oracle: &'a dyn SkipOracle) -> super::PlanFuture<'a> {
129        let root = self.root.clone();
130        Box::pin(async move {
131            // The events_with freshness pre-pass run standalone: the same
132            // per-session audit-tail peek, classified instead of read. On an
133            // empty oracle the peeks are skipped - a first sync reads everything.
134            let peek = !oracle.is_empty();
135            let heads = tokio::task::spawn_blocking(move || {
136                let sessions = collect_sessions(&root)?;
137                Ok::<_, AdapterError>(
138                    sessions
139                        .into_iter()
140                        .map(|session| {
141                            let watermark = if peek {
142                                match source_last_ts(&session.audit_path) {
143                                    Some(ts) => super::SourceWatermark::At(ts),
144                                    None => super::SourceWatermark::Opaque,
145                                }
146                            } else {
147                                super::SourceWatermark::Opaque
148                            };
149                            (session.session_id, watermark)
150                        })
151                        .collect::<Vec<_>>(),
152                )
153            })
154            .await
155            .map_err(join_error)??;
156            if !peek {
157                return Ok(Some(super::SyncPlan::all_pending(heads.len())));
158            }
159            Ok(Some(super::SyncPlan::from_heads(
160                oracle,
161                heads
162                    .iter()
163                    .map(|(session_id, watermark)| (Some(session_id.as_str()), *watermark)),
164            )))
165        })
166    }
167
168    fn events_with<'a>(&'a self, oracle: &'a dyn SkipOracle) -> AdapterYieldStream<'a> {
169        let adapter = self.clone();
170        Box::pin(stream! {
171            let files = {
172                let root = adapter.root.clone();
173                tokio::task::spawn_blocking(move || collect_sessions(&root)).await
174            };
175            let files = match files {
176                Ok(Ok(files)) => files,
177                Ok(Err(error)) => { yield Err(error); return; }
178                Err(join) => { yield Err(join_error(join)); return; }
179            };
180
181            // Freshness pre-pass: read the audit log's last-message timestamp (its
182            // tail line) and skip when it is no newer than pond's watermark. Only
183            // when the oracle has entries - a first ingest has nothing to compare.
184            let mut survivors = Vec::with_capacity(files.len());
185            for file in files {
186                if !oracle.is_empty() {
187                    let audit = file.audit_path.clone();
188                    let last_ts =
189                        match tokio::task::spawn_blocking(move || source_last_ts(&audit)).await {
190                            Ok(last_ts) => last_ts,
191                            Err(join) => { yield Err(join_error(join)); return; }
192                        };
193                    if crate::adapter::is_session_fresh(oracle, &file.session_id, last_ts) {
194                        yield Ok(AdapterYield::Skipped {
195                            session_id: Some(file.session_id.clone()),
196                            project: None,
197                            reason: SkipReason::Fresh,
198                        });
199                        continue;
200                    }
201                }
202                survivors.push(file);
203            }
204
205            let (tx, mut rx) = mpsc::channel(CHANNEL_CAP);
206            let handle = tokio::task::spawn_blocking(move || read_sessions(survivors, &tx));
207            while let Some(item) = rx.recv().await {
208                yield item;
209            }
210            if let Err(join) = handle.await {
211                yield Err(join_error(join));
212            }
213        })
214    }
215}
216
217/// A blocking-task panic is a pond bug, not bad source data, so it fails the
218/// whole run rather than skipping a session.
219/// Latest message timestamp (micros) for the freshness gate: the audit log's
220/// last record. The audit stream is append-ordered, so its tail line is the
221/// latest message; an unreadable file or a record without a timestamp yields
222/// `None` and the session re-reads (safe). The sibling metadata file is not
223/// consulted - pond never rewrites an existing session row, so a pure-metadata
224/// change is a no-op.
225fn source_last_ts(audit_path: &Path) -> Option<i64> {
226    let last_line = peek_last_line(audit_path)?;
227    let record: Value = serde_json::from_str(&last_line).ok()?;
228    Some(record_timestamp(&record)?.timestamp_micros())
229}
230
231fn join_error(join: tokio::task::JoinError) -> AdapterError {
232    AdapterError::io(
233        NAME,
234        "blocking read task",
235        std::io::Error::other(join.to_string()),
236    )
237}
238
239/// One Cowork session located on disk: its `audit.jsonl`, its sibling metadata
240/// file, the session id (the `local_<uuid>` directory name, which equals
241/// `metadata.sessionId`), and the session dir relative to the root (for restore).
242struct CoworkSession {
243    session_id: String,
244    audit_path: PathBuf,
245    meta_path: PathBuf,
246    relative_dir: PathBuf,
247}
248
249/// Walk the root for `local_*/audit.jsonl`, pruning hidden directories so the
250/// nested `.claude/` inner loop is never reached. Sorted for deterministic
251/// ingest order. A missing root means "no sessions yet", not an error.
252fn collect_sessions(root: &Path) -> Result<Vec<CoworkSession>, AdapterError> {
253    if !root.exists() {
254        return Ok(Vec::new());
255    }
256    let io = |source| AdapterError::io(NAME, root.display().to_string(), source);
257    let mut out = Vec::new();
258    let walker = WalkDir::new(root).into_iter().filter_entry(|entry| {
259        // Prune any hidden dir (`.claude`, `.audit-key` is a file). The inner
260        // Claude Code loop lives under `.claude/`, so this is the structural
261        // guard against double-counting it (spec.md#adapters).
262        !(entry.file_type().is_dir()
263            && entry
264                .file_name()
265                .to_str()
266                .is_some_and(|name| name.starts_with('.')))
267    });
268    for entry in walker {
269        let entry = entry.map_err(|error| io(error.into()))?;
270        if entry.file_name() != "audit.jsonl" {
271            continue;
272        }
273        let audit_path = entry.into_path();
274        let Some(dir) = audit_path.parent() else {
275            continue;
276        };
277        let Some(dir_name) = dir.file_name().and_then(|name| name.to_str()) else {
278            continue;
279        };
280        // The transcript dir is `local_<uuid>`; its name equals
281        // `metadata.sessionId`. A stray `audit.jsonl` elsewhere is not a Cowork
282        // session.
283        if !dir_name.starts_with("local_") {
284            continue;
285        }
286        let Some(workspace) = dir.parent() else {
287            continue;
288        };
289        let meta_path = workspace.join(format!("{dir_name}.json"));
290        let relative_dir = dir.strip_prefix(root).unwrap_or(dir).to_path_buf();
291        out.push(CoworkSession {
292            session_id: dir_name.to_owned(),
293            audit_path,
294            meta_path,
295            relative_dir,
296        });
297    }
298    out.sort_by(|a, b| a.audit_path.cmp(&b.audit_path));
299    Ok(out)
300}
301
302fn read_sessions(
303    sessions: Vec<CoworkSession>,
304    tx: &mpsc::Sender<Result<AdapterYield, AdapterError>>,
305) {
306    for session in sessions {
307        if !read_one_session(session, tx) {
308            return;
309        }
310    }
311}
312
313/// Returns `false` when the consumer dropped the receiver and the read should stop.
314fn read_one_session(
315    file: CoworkSession,
316    tx: &mpsc::Sender<Result<AdapterYield, AdapterError>>,
317) -> bool {
318    macro_rules! emit {
319        ($item:expr) => {
320            if tx.blocking_send($item).is_err() {
321                return false;
322            }
323        };
324    }
325
326    let meta = match read_json(&file.meta_path) {
327        Ok(value) => value,
328        Err(error) => {
329            emit!(Err(error));
330            return true;
331        }
332    };
333    let session = match build_session(&file, &meta) {
334        Ok(session) => session,
335        Err(error) => {
336            emit!(Err(error));
337            return true;
338        }
339    };
340    let created_at = session.created_at;
341    let session_id = session.id.clone();
342    emit!(Ok(AdapterYield::Event(IngestEvent::Session(session))));
343
344    let bytes = match std::fs::read(&file.audit_path) {
345        Ok(bytes) => bytes,
346        Err(error) => {
347            emit!(Err(AdapterError::io(
348                NAME,
349                file.audit_path.display().to_string(),
350                error
351            )));
352            return true;
353        }
354    };
355    let text = match std::str::from_utf8(&bytes) {
356        Ok(text) => text,
357        Err(_) => {
358            emit!(Err(AdapterError::schema(
359                NAME,
360                file.audit_path.display().to_string(),
361                "audit.jsonl is not valid UTF-8",
362            )));
363            return true;
364        }
365    };
366
367    let mut tool_call_names: HashMap<String, Extracted<String>> = HashMap::new();
368    for (index, line) in text.lines().enumerate() {
369        let line_no = index + 1;
370        if line.trim().is_empty() {
371            continue;
372        }
373        if line.len() > RECORD_CAP {
374            emit!(Err(AdapterError::schema(
375                NAME,
376                format!("{}:{line_no}", file.audit_path.display()),
377                format!(
378                    "audit line exceeds adapter record cap: {} bytes > {RECORD_CAP}",
379                    line.len()
380                ),
381            )));
382            continue;
383        }
384        let mut record: Value = match serde_json::from_str(line) {
385            Ok(value) => value,
386            Err(error) => {
387                emit!(Err(AdapterError::parse(
388                    NAME,
389                    file.audit_path.display().to_string(),
390                    line_no,
391                    error,
392                )));
393                continue;
394            }
395        };
396        bound_value(&mut record);
397        capture_tool_call_names(&record, &mut tool_call_names);
398        match record_events(&session_id, line_no, &record, created_at, &tool_call_names) {
399            Ok(events) => {
400                for event in events {
401                    emit!(Ok(AdapterYield::Event(event)));
402                }
403            }
404            Err(message) => emit!(Err(AdapterError::schema(
405                NAME,
406                format!("{}:{line_no}", file.audit_path.display()),
407                message,
408            ))),
409        }
410    }
411    true
412}
413
414/// Read one JSON file whole, bounding every string leaf at the seam cap
415/// (spec.md#adapter-bounded-values).
416fn read_json(path: &Path) -> Result<Value, AdapterError> {
417    use std::io::Read;
418    let io = |source| AdapterError::io(NAME, path.display().to_string(), source);
419    let mut file = std::fs::File::open(path).map_err(io)?;
420    let len = file.metadata().map_err(io)?.len();
421    if len > RECORD_CAP as u64 {
422        return Err(AdapterError::schema(
423            NAME,
424            path.display().to_string(),
425            format!("json file exceeds adapter record cap: {len} bytes > {RECORD_CAP}"),
426        ));
427    }
428    let mut bytes = Vec::with_capacity(len as usize);
429    file.read_to_end(&mut bytes).map_err(io)?;
430    let mut value: Value = serde_json::from_slice(&bytes)
431        .map_err(|error| AdapterError::parse(NAME, path.display().to_string(), 1, error))?;
432    bound_value(&mut value);
433    Ok(value)
434}
435
436fn build_session(file: &CoworkSession, meta: &Value) -> Result<Session, AdapterError> {
437    let display = file.meta_path.display().to_string();
438    // The `local_<uuid>` dir name is authoritative for the id (it equals
439    // `metadata.sessionId`) AND is what the freshness oracle is keyed on in
440    // events_with, so use it directly to keep the two in lockstep - a divergence
441    // would silently disable the freshness skip. The raw metadata (with its own
442    // `sessionId`) is preserved in options.source.raw_record.
443    let session_id = file.session_id.clone();
444
445    let created_at = meta
446        .get("createdAt")
447        .and_then(Value::as_i64)
448        .and_then(DateTime::from_timestamp_millis)
449        .ok_or_else(|| {
450            AdapterError::schema(
451                NAME,
452                display.clone(),
453                "metadata missing numeric `createdAt`",
454            )
455        })?;
456
457    // spec.md#model-project-non-empty: prefer the real folder the user opened
458    // (`userSelectedFolders[0]`), else the sandbox `cwd` (always present). Both
459    // are extracted from real source data, never synthesized.
460    let project = meta
461        .get("userSelectedFolders")
462        .and_then(Value::as_array)
463        .and_then(|folders| folders.first())
464        .filter(|first| first.as_str().is_some_and(|s| !s.is_empty()))
465        .and_then(|first| extract_self_str(first))
466        .or_else(|| extract_str(meta, "cwd").filter(|cwd| !cwd.trim().is_empty()))
467        .ok_or_else(|| {
468            AdapterError::schema(
469                NAME,
470                display,
471                "metadata has neither `userSelectedFolders[0]` nor `cwd` for the project",
472            )
473        })?;
474
475    let mut options = source_options(NAME, meta);
476    if let Some(source) = options.get_mut("source").and_then(Value::as_object_mut) {
477        source.insert(
478            "relative_dir".to_owned(),
479            json!(file.relative_dir.to_string_lossy()),
480        );
481        for key in [
482            "model",
483            "title",
484            "cliSessionId",
485            "systemPrompt",
486            "initialMessage",
487            "enabledMcpTools",
488            "vmProcessName",
489            "accountName",
490        ] {
491            if let Some(value) = meta.get(key) {
492                source.insert(key.to_owned(), value.clone());
493            }
494        }
495    }
496
497    Ok(Session {
498        id: session_id,
499        parent_session_id: None,
500        parent_message_id: None,
501        source_agent: NAME.to_owned(),
502        created_at,
503        project,
504        options,
505    })
506}
507
508/// Stash every `tool_use` block's `id -> name` from an assistant record's
509/// `message.content[]`, so a later `tool_result` row can resolve its name.
510/// Idempotent and safe on any record (non-assistant rows contribute nothing).
511fn capture_tool_call_names(record: &Value, map: &mut HashMap<String, Extracted<String>>) {
512    let Some(items) = record
513        .get("message")
514        .and_then(|message| message.get("content"))
515        .and_then(Value::as_array)
516    else {
517        return;
518    };
519    for item in items {
520        if !matches!(
521            item.get("type").and_then(Value::as_str),
522            Some("tool_use") | Some("server_tool_use")
523        ) {
524            continue;
525        }
526        let (Some(id), Some(name)) = (item.str_field("id"), extract_str(item, "name")) else {
527            continue;
528        };
529        map.insert(id.to_owned(), name);
530    }
531}
532
533/// Map one audit record into canonical events. `user`/`assistant` records carry
534/// an inner Anthropic `message`; everything else becomes a System carrier whose
535/// verbatim row survives in `options.source.raw_record` for lossless restore.
536fn record_events(
537    session_id: &str,
538    line: usize,
539    record: &Value,
540    default_timestamp: DateTime<Utc>,
541    tool_call_names: &HashMap<String, Extracted<String>>,
542) -> Result<Vec<IngestEvent>, String> {
543    let timestamp = record_timestamp(record).unwrap_or(default_timestamp);
544    let uuid = record
545        .get("uuid")
546        .and_then(Value::as_str)
547        .map_or_else(|| format!("{session_id}:{line}"), ToOwned::to_owned);
548    let rtype = record.get("type").and_then(Value::as_str);
549
550    match rtype {
551        Some("user") | Some("assistant") => {
552            let message_value = record.get("message").unwrap_or(&Value::Null);
553            message_events(
554                session_id,
555                &uuid,
556                timestamp,
557                record,
558                message_value,
559                tool_call_names,
560                line,
561            )
562        }
563        // `system` (init/status/api_retry/permission_*), `result`,
564        // `rate_limit_event`, `tool_use_summary`, and any future record type
565        // are kept as System carriers (spec.md#adapter-integrity-no-silent-drops).
566        _ => {
567            let content = extract_str(record, "subtype").or_else(|| extract_str(record, "type"));
568            Ok(vec![IngestEvent::Message(Message::System {
569                id: uuid,
570                session_id: session_id.to_owned(),
571                timestamp,
572                content,
573                options: row_options(record, line),
574            })])
575        }
576    }
577}
578
579fn record_timestamp(record: &Value) -> Option<DateTime<Utc>> {
580    record
581        .get("_audit_timestamp")
582        .or_else(|| record.get("timestamp"))
583        .and_then(Value::as_str)
584        .and_then(|text| DateTime::parse_from_rfc3339(text).ok())
585        .map(|dt| dt.with_timezone(&Utc))
586}
587
588fn message_events(
589    session_id: &str,
590    uuid: &str,
591    timestamp: DateTime<Utc>,
592    record: &Value,
593    message_value: &Value,
594    tool_call_names: &HashMap<String, Extracted<String>>,
595    line: usize,
596) -> Result<Vec<IngestEvent>, String> {
597    let role = message_value
598        .get("role")
599        .and_then(Value::as_str)
600        .ok_or_else(|| "message missing role".to_owned())?;
601    let content = message_value.get("content").unwrap_or(&Value::Null);
602    let mut parts = Vec::new();
603    let message = match (role, content) {
604        ("user", Value::String(_)) => {
605            parts.push(text_part(
606                session_id,
607                uuid,
608                0,
609                extract_self_str(content),
610                Provenance::Conversational,
611            ));
612            Message::User {
613                id: uuid.to_owned(),
614                session_id: session_id.to_owned(),
615                timestamp,
616                options: row_options(record, line),
617            }
618        }
619        ("user", Value::Array(items)) if !items.is_empty() && items.iter().all(is_tool_result) => {
620            let source_tool_result = record.get("tool_use_result").cloned();
621            parts.extend(items.iter().enumerate().map(|(ordinal, item)| {
622                tool_result_part(
623                    session_id,
624                    uuid,
625                    ordinal,
626                    item,
627                    source_tool_result.as_ref(),
628                    tool_call_names,
629                )
630            }));
631            Message::Tool {
632                id: uuid.to_owned(),
633                session_id: session_id.to_owned(),
634                timestamp,
635                options: row_options(record, line),
636            }
637        }
638        ("user", Value::Array(items)) => {
639            parts.extend(items.iter().enumerate().map(|(ordinal, item)| {
640                user_part(session_id, uuid, ordinal, item, tool_call_names)
641            }));
642            Message::User {
643                id: uuid.to_owned(),
644                session_id: session_id.to_owned(),
645                timestamp,
646                options: row_options(record, line),
647            }
648        }
649        ("assistant", Value::Array(items)) => {
650            parts.extend(
651                items
652                    .iter()
653                    .enumerate()
654                    .map(|(ordinal, item)| assistant_part(session_id, uuid, ordinal, item)),
655            );
656            Message::Assistant {
657                id: uuid.to_owned(),
658                session_id: session_id.to_owned(),
659                timestamp,
660                options: assistant_options(record, message_value, line),
661            }
662        }
663        _ => {
664            return Ok(vec![message_carrier_event(
665                session_id, uuid, timestamp, record, line, role,
666            )]);
667        }
668    };
669
670    let mut events = Vec::with_capacity(parts.len() + 1);
671    events.push(IngestEvent::Message(message));
672    events.extend(parts.into_iter().map(IngestEvent::Part));
673    Ok(events)
674}
675
676fn message_carrier_event(
677    session_id: &str,
678    uuid: &str,
679    timestamp: DateTime<Utc>,
680    record: &Value,
681    line: usize,
682    role: &str,
683) -> IngestEvent {
684    IngestEvent::Message(Message::System {
685        id: uuid.to_owned(),
686        session_id: session_id.to_owned(),
687        timestamp,
688        content: extract_self_str(&Value::String(role.to_owned())),
689        options: row_options(record, line),
690    })
691}
692
693fn text_part(
694    session_id: &str,
695    message_id: &str,
696    ordinal: usize,
697    text: Option<Extracted<String>>,
698    provenance: Provenance,
699) -> Part {
700    Part {
701        session_id: session_id.to_owned(),
702        id: part_id(message_id, ordinal),
703        message_id: message_id.to_owned(),
704        ordinal: part_ordinal(ordinal),
705        provenance,
706        options: empty_options(),
707        kind: PartKind::Text { text },
708    }
709}
710
711fn user_part(
712    session_id: &str,
713    message_id: &str,
714    ordinal: usize,
715    value: &Value,
716    tool_call_names: &HashMap<String, Extracted<String>>,
717) -> Part {
718    match value.get("type").and_then(Value::as_str) {
719        Some("text") => text_part(
720            session_id,
721            message_id,
722            ordinal,
723            extract_str(value, "text"),
724            Provenance::Conversational,
725        ),
726        Some("image") | Some("file") => file_part(
727            session_id,
728            message_id,
729            ordinal,
730            value,
731            Provenance::Conversational,
732        ),
733        Some("tool_result") => tool_result_part(
734            session_id,
735            message_id,
736            ordinal,
737            value,
738            None,
739            tool_call_names,
740        ),
741        // Unknown user block: preserve the raw JSON in a Text slot rather than
742        // drop it - a lossless encoding, not a synthesized value.
743        _ => text_part(
744            session_id,
745            message_id,
746            ordinal,
747            Some(extract_compact_repr(value)),
748            Provenance::Conversational,
749        ),
750    }
751}
752
753fn assistant_part(session_id: &str, message_id: &str, ordinal: usize, value: &Value) -> Part {
754    match value.get("type").and_then(Value::as_str) {
755        Some("text") => text_part(
756            session_id,
757            message_id,
758            ordinal,
759            extract_str(value, "text"),
760            Provenance::Conversational,
761        ),
762        Some("thinking") => Part {
763            session_id: session_id.to_owned(),
764            id: part_id(message_id, ordinal),
765            message_id: message_id.to_owned(),
766            ordinal: part_ordinal(ordinal),
767            provenance: Provenance::Conversational,
768            options: signature_options(value),
769            kind: PartKind::Reasoning {
770                text: extract_str(value, "thinking"),
771            },
772        },
773        Some(kind @ ("tool_use" | "server_tool_use")) => Part {
774            session_id: session_id.to_owned(),
775            id: part_id(message_id, ordinal),
776            message_id: message_id.to_owned(),
777            ordinal: part_ordinal(ordinal),
778            provenance: Provenance::Conversational,
779            options: empty_options(),
780            kind: PartKind::ToolCall {
781                call_id: extract_str(value, "id"),
782                name: extract_str(value, "name"),
783                params: value.get("input").cloned().unwrap_or(Value::Null),
784                provider_executed: kind == "server_tool_use",
785            },
786        },
787        Some("image") | Some("file") => file_part(
788            session_id,
789            message_id,
790            ordinal,
791            value,
792            Provenance::Conversational,
793        ),
794        _ => text_part(
795            session_id,
796            message_id,
797            ordinal,
798            Some(extract_compact_repr(value)),
799            Provenance::Conversational,
800        ),
801    }
802}
803
804fn tool_result_part(
805    session_id: &str,
806    message_id: &str,
807    ordinal: usize,
808    value: &Value,
809    source_tool_result: Option<&Value>,
810    tool_call_names: &HashMap<String, Extracted<String>>,
811) -> Part {
812    let call_id = extract_str(value, "tool_use_id");
813    // The name lives on the prior `tool_use`, resolved via the per-session map;
814    // a miss surfaces as `None`, never a sentinel (spec.md#model-no-synthesis).
815    let name = value
816        .str_field("tool_use_id")
817        .and_then(|id| tool_call_names.get(id))
818        .cloned();
819    let result = value
820        .get("content")
821        .cloned()
822        .or_else(|| source_tool_result.cloned())
823        .unwrap_or(Value::Null);
824    Part {
825        session_id: session_id.to_owned(),
826        id: part_id(message_id, ordinal),
827        message_id: message_id.to_owned(),
828        ordinal: part_ordinal(ordinal),
829        // spec.md#model-part-provenance: tool output is runtime-produced.
830        provenance: Provenance::Injected,
831        options: empty_options(),
832        kind: PartKind::ToolResult {
833            call_id,
834            name,
835            is_failure: value
836                .get("is_error")
837                .and_then(Value::as_bool)
838                .unwrap_or(false),
839            result,
840        },
841    }
842}
843
844fn file_part(
845    session_id: &str,
846    message_id: &str,
847    ordinal: usize,
848    value: &Value,
849    provenance: Provenance,
850) -> Part {
851    let media_type = value
852        .get("media_type")
853        .or_else(|| value.get("mime_type"))
854        .and_then(Value::as_str)
855        .map(ToOwned::to_owned);
856    let file_name = value
857        .get("file_name")
858        .or_else(|| value.get("name"))
859        .and_then(Value::as_str)
860        .map(ToOwned::to_owned);
861    let data = if let Some(source) = value.get("source") {
862        if let Some(url) = source.get("url").and_then(Value::as_str) {
863            FileData::Url(url.to_owned())
864        } else if let Some(bytes) = source.get("data").and_then(Value::as_str) {
865            FileData::String(bytes.to_owned())
866        } else {
867            FileData::String(compact_json(source))
868        }
869    } else if let Some(url) = value.get("url").and_then(Value::as_str) {
870        FileData::Url(url.to_owned())
871    } else {
872        FileData::String(compact_json(value))
873    };
874    Part {
875        session_id: session_id.to_owned(),
876        id: part_id(message_id, ordinal),
877        message_id: message_id.to_owned(),
878        ordinal: part_ordinal(ordinal),
879        provenance,
880        options: empty_options(),
881        kind: PartKind::File {
882            media_type,
883            file_name,
884            data,
885        },
886    }
887}
888
889fn row_options(record: &Value, line: usize) -> ProviderOptions {
890    let mut options = source_options(NAME, record);
891    if let Some(source) = options.get_mut("source").and_then(Value::as_object_mut) {
892        source.insert("line".to_owned(), json!(line));
893        source.insert("raw_type".to_owned(), json!(record.get("type")));
894        if let Some(subtype) = record.get("subtype") {
895            source.insert("subtype".to_owned(), subtype.clone());
896        }
897    }
898    options
899}
900
901fn assistant_options(record: &Value, message_value: &Value, line: usize) -> ProviderOptions {
902    let mut options = row_options(record, line);
903    let anthropic = json!({
904        "id": message_value.get("id"),
905        "model": message_value.get("model"),
906        "stop_reason": message_value.get("stop_reason"),
907        "usage": message_value.get("usage"),
908    });
909    options.insert("anthropic".to_owned(), anthropic);
910    options
911}
912
913fn signature_options(value: &Value) -> ProviderOptions {
914    let mut options = ProviderOptions::new();
915    if let Some(signature) = value.get("signature").and_then(Value::as_str) {
916        options.insert("anthropic".to_owned(), json!({ "signature": signature }));
917    }
918    options
919}
920
921fn is_tool_result(value: &Value) -> bool {
922    value.get("type").and_then(Value::as_str) == Some("tool_result")
923}
924
925fn serialize_session(
926    session: &crate::sessions::SessionWithMessages,
927    fidelity: RestoreFidelity,
928) -> Result<Vec<RestoredFile>, AdapterError> {
929    // Native restore replays the verbatim `audit.jsonl` rows (each message's
930    // stored `raw_record`) in source-line order, plus the metadata file from the
931    // session's stored `raw_record`. spec.md#adapter-native-restore-lossless:
932    // a session ingested without raw records (foreign-sourced) can't be replayed
933    // natively, so we re-enter forcing Foreign (which stamps actual_fidelity).
934    let session_raw = raw_record(&session.session.options);
935    if fidelity == RestoreFidelity::Native && session_raw.is_none() {
936        return serialize_session(session, RestoreFidelity::Foreign);
937    }
938
939    let relative_dir = session
940        .session
941        .options
942        .get("source")
943        .and_then(|source| source.get("relative_dir"))
944        .and_then(Value::as_str)
945        .map(PathBuf::from)
946        .unwrap_or_else(|| PathBuf::from(&session.session.id));
947
948    let mut messages = session.messages.clone();
949    if fidelity == RestoreFidelity::Native {
950        messages.sort_by(|left, right| {
951            source_line(left.message.options())
952                .cmp(&source_line(right.message.options()))
953                .then_with(|| by_timestamp_then_id(left, right))
954        });
955    } else {
956        messages.sort_by(by_timestamp_then_id);
957    }
958
959    let mut records = Vec::with_capacity(messages.len());
960    for message in &messages {
961        if fidelity == RestoreFidelity::Native {
962            if let Some(raw) = raw_record(message.message.options()) {
963                records.push(raw);
964            }
965            continue;
966        }
967        if let Some(record) = foreign_record(&session.session.id, message) {
968            records.push(record);
969        }
970    }
971
972    let mut files = vec![RestoredFile::new(
973        relative_dir.join("audit.jsonl"),
974        jsonl_bytes(NAME, &records)?,
975        fidelity,
976    )];
977
978    // The metadata sits a level up, named after the session dir: `local_x.json`.
979    let meta_value = match fidelity {
980        RestoreFidelity::Native => session_raw,
981        RestoreFidelity::Foreign => Some(foreign_metadata(session)),
982    };
983    if let (Some(meta), Some(parent), Some(dir_name)) = (
984        meta_value,
985        relative_dir.parent(),
986        relative_dir.file_name().and_then(|name| name.to_str()),
987    ) {
988        files.push(RestoredFile::new(
989            parent.join(format!("{dir_name}.json")),
990            serde_json::to_vec(&meta).map_err(|error| {
991                AdapterError::schema(
992                    NAME,
993                    &session.session.id,
994                    format!("json encode failed: {error}"),
995                )
996            })?,
997            fidelity,
998        ));
999    }
1000    Ok(files)
1001}
1002
1003/// Best-effort metadata for a foreign session: the fields `build_session`
1004/// reads back, derived from canonical data.
1005fn foreign_metadata(session: &crate::sessions::SessionWithMessages) -> Value {
1006    json!({
1007        "sessionId": session.session.id,
1008        "createdAt": session.session.created_at.timestamp_millis(),
1009        "cwd": &*session.session.project,
1010    })
1011}
1012
1013/// Best-effort `audit.jsonl` row for a foreign session, mirroring the Anthropic
1014/// `message` envelope this adapter reads.
1015fn foreign_record(session_id: &str, message: &crate::sessions::MessageWithParts) -> Option<Value> {
1016    let (rtype, role) = match &message.message {
1017        Message::User { .. } => ("user", "user"),
1018        Message::Assistant { .. } => ("assistant", "assistant"),
1019        Message::Tool { .. } => ("user", "user"),
1020        // System carriers have no idiomatic audit row; content stays canonical.
1021        Message::System { .. } => return None,
1022    };
1023    let content = Value::Array(message.parts.iter().map(audit_part).collect());
1024    Some(json!({
1025        "type": rtype,
1026        "session_id": session_id,
1027        "uuid": message.message.id(),
1028        "message": { "role": role, "content": content },
1029        "_audit_timestamp": message
1030            .message
1031            .timestamp()
1032            .to_rfc3339_opts(SecondsFormat::Millis, true),
1033    }))
1034}
1035
1036fn audit_part(part: &Part) -> Value {
1037    match &part.kind {
1038        PartKind::Text { text } => json!({ "type": "text", "text": extracted_text(text) }),
1039        PartKind::Reasoning { text } => {
1040            json!({ "type": "thinking", "thinking": extracted_text(text) })
1041        }
1042        PartKind::ToolCall {
1043            call_id,
1044            name,
1045            params,
1046            provider_executed,
1047        } => json!({
1048            "type": if *provider_executed { "server_tool_use" } else { "tool_use" },
1049            "id": extracted_text(call_id),
1050            "name": extracted_text(name),
1051            "input": params,
1052        }),
1053        PartKind::ToolResult {
1054            call_id,
1055            is_failure,
1056            result,
1057            ..
1058        } => json!({
1059            "type": "tool_result",
1060            "tool_use_id": extracted_text(call_id),
1061            "is_error": is_failure,
1062            "content": result,
1063        }),
1064        other => json!({
1065            "type": "text",
1066            "text": compact_json(&serde_json::to_value(other).unwrap_or(Value::Null)),
1067        }),
1068    }
1069}
1070
1071/// Read the stored source line for a message (`options.source.line`), used to
1072/// replay `audit.jsonl` rows in their original order on native restore.
1073fn source_line(options: &ProviderOptions) -> Option<u64> {
1074    options
1075        .get("source")
1076        .and_then(|source| source.get("line"))
1077        .and_then(Value::as_u64)
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082    //! End-to-end tests over the committed Cowork fixture corpus
1083    //! (`tests/fixtures/adapter/claude_desktop_app/`), including the regression
1084    //! guard that the nested `.claude/` inner Claude Code loop is never ingested.
1085    #![allow(clippy::expect_used, clippy::unwrap_used)]
1086
1087    use super::*;
1088    use crate::{handlers::ingest_adapter, sessions::Store};
1089    use tempfile::TempDir;
1090
1091    // Manifest-dir anchored: unit tests must not depend on the process cwd
1092    // (figment::Jail chdirs the whole test process while config tests run).
1093    const FIXTURES: &str = concat!(
1094        env!("CARGO_MANIFEST_DIR"),
1095        "/tests/fixtures/adapter/claude_desktop_app/local-agent-mode-sessions"
1096    );
1097    /// The inner Claude Code loop transcript nested under one session's
1098    /// `.claude/projects/`; the adapter must never surface it as a session.
1099    const INNER_LOOP_ID: &str = "a9985b0b-2f5e-4125-b105-7f62376f5509";
1100
1101    #[test]
1102    fn probe_default_finds_cowork_store_under_home() -> anyhow::Result<()> {
1103        crate::adapter::test_support::assert_probe_default(
1104            &ClaudeDesktopAppFactory,
1105            &[
1106                "Library",
1107                "Application Support",
1108                "Claude",
1109                "local-agent-mode-sessions",
1110            ],
1111        )
1112    }
1113
1114    /// `plan` is the events_with freshness pre-pass run standalone and MUST
1115    /// agree with it: the sessions plan calls fresh are exactly the sessions
1116    /// the gate skips. An empty oracle plans everything pending at walk cost.
1117    #[tokio::test(flavor = "multi_thread")]
1118    async fn plan_matches_the_events_gate() -> anyhow::Result<()> {
1119        use tokio_stream::StreamExt;
1120
1121        let adapter = ClaudeDesktopAppAdapter::new(FIXTURES);
1122        let first_sync = adapter
1123            .plan(&crate::adapter::NoopOracle)
1124            .await?
1125            .expect("cowork supports plan");
1126        assert_eq!(first_sync.sessions, 4, "the four audit.jsonl sessions");
1127        assert_eq!(first_sync.pending, first_sync.sessions);
1128        assert_eq!(first_sync.fresh, 0);
1129
1130        use crate::adapter::test_support::MaxWatermarkOracle;
1131        let plan = adapter
1132            .plan(&MaxWatermarkOracle)
1133            .await?
1134            .expect("cowork supports plan");
1135        assert_eq!(plan.sessions, first_sync.sessions);
1136        assert_eq!(plan.pending, 0, "a saturated oracle gates everything fresh");
1137
1138        let mut gate_fresh = 0usize;
1139        let mut events = 0usize;
1140        let mut stream = adapter.events_with(&MaxWatermarkOracle);
1141        while let Some(item) = stream.next().await {
1142            match item? {
1143                AdapterYield::Skipped {
1144                    reason: SkipReason::Fresh,
1145                    ..
1146                } => gate_fresh += 1,
1147                AdapterYield::SkippedBatch {
1148                    reason: SkipReason::Fresh,
1149                    count,
1150                } => gate_fresh += count,
1151                AdapterYield::Event(_) => events += 1,
1152                _ => {}
1153            }
1154        }
1155        assert_eq!(gate_fresh, plan.fresh, "plan and gate must agree");
1156        assert_eq!(events, 0, "a fully fresh corpus reads nothing");
1157        Ok(())
1158    }
1159
1160    #[tokio::test(flavor = "multi_thread")]
1161    async fn ingests_cowork_fixture_into_canonical_shape() -> anyhow::Result<()> {
1162        let temp = TempDir::new()?;
1163        let store = Store::open_local(temp.path()).await?;
1164        let adapter = ClaudeDesktopAppAdapter::new(FIXTURES);
1165        let summary = ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
1166        assert_eq!(summary.dropped_sessions, 0, "no session-level rejections");
1167
1168        let ids = store.session_ids().await?;
1169        // Four `audit.jsonl` sessions; the nested `.claude/**/*.jsonl` inner loop
1170        // must NOT become a fifth session (spec.md#adapters double-count guard).
1171        assert_eq!(ids.len(), 4, "exactly the four audit.jsonl sessions");
1172        assert!(
1173            !ids.iter().any(|id| id.contains(INNER_LOOP_ID)),
1174            "the nested inner Claude Code loop must not be ingested as a session",
1175        );
1176        for id in &ids {
1177            assert!(
1178                id.starts_with("local_"),
1179                "session id is the metadata sessionId (local_<uuid>): {id}",
1180            );
1181        }
1182
1183        let mut saw_call = false;
1184        let mut saw_resolved_result = false;
1185        let mut saw_reasoning = false;
1186        let mut saw_system = false;
1187        for id in &ids {
1188            let session = store.get_session(id).await?.expect("session round-trips");
1189            assert_eq!(session.session.source_agent, NAME);
1190            assert!(
1191                !(*session.session.project).is_empty(),
1192                "spec.md#model-project-non-empty",
1193            );
1194            for stored in &session.messages {
1195                if matches!(stored.message, Message::System { .. }) {
1196                    saw_system = true;
1197                }
1198                for part in &stored.parts {
1199                    match &part.kind {
1200                        PartKind::ToolCall { .. } => saw_call = true,
1201                        PartKind::ToolResult { name, .. } if name.is_some() => {
1202                            saw_resolved_result = true;
1203                        }
1204                        PartKind::Reasoning { .. } => saw_reasoning = true,
1205                        _ => {}
1206                    }
1207                }
1208            }
1209        }
1210        assert!(saw_call, "assistant tool_use -> ToolCall");
1211        assert!(
1212            saw_resolved_result,
1213            "tool_result name resolved via the per-session tool_use map",
1214        );
1215        assert!(saw_reasoning, "assistant thinking -> Reasoning");
1216        assert!(
1217            saw_system,
1218            "system/result/... records become System carriers"
1219        );
1220        Ok(())
1221    }
1222
1223    #[tokio::test(flavor = "multi_thread")]
1224    async fn native_restore_round_trips() -> anyhow::Result<()> {
1225        let temp = TempDir::new()?;
1226        let store = Store::open_local(temp.path().join("store")).await?;
1227        let adapter = ClaudeDesktopAppAdapter::new(FIXTURES);
1228        ingest_adapter(&store, &adapter, &crate::adapter::NoopOracle, |_| {}).await?;
1229        let original = store.session_ids().await?;
1230
1231        // Native restore replays each session's audit.jsonl + metadata; collect
1232        // all files (distinct dirs, no collision) then write the tree once.
1233        let mut files = Vec::new();
1234        for id in &original {
1235            let session = store.get_session(id).await?.expect("round-trips");
1236            files.extend(ClaudeDesktopAppFactory.serialize(&session, RestoreFidelity::Native)?);
1237        }
1238        let restore_root = temp.path().join("restore");
1239        crate::adapter::write_restored_files(&restore_root, files)?;
1240
1241        let restore_store = Store::open_local(temp.path().join("restore-store")).await?;
1242        let restored = ClaudeDesktopAppAdapter::new(&restore_root);
1243        ingest_adapter(
1244            &restore_store,
1245            &restored,
1246            &crate::adapter::NoopOracle,
1247            |_| {},
1248        )
1249        .await?;
1250        assert_eq!(
1251            restore_store.session_ids().await?.len(),
1252            original.len(),
1253            "native restore re-ingests as the same session set",
1254        );
1255        Ok(())
1256    }
1257
1258    #[test]
1259    fn unexpected_message_content_becomes_lossless_carrier() {
1260        let names = HashMap::new();
1261        let record = json!({
1262            "type": "user",
1263            "uuid": "local-message-1",
1264            "_audit_timestamp": "2026-06-01T00:00:00Z",
1265            "message": {
1266                "role": "user",
1267                "content": null,
1268            },
1269        });
1270
1271        let events = record_events("local_session", 7, &record, Utc::now(), &names)
1272            .expect("carrier is valid");
1273        assert_eq!(events.len(), 1);
1274        assert!(matches!(
1275            &events[0],
1276            IngestEvent::Message(Message::System { id, content, .. })
1277                if id == "local-message-1" && content.as_deref().map(String::as_str) == Some("user")
1278        ));
1279    }
1280}