Files
agentic-mobile-control/tests/test_cloud_planner_decision_endpoint.py
T
q792602257 a68f609453 Implement cloud-planner-proxy: AI planner routes through Cloud API
Implements all 19 tasks of the cloud-planner-proxy OpenSpec change:

- Cloud API: cloud.planner_config (CloudPlannerConfig, load/build helpers)
  reusing runtime.tool_calling_client provider clients (no new dependency
  needed -- device-cloud-platform already depends on device-agent-runtime).
- Cloud API: new host-scoped POST /internal/v1/hosts/{host_id}/planner/decide
  internal endpoint, reusing existing bearer auth; logs only metadata
  (host id, tool name, latency, error class), never prompt/screenshot
  content.
- Host Agent: new AI_PLANNER_TRANSPORT config (direct default | cloud) and
  host_agent/cloud_planner_client.py::CloudProxyToolCallingClient, a
  synchronous ToolCallingClient implementation (structural, not importing
  runtime) that calls the new endpoint via its own httpx.Client -- avoids
  bridging the async HostAgentClient across the worker-thread boundary
  that AIPlanner.plan() runs in (asyncio.to_thread in lease.py).
- Host Agent wiring: create_execution_factories()/_host_agent_planner()
  select the cloud-proxy client only when AI_PLANNER_TRANSPORT=cloud;
  direct/unset transport is unchanged (still the default).
- Tests: 22 new tests across Cloud API config, the new endpoint, the new
  client, and transport-selection wiring; full non-integration suite
  (492 tests) passes with no regressions.
- Docs: docs/CLOUD_DEPLOYMENT.md documents the cloud transport, its
  trade-offs, and the credential split between Host Agent and Cloud API.

proposal.md/design.md were corrected during implementation to reflect two
findings: no new anthropic/openai dependency is actually needed, and
CloudProxyToolCallingClient uses its own sync httpx.Client rather than a
new HostAgentClient method, per the thread-boundary reasoning above.
2026-07-13 21:27:48 +08:00

206 lines
6.5 KiB
Python

from __future__ import annotations
from fastapi import FastAPI
from fastapi.testclient import TestClient
from cloud.auth import BearerCredential, ConfiguredBearerAuthProvider
from cloud.config import CloudConfig
from cloud.internal_api.api import create_internal_router
from cloud.pool import DevicePool
from cloud.store import CloudStore
from runtime.tool_calling_client import ToolCallDecision, ToolCallUnavailable
from runtime.tool_specs import ToolSpec
class _FakeToolCallingClient:
"""Stand-in ``ToolCallingClient`` for endpoint tests -- never calls a real
LLM provider."""
def __init__(
self, decision: ToolCallDecision | None = None, error: str | None = None
):
self.decision = decision
self.error = error
self.calls: list[dict[str, object]] = []
def decide(
self,
*,
system_prompt: str,
user_prompt: str,
screenshot: bytes | None,
tools: list[ToolSpec],
timeout: float,
) -> ToolCallDecision:
self.calls.append(
{
"system_prompt": system_prompt,
"user_prompt": user_prompt,
"screenshot": screenshot,
"tools": tools,
"timeout": timeout,
}
)
if self.error is not None:
raise ToolCallUnavailable(self.error)
assert self.decision is not None
return self.decision
def _build_client(
tmp_path, *, fake_client: _FakeToolCallingClient
) -> tuple[TestClient, _FakeToolCallingClient]:
pool = DevicePool(
CloudStore(tmp_path / "internal.sqlite3"),
CloudConfig(stale_after_seconds=60),
)
auth_provider = ConfiguredBearerAuthProvider(
[
BearerCredential(principal_id="agent-a", token="token-a", host_id="host-a"),
BearerCredential(principal_id="agent-b", token="token-b", host_id="host-b"),
]
)
app = FastAPI()
app.include_router(
create_internal_router(
pool=pool,
auth_provider=auth_provider,
planner_client_factory=lambda: fake_client,
)
)
return TestClient(app), fake_client
def _decision_payload(**overrides: object) -> dict[str, object]:
payload = {
"host_id": "host-a",
"system_prompt": "you are a planner",
"user_prompt": "tap the login button",
"screenshot_base64": None,
"tools": [
{
"name": "tap",
"description": "tap an element",
"parameters": {"type": "object", "properties": {}},
}
],
"timeout_seconds": 30.0,
}
payload.update(overrides)
return payload
def test_authenticated_host_resolves_planner_decision(tmp_path) -> None:
fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
)
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
response = client.post(
"/internal/v1/hosts/host-a/planner/decide",
headers={"Authorization": "Bearer token-a"},
json=_decision_payload(),
)
assert response.status_code == 200
assert response.json() == {"tool_name": "tap", "arguments": {"x": 1, "y": 2}}
assert len(fake_client.calls) == 1
assert fake_client.calls[0]["system_prompt"] == "you are a planner"
assert fake_client.calls[0]["timeout"] == 30.0
def test_planner_decision_decodes_screenshot_base64(tmp_path) -> None:
fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={})
)
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
response = client.post(
"/internal/v1/hosts/host-a/planner/decide",
headers={"Authorization": "Bearer token-a"},
json=_decision_payload(screenshot_base64="aGVsbG8="),
)
assert response.status_code == 200
assert fake_client.calls[0]["screenshot"] == b"hello"
def test_invalid_screenshot_base64_is_rejected(tmp_path) -> None:
fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={})
)
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
response = client.post(
"/internal/v1/hosts/host-a/planner/decide",
headers={"Authorization": "Bearer token-a"},
json=_decision_payload(screenshot_base64="not-valid-base64!!"),
)
assert response.status_code == 422
assert fake_client.calls == []
def test_unauthenticated_request_is_rejected(tmp_path) -> None:
fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={})
)
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
response = client.post(
"/internal/v1/hosts/host-a/planner/decide",
json=_decision_payload(),
)
assert response.status_code == 401
assert fake_client.calls == []
def test_foreign_host_token_cannot_request_another_hosts_decision(tmp_path) -> None:
fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={})
)
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
response = client.post(
"/internal/v1/hosts/host-a/planner/decide",
headers={"Authorization": "Bearer token-b"},
json=_decision_payload(),
)
assert response.status_code == 403
assert fake_client.calls == []
def test_path_and_payload_host_id_mismatch_is_rejected(tmp_path) -> None:
fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={})
)
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
response = client.post(
"/internal/v1/hosts/host-a/planner/decide",
headers={"Authorization": "Bearer token-a"},
json=_decision_payload(host_id="host-b"),
)
assert response.status_code == 422
assert fake_client.calls == []
def test_provider_failure_returns_structured_error_without_crashing(tmp_path) -> None:
fake_client = _FakeToolCallingClient(error="anthropic timed out")
client, fake_client = _build_client(tmp_path, fake_client=fake_client)
response = client.post(
"/internal/v1/hosts/host-a/planner/decide",
headers={"Authorization": "Bearer token-a"},
json=_decision_payload(),
)
assert response.status_code == 502
assert response.json() == {
"code": "planner_unavailable",
"detail": "anthropic timed out",
}