"""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