# Tests that wait for asynchronous state MUST poll a purpose-built signal endpoint — NOT poll derived state ## What it says When a test or external automation needs to wait for an asynchronous state change (project switch, file creation, hook completion, etc.), it MUST poll a **purpose-built signal endpoint** that returns the exact state-of-interest (e.g., `/api/project_switch_status` returning `{"in_progress": bool, "path": str | null, "error": str | null}`). Do NOT poll derived state (the project dict, the file list, the hook log) — derived state is stale until the originating state settles. ## Why Polling derived state is fragile because: 1. **Derived state lags.** The project dict updates AFTER `_do_project_switch` finishes. Polling the project dict during the switch returns stale state from the previous test. 2. **Polling doesn't surface failure reasons.** If the switch fails, the project dict returns the OLD state forever; the test times out without knowing why. 3. **Polling is opaque.** The test cannot distinguish "switch in progress" from "switch failed and state is back to old" without parsing logs. A purpose-built signal endpoint gives the test: 1. **A single source of truth** for the in-progress state. 2. **A failure reason** surfaced via an `error` field. 3. **A bounded poll** that times out with a clear message instead of looping forever. 4. **An idempotent retry contract** — the test can re-poll safely without side effects. ## Pattern (the canonical example) ```python def wait_for_project_switch( self, expected_path: str, timeout: float = 30.0, interval: float = 0.5, ) -> str: """Poll /api/project_switch_status until the switch completes or times out. Returns the final path. Raises ApiTimeoutError if timeout exceeded or the error field is non-null. """ deadline = time.monotonic() + timeout while time.monotonic() < deadline: status = self.get_project_switch_status() if not status["in_progress"]: if status["error"] is not None: raise ApiTimeoutError( f"project switch failed: {status['error']}" ) if status["path"] != expected_path: raise ApiTimeoutError( f"project switch wrong path: " f"expected={expected_path}, got={status['path']}" ) return status["path"] time.sleep(interval) raise ApiTimeoutError( f"project switch did not complete within {timeout}s" ) ``` ## The forbidden pattern ```python # WRONG: poll derived state while True: project = client.get_project_dict() if project.get("active_path") == expected_path: break time.sleep(0.5) # FAILS: stale state; no failure reason; opaque timeout ``` ## When to add a new signal endpoint When a test needs to wait for: - a state transition (file created, project switched, hook completed, panel opened) - a result that can fail (workflow ran, AI responded, batch completed) - a per-turn event in a multi-step process …add a `/api/_status` endpoint and a `client.wait_for_()` helper. Do NOT add a derived-state poll helper to the client; the source of truth is the controller's `_handle_` state, exposed via the new endpoint.