Files
amc/tests/test_state.py
teernisse baa712ba15 refactor(dashboard): change SpawnModal from overlay modal to dropdown
Position the spawn modal directly under the 'New Agent' button without a
blur overlay. Uses click-outside dismissal and absolute positioning.
Reduces visual disruption for quick agent spawning.
2026-02-26 17:15:22 -05:00

421 lines
17 KiB
Python

import json
import subprocess
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
import amc_server.mixins.state as state_mod
from amc_server.mixins.state import StateMixin
from amc_server.mixins.parsing import SessionParsingMixin
from amc_server.mixins.discovery import SessionDiscoveryMixin
class DummyStateHandler(StateMixin, SessionParsingMixin, SessionDiscoveryMixin):
pass
class TestGetActiveZellijSessions(unittest.TestCase):
"""Tests for _get_active_zellij_sessions edge cases."""
def setUp(self):
state_mod._zellij_cache["sessions"] = None
state_mod._zellij_cache["expires"] = 0
def test_parses_output_with_metadata(self):
handler = DummyStateHandler()
completed = subprocess.CompletedProcess(
args=[],
returncode=0,
stdout="infra [created 1h ago]\nwork\n",
stderr="",
)
with patch.object(state_mod, "ZELLIJ_BIN", "/opt/homebrew/bin/zellij"), \
patch("amc_server.mixins.state.subprocess.run", return_value=completed) as run_mock:
sessions = handler._get_active_zellij_sessions()
self.assertEqual(sessions, {"infra", "work"})
args = run_mock.call_args.args[0]
self.assertEqual(args, ["/opt/homebrew/bin/zellij", "list-sessions", "--no-formatting"])
def test_empty_output_returns_empty_set(self):
handler = DummyStateHandler()
completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
with patch("amc_server.mixins.state.subprocess.run", return_value=completed):
sessions = handler._get_active_zellij_sessions()
self.assertEqual(sessions, set())
def test_whitespace_only_lines_ignored(self):
handler = DummyStateHandler()
completed = subprocess.CompletedProcess(
args=[], returncode=0, stdout="session1\n \n\nsession2\n", stderr=""
)
with patch("amc_server.mixins.state.subprocess.run", return_value=completed):
sessions = handler._get_active_zellij_sessions()
self.assertEqual(sessions, {"session1", "session2"})
def test_nonzero_exit_returns_none(self):
handler = DummyStateHandler()
completed = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="error")
with patch("amc_server.mixins.state.subprocess.run", return_value=completed):
sessions = handler._get_active_zellij_sessions()
self.assertIsNone(sessions)
def test_timeout_returns_none(self):
handler = DummyStateHandler()
with patch("amc_server.mixins.state.subprocess.run",
side_effect=subprocess.TimeoutExpired("cmd", 2)):
sessions = handler._get_active_zellij_sessions()
self.assertIsNone(sessions)
def test_file_not_found_returns_none(self):
handler = DummyStateHandler()
with patch("amc_server.mixins.state.subprocess.run", side_effect=FileNotFoundError()):
sessions = handler._get_active_zellij_sessions()
self.assertIsNone(sessions)
def test_cache_used_when_fresh(self):
handler = DummyStateHandler()
state_mod._zellij_cache["sessions"] = {"cached"}
state_mod._zellij_cache["expires"] = time.time() + 100
with patch("amc_server.mixins.state.subprocess.run") as mock_run:
sessions = handler._get_active_zellij_sessions()
mock_run.assert_not_called()
self.assertEqual(sessions, {"cached"})
class TestIsSessionDead(unittest.TestCase):
"""Tests for _is_session_dead edge cases."""
def setUp(self):
self.handler = DummyStateHandler()
def test_starting_session_not_dead(self):
session = {"status": "starting", "agent": "claude", "zellij_session": "s"}
self.assertFalse(self.handler._is_session_dead(session, {"s"}, set()))
def test_claude_without_zellij_session_is_dead(self):
session = {"status": "active", "agent": "claude", "zellij_session": ""}
self.assertTrue(self.handler._is_session_dead(session, set(), set()))
def test_claude_with_missing_zellij_session_is_dead(self):
session = {"status": "active", "agent": "claude", "zellij_session": "deleted"}
active_zellij = {"other_session"}
self.assertTrue(self.handler._is_session_dead(session, active_zellij, set()))
def test_claude_with_active_zellij_session_not_dead(self):
session = {"status": "active", "agent": "claude", "zellij_session": "existing"}
active_zellij = {"existing", "other"}
self.assertFalse(self.handler._is_session_dead(session, active_zellij, set()))
def test_claude_unknown_zellij_status_assumes_alive(self):
# When we can't query zellij (None), assume alive to avoid false positives
session = {"status": "active", "agent": "claude", "zellij_session": "unknown"}
self.assertFalse(self.handler._is_session_dead(session, None, set()))
def test_codex_without_transcript_path_is_dead(self):
session = {"status": "active", "agent": "codex", "transcript_path": ""}
self.assertTrue(self.handler._is_session_dead(session, None, set()))
def test_codex_with_active_transcript_not_dead(self):
session = {"status": "active", "agent": "codex", "transcript_path": "/path/to/file.jsonl"}
active_files = {"/path/to/file.jsonl"}
self.assertFalse(self.handler._is_session_dead(session, None, active_files))
def test_codex_without_active_transcript_checks_lsof(self):
session = {"status": "active", "agent": "codex", "transcript_path": "/path/to/file.jsonl"}
# Simulate lsof finding the file open
with patch.object(self.handler, "_is_file_open", return_value=True):
result = self.handler._is_session_dead(session, None, set())
self.assertFalse(result)
# Simulate lsof not finding the file
with patch.object(self.handler, "_is_file_open", return_value=False):
result = self.handler._is_session_dead(session, None, set())
self.assertTrue(result)
def test_unknown_agent_assumes_alive(self):
session = {"status": "active", "agent": "unknown_agent"}
self.assertFalse(self.handler._is_session_dead(session, None, set()))
class TestIsFileOpen(unittest.TestCase):
"""Tests for _is_file_open edge cases."""
def setUp(self):
self.handler = DummyStateHandler()
def test_lsof_finds_pid_returns_true(self):
completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="12345\n", stderr="")
with patch("amc_server.mixins.state.subprocess.run", return_value=completed):
result = self.handler._is_file_open("/some/file.jsonl")
self.assertTrue(result)
def test_lsof_no_result_returns_false(self):
completed = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")
with patch("amc_server.mixins.state.subprocess.run", return_value=completed):
result = self.handler._is_file_open("/some/file.jsonl")
self.assertFalse(result)
def test_lsof_timeout_returns_false(self):
with patch("amc_server.mixins.state.subprocess.run",
side_effect=subprocess.TimeoutExpired("cmd", 2)):
result = self.handler._is_file_open("/some/file.jsonl")
self.assertFalse(result)
def test_lsof_not_found_returns_false(self):
with patch("amc_server.mixins.state.subprocess.run", side_effect=FileNotFoundError()):
result = self.handler._is_file_open("/some/file.jsonl")
self.assertFalse(result)
class TestCleanupStale(unittest.TestCase):
"""Tests for _cleanup_stale edge cases."""
def setUp(self):
self.handler = DummyStateHandler()
def test_removes_orphan_event_logs_older_than_24h(self):
with tempfile.TemporaryDirectory() as tmpdir:
events_dir = Path(tmpdir) / "events"
events_dir.mkdir()
sessions_dir = Path(tmpdir) / "sessions"
sessions_dir.mkdir()
# Create an orphan event log (no matching session)
orphan_log = events_dir / "orphan.jsonl"
orphan_log.write_text('{"event": "test"}\n')
# Set mtime to 25 hours ago
old_time = time.time() - (25 * 3600)
import os
os.utime(orphan_log, (old_time, old_time))
with patch.object(state_mod, "EVENTS_DIR", events_dir), \
patch.object(state_mod, "SESSIONS_DIR", sessions_dir):
self.handler._cleanup_stale([]) # No active sessions
self.assertFalse(orphan_log.exists())
def test_keeps_orphan_event_logs_younger_than_24h(self):
with tempfile.TemporaryDirectory() as tmpdir:
events_dir = Path(tmpdir) / "events"
events_dir.mkdir()
sessions_dir = Path(tmpdir) / "sessions"
sessions_dir.mkdir()
# Create a recent orphan event log
recent_log = events_dir / "recent.jsonl"
recent_log.write_text('{"event": "test"}\n')
with patch.object(state_mod, "EVENTS_DIR", events_dir), \
patch.object(state_mod, "SESSIONS_DIR", sessions_dir):
self.handler._cleanup_stale([])
self.assertTrue(recent_log.exists())
def test_keeps_event_logs_with_active_session(self):
with tempfile.TemporaryDirectory() as tmpdir:
events_dir = Path(tmpdir) / "events"
events_dir.mkdir()
sessions_dir = Path(tmpdir) / "sessions"
sessions_dir.mkdir()
# Create an old event log that HAS an active session
event_log = events_dir / "active-session.jsonl"
event_log.write_text('{"event": "test"}\n')
old_time = time.time() - (25 * 3600)
import os
os.utime(event_log, (old_time, old_time))
sessions = [{"session_id": "active-session"}]
with patch.object(state_mod, "EVENTS_DIR", events_dir), \
patch.object(state_mod, "SESSIONS_DIR", sessions_dir):
self.handler._cleanup_stale(sessions)
self.assertTrue(event_log.exists())
def test_removes_stale_starting_sessions(self):
with tempfile.TemporaryDirectory() as tmpdir:
events_dir = Path(tmpdir) / "events"
events_dir.mkdir()
sessions_dir = Path(tmpdir) / "sessions"
sessions_dir.mkdir()
# Create a stale "starting" session
stale_session = sessions_dir / "stale.json"
stale_session.write_text(json.dumps({"status": "starting"}))
# Set mtime to 2 hours ago (> 1 hour threshold)
old_time = time.time() - (2 * 3600)
import os
os.utime(stale_session, (old_time, old_time))
with patch.object(state_mod, "EVENTS_DIR", events_dir), \
patch.object(state_mod, "SESSIONS_DIR", sessions_dir):
self.handler._cleanup_stale([])
self.assertFalse(stale_session.exists())
def test_keeps_stale_active_sessions(self):
with tempfile.TemporaryDirectory() as tmpdir:
events_dir = Path(tmpdir) / "events"
events_dir.mkdir()
sessions_dir = Path(tmpdir) / "sessions"
sessions_dir.mkdir()
# Create an old "active" session (should NOT be deleted)
active_session = sessions_dir / "active.json"
active_session.write_text(json.dumps({"status": "active"}))
old_time = time.time() - (2 * 3600)
import os
os.utime(active_session, (old_time, old_time))
with patch.object(state_mod, "EVENTS_DIR", events_dir), \
patch.object(state_mod, "SESSIONS_DIR", sessions_dir):
self.handler._cleanup_stale([])
self.assertTrue(active_session.exists())
class TestCollectSessions(unittest.TestCase):
"""Tests for _collect_sessions edge cases."""
def setUp(self):
self.handler = DummyStateHandler()
def test_invalid_json_session_file_skipped(self):
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir) / "sessions"
sessions_dir.mkdir()
events_dir = Path(tmpdir) / "events"
events_dir.mkdir()
# Create an invalid JSON file
bad_file = sessions_dir / "bad.json"
bad_file.write_text("not json")
# Create a valid session
good_file = sessions_dir / "good.json"
good_file.write_text(json.dumps({
"session_id": "good",
"agent": "claude",
"status": "active",
"last_event_at": "2024-01-01T00:00:00Z",
}))
with patch.object(state_mod, "SESSIONS_DIR", sessions_dir), \
patch.object(state_mod, "EVENTS_DIR", events_dir), \
patch.object(self.handler, "_discover_active_codex_sessions"), \
patch.object(self.handler, "_get_active_zellij_sessions", return_value=None), \
patch.object(self.handler, "_get_active_transcript_files", return_value=set()), \
patch.object(self.handler, "_get_context_usage_for_session", return_value=None), \
patch.object(self.handler, "_get_conversation_mtime", return_value=None):
sessions = self.handler._collect_sessions()
# Should only get the good session
self.assertEqual(len(sessions), 1)
self.assertEqual(sessions[0]["session_id"], "good")
def test_non_dict_session_file_skipped(self):
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir) / "sessions"
sessions_dir.mkdir()
events_dir = Path(tmpdir) / "events"
events_dir.mkdir()
# Create a file with array instead of dict
array_file = sessions_dir / "array.json"
array_file.write_text("[1, 2, 3]")
with patch.object(state_mod, "SESSIONS_DIR", sessions_dir), \
patch.object(state_mod, "EVENTS_DIR", events_dir), \
patch.object(self.handler, "_discover_active_codex_sessions"), \
patch.object(self.handler, "_get_active_zellij_sessions", return_value=None), \
patch.object(self.handler, "_get_active_transcript_files", return_value=set()), \
patch.object(self.handler, "_get_context_usage_for_session", return_value=None), \
patch.object(self.handler, "_get_conversation_mtime", return_value=None):
sessions = self.handler._collect_sessions()
self.assertEqual(len(sessions), 0)
def test_orphan_starting_session_deleted(self):
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir) / "sessions"
sessions_dir.mkdir()
events_dir = Path(tmpdir) / "events"
events_dir.mkdir()
# Create a starting session with a non-existent zellij session
orphan_file = sessions_dir / "orphan.json"
orphan_file.write_text(json.dumps({
"session_id": "orphan",
"status": "starting",
"zellij_session": "deleted_session",
}))
active_zellij = {"other_session"} # orphan's session not in here
with patch.object(state_mod, "SESSIONS_DIR", sessions_dir), \
patch.object(state_mod, "EVENTS_DIR", events_dir), \
patch.object(self.handler, "_discover_active_codex_sessions"), \
patch.object(self.handler, "_get_active_zellij_sessions", return_value=active_zellij), \
patch.object(self.handler, "_get_active_transcript_files", return_value=set()), \
patch.object(self.handler, "_get_context_usage_for_session", return_value=None), \
patch.object(self.handler, "_get_conversation_mtime", return_value=None):
sessions = self.handler._collect_sessions()
# Orphan should be deleted and not in results
self.assertEqual(len(sessions), 0)
self.assertFalse(orphan_file.exists())
def test_sessions_sorted_by_id(self):
with tempfile.TemporaryDirectory() as tmpdir:
sessions_dir = Path(tmpdir) / "sessions"
sessions_dir.mkdir()
events_dir = Path(tmpdir) / "events"
events_dir.mkdir()
for sid in ["zebra", "alpha", "middle"]:
(sessions_dir / f"{sid}.json").write_text(json.dumps({
"session_id": sid,
"status": "active",
"last_event_at": "2024-01-01T00:00:00Z",
}))
with patch.object(state_mod, "SESSIONS_DIR", sessions_dir), \
patch.object(state_mod, "EVENTS_DIR", events_dir), \
patch.object(self.handler, "_discover_active_codex_sessions"), \
patch.object(self.handler, "_get_active_zellij_sessions", return_value=None), \
patch.object(self.handler, "_get_active_transcript_files", return_value=set()), \
patch.object(self.handler, "_get_context_usage_for_session", return_value=None), \
patch.object(self.handler, "_get_conversation_mtime", return_value=None):
sessions = self.handler._collect_sessions()
ids = [s["session_id"] for s in sessions]
self.assertEqual(ids, ["alpha", "middle", "zebra"])
if __name__ == "__main__":
unittest.main()