agent-berth 1.0.0

Monitor and resume coding agents
Documentation
use super::*;
use crate::status::{AgentEvent, AgentEventKind, apply_event};

#[test]
fn hook_payload_title_is_listed() {
    let mut store = Store::default();
    store
        .update(
            "claude",
            serde_json::json!({
                "session_id":"s",
                "hook_event_name":"UserPromptSubmit",
                "cwd":"/work",
                "title":"Fix the hooks"
            }),
        )
        .unwrap();
    assert_eq!(store.listed()[0].title.as_deref(), Some("Fix the hooks"));
}

#[test]
fn plugin_payload_title_is_listed() {
    let mut store = Store::default();
    store
        .update(
            "opencode",
            serde_json::json!({
                "id":"p",
                "status":{"s":"busy"},
                "titles":{"s":"Fix the plugin","other":"unused"}
            }),
        )
        .unwrap();
    assert_eq!(store.listed()[0].title.as_deref(), Some("Fix the plugin"));
}

#[test]
fn plugin_and_hook_identities_do_not_collide() {
    let mut store = Store::default();
    store
        .update(
            "codex",
            serde_json::json!({"session_id":"s","hook_event_name":"PermissionRequest","cwd":"/tmp"}),
        )
        .unwrap();
    store
        .update(
            "codex",
            serde_json::json!({"id":"cli","status":{"s":"busy"}}),
        )
        .unwrap();
    let listed = store.listed();
    assert_eq!(listed.len(), 2);
    assert!(
        listed
            .iter()
            .any(|s| s.kind == SessionKind::Hook && s.status == AgentStatus::Waiting)
    );
    assert!(
        listed
            .iter()
            .any(|s| s.kind == SessionKind::Plugin && s.status == AgentStatus::Working)
    );
}

#[test]
fn busy_session_without_process_is_resumable() {
    let mut store = Store::default();
    store
        .update(
            "claude",
            serde_json::json!({
                "session_id":"abc",
                "hook_event_name":"UserPromptSubmit",
                "cwd":"/work",
                "pid": 4294967295u64
            }),
        )
        .unwrap();
    let idle = Duration::from_secs(1200);
    let sessions = store.resumable(Some(idle));
    assert_eq!(sessions.len(), 1);
    assert_eq!(sessions[0].session_id, "abc");
}

#[test]
fn idle_sessions_need_an_explicit_window() {
    let mut store = Store::default();
    for event in ["UserPromptSubmit", "Stop"] {
        store
            .update(
                "claude",
                serde_json::json!({
                    "session_id":"abc",
                    "hook_event_name":event,
                    "cwd":"/work"
                }),
            )
            .unwrap();
    }
    assert!(store.resumable(None).is_empty());
    assert_eq!(store.resumable(Some(Duration::from_secs(1200))).len(), 1);
}

#[test]
fn session_end_is_not_resumable() {
    let mut store = Store::default();
    store
        .update(
            "claude",
            serde_json::json!({
                "session_id":"abc",
                "hook_event_name":"UserPromptSubmit",
                "cwd":"/work"
            }),
        )
        .unwrap();
    store
        .update(
            "claude",
            serde_json::json!({
                "session_id":"abc",
                "hook_event_name":"SessionEnd",
                "cwd":"/work"
            }),
        )
        .unwrap();
    assert!(store.resumable(Some(Duration::from_secs(1200))).is_empty());
}

#[test]
fn apply_event_roundtrip_through_store() {
    let mut sessions = BTreeMap::new();
    apply_event(
        &mut sessions,
        AgentEvent::new("s", AgentEventKind::PromptSubmit),
    );
    assert_eq!(sessions["s"].status, AgentStatus::Working);
}

#[test]
fn idle_session_uses_previous_heartbeat_window() {
    let mut store = Store::default();
    store
        .update(
            "claude",
            serde_json::json!({
                "session_id":"abc",
                "hook_event_name":"UserPromptSubmit",
                "cwd":"/work"
            }),
        )
        .unwrap();
    store
        .update(
            "claude",
            serde_json::json!({
                "session_id":"abc",
                "hook_event_name":"Stop",
                "cwd":"/work"
            }),
        )
        .unwrap();
    {
        let session = store
            .hooks
            .get_mut("claude")
            .unwrap()
            .get_mut("abc")
            .unwrap();
        session.last_report_ms = 1_000;
    }
    store.previous_heartbeat_ms = Some(1_000 + 10 * 60 * 1000);
    assert_eq!(store.resumable(Some(Duration::from_secs(20 * 60))).len(), 1);
    store.previous_heartbeat_ms = Some(1_000 + 30 * 60 * 1000);
    assert!(
        store
            .resumable(Some(Duration::from_secs(20 * 60)))
            .is_empty()
    );
}

#[test]
fn desktop_busy_sessions_are_resumable_for_list() {
    let mut store = Store::default();
    store
        .update(
            "claude",
            serde_json::json!({
                "session_id":"desk",
                "hook_event_name":"UserPromptSubmit",
                "cwd":"/work",
                "entrypoint":"claude-desktop"
            }),
        )
        .unwrap();
    let sessions = store.resumable(Some(Duration::from_secs(1200)));
    assert_eq!(sessions.len(), 1);
    assert_eq!(sessions[0].source, Source::Desktop);
}

#[test]
fn missing_cwd_and_live_pid_are_not_resumable() {
    let mut store = Store::default();
    store
        .update(
            "claude",
            serde_json::json!({
                "session_id":"nocwd",
                "hook_event_name":"UserPromptSubmit"
            }),
        )
        .unwrap();
    store
        .update(
            "claude",
            serde_json::json!({
                "session_id":"live",
                "hook_event_name":"UserPromptSubmit",
                "cwd":"/work",
                "pid": std::process::id()
            }),
        )
        .unwrap();
    assert!(store.resumable(Some(Duration::from_secs(1200))).is_empty());
}

#[test]
fn removed_sessions_are_hidden_until_they_report_again() {
    let mut store = Store::default();
    store
        .update(
            "claude",
            serde_json::json!({
                "session_id":"abc",
                "hook_event_name":"UserPromptSubmit",
                "cwd":"/work"
            }),
        )
        .unwrap();
    store.mark_removed("claude", "abc");
    assert!(store.listed().is_empty());
    store
        .update(
            "claude",
            serde_json::json!({
                "session_id":"abc",
                "hook_event_name":"PermissionRequest",
                "cwd":"/work"
            }),
        )
        .unwrap();
    assert_eq!(store.listed().len(), 1);
}

#[test]
fn prune_drops_stale_sessions_and_their_tombstones() {
    let mut store = Store::default();
    for sid in ["old", "fresh", "live"] {
        store
            .update(
                "claude",
                serde_json::json!({
                    "session_id": sid,
                    "hook_event_name": "UserPromptSubmit",
                    "cwd": "/work"
                }),
            )
            .unwrap();
    }
    {
        let bucket = store.hooks.get_mut("claude").unwrap();
        bucket.get_mut("old").unwrap().last_report_ms = 1_000;
        bucket.get_mut("fresh").unwrap().last_report_ms = 999_999;
        let live = bucket.get_mut("live").unwrap();
        live.last_report_ms = 1_000;
        live.pid = Some(std::process::id());
    }
    store.mark_removed("claude", "old");
    assert!(store.prune(1_000_000, Duration::from_secs(60)));
    let bucket = &store.hooks["claude"];
    assert!(!bucket.contains_key("old"));
    assert!(bucket.contains_key("fresh"));
    assert!(bucket.contains_key("live"));
    assert!(!store.is_removed("claude", "old"));
}

#[test]
fn prune_is_noop_when_sessions_are_recent() {
    let mut store = Store::default();
    store
        .update(
            "claude",
            serde_json::json!({
                "session_id": "abc",
                "hook_event_name": "UserPromptSubmit",
                "cwd": "/work"
            }),
        )
        .unwrap();
    let now = store.hooks["claude"]["abc"].last_report_ms;
    assert!(!store.prune(now, Duration::from_secs(60)));
}

#[test]
fn duplicate_snapshots_deduplicate_by_session() {
    let mut store = Store::default();
    for instance in ["1", "2"] {
        store
            .update(
                "opencode",
                serde_json::json!({
                    "id": instance,
                    "cwd":"/work",
                    "status":{"same":"idle"}
                }),
            )
            .unwrap();
    }
    let listed = store.listed();
    assert_eq!(listed.len(), 1);
    assert_eq!(listed[0].session_id, "same");
}

#[test]
fn plugin_blocking_is_waiting_and_stale_is_inactive() {
    let mut store = Store::default();
    store
        .update(
            "opencode",
            serde_json::json!({
                "id":"a:b",
                "status":{"c":"busy"}
            }),
        )
        .unwrap();
    store
        .update(
            "opencode",
            serde_json::json!({
                "id":"a",
                "status":{"b:c":"idle"},
                "blocking":["b:c"]
            }),
        )
        .unwrap();
    let listed = store.listed();
    assert!(
        listed
            .iter()
            .any(|s| s.session_id == "c" && s.status == AgentStatus::Working)
    );
    assert!(
        listed
            .iter()
            .any(|s| s.session_id == "b:c" && s.status == AgentStatus::Waiting)
    );
    let now = now_ms();
    assert!(listed.iter().all(|s| s.is_active(now)));
    assert!(
        listed
            .iter()
            .all(|s| !s.is_active(now + PLUGIN_STALE.as_millis() as u64 + 1))
    );
}

#[test]
fn rejects_invalid_notify_payloads() {
    let mut store = Store::default();
    assert!(store.update("claude", serde_json::json!([])).is_err());
    assert!(
        store
            .update("opencode", serde_json::json!({"status":{}}))
            .is_err()
    );
    assert!(
        store
            .update(
                "opencode",
                serde_json::json!({"id":"p","status":{"s":"nope"}})
            )
            .is_err()
    );
    assert!(
        store
            .update(
                "opencode",
                serde_json::json!({"id":"p","status":{},"blocking":[1]})
            )
            .is_err()
    );
    assert!(
        store
            .update(
                "opencode",
                serde_json::json!({"id":"p","status":{},"titles":{"s":1}})
            )
            .is_err()
    );
}