Add Phase 7: polish and hardening — retry, truncation, sessions, shutdown

- 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>
This commit is contained in:
2026-03-11 10:20:16 -05:00
parent 82846d6236
commit 76ba490aa2
16 changed files with 1550 additions and 12 deletions

View File

@@ -62,6 +62,7 @@ def handler() -> MagicMock:
mock.usage = None
mock.had_reasoning_only = False
mock.reset = MagicMock()
mock.get_partial_message = MagicMock(return_value=None)
return mock

125
tests/unit/test_retry.py Normal file
View File

@@ -0,0 +1,125 @@
"""Unit tests for LLM retry with exponential backoff."""
from unittest.mock import AsyncMock, patch
import pytest
from app.models.config import LLMConfig
from app.models.message import Message
from app.services.llm import LLMClient, LLMConnectionError, LLMResponseError
@pytest.fixture
def llm_config() -> LLMConfig:
return LLMConfig(
model="test-model",
endpoint="http://localhost:11434",
max_retries=3,
retry_backoff_base=0.01,
retry_backoff_max=0.05,
)
@pytest.fixture
def client(llm_config: LLMConfig) -> LLMClient:
return LLMClient(llm_config)
@pytest.fixture
def messages() -> list[Message]:
return [Message(role="user", content="Hello")]
class TestRetry:
@pytest.mark.asyncio
async def test_succeeds_without_retry(self, client: LLMClient, messages: list[Message]) -> None:
"""Successful stream doesn't retry."""
call_count = 0
async def fake_stream(*args, **kwargs):
nonlocal call_count
call_count += 1
yield {"choices": [{"delta": {"content": "Hi"}}]}
client.stream_chat = fake_stream # type: ignore[assignment]
collected = []
async for chunk in client.stream_chat_with_retry(messages):
collected.append(chunk)
assert len(collected) == 1
assert call_count == 1
@pytest.mark.asyncio
async def test_retries_on_connection_error(self, client: LLMClient, messages: list[Message]) -> None:
"""Retries on LLMConnectionError, then succeeds."""
call_count = 0
async def flaky_stream(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count < 3:
raise LLMConnectionError("Connection refused")
yield {"choices": [{"delta": {"content": "OK"}}]}
client.stream_chat = flaky_stream # type: ignore[assignment]
with patch("app.services.llm.asyncio.sleep", new_callable=AsyncMock):
collected = []
async for chunk in client.stream_chat_with_retry(messages):
collected.append(chunk)
assert len(collected) == 1
assert call_count == 3
@pytest.mark.asyncio
async def test_retries_on_5xx(self, client: LLMClient, messages: list[Message]) -> None:
"""Retries on 5xx LLMResponseError."""
call_count = 0
async def server_error_stream(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count < 2:
raise LLMResponseError("Internal Server Error", status_code=500)
yield {"choices": [{"delta": {"content": "OK"}}]}
client.stream_chat = server_error_stream # type: ignore[assignment]
with patch("app.services.llm.asyncio.sleep", new_callable=AsyncMock):
collected = []
async for chunk in client.stream_chat_with_retry(messages):
collected.append(chunk)
assert len(collected) == 1
assert call_count == 2
@pytest.mark.asyncio
async def test_no_retry_on_4xx(self, client: LLMClient, messages: list[Message]) -> None:
"""Does NOT retry on 4xx errors — raises immediately."""
async def bad_request_stream(*args, **kwargs):
raise LLMResponseError("Bad Request", status_code=400)
yield # pragma: no cover — make this an async generator
client.stream_chat = bad_request_stream # type: ignore[assignment]
with pytest.raises(LLMResponseError, match="Bad Request"):
async for _ in client.stream_chat_with_retry(messages):
pass # pragma: no cover
@pytest.mark.asyncio
async def test_respects_max_retries(self, client: LLMClient, messages: list[Message]) -> None:
"""After exhausting retries, re-raises the last exception."""
async def always_fail(*args, **kwargs):
raise LLMConnectionError("Down forever")
yield # pragma: no cover
client.stream_chat = always_fail # type: ignore[assignment]
with patch("app.services.llm.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
with pytest.raises(LLMConnectionError, match="Down forever"):
async for _ in client.stream_chat_with_retry(messages):
pass # pragma: no cover
# Should have slept max_retries times (3 retries after initial attempt)
assert mock_sleep.call_count == 3

122
tests/unit/test_session.py Normal file
View File

@@ -0,0 +1,122 @@
"""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()

View File

@@ -0,0 +1,128 @@
"""Unit tests for conversation truncation logic."""
from pathlib import Path
import pytest
from app.agent.context import SessionContext
from app.models.config import AgentConfig, AppConfig, LLMConfig
@pytest.fixture
def config() -> AppConfig:
return AppConfig(
llm=LLMConfig(model="test-model", endpoint="http://localhost:11434"),
agent=AgentConfig(
max_conversation_tokens=200,
truncation_keep_recent=3,
truncation_threshold=0.85,
workspace_root=Path("/tmp/test"),
),
)
@pytest.fixture
def ctx(config: AppConfig) -> SessionContext:
return SessionContext(config)
class TestTruncation:
def test_no_truncation_under_threshold(self, ctx: SessionContext) -> None:
"""No messages dropped when under threshold."""
ctx.add_message("user", "Hello")
ctx.add_message("assistant", "Hi there!")
dropped = ctx.truncate_history()
assert dropped == 0
assert ctx.message_count == 2
def test_drops_oldest_messages(self, ctx: SessionContext) -> None:
"""Drops middle messages when over budget."""
# Fill with enough content to exceed the small 200-token budget
ctx.add_message("user", "First message " * 20)
for i in range(8):
ctx.add_message("assistant", f"Response {i} " * 15)
ctx.add_message("user", f"Follow-up {i} " * 15)
# Force the token counter to report over budget
from app.utils.token_counter import TokenUsage
ctx.token_counter.count_usage(TokenUsage(total_tokens=200))
original_count = len(ctx.get_history())
dropped = ctx.truncate_history()
assert dropped > 0
assert len(ctx.get_history()) < original_count
def test_preserves_recent_messages(self, ctx: SessionContext) -> None:
"""The most recent N messages are always preserved."""
ctx.add_message("user", "First message " * 20)
for i in range(10):
ctx.add_message("assistant", f"Response {i} " * 10)
ctx.add_message("user", f"Follow-up {i} " * 10)
from app.utils.token_counter import TokenUsage
ctx.token_counter.count_usage(TokenUsage(total_tokens=200))
history_before = ctx.get_history()
recent_before = history_before[-3:] # keep_recent=3
ctx.truncate_history()
history_after = ctx.get_history()
recent_after = history_after[-3:]
# Recent messages should be preserved
for before, after in zip(recent_before, recent_after):
assert before.content == after.content
def test_preserves_first_user_message(self, ctx: SessionContext) -> None:
"""First user message is always kept."""
first_content = "This is the very first user message"
ctx.add_message("user", first_content)
for i in range(10):
ctx.add_message("assistant", f"Response {i} " * 10)
ctx.add_message("user", f"Follow-up {i} " * 10)
from app.utils.token_counter import TokenUsage
ctx.token_counter.count_usage(TokenUsage(total_tokens=200))
ctx.truncate_history()
history = ctx.get_history()
assert history[0].role == "user"
assert history[0].content == first_content
def test_orphaned_tool_messages_cleaned(self, ctx: SessionContext) -> None:
"""Tool messages without matching tool_call are cleaned up."""
from app.models.tool_call import ToolCall, ToolCallFunction
ctx.add_message("user", "Do something " * 20)
# Assistant with tool call
ctx.add_message(
"assistant",
None,
tool_calls=[ToolCall(id="tc_1", type="function", function=ToolCallFunction(name="read_file", arguments='{"path": "x"}'))],
)
# Tool result for tc_1
ctx.add_message("tool", "file contents " * 20, tool_call_id="tc_1", name="read_file")
# More padding to push over budget
for i in range(8):
ctx.add_message("assistant", f"Analysis {i} " * 15)
ctx.add_message("user", f"Next {i} " * 15)
from app.utils.token_counter import TokenUsage
ctx.token_counter.count_usage(TokenUsage(total_tokens=200))
ctx.truncate_history()
history = ctx.get_history()
# If the assistant message with tc_1 was dropped, the orphaned tool message should also be gone
has_tc1_assistant = any(
m.role == "assistant" and m.tool_calls and any(tc.id == "tc_1" for tc in m.tool_calls)
for m in history
)
has_tc1_tool = any(m.role == "tool" and m.tool_call_id == "tc_1" for m in history)
# Either both exist or neither exists
assert has_tc1_assistant == has_tc1_tool