Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef29902963 | |||
| 0d09007dc1 | |||
| 5f9bc193cb | |||
| 03db4190d7 | |||
| d9d056c80d | |||
| a65990f72b | |||
| 2bc7a3f0a5 | |||
| bf76a763c3 | |||
| 44c2585f95 | |||
| bd7ccf3a07 | |||
| 1306163446 |
+102
@@ -0,0 +1,102 @@
|
||||
import json
|
||||
import threading
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
import logging
|
||||
import session_logger
|
||||
|
||||
class HookServerInstance(HTTPServer):
|
||||
def __init__(self, server_address, RequestHandlerClass, app):
|
||||
super().__init__(server_address, RequestHandlerClass)
|
||||
self.app = app
|
||||
|
||||
class HookHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
app = self.server.app
|
||||
session_logger.log_api_hook("GET", self.path, "")
|
||||
if self.path == '/status':
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'status': 'ok'}).encode('utf-8'))
|
||||
elif self.path == '/api/project':
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'project': app.project}).encode('utf-8'))
|
||||
elif self.path == '/api/session':
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'session': {'entries': app.disc_entries}}).encode('utf-8'))
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
app = self.server.app
|
||||
content_length = int(self.headers.get('Content-Length', 0))
|
||||
body = self.rfile.read(content_length)
|
||||
body_str = body.decode('utf-8') if body else ""
|
||||
session_logger.log_api_hook("POST", self.path, body_str)
|
||||
|
||||
try:
|
||||
data = json.loads(body_str) if body_str else {}
|
||||
if self.path == '/api/project':
|
||||
app.project = data.get('project', app.project)
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'status': 'updated'}).encode('utf-8'))
|
||||
elif self.path == '/api/session':
|
||||
app.disc_entries = data.get('session', {}).get('entries', app.disc_entries)
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'status': 'updated'}).encode('utf-8'))
|
||||
elif self.path == '/api/gui':
|
||||
if not hasattr(app, '_pending_gui_tasks'):
|
||||
app._pending_gui_tasks = []
|
||||
if not hasattr(app, '_pending_gui_tasks_lock'):
|
||||
app._pending_gui_tasks_lock = threading.Lock()
|
||||
|
||||
with app._pending_gui_tasks_lock:
|
||||
app._pending_gui_tasks.append(data)
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'status': 'queued'}).encode('utf-8'))
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
except Exception as e:
|
||||
self.send_response(500)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'error': str(e)}).encode('utf-8'))
|
||||
|
||||
def log_message(self, format, *args):
|
||||
logging.info("Hook API: " + format % args)
|
||||
|
||||
class HookServer:
|
||||
def __init__(self, app, port=8999):
|
||||
self.app = app
|
||||
self.port = port
|
||||
self.server = None
|
||||
self.thread = None
|
||||
|
||||
def start(self):
|
||||
if not getattr(self.app, 'test_hooks_enabled', False):
|
||||
return
|
||||
self.server = HookServerInstance(('127.0.0.1', self.port), HookHandler, self.app)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
logging.info(f"Hook server started on port {self.port}")
|
||||
|
||||
def stop(self):
|
||||
if self.server:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
if self.thread:
|
||||
self.thread.join()
|
||||
logging.info("Hook server stopped")
|
||||
@@ -1,19 +1,19 @@
|
||||
# Implementation Plan
|
||||
|
||||
## Phase 1: Foundation and Opt-in Mechanisms
|
||||
- [ ] Task: Implement CLI flag/env-var to enable the hook system
|
||||
- [ ] Sub-task: Write Tests
|
||||
- [ ] Sub-task: Implement Feature
|
||||
- [ ] Task: Set up lightweight local IPC server (e.g., standard library socket/HTTP) for receiving hook commands
|
||||
- [ ] Sub-task: Write Tests
|
||||
- [ ] Sub-task: Implement Feature
|
||||
- [ ] Task: Conductor - User Manual Verification 'Phase 1: Foundation and Opt-in Mechanisms' (Protocol in workflow.md)
|
||||
## Phase 1: Foundation and Opt-in Mechanisms [checkpoint: 2bc7a3f]
|
||||
- [x] Task: Implement CLI flag/env-var to enable the hook system [1306163]
|
||||
- [x] Sub-task: Write Tests
|
||||
- [x] Sub-task: Implement Feature
|
||||
- [x] Task: Set up lightweight local IPC server (e.g., standard library socket/HTTP) for receiving hook commands [44c2585]
|
||||
- [x] Sub-task: Write Tests
|
||||
- [x] Sub-task: Implement Feature
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 1: Foundation and Opt-in Mechanisms' (Protocol in workflow.md) [2bc7a3f]
|
||||
|
||||
## Phase 2: Hook Implementations and Logging
|
||||
- [ ] Task: Implement project and AI session state manipulation hooks
|
||||
- [x] Task: Implement project and AI session state manipulation hooks [d9d056c]
|
||||
- [ ] Sub-task: Write Tests
|
||||
- [ ] Sub-task: Implement Feature
|
||||
- [ ] Task: Implement GUI state manipulation hooks with thread-safe queueing
|
||||
- [x] Task: Implement GUI state manipulation hooks with thread-safe queueing [5f9bc19]
|
||||
- [ ] Sub-task: Write Tests
|
||||
- [ ] Sub-task: Implement Feature
|
||||
- [ ] Task: Integrate aggressive logging for all hook invocations
|
||||
|
||||
@@ -14,6 +14,8 @@ import tomli_w
|
||||
import threading
|
||||
import time
|
||||
import math
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, Tk
|
||||
import aggregate
|
||||
@@ -23,6 +25,7 @@ import shell_runner
|
||||
import session_logger
|
||||
import project_manager
|
||||
import theme
|
||||
import api_hooks
|
||||
|
||||
CONFIG_PATH = Path("config.toml")
|
||||
PROVIDERS = ["gemini", "anthropic"]
|
||||
@@ -393,6 +396,8 @@ def _parse_history_entries(history: list[str], roles: list[str] | None = None) -
|
||||
class App:
|
||||
def __init__(self):
|
||||
self.config = load_config()
|
||||
self.test_hooks_enabled = '--enable-test-hooks' in sys.argv or os.environ.get('SLOP_TEST_HOOKS') == '1'
|
||||
self.hook_server = api_hooks.HookServer(self)
|
||||
|
||||
# ---- global settings from config.toml ----
|
||||
ai_cfg = self.config.get("ai", {})
|
||||
@@ -468,6 +473,10 @@ class App:
|
||||
self._pending_history_adds: list[dict] = []
|
||||
self._pending_history_adds_lock = threading.Lock()
|
||||
|
||||
# API GUI Hooks Queue
|
||||
self._pending_gui_tasks: list[dict] = []
|
||||
self._pending_gui_tasks_lock = threading.Lock()
|
||||
|
||||
# Blink state
|
||||
self._trigger_blink = False
|
||||
self._is_blinking = False
|
||||
@@ -2055,6 +2064,8 @@ class App:
|
||||
self._rebuild_discussion_selector()
|
||||
self._fetch_models(self.current_provider)
|
||||
|
||||
self.hook_server.start()
|
||||
|
||||
while dpg.is_dearpygui_running():
|
||||
# Show any pending confirmation dialog on the main thread safely
|
||||
with self._pending_dialog_lock:
|
||||
@@ -2078,6 +2089,27 @@ class App:
|
||||
# Force scroll to bottom using a very large number
|
||||
dpg.set_y_scroll("disc_scroll", 99999)
|
||||
|
||||
# Process queued API GUI tasks
|
||||
with self._pending_gui_tasks_lock:
|
||||
gui_tasks = self._pending_gui_tasks[:]
|
||||
self._pending_gui_tasks.clear()
|
||||
for task in gui_tasks:
|
||||
try:
|
||||
action = task.get("action")
|
||||
if action == "set_value":
|
||||
item = task.get("item")
|
||||
val = task.get("value")
|
||||
if item and dpg.does_item_exist(item):
|
||||
dpg.set_value(item, val)
|
||||
elif action == "click":
|
||||
item = task.get("item")
|
||||
if item and dpg.does_item_exist(item):
|
||||
cb = dpg.get_item_callback(item)
|
||||
if cb:
|
||||
cb()
|
||||
except Exception as e:
|
||||
print(f"Error executing GUI hook task: {e}")
|
||||
|
||||
# Handle retro arcade blinking effect
|
||||
if self._trigger_script_blink:
|
||||
self._trigger_script_blink = False
|
||||
@@ -2181,6 +2213,7 @@ class App:
|
||||
dpg.save_init_file("dpg_layout.ini")
|
||||
session_logger.close_session()
|
||||
ai_client.cleanup() # Destroy active API caches to stop billing
|
||||
self.hook_server.stop()
|
||||
dpg.destroy_context()
|
||||
|
||||
|
||||
|
||||
+21
-2
@@ -40,6 +40,7 @@ _seq_lock = threading.Lock()
|
||||
|
||||
_comms_fh = None # file handle: logs/comms_<ts>.log
|
||||
_tool_fh = None # file handle: logs/toolcalls_<ts>.log
|
||||
_api_fh = None # file handle: logs/apihooks_<ts>.log
|
||||
|
||||
|
||||
def _now_ts() -> str:
|
||||
@@ -52,7 +53,7 @@ def open_session():
|
||||
opens the two log files for this session. Idempotent - a second call is
|
||||
ignored.
|
||||
"""
|
||||
global _ts, _comms_fh, _tool_fh, _seq
|
||||
global _ts, _comms_fh, _tool_fh, _api_fh, _seq
|
||||
|
||||
if _comms_fh is not None:
|
||||
return # already open
|
||||
@@ -65,6 +66,7 @@ def open_session():
|
||||
|
||||
_comms_fh = open(_LOG_DIR / f"comms_{_ts}.log", "w", encoding="utf-8", buffering=1)
|
||||
_tool_fh = open(_LOG_DIR / f"toolcalls_{_ts}.log", "w", encoding="utf-8", buffering=1)
|
||||
_api_fh = open(_LOG_DIR / f"apihooks_{_ts}.log", "w", encoding="utf-8", buffering=1)
|
||||
|
||||
_tool_fh.write(f"# Tool-call log — session {_ts}\n\n")
|
||||
_tool_fh.flush()
|
||||
@@ -72,13 +74,30 @@ def open_session():
|
||||
|
||||
def close_session():
|
||||
"""Flush and close both log files. Called on clean exit (optional)."""
|
||||
global _comms_fh, _tool_fh
|
||||
global _comms_fh, _tool_fh, _api_fh
|
||||
if _comms_fh:
|
||||
_comms_fh.close()
|
||||
_comms_fh = None
|
||||
if _tool_fh:
|
||||
_tool_fh.close()
|
||||
_tool_fh = None
|
||||
if _api_fh:
|
||||
_api_fh.close()
|
||||
_api_fh = None
|
||||
|
||||
|
||||
def log_api_hook(method: str, path: str, payload: str):
|
||||
"""
|
||||
Log an API hook invocation.
|
||||
"""
|
||||
if _api_fh is None:
|
||||
return
|
||||
ts_entry = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
try:
|
||||
_api_fh.write(f"[{ts_entry}] {method} {path} - Payload: {payload}\n")
|
||||
_api_fh.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def log_comms(entry: dict):
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
import gui
|
||||
import api_hooks
|
||||
import urllib.request
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
def test_hooks_enabled_via_cli():
|
||||
with patch.object(sys, 'argv', ['gui.py', '--enable-test-hooks']):
|
||||
app = gui.App()
|
||||
assert app.test_hooks_enabled is True
|
||||
|
||||
def test_hooks_disabled_by_default():
|
||||
with patch.object(sys, 'argv', ['gui.py']):
|
||||
if 'SLOP_TEST_HOOKS' in os.environ:
|
||||
del os.environ['SLOP_TEST_HOOKS']
|
||||
app = gui.App()
|
||||
assert getattr(app, 'test_hooks_enabled', False) is False
|
||||
|
||||
def test_hooks_enabled_via_env():
|
||||
with patch.object(sys, 'argv', ['gui.py']):
|
||||
with patch.dict(os.environ, {'SLOP_TEST_HOOKS': '1'}):
|
||||
app = gui.App()
|
||||
assert app.test_hooks_enabled is True
|
||||
|
||||
def test_ipc_server_starts_and_responds():
|
||||
app_mock = gui.App()
|
||||
app_mock.test_hooks_enabled = True
|
||||
server = api_hooks.HookServer(app_mock, port=8999)
|
||||
server.start()
|
||||
|
||||
# Wait for server to start
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
req = urllib.request.Request("http://127.0.0.1:8999/status")
|
||||
with urllib.request.urlopen(req) as response:
|
||||
assert response.status == 200
|
||||
data = json.loads(response.read().decode())
|
||||
assert data.get("status") == "ok"
|
||||
|
||||
# Test project GET
|
||||
req = urllib.request.Request("http://127.0.0.1:8999/api/project")
|
||||
with urllib.request.urlopen(req) as response:
|
||||
assert response.status == 200
|
||||
data = json.loads(response.read().decode())
|
||||
assert "project" in data
|
||||
|
||||
# Test session GET
|
||||
req = urllib.request.Request("http://127.0.0.1:8999/api/session")
|
||||
with urllib.request.urlopen(req) as response:
|
||||
assert response.status == 200
|
||||
data = json.loads(response.read().decode())
|
||||
assert "session" in data
|
||||
|
||||
# Test project POST
|
||||
req = urllib.request.Request("http://127.0.0.1:8999/api/project", method="POST", data=json.dumps({"project": {"foo": "bar"}}).encode("utf-8"), headers={'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(req) as response:
|
||||
assert response.status == 200
|
||||
assert app_mock.project == {"foo": "bar"}
|
||||
|
||||
# Test session POST
|
||||
req = urllib.request.Request("http://127.0.0.1:8999/api/session", method="POST", data=json.dumps({"session": {"entries": [{"role": "User", "content": "hi"}]}}).encode("utf-8"), headers={'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(req) as response:
|
||||
assert response.status == 200
|
||||
assert app_mock.disc_entries == [{"role": "User", "content": "hi"}]
|
||||
|
||||
# Test GUI queue hook
|
||||
req = urllib.request.Request("http://127.0.0.1:8999/api/gui", method="POST", data=json.dumps({"action": "set_value", "item": "test_item", "value": "test_value"}).encode("utf-8"), headers={'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(req) as response:
|
||||
assert response.status == 200
|
||||
# Instead of checking DPG (since we aren't running the real main loop in tests),
|
||||
# check if it got queued in app_mock
|
||||
assert hasattr(app_mock, '_pending_gui_tasks')
|
||||
assert len(app_mock._pending_gui_tasks) == 1
|
||||
assert app_mock._pending_gui_tasks[0] == {"action": "set_value", "item": "test_item", "value": "test_value"}
|
||||
|
||||
finally:
|
||||
server.stop()
|
||||
Reference in New Issue
Block a user