3.3 KiB
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:
- Derived state lags. The project dict updates AFTER
_do_project_switchfinishes. Polling the project dict during the switch returns stale state from the previous test. - 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.
- 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:
- A single source of truth for the in-progress state.
- A failure reason surfaced via an
errorfield. - A bounded poll that times out with a clear message instead of looping forever.
- An idempotent retry contract — the test can re-poll safely without side effects.
Pattern (the canonical example)
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
# 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/<thing>_status endpoint and a client.wait_for_<thing>() helper. Do NOT add a derived-state poll helper to the client; the source of truth is the controller's _handle_<thing> state, exposed via the new endpoint.