Private
Public Access
0
0
Files

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:

  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)

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.