docs: Add AI Server IPC implementation plan
This commit is contained in:
@@ -0,0 +1,592 @@
|
||||
# AI Server IPC Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Decouple heavy AI SDK imports from GUI via subprocess command queue, achieving ~0.5s instant GUI startup.
|
||||
|
||||
**Architecture:** Subprocess spawns `python -m src.ai_server` which loads google.genai/anthropic. GUI communicates via JSON-RPC over stdin/stdout pipe. Command queue pattern with response matching by UUID.
|
||||
|
||||
**Tech Stack:** Python subprocess, JSON-RPC, threading, queue-based IPC
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New Files
|
||||
- `src/ai_server.py` - Subprocess AI server (stdin/stdout JSON-RPC)
|
||||
- `src/ai_client_proxy.py` - Queue client for GUI
|
||||
- `tests/test_ai_server.py` - Server tests
|
||||
- `tests/test_ai_client_proxy.py` - Proxy tests
|
||||
|
||||
### Modified Files
|
||||
- `src/ai_client.py` - Add proxy routing when AI_SERVER_ENABLED env var
|
||||
- `sloppy.py` - Set AI_SERVER_ENABLED=1
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create ai_client_proxy.py - Queue Client
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ai_client_proxy.py`
|
||||
- Test: `tests/test_ai_client_proxy.py`
|
||||
|
||||
- [ ] **Step 1: Write failing test for proxy basic operations**
|
||||
|
||||
```python
|
||||
# tests/test_ai_client_proxy.py
|
||||
import pytest
|
||||
from src.ai_client_proxy import AIProxyClient
|
||||
|
||||
def test_proxy_initialization():
|
||||
proxy = AIProxyClient()
|
||||
assert proxy._status == "disconnected"
|
||||
assert proxy._pending == {}
|
||||
|
||||
def test_proxy_status_states():
|
||||
proxy = AIProxyClient()
|
||||
assert proxy.status in ("disconnected", "init", "ready", "busy", "error")
|
||||
```
|
||||
|
||||
Run: `uv run pytest tests/test_ai_client_proxy.py -v`
|
||||
Expected: FAIL - module not found
|
||||
|
||||
- [ ] **Step 2: Implement minimal AIProxyClient**
|
||||
|
||||
```python
|
||||
# src/ai_client_proxy.py
|
||||
import json
|
||||
import uuid
|
||||
import threading
|
||||
import subprocess
|
||||
from typing import Any, Optional
|
||||
|
||||
class AIProxyClient:
|
||||
def __init__(self):
|
||||
self._process: Optional[subprocess.Popen] = None
|
||||
self._status: str = "disconnected"
|
||||
self._pending: dict[str, threading.Event] = {}
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
def start_server(self):
|
||||
self._process = subprocess.Popen(
|
||||
["python", "-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):
|
||||
pass # stub
|
||||
|
||||
def stop(self):
|
||||
if self._process:
|
||||
self._process.terminate()
|
||||
self._process.wait(timeout=5)
|
||||
self._process = None
|
||||
self._status = "disconnected"
|
||||
|
||||
def send_command(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
request_id = str(uuid.uuid4())
|
||||
event = threading.Event()
|
||||
self._pending[request_id] = event
|
||||
command = {"id": request_id, "method": method, "params": params}
|
||||
self._process.stdin.write(json.dumps(command) + "\n")
|
||||
self._process.stdin.flush()
|
||||
event.wait(timeout=60)
|
||||
result = self._pending.pop(request_id, None)
|
||||
return result or {"error": "timeout"}
|
||||
```
|
||||
|
||||
Run: `uv run pytest tests/test_ai_client_proxy.py::test_proxy_initialization -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Write test for command/response matching**
|
||||
|
||||
```python
|
||||
# Add to tests/test_ai_client_proxy.py
|
||||
|
||||
def test_send_command_returns_response():
|
||||
proxy = AIProxyClient()
|
||||
proxy._status = "ready"
|
||||
proxy._process = MockPopen()
|
||||
|
||||
response = proxy.send_command("list_models", {"provider": "gemini"})
|
||||
assert "result" in response or "error" in response
|
||||
```
|
||||
|
||||
Run: `uv run pytest tests/test_ai_client_proxy.py -v`
|
||||
Expected: FAIL - MockPopen not defined
|
||||
|
||||
- [ ] **Step 4: Implement command/response matching**
|
||||
|
||||
Add to `src/ai_client_proxy.py`:
|
||||
|
||||
```python
|
||||
def _read_loop(self):
|
||||
for line in self._process.stdout:
|
||||
try:
|
||||
response = json.loads(line.strip())
|
||||
rid = response.get("id")
|
||||
if rid in self._pending:
|
||||
self._pending[rid] = response
|
||||
self._pending[rid + "_event"].set()
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
def send_command(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
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}
|
||||
self._process.stdin.write(json.dumps(command) + "\n")
|
||||
self._process.stdin.flush()
|
||||
if not event.wait(timeout=60):
|
||||
return {"error": "timeout"}
|
||||
result = self._pending.pop(request_id, {})
|
||||
self._pending.pop(request_id + "_event", None)
|
||||
return result
|
||||
```
|
||||
|
||||
Run: `uv run pytest tests/test_ai_client_proxy.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Write test for status tracking**
|
||||
|
||||
```python
|
||||
# Add to tests/test_ai_client_proxy.py
|
||||
|
||||
def test_status_reflects_server_state():
|
||||
proxy = AIProxyClient()
|
||||
assert proxy.status == "disconnected"
|
||||
proxy._status = "ready"
|
||||
assert proxy.status == "ready"
|
||||
```
|
||||
|
||||
Run: `uv run pytest tests/test_ai_client_proxy.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ai_client_proxy.py tests/test_ai_client_proxy.py
|
||||
git commit -m "feat(ai-server): Add AIProxyClient queue communication layer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create ai_server.py - Subprocess Server
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ai_server.py`
|
||||
- Test: `tests/test_ai_server.py`
|
||||
|
||||
- [ ] **Step 1: Write failing test for server startup**
|
||||
|
||||
```python
|
||||
# tests/test_ai_server.py
|
||||
import pytest
|
||||
import subprocess
|
||||
import json
|
||||
import time
|
||||
import sys
|
||||
import os
|
||||
|
||||
def test_server_starts_and_loads():
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "src.ai_server"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
# Server should output ready marker
|
||||
line = proc.stdout.readline()
|
||||
proc.stdin.close()
|
||||
proc.stdout.close()
|
||||
proc.wait(timeout=5)
|
||||
assert "ready" in line.lower() or proc.returncode == 0
|
||||
```
|
||||
|
||||
Run: `uv run pytest tests/test_ai_server.py::test_server_starts_and_loads -v`
|
||||
Expected: FAIL - module not found
|
||||
|
||||
- [ ] **Step 2: Implement minimal ai_server.py**
|
||||
|
||||
```python
|
||||
# src/ai_server.py
|
||||
#!/usr/bin/env python
|
||||
import json
|
||||
import sys
|
||||
|
||||
def main():
|
||||
# Signal ready
|
||||
print(json.dumps({"type": "ready"}))
|
||||
sys.stdout.flush()
|
||||
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
cmd = json.loads(line.strip())
|
||||
print(json.dumps({"id": cmd.get("id"), "result": {}}))
|
||||
sys.stdout.flush()
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
Run: `uv run pytest tests/test_ai_server.py::test_server_starts_and_loads -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Write test for list_models command**
|
||||
|
||||
```python
|
||||
# Add to tests/test_ai_server.py
|
||||
|
||||
def test_list_models_returns_models():
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "src.ai_server"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
# Read ready
|
||||
proc.stdout.readline()
|
||||
# Send list_models command
|
||||
cmd = json.dumps({"id": "1", "method": "list_models", "params": {"provider": "gemini"}})
|
||||
proc.stdin.write(cmd + "\n")
|
||||
proc.stdin.flush()
|
||||
# Read response
|
||||
resp = proc.stdout.readline()
|
||||
result = json.loads(resp)
|
||||
assert "result" in result or "error" in result
|
||||
proc.stdin.close()
|
||||
proc.stdout.close()
|
||||
proc.wait()
|
||||
```
|
||||
|
||||
Run: `uv run pytest tests/test_ai_server.py::test_list_models_returns_models -v`
|
||||
Expected: FAIL - list_models not implemented
|
||||
|
||||
- [ ] **Step 4: Implement list_models command**
|
||||
|
||||
Update `src/ai_server.py`:
|
||||
|
||||
```python
|
||||
# src/ai_server.py
|
||||
#!/usr/bin/env python
|
||||
import json
|
||||
import sys
|
||||
|
||||
_PROVIDERS = {
|
||||
"gemini": ["gemini-2.5-flash-lite", "gemini-3-flash-preview"],
|
||||
"anthropic": ["claude-sonnet-4-20250514", "claude-3-5-sonnet-20241022"],
|
||||
}
|
||||
|
||||
def handle_command(cmd: dict) -> dict:
|
||||
method = cmd.get("method", "")
|
||||
params = cmd.get("params", {})
|
||||
|
||||
if method == "list_models":
|
||||
provider = params.get("provider", "gemini")
|
||||
return {"id": cmd.get("id"), "result": {"models": _PROVIDERS.get(provider, [])}}
|
||||
|
||||
return {"id": cmd.get("id"), "error": f"Unknown method: {method}"}
|
||||
|
||||
def main():
|
||||
print(json.dumps({"type": "ready"}))
|
||||
sys.stdout.flush()
|
||||
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
cmd = json.loads(line.strip())
|
||||
response = handle_command(cmd)
|
||||
print(json.dumps(response))
|
||||
sys.stdout.flush()
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
Run: `uv run pytest tests/test_ai_server.py::test_list_models_returns_models -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Write test for google.genai loading**
|
||||
|
||||
```python
|
||||
# Add to tests/test_ai_server.py
|
||||
|
||||
def test_server_loads_google_genai():
|
||||
import time
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "src.ai_server"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
start = time.time()
|
||||
ready_line = proc.stdout.readline()
|
||||
elapsed = time.time() - start
|
||||
proc.stdin.close()
|
||||
proc.stdout.close()
|
||||
proc.wait(timeout=10)
|
||||
# Should load in reasonable time (allow up to 5s for SDK)
|
||||
assert elapsed < 5, f"Server took {elapsed}s to start"
|
||||
```
|
||||
|
||||
Run: `uv run pytest tests/test_ai_server.py::test_server_loads_google_genai -v`
|
||||
Expected: FAIL - needs implementation
|
||||
|
||||
- [ ] **Step 6: Implement google.genai loading**
|
||||
|
||||
Update `src/ai_server.py`:
|
||||
|
||||
```python
|
||||
# src/ai_server.py
|
||||
#!/usr/bin/env python
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
_PROVIDERS = {
|
||||
"gemini": ["gemini-2.5-flash-lite", "gemini-3-flash-preview"],
|
||||
"anthropic": ["claude-sonnet-4-20250514", "claude-3-5-sonnet-20241022"],
|
||||
}
|
||||
|
||||
_google_genai = None
|
||||
_anthropic = None
|
||||
|
||||
def _ensure_google_genai():
|
||||
global _google_genai
|
||||
if _google_genai is None:
|
||||
from google import genai
|
||||
_google_genai = genai
|
||||
return _google_genai
|
||||
|
||||
def handle_command(cmd: dict) -> dict:
|
||||
method = cmd.get("method", "")
|
||||
params = cmd.get("params", {})
|
||||
|
||||
if method == "list_models":
|
||||
provider = params.get("provider", "gemini")
|
||||
if provider == "gemini":
|
||||
_ensure_google_genai()
|
||||
return {"id": cmd.get("id"), "result": {"models": _PROVIDERS.get(provider, [])}}
|
||||
|
||||
if method == "send":
|
||||
_ensure_google_genai()
|
||||
return {"id": cmd.get("id"), "result": {"status": "processed"}}
|
||||
|
||||
return {"id": cmd.get("id"), "error": f"Unknown method: {method}"}
|
||||
|
||||
def main():
|
||||
print(json.dumps({"type": "ready"}))
|
||||
sys.stdout.flush()
|
||||
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
cmd = json.loads(line.strip())
|
||||
response = handle_command(cmd)
|
||||
print(json.dumps(response))
|
||||
sys.stdout.flush()
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.stdout.flush()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
Run: `uv run pytest tests/test_ai_server.py::test_server_loads_google_genai -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ai_server.py tests/test_ai_server.py
|
||||
git commit -m "feat(ai-server): Add ai_server subprocess with google.genai lazy loading"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire AIProxyClient into ai_client.py
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ai_client.py` (add proxy routing)
|
||||
- Modify: `sloppy.py` (enable AI server)
|
||||
|
||||
- [ ] **Step 1: Write test for proxy integration**
|
||||
|
||||
```python
|
||||
# tests/test_ai_client_integration.py
|
||||
import pytest
|
||||
import os
|
||||
|
||||
def test_ai_client_uses_proxy_when_enabled(monkeypatch):
|
||||
os.environ["AI_SERVER_ENABLED"] = "1"
|
||||
# Import should trigger proxy initialization
|
||||
from src import ai_client
|
||||
assert hasattr(ai_client, "_proxy")
|
||||
assert ai_client._proxy is not None
|
||||
```
|
||||
|
||||
Run: `uv run pytest tests/test_ai_client_integration.py -v`
|
||||
Expected: FAIL - AI_SERVER_ENABLED not checked
|
||||
|
||||
- [ ] **Step 2: Add proxy to ai_client.py**
|
||||
|
||||
Modify `src/ai_client.py` - add near top after imports:
|
||||
|
||||
```python
|
||||
# At top of ai_client.py, after other imports
|
||||
_ai_proxy = None
|
||||
|
||||
def _get_proxy():
|
||||
global _ai_proxy
|
||||
if _ai_proxy is None and os.environ.get("AI_SERVER_ENABLED"):
|
||||
from src.ai_client_proxy import AIProxyClient
|
||||
_ai_proxy = AIProxyClient()
|
||||
_ai_proxy.start_server()
|
||||
return _ai_proxy
|
||||
```
|
||||
|
||||
Modify `_list_gemini_models()`:
|
||||
|
||||
```python
|
||||
def _list_gemini_models() -> list[str]:
|
||||
proxy = _get_proxy()
|
||||
if proxy and proxy.status == "ready":
|
||||
result = proxy.send_command("list_models", {"provider": "gemini"})
|
||||
if "result" in result:
|
||||
return result["result"].get("models", [])
|
||||
# Fallback to direct import
|
||||
global _gemini_client
|
||||
_ensure_gemini_client()
|
||||
return [m.name for m in _gemini_client.models.list()]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Test the integration**
|
||||
|
||||
Run: `uv run pytest tests/test_ai_client_integration.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Modify sloppy.py to enable AI server**
|
||||
|
||||
```python
|
||||
# sloppy.py - add near top
|
||||
os.environ["AI_SERVER_ENABLED"] = "1"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ai_client.py sloppy.py tests/test_ai_client_integration.py
|
||||
git commit -m "feat(ai-server): Wire AIProxyClient into ai_client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add GUI Status Indicator and Panel Tinting
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/gui_2.py` - add AI status tracking
|
||||
- Modify: `src/app_controller.py` - track AI server status
|
||||
|
||||
- [ ] **Step 1: Write test for AI status in GUI**
|
||||
|
||||
```python
|
||||
# tests/test_ai_status_gui.py
|
||||
def test_gui_shows_ai_status():
|
||||
from src.gui_2 import App
|
||||
# App should have ai_status property
|
||||
app = App.__new__(App)
|
||||
assert hasattr(app, "ai_status") or "ai_status" in dir(app)
|
||||
```
|
||||
|
||||
Run: `uv run pytest tests/test_ai_status_gui.py -v`
|
||||
Expected: FAIL - ai_status not in App
|
||||
|
||||
- [ ] **Step 2: Add ai_status to App.__init__**
|
||||
|
||||
In `src/gui_2.py` `App.__init__`, add:
|
||||
|
||||
```python
|
||||
self.ai_status = "disconnected" # disconnected, init, ready, busy, error
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add status panel rendering**
|
||||
|
||||
In `src/gui_2.py` `_render_provider_panel()` or similar, add tint indicator:
|
||||
|
||||
```python
|
||||
# At top of panel
|
||||
if getattr(self, 'ai_status', 'ready') != 'ready':
|
||||
imgui.push_style_color(imgui.COLOR_WINDOW_BACKGROUND, 0.5, 0.5, 0.5, 1.0)
|
||||
imgui.text_colored(imgui.Vec4(1, 0.5, 0, 1), "[AI: Initializing...]")
|
||||
# ... rest of panel ...
|
||||
if self.ai_status != 'ready':
|
||||
imgui.pop_style_color()
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test tinted panel**
|
||||
|
||||
Run: `uv run pytest tests/test_ai_status_gui.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/gui_2.py tests/test_ai_status_gui.py
|
||||
git commit -m "feat(gui): Add AI status indicator and panel tinting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: End-to-End Test
|
||||
|
||||
- [ ] **Step 1: Full startup test**
|
||||
|
||||
```bash
|
||||
cd C:\projects\manual_slop
|
||||
timeout 30 uv run python sloppy.py &
|
||||
sleep 3
|
||||
# Check if GUI responds
|
||||
curl http://localhost:8999/status 2>/dev/null || echo "Hook API not ready"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify startup time**
|
||||
|
||||
```powershell
|
||||
Measure-Command { uv run python sloppy.py } | Select TotalSeconds
|
||||
```
|
||||
|
||||
Target: < 2 seconds to GUI visible
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(ai-server): Complete AI server IPC integration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Startup time**: GUI should appear in < 2 seconds
|
||||
2. **AI panels**: Should show "Initializing..." tint until AI server ready
|
||||
3. **AI functionality**: After ~1.2s, AI panels become active
|
||||
4. **No regressions**: Existing tests pass
|
||||
Reference in New Issue
Block a user