1use crate::blob::BlobStore;
46use crate::event::{truncate_summary, AdapterStatus, AgentKind, Event, EventKind};
47use std::io::BufRead;
48use std::path::{Path, PathBuf};
49use std::sync::atomic::{AtomicBool, Ordering};
50use std::sync::mpsc;
51use std::thread::JoinHandle;
52use std::time::{Duration, Instant};
53
54const SPILLOVER_BYTES: usize = 256 * 1024;
58const TAIL_POLL: Duration = Duration::from_millis(50);
60const APPEAR_POLL: Duration = Duration::from_millis(200);
62
63#[derive(Clone)]
65pub struct AdapterContext {
66 pub session_id: String,
68 pub started_at_unix_ms: i64,
70 pub cwd: PathBuf,
72 pub command: Vec<String>,
74 pub blobs: std::sync::Arc<BlobStore>,
76 pub stop: std::sync::Arc<AtomicBool>,
78 pub projects_dir: Option<PathBuf>,
82}
83
84pub struct AdapterHandle {
87 pub events: mpsc::Receiver<Event>,
89 pub outcome: JoinHandle<AdapterOutcome>,
91}
92
93impl AdapterHandle {
94 pub fn join(self) -> crate::Result<AdapterOutcome> {
99 self.outcome.join().map_err(|_| {
100 crate::Error::Storage(crate::error::StorageError::Open {
101 path: PathBuf::new(),
104 source: std::io::Error::other("adapter thread panicked"),
105 })
106 })
107 }
108}
109
110#[derive(Debug, Default, Clone)]
112pub struct AdapterOutcome {
113 pub model: Option<String>,
115 pub usage_json: Option<serde_json::Value>,
118 pub status: AdapterStatus,
121 pub degrade_reason: Option<String>,
126}
127
128pub trait Adapter: Send + 'static {
137 fn agent_kind(&self) -> AgentKind;
139 fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle>;
146}
147
148#[must_use]
153pub fn select(command: &[String], _cwd: &Path) -> Option<Box<dyn Adapter>> {
154 if is_claude_code(command) {
155 Some(Box::new(ClaudeAdapter))
156 } else if is_claude_desktop(command) {
157 Some(Box::new(ClaudeDesktopAdapter))
158 } else if is_codex_cli(command) {
159 Some(Box::new(CodexAdapter))
160 } else if is_gemini_cli(command) {
161 Some(Box::new(GeminiAdapter))
162 } else {
163 None
164 }
165}
166
167#[must_use]
173pub fn resolve_override(name: &str) -> Option<Box<dyn Adapter>> {
174 match name {
175 "claude-code" => Some(Box::new(ClaudeAdapter)),
176 "claude-desktop" => Some(Box::new(ClaudeDesktopAdapter)),
177 "codex-cli" => Some(Box::new(CodexAdapter)),
178 "gemini-cli" => Some(Box::new(GeminiAdapter)),
179 _ => None,
180 }
181}
182
183#[must_use]
186pub fn is_claude_code(command: &[String]) -> bool {
187 let prog = command.first().map_or("", String::as_str);
188 let base = prog.rsplit(['/', '\\']).next().unwrap_or(prog);
192 let base = base.strip_suffix(".exe").unwrap_or(base);
193 base.eq_ignore_ascii_case("claude")
194}
195
196#[must_use]
200pub fn is_claude_desktop(command: &[String]) -> bool {
201 let prog = command.first().map_or("", String::as_str);
202 let base = prog.rsplit(['/', '\\']).next().unwrap_or(prog);
203 let base = base.strip_suffix(".exe").unwrap_or(base);
204 base.eq_ignore_ascii_case("claude-desktop")
205}
206
207#[must_use]
210pub fn is_codex_cli(command: &[String]) -> bool {
211 let prog = command.first().map_or("", String::as_str);
212 let base = prog.rsplit(['/', '\\']).next().unwrap_or(prog);
213 let base = base.strip_suffix(".exe").unwrap_or(base);
214 base.eq_ignore_ascii_case("codex")
215}
216
217#[must_use]
220pub fn is_gemini_cli(command: &[String]) -> bool {
221 let prog = command.first().map_or("", String::as_str);
222 let base = prog.rsplit(['/', '\\']).next().unwrap_or(prog);
223 let base = base.strip_suffix(".exe").unwrap_or(base);
224 base.eq_ignore_ascii_case("gemini")
225}
226
227#[derive(Debug, Clone)]
238pub struct ClaudeAdapter;
239
240impl Adapter for ClaudeAdapter {
241 fn agent_kind(&self) -> AgentKind {
242 AgentKind::ClaudeCode
243 }
244
245 #[allow(clippy::unused_self)] fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle> {
247 let (tx, rx) = mpsc::channel::<Event>();
248 let outcome = std::thread::Builder::new()
249 .name("hh-claude-adapter".into())
250 .spawn(move || run_claude_tailer(ctx, tx))?;
251 Ok(AdapterHandle {
252 events: rx,
253 outcome,
254 })
255 }
256}
257
258#[allow(clippy::needless_pass_by_value)] fn run_claude_tailer(ctx: AdapterContext, tx: mpsc::Sender<Event>) -> AdapterOutcome {
267 let Some(projects) = ctx.projects_dir.clone().or_else(claude_projects_dir) else {
268 return degraded(
269 "no ~/.claude/projects directory found (HOME unset?); run `hh doctor` to diagnose",
270 );
271 };
272 if !projects.is_dir() {
273 return degraded(format!(
274 "projects directory does not exist: {}; run `hh doctor` to diagnose",
275 projects.display()
276 ));
277 }
278 let slug = slugify(&ctx.cwd);
279 let slug_dir = projects.join(&slug);
280 debug(&format!(
281 "claude adapter: projects_dir={}, slug={slug}, slug_dir={}, session cwd={}, started_at_unix_ms={}",
282 projects.display(),
283 slug_dir.display(),
284 ctx.cwd.display(),
285 ctx.started_at_unix_ms
286 ));
287 let mut diag = DiscoveryDiag {
288 projects: Some(projects.clone()),
289 slug: Some(slug.clone()),
290 slug_dir: Some(slug_dir.clone()),
291 cwd: Some(ctx.cwd.clone()),
292 ..Default::default()
293 };
294 let start = Instant::now();
295 let Some(file) = locate_transcript(&projects, &ctx.cwd, &ctx.stop, &mut diag) else {
296 debug(&format!(
297 "claude adapter: discovery failed: new_candidates={}, cwd_mismatches={}, directories={}",
298 diag.new_candidates.len(),
299 diag.cwd_mismatches.len(),
300 diag.directories.len()
301 ));
302 return degraded(locate_failure_reason(&diag));
303 };
304 debug(&format!(
305 "claude adapter: selected transcript {} (cwd matched)",
306 file.display()
307 ));
308 let mut acc = OutcomeAcc::default();
309 let stats = tail_file(&file, &projects, &ctx, &tx, &start, &mut acc);
310 if !stats.read_ok {
311 return degraded(format!(
312 "found a transcript at {} but could not read it (open/read failed); \
313 run `hh doctor` to check file permissions",
314 file.display(),
315 ));
316 }
317 debug(&format!(
318 "claude adapter: tail done: lines_seen={}, records_parsed={}, events_produced={}, first_parse_error={:?}",
319 stats.lines_seen, stats.records_parsed, stats.events_produced, stats.first_parse_error
320 ));
321 if stats.events_produced == 0 {
322 let first_err = stats
329 .first_parse_error
330 .map(|(n, msg)| format!("; first parse error at line {n}: {msg}"))
331 .unwrap_or_default();
332 return degraded(format!(
333 "jsonl found ({}) but 0 records parsed (read {} line(s){first_err}); \
334 run `hh doctor` to check the Claude Code transcript format",
335 file.display(),
336 stats.lines_seen,
337 ));
338 }
339 acc.finish(AdapterStatus::Active)
340}
341
342fn degraded(reason: impl Into<String>) -> AdapterOutcome {
345 AdapterOutcome {
346 status: AdapterStatus::Degraded,
347 degrade_reason: Some(reason.into()),
348 ..Default::default()
349 }
350}
351
352#[derive(Default)]
359struct DiscoveryDiag {
360 projects: Option<PathBuf>,
362 slug: Option<String>,
364 slug_dir: Option<PathBuf>,
366 cwd: Option<PathBuf>,
368 new_candidates: std::collections::HashSet<PathBuf>,
371 cwd_mismatches: Vec<PathBuf>,
374 directories: Vec<PathBuf>,
376}
377
378#[derive(Default)]
382struct TailStats {
383 read_ok: bool,
385 lines_seen: usize,
387 records_parsed: usize,
389 events_produced: usize,
391 first_parse_error: Option<(usize, String)>,
394}
395
396fn locate_failure_reason(diag: &DiscoveryDiag) -> String {
401 use std::fmt::Write as _;
402 let slug = diag.slug.as_deref().unwrap_or("?");
403 let projects = diag
404 .projects
405 .as_ref()
406 .map_or_else(|| "(unknown)".to_string(), |p| p.display().to_string());
407 let cwd = diag
408 .cwd
409 .as_ref()
410 .map_or_else(|| "(unknown)".to_string(), |p| p.display().to_string());
411 let slug_dir = diag
412 .slug_dir
413 .as_ref()
414 .map_or_else(|| "(unknown)".to_string(), |p| p.display().to_string());
415 if diag.new_candidates.is_empty() {
416 format!(
417 "no jsonl matched cwd slug {slug}: no new transcript appeared under {projects} \
418 (looked in {slug_dir}; cwd={cwd}) before the session ended; Claude creates it \
419 lazily on first output — if you sent no prompt this is expected. \
420 Run `hh doctor` to verify discoverability."
421 )
422 } else {
423 let mut s = format!(
424 "no jsonl matched cwd slug {slug}: {} new candidate(s) appeared under {projects} \
425 but none carried cwd={cwd} before the session ended",
426 diag.new_candidates.len(),
427 );
428 if !diag.cwd_mismatches.is_empty() {
429 let names: Vec<String> = diag
430 .cwd_mismatches
431 .iter()
432 .take(3)
433 .map(|p| {
434 p.file_name()
435 .map(|f| f.to_string_lossy().to_string())
436 .unwrap_or_default()
437 })
438 .collect();
439 let _ = write!(
440 s,
441 "; {} rejected for a different cwd ({})",
442 diag.cwd_mismatches.len(),
443 names.join(", ")
444 );
445 }
446 if !diag.directories.is_empty() {
447 let _ = write!(
448 s,
449 "; {} candidate(s) were directories, not files",
450 diag.directories.len()
451 );
452 }
453 s.push_str(". Run `hh doctor` to diagnose.");
454 s
455 }
456}
457
458#[derive(Default)]
460struct OutcomeAcc {
461 model: Option<String>,
462 usage_json: Option<serde_json::Value>,
463}
464
465impl OutcomeAcc {
466 fn note_assistant(&mut self, message: &serde_json::Value) {
467 if let Some(m) = message.get("model").and_then(|v| v.as_str()) {
468 self.model = Some(m.to_string());
469 }
470 if let Some(u) = message.get("usage") {
471 self.usage_json = Some(u.clone());
472 }
473 }
474
475 fn finish(self, status: AdapterStatus) -> AdapterOutcome {
476 AdapterOutcome {
477 model: self.model,
478 usage_json: self.usage_json,
479 status,
480 degrade_reason: None,
481 }
482 }
483}
484
485fn locate_transcript(
508 projects: &Path,
509 cwd: &Path,
510 stop: &std::sync::Arc<AtomicBool>,
511 diag: &mut DiscoveryDiag,
512) -> Option<PathBuf> {
513 let slug_dir = projects.join(slugify(cwd));
514 let preexisting = snapshot_all_jsonl(projects);
515 let mut rejected: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
518
519 loop {
520 if stop.load(Ordering::Acquire) {
521 return None;
522 }
523 if slug_dir.is_dir() {
525 for f in new_jsonl_in(&slug_dir, &preexisting) {
526 if rejected.contains(&f) {
527 continue;
528 }
529 diag.new_candidates.insert(f.clone());
530 match first_record_cwd_state(&f, cwd) {
531 CwdState::Matches => return Some(f),
532 CwdState::Different => {
533 diag.cwd_mismatches.push(f.clone());
534 rejected.insert(f);
535 }
536 CwdState::Directory => {
537 diag.directories.push(f.clone());
538 rejected.insert(f);
539 }
540 CwdState::None => {} }
542 }
543 }
544 for f in new_jsonl_under(projects, &preexisting) {
548 if rejected.contains(&f) {
549 continue;
550 }
551 diag.new_candidates.insert(f.clone());
552 match first_record_cwd_state(&f, cwd) {
553 CwdState::Matches => return Some(f),
554 CwdState::Different => {
555 diag.cwd_mismatches.push(f.clone());
556 rejected.insert(f);
557 }
558 CwdState::Directory => {
559 diag.directories.push(f.clone());
560 rejected.insert(f);
561 }
562 CwdState::None => {}
563 }
564 }
565 std::thread::sleep(APPEAR_POLL);
566 }
567}
568
569fn newest_in_dir(dir: &Path, since_unix_ms: i64) -> Option<PathBuf> {
572 let entries = std::fs::read_dir(dir).ok()?;
573 let mut best: Option<(PathBuf, i64)> = None;
574 for e in entries.flatten() {
575 let p = e.path();
576 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
577 continue;
578 }
579 let Ok(m) = e.metadata() else { continue };
580 let Ok(modified) = m.modified() else { continue };
581 let mtime_ms = system_time_to_unix_ms(modified);
582 if mtime_ms < since_unix_ms - 5_000 {
583 continue; }
585 match &best {
586 Some((_, best_ms)) if mtime_ms <= *best_ms => {}
587 _ => best = Some((p, mtime_ms)),
588 }
589 }
590 best.map(|(p, _)| p)
591}
592
593fn snapshot_all_jsonl(projects: &Path) -> std::collections::HashSet<PathBuf> {
597 let mut set = std::collections::HashSet::new();
598 let Ok(dirs) = std::fs::read_dir(projects) else {
599 return set;
600 };
601 for d in dirs.flatten() {
602 let dp = d.path();
603 if !dp.is_dir() {
604 continue;
605 }
606 if let Ok(entries) = std::fs::read_dir(&dp) {
607 for e in entries.flatten() {
608 let p = e.path();
609 if p.extension().and_then(|x| x.to_str()) == Some("jsonl") {
610 set.insert(p);
611 }
612 }
613 }
614 }
615 set
616}
617
618fn new_jsonl_in(dir: &Path, preexisting: &std::collections::HashSet<PathBuf>) -> Vec<PathBuf> {
620 let Ok(entries) = std::fs::read_dir(dir) else {
621 return Vec::new();
622 };
623 let mut found: Vec<(PathBuf, i64)> = Vec::new();
624 for e in entries.flatten() {
625 let p = e.path();
626 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
627 continue;
628 }
629 if preexisting.contains(&p) {
630 continue;
631 }
632 let Ok(m) = e.metadata() else { continue };
633 let mtime_ms = m.modified().map_or(0, system_time_to_unix_ms);
634 found.push((p, mtime_ms));
635 }
636 found.sort_by_key(|(_, m)| std::cmp::Reverse(*m));
637 found.into_iter().map(|(p, _)| p).collect()
638}
639
640fn new_jsonl_under(
642 projects: &Path,
643 preexisting: &std::collections::HashSet<PathBuf>,
644) -> Vec<PathBuf> {
645 let Ok(dirs) = std::fs::read_dir(projects) else {
646 return Vec::new();
647 };
648 let mut found: Vec<(PathBuf, i64)> = Vec::new();
649 for d in dirs.flatten() {
650 let dp = d.path();
651 if !dp.is_dir() {
652 continue;
653 }
654 for p in new_jsonl_in(&dp, preexisting) {
655 let mtime_ms = std::fs::metadata(&p)
656 .ok()
657 .and_then(|m| m.modified().ok())
658 .map_or(0, system_time_to_unix_ms);
659 found.push((p, mtime_ms));
660 }
661 }
662 found.sort_by_key(|(_, m)| std::cmp::Reverse(*m));
663 found.into_iter().map(|(p, _)| p).collect()
664}
665
666#[derive(Debug, PartialEq, Eq)]
668enum CwdState {
669 Matches,
671 Different,
673 None,
675 Directory,
683}
684
685fn first_record_cwd_state(file: &Path, session_cwd: &Path) -> CwdState {
690 if file.is_dir() {
691 return CwdState::Directory;
692 }
693 let Ok(content) = std::fs::read_to_string(file) else {
694 return CwdState::None;
695 };
696 for line in content.lines() {
697 let line = line.trim();
698 if line.is_empty() {
699 continue;
700 }
701 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
702 continue;
703 };
704 if let Some(rc) = v.get("cwd").and_then(|c| c.as_str()) {
705 let rp = Path::new(rc);
706 return if rp == session_cwd || rp.starts_with(session_cwd) {
707 CwdState::Matches
708 } else {
709 CwdState::Different
710 };
711 }
712 }
713 CwdState::None
714}
715
716fn tail_file(
723 file: &Path,
724 projects: &Path,
725 ctx: &AdapterContext,
726 tx: &mpsc::Sender<Event>,
727 start: &Instant,
728 acc: &mut OutcomeAcc,
729) -> TailStats {
730 let slug_dir = projects.join(slugify(&ctx.cwd));
731 let mut current = file.to_path_buf();
732 let mut stats = TailStats::default();
733 let mut reader = match std::fs::File::open(¤t) {
734 Ok(f) => std::io::BufReader::new(f),
735 Err(_) => return stats, };
737 stats.read_ok = true;
738 let mut buf: Vec<u8> = Vec::new();
739 let mut line = Vec::new();
740 loop {
741 if ctx.stop.load(Ordering::Acquire) {
742 break;
743 }
744 match reader.read_until(b'\n', &mut line) {
745 Ok(0) => {
746 drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
749 if let Some(newer) = rotate_to_newer(&slug_dir, ¤t, ctx.started_at_unix_ms) {
751 debug(&format!(
752 "claude adapter: rotating to newer transcript {}",
753 newer.display()
754 ));
755 current = newer;
756 reader = match std::fs::File::open(¤t) {
757 Ok(f) => std::io::BufReader::new(f),
758 Err(_) => break,
759 };
760 line.clear();
761 continue;
762 }
763 if ctx.stop.load(Ordering::Acquire) {
764 break;
765 }
766 std::thread::sleep(TAIL_POLL);
767 }
768 Ok(_n) => {
769 drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
770 }
771 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
772 Err(_) => break,
773 }
774 }
775 drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
777 stats
778}
779
780fn drain_complete_lines(
783 buf: &mut Vec<u8>,
784 line: &mut Vec<u8>,
785 ctx: &AdapterContext,
786 tx: &mpsc::Sender<Event>,
787 start: &Instant,
788 acc: &mut OutcomeAcc,
789 stats: &mut TailStats,
790) {
791 if line.is_empty() {
792 return;
793 }
794 buf.append(line);
795 line.clear();
796 while let Some(nl) = buf.iter().rposition(|&b| b == b'\n') {
798 let complete: Vec<u8> = buf.drain(..=nl).collect();
799 parse_and_send(&complete, ctx, tx, start, acc, stats);
800 }
801}
802
803fn parse_and_send(
809 bytes: &[u8],
810 ctx: &AdapterContext,
811 tx: &mpsc::Sender<Event>,
812 start: &Instant,
813 acc: &mut OutcomeAcc,
814 stats: &mut TailStats,
815) {
816 let s = match std::str::from_utf8(bytes) {
817 Ok(s) => s.trim(),
818 Err(e) => {
819 stats.lines_seen += 1;
820 if stats.first_parse_error.is_none() {
821 stats.first_parse_error = Some((stats.lines_seen, format!("not UTF-8: {e}")));
822 }
823 debug(&format!(
824 "claude adapter: line {} not UTF-8, skipping: {e}",
825 stats.lines_seen
826 ));
827 return;
828 }
829 };
830 if s.is_empty() {
831 return;
832 }
833 stats.lines_seen += 1;
834 let value: serde_json::Value = match serde_json::from_str(s) {
835 Ok(v) => v,
836 Err(e) => {
837 if stats.first_parse_error.is_none() {
838 stats.first_parse_error = Some((stats.lines_seen, e.to_string()));
839 }
840 debug(&format!(
841 "claude adapter: line {} not valid JSON, skipping: {e}",
842 stats.lines_seen
843 ));
844 return;
845 }
846 };
847 stats.records_parsed += 1;
848 let ts_ms = value
849 .get("timestamp")
850 .and_then(|v| v.as_str())
851 .and_then(|s| ts_ms_from_iso(s, ctx.started_at_unix_ms))
852 .unwrap_or_else(|| elapsed_ms(start));
853 let parsed = parse_record(&value, &ctx.session_id, ts_ms, &ctx.blobs);
854 if parsed.is_assistant_message {
855 if let Some(msg) = value.get("message") {
856 acc.note_assistant(msg);
857 }
858 }
859 for ev in parsed.events {
860 if tx.send(ev).is_err() {
861 break; }
863 stats.events_produced += 1;
864 }
865}
866
867fn rotate_to_newer(slug_dir: &Path, current: &Path, started_at_unix_ms: i64) -> Option<PathBuf> {
869 let newest = newest_in_dir(slug_dir, started_at_unix_ms)?;
870 if newest == current {
871 return None;
872 }
873 let newer_mtime = std::fs::metadata(&newest)
875 .ok()
876 .and_then(|m| m.modified().ok())
877 .map(system_time_to_unix_ms);
878 let cur_mtime = std::fs::metadata(current)
879 .ok()
880 .and_then(|m| m.modified().ok())
881 .map(system_time_to_unix_ms);
882 match (newer_mtime, cur_mtime) {
883 (Some(n), Some(c)) if n > c => Some(newest),
884 _ => None,
885 }
886}
887
888#[derive(Debug, Clone)]
897pub struct ClaudeDesktopAdapter;
898
899impl Adapter for ClaudeDesktopAdapter {
900 fn agent_kind(&self) -> AgentKind {
901 AgentKind::ClaudeDesktop
902 }
903
904 #[allow(clippy::unused_self)]
905 fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle> {
906 let (tx, rx) = mpsc::channel::<Event>();
907 let outcome = std::thread::Builder::new()
908 .name("hh-claude-desktop-adapter".into())
909 .spawn(move || run_claude_tailer(ctx, tx))?;
910 Ok(AdapterHandle {
911 events: rx,
912 outcome,
913 })
914 }
915}
916
917#[derive(Debug, Clone)]
936pub struct CodexAdapter;
937
938impl Adapter for CodexAdapter {
939 fn agent_kind(&self) -> AgentKind {
940 AgentKind::CodexCli
941 }
942
943 #[allow(clippy::unused_self)]
944 fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle> {
945 let (tx, rx) = mpsc::channel::<Event>();
946 let outcome = std::thread::Builder::new()
947 .name("hh-codex-adapter".into())
948 .spawn(move || run_codex_tailer(ctx, tx))?;
949 Ok(AdapterHandle {
950 events: rx,
951 outcome,
952 })
953 }
954}
955
956#[must_use]
959pub fn codex_sessions_dir() -> Option<PathBuf> {
960 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
961 Some(PathBuf::from(home).join(".codex").join("sessions"))
962}
963
964#[allow(clippy::needless_pass_by_value)] fn run_codex_tailer(ctx: AdapterContext, tx: mpsc::Sender<Event>) -> AdapterOutcome {
968 let Some(sessions) = ctx.projects_dir.clone().or_else(codex_sessions_dir) else {
969 return degraded(
970 "no ~/.codex/sessions directory found (HOME unset?); run `hh doctor` to diagnose",
971 );
972 };
973 if !sessions.is_dir() {
974 return degraded(format!(
975 "sessions directory does not exist: {}; run `hh doctor` to diagnose",
976 sessions.display()
977 ));
978 }
979 let start = Instant::now();
980 let Some(file) = locate_codex_transcript(&sessions, &ctx.cwd, &ctx.stop) else {
981 return degraded(format!(
982 "no codex rollout transcript appeared under {} matching cwd {} before the session ended",
983 sessions.display(),
984 ctx.cwd.display(),
985 ));
986 };
987 let mut acc = OutcomeAcc::default();
988 let stats = tail_codex_file(&file, &ctx, &tx, &start, &mut acc);
989 if !stats.read_ok {
990 return degraded(format!(
991 "found a codex transcript at {} but could not read it; \
992 run `hh doctor` to check file permissions",
993 file.display(),
994 ));
995 }
996 if stats.events_produced == 0 {
997 let first_err = stats
998 .first_parse_error
999 .map(|(n, msg)| format!("; first parse error at line {n}: {msg}"))
1000 .unwrap_or_default();
1001 return degraded(format!(
1002 "codex transcript found ({}) but 0 records parsed (read {} line(s){first_err}); \
1003 run `hh doctor` to check the Codex CLI transcript format",
1004 file.display(),
1005 stats.lines_seen,
1006 ));
1007 }
1008 acc.finish(AdapterStatus::Active)
1009}
1010
1011fn locate_codex_transcript(
1018 sessions: &Path,
1019 cwd: &Path,
1020 stop: &std::sync::Arc<AtomicBool>,
1021) -> Option<PathBuf> {
1022 let preexisting = snapshot_codex_rollouts(sessions);
1023 let mut rejected: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
1024
1025 loop {
1026 if stop.load(Ordering::Acquire) {
1027 return None;
1028 }
1029 for date_dir in candidate_date_dirs() {
1031 let dir = sessions.join(&date_dir);
1032 if !dir.is_dir() {
1033 continue;
1034 }
1035 for f in new_rollout_in(&dir, &preexisting) {
1036 if rejected.contains(&f) {
1037 continue;
1038 }
1039 match first_codex_record_cwd(&f, cwd) {
1040 CwdState::Matches => return Some(f),
1041 CwdState::Different | CwdState::Directory => {
1042 rejected.insert(f);
1043 }
1044 CwdState::None => {} }
1046 }
1047 }
1048 std::thread::sleep(APPEAR_POLL);
1049 }
1050}
1051
1052fn snapshot_codex_rollouts(sessions: &Path) -> std::collections::HashSet<PathBuf> {
1054 let mut set = std::collections::HashSet::new();
1055 let Ok(years) = std::fs::read_dir(sessions) else {
1056 return set;
1057 };
1058 for year in years.flatten() {
1059 let yp = year.path();
1060 if !yp.is_dir() {
1061 continue;
1062 }
1063 let Ok(months) = std::fs::read_dir(&yp) else {
1064 continue;
1065 };
1066 for month in months.flatten() {
1067 let mp = month.path();
1068 if !mp.is_dir() {
1069 continue;
1070 }
1071 let Ok(days) = std::fs::read_dir(&mp) else {
1072 continue;
1073 };
1074 for day in days.flatten() {
1075 let dp = day.path();
1076 if !dp.is_dir() {
1077 continue;
1078 }
1079 if let Ok(entries) = std::fs::read_dir(&dp) {
1080 for e in entries.flatten() {
1081 let p = e.path();
1082 if p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
1083 n.starts_with("rollout-")
1084 && std::path::Path::new(n).extension()
1085 == Some(std::ffi::OsStr::new("jsonl"))
1086 }) {
1087 set.insert(p);
1088 }
1089 }
1090 }
1091 }
1092 }
1093 }
1094 set
1095}
1096
1097fn new_rollout_in(dir: &Path, preexisting: &std::collections::HashSet<PathBuf>) -> Vec<PathBuf> {
1099 let Ok(entries) = std::fs::read_dir(dir) else {
1100 return Vec::new();
1101 };
1102 let mut found: Vec<(PathBuf, i64)> = Vec::new();
1103 for e in entries.flatten() {
1104 let p = e.path();
1105 if !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
1106 n.starts_with("rollout-")
1107 && std::path::Path::new(n).extension() == Some(std::ffi::OsStr::new("jsonl"))
1108 }) {
1109 continue;
1110 }
1111 if preexisting.contains(&p) {
1112 continue;
1113 }
1114 let mtime_ms = e
1115 .metadata()
1116 .ok()
1117 .and_then(|m| m.modified().ok())
1118 .map_or(0, system_time_to_unix_ms);
1119 found.push((p, mtime_ms));
1120 }
1121 found.sort_by_key(|(_, m)| std::cmp::Reverse(*m));
1122 found.into_iter().map(|(p, _)| p).collect()
1123}
1124
1125fn candidate_date_dirs() -> Vec<String> {
1128 let now = std::time::SystemTime::now()
1129 .duration_since(std::time::UNIX_EPOCH)
1130 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
1131 let today_secs = now - (now % 86400);
1132 let mut dirs = Vec::with_capacity(2);
1133 for offset in [0, -86400] {
1134 let ts = today_secs + offset;
1135 if let Ok(dt) = time::OffsetDateTime::from_unix_timestamp(ts) {
1136 dirs.push(format!(
1137 "{:04}/{:02}/{:02}",
1138 dt.year(),
1139 u8::from(dt.month()),
1140 dt.day()
1141 ));
1142 }
1143 }
1144 dirs
1145}
1146
1147fn first_codex_record_cwd(file: &Path, session_cwd: &Path) -> CwdState {
1150 if file.is_dir() {
1151 return CwdState::Directory;
1152 }
1153 let Ok(content) = std::fs::read_to_string(file) else {
1154 return CwdState::None;
1155 };
1156 for line in content.lines() {
1157 let line = line.trim();
1158 if line.is_empty() {
1159 continue;
1160 }
1161 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
1162 continue;
1163 };
1164 if let Some(cwd_val) = v
1166 .get("payload")
1167 .and_then(|p| p.get("cwd"))
1168 .and_then(|c| c.as_str())
1169 {
1170 let rp = Path::new(cwd_val);
1171 return if rp == session_cwd || rp.starts_with(session_cwd) {
1172 CwdState::Matches
1173 } else {
1174 CwdState::Different
1175 };
1176 }
1177 }
1178 CwdState::None
1179}
1180
1181fn tail_codex_file(
1183 file: &Path,
1184 ctx: &AdapterContext,
1185 tx: &mpsc::Sender<Event>,
1186 start: &Instant,
1187 acc: &mut OutcomeAcc,
1188) -> TailStats {
1189 let mut stats = TailStats::default();
1190 let mut reader = match std::fs::File::open(file) {
1191 Ok(f) => std::io::BufReader::new(f),
1192 Err(_) => return stats,
1193 };
1194 stats.read_ok = true;
1195 let mut buf: Vec<u8> = Vec::new();
1196 let mut line = Vec::new();
1197 loop {
1198 if ctx.stop.load(Ordering::Acquire) {
1199 break;
1200 }
1201 match reader.read_until(b'\n', &mut line) {
1202 Ok(0) => {
1203 drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
1204 if ctx.stop.load(Ordering::Acquire) {
1205 break;
1206 }
1207 std::thread::sleep(TAIL_POLL);
1208 }
1209 Ok(_n) => {
1210 drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
1211 }
1212 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
1213 Err(_) => break,
1214 }
1215 }
1216 drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
1217 stats
1218}
1219
1220#[must_use]
1232#[allow(dead_code)] pub(crate) fn parse_codex_record(
1234 value: &serde_json::Value,
1235 session_id: &str,
1236 ts_ms: i64,
1237 blobs: &BlobStore,
1238) -> ParsedRecord {
1239 let mut out = ParsedRecord::default();
1240 let Some(obj) = value.as_object() else {
1241 return out;
1242 };
1243 let ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
1244 let payload = obj.get("payload");
1245 match ty {
1246 "response_item" => {
1247 if let Some(p) = payload {
1248 parse_codex_response_item(p, session_id, ts_ms, blobs, &mut out);
1249 }
1250 }
1251 "event_msg" => {
1252 if let Some(p) = payload {
1253 parse_codex_event_msg(p, session_id, ts_ms, blobs, &mut out);
1254 }
1255 }
1256 _ => {}
1258 }
1259 out
1260}
1261
1262#[allow(dead_code, clippy::too_many_lines)]
1264fn parse_codex_response_item(
1265 payload: &serde_json::Value,
1266 session_id: &str,
1267 ts_ms: i64,
1268 blobs: &BlobStore,
1269 out: &mut ParsedRecord,
1270) {
1271 let Some(obj) = payload.as_object() else {
1272 return;
1273 };
1274 let inner_ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
1275 match inner_ty {
1276 "message" => {
1277 let role = obj.get("role").and_then(|v| v.as_str()).unwrap_or("");
1278 let content = obj.get("content").and_then(|v| v.as_array());
1279 let text = content.and_then(|arr| {
1280 arr.iter()
1281 .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
1282 .collect::<Vec<_>>()
1283 .join("\n")
1284 .into()
1285 });
1286 let kind = match role {
1287 "assistant" => EventKind::AgentMessage,
1288 _ => EventKind::UserMessage,
1289 };
1290 if let Some(t) = text {
1291 out.events
1292 .push(text_event(session_id, ts_ms, kind, &t, blobs));
1293 }
1294 out.is_assistant_message = role == "assistant";
1295 }
1296 "reasoning" => {
1297 let summary = obj
1299 .get("summary")
1300 .and_then(|v| v.as_array())
1301 .map(|arr| {
1302 arr.iter()
1303 .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
1304 .collect::<Vec<_>>()
1305 .join("\n")
1306 })
1307 .unwrap_or_default();
1308 let text = if summary.is_empty() {
1309 "reasoning (encrypted)".to_string()
1310 } else {
1311 summary
1312 };
1313 out.events.push(text_event(
1314 session_id,
1315 ts_ms,
1316 EventKind::Thinking,
1317 &text,
1318 blobs,
1319 ));
1320 }
1321 "function_call" | "custom_tool_call" => {
1322 let call_id = obj.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
1323 let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
1324 let input = if inner_ty == "function_call" {
1325 obj.get("arguments")
1327 .and_then(|v| v.as_str())
1328 .and_then(|s| serde_json::from_str(s).ok())
1329 .unwrap_or(serde_json::Value::Null)
1330 } else {
1331 obj.get("input").cloned().unwrap_or(serde_json::Value::Null)
1333 };
1334 let body = serde_json::json!({
1335 "name": name,
1336 "input": input,
1337 "correlate_key": call_id,
1338 });
1339 let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
1340 out.events.push(Event {
1341 session_id: session_id.to_string(),
1342 ts_ms,
1343 kind: EventKind::ToolCall,
1344 step: None,
1345 summary: truncate_summary(&format!("tool_call: {name}")),
1346 body_json,
1347 blob_hash,
1348 blob_size,
1349 correlates: None,
1350 });
1351 }
1352 "function_call_output" | "custom_tool_call_output" => {
1353 let call_id = obj.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
1354 let output = obj.get("output").and_then(|v| v.as_str()).unwrap_or("");
1355 let body = serde_json::json!({
1356 "tool_use_id": call_id,
1357 "is_error": false,
1358 "content": output,
1359 "correlate_key": call_id,
1360 });
1361 let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
1362 out.events.push(Event {
1363 session_id: session_id.to_string(),
1364 ts_ms,
1365 kind: EventKind::ToolResult,
1366 step: None,
1367 summary: truncate_summary(&format!("tool_result: {}", one_line(output))),
1368 body_json,
1369 blob_hash,
1370 blob_size,
1371 correlates: None,
1372 });
1373 }
1374 _ => {} }
1376}
1377
1378#[allow(dead_code, clippy::too_many_lines)]
1380fn parse_codex_event_msg(
1381 payload: &serde_json::Value,
1382 session_id: &str,
1383 ts_ms: i64,
1384 blobs: &BlobStore,
1385 out: &mut ParsedRecord,
1386) {
1387 let Some(obj) = payload.as_object() else {
1388 return;
1389 };
1390 let inner_ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
1391 match inner_ty {
1392 "user_message" => {
1393 if let Some(text) = extract_codex_event_text(obj) {
1394 out.events.push(text_event(
1395 session_id,
1396 ts_ms,
1397 EventKind::UserMessage,
1398 &text,
1399 blobs,
1400 ));
1401 }
1402 }
1403 "agent_message" => {
1404 if let Some(text) = extract_codex_event_text(obj) {
1405 out.events.push(text_event(
1406 session_id,
1407 ts_ms,
1408 EventKind::AgentMessage,
1409 &text,
1410 blobs,
1411 ));
1412 }
1413 out.is_assistant_message = true;
1414 }
1415 "token_count" => {
1416 if let Some(info) = obj.get("info").and_then(|v| v.as_object()) {
1418 if info.get("total_token_usage").is_some() {
1419 out.is_assistant_message = true;
1420 }
1426 }
1427 }
1428 "exec_command_end" | "patch_apply_end" => {
1429 let call_id = obj.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
1431 let aggregated_output = obj
1432 .get("aggregated_output")
1433 .and_then(|v| v.as_str())
1434 .unwrap_or("");
1435 let stdout = obj.get("stdout").and_then(|v| v.as_str()).unwrap_or("");
1436 let stderr = obj.get("stderr").and_then(|v| v.as_str()).unwrap_or("");
1437 let exit_code = obj
1438 .get("exit_code")
1439 .and_then(serde_json::Value::as_i64)
1440 .unwrap_or(0);
1441 let content = if aggregated_output.is_empty() {
1442 let mut parts = Vec::new();
1443 if !stdout.is_empty() {
1444 parts.push(stdout);
1445 }
1446 if !stderr.is_empty() {
1447 parts.push(stderr);
1448 }
1449 parts.join("\n")
1450 } else {
1451 aggregated_output.to_string()
1452 };
1453 let body = serde_json::json!({
1454 "tool_use_id": call_id,
1455 "is_error": exit_code != 0,
1456 "content": content,
1457 "correlate_key": call_id,
1458 });
1459 let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
1460 let summary = if exit_code != 0 {
1461 format!("tool_result (error): {}", one_line(&content))
1462 } else {
1463 format!("tool_result: {}", one_line(&content))
1464 };
1465 out.events.push(Event {
1466 session_id: session_id.to_string(),
1467 ts_ms,
1468 kind: EventKind::ToolResult,
1469 step: None,
1470 summary: truncate_summary(&summary),
1471 body_json,
1472 blob_hash,
1473 blob_size,
1474 correlates: None,
1475 });
1476 }
1477 _ => {} }
1479}
1480
1481#[allow(dead_code)]
1484fn extract_codex_event_text(obj: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
1485 if let Some(content) = obj.get("content") {
1487 match content {
1488 serde_json::Value::String(s) => return Some(s.clone()),
1489 serde_json::Value::Array(arr) => {
1490 let parts: String = arr
1491 .iter()
1492 .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
1493 .collect::<Vec<_>>()
1494 .join("\n");
1495 if !parts.is_empty() {
1496 return Some(parts);
1497 }
1498 }
1499 _ => {}
1500 }
1501 }
1502 if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
1504 return Some(text.to_string());
1505 }
1506 None
1507}
1508
1509#[derive(Debug, Clone)]
1527pub struct GeminiAdapter;
1528
1529impl Adapter for GeminiAdapter {
1530 fn agent_kind(&self) -> AgentKind {
1531 AgentKind::GeminiCli
1532 }
1533
1534 #[allow(clippy::unused_self)]
1535 fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle> {
1536 let (tx, rx) = mpsc::channel::<Event>();
1537 let outcome = std::thread::Builder::new()
1538 .name("hh-gemini-adapter".into())
1539 .spawn(move || run_gemini_tailer(ctx, tx))?;
1540 Ok(AdapterHandle {
1541 events: rx,
1542 outcome,
1543 })
1544 }
1545}
1546
1547#[must_use]
1550pub fn gemini_tmp_dir() -> Option<PathBuf> {
1551 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
1552 Some(PathBuf::from(home).join(".gemini").join("tmp"))
1553}
1554
1555#[allow(clippy::needless_pass_by_value)] fn run_gemini_tailer(ctx: AdapterContext, tx: mpsc::Sender<Event>) -> AdapterOutcome {
1559 let Some(tmp) = ctx.projects_dir.clone().or_else(gemini_tmp_dir) else {
1560 return degraded(
1561 "no ~/.gemini/tmp directory found (HOME unset?); run `hh doctor` to diagnose",
1562 );
1563 };
1564 if !tmp.is_dir() {
1565 return degraded(format!(
1566 "gemini tmp directory does not exist: {}; run `hh doctor` to diagnose",
1567 tmp.display()
1568 ));
1569 }
1570 let start = Instant::now();
1571 let Some(file) = locate_gemini_transcript(&tmp, &ctx.cwd, &ctx.stop) else {
1572 return degraded(format!(
1573 "no gemini session transcript appeared under {} matching cwd {} before the session ended",
1574 tmp.display(),
1575 ctx.cwd.display(),
1576 ));
1577 };
1578 let mut acc = OutcomeAcc::default();
1579 let stats = tail_gemini_file(&file, &ctx, &tx, &start, &mut acc);
1580 if !stats.read_ok {
1581 return degraded(format!(
1582 "found a gemini transcript at {} but could not read it; \
1583 run `hh doctor` to check file permissions",
1584 file.display(),
1585 ));
1586 }
1587 if stats.events_produced == 0 {
1588 let first_err = stats
1589 .first_parse_error
1590 .map(|(n, msg)| format!("; first parse error at line {n}: {msg}"))
1591 .unwrap_or_default();
1592 return degraded(format!(
1593 "gemini transcript found ({}) but 0 records parsed (read {} line(s){first_err}); \
1594 run `hh doctor` to check the Gemini CLI transcript format",
1595 file.display(),
1596 stats.lines_seen,
1597 ));
1598 }
1599 acc.finish(AdapterStatus::Active)
1600}
1601
1602fn locate_gemini_transcript(
1608 tmp: &Path,
1609 cwd: &Path,
1610 stop: &std::sync::Arc<AtomicBool>,
1611) -> Option<PathBuf> {
1612 let preexisting = snapshot_gemini_sessions(tmp);
1613 let mut rejected: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
1614
1615 loop {
1616 if stop.load(Ordering::Acquire) {
1617 return None;
1618 }
1619 let Ok(projects) = std::fs::read_dir(tmp) else {
1621 std::thread::sleep(APPEAR_POLL);
1622 continue;
1623 };
1624 for project in projects.flatten() {
1625 let chats = project.path().join("chats");
1626 if !chats.is_dir() {
1627 continue;
1628 }
1629 for f in new_gemini_sessions_in(&chats, &preexisting) {
1630 if rejected.contains(&f) {
1631 continue;
1632 }
1633 match first_gemini_record_cwd(&f, cwd) {
1634 CwdState::Matches => return Some(f),
1635 CwdState::Different | CwdState::Directory => {
1636 rejected.insert(f);
1637 }
1638 CwdState::None => {}
1639 }
1640 }
1641 }
1642 std::thread::sleep(APPEAR_POLL);
1643 }
1644}
1645
1646fn snapshot_gemini_sessions(tmp: &Path) -> std::collections::HashSet<PathBuf> {
1648 let mut set = std::collections::HashSet::new();
1649 let Ok(projects) = std::fs::read_dir(tmp) else {
1650 return set;
1651 };
1652 for project in projects.flatten() {
1653 let chats = project.path().join("chats");
1654 if !chats.is_dir() {
1655 continue;
1656 }
1657 if let Ok(entries) = std::fs::read_dir(&chats) {
1658 for e in entries.flatten() {
1659 let p = e.path();
1660 if p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
1661 n.starts_with("session-")
1662 && std::path::Path::new(n).extension()
1663 == Some(std::ffi::OsStr::new("jsonl"))
1664 }) {
1665 set.insert(p);
1666 }
1667 }
1668 }
1669 }
1670 set
1671}
1672
1673fn new_gemini_sessions_in(
1675 dir: &Path,
1676 preexisting: &std::collections::HashSet<PathBuf>,
1677) -> Vec<PathBuf> {
1678 let Ok(entries) = std::fs::read_dir(dir) else {
1679 return Vec::new();
1680 };
1681 let mut found: Vec<(PathBuf, i64)> = Vec::new();
1682 for e in entries.flatten() {
1683 let p = e.path();
1684 if !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
1685 n.starts_with("session-")
1686 && std::path::Path::new(n).extension() == Some(std::ffi::OsStr::new("jsonl"))
1687 }) {
1688 continue;
1689 }
1690 if preexisting.contains(&p) {
1691 continue;
1692 }
1693 let mtime_ms = e
1694 .metadata()
1695 .ok()
1696 .and_then(|m| m.modified().ok())
1697 .map_or(0, system_time_to_unix_ms);
1698 found.push((p, mtime_ms));
1699 }
1700 found.sort_by_key(|(_, m)| std::cmp::Reverse(*m));
1701 found.into_iter().map(|(p, _)| p).collect()
1702}
1703
1704fn first_gemini_record_cwd(file: &Path, _session_cwd: &Path) -> CwdState {
1707 if file.is_dir() {
1708 return CwdState::Directory;
1709 }
1710 let Ok(content) = std::fs::read_to_string(file) else {
1711 return CwdState::None;
1712 };
1713 for line in content.lines() {
1714 let line = line.trim();
1715 if line.is_empty() {
1716 continue;
1717 }
1718 let Ok(_v) = serde_json::from_str::<serde_json::Value>(line) else {
1719 continue;
1720 };
1721 return CwdState::Matches;
1726 }
1727 CwdState::None
1728}
1729
1730fn tail_gemini_file(
1732 file: &Path,
1733 ctx: &AdapterContext,
1734 tx: &mpsc::Sender<Event>,
1735 start: &Instant,
1736 acc: &mut OutcomeAcc,
1737) -> TailStats {
1738 let mut stats = TailStats::default();
1739 let mut reader = match std::fs::File::open(file) {
1740 Ok(f) => std::io::BufReader::new(f),
1741 Err(_) => return stats,
1742 };
1743 stats.read_ok = true;
1744 let mut buf: Vec<u8> = Vec::new();
1745 let mut line = Vec::new();
1746 loop {
1747 if ctx.stop.load(Ordering::Acquire) {
1748 break;
1749 }
1750 match reader.read_until(b'\n', &mut line) {
1751 Ok(0) => {
1752 drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
1753 if ctx.stop.load(Ordering::Acquire) {
1754 break;
1755 }
1756 std::thread::sleep(TAIL_POLL);
1757 }
1758 Ok(_n) => {
1759 drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
1760 }
1761 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
1762 Err(_) => break,
1763 }
1764 }
1765 drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
1766 stats
1767}
1768
1769#[must_use]
1781#[allow(dead_code, clippy::too_many_lines)] pub(crate) fn parse_gemini_record(
1783 value: &serde_json::Value,
1784 session_id: &str,
1785 ts_ms: i64,
1786 blobs: &BlobStore,
1787) -> ParsedRecord {
1788 let mut out = ParsedRecord::default();
1789 let Some(obj) = value.as_object() else {
1790 return out;
1791 };
1792 let ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
1793 match ty {
1794 "user" => {
1795 if let Some(text) = extract_gemini_text(obj) {
1796 out.events.push(text_event(
1797 session_id,
1798 ts_ms,
1799 EventKind::UserMessage,
1800 &text,
1801 blobs,
1802 ));
1803 }
1804 }
1805 "gemini" => {
1806 if let Some(text) = extract_gemini_text(obj) {
1808 out.events.push(text_event(
1809 session_id,
1810 ts_ms,
1811 EventKind::AgentMessage,
1812 &text,
1813 blobs,
1814 ));
1815 }
1816 if let Some(thoughts) = obj.get("thoughts").and_then(|v| v.as_array()) {
1818 for thought in thoughts {
1819 if let Some(text) = thought.get("text").and_then(|v| v.as_str()) {
1820 out.events.push(text_event(
1821 session_id,
1822 ts_ms,
1823 EventKind::Thinking,
1824 text,
1825 blobs,
1826 ));
1827 }
1828 }
1829 }
1830 if let Some(tool_calls) = obj.get("toolCalls").and_then(|v| v.as_array()) {
1832 for tc in tool_calls {
1833 if let Some(tc_obj) = tc.as_object() {
1834 let call_id = tc_obj.get("id").and_then(|v| v.as_str()).unwrap_or("");
1835 let name = tc_obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
1836 let input = tc_obj
1837 .get("input")
1838 .cloned()
1839 .unwrap_or(serde_json::Value::Null);
1840 let body = serde_json::json!({
1841 "name": name,
1842 "input": input,
1843 "correlate_key": call_id,
1844 });
1845 let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
1846 out.events.push(Event {
1847 session_id: session_id.to_string(),
1848 ts_ms,
1849 kind: EventKind::ToolCall,
1850 step: None,
1851 summary: truncate_summary(&format!("tool_call: {name}")),
1852 body_json,
1853 blob_hash,
1854 blob_size,
1855 correlates: None,
1856 });
1857 if let Some(result) = tc_obj.get("result").and_then(|v| v.as_array()) {
1859 let result_text: String = result
1860 .iter()
1861 .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
1862 .collect::<Vec<_>>()
1863 .join("\n");
1864 let body = serde_json::json!({
1865 "tool_use_id": call_id,
1866 "is_error": false,
1867 "content": result_text,
1868 "correlate_key": call_id,
1869 });
1870 let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
1871 out.events.push(Event {
1872 session_id: session_id.to_string(),
1873 ts_ms,
1874 kind: EventKind::ToolResult,
1875 step: None,
1876 summary: truncate_summary(&format!(
1877 "tool_result: {}",
1878 one_line(&result_text)
1879 )),
1880 body_json,
1881 blob_hash,
1882 blob_size,
1883 correlates: None,
1884 });
1885 }
1886 }
1887 }
1888 }
1889 if obj.get("tokens").is_some() {
1891 out.is_assistant_message = true;
1892 }
1893 if let Some(_model) = obj.get("model").and_then(|v| v.as_str()) {
1895 out.is_assistant_message = true;
1896 }
1897 }
1898 _ => {}
1900 }
1901 out
1902}
1903
1904fn extract_gemini_text(obj: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
1907 if let Some(content) = obj.get("content") {
1908 match content {
1909 serde_json::Value::String(s) => return Some(s.clone()),
1910 serde_json::Value::Array(arr) => {
1911 let parts: String = arr
1912 .iter()
1913 .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
1914 .collect::<Vec<_>>()
1915 .join("\n");
1916 if !parts.is_empty() {
1917 return Some(parts);
1918 }
1919 }
1920 _ => {}
1921 }
1922 }
1923 None
1924}
1925
1926#[derive(Debug, Default, Clone)]
1933pub(crate) struct ParsedRecord {
1934 pub events: Vec<Event>,
1936 pub is_assistant_message: bool,
1938}
1939
1940#[must_use]
1946pub(crate) fn parse_record(
1947 value: &serde_json::Value,
1948 session_id: &str,
1949 ts_ms: i64,
1950 blobs: &BlobStore,
1951) -> ParsedRecord {
1952 let mut out = ParsedRecord::default();
1953 let Some(obj) = value.as_object() else {
1954 return out; };
1956 let ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
1957 if ty != "user" && ty != "assistant" {
1958 return out; }
1960 if obj
1961 .get("isSidechain")
1962 .and_then(serde_json::Value::as_bool)
1963 .unwrap_or(false)
1964 {
1965 return out; }
1967 if obj
1968 .get("isMeta")
1969 .and_then(serde_json::Value::as_bool)
1970 .unwrap_or(false)
1971 {
1972 return out; }
1974 let Some(message) = obj.get("message") else {
1975 return out;
1976 };
1977 let is_assistant = ty == "assistant";
1978 out.is_assistant_message = is_assistant;
1979 let Some(content) = message.get("content") else {
1980 return out;
1981 };
1982 match content {
1983 serde_json::Value::String(s) => {
1984 let kind = if is_assistant {
1985 EventKind::AgentMessage
1986 } else {
1987 EventKind::UserMessage
1988 };
1989 out.events
1990 .push(text_event(session_id, ts_ms, kind, s, blobs));
1991 }
1992 serde_json::Value::Array(blocks) => {
1993 for block in blocks {
1994 if let Some(b) = block.as_object() {
1995 parse_block(b, is_assistant, session_id, ts_ms, blobs, &mut out.events);
1996 }
1997 }
1998 }
1999 _ => {} }
2001 out
2002}
2003
2004fn parse_block(
2006 b: &serde_json::Map<String, serde_json::Value>,
2007 is_assistant: bool,
2008 session_id: &str,
2009 ts_ms: i64,
2010 blobs: &BlobStore,
2011 events: &mut Vec<Event>,
2012) {
2013 let bty = b.get("type").and_then(|v| v.as_str()).unwrap_or("");
2014 match bty {
2015 "text" => {
2016 if let Some(t) = b.get("text").and_then(|v| v.as_str()) {
2017 let kind = if is_assistant {
2018 EventKind::AgentMessage
2019 } else {
2020 EventKind::UserMessage
2021 };
2022 events.push(text_event(session_id, ts_ms, kind, t, blobs));
2023 }
2024 }
2025 "thinking" => {
2026 if let Some(t) = b.get("thinking").and_then(|v| v.as_str()) {
2027 events.push(text_event(session_id, ts_ms, EventKind::Thinking, t, blobs));
2028 }
2029 }
2030 "tool_use" => {
2031 let id = b.get("id").and_then(|v| v.as_str()).unwrap_or("");
2032 let name = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
2033 let input = b.get("input").cloned().unwrap_or(serde_json::Value::Null);
2034 events.push(tool_call_event(session_id, ts_ms, id, name, &input, blobs));
2035 }
2036 "tool_result" => {
2037 let tool_use_id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
2038 let is_error = b
2039 .get("is_error")
2040 .and_then(serde_json::Value::as_bool)
2041 .unwrap_or(false);
2042 let content_text = extract_result_content(b.get("content"));
2043 events.push(tool_result_event(
2044 session_id,
2045 ts_ms,
2046 tool_use_id,
2047 is_error,
2048 &content_text,
2049 blobs,
2050 ));
2051 }
2052 _ => {} }
2054}
2055
2056fn text_event(
2058 session_id: &str,
2059 ts_ms: i64,
2060 kind: EventKind,
2061 text: &str,
2062 blobs: &BlobStore,
2063) -> Event {
2064 let body = serde_json::json!({ "text": text });
2065 let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
2066 Event {
2067 session_id: session_id.to_string(),
2068 ts_ms,
2069 kind,
2070 step: None, summary: truncate_summary(text),
2072 body_json,
2073 blob_hash,
2074 blob_size,
2075 correlates: None,
2076 }
2077}
2078
2079fn tool_call_event(
2081 session_id: &str,
2082 ts_ms: i64,
2083 id: &str,
2084 name: &str,
2085 input: &serde_json::Value,
2086 blobs: &BlobStore,
2087) -> Event {
2088 let body = serde_json::json!({
2089 "name": name,
2090 "input": input,
2091 "correlate_key": id,
2092 });
2093 let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
2094 Event {
2095 session_id: session_id.to_string(),
2096 ts_ms,
2097 kind: EventKind::ToolCall,
2098 step: None,
2099 summary: truncate_summary(&format!("tool_call: {name}")),
2100 body_json,
2101 blob_hash,
2102 blob_size,
2103 correlates: None,
2104 }
2105}
2106
2107fn tool_result_event(
2109 session_id: &str,
2110 ts_ms: i64,
2111 tool_use_id: &str,
2112 is_error: bool,
2113 content: &str,
2114 blobs: &BlobStore,
2115) -> Event {
2116 let body = serde_json::json!({
2117 "tool_use_id": tool_use_id,
2118 "is_error": is_error,
2119 "content": content,
2120 "correlate_key": tool_use_id,
2121 });
2122 let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
2123 let summary = if is_error {
2124 format!("tool_result (error): {}", one_line(content))
2125 } else {
2126 format!("tool_result: {}", one_line(content))
2127 };
2128 Event {
2129 session_id: session_id.to_string(),
2130 ts_ms,
2131 kind: EventKind::ToolResult,
2132 step: None,
2133 summary: truncate_summary(&summary),
2134 body_json,
2135 blob_hash,
2136 blob_size,
2137 correlates: None,
2138 }
2139}
2140
2141fn maybe_spill(
2145 body: serde_json::Value,
2146 blobs: &BlobStore,
2147) -> (Option<serde_json::Value>, Option<String>, Option<u64>) {
2148 let serialized = serde_json::to_vec(&body).unwrap_or_default();
2149 if serialized.len() >= SPILLOVER_BYTES {
2150 if let Ok(outcome) = blobs.put(&serialized) {
2151 let envelope = serde_json::json!({
2152 "overflow": true,
2153 "size": outcome.size,
2154 "blob_hash": outcome.hash,
2155 "encoding": "blob",
2156 });
2157 return (Some(envelope), Some(outcome.hash), Some(outcome.size));
2158 }
2159 }
2160 (Some(body), None, None)
2161}
2162
2163fn extract_result_content(content: Option<&serde_json::Value>) -> String {
2166 match content {
2167 Some(serde_json::Value::String(s)) => s.clone(),
2168 Some(serde_json::Value::Array(arr)) => {
2169 let mut parts = Vec::with_capacity(arr.len());
2170 for b in arr {
2171 if let Some(t) = b.get("text").and_then(|v| v.as_str()) {
2172 parts.push(t.to_string());
2173 }
2174 }
2175 parts.join("\n")
2176 }
2177 _ => String::new(),
2178 }
2179}
2180
2181fn one_line(s: &str) -> String {
2183 s.chars()
2184 .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
2185 .collect()
2186}
2187
2188pub(crate) fn ts_ms_from_iso(iso: &str, started_at_unix_ms: i64) -> Option<i64> {
2192 let dt =
2193 time::OffsetDateTime::parse(iso, &time::format_description::well_known::Rfc3339).ok()?;
2194 let unix_ms = dt.unix_timestamp_nanos() / 1_000_000;
2195 let unix_ms = i64::try_from(unix_ms).unwrap_or(i64::MAX);
2196 Some((unix_ms - started_at_unix_ms).max(0))
2197}
2198
2199#[must_use]
2207pub fn claude_projects_dir() -> Option<PathBuf> {
2208 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
2209 Some(PathBuf::from(home).join(".claude").join("projects"))
2210}
2211
2212#[must_use]
2217pub fn newest_jsonl_for_cwd(cwd: &Path) -> Option<PathBuf> {
2218 let projects = claude_projects_dir()?;
2219 let slug_dir = projects.join(slugify(cwd));
2220 if !slug_dir.is_dir() {
2221 return None;
2222 }
2223 newest_in_dir(&slug_dir, 0)
2225}
2226
2227#[must_use]
2231pub(crate) fn slugify(cwd: &Path) -> String {
2232 cwd.to_string_lossy()
2233 .chars()
2234 .map(|c| match c {
2235 '/' | '\\' | '.' => '-',
2236 other => other,
2237 })
2238 .collect()
2239}
2240
2241fn system_time_to_unix_ms(t: std::time::SystemTime) -> i64 {
2243 t.duration_since(std::time::UNIX_EPOCH)
2244 .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
2245}
2246
2247fn elapsed_ms(start: &Instant) -> i64 {
2249 i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX)
2250}
2251
2252fn debug(msg: &str) {
2260 if std::env::var_os("HH_DEBUG").is_some() {
2261 eprintln!("hh: debug: {msg}");
2262 }
2263}
2264
2265#[cfg(feature = "fuzzing")]
2270pub mod fuzzing {
2271 use super::{parse_codex_record, parse_gemini_record, parse_record, BlobStore};
2272 use std::sync::OnceLock;
2273
2274 fn blobs() -> &'static BlobStore {
2278 static BLOBS: OnceLock<BlobStore> = OnceLock::new();
2279 BLOBS.get_or_init(|| {
2280 let dir = std::env::temp_dir().join(format!("hh-fuzz-adapter-{}", std::process::id()));
2281 BlobStore::new(dir)
2282 })
2283 }
2284
2285 pub fn fuzz_parse_line(bytes: &[u8]) {
2289 let Ok(s) = std::str::from_utf8(bytes) else {
2290 return;
2291 };
2292 let s = s.trim();
2293 if s.is_empty() {
2294 return;
2295 }
2296 let Ok(value) = serde_json::from_str::<serde_json::Value>(s) else {
2297 return;
2298 };
2299 let _ = parse_record(&value, "fuzz-session", 0, blobs());
2300 }
2301
2302 pub fn fuzz_parse_codex_line(bytes: &[u8]) {
2305 let Ok(s) = std::str::from_utf8(bytes) else {
2306 return;
2307 };
2308 let s = s.trim();
2309 if s.is_empty() {
2310 return;
2311 }
2312 let Ok(value) = serde_json::from_str::<serde_json::Value>(s) else {
2313 return;
2314 };
2315 let _ = parse_codex_record(&value, "fuzz-session", 0, blobs());
2316 }
2317
2318 pub fn fuzz_parse_gemini_line(bytes: &[u8]) {
2321 let Ok(s) = std::str::from_utf8(bytes) else {
2322 return;
2323 };
2324 let s = s.trim();
2325 if s.is_empty() {
2326 return;
2327 }
2328 let Ok(value) = serde_json::from_str::<serde_json::Value>(s) else {
2329 return;
2330 };
2331 let _ = parse_gemini_record(&value, "fuzz-session", 0, blobs());
2332 }
2333}
2334
2335#[cfg(test)]
2336mod tests {
2337 use super::*;
2338 use serde_json::Value;
2339 use std::io::Write;
2340
2341 const STARTED: i64 = 1_782_972_880_000;
2344 const SID: &str = "test-session-id";
2345
2346 fn iso(s: &str) -> i64 {
2347 ts_ms_from_iso(s, STARTED).expect("fixture timestamps must parse")
2348 }
2349
2350 fn parse(value: &Value, ts_ms: i64) -> ParsedRecord {
2351 let tmp = tempfile::TempDir::new().unwrap();
2352 let blobs = BlobStore::new(tmp.path().join("blobs"));
2353 parse_record(value, SID, ts_ms, &blobs)
2354 }
2355
2356 fn snap(pr: &ParsedRecord) -> String {
2359 serde_json::to_string_pretty(&serde_json::json!({
2360 "events": pr.events,
2361 "is_assistant_message": pr.is_assistant_message,
2362 }))
2363 .unwrap()
2364 }
2365
2366 fn rec(json: &str) -> Value {
2367 serde_json::from_str(json).unwrap()
2368 }
2369
2370 #[test]
2371 fn parse_user_text_prompt() {
2372 let v = rec(r#"{"type":"user","isSidechain":false,"isMeta":false,
2373 "message":{"role":"user","content":"Please list the files in the repo and show Cargo.toml."},
2374 "timestamp":"2026-07-02T06:14:40.699Z","cwd":"/tmp/work","sessionId":"s"}"#);
2375 let pr = parse(&v, iso("2026-07-02T06:14:40.699Z"));
2376 insta::assert_snapshot!(snap(&pr));
2377 }
2378
2379 #[test]
2380 fn parse_assistant_with_two_tool_uses() {
2381 let v = rec(r#"{"type":"assistant","isSidechain":false,"isMeta":false,
2382 "message":{"role":"assistant","model":"glm-5.2","content":[
2383 {"type":"thinking","thinking":"I'll list files then read Cargo.toml.","signature":""},
2384 {"type":"tool_use","id":"call_abc123","name":"Bash","input":{"command":"ls -la"}},
2385 {"type":"tool_use","id":"call_def456","name":"Read","input":{"file_path":"Cargo.toml"}}
2386 ],"usage":{"input_tokens":1200,"output_tokens":80}},
2387 "timestamp":"2026-07-02T06:14:41.500Z","cwd":"/tmp/work","sessionId":"s"}"#);
2388 let pr = parse(&v, iso("2026-07-02T06:14:41.500Z"));
2389 insta::assert_snapshot!(snap(&pr));
2390 assert_eq!(pr.events.len(), 3);
2392 assert!(pr.is_assistant_message);
2393 assert_eq!(pr.events[0].kind, EventKind::Thinking);
2394 assert_eq!(pr.events[1].kind, EventKind::ToolCall);
2395 assert_eq!(pr.events[2].kind, EventKind::ToolCall);
2396 }
2397
2398 #[test]
2399 fn parse_user_with_two_tool_results_one_error() {
2400 let v = rec(r#"{"type":"user","isSidechain":false,"isMeta":false,
2401 "message":{"role":"user","content":[
2402 {"type":"tool_result","tool_use_id":"call_abc123","content":"total 0","is_error":false},
2403 {"type":"tool_result","tool_use_id":"call_def456","content":"Error: file not found","is_error":true}
2404 ]},
2405 "timestamp":"2026-07-02T06:14:42.800Z","cwd":"/tmp/work","sessionId":"s"}"#);
2406 let pr = parse(&v, iso("2026-07-02T06:14:42.800Z"));
2407 insta::assert_snapshot!(snap(&pr));
2408 assert_eq!(pr.events.len(), 2);
2409 assert_eq!(pr.events[0].kind, EventKind::ToolResult);
2410 assert_eq!(pr.events[1].kind, EventKind::ToolResult);
2411 assert_eq!(
2413 pr.events[0].body_json.as_ref().unwrap()["correlate_key"],
2414 "call_abc123"
2415 );
2416 assert!(pr.events[1].body_json.as_ref().unwrap()["is_error"]
2417 .as_bool()
2418 .unwrap());
2419 }
2420
2421 #[test]
2422 fn parse_assistant_followup_with_model_usage() {
2423 let v = rec(r#"{"type":"assistant","isSidechain":false,"isMeta":false,
2424 "message":{"role":"assistant","model":"glm-5.2","content":[
2425 {"type":"text","text":"The repo is empty and Cargo.toml is missing."}
2426 ],"usage":{"input_tokens":1500,"output_tokens":40,"cache_read_input_tokens":300}},
2427 "timestamp":"2026-07-02T06:14:43.100Z","cwd":"/tmp/work","sessionId":"s"}"#);
2428 let pr = parse(&v, iso("2026-07-02T06:14:43.100Z"));
2429 insta::assert_snapshot!(snap(&pr));
2430 assert_eq!(pr.events.len(), 1);
2431 assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
2432 let mut acc = OutcomeAcc::default();
2434 acc.note_assistant(v.get("message").unwrap());
2435 assert_eq!(acc.model.as_deref(), Some("glm-5.2"));
2436 assert_eq!(acc.usage_json.as_ref().unwrap()["output_tokens"], 40);
2437 }
2438
2439 #[test]
2440 fn parse_skips_unknown_type() {
2441 let v = rec(r#"{"type":"system","subtype":"init","cwd":"/tmp/work","sessionId":"s"}"#);
2442 let pr = parse(&v, 0);
2443 assert!(pr.events.is_empty());
2444 assert!(!pr.is_assistant_message);
2445 }
2446
2447 #[test]
2448 fn parse_skips_non_object_value() {
2449 for v in [rec("[]"), rec("null"), rec("\"oops\"")] {
2451 assert!(
2452 parse(&v, 0).events.is_empty(),
2453 "non-object must yield no events"
2454 );
2455 }
2456 }
2457
2458 #[test]
2459 fn parse_skips_is_sidechain() {
2460 let v = rec(r#"{"type":"assistant","isSidechain":true,"isMeta":false,
2461 "message":{"role":"assistant","content":[{"type":"text","text":"subagent"}]},
2462 "timestamp":"2026-07-02T06:14:41.000Z","cwd":"/tmp/work","sessionId":"s"}"#);
2463 assert!(parse(&v, 0).events.is_empty());
2464 }
2465
2466 #[test]
2467 fn parse_skips_is_meta() {
2468 let v = rec(r#"{"type":"user","isSidechain":false,"isMeta":true,
2469 "message":{"role":"user","content":"<local-command-caveat>system-reminder</local-command-caveat>"},
2470 "timestamp":"2026-07-02T06:14:40.600Z","cwd":"/tmp/work","sessionId":"s"}"#);
2471 assert!(parse(&v, 0).events.is_empty());
2472 }
2473
2474 #[test]
2475 fn parse_256kib_spills_to_blob() {
2476 let big = "x".repeat(SPILLOVER_BYTES + 1024);
2477 let v = rec(&format!(
2478 r#"{{"type":"user","isSidechain":false,"isMeta":false,
2479 "message":{{"role":"user","content":{}}},
2480 "timestamp":"2026-07-02T06:14:40.699Z","cwd":"/tmp/work","sessionId":"s"}}"#,
2481 serde_json::Value::String(big)
2482 ));
2483 let tmp = tempfile::TempDir::new().unwrap();
2484 let blobs = BlobStore::new(tmp.path().join("blobs"));
2485 let pr = parse_record(&v, SID, 699, &blobs);
2486 let ev = &pr.events[0];
2487 assert!(ev.blob_hash.is_some(), "large payload must spill to a blob");
2488 let hash = ev.blob_hash.as_ref().unwrap();
2489 assert_eq!(ev.body_json.as_ref().unwrap()["overflow"], true);
2490 assert_eq!(ev.body_json.as_ref().unwrap()["encoding"], "blob");
2491 let raw = blobs.get(hash).unwrap();
2493 let body: Value = serde_json::from_slice(&raw).unwrap();
2494 assert_eq!(body["text"].as_str().unwrap().len(), SPILLOVER_BYTES + 1024);
2495 }
2496
2497 #[test]
2498 fn slugify_replaces_slash_and_dot() {
2499 assert_eq!(
2500 slugify(Path::new("/home/saadman/switch")),
2501 "-home-saadman-switch"
2502 );
2503 assert_eq!(
2504 slugify(Path::new("/home/saadman/switch/.claude/worktrees/x")),
2505 "-home-saadman-switch--claude-worktrees-x"
2506 );
2507 assert_eq!(slugify(Path::new("C:\\Users\\me")), "C:-Users-me");
2508 }
2509
2510 #[test]
2511 fn select_detects_claude_basename() {
2512 assert!(is_claude_code(&["claude".into()]));
2513 assert!(is_claude_code(&["/usr/local/bin/claude".into()]));
2514 assert!(is_claude_code(&["C:\\Apps\\claude.exe".into()]));
2515 assert!(!is_claude_code(&["python3".into()]));
2516 assert!(!is_claude_code(&[]));
2517 assert!(select(&["claude".into()], Path::new("/tmp")).is_some());
2519 assert!(select(&["python3".into()], Path::new("/tmp")).is_none());
2520 }
2521
2522 #[test]
2523 fn ts_ms_from_iso_is_relative_and_clamped() {
2524 assert_eq!(
2525 ts_ms_from_iso("2026-07-02T06:14:40.699Z", STARTED),
2526 Some(699)
2527 );
2528 assert_eq!(ts_ms_from_iso("2026-07-02T06:14:40.000Z", STARTED), Some(0));
2529 assert_eq!(ts_ms_from_iso("2026-07-02T06:14:39.000Z", STARTED), Some(0));
2531 assert!(ts_ms_from_iso("not-a-date", STARTED).is_none());
2532 }
2533
2534 fn spawn_with(home: &Path, cwd: &Path) -> (AdapterHandle, std::sync::Arc<AtomicBool>, PathBuf) {
2543 let slug_dir = home.join(".claude").join("projects").join(slugify(cwd));
2544 std::fs::create_dir_all(&slug_dir).unwrap();
2545 let stop = std::sync::Arc::new(AtomicBool::new(false));
2546 let blobs = std::sync::Arc::new(BlobStore::new(home.join("blobs")));
2547 let ctx = AdapterContext {
2548 session_id: SID.to_string(),
2549 started_at_unix_ms: STARTED,
2550 cwd: cwd.to_path_buf(),
2551 command: vec!["claude".into()],
2552 blobs,
2553 stop: std::sync::Arc::clone(&stop),
2554 projects_dir: Some(home.join(".claude").join("projects")),
2555 };
2556 let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn adapter");
2557 std::thread::sleep(Duration::from_millis(50));
2561 (handle, stop, slug_dir)
2562 }
2563
2564 fn write_transcript(slug_dir: &Path, name: &str, content: &str) -> PathBuf {
2567 let path = slug_dir.join(name);
2568 std::fs::write(&path, content).unwrap();
2569 path
2570 }
2571
2572 fn drain(events: &mpsc::Receiver<Event>) -> Vec<Event> {
2574 let mut out = Vec::new();
2575 while let Ok(ev) = events.recv() {
2576 out.push(ev);
2577 }
2578 out
2579 }
2580
2581 const FIXTURE: &str = "\
2582{\"type\":\"system\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\",\"timestamp\":\"2026-07-02T06:14:40.500Z\"}
2583{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":true,\"message\":{\"role\":\"user\",\"content\":\"caveat\"},\"timestamp\":\"2026-07-02T06:14:40.600Z\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\"}
2584{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\"}
2585{\"type\":\"assistant\",\"isSidechain\":true,\"isMeta\":false,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"side\"}]},\"timestamp\":\"2026-07-02T06:14:41.000Z\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\"}
2586{\"type\":\"assistant\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"assistant\",\"model\":\"glm-5.2\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"Bash\",\"input\":{\"command\":\"ls\"}}]},\"timestamp\":\"2026-07-02T06:14:41.500Z\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\"}
2587{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"ok\",\"is_error\":false}]},\"timestamp\":\"2026-07-02T06:14:42.800Z\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\"}
2588";
2589
2590 #[test]
2591 fn tailer_parses_fixture_and_skips_filtered_records() {
2592 let home = tempfile::TempDir::new().unwrap();
2593 let cwd = Path::new("/tmp/work");
2594 let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
2595 write_transcript(&slug_dir, "session.jsonl", FIXTURE);
2598 std::thread::sleep(Duration::from_millis(300));
2600 stop.store(true, Ordering::Release);
2601 let events = drain(&handle.events);
2602 let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
2604 assert_eq!(
2605 kinds,
2606 vec![
2607 EventKind::UserMessage,
2608 EventKind::ToolCall,
2609 EventKind::ToolResult
2610 ]
2611 );
2612 }
2613
2614 const FIXTURE_NEW_FORMAT: &str = "\
2623{\"type\":\"system\",\"subtype\":\"init\",\"sessionId\":\"s\",\"timestamp\":\"2026-07-02T06:14:40.500Z\",\"mode\":\"default\",\"permissionMode\":\"default\"}
2624{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":true,\"message\":{\"role\":\"user\",\"content\":\"caveat\"},\"timestamp\":\"2026-07-02T06:14:40.600Z\",\"mode\":\"default\",\"permissionMode\":\"default\"}
2625{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/work-new\",\"sessionId\":\"s\",\"mode\":\"default\",\"permissionMode\":\"default\",\"fileHistorySnapshot\":{}}
2626{\"type\":\"assistant\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"assistant\",\"model\":\"glm-5.2\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"Bash\",\"input\":{\"command\":\"ls\"}}]},\"timestamp\":\"2026-07-02T06:14:41.500Z\",\"cwd\":\"/tmp/work-new\",\"sessionId\":\"s\",\"mode\":\"default\",\"permissionMode\":\"default\"}
2627{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"ok\",\"is_error\":false}]},\"timestamp\":\"2026-07-02T06:14:42.800Z\",\"cwd\":\"/tmp/work-new\",\"sessionId\":\"s\",\"mode\":\"default\",\"permissionMode\":\"default\"}
2628";
2629
2630 #[test]
2636 fn first_record_cwd_state_skips_cwdless_records() {
2637 let tmp = tempfile::tempdir().unwrap();
2638 let f = tmp.path().join("new.jsonl");
2639 std::fs::write(&f, FIXTURE_NEW_FORMAT).unwrap();
2640 assert_eq!(
2642 first_record_cwd_state(&f, Path::new("/tmp/work-new")),
2643 CwdState::Matches
2644 );
2645 assert_eq!(
2647 first_record_cwd_state(&f, Path::new("/tmp/other")),
2648 CwdState::Different
2649 );
2650 }
2651
2652 #[test]
2658 fn tailer_parses_new_format_fixture_with_no_early_cwd() {
2659 let home = tempfile::TempDir::new().unwrap();
2660 let cwd = Path::new("/tmp/work-new");
2661 let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
2662 write_transcript(&slug_dir, "session.jsonl", FIXTURE_NEW_FORMAT);
2663 std::thread::sleep(Duration::from_millis(300));
2664 stop.store(true, Ordering::Release);
2665 let events = drain(&handle.events);
2666 let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
2669 assert_eq!(
2670 kinds,
2671 vec![
2672 EventKind::UserMessage,
2673 EventKind::ToolCall,
2674 EventKind::ToolResult
2675 ],
2676 "new-format fixture must parse the same events as the original"
2677 );
2678 }
2679
2680 #[test]
2681 fn tailer_finds_file_late() {
2682 let home = tempfile::TempDir::new().unwrap();
2683 let cwd = Path::new("/tmp/work-late");
2684 std::fs::create_dir_all(
2687 home.path()
2688 .join(".claude")
2689 .join("projects")
2690 .join(slugify(cwd)),
2691 )
2692 .unwrap();
2693 let stop = std::sync::Arc::new(AtomicBool::new(false));
2694 let blobs = std::sync::Arc::new(BlobStore::new(home.path().join("blobs")));
2695 let ctx = AdapterContext {
2696 session_id: SID.to_string(),
2697 started_at_unix_ms: STARTED,
2698 cwd: cwd.to_path_buf(),
2699 command: vec!["claude".into()],
2700 blobs,
2701 stop: std::sync::Arc::clone(&stop),
2702 projects_dir: Some(home.path().join(".claude").join("projects")),
2703 };
2704 let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn");
2705 std::thread::sleep(Duration::from_millis(150));
2707 std::fs::write(
2708 home
2709 .path()
2710 .join(".claude")
2711 .join("projects")
2712 .join(slugify(cwd))
2713 .join("late.jsonl"),
2714 "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"late\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/work-late\",\"sessionId\":\"s\"}\n",
2715 )
2716 .unwrap();
2717 std::thread::sleep(Duration::from_millis(150));
2718 stop.store(true, Ordering::Release);
2719 let events = drain(&handle.events);
2720 assert_eq!(
2721 events.len(),
2722 1,
2723 "adapter should pick up the late-appearing file"
2724 );
2725 assert_eq!(events[0].kind, EventKind::UserMessage);
2726 }
2727
2728 #[test]
2729 fn tailer_partial_last_line_retries() {
2730 let home = tempfile::TempDir::new().unwrap();
2731 let cwd = Path::new("/tmp/work-partial");
2732 let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
2733 let complete = "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"first\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/work-partial\",\"sessionId\":\"s\"}\n";
2736 let partial = "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"sec";
2737 let path = write_transcript(&slug_dir, "partial.jsonl", &format!("{complete}{partial}"));
2738 std::thread::sleep(Duration::from_millis(250));
2740 let mut f = std::fs::OpenOptions::new()
2742 .append(true)
2743 .open(&path)
2744 .unwrap();
2745 writeln!(f, "ond\"}}}}\n").unwrap();
2746 std::thread::sleep(Duration::from_millis(250));
2747 stop.store(true, Ordering::Release);
2748 let events = drain(&handle.events);
2749 let contents: Vec<&str> = events
2750 .iter()
2751 .map(|e| e.body_json.as_ref().unwrap()["text"].as_str().unwrap())
2752 .collect();
2753 assert_eq!(
2754 contents,
2755 vec!["first", "second"],
2756 "partial line must be retried until complete"
2757 );
2758 }
2759
2760 #[test]
2761 fn tailer_fallback_scan_by_cwd() {
2762 let home = tempfile::TempDir::new().unwrap();
2763 let cwd = Path::new("/tmp/work-fallback");
2764 let wrong_slug = "-totally-wrong-slug";
2767 let wrong_dir = home
2768 .path()
2769 .join(".claude")
2770 .join("projects")
2771 .join(wrong_slug);
2772 std::fs::create_dir_all(&wrong_dir).unwrap();
2773 let stop = std::sync::Arc::new(AtomicBool::new(false));
2774 let blobs = std::sync::Arc::new(BlobStore::new(home.path().join("blobs")));
2775 let ctx = AdapterContext {
2776 session_id: SID.to_string(),
2777 started_at_unix_ms: STARTED,
2778 cwd: cwd.to_path_buf(),
2779 command: vec!["claude".into()],
2780 blobs,
2781 stop: std::sync::Arc::clone(&stop),
2782 projects_dir: Some(home.path().join(".claude").join("projects")),
2783 };
2784 let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn");
2785 std::thread::sleep(Duration::from_millis(50));
2788 std::fs::write(
2789 wrong_dir.join("fb.jsonl"),
2790 "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"fb\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/work-fallback\",\"sessionId\":\"s\"}\n",
2791 )
2792 .unwrap();
2793 std::thread::sleep(Duration::from_millis(300));
2794 stop.store(true, Ordering::Release);
2795 let events = drain(&handle.events);
2796 assert_eq!(
2797 events.len(),
2798 1,
2799 "fallback scan should locate the transcript by cwd"
2800 );
2801 assert_eq!(events[0].kind, EventKind::UserMessage);
2802 let outcome = handle.outcome.join().expect("tailer thread");
2803 assert_eq!(outcome.status, AdapterStatus::Active);
2804 }
2805
2806 #[test]
2807 fn tailer_degrades_when_projects_dir_missing() {
2808 let home = tempfile::TempDir::new().unwrap();
2810 let cwd = Path::new("/tmp/nowhere");
2811 let stop = std::sync::Arc::new(AtomicBool::new(false));
2812 let blobs = std::sync::Arc::new(BlobStore::new(home.path().join("blobs")));
2813 let ctx = AdapterContext {
2814 session_id: SID.to_string(),
2815 started_at_unix_ms: STARTED,
2816 cwd: cwd.to_path_buf(),
2817 command: vec!["claude".into()],
2818 blobs,
2819 stop: std::sync::Arc::clone(&stop),
2820 projects_dir: Some(home.path().join(".claude").join("projects")),
2821 };
2822 let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn");
2823 let events = drain(&handle.events);
2824 assert!(events.is_empty(), "no projects dir → no events");
2825 let outcome = handle.outcome.join().expect("tailer thread");
2826 assert_eq!(outcome.status, AdapterStatus::Degraded);
2827 assert!(
2830 outcome.degrade_reason.is_some(),
2831 "degraded outcome must carry a degrade_reason"
2832 );
2833 }
2834
2835 #[test]
2836 fn tailer_degrades_when_transcript_never_appears() {
2837 let home = tempfile::TempDir::new().unwrap();
2842 let cwd = Path::new("/tmp/no-transcript");
2843 let (handle, stop, _slug_dir) = spawn_with(home.path(), cwd);
2844 std::thread::sleep(Duration::from_millis(80));
2846 stop.store(true, Ordering::Release);
2847 let events = drain(&handle.events);
2848 assert!(events.is_empty(), "no transcript ever → no events");
2849 let outcome = handle.outcome.join().expect("tailer thread");
2850 assert_eq!(outcome.status, AdapterStatus::Degraded);
2851 assert!(
2852 outcome.degrade_reason.is_some(),
2853 "degraded outcome must carry a degrade_reason pointing at hh doctor"
2854 );
2855 assert!(
2856 outcome
2857 .degrade_reason
2858 .as_deref()
2859 .unwrap()
2860 .contains("hh doctor"),
2861 "degrade reason should suggest running `hh doctor`"
2862 );
2863 }
2864
2865 #[test]
2866 fn tailer_ignores_preexisting_transcript() {
2867 let home = tempfile::TempDir::new().unwrap();
2870 let cwd = Path::new("/tmp/concurrent");
2871 let slug_dir = home
2872 .path()
2873 .join(".claude")
2874 .join("projects")
2875 .join(slugify(cwd));
2876 std::fs::create_dir_all(&slug_dir).unwrap();
2877 std::fs::write(
2879 slug_dir.join("old.jsonl"),
2880 "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"not mine\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/concurrent\",\"sessionId\":\"other\"}\n",
2881 )
2882 .unwrap();
2883 let (handle, stop, _slug) = spawn_with(home.path(), cwd);
2884 std::thread::sleep(Duration::from_millis(80));
2886 stop.store(true, Ordering::Release);
2887 let events = drain(&handle.events);
2888 assert!(
2889 events.is_empty(),
2890 "a pre-existing transcript from another session must not be tailed"
2891 );
2892 let outcome = handle.outcome.join().expect("tailer thread");
2893 assert_eq!(outcome.status, AdapterStatus::Degraded);
2894 }
2895
2896 #[test]
2901 fn first_record_cwd_state_rejects_directory() {
2902 let tmp = tempfile::tempdir().unwrap();
2903 let dir = tmp.path().join("2457c4b0-is-a-dir");
2904 std::fs::create_dir_all(&dir).unwrap();
2905 assert_eq!(
2906 first_record_cwd_state(&dir, Path::new("/tmp/anything")),
2907 CwdState::Directory,
2908 "a directory candidate must be rejected, not re-polled"
2909 );
2910 }
2911
2912 #[test]
2917 fn tailer_ignores_same_stem_directory_beside_jsonl() {
2918 let home = tempfile::TempDir::new().unwrap();
2919 let cwd = Path::new("/tmp/same-stem");
2920 let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
2921 write_transcript(
2923 &slug_dir,
2924 "abc123.jsonl",
2925 "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"hi\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/same-stem\",\"sessionId\":\"s\"}\n",
2926 );
2927 std::fs::create_dir_all(slug_dir.join("abc123")).unwrap();
2930 std::thread::sleep(Duration::from_millis(300));
2931 stop.store(true, Ordering::Release);
2932 let events = drain(&handle.events);
2933 assert_eq!(
2934 events.len(),
2935 1,
2936 "the same-stem directory must not prevent discovering the .jsonl"
2937 );
2938 assert_eq!(events[0].kind, EventKind::UserMessage);
2939 let outcome = handle.outcome.join().expect("tailer thread");
2940 assert_eq!(outcome.status, AdapterStatus::Active);
2941 }
2942
2943 #[test]
2949 fn tailer_degrades_when_zero_records_parsed() {
2950 let home = tempfile::TempDir::new().unwrap();
2951 let cwd = Path::new("/tmp/zero-records");
2952 let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
2953 write_transcript(
2956 &slug_dir,
2957 "empty.jsonl",
2958 "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":true,\"message\":{\"role\":\"user\",\"content\":\"caveat\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/zero-records\",\"sessionId\":\"s\"}\n",
2959 );
2960 std::thread::sleep(Duration::from_millis(300));
2961 stop.store(true, Ordering::Release);
2962 let events = drain(&handle.events);
2963 assert!(events.is_empty(), "isMeta record yields no events");
2964 let outcome = handle.outcome.join().expect("tailer thread");
2965 assert_eq!(
2966 outcome.status,
2967 AdapterStatus::Degraded,
2968 "0 records parsed must degrade, not report active with 0 steps"
2969 );
2970 let reason = outcome.degrade_reason.as_deref().unwrap();
2971 assert!(
2972 reason.contains("0 records parsed"),
2973 "reason must be the specific '0 records parsed' string: {reason}"
2974 );
2975 assert!(
2976 reason.contains("read 1 line"),
2977 "reason must carry the line count: {reason}"
2978 );
2979 }
2980
2981 #[test]
2985 fn tailer_degrade_reason_records_first_parse_error() {
2986 let home = tempfile::TempDir::new().unwrap();
2987 let cwd = Path::new("/tmp/parse-err");
2988 let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
2989 write_transcript(
2999 &slug_dir,
3000 "broken.jsonl",
3001 "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"ok\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/parse-err\",\"sessionId\":\"s\"}\n{not valid json\n",
3002 );
3003 std::thread::sleep(Duration::from_millis(300));
3004 stop.store(true, Ordering::Release);
3005 let events = drain(&handle.events);
3006 assert_eq!(
3007 events.len(),
3008 1,
3009 "the valid record parses; the broken line is skipped"
3010 );
3011 let outcome = handle.outcome.join().expect("tailer thread");
3012 assert_eq!(outcome.status, AdapterStatus::Active);
3013 }
3014
3015 #[test]
3021 fn locate_failure_reason_is_specific() {
3022 let empty = DiscoveryDiag {
3024 projects: Some(PathBuf::from("/home/me/.claude/projects")),
3025 slug: Some("-home-me-work".into()),
3026 slug_dir: Some(PathBuf::from("/home/me/.claude/projects/-home-me-work")),
3027 cwd: Some(PathBuf::from("/home/me/work")),
3028 ..Default::default()
3029 };
3030 let r = locate_failure_reason(&empty);
3031 assert!(r.contains("no jsonl matched cwd slug -home-me-work"), "{r}");
3032 assert!(r.contains("no new transcript appeared"), "{r}");
3033 assert!(
3034 r.contains("looked in /home/me/.claude/projects/-home-me-work"),
3035 "{r}"
3036 );
3037
3038 let mut seen = std::collections::HashSet::new();
3040 seen.insert(PathBuf::from(
3041 "/home/me/.claude/projects/-home-me-work/a.jsonl",
3042 ));
3043 let with_cands = DiscoveryDiag {
3044 projects: Some(PathBuf::from("/home/me/.claude/projects")),
3045 slug: Some("-home-me-work".into()),
3046 slug_dir: Some(PathBuf::from("/home/me/.claude/projects/-home-me-work")),
3047 cwd: Some(PathBuf::from("/home/me/work")),
3048 new_candidates: seen,
3049 cwd_mismatches: vec![PathBuf::from(
3050 "/home/me/.claude/projects/-home-me-work/a.jsonl",
3051 )],
3052 directories: vec![PathBuf::from(
3053 "/home/me/.claude/projects/-home-me-work/b.jsonl",
3054 )],
3055 };
3056 let r = locate_failure_reason(&with_cands);
3057 assert!(r.contains("1 new candidate(s)"), "{r}");
3058 assert!(r.contains("1 rejected for a different cwd"), "{r}");
3059 assert!(r.contains("1 candidate(s) were directories"), "{r}");
3060 }
3061
3062 fn parse_codex(value: &Value, ts_ms: i64) -> ParsedRecord {
3067 let tmp = tempfile::TempDir::new().unwrap();
3068 let blobs = BlobStore::new(tmp.path().join("blobs"));
3069 parse_codex_record(value, SID, ts_ms, &blobs)
3070 }
3071
3072 fn snap_codex(pr: &ParsedRecord) -> String {
3073 serde_json::to_string_pretty(&serde_json::json!({
3074 "events": pr.events,
3075 "is_assistant_message": pr.is_assistant_message,
3076 }))
3077 .unwrap()
3078 }
3079
3080 fn codex_rec(json: &str) -> Value {
3081 serde_json::from_str(json).unwrap()
3082 }
3083
3084 #[test]
3085 fn codex_parse_user_message() {
3086 let v = codex_rec(
3087 r#"{"type":"event_msg","timestamp":"2026-07-02T06:14:40.699Z","payload":{"type":"user_message","content":[{"text":"hello"}]}}"#,
3088 );
3089 let pr = parse_codex(&v, iso("2026-07-02T06:14:40.699Z"));
3090 insta::assert_snapshot!(snap_codex(&pr));
3091 assert_eq!(pr.events.len(), 1);
3092 assert_eq!(pr.events[0].kind, EventKind::UserMessage);
3093 }
3094
3095 #[test]
3096 fn codex_parse_agent_message() {
3097 let v = codex_rec(
3098 r#"{"type":"event_msg","timestamp":"2026-07-02T06:14:41.500Z","payload":{"type":"agent_message","content":[{"text":"I can help with that"}]}}"#,
3099 );
3100 let pr = parse_codex(&v, iso("2026-07-02T06:14:41.500Z"));
3101 insta::assert_snapshot!(snap_codex(&pr));
3102 assert_eq!(pr.events.len(), 1);
3103 assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
3104 assert!(pr.is_assistant_message);
3105 }
3106
3107 #[test]
3108 fn codex_parse_response_item_message() {
3109 let v = codex_rec(
3110 r#"{"type":"response_item","timestamp":"2026-07-02T06:14:41.500Z","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Here is the solution"}]}}"#,
3111 );
3112 let pr = parse_codex(&v, iso("2026-07-02T06:14:41.500Z"));
3113 insta::assert_snapshot!(snap_codex(&pr));
3114 assert_eq!(pr.events.len(), 1);
3115 assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
3116 }
3117
3118 #[test]
3119 fn codex_parse_function_call_and_output() {
3120 let v = codex_rec(
3121 r#"{"type":"response_item","timestamp":"2026-07-02T06:14:42.000Z","payload":{"type":"function_call","call_id":"call_1","name":"Bash","arguments":"{\"command\":\"ls\"}"}}"#,
3122 );
3123 let pr = parse_codex(&v, iso("2026-07-02T06:14:42.000Z"));
3124 insta::assert_snapshot!(snap_codex(&pr));
3125 assert_eq!(pr.events.len(), 1);
3126 assert_eq!(pr.events[0].kind, EventKind::ToolCall);
3127 assert_eq!(
3128 pr.events[0].body_json.as_ref().unwrap()["correlate_key"],
3129 "call_1"
3130 );
3131
3132 let v2 = codex_rec(
3133 r#"{"type":"response_item","timestamp":"2026-07-02T06:14:43.000Z","payload":{"type":"function_call_output","call_id":"call_1","output":"total 42"}}"#,
3134 );
3135 let pr2 = parse_codex(&v2, iso("2026-07-02T06:14:43.000Z"));
3136 assert_eq!(pr2.events.len(), 1);
3137 assert_eq!(pr2.events[0].kind, EventKind::ToolResult);
3138 assert_eq!(
3139 pr2.events[0].body_json.as_ref().unwrap()["correlate_key"],
3140 "call_1"
3141 );
3142 }
3143
3144 #[test]
3145 fn codex_parse_reasoning() {
3146 let v = codex_rec(
3147 r#"{"type":"response_item","timestamp":"2026-07-02T06:14:41.000Z","payload":{"type":"reasoning","summary":[],"content":null,"encrypted_content":"gAAAAABp5lf5..."}}"#,
3148 );
3149 let pr = parse_codex(&v, iso("2026-07-02T06:14:41.000Z"));
3150 insta::assert_snapshot!(snap_codex(&pr));
3151 assert_eq!(pr.events.len(), 1);
3152 assert_eq!(pr.events[0].kind, EventKind::Thinking);
3153 }
3154
3155 #[test]
3156 fn codex_parse_skips_session_meta() {
3157 let v = codex_rec(
3158 r#"{"type":"session_meta","timestamp":"2026-07-02T06:14:40.000Z","payload":{"id":"abc","cwd":"/tmp","originator":"codex-tui"}}"#,
3159 );
3160 let pr = parse_codex(&v, 0);
3161 assert!(pr.events.is_empty());
3162 }
3163
3164 #[test]
3165 fn codex_parse_skips_unknown_type() {
3166 let v = codex_rec(
3167 r#"{"type":"unknown_type","timestamp":"2026-07-02T06:14:40.000Z","payload":{}}"#,
3168 );
3169 let pr = parse_codex(&v, 0);
3170 assert!(pr.events.is_empty());
3171 }
3172
3173 #[test]
3174 fn codex_parse_non_object_value() {
3175 for v in [codex_rec("[]"), codex_rec("null"), codex_rec("\"oops\"")] {
3176 assert!(parse_codex(&v, 0).events.is_empty());
3177 }
3178 }
3179
3180 fn parse_gemini(value: &Value, ts_ms: i64) -> ParsedRecord {
3185 let tmp = tempfile::TempDir::new().unwrap();
3186 let blobs = BlobStore::new(tmp.path().join("blobs"));
3187 parse_gemini_record(value, SID, ts_ms, &blobs)
3188 }
3189
3190 fn snap_gemini(pr: &ParsedRecord) -> String {
3191 serde_json::to_string_pretty(&serde_json::json!({
3192 "events": pr.events,
3193 "is_assistant_message": pr.is_assistant_message,
3194 }))
3195 .unwrap()
3196 }
3197
3198 fn gemini_rec(json: &str) -> Value {
3199 serde_json::from_str(json).unwrap()
3200 }
3201
3202 #[test]
3203 fn gemini_parse_user_message() {
3204 let v = gemini_rec(
3205 r#"{"type":"user","id":"msg1","timestamp":"2026-07-02T06:14:40.699Z","content":[{"text":"hello"}]}"#,
3206 );
3207 let pr = parse_gemini(&v, iso("2026-07-02T06:14:40.699Z"));
3208 insta::assert_snapshot!(snap_gemini(&pr));
3209 assert_eq!(pr.events.len(), 1);
3210 assert_eq!(pr.events[0].kind, EventKind::UserMessage);
3211 }
3212
3213 #[test]
3214 fn gemini_parse_agent_message() {
3215 let v = gemini_rec(
3216 r#"{"type":"gemini","id":"msg2","timestamp":"2026-07-02T06:14:41.500Z","content":[{"text":"Hi! How can I help?"}],"model":"gemini-2.5-pro"}"#,
3217 );
3218 let pr = parse_gemini(&v, iso("2026-07-02T06:14:41.500Z"));
3219 insta::assert_snapshot!(snap_gemini(&pr));
3220 assert_eq!(pr.events.len(), 1);
3221 assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
3222 }
3223
3224 #[test]
3225 fn gemini_parse_with_tool_calls() {
3226 let v = gemini_rec(
3227 r#"{"type":"gemini","id":"msg3","timestamp":"2026-07-02T06:14:42.000Z","content":[{"text":"Let me check"}],"toolCalls":[{"id":"tc_1","name":"Bash","input":{"command":"ls"}}]}"#,
3228 );
3229 let pr = parse_gemini(&v, iso("2026-07-02T06:14:42.000Z"));
3230 insta::assert_snapshot!(snap_gemini(&pr));
3231 assert_eq!(pr.events.len(), 2); assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
3233 assert_eq!(pr.events[1].kind, EventKind::ToolCall);
3234 assert_eq!(
3235 pr.events[1].body_json.as_ref().unwrap()["correlate_key"],
3236 "tc_1"
3237 );
3238 }
3239
3240 #[test]
3241 fn gemini_parse_with_thoughts() {
3242 let v = gemini_rec(
3243 r#"{"type":"gemini","id":"msg4","timestamp":"2026-07-02T06:14:41.000Z","content":[{"text":"answer"}],"thoughts":[{"text":"I need to think about this"}]}"#,
3244 );
3245 let pr = parse_gemini(&v, iso("2026-07-02T06:14:41.000Z"));
3246 insta::assert_snapshot!(snap_gemini(&pr));
3247 assert_eq!(pr.events.len(), 2); assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
3249 assert_eq!(pr.events[1].kind, EventKind::Thinking);
3250 }
3251
3252 #[test]
3253 fn gemini_parse_skips_session_metadata() {
3254 let v = gemini_rec(
3255 r#"{"type":"session_metadata","sessionId":"abc","projectHash":"xyz","startTime":"2026-07-02T06:14:40.000Z"}"#,
3256 );
3257 let pr = parse_gemini(&v, 0);
3258 assert!(pr.events.is_empty());
3259 }
3260
3261 #[test]
3262 fn gemini_parse_skips_message_update() {
3263 let v = gemini_rec(
3264 r#"{"type":"message_update","id":"msg2","timestamp":"2026-07-02T06:14:41.000Z","tokens":{"input":5,"output":4}}"#,
3265 );
3266 let pr = parse_gemini(&v, 0);
3267 assert!(pr.events.is_empty());
3268 }
3269
3270 #[test]
3271 fn gemini_parse_skips_unknown_type() {
3272 let v = gemini_rec(r#"{"type":"unknown","id":"x","timestamp":"2026-07-02T06:14:40.000Z"}"#);
3273 let pr = parse_gemini(&v, 0);
3274 assert!(pr.events.is_empty());
3275 }
3276
3277 #[test]
3278 fn gemini_parse_non_object_value() {
3279 for v in [gemini_rec("[]"), gemini_rec("null")] {
3280 assert!(parse_gemini(&v, 0).events.is_empty());
3281 }
3282 }
3283
3284 #[test]
3289 fn select_detects_all_adapters() {
3290 assert!(select(&["claude".into()], Path::new("/tmp")).is_some());
3291 assert!(select(&["claude-desktop".into()], Path::new("/tmp")).is_some());
3292 assert!(select(&["codex".into()], Path::new("/tmp")).is_some());
3293 assert!(select(&["gemini".into()], Path::new("/tmp")).is_some());
3294 assert!(select(&["python3".into()], Path::new("/tmp")).is_none());
3295 }
3296
3297 #[test]
3298 fn is_claude_desktop_detects_basename() {
3299 assert!(is_claude_desktop(&["claude-desktop".into()]));
3300 assert!(is_claude_desktop(&["/usr/local/bin/claude-desktop".into()]));
3301 assert!(is_claude_desktop(&["claude-desktop.exe".into()]));
3302 assert!(!is_claude_desktop(&["claude".into()]));
3303 assert!(!is_claude_desktop(&["codex".into()]));
3304 assert!(!is_claude_desktop(&[]));
3305 }
3306
3307 #[test]
3308 fn is_codex_cli_detects_basename() {
3309 assert!(is_codex_cli(&["codex".into()]));
3310 assert!(is_codex_cli(&["/usr/local/bin/codex".into()]));
3311 assert!(is_codex_cli(&["codex.exe".into()]));
3312 assert!(!is_codex_cli(&["claude".into()]));
3313 assert!(!is_codex_cli(&[]));
3314 }
3315
3316 #[test]
3317 fn is_gemini_cli_detects_basename() {
3318 assert!(is_gemini_cli(&["gemini".into()]));
3319 assert!(is_gemini_cli(&["/usr/local/bin/gemini".into()]));
3320 assert!(is_gemini_cli(&["gemini.exe".into()]));
3321 assert!(!is_gemini_cli(&["claude".into()]));
3322 assert!(!is_gemini_cli(&[]));
3323 }
3324
3325 #[test]
3326 fn resolve_override_accepts_all_adapters() {
3327 assert!(resolve_override("claude-code").is_some());
3328 assert!(resolve_override("claude-desktop").is_some());
3329 assert!(resolve_override("codex-cli").is_some());
3330 assert!(resolve_override("gemini-cli").is_some());
3331 assert!(resolve_override("unknown").is_none());
3332 }
3333}