fix(plan-mode): make progress tracking robust and tolerant (#7950)

## What

Hardens the plan-mode example extension's step-progress tracking.
Previously, completion markers were only recognized in the exact
`[DONE:n]` form inside visible text blocks, so steps frequently never
got checked off during execution.

## Changes

- **`getTextContent`** now reads `thinking` blocks as well as text
blocks, so `[DONE:n]` tags emitted during reasoning are honored.
- **Marker tolerance** (`extractDoneSteps`): accepts `[DONE: 1]`, `[DONE
1]`, `Done: n`, and prose phrasings like "Step n done / is complete /
finished" — all adjacency-anchored so negations ("Step 9 is not done")
never match.
- **Whole-plan fallback** (`allStepsComplete`): if the run's final
message says the plan is done (e.g. "All steps complete"), all remaining
steps are marked at `agent_end`.
- **Plan extraction dedup**: only the last `Plan:` section is extracted,
so a plan drafted in a thinking block no longer duplicates the steps
from the text block.
- **No more truncation**: step text is kept in full (previously cut at
50 chars); the widget truncates for display only. The execution prompt
now shows the complete step text.
- **No more dropped steps**: steps whose text starts with a backtick
(e.g. `1. \`index.ts\` — …`) are no longer skipped.
- **Widget refresh** happens on every turn instead of only when a marker
matched.
- **Plan-complete card** is delivered with `nextTurn` instead of
triggering an extra LLM turn.

## Tests

`packages/coding-agent/test/plan-mode-utils.test.ts` updated for the new
behavior (truncation, backtick steps, marker-count semantics) and
extended with coverage for the new cases (marker variants, prose
phrasings, negation safety, thinking-draft dedup, markdown titles,
whole-plan completion).

All 43 tests in `plan-mode-utils.test.ts` +
`plan-mode-extension.test.ts` pass (`npx vitest run`).

Note: the repo-wide type-check currently fails on `main` with
pre-existing `ModelCatalog`/`never` errors unrelated to this change;
this branch adds none (same count with and without the diff).
This commit is contained in:
Giannis Papakostas 2026-11-04 11:53:52 +02:00 • committed by GitHub
parent c13f6acfbe
commit ee962fb4db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 113 additions and 38 deletions

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
allStepsComplete,
cleanStepText,
extractDoneSteps,
extractTodoItems,
@ -119,11 +120,10 @@ describe("cleanStepText", () => {
expect(cleanStepText("update config")).toBe("Config");
});
it("truncates long text", () => {
it("keeps full text (truncation moved to the widget display)", () => {
const longText = "This is a very long step description that exceeds the maximum allowed length for display";
const result = cleanStepText(longText);
expect(result.length).toBe(50);
expect(result.endsWith("...")).toBe(true);
expect(result).toBe(longText);
});
it("normalizes whitespace", () => {
@ -183,13 +183,31 @@ Plan:
expect(items[0].text).toContain("proper");
});
it("filters out code-like items", () => {
it("keeps steps that start with backticks (code-file steps)", () => {
const message = `Plan:
1. \`npm install\`
2. Run the build process`;
const items = extractTodoItems(message);
expect(items).toHaveLength(1);
expect(items).toHaveLength(2);
expect(items[0].text).toBe("Npm install");
});
it("extracts only the last Plan: section (thinking draft + text plan)", () => {
const message = `Some reasoning...\nPlan:\n1. Draft one\n2. Draft two\nNow the answer.\nPlan:\n1. Print 1\n2. Print 2\n3. Print 3`;
const items = extractTodoItems(message);
expect(items).toHaveLength(3);
expect(items.map((i) => i.text)).toEqual(["Print 1", "Print 2", "Print 3"]);
});
it("extracts markdown step titles fully", () => {
const message = `Plan:\n1. **Explore** the repo structure\n2. \`index.ts\` — fix getTextContent`;
const items = extractTodoItems(message);
expect(items).toHaveLength(2);
expect(items[0].text).toBe("Explore the repo structure");
expect(items[1].text).toBe("Index.ts — fix getTextContent");
});
});
@ -209,11 +227,25 @@ describe("extractDoneSteps", () => {
expect(extractDoneSteps(message)).toEqual([1, 2, 3]);
});
it("returns empty array with no markers", () => {
const message = "No markers here";
it("extracts DONE marker variants", () => {
const message = "[DONE:1] [DONE: 2] [DONE3] [Done:4] Done: 5";
expect(extractDoneSteps(message)).toEqual([1, 2, 3, 4, 5]);
});
it("extracts prose completion phrasings", () => {
const message = "Step 6 done, Step 7 is complete, Step 8 finished";
expect(extractDoneSteps(message)).toEqual([6, 7, 8]);
});
it("does not match negated or unfinished phrasings", () => {
const message = "Step 9 is not done, Step 10 incomplete";
expect(extractDoneSteps(message)).toEqual([]);
});
it("collapses duplicate markers", () => {
expect(extractDoneSteps("[DONE:1] [DONE: 1]")).toEqual([1]);
});
it("ignores malformed markers", () => {
const message = "[DONE:abc] [DONE:] [DONE:1]";
expect(extractDoneSteps(message)).toEqual([1]);
@ -248,8 +280,15 @@ describe("markCompletedSteps", () => {
const count = markCompletedSteps("[DONE:99]", items);
expect(count).toBe(1); // Still counts the marker found
expect(items[0].completed).toBe(false); // But doesn't mark anything
expect(count).toBe(0); // Counts only newly marked items
expect(items[0].completed).toBe(false);
});
it("marks all remaining steps for whole-plan completion phrasings", () => {
expect(allStepsComplete("All steps complete, wrapping up")).toBe(true);
expect(allStepsComplete("Every step is finished")).toBe(true);
expect(allStepsComplete("The plan is done")).toBe(true);
expect(allStepsComplete("Step 1 complete, moving on")).toBe(false);
});
it("doesn't double-complete already completed items", () => {