diff --git a/clients/terminal/tui/renderers/__init__.py b/clients/terminal/tui/renderers/__init__.py index 83a6209..fd6010c 100644 --- a/clients/terminal/tui/renderers/__init__.py +++ b/clients/terminal/tui/renderers/__init__.py @@ -4,7 +4,7 @@ from .base import ContentRenderer from .registry import RendererRegistry -from . import message, tool, thinking, error, markdown_content, plain, diff, artifact, status, planning, subagent, turn_meta +from . import message, tool, thinking, error, markdown_content, plain, diff, artifact, status, planning, subagent, todo, turn_meta def default_registry() -> RendererRegistry: @@ -13,9 +13,11 @@ reg.register(message.UserMessageRenderer()) reg.register(message.AssistantMessageRenderer()) reg.register(thinking.ThinkingRenderer()) - # spawn_agent cards must be checked before the generic tool renderers. + # spawn_agent and todo cards must be checked before the generic tool renderers. reg.register(subagent.SpawnAgentStartedRenderer()) reg.register(subagent.SpawnAgentResultRenderer()) + reg.register(todo.TodoStartedRenderer()) + reg.register(todo.TodoResultRenderer()) reg.register(tool.ToolStartedRenderer()) reg.register(tool.ToolResultRenderer()) reg.register(error.ErrorRenderer()) diff --git a/clients/terminal/tui/renderers/todo.py b/clients/terminal/tui/renderers/todo.py new file mode 100644 index 0000000..fdd3105 --- /dev/null +++ b/clients/terminal/tui/renderers/todo.py @@ -0,0 +1,222 @@ +"""Renderers for the ``todo`` tool — the plan tracker. + +The default ``ToolStartedRenderer`` dumps the call args as raw JSON, which is +unreadable for a tool as central as todo (and for ``set`` produces a wall of +quoted strings). These renderers turn the call into a compact, op-aware card +and collapse the result into a single status line — the full live plan already +lives in the side panel (``TodoList``), so the chat card only needs to convey +*what changed*, not the whole state. + +Pattern mirrors ``subagent.py``: registered before the generic tool renderers. +""" + +from __future__ import annotations + +import json + +from rich.box import ROUNDED +from rich.console import RenderableType +from rich.json import JSON as RichJSON +from rich.panel import Panel +from rich.text import Text + +from clients.terminal.tui.themes import get_active_theme + +from .base import ContentRenderer + +_TODO_TOOL = "todo" + +# Same markers as the side panel (todo_list.py) so the chat card and the panel +# read as one vocabulary. +_ICON = {"pending": "○", "in_progress": "◎", "done": "✓", "failed": "✗", "skipped": "—"} +# Validation status text colour by the new status (used in update cards). +_VAL_COLOR = { + "done": "success", + "failed": "error", + "in_progress": "accent", + "skipped": "text_dim", + "pending": "text_dim", +} + + +def _truncate(text: str, limit: int) -> str: + text = (text or "").strip() + if len(text) <= limit: + return text + return text[:limit].rstrip() + " …" + + +class TodoStartedRenderer(ContentRenderer): + """Render a ``todo`` call start as a compact, op-aware card.""" + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "tool_started" and msg.get("tool") == _TODO_TOOL + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + args = msg.get("args") or {} + op = args.get("op") + accent = theme.accent.hex + + body = self._body(msg, theme) + title = self._title(op, args, accent) + panel = Panel( + body, + title=title, + title_align="left", + border_style=theme.tool_border.hex, + box=ROUNDED, + ) + if bool(msg.get("is_subagent", False)): + from rich.padding import Padding + + return Padding(panel, (0, 0, 0, 2)) + return panel + + def _title(self, op: str | None, args: dict, accent: str) -> str: + if op == "set": + n = len(args.get("tasks") or []) + return f"→ todo · set plan ({n})" + if op == "update": + idx = args.get("index") + status = args.get("status", "") + arrow = f" → {status}" if status else "" + return f"→ todo · #{idx}{arrow}" + if op == "view": + return "→ todo · view" + if op == "clear": + return "→ todo · clear" + return f"→ todo · {op or '?'}" + + def _body(self, msg: dict, theme) -> RenderableType: + args = msg.get("args") or {} + op = args.get("op") + dim = theme.text_dim.hex + muted = theme.text_muted.hex + + if op == "set": + tasks = args.get("tasks") or [] + if not tasks: + return Text("(empty plan)", style=dim) + lines = Text() + for i, t in enumerate(tasks, 1): + if i > 1: + lines.append("\n") + lines.append(f" {_ICON['pending']} ", style=dim) + lines.append(f"{i}. {_truncate(str(t), 80)}", style=muted) + return lines + + if op == "update": + status = args.get("status", "") + validation = (args.get("validation") or "").strip() + # The step text comes from ToolStarted.metadata (enriched on the + # backend by loading the plan before the tool runs — the LLM's + # update args carry only the index). Absent on history replay. + step_text = "" + meta = msg.get("metadata") + if isinstance(meta, dict): + step_text = (meta.get("step_text") or "").strip() + body = Text() + has_content = False + if step_text: + body.append(_truncate(step_text, 100), style=theme.text.hex) + has_content = True + if validation: + color_attr = _VAL_COLOR.get(status, "text_dim") + color = getattr(theme, color_attr, theme.text_dim).hex + if has_content: + body.append("\n") + body.append("verified: ", style=dim) + body.append(_truncate(validation, 160), style=color) + has_content = True + if not has_content: + # No step text (history replay) and no validation — keep the + # placeholder so the card isn't empty. + body.append("no validation", style=dim) + return body + + if op in ("view", "clear"): + return Text("") + + # Unknown op — fall back to readable JSON so the call is still legible. + try: + return RichJSON(json.dumps(args)) + except Exception: + return Text(str(args), style=dim) + + +class TodoResultRenderer(ContentRenderer): + """Render a ``todo`` result as a single compact status line. + + The full plan is in the side panel; the chat card only needs the summary. + The result text from ``todo._render`` starts with ``Plan — X/N done:``; + we parse that. Other result strings (``Plan is empty.``, ``Plan cleared.``, + ``No plan set for this session.``) map to short labels. + """ + + def accepts(self, msg: dict) -> bool: + return msg.get("type") == "tool_call" and msg.get("tool") == _TODO_TOOL + + def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() + success = msg.get("success", True) + result = msg.get("result") + text = str(result) if result is not None else "" + + if bool(msg.get("is_subagent", False)): + from rich.padding import Padding + + return Padding(self._card(success, text, theme), (0, 0, 0, 2)) + return self._card(success, text, theme) + + def _card(self, success: bool, text: str, theme) -> Panel: + if not success: + # e.g. validation_required — surface the first line compactly. + first = text.splitlines()[0].strip() if text else "error" + color = theme.tool_error + return Panel( + Text(_truncate(first, 120), style=color.hex), + title="← todo ✗", + title_align="left", + border_style=color.hex, + box=ROUNDED, + ) + + # The step text lives in the *call* card (started, via metadata); the + # result card only carries the new overall state — one compact line. + label = self._status_label(text) + return Panel( + self._status_line(label, theme), + title="← todo ✓", + title_align="left", + border_style=theme.tool_success.hex, + box=ROUNDED, + ) + + def _status_label(self, text: str) -> str: + first = text.splitlines()[0].strip() if text else "" + # "Plan — 2/5 done:" + if first.startswith("Plan —") and "done" in first: + tail = first[len("Plan —"):].rstrip(":").strip() # "2/5 done" + return f"plan · {tail}" + if first == "Plan is empty.": + return "plan · empty" + if first == "Plan cleared.": + return "plan · cleared" + if first == "No plan set for this session.": + return "plan · no plan" + return _truncate(first, 80) or "plan · ok" + + def _status_line(self, label: str, theme) -> Text: + dim = theme.text_dim.hex + if label.startswith("plan · ") and "/" in label: + # "plan · 2/5 done" → prefix dim, "2" success, "/5 done" dim. + body = Text("plan · ", style=dim) + rest = label[len("plan · "):] # "2/5 done" + slash = rest.find("/") + done_n = rest[:slash] + after = rest[slash:] # "/5 done" + body.append(done_n, style=theme.success.hex) + body.append(after, style=dim) + return body + return Text(label, style=dim) \ No newline at end of file diff --git a/navi/core/agent.py b/navi/core/agent.py index fd0e044..82489d0 100644 --- a/navi/core/agent.py +++ b/navi/core/agent.py @@ -890,7 +890,14 @@ """ tool_map = {t.name: t for t in tools} for tc in turn_tool_calls: - yield ToolStarted(tool_name=tc.name, arguments=tc.arguments, tool_call_id=tc.id) + from navi.tools.todo import started_metadata_for_call + + yield ToolStarted( + tool_name=tc.name, + arguments=tc.arguments, + tool_call_id=tc.id, + metadata=await started_metadata_for_call(tc, tool_ctx), + ) sink: asyncio.Queue = asyncio.Queue() sink_token = current_event_sink.set(sink) diff --git a/navi/core/events.py b/navi/core/events.py index 2fc70ea..a83f509 100644 --- a/navi/core/events.py +++ b/navi/core/events.py @@ -11,6 +11,7 @@ arguments: dict is_subagent: bool = False # True when emitted from inside run_ephemeral tool_call_id: str = "" # Stable id for client-side tool_started → tool_call pairing + metadata: dict = field(default_factory=dict) # Extra data for client rendering def to_wire(self) -> dict: return { @@ -18,6 +19,7 @@ "tool": self.tool_name, "args": self.arguments, "is_subagent": self.is_subagent, + "metadata": self.metadata, "tool_call_id": self.tool_call_id, } diff --git a/navi/core/subagent_runner.py b/navi/core/subagent_runner.py index 0a890e0..b6ab0f6 100644 --- a/navi/core/subagent_runner.py +++ b/navi/core/subagent_runner.py @@ -364,12 +364,15 @@ _sub_tool_count += 1 if sink is not None: + from navi.tools.todo import started_metadata_for_call + await sink.put( ToolStarted( tool_name=tc.name, arguments=tc.arguments, is_subagent=True, tool_call_id=tc.id, + metadata=await started_metadata_for_call(tc, tool_ctx), ) ) diff --git a/navi/tools/todo.py b/navi/tools/todo.py index 8ca624f..451fc87 100644 --- a/navi/tools/todo.py +++ b/navi/tools/todo.py @@ -308,6 +308,42 @@ await _save_tasks(session_id, tasks) +async def step_text_for_update(index, ctx=None) -> str | None: + """Return the current text of step ``index`` for an in-flight ``update`` + call, so the client can render *which* step is changing in the call card + (the LLM's update args carry only the index, not the text). + + Resolves the row via the same ``_sid``/``_uid`` logic the tool itself uses, + so a sub-agent's isolated todo row is read correctly + (``current_todo_session_id`` wins over ``ctx.session_id``). + """ + if index is None: + return None + try: + idx = int(index) + except (TypeError, ValueError): + return None + sid = _sid(ctx.session_id if ctx is not None else None) + tasks = await _load_tasks(sid) + if not tasks or idx < 1 or idx > len(tasks): + return None + return tasks[idx - 1].text + + +async def started_metadata_for_call(tc, ctx=None) -> dict: + """Client-rendering metadata for a ``ToolStarted`` event (duck-typed ``tc`` + with ``.name`` and ``.arguments``). For a ``todo`` ``update`` call, attaches + the step text at the given index; empty for everything else. Used by both + the parent agent and the sub-agent runner so the call card shows which step + is changing before the tool runs. + """ + if tc.name == "todo" and (tc.arguments or {}).get("op") == "update": + step_text = await step_text_for_update(tc.arguments.get("index"), ctx) + if step_text: + return {"step_text": step_text} + return {} + + async def load_tasks_for( session_id: str, user_id: str | None, kv=None ) -> list[dict]: diff --git a/tests/clients/test_todo_renderers.py b/tests/clients/test_todo_renderers.py new file mode 100644 index 0000000..1b31290 --- /dev/null +++ b/tests/clients/test_todo_renderers.py @@ -0,0 +1,229 @@ +"""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}) \ No newline at end of file diff --git a/tests/unit/core/test_events.py b/tests/unit/core/test_events.py index 4871b37..e4fbb4b 100644 --- a/tests/unit/core/test_events.py +++ b/tests/unit/core/test_events.py @@ -29,9 +29,14 @@ "tool": "fs", "args": {"action": "read"}, "is_subagent": False, + "metadata": {}, "tool_call_id": "", } + def test_to_wire_carries_metadata(self): + ev = ToolStarted(tool_name="todo", arguments={"op": "update"}, metadata={"step_text": "run tests"}) + assert ev.to_wire()["metadata"] == {"step_text": "run tests"} + def test_to_wire_subagent(self): ev = ToolStarted(tool_name="spawn_agent", arguments={}, is_subagent=True) assert ev.to_wire()["is_subagent"] is True diff --git a/tests/unit/tools/test_todo.py b/tests/unit/tools/test_todo.py index 32c96df..b4988c8 100644 --- a/tests/unit/tools/test_todo.py +++ b/tests/unit/tools/test_todo.py @@ -306,3 +306,101 @@ assert await load_tasks_for("sess1", None) == [ {"index": 1, "text": "global-row", "status": "pending", "validation": ""}, ] + + +# ── step_text_for_update / started_metadata_for_call ──────────────────────── +# The call-card step text: the LLM's update args carry only the index, so the +# text is read from the current plan row before the tool runs. + + +@pytest.mark.asyncio +async def test_step_text_for_update_returns_text_for_index(_fake_kv): + from navi.tools.todo import step_text_for_update + + await set_tasks("sess1", ["read spec", "write parser", "run tests"]) + ctx = ToolContext(session_id="sess1", user_id="user1") + assert await step_text_for_update(2, ctx) == "write parser" + assert await step_text_for_update(3, ctx) == "run tests" + + +@pytest.mark.asyncio +async def test_step_text_for_update_out_of_range_returns_none(_fake_kv): + from navi.tools.todo import step_text_for_update + + await set_tasks("sess1", ["only one"]) + ctx = ToolContext(session_id="sess1", user_id="user1") + assert await step_text_for_update(9, ctx) is None + assert await step_text_for_update(0, ctx) is None + + +@pytest.mark.asyncio +async def test_step_text_for_update_no_plan_returns_none(_fake_kv): + from navi.tools.todo import step_text_for_update + + ctx = ToolContext(session_id="no-plan", user_id="user1") + assert await step_text_for_update(1, ctx) is None + + +@pytest.mark.asyncio +async def test_step_text_for_update_bad_index_returns_none(_fake_kv): + from navi.tools.todo import step_text_for_update + + await set_tasks("sess1", ["a"]) + ctx = ToolContext(session_id="sess1", user_id="user1") + assert await step_text_for_update(None, ctx) is None + assert await step_text_for_update("oops", ctx) is None + + +@pytest.mark.asyncio +async def test_started_metadata_for_call_todo_update_attaches_step_text(_fake_kv): + from navi.tools.todo import started_metadata_for_call + + await set_tasks("sess1", ["read spec", "write parser"]) + + class _TC: + name = "todo" + arguments = {"op": "update", "index": 2, "status": "in_progress"} + + ctx = ToolContext(session_id="sess1", user_id="user1") + meta = await started_metadata_for_call(_TC(), ctx) + assert meta == {"step_text": "write parser"} + + +@pytest.mark.asyncio +async def test_started_metadata_for_call_empty_for_non_update(_fake_kv): + from navi.tools.todo import started_metadata_for_call + + await set_tasks("sess1", ["a"]) + + class _TC: + name = "todo" + arguments = {"op": "view"} + + ctx = ToolContext(session_id="sess1", user_id="user1") + assert await started_metadata_for_call(_TC(), ctx) == {} + + +@pytest.mark.asyncio +async def test_started_metadata_for_call_empty_for_other_tools(_fake_kv): + from navi.tools.todo import started_metadata_for_call + + class _TC: + name = "filesystem" + arguments = {"op": "update", "index": 1} + + ctx = ToolContext(session_id="sess1", user_id="user1") + assert await started_metadata_for_call(_TC(), ctx) == {} + + +@pytest.mark.asyncio +async def test_started_metadata_for_call_empty_when_step_missing(_fake_kv): + """If the step text can't be resolved (no plan / bad index), no metadata is + attached — the renderer falls back to its placeholder.""" + from navi.tools.todo import started_metadata_for_call + + class _TC: + name = "todo" + arguments = {"op": "update", "index": 5, "status": "done"} + + ctx = ToolContext(session_id="no-plan", user_id="user1") + assert await started_metadata_for_call(_TC(), ctx) == {}