use crate::blob::BlobStore;
use crate::event::{truncate_summary, AdapterStatus, AgentKind, Event, EventKind};
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
const SPILLOVER_BYTES: usize = 256 * 1024;
const FILE_APPEAR_TIMEOUT: Duration = Duration::from_secs(3);
const TAIL_POLL: Duration = Duration::from_millis(50);
const APPEAR_POLL: Duration = Duration::from_millis(200);
#[derive(Clone)]
pub struct AdapterContext {
pub session_id: String,
pub started_at_unix_ms: i64,
pub cwd: PathBuf,
pub command: Vec<String>,
pub blobs: std::sync::Arc<BlobStore>,
pub stop: std::sync::Arc<AtomicBool>,
pub projects_dir: Option<PathBuf>,
}
pub struct AdapterHandle {
pub events: mpsc::Receiver<Event>,
pub outcome: JoinHandle<AdapterOutcome>,
}
impl AdapterHandle {
pub fn join(self) -> crate::Result<AdapterOutcome> {
self.outcome.join().map_err(|_| {
crate::Error::Storage(crate::error::StorageError::Open {
path: PathBuf::new(),
source: std::io::Error::other("adapter thread panicked"),
})
})
}
}
#[derive(Debug, Default, Clone)]
pub struct AdapterOutcome {
pub model: Option<String>,
pub usage_json: Option<serde_json::Value>,
pub status: AdapterStatus,
}
pub trait Adapter: Send + 'static {
fn agent_kind(&self) -> AgentKind;
fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle>;
}
#[must_use]
pub fn select(command: &[String], _cwd: &Path) -> Option<Box<dyn Adapter>> {
if is_claude_code(command) {
Some(Box::new(ClaudeAdapter))
} else {
None
}
}
#[must_use]
pub fn resolve_override(name: &str) -> Option<Box<dyn Adapter>> {
match name {
"claude-code" => Some(Box::new(ClaudeAdapter)),
_ => None,
}
}
#[must_use]
pub fn is_claude_code(command: &[String]) -> bool {
let prog = command.first().map_or("", String::as_str);
let base = prog.rsplit(['/', '\\']).next().unwrap_or(prog);
let base = base.strip_suffix(".exe").unwrap_or(base);
base.eq_ignore_ascii_case("claude")
}
#[derive(Debug, Clone)]
pub struct ClaudeAdapter;
impl Adapter for ClaudeAdapter {
fn agent_kind(&self) -> AgentKind {
AgentKind::ClaudeCode
}
#[allow(clippy::unused_self)] fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle> {
let (tx, rx) = mpsc::channel::<Event>();
let outcome = std::thread::Builder::new()
.name("hh-claude-adapter".into())
.spawn(move || run_claude_tailer(ctx, tx))?;
Ok(AdapterHandle {
events: rx,
outcome,
})
}
}
#[allow(clippy::needless_pass_by_value)] fn run_claude_tailer(ctx: AdapterContext, tx: mpsc::Sender<Event>) -> AdapterOutcome {
let Some(projects) = ctx.projects_dir.clone().or_else(claude_projects_dir) else {
warn("claude adapter: no ~/.claude/projects directory found (HOME unset?)");
return AdapterOutcome {
status: AdapterStatus::Degraded,
..Default::default()
};
};
if !projects.is_dir() {
warn("claude adapter: projects directory does not exist; tailing skipped");
return AdapterOutcome {
status: AdapterStatus::Degraded,
..Default::default()
};
}
let start = Instant::now();
let Some(file) = locate_transcript(
&projects,
&ctx.cwd,
ctx.started_at_unix_ms,
&start,
&ctx.stop,
) else {
warn("claude adapter: could not locate a transcript for this session within deadline");
return AdapterOutcome {
status: AdapterStatus::Degraded,
..Default::default()
};
};
let mut acc = OutcomeAcc::default();
tail_file(&file, &projects, &ctx, &tx, &start, &mut acc);
acc.finish(AdapterStatus::Active)
}
#[derive(Default)]
struct OutcomeAcc {
model: Option<String>,
usage_json: Option<serde_json::Value>,
}
impl OutcomeAcc {
fn note_assistant(&mut self, message: &serde_json::Value) {
if let Some(m) = message.get("model").and_then(|v| v.as_str()) {
self.model = Some(m.to_string());
}
if let Some(u) = message.get("usage") {
self.usage_json = Some(u.clone());
}
}
fn finish(self, status: AdapterStatus) -> AdapterOutcome {
AdapterOutcome {
model: self.model,
usage_json: self.usage_json,
status,
}
}
}
fn locate_transcript(
projects: &Path,
cwd: &Path,
started_at_unix_ms: i64,
start: &Instant,
stop: &std::sync::Arc<AtomicBool>,
) -> Option<PathBuf> {
let slug_dir = projects.join(slugify(cwd));
if slug_dir.is_dir() {
if let Some(f) = newest_in_dir(&slug_dir, started_at_unix_ms) {
return Some(f);
}
let deadline = *start + FILE_APPEAR_TIMEOUT;
while Instant::now() < deadline {
if stop.load(Ordering::Acquire) {
return None;
}
if let Some(f) = newest_in_dir(&slug_dir, started_at_unix_ms) {
return Some(f);
}
std::thread::sleep(APPEAR_POLL);
}
}
fallback_scan_by_cwd(projects, cwd, started_at_unix_ms)
}
fn newest_in_dir(dir: &Path, since_unix_ms: i64) -> Option<PathBuf> {
let entries = std::fs::read_dir(dir).ok()?;
let mut best: Option<(PathBuf, i64)> = None;
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
continue;
}
let Ok(m) = e.metadata() else { continue };
let Ok(modified) = m.modified() else { continue };
let mtime_ms = system_time_to_unix_ms(modified);
if mtime_ms < since_unix_ms - 5_000 {
continue; }
match &best {
Some((_, best_ms)) if mtime_ms <= *best_ms => {}
_ => best = Some((p, mtime_ms)),
}
}
best.map(|(p, _)| p)
}
fn fallback_scan_by_cwd(projects: &Path, cwd: &Path, started_at_unix_ms: i64) -> Option<PathBuf> {
let mut candidates: Vec<(PathBuf, i64)> = Vec::new();
let slug_dirs = std::fs::read_dir(projects).ok()?;
for sd in slug_dirs.flatten() {
let sp = sd.path();
if !sp.is_dir() {
continue;
}
if let Some(f) = newest_in_dir(&sp, started_at_unix_ms) {
let mtime_ms = std::fs::metadata(&f)
.ok()
.and_then(|m| m.modified().ok())
.map_or(0, system_time_to_unix_ms);
candidates.push((f, mtime_ms));
}
}
candidates.sort_by_key(|(_, m)| std::cmp::Reverse(*m));
candidates
.into_iter()
.map(|(f, _)| f)
.find(|f| first_record_cwd_matches(f, cwd))
}
fn first_record_cwd_matches(file: &Path, session_cwd: &Path) -> bool {
let Ok(content) = std::fs::read_to_string(file) else {
return false;
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if let Some(rc) = v.get("cwd").and_then(|c| c.as_str()) {
let rp = Path::new(rc);
return rp == session_cwd || rp.starts_with(session_cwd);
}
}
false
}
fn tail_file(
file: &Path,
projects: &Path,
ctx: &AdapterContext,
tx: &mpsc::Sender<Event>,
start: &Instant,
acc: &mut OutcomeAcc,
) {
let slug_dir = projects.join(slugify(&ctx.cwd));
let mut current = file.to_path_buf();
let mut reader = match std::fs::File::open(¤t) {
Ok(f) => std::io::BufReader::new(f),
Err(e) => {
warn(&format!("claude adapter: could not open transcript: {e}"));
return;
}
};
let mut buf: Vec<u8> = Vec::new();
let mut line = Vec::new();
loop {
if ctx.stop.load(Ordering::Acquire) {
break;
}
match reader.read_until(b'\n', &mut line) {
Ok(0) => {
drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc);
if let Some(newer) = rotate_to_newer(&slug_dir, ¤t, ctx.started_at_unix_ms) {
current = newer;
reader = match std::fs::File::open(¤t) {
Ok(f) => std::io::BufReader::new(f),
Err(_) => break,
};
line.clear();
continue;
}
if ctx.stop.load(Ordering::Acquire) {
break;
}
std::thread::sleep(TAIL_POLL);
}
Ok(_n) => {
drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc);
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc);
}
fn drain_complete_lines(
buf: &mut Vec<u8>,
line: &mut Vec<u8>,
ctx: &AdapterContext,
tx: &mpsc::Sender<Event>,
start: &Instant,
acc: &mut OutcomeAcc,
) {
if line.is_empty() {
return;
}
buf.append(line);
line.clear();
while let Some(nl) = buf.iter().rposition(|&b| b == b'\n') {
let complete: Vec<u8> = buf.drain(..=nl).collect();
parse_and_send(&complete, ctx, tx, start, acc);
}
}
fn parse_and_send(
bytes: &[u8],
ctx: &AdapterContext,
tx: &mpsc::Sender<Event>,
start: &Instant,
acc: &mut OutcomeAcc,
) {
let s = match std::str::from_utf8(bytes) {
Ok(s) => s.trim(),
Err(_) => return,
};
if s.is_empty() {
return;
}
let value: serde_json::Value = match serde_json::from_str(s) {
Ok(v) => v,
Err(e) => {
debug(&format!("claude adapter: skipping unparseable line: {e}"));
return;
}
};
let ts_ms = value
.get("timestamp")
.and_then(|v| v.as_str())
.and_then(|s| ts_ms_from_iso(s, ctx.started_at_unix_ms))
.unwrap_or_else(|| elapsed_ms(start));
let parsed = parse_record(&value, &ctx.session_id, ts_ms, &ctx.blobs);
if parsed.is_assistant_message {
if let Some(msg) = value.get("message") {
acc.note_assistant(msg);
}
}
for ev in parsed.events {
if tx.send(ev).is_err() {
break; }
}
}
fn rotate_to_newer(slug_dir: &Path, current: &Path, started_at_unix_ms: i64) -> Option<PathBuf> {
let newest = newest_in_dir(slug_dir, started_at_unix_ms)?;
if newest == current {
return None;
}
let newer_mtime = std::fs::metadata(&newest)
.ok()
.and_then(|m| m.modified().ok())
.map(system_time_to_unix_ms);
let cur_mtime = std::fs::metadata(current)
.ok()
.and_then(|m| m.modified().ok())
.map(system_time_to_unix_ms);
match (newer_mtime, cur_mtime) {
(Some(n), Some(c)) if n > c => Some(newest),
_ => None,
}
}
#[derive(Debug, Default, Clone)]
pub(crate) struct ParsedRecord {
pub events: Vec<Event>,
pub is_assistant_message: bool,
}
#[must_use]
pub(crate) fn parse_record(
value: &serde_json::Value,
session_id: &str,
ts_ms: i64,
blobs: &BlobStore,
) -> ParsedRecord {
let mut out = ParsedRecord::default();
let Some(obj) = value.as_object() else {
return out; };
let ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
if ty != "user" && ty != "assistant" {
return out; }
if obj
.get("isSidechain")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
return out; }
if obj
.get("isMeta")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
return out; }
let Some(message) = obj.get("message") else {
return out;
};
let is_assistant = ty == "assistant";
out.is_assistant_message = is_assistant;
let Some(content) = message.get("content") else {
return out;
};
match content {
serde_json::Value::String(s) => {
let kind = if is_assistant {
EventKind::AgentMessage
} else {
EventKind::UserMessage
};
out.events
.push(text_event(session_id, ts_ms, kind, s, blobs));
}
serde_json::Value::Array(blocks) => {
for block in blocks {
if let Some(b) = block.as_object() {
parse_block(b, is_assistant, session_id, ts_ms, blobs, &mut out.events);
}
}
}
_ => {} }
out
}
fn parse_block(
b: &serde_json::Map<String, serde_json::Value>,
is_assistant: bool,
session_id: &str,
ts_ms: i64,
blobs: &BlobStore,
events: &mut Vec<Event>,
) {
let bty = b.get("type").and_then(|v| v.as_str()).unwrap_or("");
match bty {
"text" => {
if let Some(t) = b.get("text").and_then(|v| v.as_str()) {
let kind = if is_assistant {
EventKind::AgentMessage
} else {
EventKind::UserMessage
};
events.push(text_event(session_id, ts_ms, kind, t, blobs));
}
}
"thinking" => {
if let Some(t) = b.get("thinking").and_then(|v| v.as_str()) {
events.push(text_event(session_id, ts_ms, EventKind::Thinking, t, blobs));
}
}
"tool_use" => {
let id = b.get("id").and_then(|v| v.as_str()).unwrap_or("");
let name = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
let input = b.get("input").cloned().unwrap_or(serde_json::Value::Null);
events.push(tool_call_event(session_id, ts_ms, id, name, &input, blobs));
}
"tool_result" => {
let tool_use_id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
let is_error = b
.get("is_error")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let content_text = extract_result_content(b.get("content"));
events.push(tool_result_event(
session_id,
ts_ms,
tool_use_id,
is_error,
&content_text,
blobs,
));
}
_ => {} }
}
fn text_event(
session_id: &str,
ts_ms: i64,
kind: EventKind,
text: &str,
blobs: &BlobStore,
) -> Event {
let body = serde_json::json!({ "text": text });
let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
Event {
session_id: session_id.to_string(),
ts_ms,
kind,
step: None, summary: truncate_summary(text),
body_json,
blob_hash,
blob_size,
correlates: None,
}
}
fn tool_call_event(
session_id: &str,
ts_ms: i64,
id: &str,
name: &str,
input: &serde_json::Value,
blobs: &BlobStore,
) -> Event {
let body = serde_json::json!({
"name": name,
"input": input,
"correlate_key": id,
});
let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
Event {
session_id: session_id.to_string(),
ts_ms,
kind: EventKind::ToolCall,
step: None,
summary: truncate_summary(&format!("tool_call: {name}")),
body_json,
blob_hash,
blob_size,
correlates: None,
}
}
fn tool_result_event(
session_id: &str,
ts_ms: i64,
tool_use_id: &str,
is_error: bool,
content: &str,
blobs: &BlobStore,
) -> Event {
let body = serde_json::json!({
"tool_use_id": tool_use_id,
"is_error": is_error,
"content": content,
"correlate_key": tool_use_id,
});
let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
let summary = if is_error {
format!("tool_result (error): {}", one_line(content))
} else {
format!("tool_result: {}", one_line(content))
};
Event {
session_id: session_id.to_string(),
ts_ms,
kind: EventKind::ToolResult,
step: None,
summary: truncate_summary(&summary),
body_json,
blob_hash,
blob_size,
correlates: None,
}
}
fn maybe_spill(
body: serde_json::Value,
blobs: &BlobStore,
) -> (Option<serde_json::Value>, Option<String>, Option<u64>) {
let serialized = serde_json::to_vec(&body).unwrap_or_default();
if serialized.len() >= SPILLOVER_BYTES {
if let Ok(outcome) = blobs.put(&serialized) {
let envelope = serde_json::json!({
"overflow": true,
"size": outcome.size,
"blob_hash": outcome.hash,
"encoding": "blob",
});
return (Some(envelope), Some(outcome.hash), Some(outcome.size));
}
}
(Some(body), None, None)
}
fn extract_result_content(content: Option<&serde_json::Value>) -> String {
match content {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(arr)) => {
let mut parts = Vec::with_capacity(arr.len());
for b in arr {
if let Some(t) = b.get("text").and_then(|v| v.as_str()) {
parts.push(t.to_string());
}
}
parts.join("\n")
}
_ => String::new(),
}
}
fn one_line(s: &str) -> String {
s.chars()
.map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
.collect()
}
pub(crate) fn ts_ms_from_iso(iso: &str, started_at_unix_ms: i64) -> Option<i64> {
let dt =
time::OffsetDateTime::parse(iso, &time::format_description::well_known::Rfc3339).ok()?;
let unix_ms = dt.unix_timestamp_nanos() / 1_000_000;
let unix_ms = i64::try_from(unix_ms).unwrap_or(i64::MAX);
Some((unix_ms - started_at_unix_ms).max(0))
}
#[must_use]
fn claude_projects_dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".claude").join("projects"))
}
#[must_use]
pub(crate) fn slugify(cwd: &Path) -> String {
cwd.to_string_lossy()
.chars()
.map(|c| match c {
'/' | '\\' | '.' => '-',
other => other,
})
.collect()
}
fn system_time_to_unix_ms(t: std::time::SystemTime) -> i64 {
t.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
}
fn elapsed_ms(start: &Instant) -> i64 {
i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX)
}
fn warn(msg: &str) {
eprintln!("hh: warning: {msg}");
}
fn debug(msg: &str) {
if std::env::var_os("HH_DEBUG").is_some() {
eprintln!("hh: debug: {msg}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
use std::io::Write;
const STARTED: i64 = 1_782_972_880_000;
const SID: &str = "test-session-id";
fn iso(s: &str) -> i64 {
ts_ms_from_iso(s, STARTED).expect("fixture timestamps must parse")
}
fn parse(value: &Value, ts_ms: i64) -> ParsedRecord {
let tmp = tempfile::TempDir::new().unwrap();
let blobs = BlobStore::new(tmp.path().join("blobs"));
parse_record(value, SID, ts_ms, &blobs)
}
fn snap(pr: &ParsedRecord) -> String {
serde_json::to_string_pretty(&serde_json::json!({
"events": pr.events,
"is_assistant_message": pr.is_assistant_message,
}))
.unwrap()
}
fn rec(json: &str) -> Value {
serde_json::from_str(json).unwrap()
}
#[test]
fn parse_user_text_prompt() {
let v = rec(r#"{"type":"user","isSidechain":false,"isMeta":false,
"message":{"role":"user","content":"Please list the files in the repo and show Cargo.toml."},
"timestamp":"2026-07-02T06:14:40.699Z","cwd":"/tmp/work","sessionId":"s"}"#);
let pr = parse(&v, iso("2026-07-02T06:14:40.699Z"));
insta::assert_snapshot!(snap(&pr));
}
#[test]
fn parse_assistant_with_two_tool_uses() {
let v = rec(r#"{"type":"assistant","isSidechain":false,"isMeta":false,
"message":{"role":"assistant","model":"glm-5.2","content":[
{"type":"thinking","thinking":"I'll list files then read Cargo.toml.","signature":""},
{"type":"tool_use","id":"call_abc123","name":"Bash","input":{"command":"ls -la"}},
{"type":"tool_use","id":"call_def456","name":"Read","input":{"file_path":"Cargo.toml"}}
],"usage":{"input_tokens":1200,"output_tokens":80}},
"timestamp":"2026-07-02T06:14:41.500Z","cwd":"/tmp/work","sessionId":"s"}"#);
let pr = parse(&v, iso("2026-07-02T06:14:41.500Z"));
insta::assert_snapshot!(snap(&pr));
assert_eq!(pr.events.len(), 3);
assert!(pr.is_assistant_message);
assert_eq!(pr.events[0].kind, EventKind::Thinking);
assert_eq!(pr.events[1].kind, EventKind::ToolCall);
assert_eq!(pr.events[2].kind, EventKind::ToolCall);
}
#[test]
fn parse_user_with_two_tool_results_one_error() {
let v = rec(r#"{"type":"user","isSidechain":false,"isMeta":false,
"message":{"role":"user","content":[
{"type":"tool_result","tool_use_id":"call_abc123","content":"total 0","is_error":false},
{"type":"tool_result","tool_use_id":"call_def456","content":"Error: file not found","is_error":true}
]},
"timestamp":"2026-07-02T06:14:42.800Z","cwd":"/tmp/work","sessionId":"s"}"#);
let pr = parse(&v, iso("2026-07-02T06:14:42.800Z"));
insta::assert_snapshot!(snap(&pr));
assert_eq!(pr.events.len(), 2);
assert_eq!(pr.events[0].kind, EventKind::ToolResult);
assert_eq!(pr.events[1].kind, EventKind::ToolResult);
assert_eq!(
pr.events[0].body_json.as_ref().unwrap()["correlate_key"],
"call_abc123"
);
assert!(pr.events[1].body_json.as_ref().unwrap()["is_error"]
.as_bool()
.unwrap());
}
#[test]
fn parse_assistant_followup_with_model_usage() {
let v = rec(r#"{"type":"assistant","isSidechain":false,"isMeta":false,
"message":{"role":"assistant","model":"glm-5.2","content":[
{"type":"text","text":"The repo is empty and Cargo.toml is missing."}
],"usage":{"input_tokens":1500,"output_tokens":40,"cache_read_input_tokens":300}},
"timestamp":"2026-07-02T06:14:43.100Z","cwd":"/tmp/work","sessionId":"s"}"#);
let pr = parse(&v, iso("2026-07-02T06:14:43.100Z"));
insta::assert_snapshot!(snap(&pr));
assert_eq!(pr.events.len(), 1);
assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
let mut acc = OutcomeAcc::default();
acc.note_assistant(v.get("message").unwrap());
assert_eq!(acc.model.as_deref(), Some("glm-5.2"));
assert_eq!(acc.usage_json.as_ref().unwrap()["output_tokens"], 40);
}
#[test]
fn parse_skips_unknown_type() {
let v = rec(r#"{"type":"system","subtype":"init","cwd":"/tmp/work","sessionId":"s"}"#);
let pr = parse(&v, 0);
assert!(pr.events.is_empty());
assert!(!pr.is_assistant_message);
}
#[test]
fn parse_skips_non_object_value() {
for v in [rec("[]"), rec("null"), rec("\"oops\"")] {
assert!(
parse(&v, 0).events.is_empty(),
"non-object must yield no events"
);
}
}
#[test]
fn parse_skips_is_sidechain() {
let v = rec(r#"{"type":"assistant","isSidechain":true,"isMeta":false,
"message":{"role":"assistant","content":[{"type":"text","text":"subagent"}]},
"timestamp":"2026-07-02T06:14:41.000Z","cwd":"/tmp/work","sessionId":"s"}"#);
assert!(parse(&v, 0).events.is_empty());
}
#[test]
fn parse_skips_is_meta() {
let v = rec(r#"{"type":"user","isSidechain":false,"isMeta":true,
"message":{"role":"user","content":"<local-command-caveat>system-reminder</local-command-caveat>"},
"timestamp":"2026-07-02T06:14:40.600Z","cwd":"/tmp/work","sessionId":"s"}"#);
assert!(parse(&v, 0).events.is_empty());
}
#[test]
fn parse_256kib_spills_to_blob() {
let big = "x".repeat(SPILLOVER_BYTES + 1024);
let v = rec(&format!(
r#"{{"type":"user","isSidechain":false,"isMeta":false,
"message":{{"role":"user","content":{}}},
"timestamp":"2026-07-02T06:14:40.699Z","cwd":"/tmp/work","sessionId":"s"}}"#,
serde_json::Value::String(big)
));
let tmp = tempfile::TempDir::new().unwrap();
let blobs = BlobStore::new(tmp.path().join("blobs"));
let pr = parse_record(&v, SID, 699, &blobs);
let ev = &pr.events[0];
assert!(ev.blob_hash.is_some(), "large payload must spill to a blob");
let hash = ev.blob_hash.as_ref().unwrap();
assert_eq!(ev.body_json.as_ref().unwrap()["overflow"], true);
assert_eq!(ev.body_json.as_ref().unwrap()["encoding"], "blob");
let raw = blobs.get(hash).unwrap();
let body: Value = serde_json::from_slice(&raw).unwrap();
assert_eq!(body["text"].as_str().unwrap().len(), SPILLOVER_BYTES + 1024);
}
#[test]
fn slugify_replaces_slash_and_dot() {
assert_eq!(
slugify(Path::new("/home/saadman/switch")),
"-home-saadman-switch"
);
assert_eq!(
slugify(Path::new("/home/saadman/switch/.claude/worktrees/x")),
"-home-saadman-switch--claude-worktrees-x"
);
assert_eq!(slugify(Path::new("C:\\Users\\me")), "C:-Users-me");
}
#[test]
fn select_detects_claude_basename() {
assert!(is_claude_code(&["claude".into()]));
assert!(is_claude_code(&["/usr/local/bin/claude".into()]));
assert!(is_claude_code(&["C:\\Apps\\claude.exe".into()]));
assert!(!is_claude_code(&["python3".into()]));
assert!(!is_claude_code(&[]));
assert!(select(&["claude".into()], Path::new("/tmp")).is_some());
assert!(select(&["python3".into()], Path::new("/tmp")).is_none());
}
#[test]
fn ts_ms_from_iso_is_relative_and_clamped() {
assert_eq!(
ts_ms_from_iso("2026-07-02T06:14:40.699Z", STARTED),
Some(699)
);
assert_eq!(ts_ms_from_iso("2026-07-02T06:14:40.000Z", STARTED), Some(0));
assert_eq!(ts_ms_from_iso("2026-07-02T06:14:39.000Z", STARTED), Some(0));
assert!(ts_ms_from_iso("not-a-date", STARTED).is_none());
}
fn spawn_with(
home: &Path,
cwd: &Path,
transcript: &str,
) -> (AdapterHandle, std::sync::Arc<AtomicBool>) {
std::fs::create_dir_all(home.join(".claude").join("projects").join(slugify(cwd))).unwrap();
std::fs::write(
home.join(".claude")
.join("projects")
.join(slugify(cwd))
.join("session.jsonl"),
transcript,
)
.unwrap();
let stop = std::sync::Arc::new(AtomicBool::new(false));
let blobs = std::sync::Arc::new(BlobStore::new(home.join("blobs")));
let ctx = AdapterContext {
session_id: SID.to_string(),
started_at_unix_ms: STARTED,
cwd: cwd.to_path_buf(),
command: vec!["claude".into()],
blobs,
stop: std::sync::Arc::clone(&stop),
projects_dir: Some(home.join(".claude").join("projects")),
};
let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn adapter");
(handle, stop)
}
fn drain(events: &mpsc::Receiver<Event>) -> Vec<Event> {
let mut out = Vec::new();
while let Ok(ev) = events.recv() {
out.push(ev);
}
out
}
const FIXTURE: &str = "\
{\"type\":\"system\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\",\"timestamp\":\"2026-07-02T06:14:40.500Z\"}
{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":true,\"message\":{\"role\":\"user\",\"content\":\"caveat\"},\"timestamp\":\"2026-07-02T06:14:40.600Z\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\"}
{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\"}
{\"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\"}
{\"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\"}
{\"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\"}
";
#[test]
fn tailer_parses_fixture_and_skips_filtered_records() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/work");
let (handle, stop) = spawn_with(home.path(), cwd, FIXTURE);
std::thread::sleep(Duration::from_millis(150));
stop.store(true, Ordering::Release);
let events = drain(&handle.events);
let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
assert_eq!(
kinds,
vec![
EventKind::UserMessage,
EventKind::ToolCall,
EventKind::ToolResult
]
);
}
#[test]
fn tailer_finds_file_late() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/work-late");
std::fs::create_dir_all(
home.path()
.join(".claude")
.join("projects")
.join(slugify(cwd)),
)
.unwrap();
let stop = std::sync::Arc::new(AtomicBool::new(false));
let blobs = std::sync::Arc::new(BlobStore::new(home.path().join("blobs")));
let ctx = AdapterContext {
session_id: SID.to_string(),
started_at_unix_ms: STARTED,
cwd: cwd.to_path_buf(),
command: vec!["claude".into()],
blobs,
stop: std::sync::Arc::clone(&stop),
projects_dir: Some(home.path().join(".claude").join("projects")),
};
let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn");
std::thread::sleep(Duration::from_millis(150));
std::fs::write(
home
.path()
.join(".claude")
.join("projects")
.join(slugify(cwd))
.join("late.jsonl"),
"{\"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",
)
.unwrap();
std::thread::sleep(Duration::from_millis(150));
stop.store(true, Ordering::Release);
let events = drain(&handle.events);
assert_eq!(
events.len(),
1,
"adapter should pick up the late-appearing file"
);
assert_eq!(events[0].kind, EventKind::UserMessage);
}
#[test]
fn tailer_partial_last_line_retries() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/work-partial");
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";
let partial = "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"sec";
let path = home
.path()
.join(".claude")
.join("projects")
.join(slugify(cwd))
.join("partial.jsonl");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, format!("{complete}{partial}")).unwrap();
let stop = std::sync::Arc::new(AtomicBool::new(false));
let blobs = std::sync::Arc::new(BlobStore::new(home.path().join("blobs")));
let ctx = AdapterContext {
session_id: SID.to_string(),
started_at_unix_ms: STARTED,
cwd: cwd.to_path_buf(),
command: vec!["claude".into()],
blobs,
stop: std::sync::Arc::clone(&stop),
projects_dir: Some(home.path().join(".claude").join("projects")),
};
let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn");
std::thread::sleep(Duration::from_millis(150));
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
writeln!(f, "ond\"}}}}\n").unwrap();
std::thread::sleep(Duration::from_millis(200));
stop.store(true, Ordering::Release);
let events = drain(&handle.events);
let contents: Vec<&str> = events
.iter()
.map(|e| e.body_json.as_ref().unwrap()["text"].as_str().unwrap())
.collect();
assert_eq!(
contents,
vec!["first", "second"],
"partial line must be retried until complete"
);
}
#[test]
fn tailer_fallback_scan_by_cwd() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/work-fallback");
let wrong_slug = "-totally-wrong-slug";
std::fs::create_dir_all(
home.path()
.join(".claude")
.join("projects")
.join(wrong_slug),
)
.unwrap();
std::fs::write(
home
.path()
.join(".claude")
.join("projects")
.join(wrong_slug)
.join("fb.jsonl"),
"{\"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",
)
.unwrap();
let stop = std::sync::Arc::new(AtomicBool::new(false));
let blobs = std::sync::Arc::new(BlobStore::new(home.path().join("blobs")));
let ctx = AdapterContext {
session_id: SID.to_string(),
started_at_unix_ms: STARTED,
cwd: cwd.to_path_buf(),
command: vec!["claude".into()],
blobs,
stop: std::sync::Arc::clone(&stop),
projects_dir: Some(home.path().join(".claude").join("projects")),
};
let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn");
std::thread::sleep(Duration::from_millis(150));
stop.store(true, Ordering::Release);
let events = drain(&handle.events);
assert_eq!(
events.len(),
1,
"fallback scan should locate the transcript by cwd"
);
assert_eq!(events[0].kind, EventKind::UserMessage);
let outcome = handle.outcome.join().expect("tailer thread");
assert_eq!(outcome.status, AdapterStatus::Active);
}
#[test]
fn tailer_degrades_when_projects_dir_missing() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/nowhere");
let stop = std::sync::Arc::new(AtomicBool::new(false));
let blobs = std::sync::Arc::new(BlobStore::new(home.path().join("blobs")));
let ctx = AdapterContext {
session_id: SID.to_string(),
started_at_unix_ms: STARTED,
cwd: cwd.to_path_buf(),
command: vec!["claude".into()],
blobs,
stop: std::sync::Arc::clone(&stop),
projects_dir: Some(home.path().join(".claude").join("projects")),
};
let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn");
let events = drain(&handle.events);
assert!(events.is_empty(), "no projects dir → no events");
let outcome = handle.outcome.join().expect("tailer thread");
assert_eq!(outcome.status, AdapterStatus::Degraded);
}
}