hermes-agent/tests/agent/test_context_breakdown.py
teknium1 07370a9dba feat(cli,gateway): unify /context into a visual context-usage breakdown
Extends the cherry-picked /context command (PR #52184) and prompt-size
attribution helpers (PR #66656) into one visual context view across
surfaces, and absorbs the per-component budget-visibility goal of the
/tokens proposal (PR #48470):

- agent/context_breakdown.py: pure renderers over the existing payload —
  a 5x20 glyph block grid (1 cell ~= 1% of the model window), an
  'Estimated usage by category' table with free space, and expanded
  per-skill / per-toolset listings via compute_context_details(), which
  reuses the prompt-size attribution mechanism (skills index-line bytes +
  registry tool->toolset map) converted to the same chars/4 heuristic.
- cli.py: /context [all] renders grid + category table (+ expanded
  listings) from the live agent and in-memory conversation history.
- gateway/slash_commands.py: /context appends the plain-text category
  table (no grid — monospace not guaranteed on messaging platforms);
  /context all adds the expanded listings. Fail-open: breakdown errors
  never break the gauge.
- hermes_cli/commands.py: /context gains the 'all' subcommand; /version
  demoted to /hermes version on Slack to keep the 50-slash cap.
- tests: renderer unit tests against synthetic payloads, registry test,
  gateway /context + /context all + failure-degradation handler tests.
- docs: slash-commands reference + CLI guide entries.

Read-only and locally computed: no provider calls, no prompt-cache impact.

Co-authored-by: RemyFevry <29257684+RemyFevry@users.noreply.github.com>
Co-authored-by: joelbrilliant <joelbrilliant1@gmail.com>
Co-authored-by: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com>
2026-07-26 18:06:21 -07:00

204 lines
7.2 KiB
Python

"""Tests for live session context breakdown."""
from unittest.mock import MagicMock, patch
from agent.context_breakdown import compute_session_context_breakdown
def _make_agent(
*,
stable: str = "identity and guidance",
context: str = "",
volatile: str = "timestamp line",
tools: list | None = None,
context_length: int = 200_000,
last_prompt_tokens: int = 0,
):
agent = MagicMock()
agent.model = "openai/gpt-5.4"
agent.tools = tools or [
{"type": "function", "function": {"name": "terminal", "description": "run"}},
{"type": "function", "function": {"name": "mcp_demo_tool", "description": "mcp"}},
{"type": "function", "function": {"name": "delegate_task", "description": "spawn"}},
]
agent._memory_store = None
agent._memory_enabled = True
agent._user_profile_enabled = True
agent.context_compressor = MagicMock(
context_length=context_length,
last_prompt_tokens=last_prompt_tokens,
)
return agent, {"stable": stable, "context": context, "volatile": volatile}
def test_breakdown_includes_major_categories():
stable = (
"base guidance\n"
"<available_skills>\n demo:\n - hello: hi\n</available_skills>"
)
context = "# Project Context\nFollow AGENTS.md"
volatile = "Current time: now"
history = [{"role": "user", "content": "hello there"}]
agent, parts = _make_agent(stable=stable, context=context, volatile=volatile)
with patch("agent.system_prompt.build_system_prompt_parts", return_value=parts):
data = compute_session_context_breakdown(agent, history)
ids = {item["id"] for item in data["categories"]}
assert {"system_prompt", "tool_definitions", "rules", "skills", "mcp", "subagent_definitions", "conversation"} <= ids
assert data["context_max"] == 200_000
assert data["estimated_total"] > 0
def test_breakdown_uses_measured_context_when_available():
agent, parts = _make_agent(last_prompt_tokens=42_000)
with patch("agent.system_prompt.build_system_prompt_parts", return_value=parts):
data = compute_session_context_breakdown(agent, [])
assert data["context_used"] == 42_000
assert data["context_percent"] == 21
# ── /context renderers (pure functions over the payload) ────────────────────
from agent.context_breakdown import ( # noqa: E402
compute_context_details,
render_context_breakdown_lines,
render_context_category_lines,
render_context_details_lines,
render_context_grid,
)
def _payload(**overrides):
base = {
"categories": [
{"id": "system_prompt", "label": "System prompt", "tokens": 10_000},
{"id": "tool_definitions", "label": "Tool definitions", "tokens": 20_000},
{"id": "skills", "label": "Skills", "tokens": 5_000},
{"id": "conversation", "label": "Conversation", "tokens": 15_000},
],
"context_max": 200_000,
"context_percent": 25,
"context_used": 50_000,
"estimated_total": 50_000,
"model": "openai/gpt-test",
}
base.update(overrides)
return base
def test_grid_is_5x20_and_mostly_free():
rows = render_context_grid(_payload())
assert len(rows) == 5
cells = " ".join(rows).split(" ")
assert len(cells) == 100
# 50k / 200k → 25 used cells, 75 free
assert cells.count("·") == 75
# Category glyphs proportional: 10k→5, 20k→10, 5k→2-3, 15k→7-8 cells
assert cells.count("") == 5
assert cells.count("") == 10
def test_grid_nonzero_category_never_invisible():
payload = _payload(
categories=[{"id": "memory", "label": "Memory", "tokens": 10}],
estimated_total=10,
context_used=10,
)
rows = render_context_grid(payload)
assert "" in " ".join(rows)
def test_grid_without_context_max_is_all_free():
rows = render_context_grid(_payload(context_max=0))
cells = " ".join(rows).split(" ")
assert set(cells) == {"·"}
def test_category_lines_include_tokens_percent_and_free_space():
lines = render_context_category_lines(_payload())
text = "\n".join(lines)
assert "Estimated usage by category" in text
assert "System prompt" in text and "10,000 tokens" in text
assert "5.0%" in text # 10k / 200k
assert "Free space" in text and "150,000 tokens" in text
def test_category_lines_no_categories():
lines = render_context_category_lines(_payload(categories=[]))
assert any("no data yet" in line for line in lines)
def test_breakdown_lines_grid_toggle():
with_grid = render_context_breakdown_lines(_payload(), grid=True)
without = render_context_breakdown_lines(_payload(), grid=False)
assert any("·" in line for line in with_grid[:5])
assert not any("·" in line for line in without[:2])
# Both include the window summary and the expand hint
for lines in (with_grid, without):
text = "\n".join(lines)
assert "Context window: 50,000 / 200,000 tokens (25%)" in text
assert "/context all" in text
def test_breakdown_lines_with_details_omits_hint():
details = {
"skills": [
{"name": "alpha", "index_tokens": 25, "skill_md_tokens": 800},
{"name": "beta", "index_tokens": 30, "skill_md_tokens": None},
],
"toolsets": [
{"toolset": "terminal", "tool_count": 3, "schema_tokens": 4_000},
],
}
lines = render_context_breakdown_lines(_payload(), details=details, grid=False)
text = "\n".join(lines)
assert "Toolsets by schema cost" in text
assert "terminal" in text and "4,000 tokens" in text
assert "Skills by cost" in text
assert "alpha" in text and "beta" in text
assert "n/a" in text # unmapped SKILL.md renders n/a, not a crash
assert "Use /context all" not in text
def test_details_lines_caps_listing():
details = {
"skills": [
{"name": f"skill-{i}", "index_tokens": 10, "skill_md_tokens": 100}
for i in range(20)
],
"toolsets": [],
}
lines = render_context_details_lines(details)
assert any("… and 5 more" in line for line in lines)
def test_compute_context_details_maps_bytes_to_tokens():
agent, parts = _make_agent(
stable=(
"base\n<available_skills>\n demo:\n"
" - hello: a demo skill\n</available_skills>"
),
)
fake_skills = [{
"name": "hello",
"index_line_bytes": 40,
"index_line_total_bytes": 40,
"index_line_shared_bytes": 0,
"index_line_skill_count": 1,
"skill_md_bytes": 401,
"path": "/tmp/hello/SKILL.md",
}]
fake_toolsets = [{"toolset": "terminal", "tool_count": 2, "json_bytes": 399}]
with patch("agent.system_prompt.build_system_prompt_parts", return_value=parts), \
patch("hermes_cli.prompt_size._compute_skills_breakdown", return_value=fake_skills), \
patch("hermes_cli.prompt_size._compute_toolsets_breakdown", return_value=fake_toolsets):
details = compute_context_details(agent)
assert details["skills"] == [
{"name": "hello", "index_tokens": 10, "skill_md_tokens": 101},
]
assert details["toolsets"] == [
{"toolset": "terminal", "tool_count": 2, "schema_tokens": 100},
]