"""Tests for the todo tool renderers (compact, op-aware cards)."""
from __future__ import annotations
from rich.console import Console
from clients.terminal.tui.renderers.todo import TodoResultRenderer, TodoStartedRenderer
from clients.terminal.tui.renderers.tool import ToolStartedRenderer, ToolResultRenderer
from clients.terminal.tui.themes import set_active_theme
def _render_text(renderable) -> str:
console = Console(record=True, width=80, force_terminal=True, color_system=None)
console.print(renderable)
return console.export_text()
# ── accepts() gating ────────────────────────────────────────────────────────
def test_started_accepts_only_todo_started() -> None:
started = TodoStartedRenderer()
assert started.accepts({"type": "tool_started", "tool": "todo", "args": {"op": "view"}})
assert not started.accepts({"type": "tool_started", "tool": "filesystem", "args": {}})
assert not started.accepts({"type": "tool_call", "tool": "todo"})
def test_result_accepts_only_todo_call() -> None:
result = TodoResultRenderer()
assert result.accepts({"type": "tool_call", "tool": "todo", "result": "Plan — 1/2 done:", "success": True})
assert not result.accepts({"type": "tool_call", "tool": "filesystem", "result": "ok"})
assert not result.accepts({"type": "tool_started", "tool": "todo"})
def test_todo_renderers_win_over_generic_tool() -> None:
"""The registry checks todo renderers before the generic tool ones, so a
todo call never falls through to the JSON-dump ToolStartedRenderer."""
from clients.terminal.tui.renderers import default_registry
reg = default_registry()
msg = {"type": "tool_started", "tool": "todo", "args": {"op": "set", "tasks": ["a"]}}
rendered = reg.render(msg)
title = str(getattr(rendered, "title", ""))
assert "set plan" in title
# And the generic renderer does NOT accept a todo call (it would, but the
# todo renderer is checked first — sanity-check the generic still accepts
# it in isolation to confirm ordering is what protects us, not accepts()).
assert ToolStartedRenderer().accepts(msg)
# ── started cards by op ──────────────────────────────────────────────────────
def test_set_card_lists_all_tasks_numbered_with_pending_icon() -> None:
set_active_theme("gnexus-dark")
renderer = TodoStartedRenderer()
msg = {
"type": "tool_started",
"tool": "todo",
"args": {"op": "set", "tasks": ["read the spec", "write the parser", "run tests"]},
}
panel = renderer.render(msg)
assert "set plan (3)" in str(panel.title)
out = _render_text(panel)
assert "○" in out # pending marker
assert "1. read the spec" in out
assert "2. write the parser" in out
assert "3. run tests" in out
# No raw JSON quoted-array dump.
assert '"tasks"' not in out
def test_set_card_empty_plan() -> None:
set_active_theme("gnexus-dark")
panel = TodoStartedRenderer().render({"type": "tool_started", "tool": "todo", "args": {"op": "set", "tasks": []}})
assert "(empty plan)" in _render_text(panel)
def test_update_card_shows_step_text_and_validation() -> None:
"""The call card for an update shows the text of the step being changed
(from ToolStarted.metadata, enriched on the backend) plus the validation."""
set_active_theme("gnexus-dark")
msg = {
"type": "tool_started",
"tool": "todo",
"args": {"op": "update", "index": 3, "status": "done", "validation": "ran pytest — 12 passed"},
"metadata": {"step_text": "run the tests"},
}
panel = TodoStartedRenderer().render(msg)
assert "#3 → done" in str(panel.title)
out = _render_text(panel)
assert "run the tests" in out
assert "ran pytest — 12 passed" in out
assert "verified:" in out
def test_update_card_step_text_without_validation() -> None:
"""An in_progress update has no validation — the step text alone is shown,
without a noisy 'no validation' placeholder."""
set_active_theme("gnexus-dark")
msg = {
"type": "tool_started",
"tool": "todo",
"args": {"op": "update", "index": 1, "status": "in_progress"},
"metadata": {"step_text": "write the parser"},
}
panel = TodoStartedRenderer().render(msg)
assert "#1 → in_progress" in str(panel.title)
out = _render_text(panel)
assert "write the parser" in out
assert "no validation" not in out
def test_update_card_history_no_metadata_falls_back() -> None:
"""History replay has no metadata (step text wasn't persisted) and may
have no validation — keep the placeholder so the card isn't empty."""
set_active_theme("gnexus-dark")
msg = {"type": "tool_started", "tool": "todo", "args": {"op": "update", "index": 1, "status": "in_progress"}}
panel = TodoStartedRenderer().render(msg)
assert "#1 → in_progress" in str(panel.title)
assert "no validation" in _render_text(panel)
def test_update_card_validation_without_step_text() -> None:
"""No metadata but validation present — still show the validation line."""
set_active_theme("gnexus-dark")
msg = {
"type": "tool_started",
"tool": "todo",
"args": {"op": "update", "index": 2, "status": "done", "validation": "checked output"},
}
out = _render_text(TodoStartedRenderer().render(msg))
assert "verified: checked output" in out
def test_view_and_clear_cards_are_minimal() -> None:
set_active_theme("gnexus-dark")
view = TodoStartedRenderer().render({"type": "tool_started", "tool": "todo", "args": {"op": "view"}})
assert "view" in str(view.title)
clear = TodoStartedRenderer().render({"type": "tool_started", "tool": "todo", "args": {"op": "clear"}})
assert "clear" in str(clear.title)
def test_unknown_op_falls_back_to_readable_json() -> None:
set_active_theme("gnexus-dark")
msg = {"type": "tool_started", "tool": "todo", "args": {"op": "nope", "foo": 1}}
out = _render_text(TodoStartedRenderer().render(msg))
# Legible key/value, not a Python dict repr.
assert '"foo"' in out and "nope" in out
def test_subagent_todo_started_is_indented() -> None:
set_active_theme("gnexus-dark")
panel = TodoStartedRenderer().render(
{"type": "tool_started", "tool": "todo", "args": {"op": "view"}, "is_subagent": True}
)
assert any(line.startswith(" ") for line in _render_text(panel).splitlines())
# ── result cards ────────────────────────────────────────────────────────────
def test_result_card_compact_status_line_from_plan_render() -> None:
set_active_theme("gnexus-dark")
result_text = "Plan — 2/5 done:\n ✓ 1. read spec\n ○ 2. write code\n"
panel = TodoResultRenderer().render(
{"type": "tool_call", "tool": "todo", "result": result_text, "success": True}
)
assert "✓" in str(panel.title)
out = _render_text(panel)
assert "plan · 2/5 done" in out
# The full per-step dump is NOT in the chat card (it lives in the side panel).
assert "read spec" not in out
assert "write code" not in out
def test_result_card_empty_plan_label() -> None:
set_active_theme("gnexus-dark")
panel = TodoResultRenderer().render(
{"type": "tool_call", "tool": "todo", "result": "Plan is empty.", "success": True}
)
assert "plan · empty" in _render_text(panel)
def test_result_card_cleared_label() -> None:
set_active_theme("gnexus-dark")
panel = TodoResultRenderer().render(
{"type": "tool_call", "tool": "todo", "result": "Plan cleared.", "success": True}
)
assert "plan · cleared" in _render_text(panel)
def test_result_card_no_plan_label() -> None:
set_active_theme("gnexus-dark")
panel = TodoResultRenderer().render(
{"type": "tool_call", "tool": "todo", "result": "No plan set for this session.", "success": True}
)
assert "plan · no plan" in _render_text(panel)
def test_result_card_failed_shows_first_line_compactly() -> None:
set_active_theme("gnexus-dark")
result_text = (
"Cannot mark step 3 as done without validation.\n"
"Provide a 'validation' field describing how you verified the result.\n"
)
panel = TodoResultRenderer().render(
{"type": "tool_call", "tool": "todo", "result": result_text, "success": False}
)
title = str(panel.title)
assert "✗" in title
out = _render_text(panel)
assert "Cannot mark step 3 as done without validation" in out
# Only the first line, not the whole multi-line guidance.
assert "Provide a 'validation'" not in out
def test_subagent_todo_result_is_indented() -> None:
set_active_theme("gnexus-dark")
panel = TodoResultRenderer().render(
{"type": "tool_call", "tool": "todo", "result": "Plan — 1/1 done:", "success": True, "is_subagent": True}
)
assert any(line.startswith(" ") for line in _render_text(panel).splitlines())
def test_generic_tool_result_renderer_does_not_handle_todo_first():
"""Sanity: the generic ToolResultRenderer would accept a todo call, so the
registry ordering (todo before tool) is what gives us the compact card."""
assert ToolResultRenderer().accepts({"type": "tool_call", "tool": "todo", "result": "x", "success": True})