- 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>
151 lines
4.3 KiB
Python
151 lines
4.3 KiB
Python
"""Shared fixtures for integration tests."""
|
|
|
|
import json
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from app.models.config import AgentConfig, AppConfig, DisplayConfig, LLMConfig, PermissionsConfig, SessionConfig
|
|
from app.models.message import Message
|
|
from app.services.llm import LLMClient
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_workspace(tmp_path: Path) -> Path:
|
|
"""Create a temporary workspace directory with a sample file."""
|
|
ws = tmp_path / "workspace"
|
|
ws.mkdir()
|
|
(ws / "hello.txt").write_text("Hello, world!")
|
|
return ws
|
|
|
|
|
|
@pytest.fixture
|
|
def test_config(tmp_workspace: Path) -> AppConfig:
|
|
"""AppConfig suitable for integration tests."""
|
|
return AppConfig(
|
|
llm=LLMConfig(
|
|
model="test-model",
|
|
endpoint="http://localhost:11434",
|
|
max_retries=2,
|
|
retry_backoff_base=0.01,
|
|
retry_backoff_max=0.02,
|
|
),
|
|
agent=AgentConfig(
|
|
max_iterations=10,
|
|
max_conversation_tokens=32000,
|
|
workspace_root=tmp_workspace,
|
|
truncation_keep_recent=4,
|
|
truncation_threshold=0.85,
|
|
),
|
|
permissions=PermissionsConfig(
|
|
auto_approve=["read_file", "list_dir", "grep_files", "find_files", "finish"],
|
|
),
|
|
display=DisplayConfig(
|
|
show_tool_calls=False,
|
|
show_token_usage=False,
|
|
stream_output=False,
|
|
),
|
|
session=SessionConfig(
|
|
session_dir=tmp_workspace / ".sneakycode" / "sessions",
|
|
),
|
|
)
|
|
|
|
|
|
def make_text_chunks(content: str) -> list[dict[str, Any]]:
|
|
"""Create SSE chunk dicts for a plain text response."""
|
|
chunks = []
|
|
for char in content:
|
|
chunks.append({
|
|
"choices": [{"delta": {"content": char}, "index": 0}]
|
|
})
|
|
chunks.append({
|
|
"choices": [{"delta": {}, "finish_reason": "stop", "index": 0}],
|
|
"usage": {"prompt_tokens": 10, "completion_tokens": len(content), "total_tokens": 10 + len(content)},
|
|
})
|
|
return chunks
|
|
|
|
|
|
def make_tool_call_chunks(name: str, args: dict[str, Any], tc_id: str = "call_001") -> list[dict[str, Any]]:
|
|
"""Create SSE chunk dicts for a tool call response."""
|
|
args_str = json.dumps(args)
|
|
chunks = [
|
|
{
|
|
"choices": [{
|
|
"delta": {
|
|
"tool_calls": [{
|
|
"index": 0,
|
|
"id": tc_id,
|
|
"function": {"name": name, "arguments": ""},
|
|
}]
|
|
},
|
|
"index": 0,
|
|
}]
|
|
},
|
|
{
|
|
"choices": [{
|
|
"delta": {
|
|
"tool_calls": [{
|
|
"index": 0,
|
|
"function": {"arguments": args_str},
|
|
}]
|
|
},
|
|
"index": 0,
|
|
}]
|
|
},
|
|
{
|
|
"choices": [{"delta": {}, "finish_reason": "tool_calls", "index": 0}],
|
|
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
|
|
},
|
|
]
|
|
return chunks
|
|
|
|
|
|
class MockLLMClient:
|
|
"""LLM client that returns scripted SSE chunk sequences."""
|
|
|
|
def __init__(self, responses: list[list[dict[str, Any]]]) -> None:
|
|
self._responses = list(responses)
|
|
self._call_count = 0
|
|
|
|
async def stream_chat(
|
|
self,
|
|
messages: list[Message],
|
|
tools: list[dict[str, Any]] | None = None,
|
|
) -> AsyncIterator[dict]:
|
|
if self._call_count >= len(self._responses):
|
|
raise RuntimeError("MockLLMClient ran out of scripted responses")
|
|
chunks = self._responses[self._call_count]
|
|
self._call_count += 1
|
|
for chunk in chunks:
|
|
yield chunk
|
|
|
|
async def stream_chat_with_retry(
|
|
self,
|
|
messages: list[Message],
|
|
tools: list[dict[str, Any]] | None = None,
|
|
) -> AsyncIterator[dict]:
|
|
async for chunk in self.stream_chat(messages, tools=tools):
|
|
yield chunk
|
|
|
|
@property
|
|
def call_count(self) -> int:
|
|
return self._call_count
|
|
|
|
async def close(self) -> None:
|
|
pass
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_llm_client():
|
|
"""Factory fixture for creating MockLLMClient instances."""
|
|
return MockLLMClient
|