diff --git a/clients/terminal/tui/widgets/todo_list.py b/clients/terminal/tui/widgets/todo_list.py index 0de0ba6..488de36 100644 --- a/clients/terminal/tui/widgets/todo_list.py +++ b/clients/terminal/tui/widgets/todo_list.py @@ -64,7 +64,10 @@ def set_tasks(self, tasks: list[dict]) -> None: """Replace the rendered todo. ``tasks`` is the REST/WS payload: - ``[{"index", "text", "status", "validation"}, ...]``.""" + ``[{"index", "text", "status", "validation", "milestone"}, ...]``. + When any task carries a ``milestone`` label, the panel groups steps + under ``Milestone `` headers; otherwise it renders flat + (status-rank ordering).""" self._tasks = list(tasks) self.update(self._build_content()) @@ -88,14 +91,57 @@ "done": 3, } - def _ordered_tasks(self) -> list[dict]: - """Tasks grouped by status for display, original order preserved within - each group. Does not mutate ``self._tasks``.""" + def _ordered_within(self, tasks: list[dict]) -> list[dict]: + """Tasks grouped by status for display (in_progress → pending → + failed/skipped → done), original order preserved within each rank. + Does not mutate the input.""" return sorted( - self._tasks, + tasks, key=lambda t: self._STATUS_RANK.get(t.get("status", "") or "pending", 1), ) + def _ordered_tasks(self) -> list[dict]: + """All tasks, status-grouped for display (legacy flat path).""" + return self._ordered_within(self._tasks) + + def _has_milestones(self) -> bool: + return any((t.get("milestone") or "") for t in self._tasks) + + def _milestone_groups(self) -> list[tuple[str, list[dict]]]: + """Group tasks by ``milestone`` in first-appearance order. The unnamed + group (``""``) is moved to the end so named milestones lead the panel.""" + order: list[str] = [] + by_group: dict[str, list[dict]] = {} + for t in self._tasks: + ms = t.get("milestone") or "" + if ms not in by_group: + order.append(ms) + by_group[ms] = [] + by_group[ms].append(t) + if "" in by_group and order[-1] != "": + order.remove("") + order.append("") + return [(g, by_group[g]) for g in order] + + def _render_task_line(self, t: dict, theme, dim: str) -> str: + status = t.get("status", "") or "pending" + icon = _STATUS_ICON.get(status, "?") + icon_color = getattr(theme, _STATUS_COLOR_ATTR.get(status, "text_dim"), theme.text_dim).hex + text = t.get("text", "") + # Line emphasis: the active step is bold + bright; completed/skipped + # are dimmed; failed pops in the warning colour; pending is muted. + if status == "in_progress": + text_style = f"bold {theme.text.hex}" + elif status in ("done", "skipped"): + text_style = f"dim {theme.text_muted.hex}" + elif status == "failed": + text_style = theme.warning.hex + else: + text_style = theme.text_muted.hex + suffix = f" [{dim}]({status})[/]" if status not in ("pending", "done") else "" + note = f" [{dim}]✓ {t['validation']}[/]" if t.get("validation") else "" + return f" [{icon_color}]{icon}[/] [{text_style}]{t.get('index', '')}. {text}[/]{suffix}{note}" + def _build_content(self) -> str: theme = get_active_theme() dim = theme.text_dim.hex @@ -107,30 +153,23 @@ lines: list[str] = [ f"[{theme.success.hex}]{done}[/{theme.success.hex}]/{n} done" ] - # Display order: active work on top, queued next, blocked/dismissed - # (failed + skipped) below the queue, completed at the bottom. Stable - # within a group — original payload order is preserved, only the groups - # are reordered. ``self._tasks`` itself keeps the plan's original order. - for t in self._ordered_tasks(): - status = t.get("status", "") or "pending" - icon = _STATUS_ICON.get(status, "?") - icon_color = getattr(theme, _STATUS_COLOR_ATTR.get(status, "text_dim"), theme.text_dim).hex - text = t.get("text", "") - # Line emphasis: the active step is bold + bright; completed/skipped - # are dimmed; failed pops in the warning colour; pending is muted. - if status == "in_progress": - text_style = f"bold {theme.text.hex}" - elif status in ("done", "skipped"): - text_style = f"dim {theme.text_muted.hex}" - elif status == "failed": - text_style = theme.warning.hex - else: - text_style = theme.text_muted.hex - suffix = f" [{dim}]({status})[/]" if status not in ("pending", "done") else "" - note = f" [{dim}]✓ {t['validation']}[/]" if t.get("validation") else "" - lines.append( - f" [{icon_color}]{icon}[/] [{text_style}]{t.get('index', '')}. {text}[/]{suffix}{note}" - ) + if not self._has_milestones(): + # Flat path (legacy): status-rank ordering, no group headers. + for t in self._ordered_tasks(): + lines.append(self._render_task_line(t, theme, dim)) + return "\n".join(lines) + # Grouped path: milestone headers (✓ when the whole group is done/ + # skipped), status-rank ordering within each group. Plan order defines + # group sequence; the unnamed group goes last. + for ms, group_tasks in self._milestone_groups(): + if ms: + group_done = all( + t.get("status") in ("done", "skipped") for t in group_tasks + ) + check = f" [{theme.success.hex}]✓[/]" if group_done else "" + lines.append(f"[{dim}]Milestone {ms}{check}[/]") + for t in self._ordered_within(group_tasks): + lines.append(self._render_task_line(t, theme, dim)) return "\n".join(lines) diff --git a/navi/api/routes/admin.py b/navi/api/routes/admin.py index 55818a6..aae824b 100644 --- a/navi/api/routes/admin.py +++ b/navi/api/routes/admin.py @@ -348,6 +348,9 @@ "anti_stall_threshold": profile.anti_stall_threshold, "step_validation_enabled": profile.step_validation_enabled, "adaptive_replan_enabled": profile.adaptive_replan_enabled, + "adaptive_long_step_threshold": profile.adaptive_long_step_threshold, + "final_intercept_enabled": profile.final_intercept_enabled, + "final_intercept_limit": profile.final_intercept_limit, "subagent_planning_enabled": profile.subagent_planning_enabled, "subagent_think_enabled": profile.subagent_think_enabled, "tools": profile.tools.model_dump(), diff --git a/navi/core/agent.py b/navi/core/agent.py index 75aa9ce..7b57f28 100644 --- a/navi/core/agent.py +++ b/navi/core/agent.py @@ -258,6 +258,22 @@ return await get_progress_message(session_id, first_iteration=first_iteration) +# Final-turn intercept nudges — injected when the model ends a turn with bare +# text (no tool call) while the todo still has open steps, then the loop is +# continued instead of closed. Escalating tone: 1st is a soft pointer to the +# available recovery tools, 2nd is a hard "act now, no bare text" instruction. +# Indexed by turn_ctx.final_interceptions (clamped to the last/hardest). +_FINAL_INTERCEPT_NUDGES = ( + "[Final-turn check] You ended the turn with text only, but the todo list still has open steps " + "(pending/in_progress). Do not stop with bare text while work remains. Either mark finished " + "steps done/skipped via the todo tool, reflect on what is blocking, replan if the approach is " + "dead, or call a tool to continue — then keep going.", + "[Final-turn check — second stop] You already stopped once without acting and the todo still " + "has open steps. You must act now: call a tool — todo (close steps as done/skipped/failed with " + "validation), reflect, replan, or continue execution. Do not output text without a tool call.", +) + + class Agent: def __init__( self, @@ -525,6 +541,13 @@ stop_event = current_stop_event.get() turn_ctx = AgentTurnContext(turn_start=time.monotonic()) + # Final-turn intercept nudge pending injection into the next iteration's + # built_ctx. Lives in the generator's local scope so it survives across + # iterations of the for-loop (injections are ephemeral — built_ctx is + # rebuilt each iteration — so this is the channel that carries the + # nudge from the intercept site to the next turn's context). + pending_final_nudge: Message | None = None + # Planning phase — always runs on the first user message in a session; # on subsequent messages uses the profile's planning_enabled flag. # force_plan suppresses the DIRECT shortcut: first message is always forced, @@ -643,6 +666,13 @@ if _stall_msg: built_ctx.append(_stall_msg) + # Final-turn intercept nudge carried over from a previous bare-text + # turn: the model stopped to narrate while the todo had open steps, + # so we inject the recovery pointer and continue the loop. + if pending_final_nudge is not None: + built_ctx.append(pending_final_nudge) + pending_final_nudge = None + try: self._compressor.check_context_size(built_ctx, session_context=session.context) except ContextTooLargeError as e: @@ -705,7 +735,10 @@ turn_tool_calls = state.turn_tool_calls if not turn_tool_calls: - # Final response — text already streamed above + # Final response — text already streamed above. The base message + # is built without metrics so it is shared by the intercept and the + # genuine-final paths; the model must SEE its own bare text next + # iteration when we intercept, so it goes into session.context now. _elapsed = round(time.monotonic() - turn_ctx.turn_start, 1) # Net tokens = accumulated across all iterations + subagent tokens _net_tokens = turn_ctx.turn_tokens + turn_ctx.subagent_tokens @@ -714,13 +747,38 @@ content=state.accumulated_text or None, thinking=state.accumulated_thinking or None, created_at=datetime.now(timezone.utc), - elapsed_seconds=_elapsed, - tool_call_count=turn_ctx.tool_call_count if turn_ctx.tool_call_count else None, - token_count=_net_tokens if _net_tokens else None, ) session.messages.append(assistant_msg) session.context.append(assistant_msg) session.context_token_count = state.context_tokens or 0 + + # Final-turn intercept: the model stopped with bare text, but the + # anti-stall/adaptive nudges never fired (they live inside the + # tool-loop; a bare-text turn closes the run before post_turn). + # If the todo still has open steps and we haven't hit the limit, + # queue a recovery nudge and continue the loop instead of closing. + from navi.tools.todo import has_open_steps + + if ( + profile.final_intercept_enabled + and turn_ctx.final_interceptions < profile.final_intercept_limit + and await has_open_steps(session_id) + ): + turn_ctx.final_interceptions += 1 + pending_final_nudge = Message( + role="system", + content=_FINAL_INTERCEPT_NUDGES[ + min(turn_ctx.final_interceptions, len(_FINAL_INTERCEPT_NUDGES)) - 1 + ], + ) + await self._sessions.save(session) + continue # No StreamEnd, no workers — the run continues. + + # Genuine final: stamp metrics on the already-appended message + # (so history carries them) and close the run. + assistant_msg.elapsed_seconds = _elapsed + assistant_msg.tool_call_count = turn_ctx.tool_call_count if turn_ctx.tool_call_count else None + assistant_msg.token_count = _net_tokens if _net_tokens else None await self._sessions.save(session) yield StreamEnd( diff --git a/navi/core/agent_run_context.py b/navi/core/agent_run_context.py index a3b08ee..f290205 100644 --- a/navi/core/agent_run_context.py +++ b/navi/core/agent_run_context.py @@ -29,6 +29,11 @@ # across iterations so we only re-emit when the fallback backend actually # switches to a different model mid-turn. resolved_model: str | None = None + # Final-turn intercept counter: how many times a "stopped to narrate" turn + # (text without a tool call) was intercepted and continued because the todo + # still had open steps. Bounded by profile.final_intercept_limit so a model + # that keeps producing bare text is eventually allowed to finalise. + final_interceptions: int = 0 @dataclass diff --git a/navi/core/anti_stall.py b/navi/core/anti_stall.py index 28537a7..bd672c8 100644 --- a/navi/core/anti_stall.py +++ b/navi/core/anti_stall.py @@ -18,6 +18,11 @@ Also handles adaptive re-plan: when a todo step is newly marked failed, a re-planning message is queued for injection on the next iteration. + A second adaptive signal — the "long step" nudge — fires when the current + step stays in_progress for ``adaptive_long_step_threshold`` iterations + without a todo status change (and the model is still issuing non-repeating + tool calls), asking it to split the step before the general anti-stall + warning. """ profile: object # AgentProfile — avoid circular import @@ -91,7 +96,7 @@ self.stall_repeat_tools = 0 self.prev_tool_sigs = cur_sigs - # --- Adaptive re-plan: detect newly-failed steps --- + # --- Adaptive re-plan: detect newly-failed steps, or a long-running step --- if self.profile.adaptive_replan_enabled: current_failed = await get_failed_steps(session_id) new_failures = current_failed - self.known_failed @@ -114,3 +119,31 @@ failures=len(new_failures), session_id=session_id, ) + elif ( + self.profile.adaptive_long_step_threshold + and tool_calls + and self.stall_repeat_tools == 0 + and self.stall_no_todo == self.profile.adaptive_long_step_threshold + ): + # The current step has been in_progress for several iterations + # without a todo status change, yet the model is still working + # (non-repeating tool calls) — nudge it to split the step before + # the general anti-stall warning fires. ``stall_no_todo`` is only + # maintained while ``anti_stall_enabled`` is on, so this nudge is + # part of the anti-stall family. Fires once, at the threshold: + # re-newed todo progress resets the counter to 0, so the nudge + # rewards real movement rather than repeating every iteration. + self.replan_msg = ( + f"[Adaptive re-plan] the current step has been in_progress for " + f"{self.stall_no_todo} tool iterations without completing. If it is larger than the " + "plan anticipated, split it now: mark the finished part 'done' via todo (with " + "validation) and add the remainder as new steps, then continue. If you are genuinely " + "close to finishing, ignore this." + ) + import structlog + log = structlog.get_logger() + log.info( + "agent.adaptive_long_step_queued", + iterations=self.stall_no_todo, + session_id=session_id, + ) diff --git a/navi/core/events.py b/navi/core/events.py index e31fc71..dbfaf11 100644 --- a/navi/core/events.py +++ b/navi/core/events.py @@ -256,7 +256,7 @@ """ session_id: str - tasks: list = field(default_factory=list) # [{index, text, status, validation}, ...] + tasks: list = field(default_factory=list) # [{index, text, status, validation, milestone}, ...] def to_wire(self) -> dict: return { diff --git a/navi/core/planning.py b/navi/core/planning.py index 4cf7c13..8bb57a8 100644 --- a/navi/core/planning.py +++ b/navi/core/planning.py @@ -24,18 +24,31 @@ log = structlog.get_logger() -def _parse_plan_steps(plan_text: str) -> list[str]: - """Extract numbered step lines from the **Steps:** section of a plan.""" +def _parse_plan_steps(plan_text: str) -> list[tuple[str, str]]: + """Extract numbered step lines from the **Steps:** section of a plan. + + Returns ``(milestone, text)`` per step. The milestone is the single + uppercase letter in brackets at the start of a step line — + ``1. [A] description → TOOL: x`` → ``("A", "description → TOOL: x")``. + Lines without a marker get ``""``. A bracketed tag that is NOT a single + letter (e.g. ``[TOOL]``) is not a milestone; such lines are skipped, as + before (legacy behaviour preserved for old plan shapes). + """ m = re.search(r'\*\*Steps:\*\*\s*\n(.*?)(?=\n\s*\*\*[^*\n]+:\*\*|\Z)', plan_text, re.DOTALL) if not m: return [] steps_block = m.group(1) - steps: list[str] = [] - for raw in re.findall(r'^\s*\d+[\.\)]\s*(.+)', steps_block, re.MULTILINE): - step = raw.strip() - if not step or step.startswith("["): + steps: list[tuple[str, str]] = [] + for sm in re.finditer(r'^\s*\d+[\.\)]\s*(?:\[([A-Z])\]\s*)?(.+)', steps_block, re.MULTILINE): + milestone = sm.group(1) # None when the marker is absent + step = sm.group(2).strip() + if not step: continue - steps.append(step) + # Skip lines starting with a bracket that is NOT a single-letter + # milestone marker (e.g. "[TOOL] Do thing") — legacy behaviour. + if milestone is None and step.startswith("["): + continue + steps.append((milestone or "", step)) return steps @@ -364,9 +377,12 @@ "Plan depth:\n" "- simple: 1-3 steps\n" "- medium: 5-9 steps\n" - "- complex or autonomous: 8-15 steps\n" - "- hard maximum: 15 steps\n" - "Use enough steps to make execution unambiguous. Do not compress unrelated actions into one step.\n\n" + "- complex or autonomous: 8-20 steps\n" + "- hard maximum: 20 steps\n" + "Use enough steps to make execution unambiguous. Do not compress unrelated actions into one step. " + "For complex/autonomous tasks, decompose large steps into smaller independent steps that each " + "produce a verifiable result on their own — more, finer steps beat fewer coarse ones for " + "execution tracking, because the model loses sight of progress inside a long step.\n\n" "Knowledge source and persistence rules (critical):\n" "- `memory` is only for personal user facts and preferences.\n" "- Never store infrastructure inventory, service topology, network routes, proxy mappings, server roles, or service relationships in memory.\n" @@ -397,12 +413,17 @@ "B. [strategic phase]\n" "C. [strategic phase]\n\n" "**Steps:**\n" - "1. [description] → TOOL: tool_name\n" - "2. [description] → AGENT: profile_id\n" - "3. [description] → AGENT: profile_id\n" - "4. Knowledge persistence checkpoint: [search/read selected target; persist stable facts if discovered, or confirm none] → TOOL: tool_name OR SELF\n" - "5. [final synthesis] → SELF\n" - "... continue to the needed depth, up to 15 steps\n\n" + "1. [A] [description] → TOOL: tool_name\n" + "2. [A] [description] → AGENT: profile_id\n" + "3. [B] [description] → AGENT: profile_id\n" + "4. [B] Knowledge persistence checkpoint: [search/read selected target; persist stable facts if discovered, or confirm none] → TOOL: tool_name OR SELF\n" + "5. [C] [final synthesis] → SELF\n" + "... continue to the needed depth, up to 20 steps\n\n" + "Each step line begins with its milestone letter in brackets, matching a Milestone above " + "(`N. [A] description → EXECUTOR`). The letter groups steps into strategic phases and is shown " + "as a heading in the todo tracker, so the model keeps its bearings inside a long run. If a " + "step genuinely does not fit any milestone, omit the marker. The marker is a single uppercase " + "letter only — never `[TOOL]` or multi-word tags.\n\n" "**Parallel:** [step numbers that can run simultaneously, or NONE]\n" "**Risks:** [unknowns to watch for, or NONE]\n\n" "Reject vague steps such as 'research and implement everything', 'fix all issues', " diff --git a/navi/profiles/base.py b/navi/profiles/base.py index 742b338..d245696 100644 --- a/navi/profiles/base.py +++ b/navi/profiles/base.py @@ -117,6 +117,22 @@ anti_stall_enabled: bool = True anti_stall_threshold: int = 8 + # Adaptive re-plan "long step" nudge: when the current step stays in_progress + # for this many iterations without a todo status change, inject a message + # asking the model to split the step (earlier than the general anti-stall + # warning). 0 = disabled. Only fires when adaptive_replan_enabled is True. + adaptive_long_step_threshold: int = 4 + + # Final-turn intercept: when the model ends a turn with bare text (no tool + # call) but the todo still has open steps, the anti-stall/adaptive nudges + # never fired — they live *inside* the tool-loop and a bare-text turn closes + # the run before post_turn runs. Instead of closing, inject a nudge and + # continue the loop so the model calls reflect/replan/todo or acts. Bounded + # by final_intercept_limit (after that, the run finalises as normal). + # False = disabled. + final_intercept_enabled: bool = True + final_intercept_limit: int = 2 + # After the model marks a todo step as done, run a lightweight LLM check: # "did the result actually satisfy the step goal?" Adds ~1 LLM call per step. step_validation_enabled: bool = False diff --git a/navi/profiles/loader.py b/navi/profiles/loader.py index b956ef8..6fcab3a 100644 --- a/navi/profiles/loader.py +++ b/navi/profiles/loader.py @@ -106,6 +106,9 @@ anti_stall_threshold=config.get("anti_stall_threshold", 8), step_validation_enabled=config.get("step_validation_enabled", False), adaptive_replan_enabled=config.get("adaptive_replan_enabled", False), + adaptive_long_step_threshold=config.get("adaptive_long_step_threshold", 4), + final_intercept_enabled=config.get("final_intercept_enabled", True), + final_intercept_limit=config.get("final_intercept_limit", 2), subagent_planning_enabled=config.get("subagent_planning_enabled", False), subagent_think_enabled=config.get("subagent_think_enabled", None), subagent_system_prompt=subagent_system_prompt, @@ -160,6 +163,9 @@ "anti_stall_threshold": profile.anti_stall_threshold, "step_validation_enabled": profile.step_validation_enabled, "adaptive_replan_enabled": profile.adaptive_replan_enabled, + "adaptive_long_step_threshold": profile.adaptive_long_step_threshold, + "final_intercept_enabled": profile.final_intercept_enabled, + "final_intercept_limit": profile.final_intercept_limit, "subagent_planning_enabled": profile.subagent_planning_enabled, "subagent_think_enabled": profile.subagent_think_enabled, "is_subagent_only": profile.is_subagent_only, diff --git a/navi/profiles/navi_code/config.json b/navi/profiles/navi_code/config.json index 840a249..29ea33c 100644 --- a/navi/profiles/navi_code/config.json +++ b/navi/profiles/navi_code/config.json @@ -10,8 +10,6 @@ }, "llm_backend": "ollama", "model": [ - "gemma4:12b-it-qat-128k", - "gemma4:26b-a4b-it-q4_K_M", "gemma4:31b-cloud" ], "temperature": 0.35, @@ -27,7 +25,10 @@ "anti_stall_enabled": true, "anti_stall_threshold": 8, "step_validation_enabled": false, - "adaptive_replan_enabled": false, + "adaptive_replan_enabled": true, + "adaptive_long_step_threshold": 4, + "final_intercept_enabled": true, + "final_intercept_limit": 2, "planning_mandatory": false, "planning_phase1_enabled": true, "planning_phase2_enabled": true, diff --git a/navi/profiles/navi_code/system_prompt.txt b/navi/profiles/navi_code/system_prompt.txt index 34a61f6..bb643c4 100644 --- a/navi/profiles/navi_code/system_prompt.txt +++ b/navi/profiles/navi_code/system_prompt.txt @@ -46,6 +46,15 @@ 4. **Test & verify** — after code changes, run the relevant tests or build (`terminal`/`code_exec`). If the project has a linter, run it on the changed files. If there are no tests, at least syntax-check (`python -m py_compile `) and exercise the affected code path. Never claim "done" without verification output in hand (record it in the `todo` `validation` when marking done). 5. **Report** — what was done, what was tested, any caveats. +## Output discipline — act, don't announce +You run on a small local model. The failure mode that kills autonomy is narrating intent instead of acting on it: "I'll now read the file and then fix the bug" — followed by nothing, waiting for the user. + +Rules: +- If a next action requires a tool, call the tool in THIS turn. Do not write a sentence describing what you are about to do and then stop — that ends the turn (a turn ends when you produce text without a tool call). Announcing an intent and stopping is the single biggest autonomy killer for your model size. +- Never write "I've fixed X" / "I've updated Y" / "I'll do Z" without a tool result in the same turn that confirms the change was actually applied. Reading a file is not fixing it. Describing a fix is not making it. +- When you hit a problem you can't solve in one step: investigate with tools (read, grep, run), form a hypothesis, try it, read the result, adjust. Keep issuing tool calls across iterations. A mid-task text-only message that does not end with a tool call ends the run — do not produce one unless the task is genuinely complete or you are blocked on a user decision. +- The only legitimate reasons to stop and wait for the user: the task is done and verified; a destructive action needs explicit confirmation (git commit/push, system-wide package changes, rm); you hit a FUNDAMENTAL blocker (missing credentials you cannot obtain, irreversible action with no safe path). Everything else — including "I'm not sure which approach is best" — is a reason to pick the most reasonable option with tools and proceed, not to stop. + ## Editing policy When editing existing files with `filesystem`, default to `edit` (exact text replacement — read the file first and copy `old` verbatim) or `edit_lines` (by line numbers). Both are deterministic and cheap. Reserve `smart_edit` (AI) for changes that genuinely cannot be expressed as exact text or line numbers — e.g. rename a symbol everywhere, add type hints to every function. Reaching for `smart_edit` on every edit is wasteful and error-prone: it reads the whole file and makes an extra LLM call with less context than you already have. @@ -96,8 +105,8 @@ - **`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`** — before a genuinely complex plan or when stuck (repeated tool failures), call `reflect` to surface assumptions/gaps. 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`. 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. +- **`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. - **`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 451fc87..66a5845 100644 --- a/navi/tools/todo.py +++ b/navi/tools/todo.py @@ -27,6 +27,7 @@ text: str status: str = "pending" validation: str = "" # how the result was verified (required for done, encouraged for failed) + milestone: str = "" # grouping label (e.g. "A"/"B") from the plan's Milestones; "" = ungrouped # Global KV store reference — injected at startup by registry.py @@ -73,7 +74,7 @@ async def _save_tasks(sid: str, tasks: list[_Task]) -> None: if _kv_store is None: return - data = [{"text": t.text, "status": t.status, "validation": t.validation} for t in tasks] + data = [{"text": t.text, "status": t.status, "validation": t.validation, "milestone": t.milestone} for t in tasks] await _kv_store.set(_uid(), sid, "todo", "tasks", json.dumps(data)) @@ -212,7 +213,8 @@ icon = _STATUS_ICON.get(t.status, "?") suffix = f" ({t.status})" if t.status not in ("pending", "done") else "" validation_note = f" [verified: {t.validation}]" if t.validation else "" - lines.append(f" {icon} {i}. {t.text}{suffix}{validation_note}") + milestone_prefix = f"[{t.milestone}] " if t.milestone else "" + lines.append(f" {icon} {i}. {milestone_prefix}{t.text}{suffix}{validation_note}") return "\n".join(lines) @@ -240,6 +242,20 @@ return frozenset() +async def has_open_steps(session_id: str) -> bool: + """True if the plan has any step still pending/in_progress (i.e. not closed + as done/skipped/failed). An empty plan returns False. + + Used by the final-turn intercept in agent.py: a "stopped to narrate" turn + (text without a tool call) only bypasses the tool-loop nudges (anti-stall, + adaptive re-plan) because those fire in the *next* iteration's pre_turn — + which never comes. ``has_open_steps`` is the gate that decides whether the + turn is genuinely finished or should be intercepted and continued. + """ + snap = await get_task_snapshot(session_id) + return any(status in ("pending", "in_progress") for _text, status in snap) + + async def get_progress_message(session_id: str, *, first_iteration: bool = False) -> Message | None: """Build a compact system reminder with current todo state.""" try: @@ -302,9 +318,21 @@ return None -async def set_tasks(session_id: str, task_texts: list[str]) -> None: - """Auto-populate the todo list from plan steps.""" - tasks = [_Task(text=s) for s in task_texts] +async def set_tasks(session_id: str, steps) -> None: + """Auto-populate the todo list from plan steps. + + ``steps`` accepts either the legacy ``list[str]`` (milestone='') or the + planner's ``list[tuple[milestone, text]]`` produced by + ``planning._parse_plan_steps``. The legacy form keeps existing callers + (and tests) working unchanged. + """ + tasks: list[_Task] = [] + for s in steps: + if isinstance(s, tuple): + milestone, text = s + else: + milestone, text = "", str(s) + tasks.append(_Task(text=text, milestone=milestone)) await _save_tasks(session_id, tasks) @@ -371,6 +399,7 @@ "text": item.get("text", ""), "status": item.get("status", "pending"), "validation": item.get("validation", ""), + "milestone": item.get("milestone", ""), } for i, item in enumerate(data) ] diff --git a/tests/clients/test_todo_list.py b/tests/clients/test_todo_list.py index e161ac3..a4c3698 100644 --- a/tests/clients/test_todo_list.py +++ b/tests/clients/test_todo_list.py @@ -157,6 +157,61 @@ @pytest.mark.anyio +async def test_groups_tasks_by_milestone() -> None: + """When tasks carry a ``milestone`` label, the panel renders milestone + headers and nests each step under its group. Plan order defines group + sequence; the unnamed group goes last.""" + from clients.terminal.tui.tui_app import NaviCodeTui + + tasks = [ + {"index": 1, "text": "read spec", "status": "done", "validation": "", "milestone": "A"}, + {"index": 2, "text": "write code", "status": "in_progress", "validation": "", "milestone": "A"}, + {"index": 3, "text": "run tests", "status": "pending", "validation": "", "milestone": "B"}, + {"index": 4, "text": "orphan step", "status": "pending", "validation": "", "milestone": ""}, + ] + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + todo = pilot.app.query_one(TodoList) + todo.set_tasks(tasks) + plain = _plain(todo._build_content()) + # Both milestone headers present, in plan order. + assert "Milestone A" in plain + assert "Milestone B" in plain + assert plain.index("Milestone A") < plain.index("Milestone B") + # Steps nest under their group (group header precedes its steps). + assert plain.index("Milestone A") < plain.index("write code") + assert plain.index("Milestone B") < plain.index("run tests") + # The unnamed group ("orphan step") goes last, after the named groups. + assert plain.index("run tests") < plain.index("orphan step") + # Header still counts all done steps. + assert "1/4 done" in plain + + +@pytest.mark.anyio +async def test_milestone_group_marked_done_when_all_steps_done() -> None: + """A milestone group whose steps are all done/skipped gets a ✓ on its + header; an in-progress group does not.""" + from clients.terminal.tui.tui_app import NaviCodeTui + + tasks = [ + {"index": 1, "text": "done one", "status": "done", "validation": "", "milestone": "A"}, + {"index": 2, "text": "skipped one", "status": "skipped", "validation": "", "milestone": "A"}, + {"index": 3, "text": "active", "status": "in_progress", "validation": "", "milestone": "B"}, + ] + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + todo = pilot.app.query_one(TodoList) + todo.set_tasks(tasks) + content = todo._build_content() + # Group A is fully done/skipped -> ✓ in its header line. + a_line = next(line for line in content.splitlines() if "Milestone A" in line) + assert "✓" in a_line + # Group B is not -> no ✓ in its header line. + b_line = next(line for line in content.splitlines() if "Milestone B" in line) + assert "✓" not in b_line + + +@pytest.mark.anyio async def test_todo_panel_is_scrollable_and_wraps_list() -> None: """The todo lives in its own scrollable panel below the info block, not inside StatusPanel.""" diff --git a/tests/integration/test_api_routes.py b/tests/integration/test_api_routes.py index e7f57e4..b882377 100644 --- a/tests/integration/test_api_routes.py +++ b/tests/integration/test_api_routes.py @@ -151,8 +151,8 @@ assert response.status_code == 200 tasks = response.json()["tasks"] assert tasks == [ - {"index": 1, "text": "step one", "status": "done", "validation": "ran tests"}, - {"index": 2, "text": "step two", "status": "in_progress", "validation": ""}, + {"index": 1, "text": "step one", "status": "done", "validation": "ran tests", "milestone": ""}, + {"index": 2, "text": "step two", "status": "in_progress", "validation": "", "milestone": ""}, ] def test_get_todos_not_found(self, client): diff --git a/tests/unit/core/test_agent.py b/tests/unit/core/test_agent.py index e442c9d..760217a 100644 --- a/tests/unit/core/test_agent.py +++ b/tests/unit/core/test_agent.py @@ -320,6 +320,204 @@ assert saved.messages[-1].token_count == 50 +# ─── final-turn intercept tests ────────────────────────────────────────────── + + +class TestFinalIntercept: + """The final-turn intercept: a bare-text turn (no tool call) with open todo + steps is continued with a recovery nudge instead of closing the run, up to + final_intercept_limit. This is the structural patch for the "stopped to + narrate" failure mode that bypasses the tool-loop nudges (anti-stall, + adaptive re-plan) because those fire in the *next* iteration's pre_turn, + which never comes when the turn ends on bare text. + """ + + @pytest_asyncio.fixture + async def kv(self): + """In-memory KV so set_tasks persists a plan the agent's has_open_steps + can read. Mirrors the _fake_kv fixture in tests/unit/tools/test_todo.py.""" + from navi.store import KvStore + from tests.conftest_factory import FakePool + + class _MemKv(KvStore): + 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] + + from navi.tools import todo as _mod + + store = _MemKv() + _mod._kv_store = store + yield store + _mod._kv_store = None + + def _configure(self, agent, *, limit): + profile = agent._profiles.get("test") + profile.max_iterations = 20 + profile.final_intercept_enabled = True + profile.final_intercept_limit = limit + + @pytest.mark.asyncio + async def test_bare_text_with_open_todo_intercepts_and_continues(self, agent, session, kv): + """A bare-text turn while the todo has open steps is not closed: the loop + continues (n-1 intercepts) until the limit is hit, then one final StreamEnd.""" + from navi.tools.todo import set_tasks + + self._configure(agent, limit=2) + await set_tasks(session.id, ["step a", "step b"]) # open steps -> has_open_steps True + backend = FakeLLMBackend( + responses=["I'll try another approach", "I'll try again", "done"], + tool_calls=[None, None, None], + ) + agent._backends.register("ollama", backend) + + events = [] + async for ev in agent.run_stream(session.id, "do the task"): + events.append(ev) + + saved = await agent._sessions.get(session.id) + assistant = [m for m in saved.messages if m.role == "assistant"] + # Two intercepts (continue) + one genuine final = three bare-text turns. + assert len(assistant) == 3 + # Exactly one StreamEnd, at the very end — the loop never closed early. + stream_ends = [ev for ev in events if isinstance(ev, StreamEnd)] + assert len(stream_ends) == 1 + + @pytest.mark.asyncio + async def test_intercept_disabled_when_flag_off(self, agent, session, kv): + """final_intercept_enabled=False: a bare-text turn with open steps + finalises immediately, no continuation.""" + from navi.tools.todo import set_tasks + + profile = agent._profiles.get("test") + profile.max_iterations = 20 + profile.final_intercept_enabled = False + profile.final_intercept_limit = 2 + await set_tasks(session.id, ["step a", "step b"]) + backend = FakeLLMBackend(responses=["I'll try another approach"], tool_calls=[None]) + agent._backends.register("ollama", backend) + + events = [] + async for ev in agent.run_stream(session.id, "do the task"): + events.append(ev) + + saved = await agent._sessions.get(session.id) + assistant = [m for m in saved.messages if m.role == "assistant"] + assert len(assistant) == 1 + assert len([ev for ev in events if isinstance(ev, StreamEnd)]) == 1 + + @pytest.mark.asyncio + async def test_empty_todo_no_intercept(self, agent, session, kv): + """No plan (empty todo) -> has_open_steps False -> no intercept; the turn + closes normally. Protects casual messages and observe-only runs.""" + self._configure(agent, limit=2) + # No set_tasks: the plan is empty. + backend = FakeLLMBackend(responses=["all done here"], tool_calls=[None]) + agent._backends.register("ollama", backend) + + events = [] + async for ev in agent.run_stream(session.id, "hi"): + events.append(ev) + + saved = await agent._sessions.get(session.id) + assistant = [m for m in saved.messages if m.role == "assistant"] + assert len(assistant) == 1 + assert len([ev for ev in events if isinstance(ev, StreamEnd)]) == 1 + + @pytest.mark.asyncio + async def test_limit_one_intercepts_once(self, agent, session, kv): + """final_intercept_limit=1: one intercept, then the second bare-text turn + finalises (limit reached).""" + from navi.tools.todo import set_tasks + + self._configure(agent, limit=1) + await set_tasks(session.id, ["step a", "step b"]) + backend = FakeLLMBackend( + responses=["I'll try again", "done"], + tool_calls=[None, None], + ) + agent._backends.register("ollama", backend) + + events = [] + async for ev in agent.run_stream(session.id, "do the task"): + events.append(ev) + + saved = await agent._sessions.get(session.id) + assistant = [m for m in saved.messages if m.role == "assistant"] + assert len(assistant) == 2 # one intercept + one final + assert len([ev for ev in events if isinstance(ev, StreamEnd)]) == 1 + + @pytest.mark.asyncio + async def test_nudge_text_escalates_and_is_injected_next_iteration(self, agent, session, kv): + """The 1st-intercept nudge is soft, the 2nd is hard ("second stop"); each is + injected as a system message into the NEXT iteration's context (not the + closed turn's). Verified by capturing the messages each LLM call sees.""" + from navi.tools.todo import set_tasks + + self._configure(agent, limit=2) + await set_tasks(session.id, ["step a", "step b"]) + + captured: list[list[Message]] = [] + + class CapturingBackend(FakeLLMBackend): + async def stream_complete(self, messages, **kwargs): + captured.append(list(messages)) + async for chunk in super().stream_complete(messages, **kwargs): + yield chunk + + backend = CapturingBackend( + responses=["I'll try another approach", "I'll try again", "done"], + tool_calls=[None, None, None], + ) + agent._backends.register("ollama", backend) + + events = [] + async for ev in agent.run_stream(session.id, "do the task"): + events.append(ev) + + # Three LLM calls (2 intercepts + 1 final). captured[0] has no nudge yet; + # captured[1] carries the soft (1st) nudge; captured[2] the hard (2nd). + assert len(captured) == 3 + + def _sys_content(idx, needle): + return any( + m.role == "system" and needle in (m.content or "") + for m in captured[idx] + ) + + assert not _sys_content(0, "Final-turn check") # first turn: no nudge yet + assert _sys_content(1, "Final-turn check") + assert "second stop" not in "".join( + (m.content or "") for m in captured[1] if m.role == "system" + ) + assert _sys_content(2, "second stop") + # The run still closed exactly once. + assert len([ev for ev in events if isinstance(ev, StreamEnd)]) == 1 + + # ─── compact_stream() tests ────────────────────────────────────────────────── diff --git a/tests/unit/core/test_anti_stall.py b/tests/unit/core/test_anti_stall.py index 2fa3eba..715410b 100644 --- a/tests/unit/core/test_anti_stall.py +++ b/tests/unit/core/test_anti_stall.py @@ -163,6 +163,105 @@ assert monitor.replan_msg is None @pytest.mark.asyncio + async def test_long_step_nudge_queued_at_threshold(self): + """adaptive_replan + a step in_progress for ``adaptive_long_step_threshold`` + iterations (non-repeating tool calls, no new failures) → nudge the model + to split the step. Fires earlier than the general anti-stall warning.""" + profile = make_profile( + anti_stall_enabled=True, + anti_stall_threshold=8, + adaptive_replan_enabled=True, + adaptive_long_step_threshold=4, + ) + monitor = AntiStallMonitor(profile) + snapshot = frozenset({("task1", "in_progress")}) + monitor._todo_snapshot = snapshot + monitor.stall_no_todo = 3 # one more no-progress iteration -> 4 == threshold + tc = ToolCallRequest(id="1", name="fs", arguments={"path": "/tmp"}) + with patch( + "navi.tools.todo.get_task_snapshot", + new=AsyncMock(return_value=snapshot), + ), patch( + "navi.tools.todo.get_failed_steps", + new=AsyncMock(return_value=frozenset()), + ): + await monitor.post_turn("s1", [tc]) + assert monitor.replan_msg is not None + assert "split it now" in monitor.replan_msg + assert "Adaptive re-plan" in monitor.replan_msg + + @pytest.mark.asyncio + async def test_long_step_silent_below_threshold(self): + """Below the long-step threshold, no nudge is queued.""" + profile = make_profile( + anti_stall_enabled=True, + adaptive_replan_enabled=True, + adaptive_long_step_threshold=4, + ) + monitor = AntiStallMonitor(profile) + snapshot = frozenset({("task1", "in_progress")}) + monitor._todo_snapshot = snapshot + monitor.stall_no_todo = 1 # -> 2, below threshold 4 + tc = ToolCallRequest(id="1", name="fs", arguments={"path": "/tmp"}) + with patch( + "navi.tools.todo.get_task_snapshot", + new=AsyncMock(return_value=snapshot), + ), patch( + "navi.tools.todo.get_failed_steps", + new=AsyncMock(return_value=frozenset()), + ): + await monitor.post_turn("s1", [tc]) + assert monitor.replan_msg is None + + @pytest.mark.asyncio + async def test_long_step_silent_when_model_silent(self): + """No tool calls this turn (model produced text only) → the long-step + nudge stays silent; the anti-stall path handles a genuinely stuck run.""" + profile = make_profile( + anti_stall_enabled=True, + adaptive_replan_enabled=True, + adaptive_long_step_threshold=4, + ) + monitor = AntiStallMonitor(profile) + snapshot = frozenset({("task1", "in_progress")}) + monitor._todo_snapshot = snapshot + monitor.stall_no_todo = 3 # -> 4 == threshold, but no tool calls + with patch( + "navi.tools.todo.get_task_snapshot", + new=AsyncMock(return_value=snapshot), + ), patch( + "navi.tools.todo.get_failed_steps", + new=AsyncMock(return_value=frozenset()), + ): + await monitor.post_turn("s1", []) + assert monitor.replan_msg is None + + @pytest.mark.asyncio + async def test_long_step_failed_takes_precedence(self): + """New failures take precedence over the long-step nudge.""" + profile = make_profile( + anti_stall_enabled=True, + adaptive_replan_enabled=True, + adaptive_long_step_threshold=4, + ) + monitor = AntiStallMonitor(profile) + snapshot = frozenset({("task1", "in_progress")}) + monitor._todo_snapshot = snapshot + monitor.stall_no_todo = 3 # -> 4 == threshold + tc = ToolCallRequest(id="1", name="fs", arguments={"path": "/tmp"}) + with patch( + "navi.tools.todo.get_task_snapshot", + new=AsyncMock(return_value=snapshot), + ), patch( + "navi.tools.todo.get_failed_steps", + new=AsyncMock(return_value=frozenset({(2, "step B")})), + ): + await monitor.post_turn("s1", [tc]) + assert monitor.replan_msg is not None + assert "just failed" in monitor.replan_msg + assert "split it now" not in monitor.replan_msg + + @pytest.mark.asyncio async def test_disabled_anti_stall_does_not_fetch_snapshot(self): profile = make_profile(anti_stall_enabled=False, adaptive_replan_enabled=False) monitor = AntiStallMonitor(profile) diff --git a/tests/unit/core/test_planning.py b/tests/unit/core/test_planning.py index 2f7c9e7..cbd2f91 100644 --- a/tests/unit/core/test_planning.py +++ b/tests/unit/core/test_planning.py @@ -36,15 +36,31 @@ class TestParsePlanSteps: def test_basic_numbered_list(self): text = "**Steps:**\n1. First step\n2. Second step\n3. Third step" - assert _parse_plan_steps(text) == ["First step", "Second step", "Third step"] + assert _parse_plan_steps(text) == [("", "First step"), ("", "Second step"), ("", "Third step")] def test_parenthesised_numbers(self): text = "**Steps:**\n1) Step one\n2) Step two" - assert _parse_plan_steps(text) == ["Step one", "Step two"] + assert _parse_plan_steps(text) == [("", "Step one"), ("", "Step two")] def test_ignores_bracket_prefixes(self): text = "**Steps:**\n1. [TOOL] Do thing\n2. Normal step" - assert _parse_plan_steps(text) == ["Normal step"] + assert _parse_plan_steps(text) == [("", "Normal step")] + + def test_steps_carry_milestone_marker(self): + """A single uppercase letter in brackets at the start of a step line is + parsed as the milestone label; multi-word bracketed tags are not.""" + text = ( + "**Steps:**\n" + "1. [A] read config.py → TOOL: filesystem\n" + "2. [A] implement parser → AGENT: developer\n" + "3. [B] run tests → TOOL: terminal\n" + "4. [TOOL] skipped line → ignored\n" + ) + assert _parse_plan_steps(text) == [ + ("A", "read config.py → TOOL: filesystem"), + ("A", "implement parser → AGENT: developer"), + ("B", "run tests → TOOL: terminal"), + ] def test_empty_steps_section(self): text = "**Steps:**\n\n**Notes:** nothing" diff --git a/tests/unit/profiles/test_base.py b/tests/unit/profiles/test_base.py index c0de92d..03a5dd1 100644 --- a/tests/unit/profiles/test_base.py +++ b/tests/unit/profiles/test_base.py @@ -54,6 +54,8 @@ assert p.anti_stall_enabled is True assert p.anti_stall_threshold == 8 assert p.subagent_think_enabled is None + assert p.final_intercept_enabled is True + assert p.final_intercept_limit == 2 def test_max_iterations_default(self): p = AgentProfile( diff --git a/tests/unit/tools/test_todo.py b/tests/unit/tools/test_todo.py index b4988c8..91aa9ff 100644 --- a/tests/unit/tools/test_todo.py +++ b/tests/unit/tools/test_todo.py @@ -7,6 +7,7 @@ TodoTool, get_progress_message, get_task_snapshot, + has_open_steps, load_tasks_for, set_tasks, ) @@ -122,6 +123,71 @@ @pytest.mark.asyncio +async def test_has_open_steps_true_when_pending(_fake_kv): + await set_tasks("sess1", ["t1", "t2"]) + assert await has_open_steps("sess1") is True + + +@pytest.mark.asyncio +async def test_has_open_steps_true_when_in_progress(_fake_kv): + tool = TodoTool() + await tool.execute( + {"op": "set", "tasks": ["a", "b"]}, + ctx=ToolContext(session_id="sess1", user_id="user1"), + ) + await tool.execute( + {"op": "update", "index": 1, "status": "in_progress"}, + ctx=ToolContext(session_id="sess1", user_id="user1"), + ) + assert await has_open_steps("sess1") is True + + +@pytest.mark.asyncio +async def test_has_open_steps_false_when_all_closed(_fake_kv): + tool = TodoTool() + await tool.execute( + {"op": "set", "tasks": ["a", "b"]}, + ctx=ToolContext(session_id="sess1", user_id="user1"), + ) + await tool.execute( + {"op": "update", "index": 1, "status": "done", "validation": "ok"}, + ctx=ToolContext(session_id="sess1", user_id="user1"), + ) + await tool.execute( + {"op": "update", "index": 2, "status": "skipped"}, + ctx=ToolContext(session_id="sess1", user_id="user1"), + ) + assert await has_open_steps("sess1") is False + + +@pytest.mark.asyncio +async def test_has_open_steps_false_when_remaining_only_failed(_fake_kv): + """failed is terminal (the model closed the step deliberately) — it does not + count as 'open', so a plan whose only remaining step is failed does not + trigger a final-turn intercept.""" + tool = TodoTool() + await tool.execute( + {"op": "set", "tasks": ["a", "b"]}, + ctx=ToolContext(session_id="sess1", user_id="user1"), + ) + await tool.execute( + {"op": "update", "index": 1, "status": "done", "validation": "ok"}, + ctx=ToolContext(session_id="sess1", user_id="user1"), + ) + await tool.execute( + {"op": "update", "index": 2, "status": "failed"}, + ctx=ToolContext(session_id="sess1", user_id="user1"), + ) + assert await has_open_steps("sess1") is False + + +@pytest.mark.asyncio +async def test_has_open_steps_false_for_empty_plan(_fake_kv): + """No plan -> no intercept (protects casual messages and observe-only runs).""" + assert await has_open_steps("never-planned") is False + + +@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) @@ -248,8 +314,8 @@ 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": ""}, + {"index": 1, "text": "t1", "status": "pending", "validation": "", "milestone": ""}, + {"index": 2, "text": "t2", "status": "pending", "validation": "", "milestone": ""}, ] @@ -271,10 +337,10 @@ '[{"text": "B task", "status": "done", "validation": "x"}]', ) assert await load_tasks_for("sess1", "userA") == [ - {"index": 1, "text": "A task", "status": "pending", "validation": ""}, + {"index": 1, "text": "A task", "status": "pending", "validation": "", "milestone": ""}, ] assert await load_tasks_for("sess1", "userB") == [ - {"index": 1, "text": "B task", "status": "done", "validation": "x"}, + {"index": 1, "text": "B task", "status": "done", "validation": "x", "milestone": ""}, ] # No row for userC → empty. assert await load_tasks_for("sess1", "userC") == [] @@ -301,13 +367,45 @@ 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"}] + assert tasks == [{"index": 1, "text": "kv-row", "status": "done", "validation": "x", "milestone": ""}] # 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": ""}, + {"index": 1, "text": "global-row", "status": "pending", "validation": "", "milestone": ""}, ] +# ── milestone grouping label (from the planner) ────────────────────────────── + + +@pytest.mark.asyncio +async def test_set_tasks_accepts_milestone_tuples(_fake_kv): + """The planner passes list[tuple[milestone, text]]; set_tasks stores the + milestone label so it flows through to the REST/WS payload.""" + await set_tasks("sess1", [("A", "read spec"), ("A", "write parser"), ("B", "run tests")]) + tasks = await load_tasks_for("sess1", None) + assert [t["milestone"] for t in tasks] == ["A", "A", "B"] + assert [t["text"] for t in tasks] == ["read spec", "write parser", "run tests"] + + +@pytest.mark.asyncio +async def test_set_tasks_legacy_strings_get_empty_milestone(_fake_kv): + """Legacy list[str] callers keep working: milestone defaults to ''.""" + await set_tasks("sess1", ["t1", "t2"]) + tasks = await load_tasks_for("sess1", None) + assert all(t["milestone"] == "" for t in tasks) + + +@pytest.mark.asyncio +async def test_render_prefixed_with_milestone(_fake_kv): + """The textual ToolResult render prefixes the milestone letter when set, + so the chat card shows grouping without the full TUI side-panel.""" + await set_tasks("sess1", [("A", "read spec"), ("B", "run tests")]) + tool = TodoTool() + result = await tool.execute({"op": "view"}, ctx=ToolContext(session_id="sess1")) + assert "[A] read spec" in result.output + assert "[B] run tests" in result.output + + # ── 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.