use std::path::Path;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct HookInput {
pub session_id: Option<String>,
pub cwd: Option<String>,
pub reason: Option<String>,
pub transcript_path: Option<String>,
}
#[derive(Debug)]
pub enum Decision {
Skip,
NotifyOnly {
message: String,
},
LimitSwitch {
message: String,
target_profile: String,
handoff: String,
cwd: String,
born: i64,
},
}
pub const MAX_HOPS: i64 = 1;
pub const LAST_SWITCH_COOLDOWN_SECS: i64 = 300;
pub const TIER1_TAIL_RECORDS: usize = 12;
pub const TIER1_RECENCY_SECS: i64 = 900;
pub const MALFORMED_TAIL_RECORDS: usize = 8;
pub const MALFORMED_RECENCY_SECS: i64 = 180;
pub const LIMIT_PCT: i64 = 99;
pub fn is_user_quit_reason(reason: &str) -> bool {
matches!(reason, "clear" | "logout" | "prompt_input_exit" | "exit")
}
pub fn parse_stdin() -> anyhow::Result<HookInput> {
use std::io::Read as _;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
let trimmed = buf.trim();
if trimmed.is_empty() {
return Ok(HookInput {
session_id: None,
cwd: None,
reason: None,
transcript_path: None,
});
}
let input: HookInput = serde_json::from_str(trimmed)?;
Ok(input)
}
pub fn classify(input: &HookInput, owner_dir: &Path) -> anyhow::Result<Decision> {
use crate::paths;
let sid = match &input.session_id {
Some(s) if !s.is_empty() => s.as_str(),
_ => return Ok(Decision::Skip),
};
if std::env::var("CLAUDE_AUTO_SWITCH").as_deref() == Ok("0") {
return Ok(Decision::Skip);
}
if paths::smart_dir_no_create()
.join(".auto-switch-disabled")
.exists()
{
return Ok(Decision::Skip);
}
if paths::switched(sid).exists() {
return Ok(Decision::Skip);
}
let user_quit = input
.reason
.as_deref()
.map(is_user_quit_reason)
.unwrap_or(false);
let (limited_msg, limited) = {
let t1 = detect_limit_in_tail(input);
if let Some(msg) = t1 {
(msg, true)
} else {
let t2 = detect_usage_threshold(owner_dir);
if let Some(msg) = t2 {
(msg, true)
} else {
let m = detect_malformed_in_tail(input);
if let Some(msg) = m {
(msg, true)
} else {
(String::new(), false)
}
}
}
};
if !limited {
if user_quit {
}
return Ok(Decision::Skip);
}
if user_quit {
let detect_path = paths::detected(sid);
if !detect_path.exists() {
let _ = paths::smart_dir(); let _ = write_noclobber_epoch(&detect_path);
prune_detected_markers();
let profile_name = owner_dir_to_profile_name(owner_dir);
let body = format!(
"[{profile_name}] hit {limited_msg} — you quit, so not relaunching; next csm will pick a healthy account"
);
return Ok(Decision::NotifyOnly { message: body });
}
return Ok(Decision::Skip);
}
let current_profile = owner_dir_to_profile_name(owner_dir);
let target_result =
crate::account::pick_account(¤t_profile, false);
let target_profile = match target_result {
Ok(Some(name)) => name,
Ok(None) | Err(_) => {
let detect_path = paths::detected(sid);
if !detect_path.exists() {
let _ = paths::smart_dir();
let _ = write_noclobber_epoch(&detect_path);
prune_detected_markers();
let body = format!(
"[{current_profile}] hit {limited_msg} — no account with headroom to switch to"
);
return Ok(Decision::NotifyOnly { message: body });
}
return Ok(Decision::Skip);
}
};
let relaunch_env =
std::env::var("CLAUDE_AUTO_SWITCH_RELAUNCH").unwrap_or_else(|_| "1".to_string());
if relaunch_env != "1" {
let detect_path = paths::detected(sid);
if !detect_path.exists() {
let _ = paths::smart_dir();
let _ = write_noclobber_epoch(&detect_path);
prune_detected_markers();
let sid_short = sid.get(..8).unwrap_or(sid);
let body = format!(
"[{current_profile}] hit {limited_msg} → switch to [{target_profile}] (auto-relaunch OFF; csm --profile {target_profile} --resume {sid_short})"
);
return Ok(Decision::NotifyOnly { message: body });
}
return Ok(Decision::Skip);
}
let pid_path = paths::pid_file(sid);
if !pid_path.exists() {
let detect_path = paths::detected(sid);
if !detect_path.exists() {
let _ = paths::smart_dir();
let _ = write_noclobber_epoch(&detect_path);
prune_detected_markers();
let sid_short = sid.get(..8).unwrap_or(sid);
let body = format!(
"[{current_profile}] hit {limited_msg} → switch to [{target_profile}] by hand (csm --profile {target_profile} --resume {sid_short})"
);
return Ok(Decision::NotifyOnly { message: body });
}
return Ok(Decision::Skip);
}
let pid_content = std::fs::read_to_string(&pid_path).unwrap_or_default();
let (claude_pid, born_epoch) = match parse_pid_file(&pid_content) {
Some(v) => v,
None => {
return Ok(Decision::Skip);
}
};
if !is_live_claude_or_node(claude_pid) {
return Ok(Decision::Skip);
}
let smart_dir = paths::smart_dir()?;
let last_switch_path = paths::last_switch();
let cooldown_secs = std::env::var("CLAUDE_SWITCH_COOLDOWN")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(LAST_SWITCH_COOLDOWN_SECS);
let cooldown_blocked = check_and_claim_cooldown(&last_switch_path, cooldown_secs);
let _ = smart_dir; if cooldown_blocked {
return Ok(Decision::Skip);
}
let max_hops = std::env::var("CLAUDE_MAX_HOPS")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(MAX_HOPS);
let current_hop = read_sidecar_hop(sid);
if current_hop >= max_hops {
return Ok(Decision::Skip);
}
let next_hop = current_hop + 1;
let sid_short = sid.get(..8).unwrap_or(sid);
let handoff = build_handoff(sid_short, ¤t_profile, &target_profile, next_hop);
let message = format!(
"[{current_profile}] hit {limited_msg} → switching to [{target_profile}] (hop {next_hop})"
);
let cwd_str = input
.cwd
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| ".".to_string());
Ok(Decision::LimitSwitch {
message,
target_profile,
handoff,
cwd: cwd_str,
born: born_epoch,
})
}
fn detect_limit_in_tail(input: &HookInput) -> Option<String> {
let tp = input.transcript_path.as_deref().filter(|s| !s.is_empty())?;
let path = std::path::Path::new(tp);
if !path.exists() {
return None;
}
let now_secs = now_epoch();
let hit = limit_in_tail_impl(path, TIER1_TAIL_RECORDS, TIER1_RECENCY_SECS, now_secs)?;
let sanitized: String = hit
.chars()
.filter(|c| *c != '"' && *c != '\\')
.map(|c| if c == '\n' || c == '\t' { ' ' } else { c })
.take(80)
.collect();
Some(format!("api-error: {sanitized}"))
}
pub(crate) fn limit_in_tail_impl(
path: &Path,
n: usize,
window_secs: i64,
now_secs: i64,
) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
let tail: &[&str] = if lines.len() > n {
&lines[lines.len() - n..]
} else {
&lines
};
let mut result: Option<String> = None;
for line in tail {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if v.get("isApiErrorMessage").and_then(|b| b.as_bool()) != Some(true) {
continue;
}
let text = extract_message_text(&v);
if text.is_empty() {
continue;
}
let tl = text.to_ascii_lowercase();
if !tl.contains("hit your") || !tl.contains("limit") {
if !tl.contains("usage limit") && !tl.contains("limit reached") {
continue;
}
}
let ts = extract_timestamp_epoch(&v);
if ts <= 0 || (now_secs - ts) > window_secs {
continue;
}
result = Some(text);
}
result
}
fn detect_usage_threshold(owner_dir: &Path) -> Option<String> {
let profile = owner_dir_to_profile_name(owner_dir);
let (session_pct, week_pct) = crate::account::current_usage(&profile)?;
let limit_pct = std::env::var("CLAUDE_LIMIT_PCT")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(LIMIT_PCT);
if session_pct >= 0 && session_pct >= limit_pct {
return Some(format!("session {session_pct}%"));
}
if week_pct >= 0 && week_pct >= limit_pct {
return Some(format!("week_all {week_pct}%"));
}
None
}
fn detect_malformed_in_tail(input: &HookInput) -> Option<String> {
let tp = input.transcript_path.as_deref().filter(|s| !s.is_empty())?;
let path = std::path::Path::new(tp);
if !path.exists() {
return None;
}
let now_secs = now_epoch();
malformed_in_tail_impl(
path,
MALFORMED_TAIL_RECORDS,
MALFORMED_RECENCY_SECS,
now_secs,
)
}
pub(crate) fn malformed_in_tail_impl(
path: &Path,
n: usize,
window_secs: i64,
now_secs: i64,
) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
let tail: &[&str] = if lines.len() > n {
&lines[lines.len() - n..]
} else {
&lines
};
let mut found = false;
for line in tail {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if v.get("type").and_then(|t| t.as_str()) != Some("assistant") {
continue;
}
if v.get("message")
.and_then(|m| m.get("stop_reason"))
.and_then(|r| r.as_str())
!= Some("tool_use")
{
continue;
}
let content_arr = v
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
.map(|a| a.as_slice())
.unwrap_or(&[]);
let has_tool_use_block = content_arr
.iter()
.any(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_use"));
if has_tool_use_block {
continue;
}
let text: String = content_arr
.iter()
.filter_map(|block| {
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
block.get("text").and_then(|t| t.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join("");
if !text.trim_end().ends_with("</invoke>") {
continue;
}
if !text.contains("<invoke name=") && !text.contains("antml:invoke name=") {
continue;
}
let ts = extract_timestamp_epoch(&v);
if ts <= 0 || (now_secs - ts) > window_secs {
continue;
}
found = true;
}
if found {
Some("MALFORMED_TOOL_USE".to_string())
} else {
None
}
}
fn extract_message_text(v: &serde_json::Value) -> String {
let content = match v.get("message").and_then(|m| m.get("content")) {
Some(c) => c,
None => return String::new(),
};
if let Some(arr) = content.as_array() {
arr.iter()
.filter_map(|block| {
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
block.get("text").and_then(|t| t.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join(" ")
} else if let Some(s) = content.as_str() {
s.to_string()
} else {
String::new()
}
}
fn extract_timestamp_epoch(v: &serde_json::Value) -> i64 {
let ts_str = match v.get("timestamp").and_then(|t| t.as_str()) {
Some(s) if !s.is_empty() => s,
_ => return 0,
};
let normalized = if let Some(dot_pos) = ts_str.find('.') {
let base = &ts_str[..dot_pos];
if ts_str.ends_with('Z') {
format!("{base}Z")
} else {
ts_str.to_string()
}
} else {
ts_str.to_string()
};
use chrono::DateTime;
if let Ok(dt) = DateTime::parse_from_rfc3339(&normalized) {
dt.timestamp()
} else {
0
}
}
pub(crate) fn owner_dir_to_profile_name(owner_dir: &Path) -> String {
let seg = owner_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
if let Some(stripped) = seg.strip_prefix(".claude.") {
stripped.to_string()
} else {
seg.to_string()
}
}
fn parse_pid_file(content: &str) -> Option<(u32, i64)> {
let mut tokens = content.split_whitespace();
let pid: u32 = tokens.next()?.parse().ok()?;
let born: i64 = tokens.next()?.parse().ok()?;
Some((pid, born))
}
fn is_live_claude_or_node(pid: u32) -> bool {
crate::hook::stop::check_is_live_claude_or_node(pid)
}
fn read_sidecar_hop(sid: &str) -> i64 {
crate::sidecar::read_sidecar(&crate::paths::sidecar(sid))
.map(|s| s.hop_int())
.unwrap_or(0)
}
pub(crate) fn build_handoff(
sid_short: &str,
current_profile: &str,
target_profile: &str,
next_hop: i64,
) -> String {
match std::env::var("CLAUDE_SMART_RESUME_PROMPT") {
Ok(v) if v.is_empty() => {
String::new()
}
Ok(v) => {
v
}
Err(_) => {
format!(
"이전 세션({sid_short})이 사용량 한도에 걸려 [{current_profile}]에서 [{target_profile}] 계정으로 자동 전환됐어. (hop {next_hop}) 직전까지 하던 작업을 그대로 이어서 진행해줘."
)
}
}
}
fn write_noclobber_epoch(path: &Path) -> anyhow::Result<()> {
use std::fs::OpenOptions;
use std::io::Write as _;
let epoch = now_epoch().to_string();
match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(mut f) => {
let _ = f.write_all(epoch.as_bytes());
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
}
Err(e) => return Err(e.into()),
}
Ok(())
}
fn prune_detected_markers() {
use crate::paths;
let smart_dir = paths::smart_dir_no_create();
let seven_days_secs = 7 * 24 * 3600u64;
let now = std::time::SystemTime::now();
let Ok(entries) = std::fs::read_dir(&smart_dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.ends_with(".detected") {
continue;
}
if let Ok(meta) = entry.metadata() {
if let Ok(modified) = meta.modified() {
if let Ok(age) = now.duration_since(modified) {
if age.as_secs() > seven_days_secs {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
}
}
fn check_and_claim_cooldown(last_switch_path: &Path, cooldown_secs: i64) -> bool {
use std::fs::OpenOptions;
use std::io::Write as _;
let now = now_epoch();
let epoch_str = now.to_string();
let created = match OpenOptions::new()
.write(true)
.create_new(true)
.open(last_switch_path)
{
Ok(mut f) => {
let _ = f.write_all(epoch_str.as_bytes());
true
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => false,
Err(_) => {
return false;
}
};
if created {
return false;
}
let last_ts = std::fs::read_to_string(last_switch_path)
.ok()
.and_then(|s| s.trim().parse::<i64>().ok())
.unwrap_or(0);
if last_ts > 0 && (now - last_ts) < cooldown_secs {
return true;
}
let _ = std::fs::write(last_switch_path, &epoch_str);
false
}
#[cfg(not(test))]
pub(crate) fn now_epoch() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
#[cfg(test)]
pub(crate) fn now_epoch() -> i64 {
TEST_NOW.with(|n| *n.borrow())
}
#[cfg(test)]
thread_local! {
static TEST_NOW: std::cell::RefCell<i64> = const { std::cell::RefCell::new(1_718_000_000) };
}
#[cfg(test)]
pub(crate) fn set_test_now(epoch: i64) {
TEST_NOW.with(|n| *n.borrow_mut() = epoch);
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn hook_input_full_payload() {
let json = r#"{
"session_id": "01234567-89ab-cdef-0123-456789abcdef",
"cwd": "/Users/example/Projects/github.com/foo",
"reason": "stop",
"transcript_path": "/Users/example/.claude.shared/projects/-Users-example-Projects-github-com-foo/01234567-89ab-cdef-0123-456789abcdef.jsonl"
}"#;
let input: HookInput = serde_json::from_str(json).expect("deserialize full payload");
assert_eq!(
input.session_id.as_deref(),
Some("01234567-89ab-cdef-0123-456789abcdef")
);
assert_eq!(
input.cwd.as_deref(),
Some("/Users/example/Projects/github.com/foo")
);
assert_eq!(input.reason.as_deref(), Some("stop"));
assert!(input.transcript_path.is_some());
}
#[test]
fn hook_input_minimal_payload() {
let json = r#"{"session_id": "aaaabbbb-cccc-dddd-eeee-ffffaaaabbbb"}"#;
let input: HookInput = serde_json::from_str(json).expect("deserialize minimal");
assert_eq!(
input.session_id.as_deref(),
Some("aaaabbbb-cccc-dddd-eeee-ffffaaaabbbb")
);
assert!(input.cwd.is_none());
assert!(input.reason.is_none());
assert!(input.transcript_path.is_none());
}
#[test]
fn hook_input_no_session_id() {
let json = r#"{"cwd": "/tmp", "reason": "stop"}"#;
let input: HookInput = serde_json::from_str(json).expect("deserialize no sid");
assert!(input.session_id.is_none());
}
#[test]
fn hook_input_empty_string_round_trip() {
let json = r#"{}"#;
let input: HookInput = serde_json::from_str(json).expect("empty object");
assert!(input.session_id.is_none());
assert!(input.cwd.is_none());
assert!(input.reason.is_none());
assert!(input.transcript_path.is_none());
}
#[test]
fn hook_input_ignores_extra_fields() {
let json = r#"{
"session_id": "cafecafe-cafe-cafe-cafe-cafecafecafe",
"cwd": "/tmp",
"reason": "stop",
"transcript_path": null,
"future_field": "some_value",
"another_extra": 42
}"#;
let input: HookInput = serde_json::from_str(json).expect("extra fields tolerated");
assert_eq!(
input.session_id.as_deref(),
Some("cafecafe-cafe-cafe-cafe-cafecafecafe")
);
assert!(input.transcript_path.is_none());
}
#[test]
fn reason_gate_user_quit_reasons() {
for reason in &["clear", "logout", "prompt_input_exit", "exit"] {
assert!(
is_user_quit_reason(reason),
"expected {reason:?} to be a user-quit reason"
);
}
}
#[test]
fn reason_gate_stop_is_not_user_quit() {
assert!(
!is_user_quit_reason("stop"),
"\"stop\" should not be a user-quit reason"
);
}
#[test]
fn reason_gate_unknown_is_not_user_quit() {
assert!(!is_user_quit_reason("unknown_event"));
assert!(!is_user_quit_reason(""));
assert!(!is_user_quit_reason("SubagentStop"));
}
#[test]
fn max_hops_is_one() {
assert_eq!(MAX_HOPS, 1);
}
#[test]
fn last_switch_cooldown_is_300() {
assert_eq!(LAST_SWITCH_COOLDOWN_SECS, 300);
}
fn make_transcript_line(
is_api_error: bool,
text: &str,
ts_offset_secs: i64,
base_epoch: i64,
) -> String {
let epoch = base_epoch + ts_offset_secs;
let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(epoch, 0)
.unwrap()
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
serde_json::json!({
"type": "assistant",
"isApiErrorMessage": is_api_error,
"timestamp": dt,
"message": {
"content": [
{"type": "text", "text": text}
]
}
})
.to_string()
}
#[test]
fn limit_in_tail_detects_fresh_banner() {
use std::io::Write;
use tempfile::NamedTempFile;
let base_now: i64 = 1_718_000_000;
set_test_now(base_now);
let banner = "You've hit your session limit · resets 9pm (Asia/Seoul)";
let line = make_transcript_line(true, banner, -100, base_now);
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "{line}").unwrap();
let result = limit_in_tail_impl(f.path(), 12, 900, base_now);
assert!(result.is_some(), "should detect fresh limit banner");
assert!(result.unwrap().contains("hit your"));
}
#[test]
fn limit_in_tail_skips_stale_banner() {
use std::io::Write;
use tempfile::NamedTempFile;
let base_now: i64 = 1_718_000_000;
let banner = "You've hit your session limit · resets 9pm (Asia/Seoul)";
let line = make_transcript_line(true, banner, -1000, base_now);
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "{line}").unwrap();
let result = limit_in_tail_impl(f.path(), 12, 900, base_now);
assert!(result.is_none(), "stale banner should not trigger");
}
#[test]
fn limit_in_tail_skips_non_error_record() {
use std::io::Write;
use tempfile::NamedTempFile;
let base_now: i64 = 1_718_000_000;
let banner = "You've hit your session limit · resets 9pm (Asia/Seoul)";
let line = make_transcript_line(false, banner, -100, base_now);
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "{line}").unwrap();
let result = limit_in_tail_impl(f.path(), 12, 900, base_now);
assert!(result.is_none(), "non-error record should not trigger");
}
#[test]
fn limit_in_tail_skips_wrong_text() {
use std::io::Write;
use tempfile::NamedTempFile;
let base_now: i64 = 1_718_000_000;
let line = make_transcript_line(true, "Server overloaded, try again later", -100, base_now);
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "{line}").unwrap();
let result = limit_in_tail_impl(f.path(), 12, 900, base_now);
assert!(result.is_none(), "wrong text should not trigger");
}
#[test]
fn limit_in_tail_only_last_n_records() {
use std::io::Write;
use tempfile::NamedTempFile;
let base_now: i64 = 1_718_000_000;
let mut f = NamedTempFile::new().unwrap();
for i in 0..12 {
let line = make_transcript_line(false, &format!("normal message {i}"), -50, base_now);
writeln!(f, "{line}").unwrap();
}
let banner_line = make_transcript_line(
true,
"You've hit your session limit · resets 9pm",
-60,
base_now,
);
writeln!(f, "{banner_line}").unwrap();
for i in 0..3 {
let line = make_transcript_line(false, &format!("after {i}"), -10, base_now);
writeln!(f, "{line}").unwrap();
}
let result = limit_in_tail_impl(f.path(), 12, 900, base_now);
assert!(
result.is_some(),
"banner within last 12 should be detected: {result:?}"
);
}
fn make_malformed_record(ts_offset: i64, base_now: i64) -> String {
let epoch = base_now + ts_offset;
let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(epoch, 0)
.unwrap()
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
serde_json::json!({
"type": "assistant",
"timestamp": dt,
"message": {
"stop_reason": "tool_use",
"content": [
{
"type": "text",
"text": "I'll use the tool <invoke name=\"bash\"><parameter>ls</parameter></invoke>"
}
]
}
})
.to_string()
}
#[test]
fn malformed_in_tail_detects_fresh_hit() {
use std::io::Write;
use tempfile::NamedTempFile;
let base_now: i64 = 1_718_000_000;
let line = make_malformed_record(-60, base_now);
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "{line}").unwrap();
let result = malformed_in_tail_impl(f.path(), 8, 180, base_now);
assert_eq!(result.as_deref(), Some("MALFORMED_TOOL_USE"));
}
#[test]
fn malformed_in_tail_skips_when_has_tool_use_block() {
use std::io::Write;
use tempfile::NamedTempFile;
let base_now: i64 = 1_718_000_000;
let epoch = base_now - 60;
let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(epoch, 0)
.unwrap()
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
let line = serde_json::json!({
"type": "assistant",
"timestamp": dt,
"message": {
"stop_reason": "tool_use",
"content": [
{"type": "tool_use", "id": "t1", "name": "bash", "input": {"cmd": "ls"}},
{"type": "text", "text": "running <invoke name=\"bash\"></invoke>"}
]
}
})
.to_string();
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "{line}").unwrap();
let result = malformed_in_tail_impl(f.path(), 8, 180, base_now);
assert!(
result.is_none(),
"should not trigger when tool_use block present"
);
}
#[test]
fn malformed_in_tail_skips_without_close_invoke() {
use std::io::Write;
use tempfile::NamedTempFile;
let base_now: i64 = 1_718_000_000;
let epoch = base_now - 60;
let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(epoch, 0)
.unwrap()
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
let line = serde_json::json!({
"type": "assistant",
"timestamp": dt,
"message": {
"stop_reason": "tool_use",
"content": [
{"type": "text", "text": "<invoke name=\"bash\">ls"}
]
}
})
.to_string();
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "{line}").unwrap();
let result = malformed_in_tail_impl(f.path(), 8, 180, base_now);
assert!(
result.is_none(),
"should not trigger without </invoke> at end"
);
}
#[test]
fn owner_dir_profile_name_home() {
let p = Path::new("/Users/example/.claude.home");
assert_eq!(owner_dir_to_profile_name(p), "home");
}
#[test]
fn owner_dir_profile_name_work() {
let p = Path::new("/home/you/.claude.work");
assert_eq!(owner_dir_to_profile_name(p), "work");
}
#[test]
fn owner_dir_profile_name_no_prefix() {
let p = Path::new("/home/you/mydir");
assert_eq!(owner_dir_to_profile_name(p), "mydir");
}
#[test]
fn build_handoff_default_korean() {
let _g = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDE_SMART_RESUME_PROMPT");
std::env::remove_var("CLAUDE_SMART_RESUME_PROMPT");
let h = build_handoff("01234567", "home", "work", 1);
assert!(h.contains("01234567"), "should contain sid_short: {h}");
assert!(h.contains("home"), "should contain current profile: {h}");
assert!(h.contains("work"), "should contain target profile: {h}");
assert!(h.contains("hop 1"), "should contain hop: {h}");
if let Ok(v) = prev {
std::env::set_var("CLAUDE_SMART_RESUME_PROMPT", v);
}
}
#[test]
fn build_handoff_empty_suppresses() {
let _g = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDE_SMART_RESUME_PROMPT");
std::env::set_var("CLAUDE_SMART_RESUME_PROMPT", "");
let h = build_handoff("01234567", "home", "work", 1);
assert!(h.is_empty(), "empty env var should suppress handoff");
if let Ok(v) = prev {
std::env::set_var("CLAUDE_SMART_RESUME_PROMPT", v);
} else {
std::env::remove_var("CLAUDE_SMART_RESUME_PROMPT");
}
}
#[test]
fn build_handoff_custom_override() {
let _g = ENV_LOCK.lock().unwrap();
let prev = std::env::var("CLAUDE_SMART_RESUME_PROMPT");
std::env::set_var("CLAUDE_SMART_RESUME_PROMPT", "custom prompt here");
let h = build_handoff("01234567", "home", "work", 1);
assert_eq!(h, "custom prompt here");
if let Ok(v) = prev {
std::env::set_var("CLAUDE_SMART_RESUME_PROMPT", v);
} else {
std::env::remove_var("CLAUDE_SMART_RESUME_PROMPT");
}
}
#[test]
fn cooldown_first_claimant_proceeds() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".last-switch");
let blocked = check_and_claim_cooldown(&path, 300);
assert!(!blocked, "first claimant should not be blocked");
assert!(path.exists(), "last-switch file should be created");
}
#[test]
fn cooldown_blocks_within_window() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".last-switch");
let base_now: i64 = 1_718_000_000;
set_test_now(base_now);
std::fs::write(&path, (base_now - 60).to_string()).unwrap();
let blocked = check_and_claim_cooldown(&path, 300);
assert!(blocked, "should be blocked within cooldown window");
}
#[test]
fn cooldown_allows_after_window() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".last-switch");
let base_now: i64 = 1_718_000_000;
set_test_now(base_now);
std::fs::write(&path, (base_now - 400).to_string()).unwrap();
let blocked = check_and_claim_cooldown(&path, 300);
assert!(!blocked, "should not be blocked outside cooldown window");
}
#[test]
fn hop_increments_from_sidecar() {
let sidecar_json = r#"{"sessionId":"test","hop":"1","permissionMode":"default"}"#;
let val: serde_json::Value = serde_json::from_str(sidecar_json).unwrap();
let hop = match val.get("hop") {
Some(serde_json::Value::String(s)) => s.parse::<i64>().unwrap_or(0),
Some(serde_json::Value::Number(n)) => n.as_i64().unwrap_or(0),
_ => 0,
};
assert_eq!(hop, 1);
assert_eq!(hop + 1, 2, "next_hop should be 2");
}
#[test]
fn born_passthrough_from_pidfile() {
let content = "12345 1718000000\n";
let (pid, born) = parse_pid_file(content).unwrap();
assert_eq!(pid, 12345);
assert_eq!(born, 1_718_000_000);
}
#[test]
fn timestamp_epoch_parsed_correctly() {
let v = serde_json::json!({"timestamp": "2024-01-10T12:00:00.000Z"});
let epoch = extract_timestamp_epoch(&v);
assert!(epoch > 0, "should parse ISO-8601 timestamp");
assert!(
epoch > 1_700_000_000 && epoch < 1_800_000_000,
"epoch out of expected range: {epoch}"
);
}
#[test]
fn timestamp_epoch_missing_returns_zero() {
let v = serde_json::json!({"type": "assistant"});
assert_eq!(extract_timestamp_epoch(&v), 0);
}
#[test]
fn timestamp_epoch_empty_returns_zero() {
let v = serde_json::json!({"timestamp": ""});
assert_eq!(extract_timestamp_epoch(&v), 0);
}
}