diff --git a/src/api_hook_client.py b/src/api_hook_client.py index 162886e3..44228790 100644 --- a/src/api_hook_client.py +++ b/src/api_hook_client.py @@ -358,6 +358,21 @@ class ApiHookClient: """ return self._make_request('GET', '/api/project') or {} + def get_project_switch_status(self) -> dict[str, Any]: + """ + Returns the project switch status: {in_progress, path, error}. + - in_progress: True when a project switch is currently scheduled or running + - path: the path the switch is targeting (or the last completed switch path) + - error: string error message if the last switch failed; None on success + Used by tests to wait deterministically for a project switch to complete + instead of blind-polling the project state. + [C: tests/test_api_hooks_project_switch.py] + """ + result = self._make_request('GET', '/api/project_switch_status') + if not result or not isinstance(result, dict): + return {"in_progress": False, "path": None, "error": None} + return result + def post_project(self, project_data: dict) -> dict[str, Any]: """ Updates the current project configuration. diff --git a/src/api_hooks.py b/src/api_hooks.py index cc1ed295..a65a856f 100644 --- a/src/api_hooks.py +++ b/src/api_hooks.py @@ -114,6 +114,26 @@ class HookHandler(BaseHTTPRequestHandler): 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/project_switch_status": + # Determinstic signal for tests waiting on a project switch to complete. + # Polling /api/project returns derived state that may be stale from prior + # tests; this endpoint tracks in_progress/error explicitly. + # in_progress: True while _do_project_switch is scheduled or running + # path: target path (or last completed path) + # error: string error if last switch failed; None on success/idle + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + controller = _get_app_attr(app, "controller", None) + if controller is None: + payload = {"in_progress": False, "path": None, "error": None} + else: + payload = { + "in_progress": bool(getattr(controller, "_project_switch_in_progress", False)), + "path": getattr(controller, "_project_switch_pending_path", None) or getattr(controller, "active_project_path", None), + "error": getattr(controller, "_project_switch_error", None), + } + self.wfile.write(json.dumps(payload).encode("utf-8")) elif self.path == "/api/session": self.send_response(200) self.send_header("Content-Type", "application/json") diff --git a/src/app_controller.py b/src/app_controller.py index 794bcdeb..bce61c92 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -803,6 +803,7 @@ class AppController: self._project_switch_lock: threading.Lock = threading.Lock() self._project_switch_in_progress: bool = False self._project_switch_pending_path: Optional[str] = None + self._project_switch_error: Optional[str] = None # --- Shared background pool + proactive warmup (startup_speedup_20260606) --- self._io_pool = make_io_pool() _install_sigint_exit_handler(self) @@ -2690,12 +2691,14 @@ class AppController: self.ai_status = "config saved" def _do_project_switch(self, path: str) -> None: + self._project_switch_error = None try: self._flush_to_project() try: new_project = project_manager.load_project(path) except Exception as e: self.ai_status = f"failed to load project: {e}" + self._project_switch_error = f"load failed: {e}" return try: self.project = new_project @@ -2707,6 +2710,7 @@ class AppController: self.persona_manager = PersonaManager(new_root) except Exception as e: self.ai_status = f"failed to init managers: {e}" + self._project_switch_error = f"manager init failed: {e}" return self._refresh_from_project() file_items_as_dicts = [{"path": f.path if hasattr(f, "path") else str(f)} for f in self.files] diff --git a/tests/test_api_hooks_project_switch.py b/tests/test_api_hooks_project_switch.py new file mode 100644 index 00000000..b1ebe824 --- /dev/null +++ b/tests/test_api_hooks_project_switch.py @@ -0,0 +1,52 @@ +import pytest +import sys +import os +from unittest.mock import patch + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from src.api_hook_client import ApiHookClient + + +def test_get_project_switch_status_calls_correct_endpoint() -> None: + """get_project_switch_status() hits GET /api/project_switch_status.""" + client = ApiHookClient() + with patch.object(client, "_make_request") as mock_make: + mock_make.return_value = {"in_progress": False, "path": None, "error": None} + result = client.get_project_switch_status() + assert "in_progress" in result + assert "path" in result + assert "error" in result + mock_make.assert_called_once_with("GET", "/api/project_switch_status") + + +def test_get_project_switch_status_handles_empty_response() -> None: + """get_project_switch_status() returns safe default when server returns None.""" + client = ApiHookClient() + with patch.object(client, "_make_request") as mock_make: + mock_make.return_value = None + result = client.get_project_switch_status() + assert result == {"in_progress": False, "path": None, "error": None} + + +def test_get_project_switch_status_default_is_idle() -> None: + """get_project_switch_status() returns idle state when not in a live session.""" + client = ApiHookClient() + result = client.get_project_switch_status() + assert result["in_progress"] is False + assert result["path"] is None + assert result["error"] is None + + +def test_live_project_switch_status_endpoint_idle(live_gui) -> None: + """Live: GET /api/project_switch_status returns well-formed idle response.""" + client = ApiHookClient() + assert client.wait_for_server(timeout=10) + status = client.get_project_switch_status() + assert "in_progress" in status + assert "path" in status + assert "error" in status + assert isinstance(status["in_progress"], bool) + assert status["in_progress"] is False, "expected no project switch in flight at session start" + assert status["path"] is None + assert status["error"] is None