Files
agentic-mobile-control/tests/test_host_agent_progress_reporting.py
T
q792602257andClaude Opus 4.6 ec261d57c2 feat: surface task execution progress across Host Agent and Cloud
Host Agent now persists step-level execution detail locally (via a real
TaskMetadataStore/Timeline wired into TaskRunner) and reports a bounded
in-progress snapshot piggybacked on lease renewal. Cloud persists that
snapshot per active assignment and exposes it through the existing task
list/detail query path; Cloud Console renders it as a live badge. Host
Agent's local console gains authenticated, read-only task list and
detail/timeline pages (same-origin, server-rendered) with inlined
screenshots.

Also fixes a pre-existing gap in the shared Timeline: the actual
per-step LLM prompt is now recorded instead of the task goal, benefiting
both Runtime and Host Agent consoles. When a host uses the cloud planner
transport, each decide call's prompt and resulting tool decision are
durably logged in a new planner_decision_log table (with bounded
retention) and browsable from Cloud Console; direct-transport hosts
explicitly surface a "not reported" state.

Includes Alembic migrations 0008 (progress columns on scheduled_tasks)
and 0009 (planner_decision_log), bounded Host-Agent-local retention,
dual-backend repository parity, and Vitest + pytest coverage. Task 6.5
(manual end-to-end device verification) remains.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 12:47:49 +08:00

236 lines
7.1 KiB
Python

"""Tests for Host Agent progress reporting via lease renewal (task 2.4)."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from threading import Event
from typing import Any
import httpx
from cloud.internal_api.models import (
AssignmentModel,
LeaseRenewalResponse,
)
from host_agent.config import HostAgentConfig
from host_agent.lease import ActiveAssignmentRunner
from host_agent.progress import TaskProgressHolder, TaskProgressSnapshot
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _assignment(
*,
lease_expires_at: datetime | None = None,
) -> AssignmentModel:
return AssignmentModel(
task_id="task-progress",
attempt=1,
lease_id="lease-1",
lease_expires_at=lease_expires_at or datetime.now(UTC) + timedelta(minutes=5),
host_id="host-a",
device_id="device-a",
goal="do something",
)
class _FakeExecutor:
"""Stub executor that optionally fires a progress callback then blocks."""
def __init__(
self,
*,
fire_progress_before_block: bool = False,
progress_payload: tuple[int, str, str] = (1, "running", "step 1 summary"),
block_event: Event | None = None,
) -> None:
self._fire = fire_progress_before_block
self._payload = progress_payload
self._block_event = block_event or Event()
self._progress = TaskProgressHolder()
def execute(
self,
assignment: AssignmentModel,
*,
should_stop: Any | None = None,
) -> Any:
from host_agent.assignment import AssignmentExecutionResult
if self._fire:
self._progress.update(*self._payload)
# Block until test signals completion.
self._block_event.wait(timeout=5)
return AssignmentExecutionResult(status="done")
def latest_progress(self) -> TaskProgressSnapshot | None:
return self._progress.snapshot()
class _RecordingClient:
"""Fake HostAgentClient that records each renew() call's progress argument."""
def __init__(self) -> None:
self.renew_calls: list[TaskProgressSnapshot | None] = []
self._call_count = 0
async def renew(
self,
assignment: AssignmentModel,
*,
progress: TaskProgressSnapshot | None = None,
) -> LeaseRenewalResponse:
self.renew_calls.append(progress)
self._call_count += 1
return LeaseRenewalResponse(
status="renewed",
lease_expires_at=datetime.now(UTC) + timedelta(minutes=5),
)
def _runner(
client: _RecordingClient, executor: _FakeExecutor
) -> ActiveAssignmentRunner:
return ActiveAssignmentRunner(client, executor, now=lambda: datetime.now(UTC))
# --------------------------------------------------------------------------- #
# Renewal includes progress after a step completes
# --------------------------------------------------------------------------- #
def test_renewal_includes_progress_after_step(tmp_path) -> None:
block_event = Event()
executor = _FakeExecutor(
fire_progress_before_block=True,
progress_payload=(1, "running", "step 1 summary"),
block_event=block_event,
)
client = _RecordingClient()
runner = _runner(client, executor)
# Set a very short lease so renewal fires quickly.
assignment = _assignment(
lease_expires_at=datetime.now(UTC) + timedelta(milliseconds=50),
)
async def _drive() -> None:
# Start the runner; execution thread will fire progress then block.
task = asyncio.create_task(runner.run(assignment))
# Wait for at least one renewal to happen.
for _ in range(50):
await asyncio.sleep(0.05)
if client.renew_calls:
break
# Signal the executor to complete.
block_event.set()
await task
asyncio.run(_drive())
assert len(client.renew_calls) > 0
# At least one renewal should have non-None progress.
progress_renewals = [p for p in client.renew_calls if p is not None]
assert len(progress_renewals) > 0
snap = progress_renewals[0]
assert snap.step_index == 1
assert snap.step_status == "running"
assert "step 1 summary" in snap.summary
# --------------------------------------------------------------------------- #
# Renewal omits progress before any step completes
# --------------------------------------------------------------------------- #
def test_renewal_omits_progress_before_any_step(tmp_path) -> None:
block_event = Event()
executor = _FakeExecutor(
fire_progress_before_block=False,
block_event=block_event,
)
client = _RecordingClient()
runner = _runner(client, executor)
assignment = _assignment(
lease_expires_at=datetime.now(UTC) + timedelta(milliseconds=50),
)
async def _drive() -> None:
task = asyncio.create_task(runner.run(assignment))
for _ in range(50):
await asyncio.sleep(0.05)
if client.renew_calls:
break
block_event.set()
await task
asyncio.run(_drive())
assert len(client.renew_calls) > 0
# No progress should have been reported.
assert all(p is None for p in client.renew_calls)
# --------------------------------------------------------------------------- #
# Oversized summary truncated client-side before sending
# --------------------------------------------------------------------------- #
def test_oversized_summary_truncated_client_side() -> None:
"""HostAgentClient.renew truncates summary to <=500 chars before serializing."""
from host_agent.client import HostAgentClient
captured_payload: dict[str, Any] = {}
def _handler(request: httpx.Request) -> httpx.Response:
import json
body = json.loads(request.content.decode("utf-8"))
captured_payload.update(body)
return httpx.Response(
200,
json={
"status": "renewed",
"lease_expires_at": (
datetime.now(UTC) + timedelta(minutes=5)
).isoformat(),
},
)
transport = httpx.MockTransport(_handler)
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
retry_backoff_seconds=0.01,
max_retry_attempts=1,
)
http_client = httpx.AsyncClient(
base_url=config.control_plane_url, transport=transport
)
client = HostAgentClient(config, http_client=http_client)
long_summary = "x" * 2000
snapshot = TaskProgressSnapshot(
step_index=1,
step_status="running",
summary=long_summary,
updated_at=datetime.now(UTC),
)
asyncio.run(client.renew(_assignment(), progress=snapshot))
assert "progress" in captured_payload
assert captured_payload["progress"] is not None
serialized_summary = captured_payload["progress"]["summary"]
assert len(serialized_summary) <= 500
assert serialized_summary == "x" * 500
asyncio.run(http_client.aclose())