4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
362 lines
14 KiB
Python
362 lines
14 KiB
Python
"""Pure rendering tests for the ``/fleet`` dashboard table (issue #1488).
|
|
|
|
These cover ``render_agents_table`` in isolation — no slash-command
|
|
dispatch, no real registry I/O. The integration tests in
|
|
``test_agents_commands.py`` cover the dispatch path that consumes
|
|
this function.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
|
|
from surfaces.interactive_shell.ui.agents import agents_view as agents_view_mod
|
|
from surfaces.interactive_shell.ui.agents.agents_view import _build_agents_table
|
|
from tools.system.fleet_monitoring import config as config_mod
|
|
from tools.system.fleet_monitoring.probe import ProcessSnapshot
|
|
from tools.system.fleet_monitoring.registry import AgentRecord
|
|
from tools.system.fleet_monitoring.token_rate import TOKEN_RATE_TRACKER
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_agents_yaml(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
|
"""Autouse: redirect ``agents_config_path`` to a per-test tmp file
|
|
so the rendering tests don't read the developer's real
|
|
``~/.opensre/agents.yaml`` (which would let real budgets
|
|
leak into the placeholder assertions and create cross-machine
|
|
flakes).
|
|
"""
|
|
target = tmp_path / "agents.yaml"
|
|
monkeypatch.setattr(config_mod, "agents_config_path", lambda: target)
|
|
return target
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_sampler_module_state() -> None:
|
|
"""Reset module-level state in the sampler before each test.
|
|
|
|
Three globals can leak across test files:
|
|
|
|
- :data:`tools.system.fleet_monitoring.sampler._latest` (probe snapshots dict)
|
|
- :data:`tools.system.fleet_monitoring.token_rate.TOKEN_RATE_TRACKER` (per-PID deque)
|
|
- :data:`tools.system.fleet_monitoring.sampler._TickCache.registry_snapshot` and
|
|
:data:`_TickCache.agents_config` (tick caches added in #2023 to
|
|
avoid re-reading the disk on every render)
|
|
|
|
Sampler tests populate all of them; view tests must start clean
|
|
so placeholder assertions are stable under ``pytest-xdist`` and
|
|
in arbitrary alphabetical order.
|
|
"""
|
|
from tools.system.fleet_monitoring import sampler as sampler_mod
|
|
|
|
sampler_mod._latest.clear()
|
|
sampler_mod._TickCache.registry_snapshot = {}
|
|
sampler_mod._TickCache.agents_config = None
|
|
for pid in list(TOKEN_RATE_TRACKER.known_pids()):
|
|
TOKEN_RATE_TRACKER.forget(pid)
|
|
|
|
|
|
# The columns this PR ships are the contract for #1490 and later
|
|
# tickets that thread snapshot data into the rendering layer; pin
|
|
# them here so a downstream reorder doesn't silently break the
|
|
# dashboard preview.
|
|
_DASHBOARD_COLUMNS: tuple[str, ...] = (
|
|
"agent",
|
|
"pid",
|
|
"uptime",
|
|
"cpu%",
|
|
"tokens/min",
|
|
"$/hr",
|
|
"status",
|
|
)
|
|
|
|
|
|
def _render(records: list[AgentRecord]) -> tuple[Table, str]:
|
|
"""Build the table and capture the printed form for substring assertions."""
|
|
table = _build_agents_table(records)
|
|
buf = io.StringIO()
|
|
Console(file=buf, force_terminal=False, highlight=False, width=120).print(table)
|
|
return table, buf.getvalue()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Column structure — the contract downstream tickets lean on
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_table_has_full_dashboard_column_set_in_documented_order() -> None:
|
|
table, _ = _render([])
|
|
headers = tuple(str(col.header) for col in table.columns)
|
|
assert headers == _DASHBOARD_COLUMNS
|
|
|
|
|
|
def test_pid_column_is_right_justified_to_match_numeric_dashboard_preview() -> None:
|
|
"""Numeric columns (pid, uptime, cpu%, tokens/min, $/hr) are
|
|
right-justified; agent name and status are left-justified. This
|
|
matches the spacing in the issue's mock and keeps later
|
|
snapshot-injected cells aligned without re-styling."""
|
|
table, _ = _render([])
|
|
by_header = {str(col.header): col for col in table.columns}
|
|
assert by_header["pid"].justify == "right"
|
|
assert by_header["uptime"].justify == "right"
|
|
assert by_header["cpu%"].justify == "right"
|
|
assert by_header["tokens/min"].justify == "right"
|
|
assert by_header["$/hr"].justify == "right"
|
|
assert by_header["agent"].justify == "left"
|
|
assert by_header["status"].justify == "left"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Empty state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_empty_records_renders_table_with_zero_rows() -> None:
|
|
table, _ = _render([])
|
|
assert table.row_count == 0
|
|
|
|
|
|
def test_empty_records_caption_announces_empty_state() -> None:
|
|
"""Empty-state UX: the table caption tells the user the fleet
|
|
is empty rather than leaving a blank table that looks like a bug.
|
|
"""
|
|
_, out = _render([])
|
|
assert "no agents discovered or registered yet" in out
|
|
|
|
|
|
def test_non_empty_records_have_no_caption() -> None:
|
|
"""When the registry has rows, the caption is suppressed —
|
|
the table content speaks for itself and a caption would be noise."""
|
|
table, _ = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
|
|
assert table.caption is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Row content
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_row_contains_agent_name_and_pid() -> None:
|
|
_, out = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
|
|
assert "claude-code" in out
|
|
assert "8421" in out
|
|
|
|
|
|
def test_metric_cells_are_placeholders_when_no_sampler_data() -> None:
|
|
"""All five metric cells render ``-`` when the sampler has no
|
|
data for the PID (REPL not running, non-interactive ``opensre
|
|
agents list``, or fresh registration that has not yet been
|
|
sampled). #2023 split ``tokens/min`` and ``$/hr`` from the
|
|
yaml budget; both still fall back to ``-`` here for the same
|
|
"no data" reason.
|
|
"""
|
|
table, _ = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
|
|
# row_count == 1, so iterate directly to inspect the rendered cells
|
|
assert table.row_count == 1
|
|
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
|
# cells[0] = agent, cells[1] = pid, then metric cells and status.
|
|
assert rendered_cells[2:] == ["-", "-", "-", "-", "-"]
|
|
|
|
|
|
def test_table_shows_live_probe_data_when_snapshot_exists(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fixed_now = datetime(2026, 5, 10, 14, 0, 0, tzinfo=UTC)
|
|
started_at = datetime(2026, 5, 10, 12, 0, 0, tzinfo=UTC) # exactly 2h before
|
|
|
|
class FrozenDatetime(datetime):
|
|
@classmethod
|
|
def now(cls, _tz=None): # type: ignore[override]
|
|
return fixed_now
|
|
|
|
monkeypatch.setattr(agents_view_mod, "datetime", FrozenDatetime)
|
|
|
|
fake_snapshot = ProcessSnapshot(
|
|
pid=8421,
|
|
cpu_percent=23.5,
|
|
rss_mb=128.0,
|
|
num_fds=42,
|
|
num_connections=3,
|
|
status="running",
|
|
started_at=started_at,
|
|
)
|
|
monkeypatch.setattr(agents_view_mod, "get_snapshot", lambda _pid: fake_snapshot)
|
|
|
|
table, _ = _render([AgentRecord(name="cursor", pid=8444, command="cursor")])
|
|
|
|
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
|
# ``tokens/min`` and ``$/hr`` are still ``-`` because no token
|
|
# tracker entry exists for this PID — the snapshot fixture covers
|
|
# only the resource side. The full live-data case is covered by
|
|
# ``test_table_shows_tokens_and_cost_when_tracker_has_data``.
|
|
# Without recent output activity, the status heuristic falls back
|
|
# to the process start time and renders STUCK with a progress-time annotation.
|
|
assert rendered_cells[2:] == ["2h0m", "23.5", "-", "-", "[red]stuck (2h0m no progress)[/red]"]
|
|
|
|
|
|
def test_recent_agent_output_keeps_status_active(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fixed_now = datetime(2026, 5, 10, 14, 0, 0, tzinfo=UTC)
|
|
|
|
class FrozenDatetime(datetime):
|
|
@classmethod
|
|
def now(cls, _tz=None): # type: ignore[override]
|
|
return fixed_now
|
|
|
|
monkeypatch.setattr(agents_view_mod, "datetime", FrozenDatetime)
|
|
|
|
fake_snapshot = ProcessSnapshot(
|
|
pid=8421,
|
|
cpu_percent=23.5,
|
|
rss_mb=128.0,
|
|
num_fds=42,
|
|
num_connections=3,
|
|
status="running",
|
|
started_at=fixed_now - timedelta(hours=2),
|
|
last_output_at=fixed_now - timedelta(seconds=30),
|
|
)
|
|
monkeypatch.setattr(agents_view_mod, "get_snapshot", lambda _pid: fake_snapshot)
|
|
|
|
table, _ = _render([AgentRecord(name="cursor", pid=8444, command="cursor")])
|
|
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
|
|
|
assert rendered_cells[6] == "[green]active[/green]"
|
|
|
|
|
|
def test_stuck_message_anchors_to_last_output_at_not_started_at(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
fixed_now = datetime(2026, 5, 10, 14, 0, 0, tzinfo=UTC)
|
|
|
|
class FrozenDatetime(datetime):
|
|
@classmethod
|
|
def now(cls, _tz=None): # type: ignore[override]
|
|
return fixed_now
|
|
|
|
monkeypatch.setattr(agents_view_mod, "datetime", FrozenDatetime)
|
|
|
|
fake_snapshot = ProcessSnapshot(
|
|
pid=8421,
|
|
cpu_percent=5.0,
|
|
rss_mb=64.0,
|
|
num_fds=10,
|
|
num_connections=1,
|
|
status="running",
|
|
started_at=fixed_now - timedelta(hours=3),
|
|
last_output_at=fixed_now - timedelta(minutes=10),
|
|
)
|
|
monkeypatch.setattr(agents_view_mod, "get_snapshot", lambda _pid: fake_snapshot)
|
|
|
|
table, _ = _render([AgentRecord(name="cursor", pid=8444, command="cursor")])
|
|
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
|
|
|
assert rendered_cells[6] == "[red]stuck (10m no progress)[/red]"
|
|
|
|
|
|
def test_table_shows_tokens_and_cost_when_tracker_has_data(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""End-to-end of the #2023 wiring on the view side: when the
|
|
sampler's accessors return live numbers, the view formats them
|
|
into the ``tokens/min`` and ``$/hr`` columns.
|
|
"""
|
|
monkeypatch.setattr(agents_view_mod, "get_tokens_per_min", lambda _pid: 120.0)
|
|
monkeypatch.setattr(agents_view_mod, "get_usd_per_hour", lambda _pid: 0.27)
|
|
|
|
table, _ = _render([AgentRecord(name="claude-code-8421", pid=8421, command="claude")])
|
|
|
|
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
|
# cells[4] = tokens/min, cells[5] = $/hr
|
|
assert rendered_cells[4] == "120"
|
|
assert rendered_cells[5] == "$0.27"
|
|
|
|
|
|
def test_tokens_per_min_formatter_uses_k_suffix_above_1000(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A busy session emitting > 1k tokens/min must not blow out the
|
|
column width; the formatter falls back to ``1.2k`` shorthand.
|
|
"""
|
|
monkeypatch.setattr(agents_view_mod, "get_tokens_per_min", lambda _pid: 1234.0)
|
|
monkeypatch.setattr(agents_view_mod, "get_usd_per_hour", lambda _pid: None)
|
|
|
|
table, _ = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
|
|
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
|
assert rendered_cells[4] == "1.2k"
|
|
|
|
|
|
def test_idle_observed_agent_renders_zero_tokens_per_min_not_dash(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The ``0`` vs ``-`` distinction the tracker enforces must
|
|
propagate to the cell: a known-but-idle agent renders ``0``,
|
|
a never-observed agent renders ``-``.
|
|
"""
|
|
monkeypatch.setattr(agents_view_mod, "get_tokens_per_min", lambda _pid: 0.0)
|
|
monkeypatch.setattr(agents_view_mod, "get_usd_per_hour", lambda _pid: 0.0)
|
|
|
|
table, _ = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
|
|
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
|
assert rendered_cells[4] == "0"
|
|
assert rendered_cells[5] == "$0.00"
|
|
|
|
|
|
def test_usd_per_hour_renders_dash_when_model_unknown(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""``get_usd_per_hour`` returns ``None`` when the model cannot
|
|
be resolved (and therefore pricing cannot be applied). The cell
|
|
must render ``-`` rather than ``$0.00`` so the user does not
|
|
misread "unknown" as "free".
|
|
"""
|
|
monkeypatch.setattr(agents_view_mod, "get_tokens_per_min", lambda _pid: 500.0)
|
|
monkeypatch.setattr(agents_view_mod, "get_usd_per_hour", lambda _pid: None)
|
|
|
|
table, _ = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
|
|
rendered_cells = [list(col.cells)[0] for col in table.columns]
|
|
# tokens/min still shows the live figure …
|
|
assert rendered_cells[4] == "500"
|
|
# … but $/hr is honest about the missing rate.
|
|
assert rendered_cells[5] == "-"
|
|
|
|
|
|
def test_multiple_records_are_each_rendered_in_order() -> None:
|
|
records = [
|
|
AgentRecord(name="claude-code", pid=8421, command="claude"),
|
|
AgentRecord(name="cursor-tab", pid=9133, command="cursor"),
|
|
AgentRecord(name="aider", pid=7702, command="aider"),
|
|
]
|
|
table, out = _render(records)
|
|
assert table.row_count == 3
|
|
# Substring order in the rendered output preserves input order:
|
|
pos_claude = out.index("claude-code")
|
|
pos_cursor = out.index("cursor-tab")
|
|
pos_aider = out.index("aider")
|
|
assert pos_claude < pos_cursor < pos_aider
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Defense against Rich-markup injection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_record_name_is_rich_escaped_so_markup_does_not_render() -> None:
|
|
"""An adversarial agent name containing Rich markup tags must
|
|
render literally, not interpreted. Without ``escape()``, a name
|
|
like ``[bold red]ghost[/]`` would visually mimic a styled cell
|
|
and could mask other dashboard content."""
|
|
records = [
|
|
AgentRecord(name="[bold red]ghost[/]", pid=1, command="bin"),
|
|
]
|
|
_, out = _render(records)
|
|
# Literal brackets survive in the rendered output:
|
|
assert "[bold red]ghost[/]" in out
|
|
|
|
|
|
# Resilience to a schema-invalid ``agents.yaml`` is now enforced by
|
|
# the sampler's catch-all around ``load_agents_config`` (see
|
|
# ``test_sampler.py``); the view no longer touches the config.
|