test(server): add unit tests for conversation mtime tracking

Comprehensive test coverage for _get_conversation_mtime() and its
integration with _collect_sessions().

Test cases:
- Claude sessions: file exists, file missing, OSError on stat,
  missing project_dir, missing session_id
- Codex sessions: transcript_path exists, transcript_path missing,
  discovery fallback, discovery returns None, OSError on stat
- Edge cases: unknown agent type, missing agent key
- Integration: mtime included when file exists, omitted when missing
- Change detection: mtime changes trigger payload hash changes

Uses mock patching to isolate file system interactions and test
error handling paths without requiring actual conversation files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 15:23:42 -05:00
parent dcbaf12f07
commit 0d15787c7a

View File

@@ -0,0 +1,357 @@
"""Tests for conversation_mtime_ns feature in state.py.
This feature enables real-time dashboard updates by tracking the conversation
file's modification time, which changes on every write (tool call, message, etc.),
rather than relying solely on hook events which only fire at specific moments.
"""
import json
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from amc_server.mixins.state import StateMixin
from amc_server.mixins.parsing import SessionParsingMixin
from amc_server.mixins.discovery import SessionDiscoveryMixin
class CombinedMixin(StateMixin, SessionParsingMixin, SessionDiscoveryMixin):
"""Combined mixin for testing - mirrors AMCHandler's inheritance."""
pass
class TestGetConversationMtime(unittest.TestCase):
"""Tests for _get_conversation_mtime method."""
def setUp(self):
self.handler = CombinedMixin()
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_claude_session_with_existing_file(self):
"""When conversation file exists, returns its mtime_ns."""
# Create a temp conversation file
conv_file = Path(self.temp_dir) / "test-session.jsonl"
conv_file.write_text('{"type": "user"}\n')
expected_mtime = conv_file.stat().st_mtime_ns
session_data = {
"agent": "claude",
"session_id": "test-session",
"project_dir": "/some/project",
}
with patch.object(
self.handler, "_get_claude_conversation_file", return_value=conv_file
):
result = self.handler._get_conversation_mtime(session_data)
self.assertEqual(result, expected_mtime)
def test_claude_session_file_not_found(self):
"""When _get_claude_conversation_file returns None, returns None."""
session_data = {
"agent": "claude",
"session_id": "nonexistent",
"project_dir": "/some/project",
}
with patch.object(
self.handler, "_get_claude_conversation_file", return_value=None
):
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_claude_session_oserror_on_stat(self):
"""When stat() raises OSError, returns None gracefully."""
mock_file = MagicMock()
mock_file.stat.side_effect = OSError("Permission denied")
session_data = {
"agent": "claude",
"session_id": "test-session",
"project_dir": "/some/project",
}
with patch.object(
self.handler, "_get_claude_conversation_file", return_value=mock_file
):
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_claude_session_missing_project_dir(self):
"""When project_dir is empty, _get_claude_conversation_file returns None."""
session_data = {
"agent": "claude",
"session_id": "test-session",
"project_dir": "",
}
# Real method will return None for empty project_dir
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_claude_session_missing_session_id(self):
"""When session_id is empty, returns None."""
session_data = {
"agent": "claude",
"session_id": "",
"project_dir": "/some/project",
}
# _get_claude_conversation_file needs both session_id and project_dir
with patch.object(
self.handler, "_get_claude_conversation_file", return_value=None
):
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_codex_session_with_transcript_path(self):
"""When transcript_path is provided and exists, returns its mtime_ns."""
transcript_file = Path(self.temp_dir) / "codex-transcript.jsonl"
transcript_file.write_text('{"type": "response_item"}\n')
expected_mtime = transcript_file.stat().st_mtime_ns
session_data = {
"agent": "codex",
"session_id": "codex-123",
"transcript_path": str(transcript_file),
}
result = self.handler._get_conversation_mtime(session_data)
self.assertEqual(result, expected_mtime)
def test_codex_session_transcript_path_missing_file(self):
"""When transcript_path points to nonexistent file, falls back to discovery."""
session_data = {
"agent": "codex",
"session_id": "codex-123",
"transcript_path": "/nonexistent/path.jsonl",
}
# Mock the discovery fallback
with patch.object(
self.handler, "_find_codex_transcript_file", return_value=None
):
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_codex_session_discovery_fallback(self):
"""When transcript_path not provided, uses _find_codex_transcript_file."""
transcript_file = Path(self.temp_dir) / "discovered-transcript.jsonl"
transcript_file.write_text('{"type": "response_item"}\n')
expected_mtime = transcript_file.stat().st_mtime_ns
session_data = {
"agent": "codex",
"session_id": "codex-456",
# No transcript_path
}
with patch.object(
self.handler, "_find_codex_transcript_file", return_value=transcript_file
):
result = self.handler._get_conversation_mtime(session_data)
self.assertEqual(result, expected_mtime)
def test_codex_session_discovery_returns_none(self):
"""When discovery finds nothing, returns None."""
session_data = {
"agent": "codex",
"session_id": "codex-789",
}
with patch.object(
self.handler, "_find_codex_transcript_file", return_value=None
):
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_codex_session_oserror_on_transcript_stat(self):
"""When stat() on discovered transcript raises OSError, returns None."""
mock_file = MagicMock()
mock_file.stat.side_effect = OSError("I/O error")
session_data = {
"agent": "codex",
"session_id": "codex-err",
}
with patch.object(
self.handler, "_find_codex_transcript_file", return_value=mock_file
):
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_unknown_agent_returns_none(self):
"""When agent is neither 'claude' nor 'codex', returns None."""
session_data = {
"agent": "unknown_agent",
"session_id": "test-123",
"project_dir": "/some/project",
}
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_missing_agent_returns_none(self):
"""When agent key is missing, returns None."""
session_data = {
"session_id": "test-123",
"project_dir": "/some/project",
}
result = self.handler._get_conversation_mtime(session_data)
self.assertIsNone(result)
def test_mtime_changes_on_file_modification(self):
"""Verify mtime actually changes when file is modified."""
conv_file = Path(self.temp_dir) / "changing-file.jsonl"
conv_file.write_text('{"type": "user"}\n')
mtime_1 = conv_file.stat().st_mtime_ns
# Small delay to ensure filesystem mtime granularity is captured
time.sleep(0.01)
conv_file.write_text('{"type": "user"}\n{"type": "assistant"}\n')
mtime_2 = conv_file.stat().st_mtime_ns
session_data = {
"agent": "claude",
"session_id": "test-session",
"project_dir": "/some/project",
}
with patch.object(
self.handler, "_get_claude_conversation_file", return_value=conv_file
):
result = self.handler._get_conversation_mtime(session_data)
self.assertEqual(result, mtime_2)
self.assertNotEqual(mtime_1, mtime_2)
class TestCollectSessionsIntegration(unittest.TestCase):
"""Integration tests verifying conversation_mtime_ns is included in session data."""
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.sessions_dir = Path(self.temp_dir) / "sessions"
self.sessions_dir.mkdir()
def tearDown(self):
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_collect_sessions_includes_mtime_when_available(self):
"""_collect_sessions adds conversation_mtime_ns when file exists."""
handler = CombinedMixin()
# Create a session file
session_file = self.sessions_dir / "test-session.json"
session_data = {
"session_id": "test-session",
"agent": "claude",
"project_dir": "/test/project",
"status": "active",
"last_event_at": "2024-01-01T00:00:00Z",
}
session_file.write_text(json.dumps(session_data))
# Create a conversation file
conv_file = Path(self.temp_dir) / "conversation.jsonl"
conv_file.write_text('{"type": "user"}\n')
expected_mtime = conv_file.stat().st_mtime_ns
with patch("amc_server.mixins.state.SESSIONS_DIR", self.sessions_dir), \
patch("amc_server.mixins.state.EVENTS_DIR", Path(self.temp_dir) / "events"), \
patch.object(handler, "_discover_active_codex_sessions"), \
patch.object(handler, "_get_active_zellij_sessions", return_value=None), \
patch.object(handler, "_get_context_usage_for_session", return_value=None), \
patch.object(handler, "_get_claude_conversation_file", return_value=conv_file):
sessions = handler._collect_sessions()
self.assertEqual(len(sessions), 1)
self.assertEqual(sessions[0]["session_id"], "test-session")
self.assertEqual(sessions[0]["conversation_mtime_ns"], expected_mtime)
def test_collect_sessions_omits_mtime_when_file_missing(self):
"""_collect_sessions does not add conversation_mtime_ns when file doesn't exist."""
handler = CombinedMixin()
session_file = self.sessions_dir / "no-conv-session.json"
session_data = {
"session_id": "no-conv-session",
"agent": "claude",
"project_dir": "/test/project",
"status": "active",
"last_event_at": "2024-01-01T00:00:00Z",
}
session_file.write_text(json.dumps(session_data))
with patch("amc_server.mixins.state.SESSIONS_DIR", self.sessions_dir), \
patch("amc_server.mixins.state.EVENTS_DIR", Path(self.temp_dir) / "events"), \
patch.object(handler, "_discover_active_codex_sessions"), \
patch.object(handler, "_get_active_zellij_sessions", return_value=None), \
patch.object(handler, "_get_context_usage_for_session", return_value=None), \
patch.object(handler, "_get_claude_conversation_file", return_value=None):
sessions = handler._collect_sessions()
self.assertEqual(len(sessions), 1)
self.assertNotIn("conversation_mtime_ns", sessions[0])
class TestDashboardChangeDetection(unittest.TestCase):
"""Tests verifying the dashboard uses mtime for change detection."""
def test_mtime_triggers_state_hash_change(self):
"""When conversation_mtime_ns changes, payload hash should change."""
# This is implicitly tested by the SSE mechanism:
# state.py builds payload with conversation_mtime_ns
# _serve_stream hashes payload and sends on change
# Simulate two state payloads with different mtimes
payload_1 = {
"sessions": [{"session_id": "s1", "conversation_mtime_ns": 1000}],
"server_time": "2024-01-01T00:00:00Z",
}
payload_2 = {
"sessions": [{"session_id": "s1", "conversation_mtime_ns": 2000}],
"server_time": "2024-01-01T00:00:00Z",
}
import hashlib
hash_1 = hashlib.sha1(json.dumps(payload_1).encode()).hexdigest()
hash_2 = hashlib.sha1(json.dumps(payload_2).encode()).hexdigest()
self.assertNotEqual(hash_1, hash_2)
def test_same_mtime_same_hash(self):
"""When mtime hasn't changed, hash should be stable."""
payload = {
"sessions": [{"session_id": "s1", "conversation_mtime_ns": 1000}],
"server_time": "2024-01-01T00:00:00Z",
}
import hashlib
hash_1 = hashlib.sha1(json.dumps(payload).encode()).hexdigest()
hash_2 = hashlib.sha1(json.dumps(payload).encode()).hexdigest()
self.assertEqual(hash_1, hash_2)
if __name__ == "__main__":
unittest.main()