feat(ai-server): Add AIProxyClient queue communication layer

This commit is contained in:
2026-05-13 08:58:58 -04:00
parent 38270ffa16
commit 4c5e719be4
4 changed files with 161 additions and 15 deletions
+11 -12
View File
@@ -4,25 +4,27 @@ name = "manual_slop"
version = "0.1.0" version = "0.1.0"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
# "dearpygui",
"imgui-bundle", "imgui-bundle",
"google-genai", "pyopengl>=3.1.10",
"anthropic",
"openai",
"tomli-w", "tomli-w",
"psutil>=7.2.2",
"fastapi",
"uvicorn",
"tree-sitter>=0.25.2", "tree-sitter>=0.25.2",
"tree-sitter-python>=0.25.0", "tree-sitter-python>=0.25.0",
"tree-sitter-c>=0.23.2", "tree-sitter-c>=0.23.2",
"tree-sitter-cpp>=0.23.2", "tree-sitter-cpp>=0.23.2",
"psutil>=7.2.2",
"fastapi",
"mcp>=1.0.0", "mcp>=1.0.0",
"pytest-timeout>=2.4.0", "pytest-timeout>=2.4.0",
"pyopengl>=3.1.10", "uvicorn",
"anthropic",
"google-genai",
"openai",
"chromadb>=1.5.8", "chromadb>=1.5.8",
"sentence-transformers>=5.4.1", "sentence-transformers>=5.4.1",
# "python-defer"
] ]
[dependency-groups] [dependency-groups]
@@ -72,6 +74,3 @@ ignore = [
[tool.ruff.lint.mccabe] [tool.ruff.lint.mccabe]
max-complexity = 5 max-complexity = 5
+98
View File
@@ -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()
+10 -3
View File
@@ -11,10 +11,17 @@ from pathlib import Path
from typing import Generator, Any from typing import Generator, Any
from unittest.mock import patch from unittest.mock import patch
# Ensure project root is in path for imports thirdparty_dir = os.path.join(os.path.dirname(__file__), "..", "thirdparty")
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 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 from src.gui_2 import App
class VerificationLogger: class VerificationLogger:
+42
View File
@@ -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