"""Unit tests for navi.tools.todo."""
import pytest
from navi.llm.base import Message
from navi.tools.todo import (
TodoTool,
get_progress_message,
get_task_snapshot,
load_tasks_for,
set_tasks,
)
from navi.tools._internal.base import ToolContext
from navi.store import KvStore
from tests.conftest_factory import FakePool
class FakeKvStore(KvStore):
"""In-memory KV store for tests."""
def __init__(self):
self._data: dict[tuple, str] = {}
async def _get_pool(self):
return FakePool()
async def get(self, user_id, session_id, scope, key):
return self._data.get((user_id or "", session_id, scope, key))
async def set(self, user_id, session_id, scope, key, value):
self._data[(user_id or "", session_id, scope, key)] = value
async def get_all(self, user_id, session_id, scope):
return {
k[3]: v
for k, v in self._data.items()
if k[:3] == (user_id or "", session_id, scope)
}
async def delete(self, user_id, session_id, scope, key):
self._data.pop((user_id or "", session_id, scope, key), None)
async def clear_scope(self, user_id, session_id, scope):
keys = [k for k in self._data if k[:3] == (user_id or "", session_id, scope)]
for k in keys:
del self._data[k]
@pytest.fixture(autouse=True)
def _fake_kv():
store = FakeKvStore()
from navi.tools import todo as _mod
_mod._kv_store = store
yield store
_mod._kv_store = None
# ── TodoTool execute tests ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_set_tasks(_fake_kv):
tool = TodoTool()
result = await tool.execute({"op": "set", "tasks": ["task A", "task B"]}, ctx=ToolContext(session_id="sess1", user_id="user1"))
assert result.success is True
assert "task A" in result.output
assert "task B" in result.output
@pytest.mark.asyncio
async def test_view_empty(_fake_kv):
tool = TodoTool()
result = await tool.execute({"op": "view"}, ctx=ToolContext(session_id="sess1", user_id="user1"))
assert result.success is True
assert "No plan set" in result.output
@pytest.mark.asyncio
async def test_update_status(_fake_kv):
tool = TodoTool()
await tool.execute({"op": "set", "tasks": ["task A"]}, ctx=ToolContext(session_id="sess1", user_id="user1"))
result = await tool.execute({"op": "update", "index": 1, "status": "done", "validation": "tested"}, ctx=ToolContext(session_id="sess1", user_id="user1"))
assert result.success is True
assert "done" in result.output
@pytest.mark.asyncio
async def test_done_requires_validation(_fake_kv):
tool = TodoTool()
await tool.execute({"op": "set", "tasks": ["task A"]}, ctx=ToolContext(session_id="sess1", user_id="user1"))
result = await tool.execute({"op": "update", "index": 1, "status": "done"}, ctx=ToolContext(session_id="sess1", user_id="user1"))
assert result.success is False
assert "validation" in result.error.lower()
@pytest.mark.asyncio
async def test_failed_without_validation_warns(_fake_kv):
tool = TodoTool()
await tool.execute({"op": "set", "tasks": ["task A"]}, ctx=ToolContext(session_id="sess1", user_id="user1"))
result = await tool.execute({"op": "update", "index": 1, "status": "failed"}, ctx=ToolContext(session_id="sess1", user_id="user1"))
assert result.success is True
assert "Tip" in result.output
@pytest.mark.asyncio
async def test_clear(_fake_kv):
tool = TodoTool()
await tool.execute({"op": "set", "tasks": ["task A"]}, ctx=ToolContext(session_id="sess1", user_id="user1"))
result = await tool.execute({"op": "clear"}, ctx=ToolContext(session_id="sess1", user_id="user1"))
assert result.success is True
assert "cleared" in result.output.lower()
# ── Public API tests ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_task_snapshot(_fake_kv):
await set_tasks("sess1", ["t1", "t2"])
snapshot = await get_task_snapshot("sess1")
assert snapshot == frozenset({("t1", "pending"), ("t2", "pending")})
@pytest.mark.asyncio
async def test_get_progress_message(_fake_kv):
await set_tasks("sess1", ["t1", "t2"])
msg = await get_progress_message("sess1", first_iteration=True)
assert isinstance(msg, Message)
assert msg.role == "system"
assert "TODO progress" in msg.content
# ── Sub-agent todo isolation ──────────────────────────────────────────────────
#
# current_todo_session_id scopes a sub-agent's todo to its own ephemeral run id
# so its plan/updates do NOT clobber the parent session's todo (which the parent's
# goal-anchoring reads every iteration). These tests pin the _sid() precedence and
# the end-to-end isolation of the TodoTool writes.
@pytest.mark.asyncio
async def test_sid_prefers_todo_session_id_over_session_and_explicit(_fake_kv):
from navi.tools._internal.base import current_session_id, current_todo_session_id
from navi.tools.todo import _sid
t_tok = current_todo_session_id.set("sub_run_123")
s_tok = current_session_id.set("parent_sess")
try:
# todo-session-id wins over both current_session_id and an explicit arg.
assert _sid() == "sub_run_123"
assert _sid("explicit_sess") == "sub_run_123"
finally:
current_todo_session_id.reset(t_tok)
current_session_id.reset(s_tok)
@pytest.mark.asyncio
async def test_sid_falls_back_to_session_id_when_todo_unset(_fake_kv):
from navi.tools._internal.base import current_session_id
from navi.tools.todo import _sid
s_tok = current_session_id.set("parent_sess")
try:
assert _sid() == "parent_sess"
# Explicit arg still wins over the ContextVar fallback when todo-id is unset.
assert _sid("explicit_sess") == "explicit_sess"
finally:
current_session_id.reset(s_tok)
# And with everything unset, the default sentinel is used.
assert _sid() == "__default__"
@pytest.mark.asyncio
async def test_todo_tool_writes_isolated_subagent_row(_fake_kv):
"""A sub-agent (current_todo_session_id set to its run id) writes its todo into
its own KV row, leaving the parent session's todo untouched."""
from navi.tools._internal.base import current_session_id, current_todo_session_id
tool = TodoTool()
# Parent session already has a todo plan.
await tool.execute(
{"op": "set", "tasks": ["parent task A"]},
ctx=ToolContext(session_id="parent_sess", user_id="user1"),
)
# Sub-agent context: todo scoped to its run id; ctx.session_id is still the
# parent session id (other tools rely on it), but todo must isolate.
t_tok = current_todo_session_id.set("sub_run_abc")
s_tok = current_session_id.set("parent_sess")
try:
await tool.execute(
{"op": "set", "tasks": ["subagent step 1", "subagent step 2"]},
ctx=ToolContext(session_id="parent_sess", user_id="user1"),
)
await tool.execute(
{"op": "update", "index": 1, "status": "done", "validation": "verified"},
ctx=ToolContext(session_id="parent_sess", user_id="user1"),
)
finally:
current_todo_session_id.reset(t_tok)
current_session_id.reset(s_tok)
# Sub-agent row carries its own tasks.
sub_snapshot = await get_task_snapshot("sub_run_abc")
assert sub_snapshot == frozenset(
{("subagent step 1", "done"), ("subagent step 2", "pending")}
)
# Parent row is untouched — original single task, still pending.
parent_snapshot = await get_task_snapshot("parent_sess")
assert parent_snapshot == frozenset({("parent task A", "pending")})
@pytest.mark.asyncio
async def test_progress_message_reads_isolated_rows(_fake_kv):
"""get_progress_message is keyed by the sid it is given, so the parent's
goal-anchoring (which passes parent_sess) never sees the sub-agent's todo."""
from navi.tools._internal.base import current_session_id, current_todo_session_id
tool = TodoTool()
# Sub-agent writes a 2-step plan into its own row.
t_tok = current_todo_session_id.set("sub_run_def")
s_tok = current_session_id.set("parent_sess")
try:
await tool.execute(
{"op": "set", "tasks": ["sub step 1", "sub step 2"]},
ctx=ToolContext(session_id="parent_sess", user_id="user1"),
)
finally:
current_todo_session_id.reset(t_tok)
current_session_id.reset(s_tok)
# Parent's progress message (keyed by parent_sess) sees no plan.
parent_msg = await get_progress_message("parent_sess")
assert parent_msg is None
# Sub-agent's row is readable by its own run id.
sub_msg = await get_progress_message("sub_run_def")
assert sub_msg is not None
assert "sub step 1" in sub_msg.content
# ── load_tasks_for: explicit-id reader for REST / TodoUpdated emitter ─────────
@pytest.mark.asyncio
async def test_load_tasks_for_returns_serialisable_dicts(_fake_kv):
await set_tasks("sess1", ["t1", "t2"])
tasks = await load_tasks_for("sess1", None)
assert tasks == [
{"index": 1, "text": "t1", "status": "pending", "validation": ""},
{"index": 2, "text": "t2", "status": "pending", "validation": ""},
]
@pytest.mark.asyncio
async def test_load_tasks_for_empty_when_no_plan(_fake_kv):
assert await load_tasks_for("no-such-sess", None) == []
@pytest.mark.asyncio
async def test_load_tasks_for_respects_user_id(_fake_kv):
"""Rows are keyed by (user_id, session_id); load_tasks_for reads only the
requested user's row, not another user's for the same session id."""
await _fake_kv.set(
"userA", "sess1", "todo", "tasks",
'[{"text": "A task", "status": "pending", "validation": ""}]',
)
await _fake_kv.set(
"userB", "sess1", "todo", "tasks",
'[{"text": "B task", "status": "done", "validation": "x"}]',
)
assert await load_tasks_for("sess1", "userA") == [
{"index": 1, "text": "A task", "status": "pending", "validation": ""},
]
assert await load_tasks_for("sess1", "userB") == [
{"index": 1, "text": "B task", "status": "done", "validation": "x"},
]
# No row for userC → empty.
assert await load_tasks_for("sess1", "userC") == []
@pytest.mark.asyncio
async def test_load_tasks_for_uses_explicit_kv_not_global(_fake_kv):
"""When an explicit kv is passed, the module-global _kv_store is ignored —
the REST route resolves its own KvStore via deps."""
class _MemKv:
def __init__(self):
self._d = {}
async def get(self, user_id, session_id, scope, key):
return self._d.get((user_id or "", session_id, scope, key))
async def set(self, user_id, session_id, scope, key, value):
self._d[(user_id or "", session_id, scope, key)] = value
# The autouse _fake_kv fixture backs the module global with a row.
await set_tasks("sess1", ["global-row"])
# A separate in-memory kv with different contents must win when passed.
other = _MemKv()
await other.set(None, "sess1", "todo", "tasks", '[{"text": "kv-row", "status": "done", "validation": "x"}]')
tasks = await load_tasks_for("sess1", None, kv=other)
assert tasks == [{"index": 1, "text": "kv-row", "status": "done", "validation": "x"}]
# And the global (no kv arg) still reads the global row.
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) == {}