agent-berth 2.2.0

Monitor and resume coding agents
Documentation
use super::*;
use crate::paths::Context as AppContext;
use crate::status::AgentStatus;
use tempfile::tempdir;

#[test]
fn desktop_focus_links_to_the_session_and_rejects_invalid_ids() {
    let id = "01a0b525-b641-7472-90cf-14dba7db3162";
    assert_eq!(
        desktop_url(id).unwrap(),
        "codex://threads/01a0b525-b641-7472-90cf-14dba7db3162"
    );
    for invalid in [
        "",
        "new",
        "../settings",
        "01a0b525-b641-7472-90cf-14dba7db3162?prompt=hello",
        "01a0b525-b641-7472-90cf-14dba7db3162&command",
        "01a0b525-b641-7472-90cf-14dba7db316g",
        "01a0b525/b641-7472-90cf-14dba7db3162",
    ] {
        assert!(desktop_url(invalid).is_err(), "{invalid}");
    }
}

#[test]
fn hooks_preserve_known_source_and_children_inherit_the_parent_source() {
    let mut sessions = BTreeMap::new();
    for (originator, expected) in [
        ("Codex Desktop", Source::Desktop),
        ("codex_work_desktop", Source::Desktop),
        ("codex-tui", Source::Cli),
        ("codex_exec", Source::Cli),
    ] {
        apply_hook(
            &mut sessions,
            &json!({
                "session_id":"s", "hook_event_name":"UserPromptSubmit",
                "originator":originator
            }),
        );
        assert_eq!(sessions["s"].source, expected);
        apply_hook(
            &mut sessions,
            &json!({"session_id":"s", "hook_event_name":"PreToolUse"}),
        );
        assert_eq!(sessions["s"].source, expected);
        apply_hook(
            &mut sessions,
            &json!({"session_id":"s", "agent_id":"child", "hook_event_name":"PreToolUse"}),
        );
        assert_eq!(sessions["s:child"].source, expected);
        apply_hook(
            &mut sessions,
            &json!({"session_id":"s", "agent_id":"child", "hook_event_name":"SubagentStop"}),
        );
    }
}

#[test]
fn automatic_approval_review_does_not_wait_for_user_input() {
    let root = tempdir().unwrap();
    let transcript = root.path().join("rollout.jsonl");
    std::fs::write(
        &transcript,
        format!(
            "{}\n",
            json!({"type":"turn_context","payload":{
                "turn_id":"turn", "approvals_reviewer":"auto_review"
            }})
        ),
    )
    .unwrap();
    let mut sessions = BTreeMap::new();
    for event in ["UserPromptSubmit", "PreToolUse", "PermissionRequest"] {
        apply_hook(
            &mut sessions,
            &json!({
                "session_id":"s", "hook_event_name":event,
                "turn_id":"turn", "transcript_path":transcript
            }),
        );
        assert_eq!(sessions["s"].status, AgentStatus::Running, "{event}");
    }
}

#[test]
fn automatic_approval_review_recovers_a_persisted_waiting_session() {
    let root = tempdir().unwrap();
    let transcript = root.path().join("rollout.jsonl");
    std::fs::write(
        &transcript,
        "{\"type\":\"turn_context\",\"payload\":{\"turn_id\":\"turn\",\"approvals_reviewer\":\"auto_review\"}}\n",
    )
    .unwrap();
    let mut sessions: BTreeMap<String, AgentSession> = serde_json::from_value(json!({
        "s": {"status":"waiting", "background_only":true, "title":"Keep this title"}
    }))
    .unwrap();
    apply_hook(
        &mut sessions,
        &json!({
            "session_id":"s", "hook_event_name":"PermissionRequest",
            "turn_id":"turn", "transcript_path":transcript
        }),
    );
    assert_eq!(sessions["s"].status, AgentStatus::Running);
    assert!(!sessions["s"].background_only);
    assert_eq!(sessions["s"].title.as_deref(), Some("Keep this title"));
}

#[test]
fn permission_requests_wait_unless_active_turn_confirms_auto_review() {
    let root = tempdir().unwrap();
    let transcript = root.path().join("rollout.jsonl");
    for contexts in [
        vec![],
        vec![json!({"turn_id":"turn", "approvals_reviewer":"user"})],
        vec![json!({"turn_id":"other", "approvals_reviewer":"auto_review"})],
        vec![json!({"turn_id":"turn"})],
        vec![json!({"turn_id":"turn", "approvals_reviewer":"unknown"})],
        vec![
            json!({"turn_id":"turn", "approvals_reviewer":"auto_review"}),
            json!({"turn_id":"turn", "approvals_reviewer":"user"}),
        ],
    ] {
        let text: String = contexts
            .iter()
            .map(|context| format!("{}\n", json!({"type":"turn_context","payload":context})))
            .collect();
        std::fs::write(&transcript, text).unwrap();
        let mut sessions = BTreeMap::new();
        apply_hook(
            &mut sessions,
            &json!({
                "session_id":"s", "hook_event_name":"PermissionRequest",
                "turn_id":"turn", "transcript_path":transcript
            }),
        );
        assert_eq!(sessions["s"].status, AgentStatus::Waiting, "{contexts:?}");
    }
}

#[test]
fn unavailable_approval_context_preserves_permission_waiting() {
    let root = tempdir().unwrap();
    let transcript = root.path().join("missing.jsonl");
    let mut event = json!({
        "session_id":"s", "hook_event_name":"PermissionRequest",
        "turn_id":"turn", "transcript_path":transcript
    });
    for contents in [
        None,
        Some("not json\n{\"type\":\"turn_context\",\"payload\":"),
    ] {
        if let Some(contents) = contents {
            std::fs::write(&transcript, contents).unwrap();
        }
        let mut sessions = BTreeMap::new();
        apply_hook(&mut sessions, &event);
        assert_eq!(sessions["s"].status, AgentStatus::Waiting);
    }
    std::fs::write(
        &transcript,
        "{\"type\":\"turn_context\",\"payload\":{\"turn_id\":\"turn\",\"approvals_reviewer\":\"auto_review\"}}\n",
    )
    .unwrap();
    event.as_object_mut().unwrap().remove("turn_id");
    let mut sessions = BTreeMap::new();
    apply_hook(&mut sessions, &event);
    assert_eq!(sessions["s"].status, AgentStatus::Waiting);
}

#[test]
fn discovers_latest_index_title_without_changing_activity() {
    let root = tempdir().unwrap();
    let ctx = AppContext::for_test(root.path(), &root.path().join("agent-berth"));
    std::fs::create_dir_all(&ctx.codex_home).unwrap();
    std::fs::write(
        ctx.codex_home.join("session_index.jsonl"),
        concat!(
            "{\"id\":\"s\",\"thread_name\":\"Original title\"}\n",
            "invalid json\n",
            "{\"id\":\"untracked\",\"thread_name\":\"Other session\"}\n",
            "{\"id\":\"s\",\"thread_name\":\"Renamed session\"}\n",
            "{\"id\":\"s\",\"thread_name\":42}\n",
            "{\"id\":\"s\",\"thread_name\":\"\"}\n",
            "{\"id\":\"s\",\"thread_name\":",
        ),
    )
    .unwrap();
    let mut sessions = BTreeMap::from([(
        "s".into(),
        AgentSession {
            status: AgentStatus::Done,
            last_report_ms: 123,
            ..AgentSession::default()
        },
    )]);

    assert!(discover(&ctx, &mut sessions));
    assert_eq!(sessions.len(), 1);
    assert_eq!(sessions["s"].title.as_deref(), Some("Renamed session"));
    assert_eq!(sessions["s"].status, AgentStatus::Done);
    assert_eq!(sessions["s"].last_report_ms, 123);
    assert!(!discover(&ctx, &mut sessions));
}

#[test]
fn missing_index_preserves_hook_title() {
    let root = tempdir().unwrap();
    let ctx = AppContext::for_test(root.path(), &root.path().join("agent-berth"));
    let mut sessions = BTreeMap::from([(
        "s".into(),
        AgentSession {
            title: Some("Hook title".into()),
            ..AgentSession::default()
        },
    )]);

    assert!(!discover(&ctx, &mut sessions));
    assert_eq!(sessions["s"].title.as_deref(), Some("Hook title"));
}

#[test]
fn drops_sessions_moved_to_archived_sessions() {
    let root = tempdir().unwrap();
    let ctx = AppContext::for_test(root.path(), &root.path().join("agent-berth"));
    let dir = ctx.codex_home.join("archived_sessions");
    std::fs::create_dir_all(&dir).unwrap();
    std::fs::write(
        dir.join("rollout-2026-09-20T17-50-44-01a0be39-a601-7e20-bcb1-60122ec3c8c6.jsonl"),
        "{\"type\":\"session_meta\",\"payload\":{\"session_id\":\"01a0be39-a601-7e20-bcb1-60122ec3c8c6\"}}\n",
    )
    .unwrap();
    let mut sessions = BTreeMap::new();
    sessions.insert(
        "01a0be39-a601-7e20-bcb1-60122ec3c8c6".into(),
        AgentSession::default(),
    );
    sessions.insert("keep".into(), AgentSession::default());
    assert!(drop_archived(&ctx, &mut sessions));
    assert!(sessions.contains_key("keep"));
    assert!(!sessions.contains_key("01a0be39-a601-7e20-bcb1-60122ec3c8c6"));
}

#[test]
fn drops_desktop_sessions_without_session_files() {
    let root = tempdir().unwrap();
    let ctx = AppContext::for_test(root.path(), &root.path().join("agent-berth"));
    let mut sessions = BTreeMap::new();
    sessions.insert(
        "test-desktop".into(),
        AgentSession {
            source: Source::Desktop,
            status: AgentStatus::Running,
            ..AgentSession::default()
        },
    );
    sessions.insert(
        "cli-keep".into(),
        AgentSession {
            source: Source::Cli,
            status: AgentStatus::Running,
            ..AgentSession::default()
        },
    );
    assert!(drop_archived(&ctx, &mut sessions));
    assert!(sessions.contains_key("cli-keep"));
    assert!(!sessions.contains_key("test-desktop"));
}