1use 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
62const CHANNEL_CAP: usize = 256;
65
66const SESSIONS_SUBDIR: &str = "local-agent-mode-sessions";
68
69pub 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
98fn cowork_root(home: &Path) -> PathBuf {
100 home.join("Library")
101 .join("Application Support")
102 .join("Claude")
103 .join(SESSIONS_SUBDIR)
104}
105
106#[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 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 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
217fn 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
239struct CoworkSession {
243 session_id: String,
244 audit_path: PathBuf,
245 meta_path: PathBuf,
246 relative_dir: PathBuf,
247}
248
249fn 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 !(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 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
313fn 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
414fn 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 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 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
508fn 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
533fn 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 _ => {
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 _ => 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 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 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 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 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
1003fn 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
1013fn 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 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
1071fn 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 #![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 const FIXTURES: &str = concat!(
1094 env!("CARGO_MANIFEST_DIR"),
1095 "/tests/fixtures/adapter/claude_desktop_app/local-agent-mode-sessions"
1096 );
1097 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 #[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 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 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}