test(audit): fix critical test suite deadlocks and write exhaustive architectural report

- Fix 'Triple Bingo' history synchronization explosion during streaming

- Implement stateless event buffering in ApiHookClient to prevent dropped events

- Ensure 'tool_execution' events emit consistently across all LLM providers

- Add hard timeouts to all background thread wait() conditions

- Add thorough teardown cleanup to conftest.py's reset_ai_client fixture

- Write highly detailed report_gemini.md exposing asyncio lifecycle flaws
This commit is contained in:
2026-03-05 01:42:47 -05:00
parent bfdbd43785
commit 35480a26dc
15 changed files with 715 additions and 481 deletions

View File

@@ -11,7 +11,8 @@
"mcp__manual-slop__py_check_syntax",
"mcp__manual-slop__get_file_summary",
"mcp__manual-slop__get_tree",
"mcp__manual-slop__list_directory"
"mcp__manual-slop__list_directory",
"mcp__manual-slop__py_get_skeleton"
]
},
"enableAllProjectMcpServers": true,

View File

@@ -0,0 +1,130 @@
# Test Architecture Integrity Audit — Gemini Review
**Author:** Gemini 2.5 Pro (Tier 2 Tech Lead)
**Review Date:** 2026-03-05
**Source Reports:** `report.md` (GLM-4.7) and `report_claude.md` (Claude Sonnet 4.6)
**Scope:** Exhaustive root-cause analysis of intermittent and full-suite test failures introduced by the GUI decoupling refactor.
---
## Executive Summary
This report serves as the definitive autopsy of the test suite instability observed following the completion of the `GUI Decoupling & Controller Architecture` track (`1bc4205`). While the decoupling successfully isolated the `AppController` state machine from the `gui_2.py` immediate-mode rendering loop, it inadvertently exposed and amplified several systemic flaws in the project's concurrency model, IPC (Inter-Process Communication) mechanisms, and test fixture isolation.
The symptoms—tests passing in isolation but hanging, deadlocking, or failing assertions when run as a full suite—are classic signatures of **state pollution** and **race conditions**.
This audit moves beyond the surface-level observations made by GLM-4.7 (which focused heavily on missing negative paths and mock fidelity) and Claude 4.6 (which correctly identified some scoping issues). This report details the exact mechanical failures within the threading models, event loops, and synchronization primitives that caused the build to break under load.
---
## Part 1: Deep Dive — The "Triple Bingo" History Synchronization Bug
**Symptom:** During extended simulations (`test_extended_sims.py`), the GUI would mysteriously hang, memory usage would spike, and tests would eventually time out.
**Root Cause Analysis:**
The architecture relies on an asynchronous event queue (`_api_event_queue`) and a synchronized task list (`_pending_gui_tasks`) to bridge the gap between the background AI processing threads and the main GUI rendering thread. When streaming was enabled for the Gemini CLI provider, a catastrophic feedback loop was created.
1. **The Streaming Accumulator Flaw:** In `AppController._handle_request_event`, the `stream_callback` was designed to push partial updates to the GUI. However, it was pushing the *entire accumulated response text* up to that point, not just the delta.
2. **The Unconditional History Append:** The `_process_pending_gui_tasks` loop running on the GUI thread received these "streaming..." events. Crucially, the logic failed to check if the turn was actually complete. For *every single chunk* received, it appended the full accumulated text to `_pending_history_adds`.
3. **Exponential Growth:** If the AI responded with 100 chunks, the history array grew by `1 + 2 + 3... + 100` strings, leading to an massive, exponentially growing list of duplicated, partially complete responses being serialized into the project's discussion state. This caused the JSON/TOML parsers and ImGui text rendering to lock up entirely under the weight of megabytes of redundant text.
4. **Provider Inconsistency:** To compound this, the `GeminiCliAdapter` was manually emitting its own `history_add` events upon completion, meaning even without streaming, responses were being duplicated because both the controller and the adapter were trying to manage the discussion history.
**Architectural Lesson:** History state mutation must be strictly gated to terminal events (e.g., `status == 'done'`). Intermediate streaming states are purely ephemeral UI concerns and must never touch persistent data structures.
---
## Part 2: Deep Dive — IPC and Event Polling Race Conditions
**Symptom:** Integration tests (like `test_gemini_cli_edge_cases.py` and `test_visual_sim_mma_v2.py`) that rely on `ApiHookClient.wait_for_event()` would sporadically time out waiting for an event (like `ask_received` or `script_confirmation_required`) that the server logs prove was successfully emitted.
**Root Cause Analysis:**
The testing framework relies heavily on polling the `/api/events` HTTP endpoint to coordinate test assertions with GUI state transitions.
1. **Destructive Reads:** The `get_events()` implementation in `ApiHookClient` made a GET request that triggered a lock-guarded drain of `_api_event_queue` on the server. This is a destructive read; once fetched, the server queue is cleared.
2. **Stateless Polling:** The original `wait_for_event` implementation fetched events, iterated through them looking for the target type, and if found, returned it. *However*, if the fetch returned multiple events (e.g., `['refresh_metrics', 'script_confirmation_required']`), and the test was only polling for `refresh_metrics`, the `script_confirmation_required` event was silently dropped on the floor because the client retained no state between polls.
3. **The Deadlock:** When the test advanced to the next step and called `wait_for_event('script_confirmation_required')`, it would hang until timeout because the event had already been consumed and discarded in the previous polling cycle.
**Architectural Lesson:** IPC clients designed for event polling must implement local, stateful event buffers (`_event_buffer`). Destructive reads from a server queue demand that the client takes full ownership of storing all received events until they are explicitly consumed by the test logic.
---
## Part 3: Deep Dive — Asyncio Lifecycle & Threading Deadlocks
**Symptom:** The full test suite hangs around the 35% mark, often terminating with `RuntimeError: Event loop is closed` or simply freezing indefinitely.
**Root Cause Analysis:**
The `AppController` initializes its own internal `asyncio` loop running in a dedicated daemon thread (`_loop_thread`).
1. **Event Loop Exhaustion:** When `pytest` runs hundreds of tests, the `app_instance` fixture creates and tears down hundreds of `AppController` instances. `asyncio` is not designed to have hundreds of unmanaged loops created and abandoned within a single process space.
2. **Missing Teardown:** The `AppController` originally lacked a `shutdown()` method. When a test ended, the daemon thread remained alive, and the `asyncio` loop continued running. When Python's garbage collector eventually reclaimed the object, or when `pytest-asyncio` interfered with global loop policies, the background loops would violently close, throwing exceptions into the void and destabilizing the interpreter.
3. **Approval Dialog Deadlocks:** Tools like `run_powershell` and MMA worker spawns require user approval via `ConfirmDialog` or `MMASpawnApprovalDialog`. The AI background thread calls `.wait()` on a threading `Condition`. If the test fails to trigger the approval via the Hook API, or if the Hook API crashes due to the asyncio loop dying, the background thread waits *forever*. There were zero timeouts on the internal wait conditions.
**Architectural Lesson:**
1. Deterministic teardown of background threads and event loops is non-negotiable in test suites.
2. Threading primitives (`Event`, `Condition`) used for cross-thread synchronization must *always* employ outer timeouts to prevent catastrophic test suite hangs when state machines desynchronize.
---
## Part 4: Deep Dive — Phantom Hook Servers & Test State Pollution
**Symptom:** Tests utilizing the `live_gui` fixture sporadically fail with `ConnectionError`, or assertions fail because the test is interacting with a UI state left over from a completely different test file.
**Root Cause Analysis:**
The `live_gui` fixture spawns `sloppy.py` via `subprocess.Popen`. This process launches `api_hooks.HookServer` on `127.0.0.1:8999`.
1. **Zombie Processes:** If a test fails abruptly or a deadlock occurs (as detailed in Part 3), the `conftest.py` teardown block may fail to cleanly terminate the `Popen` instance.
2. **Port Hijacking:** The zombie `sloppy.py` process continues running in the background, holding port 8999 open.
3. **Cross-Test Telemetry Contamination:** When the *next* test in the suite executes, it spins up a new `ApiHookClient` pointing at port 8999. Because the zombie server is still listening, the client successfully connects—not to the fresh instance the fixture just spawned (which failed to bind to 8999), but to the dead test's GUI.
4. **In-Process Module Pollution:** For tests that don't use `live_gui` but instead mock `app_instance` in-process, global singletons like `ai_client` and `mcp_client` retain state. For example, if Test A modifies `mcp_client.MUTATING_TOOLS` or sets a specific `ai_client._provider`, Test B inherits those mutations unless strictly reset.
**Architectural Lesson:**
1. Subprocess test fixtures require aggressive, OS-level process tree termination (`taskkill /F /T` equivalent) to guarantee port release.
2. Global module state (`ai_client`, `mcp_client`, `events.EventEmitter`) mandates aggressive, explicit `autouse` fixtures that reset all variables and clear all pub/sub listeners between *every single test*.
---
## Part 5: Evaluation of Prior Audits (GLM & Claude)
### Review of GLM-4.7's Report
GLM correctly identified the lack of negative path testing and the fact that `mock_gemini_cli.py` always returns success, which masks error-handling bugs. However, GLM's recommendation to entirely rewrite the testing framework to use custom `ContextManager` mocks is an over-correction. The core architecture is sound; the failure is in lifecycle management and polling discipline, not the inherent design of the event bus. GLM entirely missed the critical threading and IPC deadlocks that actually cause the suite to fail.
### Review of Claude 4.6's Report
Claude correctly identified that auto-approving dialogs without asserting their existence hides UX failures (a very astute observation). Claude also correctly identified that `mcp_client` state was bleeding between tests. However, Claude dismissed the `simulation/` framework as merely a "workflow driver," failing to recognize that the simulations *are* the integration tests that were exposing the deep `asyncio` and IPC deadlocks.
---
## Part 6: Prioritized Recommendations & Action Plan
To stabilize the CI/CD pipeline and restore confidence in the test suite, the following tracks must be executed in order.
### Priority 1: `test_stabilization_core_ipc_20260305` (URGENT)
**Goal:** Eradicate deadlocks, race conditions, and phantom processes.
**Tasks:**
1. **Stateful Event Buffering:** Implement `_event_buffer` in `ApiHookClient` to safely accumulate events between destructive server reads. (Partially addressed, requires full test coverage).
2. **Thread Deadlock Prevention:** Enforce a hard 120-second timeout on all `wait()` calls within `ConfirmDialog`, `MMAApprovalDialog`, and `MMASpawnApprovalDialog`.
3. **Aggressive Fixture Teardown:** Update `live_gui` in `conftest.py` to assert port 8999 is free *before* launching, and use robust process tree killing on teardown.
4. **Global State Sanitization:** Ensure `reset_ai_client` explicitly clears `events._listeners` and `mcp_client.configure([], [])`.
### Priority 2: `hook_api_ui_state_verification_20260302` (HIGH)
*This track was previously proposed but is now critical.*
**Goal:** Replace fragile log-parsing assertions with deterministic UI state queries.
**Tasks:**
1. Implement `GET /api/gui/state` in `HookHandler`.
2. Wire critical UI variables (`ui_focus_agent`, active modal titles, track status) into the `_settable_fields` dictionary to allow programmatic reading without pixels/screenshots.
3. Refactor `test_visual_sim_mma_v2.py` and `test_extended_sims.py` to use these new endpoints instead of brittle `time.sleep()` and string matching.
### Priority 3: `mock_provider_hardening_20260305` (MEDIUM)
*Sourced from Claude 4.6's recommendations.*
**Goal:** Ensure error paths are exercised.
**Tasks:**
1. Add `MOCK_MODE` environment variable parsing to `mock_gemini_cli.py`.
2. Implement modes for `malformed_json`, `timeout`, and `error_result`.
3. Create a dedicated test file `test_negative_flows.py` to verify the GUI correctly displays error states and recovers without crashing when the AI provider fails.
### Priority 4: `asyncio_decoupling_refactor_20260306` (LOW - Architectural Debt)
**Goal:** Remove `asyncio` from the `AppController` entirely.
**Tasks:**
The internal use of `asyncio.Queue` and a dedicated event loop thread within `AppController` is over-engineered for a system that simply needs to pass dicts between a background worker and the GUI thread. Replacing it with a standard `queue.Queue` from the `queue` module will drastically simplify the architecture, eliminate `RuntimeError: Event loop is closed` bugs in tests, and make the application more robust.
---
*End of Report.*

View File

@@ -1,5 +1,5 @@
[ai]
provider = "gemini_cli"
provider = "gemini"
model = "gemini-2.5-flash-lite"
temperature = 0.0
max_tokens = 8192
@@ -15,7 +15,7 @@ paths = [
"C:\\projects\\manual_slop\\tests\\artifacts\\temp_livetoolssim.toml",
"C:\\projects\\manual_slop\\tests\\artifacts\\temp_liveexecutionsim.toml",
]
active = "C:\\projects\\manual_slop\\tests\\artifacts\\temp_livecontextsim.toml"
active = "C:\\projects\\manual_slop\\tests\\artifacts\\temp_liveexecutionsim.toml"
[gui.show_windows]
"Context Hub" = true

View File

@@ -23,6 +23,7 @@ class BaseSimulation:
print("\n[BaseSim] Connecting to GUI...")
if not self.client.wait_for_server(timeout=5):
raise RuntimeError("Could not connect to GUI. Ensure it is running with --enable-test-hooks")
self.client.clear_events()
self.client.set_value("auto_add_history", True)
# Wait for propagation
_start = time.time()

View File

@@ -289,9 +289,9 @@ def reset_session() -> None:
_gemini_cache = None
_gemini_cache_md_hash = None
_gemini_cache_created_at = None
if _gemini_cli_adapter:
_gemini_cli_adapter.session_id = None
_gemini_cli_adapter = None
_anthropic_client = None
with _anthropic_history_lock:
_anthropic_history = []
_deepseek_client = None
@@ -724,6 +724,7 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str,
name, args = fc.name, dict(fc.args)
out = ""
tool_executed = False
events.emit("tool_execution", payload={"status": "started", "tool": name, "args": args, "round": r_idx})
if name == TOOL_NAME and pre_tool_callback:
scr = cast(str, args.get("script", ""))
_append_comms("OUT", "tool_call", {"name": TOOL_NAME, "script": scr})
@@ -735,7 +736,6 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str,
tool_executed = True
if not tool_executed:
events.emit("tool_execution", payload={"status": "started", "tool": name, "args": args, "round": r_idx})
if name and name in mcp_client.TOOL_NAMES:
_append_comms("OUT", "tool_call", {"name": name, "args": args})
if name in mcp_client.MUTATING_TOOLS and pre_tool_callback:
@@ -840,6 +840,7 @@ def _send_gemini_cli(md_content: str, user_message: str, base_dir: str,
call_id = cast(str, fc.get("id"))
out = ""
tool_executed = False
events.emit("tool_execution", payload={"status": "started", "tool": name, "args": args, "round": r_idx})
if name == TOOL_NAME and pre_tool_callback:
scr = cast(str, args.get("script", ""))
_append_comms("OUT", "tool_call", {"name": TOOL_NAME, "id": call_id, "script": scr})
@@ -851,8 +852,8 @@ def _send_gemini_cli(md_content: str, user_message: str, base_dir: str,
tool_executed = True
if not tool_executed:
events.emit("tool_execution", payload={"status": "started", "tool": name, "args": args, "round": r_idx})
if name in mcp_client.TOOL_NAMES:
if name and name in mcp_client.TOOL_NAMES:
_append_comms("OUT", "tool_call", {"name": name, "id": call_id, "args": args})
if name in mcp_client.MUTATING_TOOLS and pre_tool_callback:
desc = f"# MCP MUTATING TOOL: {name}\n" + "\n".join(f"# {k}: {repr(v)}" for k, v in args.items())
@@ -1181,6 +1182,7 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
b_input = cast(dict[str, Any], getattr(block, "input"))
output = ""
tool_executed = False
events.emit("tool_execution", payload={"status": "started", "tool": b_name, "args": b_input, "round": round_idx})
if b_name == TOOL_NAME and pre_tool_callback:
script = cast(str, b_input.get("script", ""))
_append_comms("OUT", "tool_call", {"name": TOOL_NAME, "id": b_id, "script": script})
@@ -1192,8 +1194,8 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
tool_executed = True
if not tool_executed:
events.emit("tool_execution", payload={"status": "started", "tool": b_name, "args": b_input, "round": round_idx})
if b_name and b_name in mcp_client.TOOL_NAMES:
if name and name in mcp_client.TOOL_NAMES:
_append_comms("OUT", "tool_call", {"name": b_name, "id": b_id, "args": b_input})
if b_name in mcp_client.MUTATING_TOOLS and pre_tool_callback:
desc = f"# MCP MUTATING TOOL: {b_name}\n" + "\n".join(f"# {k}: {repr(v)}" for k, v in b_input.items())
@@ -1225,9 +1227,6 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
"tool_use_id": b_id,
"content": truncated,
})
if not tool_executed:
events.emit("tool_execution", payload={"status": "completed", "tool": b_name, "result": output, "round": round_idx})
else:
events.emit("tool_execution", payload={"status": "completed", "tool": b_name, "result": output, "round": round_idx})
if _cumulative_tool_bytes > _MAX_TOOL_OUTPUT_BYTES:
tool_results.append({
@@ -1417,6 +1416,7 @@ def _send_deepseek(md_content: str, user_message: str, base_dir: str,
tool_args = {}
tool_output = ""
tool_executed = False
events.emit("tool_execution", payload={"status": "started", "tool": tool_name, "args": tool_args, "round": round_idx})
if tool_name == TOOL_NAME and pre_tool_callback:
script = cast(str, tool_args.get("script", ""))
_append_comms("OUT", "tool_call", {"name": TOOL_NAME, "id": tool_id, "script": script})
@@ -1428,7 +1428,6 @@ def _send_deepseek(md_content: str, user_message: str, base_dir: str,
tool_executed = True
if not tool_executed:
events.emit("tool_execution", payload={"status": "started", "tool": tool_name, "args": tool_args, "round": round_idx})
if tool_name in mcp_client.TOOL_NAMES:
_append_comms("OUT", "tool_call", {"name": tool_name, "id": tool_id, "args": tool_args})
if tool_name in mcp_client.MUTATING_TOOLS and pre_tool_callback:

View File

@@ -9,6 +9,7 @@ class ApiHookClient:
self.base_url = base_url
self.max_retries = max_retries
self.retry_delay = retry_delay
self._event_buffer: list[dict[str, Any]] = []
def wait_for_server(self, timeout: float = 3) -> bool:
"""
@@ -209,21 +210,31 @@ class ApiHookClient:
return {"tag": tag, "shown": False, "error": str(e)}
def get_events(self) -> list[Any]:
"""Fetches and clears the event queue from the server."""
"""Fetches new events and adds them to the internal buffer."""
try:
res = self._make_request('GET', '/api/events')
return res.get("events", []) if res else []
new_events = res.get("events", []) if res else []
if new_events:
self._event_buffer.extend(new_events)
return list(self._event_buffer)
except Exception:
return []
return list(self._event_buffer)
def clear_events(self) -> None:
"""Clears the internal event buffer and the server queue."""
self._make_request('GET', '/api/events')
self._event_buffer.clear()
def wait_for_event(self, event_type: str, timeout: float = 5) -> dict[str, Any] | None:
"""Polls for a specific event type."""
"""Polls for a specific event type in the internal buffer."""
start = time.time()
while time.time() - start < timeout:
events = self.get_events()
for ev in events:
# Refresh buffer
self.get_events()
# Search in buffer
for i, ev in enumerate(self._event_buffer):
if isinstance(ev, dict) and ev.get("type") == event_type:
return ev
return self._event_buffer.pop(i)
time.sleep(0.1) # Fast poll
return None

View File

@@ -38,50 +38,45 @@ class HookHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
app = self.server.app
session_logger.log_api_hook("GET", self.path, "")
if self.path == '/status':
if self.path == "/status":
self.send_response(200)
self.send_header('Content-Type', 'application/json')
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.wfile.write(json.dumps({"status": "ok"}).encode("utf-8"))
elif self.path == "/api/project":
import project_manager
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header("Content-Type", "application/json")
self.end_headers()
flat = project_manager.flat_config(_get_app_attr(app, 'project'))
self.wfile.write(json.dumps({'project': flat}).encode('utf-8'))
elif self.path == '/api/session':
flat = project_manager.flat_config(_get_app_attr(app, "project"))
self.wfile.write(json.dumps({"project": flat}).encode("utf-8"))
elif self.path == "/api/session":
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header("Content-Type", "application/json")
self.end_headers()
lock = _get_app_attr(app, '_disc_entries_lock')
entries = _get_app_attr(app, 'disc_entries', [])
lock = _get_app_attr(app, "_disc_entries_lock")
entries = _get_app_attr(app, "disc_entries", [])
if lock:
with lock:
entries_snapshot = list(entries)
with lock: entries_snapshot = list(entries)
else:
entries_snapshot = list(entries)
self.wfile.write(
json.dumps({'session': {'entries': entries_snapshot}}).
encode('utf-8'))
elif self.path == '/api/performance':
self.wfile.write(json.dumps({"session": {"entries": entries_snapshot}}).encode("utf-8"))
elif self.path == "/api/performance":
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header("Content-Type", "application/json")
self.end_headers()
metrics = {}
perf = _get_app_attr(app, 'perf_monitor')
if perf:
metrics = perf.get_metrics()
self.wfile.write(json.dumps({'performance': metrics}).encode('utf-8'))
elif self.path == '/api/events':
# Long-poll or return current event queue
perf = _get_app_attr(app, "perf_monitor")
if perf: metrics = perf.get_metrics()
self.wfile.write(json.dumps({"performance": metrics}).encode("utf-8"))
elif self.path == "/api/events":
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header("Content-Type", "application/json")
self.end_headers()
events = []
if _has_app_attr(app, '_api_event_queue'):
lock = _get_app_attr(app, '_api_event_queue_lock')
queue = _get_app_attr(app, '_api_event_queue')
if _has_app_attr(app, "_api_event_queue"):
lock = _get_app_attr(app, "_api_event_queue_lock")
queue = _get_app_attr(app, "_api_event_queue")
if lock:
with lock:
events = list(queue)
@@ -89,74 +84,33 @@ class HookHandler(BaseHTTPRequestHandler):
else:
events = list(queue)
queue.clear()
self.wfile.write(json.dumps({'events': events}).encode('utf-8'))
elif self.path == '/api/gui/value':
# POST with {"field": "field_tag"} to get value
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length)
data = json.loads(body.decode('utf-8'))
field_tag = data.get("field")
self.wfile.write(json.dumps({"events": events}).encode("utf-8"))
elif self.path.startswith("/api/gui/value/"):
field_tag = self.path.split("/")[-1]
event = threading.Event()
result = {"value": None}
def get_val():
try:
settable = _get_app_attr(app, '_settable_fields', {})
settable = _get_app_attr(app, "_settable_fields", {})
if field_tag in settable:
attr = settable[field_tag]
result["value"] = _get_app_attr(app, attr, None)
finally:
event.set()
lock = _get_app_attr(app, '_pending_gui_tasks_lock')
tasks = _get_app_attr(app, '_pending_gui_tasks')
finally: event.set()
lock = _get_app_attr(app, "_pending_gui_tasks_lock")
tasks = _get_app_attr(app, "_pending_gui_tasks")
if lock and tasks is not None:
with lock:
tasks.append({
"action": "custom_callback",
"callback": get_val
})
if event.wait(timeout=60):
with lock: tasks.append({"action": "custom_callback", "callback": get_val})
if event.wait(timeout=10):
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(result).encode('utf-8'))
self.wfile.write(json.dumps(result).encode("utf-8"))
else:
self.send_response(504)
self.end_headers()
elif self.path.startswith('/api/gui/value/'):
# Generic endpoint to get the value of any settable field
field_tag = self.path.split('/')[-1]
event = threading.Event()
result = {"value": None}
def get_val():
try:
settable = _get_app_attr(app, '_settable_fields', {})
if field_tag in settable:
attr = settable[field_tag]
result["value"] = _get_app_attr(app, attr, None)
finally:
event.set()
lock = _get_app_attr(app, '_pending_gui_tasks_lock')
tasks = _get_app_attr(app, '_pending_gui_tasks')
if lock and tasks is not None:
with lock:
tasks.append({
"action": "custom_callback",
"callback": get_val
})
if event.wait(timeout=60):
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(result).encode('utf-8'))
else:
self.send_response(504)
self.end_headers()
elif self.path == '/api/gui/mma_status':
elif self.path == "/api/gui/mma_status":
event = threading.Event()
result = {}
def get_mma():
try:
result["mma_status"] = _get_app_attr(app, "mma_status", "idle")
@@ -176,178 +130,179 @@ class HookHandler(BaseHTTPRequestHandler):
result["proposed_tracks"] = _get_app_attr(app, "proposed_tracks", [])
result["mma_streams"] = _get_app_attr(app, "mma_streams", {})
result["mma_tier_usage"] = _get_app_attr(app, "mma_tier_usage", {})
finally:
event.set()
lock = _get_app_attr(app, '_pending_gui_tasks_lock')
tasks = _get_app_attr(app, '_pending_gui_tasks')
finally: event.set()
lock = _get_app_attr(app, "_pending_gui_tasks_lock")
tasks = _get_app_attr(app, "_pending_gui_tasks")
if lock and tasks is not None:
with lock:
tasks.append({
"action": "custom_callback",
"callback": get_mma
})
if event.wait(timeout=60):
with lock: tasks.append({"action": "custom_callback", "callback": get_mma})
if event.wait(timeout=10):
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(result).encode('utf-8'))
self.wfile.write(json.dumps(result).encode("utf-8"))
else:
self.send_response(504)
self.end_headers()
elif self.path == '/api/gui/diagnostics':
elif self.path == "/api/gui/diagnostics":
event = threading.Event()
result = {}
def check_all():
try:
status = _get_app_attr(app, "ai_status", "idle")
result["thinking"] = status in ["sending...", "running powershell..."]
result["live"] = status in ["running powershell...", "fetching url...", "searching web...", "powershell done, awaiting AI..."]
result["prior"] = _get_app_attr(app, "is_viewing_prior_session", False)
finally:
event.set()
lock = _get_app_attr(app, '_pending_gui_tasks_lock')
tasks = _get_app_attr(app, '_pending_gui_tasks')
finally: event.set()
lock = _get_app_attr(app, "_pending_gui_tasks_lock")
tasks = _get_app_attr(app, "_pending_gui_tasks")
if lock and tasks is not None:
with lock:
tasks.append({
"action": "custom_callback",
"callback": check_all
})
if event.wait(timeout=60):
with lock: tasks.append({"action": "custom_callback", "callback": check_all})
if event.wait(timeout=10):
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(result).encode('utf-8'))
self.wfile.write(json.dumps(result).encode("utf-8"))
else:
self.send_response(504)
self.end_headers()
self.wfile.write(json.dumps({'error': 'timeout'}).encode('utf-8'))
else:
self.send_response(404)
self.end_headers()
def do_POST(self) -> None:
app = self.server.app
content_length = int(self.headers.get('Content-Length', 0))
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
body_str = body.decode('utf-8') if body else ""
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':
project = _get_app_attr(app, 'project')
_set_app_attr(app, 'project', data.get('project', project))
if self.path == "/api/project":
project = _get_app_attr(app, "project")
_set_app_attr(app, "project", data.get("project", project))
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({'status': 'updated'}).encode('utf-8'))
elif self.path.startswith('/api/confirm/'):
action_id = self.path.split('/')[-1]
approved = data.get('approved', False)
resolve_func = _get_app_attr(app, 'resolve_pending_action')
self.wfile.write(json.dumps({"status": "updated"}).encode("utf-8"))
elif self.path.startswith("/api/confirm/"):
action_id = self.path.split("/")[-1]
approved = data.get("approved", False)
resolve_func = _get_app_attr(app, "resolve_pending_action")
if resolve_func:
success = resolve_func(action_id, approved)
if success:
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({'status': 'ok'}).encode('utf-8'))
self.wfile.write(json.dumps({"status": "ok"}).encode("utf-8"))
else:
self.send_response(404)
self.end_headers()
else:
self.send_response(500)
self.end_headers()
elif self.path == '/api/session':
lock = _get_app_attr(app, '_disc_entries_lock')
entries = _get_app_attr(app, 'disc_entries')
new_entries = data.get('session', {}).get('entries', entries)
elif self.path == "/api/session":
lock = _get_app_attr(app, "_disc_entries_lock")
entries = _get_app_attr(app, "disc_entries")
new_entries = data.get("session", {}).get("entries", entries)
if lock:
with lock:
_set_app_attr(app, 'disc_entries', new_entries)
with lock: _set_app_attr(app, "disc_entries", new_entries)
else:
_set_app_attr(app, 'disc_entries', new_entries)
_set_app_attr(app, "disc_entries", new_entries)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
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':
lock = _get_app_attr(app, '_pending_gui_tasks_lock')
tasks = _get_app_attr(app, '_pending_gui_tasks')
self.wfile.write(json.dumps({"status": "updated"}).encode("utf-8"))
elif self.path == "/api/gui":
lock = _get_app_attr(app, "_pending_gui_tasks_lock")
tasks = _get_app_attr(app, "_pending_gui_tasks")
if lock and tasks is not None:
with lock:
tasks.append(data)
with lock: tasks.append(data)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({'status': 'queued'}).encode('utf-8'))
elif self.path == '/api/ask':
self.wfile.write(json.dumps({"status": "queued"}).encode("utf-8"))
elif self.path == "/api/gui/value":
field_tag = data.get("field")
event = threading.Event()
result = {"value": None}
def get_val():
try:
settable = _get_app_attr(app, "_settable_fields", {})
if field_tag in settable:
attr = settable[field_tag]
result["value"] = _get_app_attr(app, attr, None)
finally: event.set()
lock = _get_app_attr(app, "_pending_gui_tasks_lock")
tasks = _get_app_attr(app, "_pending_gui_tasks")
if lock and tasks is not None:
with lock: tasks.append({"action": "custom_callback", "callback": get_val})
if event.wait(timeout=10):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(result).encode("utf-8"))
else:
self.send_response(504)
self.end_headers()
elif self.path == "/api/ask":
request_id = str(uuid.uuid4())
event = threading.Event()
pending_asks = _get_app_attr(app, '_pending_asks')
pending_asks = _get_app_attr(app, "_pending_asks")
if pending_asks is None:
pending_asks = {}
_set_app_attr(app, '_pending_asks', pending_asks)
ask_responses = _get_app_attr(app, '_ask_responses')
_set_app_attr(app, "_pending_asks", pending_asks)
ask_responses = _get_app_attr(app, "_ask_responses")
if ask_responses is None:
ask_responses = {}
_set_app_attr(app, '_ask_responses', ask_responses)
_set_app_attr(app, "_ask_responses", ask_responses)
pending_asks[request_id] = event
event_queue_lock = _get_app_attr(app, '_api_event_queue_lock')
event_queue = _get_app_attr(app, '_api_event_queue')
event_queue_lock = _get_app_attr(app, "_api_event_queue_lock")
event_queue = _get_app_attr(app, "_api_event_queue")
if event_queue is not None:
if event_queue_lock:
with event_queue_lock:
event_queue.append({"type": "ask_received", "request_id": request_id, "data": data})
with event_queue_lock: event_queue.append({"type": "ask_received", "request_id": request_id, "data": data})
else:
event_queue.append({"type": "ask_received", "request_id": request_id, "data": data})
gui_tasks_lock = _get_app_attr(app, '_pending_gui_tasks_lock')
gui_tasks = _get_app_attr(app, '_pending_gui_tasks')
gui_tasks_lock = _get_app_attr(app, "_pending_gui_tasks_lock")
gui_tasks = _get_app_attr(app, "_pending_gui_tasks")
if gui_tasks is not None:
if gui_tasks_lock:
with gui_tasks_lock:
gui_tasks.append({"type": "ask", "request_id": request_id, "data": data})
with gui_tasks_lock: gui_tasks.append({"type": "ask", "request_id": request_id, "data": data})
else:
gui_tasks.append({"type": "ask", "request_id": request_id, "data": data})
if event.wait(timeout=60.0):
response_data = ask_responses.get(request_id)
if request_id in ask_responses: del ask_responses[request_id]
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({'status': 'ok', 'response': response_data}).encode('utf-8'))
self.wfile.write(json.dumps({"status": "ok", "response": response_data}).encode("utf-8"))
else:
if request_id in pending_asks: del pending_asks[request_id]
self.send_response(504)
self.end_headers()
self.wfile.write(json.dumps({'error': 'timeout'}).encode('utf-8'))
elif self.path == '/api/ask/respond':
request_id = data.get('request_id')
response_data = data.get('response')
pending_asks = _get_app_attr(app, '_pending_asks')
ask_responses = _get_app_attr(app, '_ask_responses')
elif self.path == "/api/ask/respond":
request_id = data.get("request_id")
response_data = data.get("response")
pending_asks = _get_app_attr(app, "_pending_asks")
ask_responses = _get_app_attr(app, "_ask_responses")
if request_id and pending_asks and request_id in pending_asks:
ask_responses[request_id] = response_data
event = pending_asks[request_id]
event.set()
del pending_asks[request_id]
gui_tasks_lock = _get_app_attr(app, '_pending_gui_tasks_lock')
gui_tasks = _get_app_attr(app, '_pending_gui_tasks')
gui_tasks_lock = _get_app_attr(app, "_pending_gui_tasks_lock")
gui_tasks = _get_app_attr(app, "_pending_gui_tasks")
if gui_tasks is not None:
if gui_tasks_lock:
with gui_tasks_lock:
gui_tasks.append({"action": "clear_ask", "request_id": request_id})
with gui_tasks_lock: gui_tasks.append({"action": "clear_ask", "request_id": request_id})
else:
gui_tasks.append({"action": "clear_ask", "request_id": request_id})
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({'status': 'ok'}).encode('utf-8'))
self.wfile.write(json.dumps({"status": "ok"}).encode("utf-8"))
else:
self.send_response(404)
self.end_headers()
@@ -356,9 +311,10 @@ class HookHandler(BaseHTTPRequestHandler):
self.end_headers()
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'application/json')
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({'error': str(e)}).encode('utf-8'))
self.wfile.write(json.dumps({"error": str(e)}).encode("utf-8"))
def log_message(self, format: str, *args: Any) -> None:
logging.info("Hook API: " + format % args)

View File

@@ -66,8 +66,11 @@ class ConfirmDialog:
self._approved = False
def wait(self) -> tuple[bool, str]:
start_time = time.time()
with self._condition:
while not self._done:
if time.time() - start_time > 120:
return False, self._script
self._condition.wait(timeout=0.1)
return self._approved, self._script
@@ -79,8 +82,11 @@ class MMAApprovalDialog:
self._approved = False
def wait(self) -> tuple[bool, str]:
start_time = time.time()
with self._condition:
while not self._done:
if time.time() - start_time > 120:
return False, self._payload
self._condition.wait(timeout=0.1)
return self._approved, self._payload
@@ -94,8 +100,11 @@ class MMASpawnApprovalDialog:
self._abort = False
def wait(self) -> dict[str, Any]:
start_time = time.time()
with self._condition:
while not self._done:
if time.time() - start_time > 120:
return {'approved': False, 'abort': True, 'prompt': self._prompt, 'context_md': self._context_md}
self._condition.wait(timeout=0.1)
return {
'approved': self._approved,
@@ -109,6 +118,8 @@ class AppController:
The headless controller for the Manual Slop application.
Owns the application state and manages background services.
"""
PROVIDERS: list[str] = ["gemini", "anthropic", "gemini_cli", "deepseek"]
def __init__(self):
# Initialize locks first to avoid initialization order issues
self._send_thread_lock: threading.Lock = threading.Lock()
@@ -267,6 +278,230 @@ class AppController:
self.prior_session_entries: List[Dict[str, Any]] = []
self.test_hooks_enabled: bool = ("--enable-test-hooks" in sys.argv) or (os.environ.get("SLOP_TEST_HOOKS") == "1")
self.ui_manual_approve: bool = False
self._init_actions()
def _init_actions(self) -> None:
# Set up state-related action maps
self._clickable_actions: dict[str, Callable[..., Any]] = {
'btn_reset': self._handle_reset_session,
'btn_gen_send': self._handle_generate_send,
'btn_md_only': self._handle_md_only,
'btn_approve_script': self._handle_approve_script,
'btn_reject_script': self._handle_reject_script,
'btn_project_save': self._cb_project_save,
'btn_disc_create': self._cb_disc_create,
'btn_mma_plan_epic': self._cb_plan_epic,
'btn_mma_accept_tracks': self._cb_accept_tracks,
'btn_mma_start_track': self._cb_start_track,
'btn_mma_create_track': lambda: self._cb_create_track(self.ui_new_track_name, self.ui_new_track_desc, self.ui_new_track_type),
'btn_approve_tool': self._handle_approve_ask,
'btn_approve_mma_step': lambda: self._handle_mma_respond(approved=True),
'btn_approve_spawn': lambda: self._handle_mma_respond(approved=True),
}
self._predefined_callbacks: dict[str, Callable[..., Any]] = {
'_test_callback_func_write_to_file': self._test_callback_func_write_to_file
}
def _process_pending_gui_tasks(self) -> None:
if not self._pending_gui_tasks:
return
with self._pending_gui_tasks_lock:
tasks = self._pending_gui_tasks[:]
self._pending_gui_tasks.clear()
for task in tasks:
try:
action = task.get("action")
if action:
session_logger.log_api_hook("PROCESS_TASK", action, str(task))
# ...
if action == "refresh_api_metrics":
self._refresh_api_metrics(task.get("payload", {}), md_content=self.last_md or None)
elif action == "handle_ai_response":
payload = task.get("payload", {})
text = payload.get("text", "")
stream_id = payload.get("stream_id")
is_streaming = payload.get("status") == "streaming..."
if stream_id:
if is_streaming:
if stream_id not in self.mma_streams: self.mma_streams[stream_id] = ""
self.mma_streams[stream_id] += text
else:
self.mma_streams[stream_id] = text
if stream_id == "Tier 1":
if "status" in payload:
self.ai_status = payload["status"]
else:
if is_streaming:
self.ai_response += text
else:
self.ai_response = text
self.ai_status = payload.get("status", "done")
self._trigger_blink = True
if not stream_id:
self._token_stats_dirty = True
# ONLY add to history when turn is complete
if self.ui_auto_add_history and not stream_id and not is_streaming:
role = payload.get("role", "AI")
with self._pending_history_adds_lock:
self._pending_history_adds.append({
"role": role,
"content": self.ai_response,
"collapsed": False,
"ts": project_manager.now_ts()
})
elif action == "mma_stream_append":
payload = task.get("payload", {})
stream_id = payload.get("stream_id")
text = payload.get("text", "")
if stream_id:
if stream_id not in self.mma_streams:
self.mma_streams[stream_id] = ""
self.mma_streams[stream_id] += text
elif action == "show_track_proposal":
self.proposed_tracks = task.get("payload", [])
self._show_track_proposal_modal = True
elif action == "mma_state_update":
payload = task.get("payload", {})
self.mma_status = payload.get("status", "idle")
self.active_tier = payload.get("active_tier")
self.mma_tier_usage = payload.get("tier_usage", self.mma_tier_usage)
self.active_tickets = payload.get("tickets", [])
track_data = payload.get("track")
if track_data:
tickets = []
for t_data in self.active_tickets:
tickets.append(Ticket(**t_data))
self.active_track = Track(
id=track_data.get("id"),
description=track_data.get("title", ""),
tickets=tickets
)
elif action == "set_value":
item = task.get("item")
value = task.get("value")
if item in self._settable_fields:
attr_name = self._settable_fields[item]
setattr(self, attr_name, value)
if item == "gcli_path":
if not ai_client._gemini_cli_adapter:
ai_client._gemini_cli_adapter = ai_client.GeminiCliAdapter(binary_path=str(value))
else:
ai_client._gemini_cli_adapter.binary_path = str(value)
elif action == "click":
item = task.get("item")
user_data = task.get("user_data")
if item == "btn_project_new_automated":
self._cb_new_project_automated(user_data)
elif item == "btn_mma_load_track":
self._cb_load_track(str(user_data or ""))
elif item in self._clickable_actions:
import inspect
func = self._clickable_actions[item]
try:
sig = inspect.signature(func)
if 'user_data' in sig.parameters:
func(user_data=user_data)
else:
func()
except Exception:
func()
elif action == "select_list_item":
item = task.get("listbox", task.get("item"))
value = task.get("item_value", task.get("value"))
if item == "disc_listbox":
self._switch_discussion(str(value or ""))
elif task.get("type") == "ask":
self._pending_ask_dialog = True
self._ask_request_id = task.get("request_id")
self._ask_tool_data = task.get("data", {})
elif action == "clear_ask":
if self._ask_request_id == task.get("request_id"):
self._pending_ask_dialog = False
self._ask_request_id = None
self._ask_tool_data = None
elif action == "custom_callback":
cb = task.get("callback")
args = task.get("args", [])
if callable(cb):
try: cb(*args)
except Exception as e: print(f"Error in direct custom callback: {e}")
elif cb in self._predefined_callbacks:
self._predefined_callbacks[cb](*args)
elif action == "mma_step_approval":
dlg = MMAApprovalDialog(str(task.get("ticket_id") or ""), str(task.get("payload") or ""))
self._pending_mma_approval = task
if "dialog_container" in task:
task["dialog_container"][0] = dlg
elif action == 'refresh_from_project':
self._refresh_from_project()
elif action == "mma_spawn_approval":
spawn_dlg = MMASpawnApprovalDialog(
str(task.get("ticket_id") or ""),
str(task.get("role") or ""),
str(task.get("prompt") or ""),
str(task.get("context_md") or "")
)
self._pending_mma_spawn = task
self._mma_spawn_prompt = task.get("prompt", "")
self._mma_spawn_context = task.get("context_md", "")
self._mma_spawn_open = True
self._mma_spawn_edit_mode = False
if "dialog_container" in task:
task["dialog_container"][0] = spawn_dlg
except Exception as e:
print(f"Error executing GUI task: {e}")
def _process_pending_history_adds(self) -> None:
"""Synchronizes pending history entries to the active discussion and project state."""
with self._pending_history_adds_lock:
items = self._pending_history_adds[:]
self._pending_history_adds.clear()
if not items:
return
self._scroll_disc_to_bottom = True
for item in items:
item.get("role", "unknown")
if item.get("role") and item["role"] not in self.disc_roles:
self.disc_roles.append(item["role"])
disc_sec = self.project.get("discussion", {})
discussions = disc_sec.get("discussions", {})
disc_data = discussions.get(self.active_discussion)
if disc_data is not None:
if item.get("disc_title", self.active_discussion) == self.active_discussion:
if self.disc_entries is not disc_data.get("history"):
if "history" not in disc_data:
disc_data["history"] = []
disc_data["history"].append(project_manager.entry_to_str(item))
disc_data["last_updated"] = project_manager.now_ts()
with self._disc_entries_lock:
self.disc_entries.append(item)
def _test_callback_func_write_to_file(self, data: str) -> None:
"""A dummy function that a custom_callback would execute for testing."""
with open("test_callback_output.txt", "w") as f:
f.write(data)
def _handle_approve_script(self, user_data=None) -> None:
"""Approves the currently pending PowerShell script."""
with self._pending_dialog_lock:
dlg = self._pending_dialog
if dlg:
with dlg._condition:
dlg._approved = True
dlg._done = True
dlg._condition.notify_all()
self._pending_dialog = None
def _handle_reject_script(self, user_data=None) -> None:
"""Rejects the currently pending PowerShell script."""
with self._pending_dialog_lock:
dlg = self._pending_dialog
if dlg:
with dlg._condition:
dlg._approved = False
dlg._done = True
dlg._condition.notify_all()
self._pending_dialog = None
def init_state(self):
"""Initializes the application state from configurations."""
@@ -418,10 +653,12 @@ class AppController:
self._loop_thread = threading.Thread(target=self._run_event_loop, daemon=True)
self._loop_thread.start()
def stop_services(self) -> None:
def shutdown(self) -> None:
"""Stops background threads and cleans up resources."""
import ai_client
ai_client.cleanup()
if hasattr(self, 'hook_server') and self.hook_server:
self.hook_server.stop()
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(self._loop.stop)
if self._loop_thread and self._loop_thread.is_alive():
@@ -440,9 +677,9 @@ class AppController:
ai_client.tool_log_callback = self._on_tool_log
mcp_client.perf_monitor_callback = self.perf_monitor.get_metrics
self.perf_monitor.alert_callback = self._on_performance_alert
ai_client.events.on("request_start", self._on_api_event)
ai_client.events.on("response_received", self._on_api_event)
ai_client.events.on("tool_execution", self._on_api_event)
ai_client.events.on("request_start", lambda **kw: self._on_api_event("request_start", **kw))
ai_client.events.on("response_received", lambda **kw: self._on_api_event("response_received", **kw))
ai_client.events.on("tool_execution", lambda **kw: self._on_api_event("tool_execution", **kw))
self._settable_fields: Dict[str, str] = {
'ai_input': 'ui_ai_input',
@@ -477,12 +714,35 @@ class AppController:
"""Internal loop runner."""
asyncio.set_event_loop(self._loop)
self._loop.create_task(self._process_event_queue())
# Fallback: process queues even if GUI thread is idling/stuck (or in headless mode)
async def queue_fallback() -> None:
while True:
try:
# These methods are normally called by the GUI thread,
# but we call them here as a fallback for headless/background operations.
# The methods themselves are expected to be thread-safe or handle locks.
# Since they are on 'self' (the controller), and App delegates to them,
# we need to make sure we don't double-process if App is also calling them.
# However, _pending_gui_tasks uses a lock, so it's safe.
if hasattr(self, '_process_pending_gui_tasks'):
self._process_pending_gui_tasks()
if hasattr(self, '_process_pending_history_adds'):
self._process_pending_history_adds()
except: pass
await asyncio.sleep(0.1)
self._loop.create_task(queue_fallback())
self._loop.run_forever()
async def _process_event_queue(self) -> None:
"""Listens for and processes events from the AsyncEventQueue."""
sys.stderr.write("[DEBUG] _process_event_queue started\n")
sys.stderr.flush()
while True:
event_name, payload = await self.event_queue.get()
sys.stderr.write(f"[DEBUG] _process_event_queue got event: {event_name}\n")
sys.stderr.flush()
if event_name == "user_request":
self._loop.run_in_executor(None, self._handle_request_event, payload)
elif event_name == "response":
@@ -517,6 +777,10 @@ class AppController:
"collapsed": False,
"ts": project_manager.now_ts()
})
# Clear response area for new turn
self.ai_response = ""
csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()])
ai_client.set_custom_system_prompt("\n\n".join(csp))
ai_client.set_model_params(self.temperature, self.max_tokens, self.history_trunc_limit)
@@ -528,11 +792,13 @@ class AppController:
event.base_dir,
event.file_items,
event.disc_text,
stream=True,
stream_callback=lambda text: self._on_ai_stream(text),
pre_tool_callback=self._confirm_and_run,
qa_callback=ai_client.run_tier4_analysis
)
asyncio.run_coroutine_threadsafe(
self.event_queue.put("response", {"text": resp, "status": "done"}),
self.event_queue.put("response", {"text": resp, "status": "done", "role": "AI"}),
self._loop
)
except ProviderError as e:
@@ -546,6 +812,13 @@ class AppController:
self._loop
)
def _on_ai_stream(self, text: str) -> None:
"""Handles streaming text from the AI."""
asyncio.run_coroutine_threadsafe(
self.event_queue.put("response", {"text": text, "status": "streaming...", "role": "AI"}),
self._loop
)
def _on_comms_entry(self, entry: Dict[str, Any]) -> None:
session_logger.log_comms(entry)
entry["local_ts"] = time.time()
@@ -586,11 +859,13 @@ class AppController:
with self._pending_tool_calls_lock:
self._pending_tool_calls.append({"script": script, "result": result, "ts": time.time(), "source_tier": source_tier})
def _on_api_event(self, *args: Any, **kwargs: Any) -> None:
def _on_api_event(self, event_name: str, **kwargs: Any) -> None:
payload = kwargs.get("payload", {})
with self._pending_gui_tasks_lock:
self._pending_gui_tasks.append({"action": "refresh_api_metrics", "payload": payload})
if self.test_hooks_enabled:
with self._api_event_queue_lock:
self._api_event_queue.append({"type": event_name, "payload": payload})
def _on_performance_alert(self, message: str) -> None:
alert_text = f"[PERFORMANCE ALERT] {message}. Please consider optimizing recent changes or reducing load."
with self._pending_history_adds_lock:
@@ -601,12 +876,19 @@ class AppController:
})
def _confirm_and_run(self, script: str, base_dir: str, qa_callback: Optional[Callable[[str], str]] = None) -> Optional[str]:
sys.stderr.write(f"[DEBUG] _confirm_and_run called. test_hooks={self.test_hooks_enabled}, manual_approve={getattr(self, 'ui_manual_approve', False)}\n")
sys.stderr.flush()
if self.test_hooks_enabled and not getattr(self, "ui_manual_approve", False):
sys.stderr.write("[DEBUG] Auto-approving script.\n")
sys.stderr.flush()
self.ai_status = "running powershell..."
output = shell_runner.run_powershell(script, base_dir, qa_callback=qa_callback)
self._append_tool_log(script, output)
self.ai_status = "powershell done, awaiting AI..."
return output
sys.stderr.write("[DEBUG] Creating ConfirmDialog.\n")
sys.stderr.flush()
dialog = ConfirmDialog(script, base_dir)
is_headless = "--headless" in sys.argv
if is_headless:
@@ -625,8 +907,14 @@ class AppController:
"base_dir": str(base_dir),
"ts": time.time()
})
sys.stderr.write(f"[DEBUG] Appended script_confirmation_required to _api_event_queue. ID={dialog._uid}\n")
sys.stderr.flush()
sys.stderr.write(f"[DEBUG] Waiting for dialog ID={dialog._uid}...\n")
sys.stderr.flush()
approved, final_script = dialog.wait()
sys.stderr.write(f"[DEBUG] Dialog ID={dialog._uid} finished wait. approved={approved}\n")
sys.stderr.flush()
if is_headless:
with self._pending_dialog_lock:
if dialog._uid in self._pending_actions:
@@ -1119,25 +1407,37 @@ class AppController:
self._ask_tool_data = None
def _handle_reset_session(self) -> None:
"""Logic for resetting the AI session."""
"""Logic for resetting the AI session and GUI state."""
ai_client.reset_session()
ai_client.clear_comms_log()
self._tool_log.clear()
self._comms_log.clear()
self.disc_entries.clear()
# Clear history in project dict too
# Clear history in ALL discussions to be safe
disc_sec = self.project.get("discussion", {})
discussions = disc_sec.get("discussions", {})
if self.active_discussion in discussions:
discussions[self.active_discussion]["history"] = []
for d_name in discussions:
discussions[d_name]["history"] = []
self.ai_status = "session reset"
self.ai_response = ""
self.ui_ai_input = ""
self.ui_manual_approve = False
self.ui_auto_add_history = False
self._current_provider = "gemini"
self._current_model = "gemini-2.5-flash-lite"
ai_client.set_provider(self._current_provider, self._current_model)
with self._pending_history_adds_lock:
self._pending_history_adds.clear()
with self._api_event_queue_lock:
self._api_event_queue.clear()
with self._pending_gui_tasks_lock:
self._pending_gui_tasks.clear()
def _handle_md_only(self) -> None:
"""Logic for the 'MD Only' action."""
def worker():
try:
md, path, *_ = self._do_generate()
self.last_md = md
@@ -1147,21 +1447,25 @@ class AppController:
self._refresh_api_metrics({}, md_content=md)
except Exception as e:
self.ai_status = f"error: {e}"
threading.Thread(target=worker, daemon=True).start()
def _handle_generate_send(self) -> None:
"""Logic for the 'Gen + Send' action."""
def worker():
sys.stderr.write("[DEBUG] _handle_generate_send worker started\n")
sys.stderr.flush()
try:
md, path, file_items, stable_md, disc_text = self._do_generate()
self._last_stable_md = stable_md
self.last_md = md
self.last_md_path = path
self.last_file_items = file_items
except Exception as e:
self.ai_status = f"generate error: {e}"
return
self.ai_status = "sending..."
user_msg = self.ui_ai_input
base_dir = self.ui_files_base_dir
sys.stderr.write(f"[DEBUG] _do_generate success. Prompt: {user_msg[:50]}...\n")
sys.stderr.flush()
# Prepare event payload
event_payload = events.UserRequestEvent(
prompt=user_msg,
@@ -1175,6 +1479,14 @@ class AppController:
self.event_queue.put("user_request", event_payload),
self._loop
)
sys.stderr.write("[DEBUG] Enqueued user_request event\n")
sys.stderr.flush()
except Exception as e:
import traceback
sys.stderr.write(f"[DEBUG] _do_generate ERROR: {e}\n{traceback.format_exc()}\n")
sys.stderr.flush()
self.ai_status = f"generate error: {e}"
threading.Thread(target=worker, daemon=True).start()
def _recalculate_session_usage(self) -> None:
usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "total_tokens": 0, "last_latency": 0.0}

View File

@@ -38,6 +38,10 @@ class EventEmitter:
for callback in self._listeners[event_name]:
callback(*args, **kwargs)
def clear(self) -> None:
"""Clears all registered listeners."""
self._listeners.clear()
class AsyncEventQueue:
"""
Asynchronous event queue for decoupled communication using asyncio.Queue.
@@ -66,6 +70,14 @@ class AsyncEventQueue:
"""
return await self._queue.get()
def task_done(self) -> None:
"""Signals that a formerly enqueued task is complete."""
self._queue.task_done()
async def join(self) -> None:
"""Blocks until all items in the queue have been gotten and processed."""
await self._queue.join()
class UserRequestEvent:
"""
Payload for a user request event.

View File

@@ -103,6 +103,9 @@ class App:
def __init__(self) -> None:
# Initialize controller and delegate state
self.controller = AppController()
# Restore legacy PROVIDERS to controller if needed (it already has it via delegation if set on class level, but let's be explicit)
if not hasattr(self.controller, 'PROVIDERS'):
self.controller.PROVIDERS = PROVIDERS
self.controller.init_state()
self.controller.start_services(self)
@@ -116,55 +119,9 @@ class App:
self._pending_dialog_lock = self.controller._pending_dialog_lock
self._api_event_queue_lock = self.controller._api_event_queue_lock
# UI-specific initialization
self._init_ui_actions()
def _init_ui_actions(self) -> None:
# Set up UI-specific action maps
self._clickable_actions: dict[str, Callable[..., Any]] = {
'btn_reset': self._handle_reset_session,
'btn_gen_send': self._handle_generate_send,
'btn_md_only': self._handle_md_only,
'btn_approve_script': self._handle_approve_script,
'btn_reject_script': self._handle_reject_script,
'btn_project_save': self._cb_project_save,
'btn_disc_create': self._cb_disc_create,
'btn_mma_plan_epic': self._cb_plan_epic,
'btn_mma_accept_tracks': self._cb_accept_tracks,
'btn_mma_start_track': self._cb_start_track,
'btn_mma_create_track': lambda: self._cb_create_track(self.ui_new_track_name, self.ui_new_track_desc, self.ui_new_track_type),
'btn_approve_tool': self._handle_approve_tool,
'btn_approve_mma_step': self._handle_approve_mma_step,
'btn_approve_spawn': self._handle_approve_spawn,
}
self._predefined_callbacks: dict[str, Callable[..., Any]] = {
'_test_callback_func_write_to_file': self._test_callback_func_write_to_file
}
self._discussion_names_cache: list[str] = []
self._discussion_names_dirty: bool = True
def _handle_approve_script(self, user_data=None) -> None:
"""Approves the currently pending PowerShell script."""
with self._pending_dialog_lock:
dlg = self._pending_dialog
if dlg:
with dlg._condition:
dlg._approved = True
dlg._done = True
dlg._condition.notify_all()
self._pending_dialog = None
def _handle_reject_script(self, user_data=None) -> None:
"""Rejects the currently pending PowerShell script."""
with self._pending_dialog_lock:
dlg = self._pending_dialog
if dlg:
with dlg._condition:
dlg._approved = False
dlg._done = True
dlg._condition.notify_all()
self._pending_dialog = None
def _handle_approve_tool(self, user_data=None) -> None:
"""UI-level wrapper for approving a pending tool execution ask."""
self._handle_approve_ask()
@@ -210,194 +167,9 @@ class App:
# ---------------------------------------------------------------- logic
def _process_pending_gui_tasks(self) -> None:
if not self._pending_gui_tasks:
return
with self._pending_gui_tasks_lock:
tasks = self._pending_gui_tasks[:]
self._pending_gui_tasks.clear()
for task in tasks:
try:
action = task.get("action")
if action == "refresh_api_metrics":
self._refresh_api_metrics(task.get("payload", {}), md_content=self.last_md or None)
elif action == "handle_ai_response":
payload = task.get("payload", {})
text = payload.get("text", "")
stream_id = payload.get("stream_id")
is_streaming = payload.get("status") == "streaming..."
if stream_id:
if is_streaming:
if stream_id not in self.mma_streams: self.mma_streams[stream_id] = ""
self.mma_streams[stream_id] += text
else:
self.mma_streams[stream_id] = text
if stream_id == "Tier 1":
if "status" in payload:
self.ai_status = payload["status"]
else:
if is_streaming:
self.ai_response += text
else:
self.ai_response = text
self.ai_status = payload.get("status", "done")
self._trigger_blink = True
if not stream_id:
self._token_stats_dirty = True
if self.ui_auto_add_history and not stream_id:
role = payload.get("role", "AI")
with self._pending_history_adds_lock:
self._pending_history_adds.append({
"role": role,
"content": self.ai_response,
"collapsed": False,
"ts": project_manager.now_ts()
})
elif action == "mma_stream_append":
payload = task.get("payload", {})
stream_id = payload.get("stream_id")
text = payload.get("text", "")
if stream_id:
if stream_id not in self.mma_streams:
self.mma_streams[stream_id] = ""
self.mma_streams[stream_id] += text
elif action == "show_track_proposal":
self.proposed_tracks = task.get("payload", [])
self._show_track_proposal_modal = True
elif action == "mma_state_update":
payload = task.get("payload", {})
self.mma_status = payload.get("status", "idle")
self.active_tier = payload.get("active_tier")
self.mma_tier_usage = payload.get("tier_usage", self.mma_tier_usage)
self.active_tickets = payload.get("tickets", [])
track_data = payload.get("track")
if track_data:
tickets = []
for t_data in self.active_tickets:
tickets.append(Ticket(**t_data))
self.active_track = Track(
id=track_data.get("id"),
description=track_data.get("title", ""),
tickets=tickets
)
elif action == "set_value":
item = task.get("item")
value = task.get("value")
if item in self._settable_fields:
attr_name = self._settable_fields[item]
setattr(self, attr_name, value)
if item == "gcli_path":
if not ai_client._gemini_cli_adapter:
ai_client._gemini_cli_adapter = ai_client.GeminiCliAdapter(binary_path=str(value))
else:
ai_client._gemini_cli_adapter.binary_path = str(value)
elif action == "click":
item = task.get("item")
user_data = task.get("user_data")
if item == "btn_project_new_automated":
self._cb_new_project_automated(user_data)
elif item == "btn_mma_load_track":
self._cb_load_track(str(user_data or ""))
elif item in self._clickable_actions:
# Check if it's a method that accepts user_data
import inspect
func = self._clickable_actions[item]
try:
sig = inspect.signature(func)
if 'user_data' in sig.parameters:
func(user_data=user_data)
else:
func()
except Exception:
func()
elif action == "select_list_item":
item = task.get("listbox", task.get("item"))
value = task.get("item_value", task.get("value"))
if item == "disc_listbox":
self._switch_discussion(str(value or ""))
elif task.get("type") == "ask":
self._pending_ask_dialog = True
self._ask_request_id = task.get("request_id")
self._ask_tool_data = task.get("data", {})
elif action == "clear_ask":
if self._ask_request_id == task.get("request_id"):
self._pending_ask_dialog = False
self._ask_request_id = None
self._ask_tool_data = None
elif action == "custom_callback":
cb = task.get("callback")
args = task.get("args", [])
if callable(cb):
try: cb(*args)
except Exception as e: print(f"Error in direct custom callback: {e}")
elif cb in self._predefined_callbacks:
self._predefined_callbacks[cb](*args)
elif action == "mma_step_approval":
dlg = MMAApprovalDialog(str(task.get("ticket_id") or ""), str(task.get("payload") or ""))
self._pending_mma_approval = task
if "dialog_container" in task:
task["dialog_container"][0] = dlg
elif action == 'refresh_from_project':
self._refresh_from_project()
elif action == "mma_spawn_approval":
spawn_dlg = MMASpawnApprovalDialog(
str(task.get("ticket_id") or ""),
str(task.get("role") or ""),
str(task.get("prompt") or ""),
str(task.get("context_md") or "")
)
self._pending_mma_spawn = task
self._mma_spawn_prompt = task.get("prompt", "")
self._mma_spawn_context = task.get("context_md", "")
self._mma_spawn_open = True
self._mma_spawn_edit_mode = False
if "dialog_container" in task:
task["dialog_container"][0] = spawn_dlg
except Exception as e:
print(f"Error executing GUI task: {e}")
def _process_pending_history_adds(self) -> None:
"""Synchronizes pending history entries to the active discussion and project state."""
with self._pending_history_adds_lock:
items = self._pending_history_adds[:]
self._pending_history_adds.clear()
if not items:
return
self._scroll_disc_to_bottom = True
for item in items:
item.get("role", "unknown")
if item.get("role") and item["role"] not in self.disc_roles:
self.disc_roles.append(item["role"])
disc_sec = self.project.get("discussion", {})
discussions = disc_sec.get("discussions", {})
disc_data = discussions.get(self.active_discussion)
if disc_data is not None:
if item.get("disc_title", self.active_discussion) == self.active_discussion:
if self.disc_entries is not disc_data.get("history"):
if "history" not in disc_data:
disc_data["history"] = []
disc_data["history"].append(project_manager.entry_to_str(item))
disc_data["last_updated"] = project_manager.now_ts()
with self._disc_entries_lock:
self.disc_entries.append(item)
def shutdown(self) -> None:
"""Cleanly shuts down the app's background tasks and saves state."""
self.controller.stop_services()
# Join other threads if they exist
if self.send_thread and self.send_thread.is_alive():
self.send_thread.join(timeout=1.0)
if self.models_thread and self.models_thread.is_alive():
self.models_thread.join(timeout=1.0)
# Final State persistence
try:
ai_client.cleanup() # Destroy active API caches to stop billing
self._flush_to_project()
self._save_active_project()
self._flush_to_config()
save_config(self.config)
except: pass
self.controller.shutdown()
def _test_callback_func_write_to_file(self, data: str) -> None:
"""A dummy function that a custom_callback would execute for testing."""

View File

@@ -56,6 +56,28 @@ class VerificationLogger:
f.write(f"{status} {self.test_name} ({result_msg})\n\n")
print(f"[FINAL] {self.test_name}: {status} - {result_msg}")
@pytest.fixture(autouse=True)
def reset_ai_client() -> Generator[None, None, None]:
"""
Autouse fixture that resets the ai_client global state before each test.
This is critical for preventing state pollution between tests.
"""
import ai_client
import mcp_client
ai_client.reset_session()
# Reset callbacks to None or default to ensure no carry-over
ai_client.confirm_and_run_callback = None
ai_client.comms_log_callback = None
ai_client.tool_log_callback = None
# Clear all event listeners
ai_client.events.clear()
# Reset provider/model to defaults
ai_client.set_provider("gemini", "gemini-2.5-flash-lite")
# Reset MCP client state
mcp_client.configure([], [])
yield
ai_client.reset_session()
@pytest.fixture
def vlogger(request) -> VerificationLogger:
"""Fixture to provide a VerificationLogger instance to a test."""
@@ -109,8 +131,8 @@ def mock_app() -> Generator[App, None, None]:
app = App()
yield app
if hasattr(app, 'controller'):
app.controller.stop_services()
if hasattr(app, 'shutdown'):
app.controller.shutdown()
elif hasattr(app, 'shutdown'):
app.shutdown()
@pytest.fixture
@@ -142,7 +164,7 @@ def app_instance() -> Generator[App, None, None]:
yield app
# Cleanup: Ensure background threads and asyncio loop are stopped
if hasattr(app, 'controller'):
app.controller.stop_services()
app.controller.shutdown()
if hasattr(app, 'shutdown'):
app.shutdown()
@@ -209,10 +231,13 @@ def live_gui() -> Generator[tuple[subprocess.Popen, str], None, None]:
# Check if already running (shouldn't be)
try:
resp = requests.get("http://127.0.0.1:8999/status", timeout=0.1)
already_up = resp.status_code == 200
except: already_up = False
diag.log_state("Hook Server Port 8999", "Down", "UP" if already_up else "Down")
resp = requests.get("http://127.0.0.1:8999/status", timeout=0.5)
if resp.status_code == 200:
print("[Fixture] WARNING: Hook Server already up on port 8999. Test state might be polluted.")
# Optionally try to reset it
try: requests.post("http://127.0.0.1:8999/api/gui", json={"action": "click", "item": "btn_reset"}, timeout=1)
except: pass
except: pass
print(f"\n[Fixture] Starting {gui_script} --enable-test-hooks in {temp_workspace}...")
os.makedirs("logs", exist_ok=True)

View File

@@ -52,7 +52,6 @@ def test_tools_sim_live(live_gui: Any) -> None:
sim.run() # Ensure history is updated via the async queue
time.sleep(2)
sim.teardown()
@pytest.mark.integration
def test_execution_sim_live(live_gui: Any) -> None:
"""Run the Execution & Modals simulation against a live GUI."""
@@ -60,7 +59,11 @@ def test_execution_sim_live(live_gui: Any) -> None:
assert client.wait_for_server(timeout=10)
sim = ExecutionSimulation(client)
sim.setup("LiveExecutionSim")
# Enable manual approval to test modals
client.set_value('manual_approve', True)
client.set_value('current_provider', 'gemini_cli')
client.set_value('gcli_path', f'"{sys.executable}" "{os.path.abspath("tests/mock_gemini_cli.py")}"')
sim.run()
time.sleep(2)
sim.teardown()

View File

@@ -56,7 +56,8 @@ def test_gemini_cli_parameter_resilience(live_gui: Any) -> None:
"""
client = ApiHookClient("http://127.0.0.1:8999")
client.click("btn_reset")
time.sleep(1.5)
time.sleep(1.0)
client.set_value("auto_add_history", True)
client.set_value("manual_approve", True)
client.select_list_item("proj_files", "manual_slop")
@@ -130,7 +131,8 @@ def test_gemini_cli_loop_termination(live_gui: Any) -> None:
"""
client = ApiHookClient("http://127.0.0.1:8999")
client.click("btn_reset")
time.sleep(1.5)
time.sleep(1.0)
client.set_value("auto_add_history", True)
client.set_value("manual_approve", True)
client.select_list_item("proj_files", "manual_slop")

View File

@@ -13,7 +13,8 @@ def test_gemini_cli_full_integration(live_gui: Any) -> None:
client = ApiHookClient("http://127.0.0.1:8999")
# 0. Reset session and enable history
client.click("btn_reset")
time.sleep(1.5)
time.sleep(1.0)
client.set_value("auto_add_history", True)
client.set_value("manual_approve", True)
# Switch to manual_slop project explicitly
@@ -80,7 +81,8 @@ def test_gemini_cli_rejection_and_history(live_gui: Any) -> None:
client = ApiHookClient("http://127.0.0.1:8999")
# 0. Reset session
client.click("btn_reset")
time.sleep(1.5)
time.sleep(1.0)
client.set_value("auto_add_history", True)
client.set_value("manual_approve", True)
client.select_list_item("proj_files", "manual_slop")

View File

@@ -68,3 +68,11 @@ def test_visual_mma_components(live_gui):
assert tickets[1]['status'] == "running"
print("Visual MMA component verification PASSED.")
# Clean up the pending modal to prevent polluting subsequent tests
print("Cleaning up pending MMA modal...")
client.post_gui({
"action": "click",
"item": "btn_approve_mma_step"
})
time.sleep(0.5)