From 4c5e719be490514d151c4ef00151f9b947c03257 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Wed, 13 May 2026 08:58:58 -0400 Subject: [PATCH] feat(ai-server): Add AIProxyClient queue communication layer --- pyproject.toml | 23 ++++---- src/ai_client_proxy.py | 98 +++++++++++++++++++++++++++++++++++ tests/conftest.py | 13 +++-- tests/test_ai_client_proxy.py | 42 +++++++++++++++ 4 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 src/ai_client_proxy.py create mode 100644 tests/test_ai_client_proxy.py diff --git a/pyproject.toml b/pyproject.toml index 80bfe37..f20c49a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,25 +4,27 @@ name = "manual_slop" version = "0.1.0" requires-python = ">=3.11" dependencies = [ - # "dearpygui", "imgui-bundle", - "google-genai", - "anthropic", - "openai", + "pyopengl>=3.1.10", + "tomli-w", - "psutil>=7.2.2", - "fastapi", - "uvicorn", "tree-sitter>=0.25.2", "tree-sitter-python>=0.25.0", "tree-sitter-c>=0.23.2", "tree-sitter-cpp>=0.23.2", + + "psutil>=7.2.2", + "fastapi", "mcp>=1.0.0", "pytest-timeout>=2.4.0", - "pyopengl>=3.1.10", + "uvicorn", + + "anthropic", + "google-genai", + "openai", + "chromadb>=1.5.8", "sentence-transformers>=5.4.1", - # "python-defer" ] [dependency-groups] @@ -72,6 +74,3 @@ ignore = [ [tool.ruff.lint.mccabe] max-complexity = 5 - - - diff --git a/src/ai_client_proxy.py b/src/ai_client_proxy.py new file mode 100644 index 0000000..820b615 --- /dev/null +++ b/src/ai_client_proxy.py @@ -0,0 +1,98 @@ +import json +import uuid +import threading +import subprocess +import sys +import os +from typing import Any, Optional + + +class AIProxyClient: + def __init__(self): + self._process: Optional[subprocess.Popen] = None + self._status: str = "disconnected" + self._pending: dict[str, Any] = {} + self._reader_thread: Optional[threading.Thread] = None + + @property + def status(self) -> str: + return self._status + + def start_server(self): + if self._process is not None: + return + self._process = subprocess.Popen( + [sys.executable, "-m", "src.ai_server"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self._status = "init" + self._reader_thread = threading.Thread(target=self._read_loop, daemon=True) + self._reader_thread.start() + + def _read_loop(self): + if self._process is None or self._process.stdout is None: + return + try: + for line in self._process.stdout: + line = line.strip() + if not line: + continue + try: + response = json.loads(line) + rid = response.get("id") + if rid in self._pending: + self._pending[rid] = response + event_key = rid + "_event" + if event_key in self._pending: + self._pending[event_key].set() + except json.JSONDecodeError: + pass + except Exception: + pass + + def send_command(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + if self._process is None or self._process.stdin is None: + return {"error": "server not started"} + + request_id = str(uuid.uuid4()) + event = threading.Event() + self._pending[request_id] = None + self._pending[request_id + "_event"] = event + + command = {"id": request_id, "method": method, "params": params} + try: + self._process.stdin.write(json.dumps(command) + "\n") + self._process.stdin.flush() + except Exception as e: + self._pending.pop(request_id, None) + self._pending.pop(request_id + "_event", None) + return {"error": str(e)} + + if not event.wait(timeout=60): + self._pending.pop(request_id, None) + self._pending.pop(request_id + "_event", None) + return {"error": "timeout"} + + result = self._pending.pop(request_id, {"error": "response not found"}) + self._pending.pop(request_id + "_event", None) + return result if result else {"error": "no response"} + + def stop(self): + if self._process: + try: + self._process.stdin.close() + self._process.stdout.close() + self._process.stderr.close() + self._process.terminate() + self._process.wait(timeout=5) + except Exception: + try: + self._process.kill() + except Exception: + pass + self._process = None + self._status = "disconnected" + self._pending.clear() \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 5afb6b4..aa19741 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,10 +11,17 @@ from pathlib import Path from typing import Generator, Any from unittest.mock import patch -# Ensure project root is in path for imports -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +thirdparty_dir = os.path.join(os.path.dirname(__file__), "..", "thirdparty") +if thirdparty_dir not in sys.path: + sys.path.insert(0, thirdparty_dir) + +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from defer.sugar import install +install() -# Import the App class after patching if necessary, but here we just need the type hint from src.gui_2 import App class VerificationLogger: diff --git a/tests/test_ai_client_proxy.py b/tests/test_ai_client_proxy.py new file mode 100644 index 0000000..00800c3 --- /dev/null +++ b/tests/test_ai_client_proxy.py @@ -0,0 +1,42 @@ +import pytest +import threading +from unittest.mock import MagicMock, patch +from src.ai_client_proxy import AIProxyClient + + +def test_proxy_initialization(): + proxy = AIProxyClient() + assert proxy._status == "disconnected" + assert proxy._pending == {} + + +def test_proxy_status_property(): + proxy = AIProxyClient() + assert proxy.status in ("disconnected", "init", "ready", "busy", "error") + + +def test_proxy_status_reflects_internal_state(): + proxy = AIProxyClient() + assert proxy.status == "disconnected" + proxy._status = "ready" + assert proxy.status == "ready" + + +def test_send_command_without_server_returns_error(): + proxy = AIProxyClient() + proxy._status = "ready" + result = proxy.send_command("list_models", {"provider": "gemini"}) + assert "error" in result + + +def test_pending_dict_structure(): + proxy = AIProxyClient() + assert isinstance(proxy._pending, dict) + assert len(proxy._pending) == 0 + + +def test_stop_when_not_started(): + proxy = AIProxyClient() + proxy.stop() + assert proxy._status == "disconnected" + assert proxy._process is None \ No newline at end of file