mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Systematic prune per AGENTS.md test policy, one pass over every major test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli, cron, tui_gateway, honcho/openviking, root-level): - DELETE: source-reading tests (read_text/getsource on prod files), change-detector tests (exact catalog counts, model-name snapshots, config version literals), mock-echo tests (assert a mock returns what it was told), assertion-free/trivial tests, near-duplicate parametrizations (boundaries + one representative kept), async/sync twin duplicates, cosmetic within-file variations. - KEEP (mandatory): security/redaction/approval guards, message-role alternation invariants, prompt-caching/deterministic-call-id invariants, issue-number regression tests (deduped), E2E tests. - 6 test files deleted outright (script-style/no-assert or fully redundant); conftest.py, fakes/, fixtures/ untouched. - tests/acp/conftest.py added: autouse fixture stubs the live models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server tests performed on every session create — test_server.py 147s → 3.4s, and the tests are now genuinely hermetic. - Sleep-based slowness shrunk where safe (codex_ttfb_watchdog, compression_concurrent_fork, etc.); no wall-clock assertion tightened. Verification: full hermetic suite via scripts/run_tests.sh — 2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall (baseline: 583s wall, 13,564s subprocess CPU).
255 lines
11 KiB
Python
255 lines
11 KiB
Python
"""Tests for the petdex pet engine (agent/pet/*).
|
||
|
||
Behavior/invariant focused — no network, no live manifest. A tiny synthetic
|
||
spritesheet is generated with Pillow so render paths exercise real decode
|
||
without depending on a downloaded pet.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
|
||
import pytest
|
||
|
||
from agent.pet import constants, render, state, store
|
||
from agent.pet.constants import FRAME_H, FRAME_W, PetState
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# state mapping — priority invariants
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
|
||
def test_derive_priority_order():
|
||
# error beats everything
|
||
assert state.derive_pet_state(error=True, celebrate=True, busy=True) is PetState.FAILED
|
||
# celebrate beats completion/tool
|
||
assert state.derive_pet_state(celebrate=True, just_completed=True, tool_running=True) is PetState.JUMP
|
||
# completion beats waiting/tool
|
||
assert state.derive_pet_state(just_completed=True, awaiting_input=True) is PetState.WAVE
|
||
# waiting (blocked on the user) outranks the in-flight signals — a clarify
|
||
# mid-turn pauses on you even though a tool is technically still open.
|
||
assert state.derive_pet_state(awaiting_input=True, tool_running=True, busy=True) is PetState.WAITING
|
||
# tool beats reasoning
|
||
assert state.derive_pet_state(tool_running=True, reasoning=True) is PetState.RUN
|
||
# reasoning beats bare-busy
|
||
assert state.derive_pet_state(reasoning=True, busy=True) is PetState.REVIEW
|
||
# bare busy runs
|
||
assert state.derive_pet_state(busy=True) is PetState.RUN
|
||
|
||
|
||
def test_todos_all_done():
|
||
# empty / falsy → not done (no plan to celebrate)
|
||
assert state.todos_all_done(None) is False
|
||
assert state.todos_all_done([]) is False
|
||
# any open item → not done
|
||
assert state.todos_all_done([{"status": "completed"}, {"status": "pending"}]) is False
|
||
assert state.todos_all_done([{"status": "in_progress"}]) is False
|
||
# every item terminal → done (completed and/or cancelled)
|
||
assert state.todos_all_done([{"status": "completed"}, {"status": "cancelled"}]) is True
|
||
|
||
# objects with a .status attr work too (mirrors dict + attr access)
|
||
class _T:
|
||
def __init__(self, status):
|
||
self.status = status
|
||
|
||
assert state.todos_all_done([_T("completed")]) is True
|
||
assert state.todos_all_done([_T("completed"), _T("pending")]) is False
|
||
|
||
|
||
def test_state_row_index_maps_to_supported_atlas_taxonomies():
|
||
# Current Petdex sheets are 8 columns x 9 rows.
|
||
assert constants.state_row_index(PetState.IDLE, 9) == 0
|
||
assert constants.state_row_index(PetState.WAVE, 9) == 3
|
||
assert constants.state_row_index(PetState.JUMP, 9) == 4
|
||
assert constants.state_row_index(PetState.FAILED, 9) == 5
|
||
assert constants.state_row_index(PetState.WAITING, 9) == 6
|
||
assert constants.state_row_index(PetState.RUN, 9) == 7
|
||
assert constants.state_row_index(PetState.REVIEW, 9) == 8
|
||
|
||
# Legacy Hermes/petdex sheets were 8 rows with Hermes state names packed in
|
||
# order. Keep those readable instead of forcing old installs through the
|
||
# newer Codex taxonomy.
|
||
assert constants.state_row_index(PetState.WAVE, 8) == 1
|
||
assert constants.state_row_index(PetState.RUN, 8) == 2
|
||
assert constants.state_row_index(PetState.FAILED, 8) == 3
|
||
assert constants.state_row_index(PetState.REVIEW, 8) == 4
|
||
assert constants.state_row_index(PetState.JUMP, 8) == 5
|
||
assert constants.state_row_index(PetState.WAITING, 8) == 0
|
||
|
||
# Alias rows resolve as expected.
|
||
assert constants.state_row_index("wave", 9) == constants.state_row_index("waving", 9) == 3
|
||
assert constants.state_row_index("jump", 9) == constants.state_row_index("jumping", 9) == 4
|
||
assert constants.state_row_index("run", 9) == constants.state_row_index("running", 9) == 7
|
||
|
||
# unknown row names clamp to idle (row 0), never raise
|
||
assert constants.state_row_index("nonsense") == 0
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# synthetic spritesheet fixture
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
@pytest.fixture
|
||
def boba_like(tmp_path, monkeypatch):
|
||
"""Install a synthetic 8-col × 9-row pet into a temp HERMES_HOME."""
|
||
from PIL import Image
|
||
|
||
home = tmp_path / ".hermes"
|
||
home.mkdir()
|
||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||
|
||
cols, rows = 8, 9
|
||
sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0))
|
||
# paint each row a distinct opaque color so frames are non-empty
|
||
for r in range(rows):
|
||
color = (20 + r * 25, 60, 120, 255)
|
||
for c in range(cols):
|
||
block = Image.new("RGBA", (FRAME_W, FRAME_H), color)
|
||
sheet.paste(block, (c * FRAME_W, r * FRAME_H))
|
||
|
||
pet_dir = store.pets_dir() / "boba"
|
||
pet_dir.mkdir(parents=True, exist_ok=True)
|
||
sheet.save(pet_dir / "spritesheet.webp")
|
||
(pet_dir / "pet.json").write_text(
|
||
'{"id":"boba","displayName":"Boba","description":"d","spritesheetPath":"spritesheet.webp"}'
|
||
)
|
||
return pet_dir
|
||
|
||
|
||
|
||
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# render — decode + every encoder produces output
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
|
||
def test_trims_trailing_blank_frames(tmp_path):
|
||
"""Ragged state rows (real frames + transparent padding) trim to real count.
|
||
|
||
petdex sheets are left-packed: a state with fewer than FRAMES_PER_STATE real
|
||
frames pads the trailing columns transparent. Stepping into one flashes the
|
||
pet blank, so the engine must stop the row at the first gap.
|
||
"""
|
||
from PIL import Image
|
||
|
||
cols, rows = 8, 9
|
||
sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0))
|
||
# row index -> number of real (opaque) frames; the rest stay transparent.
|
||
# Codex row taxonomy: idle, running-right, running-left, wave, jump, failed,
|
||
# waiting, run, review.
|
||
real = {0: 6, 3: 4, 4: 5, 5: 8, 7: 6, 8: 5}
|
||
for r, k in real.items():
|
||
for c in range(k):
|
||
block = Image.new("RGBA", (FRAME_W, FRAME_H), (200, 80, 80, 255))
|
||
sheet.paste(block, (c * FRAME_W, r * FRAME_H))
|
||
sprite = tmp_path / "ragged.webp"
|
||
sheet.save(sprite)
|
||
|
||
r = render.PetRenderer(str(sprite), mode="unicode", scale=0.5)
|
||
# Full rows cap at FRAMES_PER_STATE; ragged rows trim to their real count.
|
||
assert r.frame_count("idle") == constants.FRAMES_PER_STATE
|
||
assert r.frame_count("run") == constants.FRAMES_PER_STATE
|
||
assert r.frame_count("wave") == 4
|
||
assert r.frame_count("jump") == 5
|
||
assert r.frame_count("failed") == constants.FRAMES_PER_STATE
|
||
assert r.frame_count("review") == 5
|
||
|
||
# Every stepped frame is non-empty — no blank flash for the trimmed states.
|
||
for state in ("wave", "jump", "review"):
|
||
for i in range(r.frame_count(state)):
|
||
assert r.frame(state, i), f"{state}[{i}] rendered blank"
|
||
|
||
counts = render.state_frame_counts(str(sprite))
|
||
assert counts == {
|
||
"idle": 6,
|
||
"wave": 4,
|
||
"run": 6,
|
||
"failed": 6,
|
||
"review": 5,
|
||
"jump": 5,
|
||
"waiting": 0,
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
def test_cells_grid_shape(boba_like):
|
||
sprite = store.load_pet("boba").spritesheet
|
||
r = render.PetRenderer(str(sprite), mode="unicode", scale=0.4, unicode_cols=14)
|
||
grid = r.cells("run", 0, cols=14)
|
||
assert grid, "no cells produced"
|
||
# every row is the requested width; every cell is (top, bottom) RGBA pairs
|
||
assert all(len(row) == 14 for row in grid)
|
||
(top, bottom) = grid[0][0]
|
||
assert len(top) == 4 and len(bottom) == 4
|
||
# missing-sheet renderer yields no cells, never raises
|
||
assert render.PetRenderer(str(sprite.parent / "missing.webp"), mode="unicode").cells("idle", 0) == []
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# render — kitty Unicode placeholders (TUI graphics path)
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
def test_kitty_payload_structure(boba_like):
|
||
sprite = store.load_pet("boba").spritesheet
|
||
image_id = render.kitty_image_id("boba")
|
||
scale = 0.4
|
||
r = render.PetRenderer(str(sprite), mode="kitty", scale=scale, unicode_cols=18)
|
||
payload = r.kitty_payload("run", image_id=image_id)
|
||
assert payload is not None
|
||
# Geometry is driven by the scaled/cropped sprite, not unicode_cols.
|
||
assert payload["cols"] >= 1 and payload["rows"] >= 1
|
||
assert payload["cols"] < 18 # 0.4 scale is much smaller than a pinned 18-col box
|
||
# placeholder grid matches the requested geometry
|
||
assert len(payload["placeholder"]) == payload["rows"]
|
||
# one transmit escape per animation frame, each a kitty virtual placement
|
||
assert len(payload["frames"]) == r.frame_count("run")
|
||
for esc in payload["frames"]:
|
||
assert esc.startswith("\x1b_G")
|
||
assert esc.endswith("\x1b\\")
|
||
assert f"i={image_id}" in esc
|
||
assert "a=T" in esc and "U=1" in esc
|
||
assert f"c={payload['cols']}" in esc and f"r={payload['rows']}" in esc
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
def test_vscode_terminal_ignores_leaked_graphics_env(monkeypatch):
|
||
# The VS Code / Cursor integrated terminal can't show inline images by
|
||
# default, yet inherits ITERM_SESSION_ID/KITTY_WINDOW_ID when launched from
|
||
# those terminals. TERM_PROGRAM=vscode must win → unicode, never a protocol
|
||
# whose escapes the embedded terminal would silently drop.
|
||
for key in ("KITTY_WINDOW_ID", "TERM_PROGRAM", "ITERM_SESSION_ID", "WEZTERM_PANE", "TERM"):
|
||
monkeypatch.delenv(key, raising=False)
|
||
monkeypatch.setenv("TERM_PROGRAM", "vscode")
|
||
|
||
assert render.detect_terminal_graphics() == "unicode"
|
||
for leaked in ("ITERM_SESSION_ID", "KITTY_WINDOW_ID", "WEZTERM_PANE"):
|
||
monkeypatch.setenv(leaked, "1")
|
||
assert render.detect_terminal_graphics() == "unicode"
|
||
monkeypatch.delenv(leaked)
|