diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d0dcc33..4741f87 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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, diff --git a/conductor/tracks/test_architecture_integrity_audit_20260304/report_gemini.md b/conductor/tracks/test_architecture_integrity_audit_20260304/report_gemini.md new file mode 100644 index 0000000..83a08e2 --- /dev/null +++ b/conductor/tracks/test_architecture_integrity_audit_20260304/report_gemini.md @@ -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.* \ No newline at end of file diff --git a/config.toml b/config.toml index d139338..4d311e3 100644 --- a/config.toml +++ b/config.toml @@ -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 diff --git a/simulation/sim_base.py b/simulation/sim_base.py index 3927afe..1339a27 100644 --- a/simulation/sim_base.py +++ b/simulation/sim_base.py @@ -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() diff --git a/src/ai_client.py b/src/ai_client.py index eec3160..5983a6f 100644 --- a/src/ai_client.py +++ b/src/ai_client.py @@ -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,10 +1227,7 @@ 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}) + 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({ "type": "text", @@ -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: diff --git a/src/api_hook_client.py b/src/api_hook_client.py index 496c0cb..728f8c7 100644 --- a/src/api_hook_client.py +++ b/src/api_hook_client.py @@ -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 diff --git a/src/api_hooks.py b/src/api_hooks.py index d7be5fa..7ab3d69 100644 --- a/src/api_hooks.py +++ b/src/api_hooks.py @@ -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) diff --git a/src/app_controller.py b/src/app_controller.py index 4f699c2..5e58430 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -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,62 +1407,86 @@ 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.""" - try: - md, path, *_ = self._do_generate() - self.last_md = md - self.last_md_path = path - self.ai_status = f"md written: {path.name}" - # Refresh token budget metrics with CURRENT md - self._refresh_api_metrics({}, md_content=md) - except Exception as e: - self.ai_status = f"error: {e}" + def worker(): + try: + md, path, *_ = self._do_generate() + self.last_md = md + self.last_md_path = path + self.ai_status = f"md written: {path.name}" + # Refresh token budget metrics with CURRENT md + 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.""" - 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 - # Prepare event payload - event_payload = events.UserRequestEvent( - prompt=user_msg, - stable_md=stable_md, - file_items=file_items, - disc_text=disc_text, - base_dir=base_dir - ) - # Push to async queue - asyncio.run_coroutine_threadsafe( - self.event_queue.put("user_request", event_payload), - self._loop - ) + 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 + + 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, + stable_md=stable_md, + file_items=file_items, + disc_text=disc_text, + base_dir=base_dir + ) + # Push to async queue + asyncio.run_coroutine_threadsafe( + 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} diff --git a/src/events.py b/src/events.py index a033f71..b2ff403 100644 --- a/src/events.py +++ b/src/events.py @@ -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. diff --git a/src/gui_2.py b/src/gui_2.py index b48c0cf..c433962 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -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.""" diff --git a/tests/conftest.py b/tests/conftest.py index 025e32c..f0e294b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_extended_sims.py b/tests/test_extended_sims.py index d0a3396..6d6bb74 100644 --- a/tests/test_extended_sims.py +++ b/tests/test_extended_sims.py @@ -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() + diff --git a/tests/test_gemini_cli_edge_cases.py b/tests/test_gemini_cli_edge_cases.py index 89050cf..21f4e3a 100644 --- a/tests/test_gemini_cli_edge_cases.py +++ b/tests/test_gemini_cli_edge_cases.py @@ -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") diff --git a/tests/test_gemini_cli_integration.py b/tests/test_gemini_cli_integration.py index 0540e7f..9fa5db5 100644 --- a/tests/test_gemini_cli_integration.py +++ b/tests/test_gemini_cli_integration.py @@ -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") diff --git a/tests/test_visual_mma.py b/tests/test_visual_mma.py index c1aa4bf..991f3d3 100644 --- a/tests/test_visual_mma.py +++ b/tests/test_visual_mma.py @@ -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)