- Config extensions: retry backoff, truncation threshold, session persistence - LLM retry with exponential backoff + jitter on transient errors (5xx, connection) - Conversation truncation: drops oldest messages preserving first user + recent N - Session persistence: auto-save/restore with atomic writes, cleanup of old files - Graceful shutdown: SIGTERM handler, cancel() on AgentLoop, save-on-exit - Partial message recovery on mid-stream interruption - New slash commands: /save, /session - 18 new tests (5 retry, 5 truncation, 4 session, 4 integration workflows) - README.md and docs/tools.md documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
123 lines
4.2 KiB
Python
123 lines
4.2 KiB
Python
"""Unit tests for session persistence."""
|
|
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.agent.context import SessionContext
|
|
from app.models.config import AgentConfig, AppConfig, LLMConfig, SessionConfig
|
|
from app.services.session import SessionManager
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_workspace(tmp_path: Path) -> Path:
|
|
return tmp_path / "workspace"
|
|
|
|
|
|
@pytest.fixture
|
|
def session_config(tmp_workspace: Path) -> SessionConfig:
|
|
return SessionConfig(
|
|
session_dir=tmp_workspace / ".sneakycode" / "sessions",
|
|
auto_save=True,
|
|
max_session_age_hours=72,
|
|
offer_resume=True,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def config(tmp_workspace: Path) -> AppConfig:
|
|
return AppConfig(
|
|
llm=LLMConfig(model="test-model", endpoint="http://localhost:11434"),
|
|
agent=AgentConfig(workspace_root=tmp_workspace),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def ctx(config: AppConfig) -> SessionContext:
|
|
return SessionContext(config)
|
|
|
|
|
|
@pytest.fixture
|
|
def session_mgr(session_config: SessionConfig, tmp_workspace: Path) -> SessionManager:
|
|
tmp_workspace.mkdir(parents=True, exist_ok=True)
|
|
return SessionManager(session_config, tmp_workspace, "test-model")
|
|
|
|
|
|
class TestSessionPersistence:
|
|
def test_save_creates_file(self, session_mgr: SessionManager, ctx: SessionContext, session_config: SessionConfig) -> None:
|
|
"""Saving a session creates a JSON file in the session directory."""
|
|
ctx.add_message("user", "Hello")
|
|
ctx.add_message("assistant", "Hi there!")
|
|
|
|
path = session_mgr.save(ctx)
|
|
|
|
assert path.exists()
|
|
assert path.suffix == ".json"
|
|
|
|
data = json.loads(path.read_text())
|
|
assert data["model"] == "test-model"
|
|
assert len(data["messages"]) == 2
|
|
|
|
def test_load_latest_returns_newest(self, session_mgr: SessionManager, ctx: SessionContext, session_config: SessionConfig) -> None:
|
|
"""load_latest returns the most recently modified session."""
|
|
ctx.add_message("user", "First session")
|
|
session_mgr.save(ctx)
|
|
|
|
# Create a second session manager (simulates a new startup)
|
|
mgr2 = SessionManager(session_config, session_mgr._workspace_root, "test-model")
|
|
ctx.add_message("assistant", "Second response")
|
|
path2 = mgr2.save(ctx)
|
|
|
|
loaded = mgr2.load_latest()
|
|
assert loaded is not None
|
|
assert loaded.session_id == mgr2._session_id
|
|
assert len(loaded.messages) == 2
|
|
|
|
def test_restore_populates_context(self, session_mgr: SessionManager, ctx: SessionContext, config: AppConfig) -> None:
|
|
"""Restoring a session populates the context with saved messages."""
|
|
ctx.add_message("user", "Hello")
|
|
ctx.add_message("assistant", "World")
|
|
session_mgr.save(ctx)
|
|
|
|
# Load and restore into a fresh context
|
|
fresh_ctx = SessionContext(config)
|
|
loaded = session_mgr.load_latest()
|
|
assert loaded is not None
|
|
|
|
session_mgr.restore(loaded, fresh_ctx)
|
|
|
|
history = fresh_ctx.get_history()
|
|
assert len(history) == 2
|
|
assert history[0].role == "user"
|
|
assert history[0].content == "Hello"
|
|
assert history[1].role == "assistant"
|
|
assert history[1].content == "World"
|
|
|
|
def test_cleanup_removes_old_files(self, session_config: SessionConfig, tmp_workspace: Path) -> None:
|
|
"""cleanup_old deletes files older than max_session_age_hours."""
|
|
tmp_workspace.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create session config with very short max age
|
|
short_config = SessionConfig(
|
|
session_dir=session_config.session_dir,
|
|
max_session_age_hours=0, # 0 hours = everything is old
|
|
)
|
|
mgr = SessionManager(short_config, tmp_workspace, "test-model")
|
|
|
|
# Create a session file manually with old timestamp
|
|
session_dir = tmp_workspace / short_config.session_dir
|
|
session_dir.mkdir(parents=True, exist_ok=True)
|
|
old_file = session_dir / "old_session.json"
|
|
old_file.write_text('{"version": 1}')
|
|
|
|
# Set mtime to the past
|
|
import os
|
|
old_time = time.time() - 3600 # 1 hour ago
|
|
os.utime(old_file, (old_time, old_time))
|
|
|
|
deleted = mgr.cleanup_old()
|
|
assert deleted == 1
|
|
assert not old_file.exists()
|