diff --git a/clients/terminal/tui/renderers/todo.py b/clients/terminal/tui/renderers/todo.py index 65ff257..2773acb 100644 --- a/clients/terminal/tui/renderers/todo.py +++ b/clients/terminal/tui/renderers/todo.py @@ -77,6 +77,9 @@ if op == "set": n = len(args.get("tasks") or []) return f"→ todo · set plan ({n})" + if op == "add": + n = len(args.get("tasks") or []) + return f"→ todo · add ({n})" if op == "update": idx = args.get("index") status = args.get("status", "") @@ -94,10 +97,10 @@ dim = theme.text_dim.hex muted = theme.text_muted.hex - if op == "set": + if op in ("set", "add"): tasks = args.get("tasks") or [] if not tasks: - return Text("(empty plan)", style=dim) + return Text("(empty plan)" if op == "set" else "(no new steps)", style=dim) lines = Text() for i, t in enumerate(tasks, 1): if i > 1: diff --git a/navi/profiles/navi_code/system_prompt.txt b/navi/profiles/navi_code/system_prompt.txt index bb643c4..edd45f0 100644 --- a/navi/profiles/navi_code/system_prompt.txt +++ b/navi/profiles/navi_code/system_prompt.txt @@ -101,12 +101,12 @@ ## Working state & memory You run on a local model with aggressive context compression — old turns get summarised and details vanish. Keep durable state in the KV-backed tools, which survive compression and sub-agent handoff; don't rely on conversation memory alone. -- **`todo`** — for any non-trivial task, create a todo up front (one item per concrete step). Mark `in_progress`/`done` as you go; `done` requires a `validation` note (how you verified it) — the structural form of "never claim done without verification". +- **`todo`** — for any non-trivial task, create a todo up front (one item per concrete step). Mark `in_progress`/`done` as you go; `done` requires a `validation` note (how you verified it) — the structural form of "never claim done without verification". When a new subtask surfaces mid-task (something the plan didn't anticipate but needs doing), add it with `todo add` right away — don't hold it in your head or wait for a replan. `add` appends steps and preserves existing progress (unlike `set`, which replaces the whole plan and resets statuses). - **`scratchpad`** — working memory for facts found mid-task (file paths, errors, decisions). Use sections: `goal` (objective in one line), `findings`, `errors`, `artifacts`. Read `scratchpad` before your final report. - **Sub-agent handoff** — before `spawn_agent`, write what the sub-agent needs (files, snippets, how to verify) into the `context_transfer` scratchpad section; it's injected into the sub-agent automatically. The sub-agent does NOT inherit your short-term memory. - **`schedule_recall`** — when a task may hit the iteration limit, or has a wait/poll cycle (build, deploy, log watch), schedule a recall with a self-instruction naming specific tools/files (future-you has the tools, not your memory). Use `immediate` to continue after the limit or offload heavy work headlessly; chain recalls for multi-phase work. Only one pending recall per session — `manage_recall cancel` before a new one. - **`reflect`** — call it before a genuinely complex plan, or when you are stuck on one step: if you have made ~3 tool attempts on the same step without progress, call `reflect` IN THIS TURN (it is a tool call, not reasoning aloud) to surface wrong assumptions and get a fresh angle. Stopping to narrate "I'll try another approach" instead of calling `reflect` is the failure mode to avoid. It costs 3 LLM calls — use selectively, not on routine edits. -- **`replan`** — when the **structure** of the remaining plan is stale because of what you discovered mid-task (a step is unnecessary, the real problem differs from the assumed one, new constraints appeared — NOT because a step failed, which you handle by revising the `todo` inline), call `replan` with a short `reason` (what changed) and optional `updated_goal`. It re-runs the planner over your current context + `todo` + `scratchpad` findings/errors and replaces the plan and `todo`. Also call `replan` (with `updated_goal`/`reason`) when `reflect` showed the whole approach is dead — not one failed step, but the approach itself won't reach the goal. A single step failing is still a `todo` edit; a dead approach found via `reflect` is a `replan`. Distinguish from: `[Adaptive re-plan]` (a step failed — revise the `todo` inline, no planner call), a small `todo` edit (drop/merge/reorder 1-2 steps — edit inline, no planner call), and `reflect` (you're unsure what's wrong — surfaces assumptions, no plan change). Costs 1–3 LLM calls — use only when the remaining steps no longer fit as a whole. +- **`replan`** — when the **structure** of the remaining plan is stale because of what you discovered mid-task (a step is unnecessary, the real problem differs from the assumed one, new constraints appeared — NOT because a step failed, which you handle by revising the `todo` inline), call `replan` with a short `reason` (what changed) and optional `updated_goal`. It re-runs the planner over your current context + `todo` + `scratchpad` findings/errors and replaces the plan and `todo`. Also call `replan` (with `updated_goal`/`reason`) when `reflect` showed the whole approach is dead — not one failed step, but the approach itself won't reach the goal. A single step failing is still a `todo` edit; a dead approach found via `reflect` is a `replan`. Distinguish from: `[Adaptive re-plan]` (a step failed — revise the `todo` inline, no planner call), a small `todo` edit (add 1–2 steps via `todo add` — no planner call; drop/merge/reorder via `set`, which resets statuses so re-apply them), and `reflect` (you're unsure what's wrong — surfaces assumptions, no plan change). Costs 1–3 LLM calls — use only when the remaining steps no longer fit as a whole. - **`memory`** — global cross-project facts (prefs, environment); not a substitute for `scratchpad` (session) or `docs/`/`NAVI.md` (project). ### System signals you'll see diff --git a/navi/tools/todo.py b/navi/tools/todo.py index 66a5845..539005c 100644 --- a/navi/tools/todo.py +++ b/navi/tools/todo.py @@ -90,6 +90,7 @@ "Before final response, make sure every completed step, including the final step, is marked done with validation. " "Call 'view' to re-orient yourself after sub-agent execution or long tool chains. " "Use 'set' only when you need to replace the plan mid-task (rare). " + "Use 'add' to append new steps discovered mid-task — it preserves existing steps and their statuses (unlike 'set'). " "Statuses: pending → in_progress → done / failed / skipped." ) parameters = { @@ -97,18 +98,19 @@ "properties": { "op": { "type": "string", - "enum": ["set", "view", "update", "clear"], + "enum": ["set", "view", "update", "add", "clear"], "description": ( "set — create/replace the Master Plan with a list of task milestones; " "view — show the current state of the plan; " "update — change the status of a specific task; " + "add — append new steps to the plan (preserves existing steps and their statuses); " "clear — reset the plan" ), }, "tasks": { "type": "array", "items": {"type": "string"}, - "description": "Ordered list of task descriptions (required for 'set').", + "description": "Ordered list of task descriptions (required for 'set' and 'add').", }, "index": { "type": "integer", @@ -196,6 +198,26 @@ await _save_tasks(sid, tasks) return ToolResult(success=True, output=self._render(sid, tasks)) + if op == "add": + # Append steps discovered mid-task without disturbing the existing + # plan (statuses preserved). Unlike 'set', this does not require + # rebuilding the whole list or re-applying progress — so a newly + # surfaced subtask is cheap to record. Requires an existing plan; + # use 'set' to create one from scratch. + raw = params.get("tasks") or [] + if not raw: + return ToolResult(success=False, output="", error="'tasks' list is required for 'add'") + tasks = await _load_tasks(sid) + if not tasks: + return ToolResult( + success=False, + output="", + error="No plan set yet. Use 'set' to create a plan first, then 'add' to extend it.", + ) + tasks.extend(_Task(text=str(t)) for t in raw) + await _save_tasks(sid, tasks) + return ToolResult(success=True, output=self._render(sid, tasks)) + if op == "clear": if _kv_store is not None: await _kv_store.clear_scope(_uid(ctx.user_id if ctx else None), sid, "todo") diff --git a/tests/clients/test_todo_renderers.py b/tests/clients/test_todo_renderers.py index a084881..dd4f72c 100644 --- a/tests/clients/test_todo_renderers.py +++ b/tests/clients/test_todo_renderers.py @@ -70,6 +70,34 @@ assert '"tasks"' not in out +def test_add_card_lists_new_steps_with_count() -> None: + """The add op (new steps surfaced mid-task) renders a compact card titled + by count, listing the new pending steps — not a JSON dump.""" + set_active_theme("gnexus-dark") + renderer = TodoStartedRenderer() + msg = { + "type": "tool_started", + "tool": "todo", + "args": {"op": "add", "tasks": ["add a retry", "log the failure"]}, + } + panel = renderer.render(msg) + assert "add (2)" in str(panel.title) + out = _render_text(panel) + assert "○" in out + assert "1. add a retry" in out + assert "2. log the failure" in out + assert '"tasks"' not in out # no JSON dump + + +def test_add_card_empty_tasks() -> None: + set_active_theme("gnexus-dark") + renderer = TodoStartedRenderer() + msg = {"type": "tool_started", "tool": "todo", "args": {"op": "add", "tasks": []}} + panel = renderer.render(msg) + assert "add (0)" in str(panel.title) + assert "no new steps" in _render_text(panel) + + def test_set_card_empty_plan() -> None: set_active_theme("gnexus-dark") panel = TodoStartedRenderer().render({"type": "tool_started", "tool": "todo", "args": {"op": "set", "tasks": []}}) diff --git a/tests/unit/tools/test_todo.py b/tests/unit/tools/test_todo.py index 91aa9ff..2ef821c 100644 --- a/tests/unit/tools/test_todo.py +++ b/tests/unit/tools/test_todo.py @@ -112,6 +112,49 @@ assert "cleared" in result.output.lower() +@pytest.mark.asyncio +async def test_add_appends_steps_preserving_statuses(_fake_kv): + """add appends new steps and preserves existing steps + their statuses + (unlike set, which resets everything to pending).""" + tool = TodoTool() + ctx = ToolContext(session_id="sess1", user_id="user1") + await tool.execute({"op": "set", "tasks": ["task A", "task B"]}, ctx=ctx) + # Mark task A done with validation. + await tool.execute({"op": "update", "index": 1, "status": "done", "validation": "tested"}, ctx=ctx) + # Add a new step discovered mid-task. + result = await tool.execute({"op": "add", "tasks": ["task C", "task D"]}, ctx=ctx) + assert result.success is True + # The new steps appear… + assert "task C" in result.output + assert "task D" in result.output + # …and the existing done step is still done (status preserved). + snapshot = await get_task_snapshot("sess1") + statuses = dict((text, status) for text, status in snapshot) + assert statuses.get("task A") == "done" + assert statuses.get("task B") == "pending" + assert statuses.get("task C") == "pending" + assert statuses.get("task D") == "pending" + + +@pytest.mark.asyncio +async def test_add_requires_existing_plan(_fake_kv): + tool = TodoTool() + ctx = ToolContext(session_id="sess1", user_id="user1") + result = await tool.execute({"op": "add", "tasks": ["task A"]}, ctx=ctx) + assert result.success is False + assert "set" in result.error # points the user to 'set' first + + +@pytest.mark.asyncio +async def test_add_requires_tasks(_fake_kv): + tool = TodoTool() + ctx = ToolContext(session_id="sess1", user_id="user1") + await tool.execute({"op": "set", "tasks": ["task A"]}, ctx=ctx) + result = await tool.execute({"op": "add", "tasks": []}, ctx=ctx) + assert result.success is False + assert "tasks" in result.error.lower() + + # ── Public API tests ─────────────────────────────────────────────────────────