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
837 lines
35 KiB
Python
837 lines
35 KiB
Python
"""Unit tests for /fleet slash command and conflict renderer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
|
|
from surfaces.interactive_shell.command_registry import SLASH_COMMANDS, dispatch_slash
|
|
from surfaces.interactive_shell.command_registry.agents import core as agents_core
|
|
from surfaces.interactive_shell.command_registry.agents import trace as agents_trace
|
|
from surfaces.interactive_shell.command_registry.agents.conflicts_view import render_conflicts
|
|
from surfaces.interactive_shell.command_registry.agents.trace import _slice_to_utf8_boundary
|
|
from surfaces.interactive_shell.session import Session
|
|
from tools.system.fleet_monitoring import config as config_mod
|
|
from tools.system.fleet_monitoring.conflicts import DEFAULT_WINDOW_SECONDS, FileWriteConflict
|
|
from tools.system.fleet_monitoring.registry import AgentRecord, AgentRegistry
|
|
from tools.system.fleet_monitoring.tail import AttachUnsupported, TailBuffer
|
|
|
|
|
|
def _capture() -> tuple[Console, io.StringIO]:
|
|
buf = io.StringIO()
|
|
return Console(file=buf, force_terminal=False, highlight=False, width=120), buf
|
|
|
|
|
|
def _isolate_registry(monkeypatch: pytest.MonkeyPatch, path: Path) -> AgentRegistry:
|
|
"""Point the slash command's ``AgentRegistry()`` constructor at
|
|
``path`` so tests don't read the developer's real
|
|
``~/.opensre/agents.jsonl``. Returns the registry instance
|
|
that the test can populate.
|
|
"""
|
|
registry = AgentRegistry(path=path)
|
|
|
|
monkeypatch.setattr(agents_core, "AgentRegistry", lambda: AgentRegistry(path=path))
|
|
monkeypatch.setattr(agents_trace, "AgentRegistry", lambda: AgentRegistry(path=path))
|
|
monkeypatch.setattr(
|
|
agents_core,
|
|
"registered_and_discovered_agents",
|
|
lambda _registry=None: AgentRegistry(path=path).list(),
|
|
)
|
|
return registry
|
|
|
|
|
|
@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 path so
|
|
``/fleet`` (which now reads ``agents.yaml`` for the ``$/hr`` cell)
|
|
and ``/fleet budget`` never touch the developer's real
|
|
``~/.opensre/agents.yaml``.
|
|
"""
|
|
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 (#2023).
|
|
|
|
Same isolation pattern as ``tests/interactive_shell/ui/test_agents_view.py``:
|
|
probe snapshots, the token rate tracker, and the per-tick caches
|
|
all live as module globals and can leak across test files.
|
|
"""
|
|
from tools.system.fleet_monitoring import sampler as sampler_mod
|
|
from tools.system.fleet_monitoring.token_rate import TOKEN_RATE_TRACKER
|
|
|
|
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)
|
|
|
|
|
|
class TestAgentsRegistration:
|
|
def test_agents_command_is_registered(self) -> None:
|
|
assert "/fleet" in SLASH_COMMANDS
|
|
|
|
def test_agents_first_arg_completions_include_conflicts(self) -> None:
|
|
cmd = SLASH_COMMANDS["/fleet"]
|
|
keywords = [pair[0] for pair in cmd.first_arg_completions]
|
|
assert "conflicts" in keywords
|
|
|
|
def test_agents_first_arg_completions_include_trace(self) -> None:
|
|
cmd = SLASH_COMMANDS["/fleet"]
|
|
keywords = [pair[0] for pair in cmd.first_arg_completions]
|
|
assert "trace" in keywords
|
|
|
|
def test_agents_first_arg_completions_include_wait_and_graph(self) -> None:
|
|
cmd = SLASH_COMMANDS["/fleet"]
|
|
keywords = [pair[0] for pair in cmd.first_arg_completions]
|
|
assert "wait" in keywords
|
|
assert "graph" in keywords
|
|
|
|
def test_default_window_constant_is_ten_seconds(self) -> None:
|
|
assert DEFAULT_WINDOW_SECONDS == 10.0
|
|
|
|
|
|
class TestAgentsDispatch:
|
|
def test_conflicts_with_empty_event_source_renders_empty_state(self) -> None:
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet conflicts", session, console) is True
|
|
assert "no conflicts detected" in buf.getvalue()
|
|
|
|
def test_no_subcommand_with_empty_registry_renders_empty_state(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_isolate_registry(monkeypatch, tmp_path / "agents.jsonl")
|
|
session = Session()
|
|
console, buf = _capture()
|
|
|
|
assert dispatch_slash("/fleet", session, console) is True
|
|
|
|
out = buf.getvalue()
|
|
# Caption from agents_view.render_agents_table:
|
|
assert "no agents discovered or registered" in out
|
|
# Header row still rendered with the dashboard column structure:
|
|
assert "agent" in out
|
|
assert "pid" in out
|
|
|
|
def test_no_subcommand_renders_registered_agents(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
registry = _isolate_registry(monkeypatch, tmp_path / "agents.jsonl")
|
|
registry.register(AgentRecord(name="claude-code", pid=8421, command="claude"))
|
|
registry.register(AgentRecord(name="cursor-tab", pid=9133, command="cursor"))
|
|
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet", session, console) is True
|
|
|
|
out = buf.getvalue()
|
|
assert "claude-code" in out
|
|
assert "8421" in out
|
|
assert "cursor-tab" in out
|
|
assert "9133" in out
|
|
|
|
def test_no_subcommand_renders_discovered_agents(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
agents_core,
|
|
"registered_and_discovered_agents",
|
|
lambda _registry=None: [
|
|
AgentRecord(
|
|
name="cursor-claude-code",
|
|
pid=80435,
|
|
command="claude --output-format stream-json",
|
|
source="discovered",
|
|
)
|
|
],
|
|
)
|
|
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet", session, console) is True
|
|
|
|
out = buf.getvalue()
|
|
assert "cursor-claude-code" in out
|
|
assert "80435" in out
|
|
|
|
def test_unknown_subcommand_prints_error(self) -> None:
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet bogus", session, console) is True
|
|
out = buf.getvalue()
|
|
assert "unknown subcommand" in out.lower()
|
|
assert "bogus" in out
|
|
|
|
def test_unknown_subcommand_message_lists_trace(self) -> None:
|
|
# When the user types a bogus subcommand the help string should
|
|
# advertise every supported one, including ``bus`` and ``trace``.
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet bogus", session, console) is True
|
|
out = buf.getvalue().lower()
|
|
assert "bus" in out
|
|
assert "trace" in out
|
|
|
|
def test_dollar_hr_cell_does_not_read_budget_from_agents_yaml(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Regression for #2023's semantic split: ``$/hr`` is observed
|
|
cost (sampler-driven), not the configured budget.
|
|
|
|
Setting ``/fleet budget`` persists ``hourly_budget_usd`` for
|
|
a future alarm feature, but it must NOT appear in the ``$/hr``
|
|
column of ``/fleet``. The cell renders ``-`` for a
|
|
not-yet-observed PID, regardless of any configured budget.
|
|
"""
|
|
registry = _isolate_registry(monkeypatch, tmp_path / "agents.jsonl")
|
|
registry.register(AgentRecord(name="claude-code", pid=8421, command="claude"))
|
|
|
|
# Seed a budget — exercises the full slash write path.
|
|
session = Session()
|
|
write_console, _ = _capture()
|
|
assert dispatch_slash("/fleet budget claude-code 5", session, write_console) is True
|
|
|
|
list_console, list_buf = _capture()
|
|
assert dispatch_slash("/fleet", session, list_console) is True
|
|
out = list_buf.getvalue()
|
|
# The budget is persisted in agents.yaml (verified by the
|
|
# ``/fleet budget`` round-trip test above), but the ``$/hr``
|
|
# cell intentionally does not surface it. No ``$5.00`` should
|
|
# appear anywhere in the rendered dashboard.
|
|
assert "$5.00" not in out
|
|
# The dashboard does render the row though.
|
|
assert "claude-code" in out
|
|
|
|
def test_bare_agents_does_not_crash_on_schema_invalid_config(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, isolated_agents_yaml: Path
|
|
) -> None:
|
|
# Hand-edited agents.yaml with a typo'd field used to crash bare
|
|
# /fleet with a raw ValidationError traceback. The dashboard
|
|
# must degrade gracefully (render with $/hr = '-') so the user
|
|
# can still see their fleet while /fleet budget surfaces the
|
|
# actual error message.
|
|
registry = _isolate_registry(monkeypatch, tmp_path / "agents.jsonl")
|
|
registry.register(AgentRecord(name="claude-code", pid=8421, command="claude"))
|
|
isolated_agents_yaml.parent.mkdir(parents=True, exist_ok=True)
|
|
isolated_agents_yaml.write_text(
|
|
"agents:\n claude-code:\n hourly_budegt_usd: 5.0\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet", session, console) is True
|
|
out = buf.getvalue()
|
|
# Dashboard still renders the agent row.
|
|
assert "claude-code" in out
|
|
assert "8421" in out
|
|
|
|
|
|
class TestAgentsBudget:
|
|
def test_no_args_empty_state_when_no_config(self) -> None:
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet budget", session, console) is True
|
|
assert "no per-agent budgets" in buf.getvalue().lower()
|
|
|
|
def test_writes_and_round_trips_through_load(self, isolated_agents_yaml: Path) -> None:
|
|
session = Session()
|
|
write_console, write_buf = _capture()
|
|
assert dispatch_slash("/fleet budget claude-code 5", session, write_console) is True
|
|
|
|
# Confirmation message references the agent and amount.
|
|
write_out = write_buf.getvalue()
|
|
assert "claude-code" in write_out
|
|
assert "$5.00" in write_out
|
|
|
|
# Subsequent /fleet budget lists the just-written entry.
|
|
read_console, read_buf = _capture()
|
|
assert dispatch_slash("/fleet budget", session, read_console) is True
|
|
read_out = read_buf.getvalue()
|
|
assert "claude-code" in read_out
|
|
assert "$5.00" in read_out
|
|
|
|
# File on disk has the expected shape.
|
|
assert isolated_agents_yaml.exists()
|
|
|
|
def test_rejects_negative_budget(self) -> None:
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet budget claude-code -3", session, console) is True
|
|
out = buf.getvalue()
|
|
assert "invalid budget" in out.lower()
|
|
# Latest slash invocation should be marked failed.
|
|
assert session.history[-1]["ok"] is False
|
|
|
|
def test_rejects_zero_budget(self) -> None:
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet budget claude-code 0", session, console) is True
|
|
assert "invalid budget" in buf.getvalue().lower()
|
|
assert session.history[-1]["ok"] is False
|
|
|
|
def test_rejects_non_numeric_budget(self) -> None:
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet budget claude-code five", session, console) is True
|
|
assert "invalid budget" in buf.getvalue().lower()
|
|
assert session.history[-1]["ok"] is False
|
|
|
|
def test_rejects_nan_budget(self, isolated_agents_yaml: Path) -> None:
|
|
# ``float("nan") <= 0`` is ``False``, so without ``math.isfinite``
|
|
# ``nan`` would slip past the guard, hit set_agent_budget, and
|
|
# poison agents.yaml so the next load raises ValidationError.
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet budget claude-code nan", session, console) is True
|
|
assert "invalid budget" in buf.getvalue().lower()
|
|
assert session.history[-1]["ok"] is False
|
|
# The file must not exist — a single non-finite write can't be
|
|
# allowed to leave agents.yaml in an unreadable state.
|
|
assert not isolated_agents_yaml.exists()
|
|
|
|
def test_rejects_inf_budget(self, isolated_agents_yaml: Path) -> None:
|
|
# ``float("inf") <= 0`` is ``False`` and ``gt=0`` alone accepts
|
|
# ``inf`` (``inf > 0`` is ``True``); only ``isfinite`` blocks it.
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet budget claude-code inf", session, console) is True
|
|
assert "invalid budget" in buf.getvalue().lower()
|
|
assert session.history[-1]["ok"] is False
|
|
assert not isolated_agents_yaml.exists()
|
|
|
|
def test_single_arg_prints_usage(self) -> None:
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet budget claude-code", session, console) is True
|
|
assert "usage" in buf.getvalue().lower()
|
|
assert session.history[-1]["ok"] is False
|
|
|
|
def test_first_arg_completions_include_budget(self) -> None:
|
|
cmd = SLASH_COMMANDS["/fleet"]
|
|
keywords = [pair[0] for pair in cmd.first_arg_completions]
|
|
assert "budget" in keywords
|
|
|
|
def test_corrupt_config_surfaces_friendly_error(self, isolated_agents_yaml: Path) -> None:
|
|
# Hand-edit an agents.yaml with a typo'd field. The loader
|
|
# raises ValidationError; the slash handler catches it and
|
|
# renders a "agents.yaml has invalid contents" message rather
|
|
# than crashing the REPL.
|
|
isolated_agents_yaml.parent.mkdir(parents=True, exist_ok=True)
|
|
isolated_agents_yaml.write_text(
|
|
"agents:\n claude-code:\n hourly_budegt_usd: 5.0\n",
|
|
encoding="utf-8",
|
|
)
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet budget", session, console) is True
|
|
out = buf.getvalue()
|
|
assert "invalid contents" in out.lower()
|
|
assert session.history[-1]["ok"] is False
|
|
|
|
|
|
class TestRenderConflicts:
|
|
def test_empty_list_returns_empty_state_string(self) -> None:
|
|
assert render_conflicts([]) == "no conflicts detected"
|
|
|
|
def test_non_empty_list_returns_table_with_paths_and_agents(self) -> None:
|
|
conflicts = [
|
|
FileWriteConflict(
|
|
path="/repo/auth.py",
|
|
agents=("claude-code:1", "cursor:2"),
|
|
first_seen=100.0,
|
|
last_seen=110.0,
|
|
),
|
|
]
|
|
result = render_conflicts(conflicts)
|
|
assert isinstance(result, Table)
|
|
|
|
buf = io.StringIO()
|
|
Console(file=buf, force_terminal=False, highlight=False, width=120).print(result)
|
|
out = buf.getvalue()
|
|
assert "/repo/auth.py" in out
|
|
assert "claude-code:1" in out
|
|
assert "cursor:2" in out
|
|
|
|
def test_multiple_conflicts_each_rendered(self) -> None:
|
|
conflicts = [
|
|
FileWriteConflict(
|
|
path="/new.py",
|
|
agents=("claude-code:1", "cursor:2"),
|
|
first_seen=140.0,
|
|
last_seen=150.0,
|
|
),
|
|
FileWriteConflict(
|
|
path="/old.py",
|
|
agents=("aider:3", "cursor:2"),
|
|
first_seen=100.0,
|
|
last_seen=105.0,
|
|
),
|
|
]
|
|
result = render_conflicts(conflicts)
|
|
assert isinstance(result, Table)
|
|
|
|
buf = io.StringIO()
|
|
Console(file=buf, force_terminal=False, highlight=False, width=120).print(result)
|
|
out = buf.getvalue()
|
|
assert "/new.py" in out
|
|
assert "/old.py" in out
|
|
assert "aider:3" in out
|
|
|
|
|
|
class _FakeSession:
|
|
"""Minimal :class:`AttachSession` stand-in for slash-command tests.
|
|
|
|
Lets us drive ``_render_live_tail`` through ``dispatch_slash`` without
|
|
spawning a reader thread or touching the filesystem.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
chunks: list[bytes] | None = None,
|
|
*,
|
|
raise_ki: bool = False,
|
|
producer_exited: bool = False,
|
|
) -> None:
|
|
self.buffer = TailBuffer()
|
|
self._chunks = list(chunks or [])
|
|
self._raise_ki = raise_ki
|
|
self.producer_exited = producer_exited
|
|
self.closed = False
|
|
|
|
def __iter__(self) -> _FakeSession:
|
|
return self
|
|
|
|
def __next__(self) -> bytes:
|
|
if self._raise_ki:
|
|
self._raise_ki = False # raise once, like a real Ctrl+C
|
|
raise KeyboardInterrupt
|
|
if not self._chunks:
|
|
raise StopIteration
|
|
chunk = self._chunks.pop(0)
|
|
# Mirror :class:`AttachSession.__next__`: append-on-yield so the
|
|
# slash-command renderer only ever needs to read ``sess.buffer``.
|
|
self.buffer.append(chunk)
|
|
return chunk
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
def __enter__(self) -> _FakeSession:
|
|
return self
|
|
|
|
def __exit__(self, *_: object) -> None:
|
|
self.close()
|
|
|
|
|
|
class TestSliceToUtf8Boundary:
|
|
"""``_slice_to_utf8_boundary`` enforces the same chunk-edge guarantee
|
|
on the render side that :class:`TailBuffer` enforces on the consumer
|
|
side. Without it, the 64 KiB cap in ``_render_live_tail`` would slice
|
|
mid-codepoint and surface a U+FFFD at the top of the live view."""
|
|
|
|
def test_returns_input_when_under_cap(self) -> None:
|
|
data = b"\xf0\x9f\xa6\x80hello"
|
|
assert _slice_to_utf8_boundary(data, max_bytes=64) is data
|
|
|
|
def test_returns_input_when_exactly_at_cap(self) -> None:
|
|
data = b"X" * 64
|
|
assert _slice_to_utf8_boundary(data, max_bytes=64) is data
|
|
|
|
def test_walks_past_continuation_bytes_at_slice_start(self) -> None:
|
|
# 🦀 = b"\xf0\x9f\xa6\x80". Slicing at byte 2 of that codepoint
|
|
# would leave two stray continuation bytes (\xa6\x80) at the
|
|
# head; the boundary walk must skip them so the decoded string
|
|
# has no leading replacement character.
|
|
prefix = b"X" * 1000
|
|
data = prefix + b"\xf0\x9f\xa6\x80 done"
|
|
# max_bytes drops the first 998 bytes of prefix but keeps the
|
|
# last two ASCII bytes of prefix plus the partial 4-byte 🦀
|
|
# plus " done" — i.e. it lands inside the codepoint.
|
|
sliced = _slice_to_utf8_boundary(data, max_bytes=8)
|
|
# The decoded suffix must not start with U+FFFD.
|
|
decoded = sliced.decode("utf-8", errors="replace")
|
|
assert not decoded.startswith("�")
|
|
# And it must end with the trailing ASCII so we know we kept
|
|
# the suffix, not over-trimmed.
|
|
assert decoded.endswith(" done")
|
|
|
|
def test_drops_at_most_three_continuation_bytes(self) -> None:
|
|
# If for some reason the slice starts with more than 3
|
|
# continuation bytes (corrupt input), we don't loop forever.
|
|
# The bound is the max UTF-8 codepoint length (4 bytes).
|
|
bad = b"\x80\x80\x80\x80\x80hello"
|
|
sliced = _slice_to_utf8_boundary(bad, max_bytes=len(bad))
|
|
assert sliced is bad # under cap, returned unchanged
|
|
|
|
sliced = _slice_to_utf8_boundary(b"prefix" + bad, max_bytes=len(bad))
|
|
# We walk forward at most 4 bytes; if all of those are still
|
|
# continuation bytes, decoding will surface U+FFFD — the helper
|
|
# is bounded, not magic.
|
|
assert len(sliced) >= len(bad) - 4
|
|
|
|
def test_pure_ascii_unchanged_after_slice(self) -> None:
|
|
data = b"abcdefghij"
|
|
sliced = _slice_to_utf8_boundary(data, max_bytes=5)
|
|
assert sliced == b"fghij"
|
|
|
|
def test_multibyte_decode_clean_after_slice(self) -> None:
|
|
# Snapshot ends with several 2-byte codepoints (é = b"\xc3\xa9");
|
|
# the slice must land on a leading byte so the decoded string
|
|
# has no leading replacement character.
|
|
prefix = b"a" * 100
|
|
data = prefix + b"\xc3\xa9\xc3\xa9\xc3\xa9"
|
|
sliced = _slice_to_utf8_boundary(data, max_bytes=5)
|
|
decoded = sliced.decode("utf-8", errors="replace")
|
|
assert "�" not in decoded
|
|
|
|
|
|
class TestAgentsTrace:
|
|
def test_no_args_prints_usage(self) -> None:
|
|
sess_obj = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet trace", sess_obj, console) is True
|
|
out = buf.getvalue().lower()
|
|
assert "usage" in out
|
|
assert "<pid>" in out
|
|
assert sess_obj.history[-1]["ok"] is False
|
|
|
|
def test_non_numeric_pid_rejected(self) -> None:
|
|
sess_obj = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet trace abc", sess_obj, console) is True
|
|
out = buf.getvalue().lower()
|
|
assert "invalid pid" in out
|
|
assert sess_obj.history[-1]["ok"] is False
|
|
|
|
def test_too_many_args_rejected(self) -> None:
|
|
sess_obj = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet trace 1 2", sess_obj, console) is True
|
|
assert "usage" in buf.getvalue().lower()
|
|
assert sess_obj.history[-1]["ok"] is False
|
|
|
|
def test_attach_unsupported_renders_reason(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _refuse(_pid: int) -> _FakeSession:
|
|
raise AttachUnsupported("stdout is on a terminal; live tail not supported")
|
|
|
|
monkeypatch.setattr(agents_trace, "attach", _refuse)
|
|
|
|
sess_obj = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet trace 8421", sess_obj, console) is True
|
|
out = buf.getvalue()
|
|
assert "cannot trace" in out
|
|
assert "stdout is on a terminal" in out
|
|
assert sess_obj.history[-1]["ok"] is False
|
|
|
|
def test_unknown_pid_falls_back_to_pid_label(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# Header says "pid <n>" when the pid is not in the registry.
|
|
_isolate_registry(monkeypatch, tmp_path / "agents.jsonl")
|
|
monkeypatch.setattr(agents_trace, "attach", lambda _pid: _FakeSession(chunks=[]))
|
|
|
|
sess_obj = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet trace 8421", sess_obj, console) is True
|
|
out = buf.getvalue()
|
|
assert "pid 8421" in out
|
|
assert "trace ended" in out
|
|
|
|
def test_known_pid_uses_registered_name_in_header(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
registry = _isolate_registry(monkeypatch, tmp_path / "agents.jsonl")
|
|
registry.register(AgentRecord(name="claude-code", pid=8421, command="claude"))
|
|
monkeypatch.setattr(agents_trace, "attach", lambda _pid: _FakeSession(chunks=[]))
|
|
|
|
sess_obj = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet trace 8421", sess_obj, console) is True
|
|
out = buf.getvalue()
|
|
assert "claude-code" in out
|
|
assert "8421" in out
|
|
|
|
def test_renders_chunks_through_live(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# Two chunks then StopIteration: the handler should append them
|
|
# to the buffer and feed Live.update; the rendered text should
|
|
# land in the captured console output.
|
|
monkeypatch.setattr(
|
|
agents_trace,
|
|
"attach",
|
|
lambda _pid: _FakeSession(chunks=[b"hello ", b"world\n"]),
|
|
)
|
|
|
|
sess_obj = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet trace 8421", sess_obj, console) is True
|
|
out = buf.getvalue()
|
|
assert "hello world" in out
|
|
assert "trace ended" in out
|
|
|
|
def test_swallows_keyboard_interrupt(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# Single Ctrl+C inside the Live block must not propagate out of
|
|
# ``dispatch_slash`` — the REPL should return to its prompt.
|
|
# This is the kubectl-logs-style UX, deliberately different from
|
|
# ``stream_to_console``'s double-press pattern.
|
|
monkeypatch.setattr(agents_trace, "attach", lambda _pid: _FakeSession(raise_ki=True))
|
|
|
|
sess_obj = Session()
|
|
console, buf = _capture()
|
|
# Must not raise:
|
|
assert dispatch_slash("/fleet trace 8421", sess_obj, console) is True
|
|
assert "trace ended" in buf.getvalue()
|
|
|
|
def test_session_is_closed_even_on_keyboard_interrupt(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# Lifecycle guard: the ``with`` block in the handler must close
|
|
# the AttachSession (and thereby the reader thread) regardless
|
|
# of whether the iteration completed naturally or was stopped
|
|
# by a Ctrl+C.
|
|
fake = _FakeSession(raise_ki=True)
|
|
monkeypatch.setattr(agents_trace, "attach", lambda _pid: fake)
|
|
|
|
sess_obj = Session()
|
|
console, _ = _capture()
|
|
assert dispatch_slash("/fleet trace 8421", sess_obj, console) is True
|
|
assert fake.closed is True
|
|
|
|
def test_renders_process_exited_when_producer_died(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# When the agent process dies during a trace, the trailer should
|
|
# surface that explicitly so an unattended trace doesn't look
|
|
# the same as a Ctrl+C abort.
|
|
monkeypatch.setattr(
|
|
agents_trace,
|
|
"attach",
|
|
lambda _pid: _FakeSession(chunks=[b"goodbye\n"], producer_exited=True),
|
|
)
|
|
sess_obj = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet trace 8421", sess_obj, console) is True
|
|
out = buf.getvalue()
|
|
assert "process exited" in out
|
|
assert "trace ended" in out
|
|
|
|
def test_does_not_render_process_exited_on_user_stop(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# Producer is alive, user pressed Ctrl+C: only "trace ended"
|
|
# should appear; "process exited" would be misleading.
|
|
monkeypatch.setattr(
|
|
agents_trace,
|
|
"attach",
|
|
lambda _pid: _FakeSession(raise_ki=True, producer_exited=False),
|
|
)
|
|
sess_obj = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet trace 8421", sess_obj, console) is True
|
|
out = buf.getvalue()
|
|
assert "process exited" not in out
|
|
assert "trace ended" in out
|
|
|
|
|
|
class TestAgentsWait:
|
|
def test_usage_when_missing_on_flag(self) -> None:
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet wait 1234 5678", session, console) is True
|
|
assert "usage:" in buf.getvalue().lower()
|
|
|
|
def test_rejects_non_numeric_pid(self) -> None:
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet wait abc --on 5678", session, console) is True
|
|
assert "invalid pid" in buf.getvalue().lower()
|
|
|
|
def test_rejects_non_numeric_on_pid(self) -> None:
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet wait 1234 --on xyz", session, console) is True
|
|
assert "invalid other-pid" in buf.getvalue().lower()
|
|
|
|
def test_rejects_self_wait(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_isolate_registry(monkeypatch, tmp_path / "agents.jsonl")
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet wait 1234 --on 1234", session, console) is True
|
|
assert "waiting for itself" in buf.getvalue()
|
|
|
|
def test_rejects_unknown_waiter(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
registry = _isolate_registry(monkeypatch, tmp_path / "agents.jsonl")
|
|
registry.register(AgentRecord(name="claude-code", pid=8421, command="claude"))
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet wait 9999 --on 8421", session, console) is True
|
|
assert "pid 9999 is not in the agent registry" in buf.getvalue()
|
|
|
|
def test_rejects_unknown_target(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
registry = _isolate_registry(monkeypatch, tmp_path / "agents.jsonl")
|
|
registry.register(AgentRecord(name="aider", pid=7702, command="aider"))
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet wait 7702 --on 9999", session, console) is True
|
|
assert "pid 9999 is not in the agent registry" in buf.getvalue()
|
|
|
|
def test_happy_path_persists_dependency(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# End-to-end: wait announces the edge in output AND survives a
|
|
# fresh AgentRegistry load (i.e. the rewrite step actually fired).
|
|
registry_path = tmp_path / "agents.jsonl"
|
|
registry = _isolate_registry(monkeypatch, registry_path)
|
|
registry.register(AgentRecord(name="claude-code", pid=8421, command="claude"))
|
|
registry.register(AgentRecord(name="aider", pid=7702, command="aider"))
|
|
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet wait 7702 --on 8421", session, console) is True
|
|
out = buf.getvalue()
|
|
assert "aider" in out
|
|
assert "claude-code" in out
|
|
assert "now waits on" in out
|
|
|
|
reloaded = AgentRegistry(path=registry_path).get(7702)
|
|
assert reloaded is not None
|
|
assert reloaded.waits_on == (8421,)
|
|
|
|
def test_repeated_wait_is_idempotent(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# Two wait calls with the same pair must not produce [8421, 8421].
|
|
# Guards against a regression where `add_waits_on`'s membership
|
|
# check is removed and duplicates leak into the JSONL.
|
|
registry_path = tmp_path / "agents.jsonl"
|
|
registry = _isolate_registry(monkeypatch, registry_path)
|
|
registry.register(AgentRecord(name="claude-code", pid=8421, command="claude"))
|
|
registry.register(AgentRecord(name="aider", pid=7702, command="aider"))
|
|
|
|
session = Session()
|
|
for _ in range(2):
|
|
console, _ = _capture()
|
|
assert dispatch_slash("/fleet wait 7702 --on 8421", session, console) is True
|
|
|
|
reloaded = AgentRegistry(path=registry_path).get(7702)
|
|
assert reloaded is not None
|
|
assert reloaded.waits_on == (8421,)
|
|
|
|
|
|
class TestAgentsGraph:
|
|
def test_empty_registry_renders_empty_state(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_isolate_registry(monkeypatch, tmp_path / "agents.jsonl")
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet graph", session, console) is True
|
|
assert "no registered agents" in buf.getvalue()
|
|
|
|
def test_acyclic_chain_renders_all_agents(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# The issue's example tree:
|
|
# claude-code (8421) [active]
|
|
# └── aider (7702) [waiting on claude-code]
|
|
# └── cursor-tab (9133) [waiting on aider]
|
|
registry = _isolate_registry(monkeypatch, tmp_path / "agents.jsonl")
|
|
registry.register(AgentRecord(name="claude-code", pid=8421, command="claude"))
|
|
registry.register(AgentRecord(name="aider", pid=7702, command="aider", waits_on=(8421,)))
|
|
registry.register(
|
|
AgentRecord(name="cursor-tab", pid=9133, command="cursor", waits_on=(7702,))
|
|
)
|
|
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet graph", session, console) is True
|
|
out = buf.getvalue()
|
|
assert "claude-code" in out
|
|
assert "aider" in out
|
|
assert "cursor-tab" in out
|
|
assert "claude-code (8421) [active]" in out
|
|
assert "aider (7702) [waiting on claude-code]" in out
|
|
assert "cursor-tab (9133) [waiting on aider]" in out.lower()
|
|
|
|
def test_cycle_prints_warning(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
registry = _isolate_registry(monkeypatch, tmp_path / "agents.jsonl")
|
|
registry.register(AgentRecord(name="alpha", pid=1, command="a", waits_on=(2,)))
|
|
registry.register(AgentRecord(name="beta", pid=2, command="b", waits_on=(1,)))
|
|
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet graph", session, console) is True
|
|
out = buf.getvalue()
|
|
|
|
assert "agent dependency cycle detected" in out
|
|
assert "alpha" in out
|
|
assert "beta" in out
|
|
assert "alpha (1) -> beta (2) -> alpha (1)" in out
|
|
|
|
def test_acyclic_chain_multiple_roots(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
registry = _isolate_registry(monkeypatch, tmp_path / "agents.jsonl")
|
|
registry.register(AgentRecord(name="claude-code", pid=8421, command="claude"))
|
|
registry.register(AgentRecord(name="aider", pid=7702, command="aider", waits_on=(8421,)))
|
|
registry.register(AgentRecord(name="cursor-tab", pid=9133, command="cursor"))
|
|
registry.register(AgentRecord(name="aider", pid=8491, command="aider", waits_on=(9133,)))
|
|
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet graph", session, console) is True
|
|
out = buf.getvalue()
|
|
assert "cursor-tab (9133) [active]" in out
|
|
assert "claude-code (8421) [active]" in out
|
|
assert "aider (7702) [waiting on claude-code]" in out
|
|
assert "aider (8491) [waiting on cursor-tab]" in out
|
|
|
|
def test_acyclic_chain_multiple_blockers(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
registry = _isolate_registry(monkeypatch, tmp_path / "agents.jsonl")
|
|
registry.register(AgentRecord(name="claude-code", pid=8421, command="claude"))
|
|
registry.register(AgentRecord(name="aider", pid=7702, command="aider", waits_on=(8421,)))
|
|
registry.register(
|
|
AgentRecord(name="cursor-tab", pid=9133, command="cursor", waits_on=(8421,))
|
|
)
|
|
registry.register(
|
|
AgentRecord(name="aider", pid=8491, command="aider", waits_on=(9133, 7702))
|
|
)
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet graph", session, console) is True
|
|
out = buf.getvalue()
|
|
assert "claude-code (8421) [active]" in out
|
|
assert "aider (8491) [waiting on aider]" in out
|
|
assert "aider (8491) [waiting on cursor-tab]" in out
|
|
|
|
def test_acyclic_chain_multiple_roots_blockers(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
registry = _isolate_registry(monkeypatch, tmp_path / "agents.jsonl")
|
|
registry.register(AgentRecord(name="claude-code", pid=8421, command="claude"))
|
|
registry.register(AgentRecord(name="cursor-tab", pid=9133, command="cursor"))
|
|
registry.register(
|
|
AgentRecord(name="aider", pid=7702, command="aider", waits_on=(8421, 9133))
|
|
)
|
|
registry.register(
|
|
AgentRecord(name="cursor-tab", pid=9134, command="cursor", waits_on=(9133, 8421))
|
|
)
|
|
registry.register(AgentRecord(name="aider", pid=8491, command="aider", waits_on=(9134,)))
|
|
session = Session()
|
|
console, buf = _capture()
|
|
assert dispatch_slash("/fleet graph", session, console) is True
|
|
out = buf.getvalue()
|
|
assert "claude-code (8421) [active]" in out
|
|
assert "cursor-tab (9133) [active]" in out
|
|
assert "cursor-tab (9134) [waiting on claude-code]" in out
|
|
assert "cursor-tab (9134) [waiting on cursor-tab]" in out
|