';
+ const reply = suggestions();
+ reply.reply = hostileReply;
+ reply.edits[0] = { ...reply.edits[0]!, summary: hostileSummary, reason: hostileReason, value: hostileValue };
+ await page.getByRole('button', { name: 'Suggest edits' }).click();
+ await expect.poll(() => invocations.length).toBe(1);
+ invocations[0]!.resolve(JSON.stringify(reply));
+ await expect(page.locator('#plan-author-result')).toContainText(hostileReply);
+ await expect(page.locator('#plan-author-result')).toContainText(hostileSummary);
+ await expect(page.locator('#plan-author-result')).toContainText(hostileReason);
+ await expect(page.locator('#plan-author-result')).toContainText(hostileValue);
+
+ const hostileDraftSummary = '';
+ const hostileDraftTitle = '\">';
+ const hostileDraft = draft(current);
+ hostileDraft.summary = hostileDraftSummary;
+ hostileDraft.items[0] = { ...hostileDraft.items[0]!, title: hostileDraftTitle };
+ await page.getByRole('button', { name: 'Draft next revision' }).click();
+ await expect.poll(() => invocations.length).toBe(2);
+ invocations[1]!.resolve(JSON.stringify(hostileDraft));
+ await expect(page.locator('#plan-author-result')).toContainText(hostileDraftSummary);
+ await expect(page.locator('#plan-author-result')).toContainText(hostileDraftTitle);
+ for (const selector of ['#g2-injected-image', '#g2-injected-svg', '#g2-injected-script', '#g2-injected-button', '#g2-draft-image', '#g2-draft-button'])
+ await expect(page.locator(selector)).toHaveCount(0);
+ expect(await page.evaluate(() => (window as unknown as Record).__g2Injected)).toBeUndefined();
+});
+
test('keeps a completed result when the next request is refused', async ({ page }) => {
const { deps, invocations } = planning();
await openPlans(page, deps);
diff --git a/web/public/app.js b/web/public/app.js
index cc74255d..b51ecda7 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -1025,16 +1025,17 @@ function renderPlanAuthor() {
return;
}
const noun = request.mode === "draft" ? "draft" : "suggestions", stale = planAuthorStale(request);
+ const hasResult = !!(request.reply || request.plan);
let tone = "neutral", message = "";
- if (stale) { tone = "warn"; message = `! Stale ${noun} · generated for r${request.revision}`; }
- else if (request.state === "starting") message = `Starting ${noun}…`;
- else if (request.state === "pending") message = `Agent is preparing ${noun} for r${request.revision}…`;
- else if (request.state === "cancelling") message = `Cancelling ${noun}…`;
+ if (request.state === "starting") message = `Starting ${noun}…`;
else if (request.state === "uncertain") { tone = "warn"; message = `! The ${noun} request outcome is unknown. Retry the exact request.`; }
- else if (request.state === "ready") { tone = "good"; message = `✓ ${request.mode === "draft" ? "Draft" : "Suggestions"} ready for r${request.revision}.`; }
else if (request.state === "failed") { tone = "bad"; message = `✕ ${request.mode === "draft" ? "Draft" : "Suggestion"} failed. ${request.reason || "Try again."}`; }
- else if (request.state === "invalidated") { tone = "warn"; message = `! ${request.mode === "draft" ? "Draft" : "Suggestions"} became stale. ${request.reason || "Reload and ask again."}`; }
+ else if (request.state === "cancelling") message = `Cancelling ${noun}…`;
else if (request.state === "cancelled") message = `– ${request.mode === "draft" ? "Draft" : "Suggestions"} dismissed. ${request.reason || ""}`.trim();
+ else if (request.state === "invalidated") { tone = "warn"; message = `! ${request.mode === "draft" ? "Draft" : "Suggestions"} became stale. ${request.reason || "Reload and ask again."}`; }
+ else if (stale) { tone = "warn"; message = `! Stale ${hasResult ? `${noun} · generated` : `${noun} request · started`} for r${request.revision}`; }
+ else if (request.state === "pending") message = `Agent is preparing ${noun} for r${request.revision}…`;
+ else if (request.state === "ready") { tone = "good"; message = `✓ ${request.mode === "draft" ? "Draft" : "Suggestions"} ready for r${request.revision}.`; }
if (request.observationError) message += ` Status check failed: ${request.observationError}`;
$("plan-author-status").className = tone;
$("plan-author-status").textContent = message;
From 4c1d1b04d485c67b38577aaacf7c6bd52b0e20e8 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Thu, 8 Oct 2026 10:57:12 -0700
Subject: [PATCH 05/10] Hide dismissed planning results
---
docs/implementation/planning-screen.md | 4 +--
test/browser/plans-authoring.spec.ts | 47 ++++++++++++++++++++++++++
web/public/app.js | 1 +
3 files changed, 50 insertions(+), 2 deletions(-)
diff --git a/docs/implementation/planning-screen.md b/docs/implementation/planning-screen.md
index eac6833a..f4962f3c 100644
--- a/docs/implementation/planning-screen.md
+++ b/docs/implementation/planning-screen.md
@@ -26,8 +26,8 @@ The guidance field is shared by the two explicit request types. **Draft next rev
After start, the screen polls only the durable request resource with bounded backoff. A lifecycle transition resets the interval. Poll updates replace only the request status and result: they never write the guidance field. The active control uses `aria-disabled` with an in-flight guard, so keyboard focus stays in place and repeat activation cannot start a second invocation. Dismiss uses the request's cancel endpoint and its own stable action ID.
-Suggestion replies render as plain text followed by one line-separated row per typed edit. Drafts render the proposed revision summary and ordered items. The browser escapes every agent-provided field. A result remains visible as history but is marked stale whenever its captured revision or snapshot no longer matches the current review response. A delayed ready poll therefore cannot present an old result as current after an import or refresh advances the plan.
+Suggestion replies render as plain text followed by one line-separated row per typed edit. Drafts render the proposed revision summary and ordered items. The browser escapes every agent-provided field. A completed, non-cancelled result remains visible as history but is marked stale whenever its captured revision or snapshot no longer matches the current review response. Dismissed output is removed, and a poll started before dismissal cannot restore it. A delayed ready poll likewise cannot present an old result as current after an import or refresh advances the plan.
-`test/browser/plans-authoring.spec.ts` covers duplicate activation and focus, request-time guidance preservation, exact ambiguous-start replay, a start refused after an intervening import, malformed-status ownership, rejection of mismatched bindings and unsafe operation payloads, hostile markup and attribute text in both result types, retained completed history when a later start is refused, suggestion rows, whole-draft preview without mutation, dismissal and ambiguous-cancellation reconciliation without a revision change, and the interleaving where an import advances the plan before an older ready poll returns.
+`test/browser/plans-authoring.spec.ts` covers duplicate activation and focus, request-time guidance preservation, exact ambiguous-start replay, a start refused after an intervening import, malformed-status ownership, rejection of mismatched bindings and unsafe operation payloads, hostile markup and attribute text in both result types, retained completed history when a later start is refused, suggestion rows, whole-draft preview without mutation, dismissal and ambiguous-cancellation reconciliation without a revision change, an older poll returning after dismissal, and the interleaving where an import advances the plan before an older ready poll returns.
G3 adds revision-bound Apply and request-time draft/attachment preservation. G4 completes real-provider/store integration and the remaining T18 browser/adapter gate.
diff --git a/test/browser/plans-authoring.spec.ts b/test/browser/plans-authoring.spec.ts
index 0e253ebf..76836183 100644
--- a/test/browser/plans-authoring.spec.ts
+++ b/test/browser/plans-authoring.spec.ts
@@ -294,9 +294,56 @@ test('dismisses ready suggestions without changing the plan', async ({ page }) =
await expect(dismiss).toHaveAttribute('aria-disabled', 'true');
release();
await expect(page.getByRole('status').filter({ hasText: 'Suggestions dismissed.' })).toBeVisible();
+ await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toHaveCount(0);
expect(app.service.store.getPlan(app.service.config.identity).revision).toBe(1);
});
+test('ignores a ready poll response that returns after dismissal completes', async ({ page }) => {
+ await page.addInitScript(() => {
+ const nativeSetTimeout = window.setTimeout.bind(window);
+ window.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
+ if (typeof handler === 'function' && handler.toString().includes('pollPlanAuthor'))
+ (window as typeof window & { __planAuthorPoll?: () => void }).__planAuthorPoll = handler as () => void;
+ return nativeSetTimeout(handler, timeout, ...args);
+ }) as typeof window.setTimeout;
+ });
+ const { deps, invocations } = planning();
+ await openPlans(page, deps);
+ await page.getByRole('button', { name: 'Suggest edits' }).click();
+ await expect.poll(() => invocations.length).toBe(1);
+ invocations[0]!.resolve(JSON.stringify(suggestions()));
+ const dismiss = page.locator('#plan-author-dismiss');
+ await expect(dismiss).toBeVisible();
+
+ let release!: () => void, arrived!: () => void, requestId = '';
+ const held = new Promise(resolve => { release = resolve; });
+ const captured = new Promise(resolve => { arrived = resolve; });
+ let heldOldPoll = false;
+ await page.route('**/api/plan/suggestions/*', async route => {
+ if (route.request().method() !== 'GET' || heldOldPoll) { await route.fallback(); return; }
+ heldOldPoll = true;
+ requestId = new URL(route.request().url()).pathname.split('/').at(-1) ?? '';
+ const response = await route.fetch();
+ arrived();
+ await held;
+ await route.fulfill({ response });
+ });
+ await page.evaluate(() => {
+ (window as typeof window & { __planAuthorPoll?: () => void }).__planAuthorPoll?.();
+ });
+ await captured;
+
+ await dismiss.click();
+ await expect(page.getByRole('status').filter({ hasText: 'Suggestions dismissed.' })).toBeVisible();
+ await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toHaveCount(0);
+ expect(app.service.store.getSuggestions(app.service.config.identity, requestId).state).toBe('cancelled');
+ release();
+
+ await expect(page.getByRole('status').filter({ hasText: 'Suggestions dismissed.' })).toBeVisible();
+ await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toHaveCount(0);
+ expect(app.service.store.getSuggestions(app.service.config.identity, requestId).state).toBe('cancelled');
+});
+
test('blocks repeat dismissal until an ambiguous cancellation is reconciled', async ({ page }) => {
const { deps, invocations } = planning();
await openPlans(page, deps);
diff --git a/web/public/app.js b/web/public/app.js
index b51ecda7..16f62e8c 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -987,6 +987,7 @@ function validPlanAuthorStatus(status, request) {
return result === null || !!validResult;
}
function planAuthorResultMarkup(request, historical = false) {
+ if (request.state === "cancelled") return "";
const stale = planAuthorStale(request);
const historyMark = historical ? ` ${stale ? "! Stale" : "– Earlier result"}` : "";
let result = "";
From 1fa7cb6dd71bbfa180902f1b78bf583b44a418db Mon Sep 17 00:00:00 2001
From: mchwang
Date: Thu, 8 Oct 2026 11:14:14 -0700
Subject: [PATCH 06/10] Finish G2 result state presentation
---
docs/architecture.md | 12 ++++++------
docs/implementation/planning-screen.md | 2 +-
test/browser/plans-authoring.spec.ts | 11 +++++++++++
web/public/app.js | 3 ++-
web/public/style.css | 6 ++++++
5 files changed, 26 insertions(+), 8 deletions(-)
diff --git a/docs/architecture.md b/docs/architecture.md
index be98522e..248f7b69 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -40,11 +40,11 @@ Writing standard: plain language, ISO 24495-1:2023
- **The central idea.** A runner-owned **commit ledger** records which plan item each commit belongs to. The linking engine uses the ledger, not commit messages, to put every changed line on a plan item's row. Anything the ledger cannot explain goes to the Unplanned or Ambiguous row.
- **The central safety rule.** Everything that comes from outside is untrusted: issue text, agent output, the browser, and the repository's own files. Only the runner decides. Agents never see your credentials, your other files, or GitHub.
- **Build state:**
- - **In use:** plan and linking library, SQLite store, review screen, plan display/import screen, Ask (questions to a Claude agent), guarded merge with merge-queue support, ranked Issues screen, agent isolation boundary, and the single-runner lock.
+ - **In use:** plan and linking library, SQLite store, review screen, plan display/import/authoring screen, Ask (questions to a Claude agent), guarded merge with merge-queue support, ranked Issues screen, agent isolation boundary, and the single-runner lock.
- **In use for a non-demo review with a `github` block:** planning. `/api/plan/suggestions` and `/api/plan/drafts` ask Claude, through lane D, for suggestion cards or a whole next plan revision (#117, #124). Nothing becomes a revision until you apply it.
- **In use with an opt-in `runner` block in `review.json`:** the whole loop after planning. `start` and `resume` on `/api/runner` run a task's plan item by item with a Claude agent (#91). They require current plan-item approvals, bind both the task state and review version, recheck approvals before each later item, and refuse when a completed prefix no longer ends at the task's current head (#107). After an out-of-scope pause, an approved amended plan can reconcile the completed prefix and resume only the unfinished suffix (#88, PR #134). When a run ends, codeboost publishes the task's pull request, a draft if the task needs a person (#103). Approve & merge merges that pull request (#121). Cancelling a task closes its pull requests (#111). These are API actions; the screen has no buttons for them yet.
- **Partly built for pre-merge automation:** the trusted local rebase engine records one-to-one commit mappings, verifies the resulting checkout byte for byte, owns its subprocesses durably, and recovers interrupted work (#22, PR #136). Its production conflict path gives a sandboxed agent only the bounded conflict files and imports only audited results for both foreign and plan-owned commits (PRs #138, #140 and #144). No production request invokes the rebaser yet.
- - **Not built:** plan authoring and suggestion controls, screens for the runner actions, production rebase admission, post-rebase attribution and approval refresh, head-bound `cmd:` checks, remote push/check refresh, review rounds, and the Queue and Learning screens.
+ - **Not built:** applying draft or suggestion results from the screen, screens for the runner actions, production rebase admission, post-rebase attribution and approval refresh, head-bound `cmd:` checks, remote push/check refresh, review rounds, and the Queue and Learning screens.
## Terms used
@@ -164,7 +164,7 @@ Dependencies point downward only. `core/` imports nothing that does input or out
|---|---|---|---|
| Command line | `web/cli.ts` | Parses `--demo` or `--config`, takes the single-runner lock, starts the server, releases the lock on Ctrl+C. | In use |
| HTTP server | `web/server.ts` | Binds `127.0.0.1` only. Checks the token, Host and Origin. Bounded JSON bodies. Routes `/api/review`, `/api/action`, `/api/questions`, `/api/merge`, `/api/issues`, `/api/settings`, plus `/api/runner` and `/api/plan/*` when a runner or planning provider is supplied. Ordered shutdown. | In use; `/api/plan/suggestions` and `/api/plan/drafts` run Claude through lane D for a review with a `github` block (#117, #124) |
-| Browser screen | `web/public/*` | Review, Issues and Plans screens. Plans displays the complete current revision and imports a complete next JSON or YAML revision through the planning API. Plain JavaScript with IBM Plex fonts served locally. Sends item and segment IDs and a state token, never fingerprints or ownership. | In use |
+| Browser screen | `web/public/*` | Review, Issues and Plans screens. Plans displays and imports revisions, and starts, polls, renders and dismisses draft or suggestion requests through the planning API. Applying those results remains future work. Plain JavaScript with IBM Plex fonts served locally. Sends item and segment IDs and a state token, never fingerprints or ownership. | In use |
| Review service | `runner/review.ts` | Builds the review view: reads history, links it to the plan, computes approval states and merge blockers. Applies review commands through the store. | In use |
| Ask | `runner/questions.ts`, `runner/question-*.ts` | Answers a question about one plan item with a read-only Claude agent (Codex is refused in every phase, #93). Runs lane D's setup in a worker thread so the server stays responsive. Ask and planning share one read-only container runner (`runReadOnlyAgent`, #117) but keep separate workers, owners and leftovers. Labels its Docker objects with a per-database Ask owner and recovers only its own leftovers, so reviews sharing one Docker daemon do not block each other (#95). | In use |
| Merge coordinator | `runner/merge.ts` | Runs the full merge gate twice, re-reads the local generation, then merges the exact reviewed head. Tracks merge-queue attempts. For a runner task it inspects and merges the task's own published pull request; a configured `github.pullRequest` that differs is refused (#121). | In use |
@@ -180,7 +180,7 @@ Dependencies point downward only. `core/` imports nothing that does input or out
| Linking engine | `core/linking.ts` | Replays each commit's line changes and assigns every final line to an owner from the ledger. Produces segments and the Unplanned and Ambiguous rows. | In use |
| Approvals | `core/approvals.ts` | Approval fingerprints, dependency staleness, assignments, and accept-as-is choices. | In use |
| Issue ranking | `core/issue-ranking.ts`, `github/issues.ts`, `web/issues.ts` | Deterministic, explainable issue score. Repository-scoped trust of issue authors. | In use |
-| Planning library | `core/planning-author.ts`, `core/planning-suggestions.ts`, `prompts/plan-author.md` | Builds bounded planning prompts and validates plan drafts and suggestion cards. Does not run an agent itself; the planning agent above does. Recorded Claude output replays through it in tests (E4, #45). | In use through `/api/plan/*` (#117, #124); Plans displays and imports revisions, while authoring and suggestion controls remain future work |
+| Planning library | `core/planning-author.ts`, `core/planning-suggestions.ts`, `prompts/plan-author.md` | Builds bounded planning prompts and validates plan drafts and suggestion cards. Does not run an agent itself; the planning agent above does. Recorded Claude output replays through it in tests (E4, #45). | In use through `/api/plan/*` (#117, #124); Plans starts and displays authoring requests, while applying their results remains future work |
| Execution prompt and audit | `core/execution-prompt.ts`, `core/run-audit.ts`, `prompts/execute.md` | Builds the execute and fix prompt with untrusted text only in escaped data blocks. Audits the change manifest after a run: safety violations first (Git metadata, links that leave the repository, agent commits), then scope against the declared files. | In use with the opt-in `runner` block |
| Pull request text | `core/pull-request-body.ts` | Title and description with the plan. Plan and agent text go in fenced blocks, and issue references and @-mentions are neutralized, so the text cannot close other issues or notify people. | In use with the opt-in `runner` block |
| Git adapter | `git/history.ts`, `git/clone.ts` | Bounded, hardened history reads. Standalone task clones with no links back to your repository. Every Git command runs with an allowlisted environment. | In use |
@@ -413,7 +413,7 @@ These are the decisions that shape the structure. The full list, with the reason
| F | Runner | Merged: F1 (lifecycle store, coordinator, shutdown wiring, startup recovery and the lock, planning API, feedback events, and stops that wait for their commit: #53, #74, #57, #59, #60, #96); F2a–F2b (execute prompts, audit, per-item execution, real workspace, durable safety findings: #67, #68, #92, #94, #128); F2d (already-fixed check, PR opening, branch pusher, production publishing, closing PRs on cancel: #84, #101, #110, #115, #116, #119, #123); production wiring and `start`/`resume` (#91, PRs #102, #105); planning through lane D (#117, #124, PRs #118, #120, #125); merging the task's own PR (#121, PR #126); start/resume approval, review-version, recovery and completed-prefix guards (#107, PR #130); continuation after a scope pause (#88, PR #134); the F3 durable trusted local-rebase foundation (#22, PR #136); the F4 foreign-conflict engine plus production sandbox adapter (#22, PRs #138 and #140); and sandboxed owned-commit conflict resolution (#22, PR #144). Next in #22: F5 owns current base/head refresh, rebase admission, post-rebase attribution and approval refresh, and head-bound `cmd:` execution. F6 owns rewritten-head push, fresh required-check refresh, the already-fixed rerun, and exact-pair guarded-merge handoff. The integrated flow pushes a rewritten head before it recomputes review state and reruns every plan item's head-bound `cmd:` checks; fresh required GitHub checks follow. |
| Hardening | Allowlisted subprocess environments | Done: every Git command (#82, #83) and every `gh` runner (#84, #86). |
| Tests | Test suite reliability | Done. The full suite runs ordinary tests first and Docker-backed files serially; CI and the real-Docker gate passed on PR #132 (#129). |
-| G | Planning screen | G1 built (#145, PR #148): display the complete current plan and import the next JSON or YAML revision. G2 authoring/suggestion cards, G3 revision-bound Apply, and G4 real-provider integration acceptance remain. |
+| G | Planning screen | G1 and G2 built (#145, PRs #148 and #150): display/import the current plan, request a next-revision draft or suggestion cards, and render or dismiss the result. G3 revision-bound Apply and G4 real-provider integration acceptance remain. |
| H | Issue ranking and Issues screen | Done. H4b (#108, PR #139) records author-bound trust, guards runner and publishing boundaries, controls which comments reach prompts, records prompt comment evidence, and provides trust/untrust controls on the Issues screen. |
| I, J | Queue and run windows; learning from feedback | Not started (after F) |
@@ -424,7 +424,7 @@ These are the decisions that shape the structure. The full list, with the reason
- The container's guarantees are those of Docker. The agent can always reach its own vendor account.
- `cmd:` acceptance checks do not run yet, so any plan item with a `cmd:` check blocks merging.
- One user, one machine, one runner per database. The lock refuses a second runner, and it refuses network filesystems, where OS locks are unreliable.
-- The runner is opt-in: it needs a `runner` block and a `github` block in `review.json`, and it is always off in the demo. It runs through the API only; the screen has no start, resume, publish, plan-authoring, suggestion or Apply controls yet. Publishing also needs `github.baseBranch`.
+- The runner is opt-in: it needs a `runner` block and a `github` block in `review.json`, and it is always off in the demo. It runs through the API only; the screen has no runner start, resume, publish or plan-result Apply controls yet. Planning authoring and suggestions are available when the planning provider is configured. Publishing also needs `github.baseBranch`.
- codeboost is Claude-only. Codex is refused in every phase (#93, PR #106), because it can read files only through its shell, which every phase turns off. `agent-isolation.md` says when to revisit this.
## Test this document with a reader
diff --git a/docs/implementation/planning-screen.md b/docs/implementation/planning-screen.md
index f4962f3c..529de195 100644
--- a/docs/implementation/planning-screen.md
+++ b/docs/implementation/planning-screen.md
@@ -28,6 +28,6 @@ After start, the screen polls only the durable request resource with bounded bac
Suggestion replies render as plain text followed by one line-separated row per typed edit. Drafts render the proposed revision summary and ordered items. The browser escapes every agent-provided field. A completed, non-cancelled result remains visible as history but is marked stale whenever its captured revision or snapshot no longer matches the current review response. Dismissed output is removed, and a poll started before dismissal cannot restore it. A delayed ready poll likewise cannot present an old result as current after an import or refresh advances the plan.
-`test/browser/plans-authoring.spec.ts` covers duplicate activation and focus, request-time guidance preservation, exact ambiguous-start replay, a start refused after an intervening import, malformed-status ownership, rejection of mismatched bindings and unsafe operation payloads, hostile markup and attribute text in both result types, retained completed history when a later start is refused, suggestion rows, whole-draft preview without mutation, dismissal and ambiguous-cancellation reconciliation without a revision change, an older poll returning after dismissal, and the interleaving where an import advances the plan before an older ready poll returns.
+`test/browser/plans-authoring.spec.ts` covers duplicate activation, focus and disabled styling, request-time guidance preservation, exact ambiguous-start replay, a start refused after an intervening import, malformed-status ownership, rejection of mismatched bindings and unsafe operation payloads, hostile markup and attribute text in both result types, retained completed history when a later start is refused, suggestion rows, whole-draft preview without mutation, dismissal and ambiguous-cancellation reconciliation without a revision change, an older poll returning after dismissal, and the interleaving where an import advances the plan before an older ready poll returns and removes the stale dismissal action.
G3 adds revision-bound Apply and request-time draft/attachment preservation. G4 completes real-provider/store integration and the remaining T18 browser/adapter gate.
diff --git a/test/browser/plans-authoring.spec.ts b/test/browser/plans-authoring.spec.ts
index 76836183..59bf1dfd 100644
--- a/test/browser/plans-authoring.spec.ts
+++ b/test/browser/plans-authoring.spec.ts
@@ -75,6 +75,16 @@ test('renders suggestion cards and preserves guidance edited after submission',
await expect.poll(() => invocations.length).toBe(1);
await expect(suggest).toBeFocused();
await expect(suggest).toHaveAttribute('aria-disabled', 'true');
+ await expect(page.locator('#plan-draft')).toHaveAttribute('aria-disabled', 'true');
+ await page.mouse.move(0, 0);
+ await expect.poll(async () => {
+ const disabledColors = await Promise.all([suggest, page.locator('#plan-draft')].map(locator =>
+ locator.evaluate(element => {
+ const style = getComputedStyle(element);
+ return { background: style.backgroundColor, border: style.borderColor, color: style.color };
+ })));
+ return JSON.stringify(disabledColors[0]) === JSON.stringify(disabledColors[1]);
+ }).toBe(true);
await guidance.fill('Keep these newer notes while the request runs.');
invocations[0]!.resolve(JSON.stringify(suggestions()));
@@ -411,5 +421,6 @@ test('marks a delayed suggestion poll stale after an import advances the plan',
await expect(page.getByRole('status').filter({ hasText: 'Stale suggestions · generated for r1' })).toBeVisible();
await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Dismiss suggestions' })).toHaveCount(0);
expect(app.service.store.getPlan(app.service.config.identity).revision).toBe(2);
});
diff --git a/web/public/app.js b/web/public/app.js
index 16f62e8c..c395413d 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -1000,7 +1000,8 @@ function planAuthorResultMarkup(request, historical = false) {
${esc(request.plan.summary)}
${request.plan.items.map((item) => `
${esc(item.id)} ${esc(item.title)}
`).join("")}
`;
}
- if (!historical && result && request.requestId && !["cancelled", "consumed"].includes(request.state))
+ const dismissible = request.state === "cancelling" || (request.state === "ready" && !stale);
+ if (!historical && result && request.requestId && dismissible)
result += ``;
return result;
}
diff --git a/web/public/style.css b/web/public/style.css
index aefc10ff..5bf44d78 100644
--- a/web/public/style.css
+++ b/web/public/style.css
@@ -105,6 +105,12 @@ button.primary,
color: var(--on-primary);
border-color: var(--primary);
}
+button.primary:disabled,
+button.primary[aria-disabled="true"] {
+ background: var(--surface);
+ color: var(--muted);
+ border-color: var(--line);
+}
#approve:hover,
#merge:not(:disabled):hover {
background: var(--hover);
From bd5380b3f3e98b942b4121f0e93d271cae1a6404 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Thu, 8 Oct 2026 11:33:34 -0700
Subject: [PATCH 07/10] Validate G2 planning status identity
---
docs/implementation/planning-screen.md | 4 +-
test/browser/plans-authoring.spec.ts | 74 ++++++++++++++++++++++++++
web/public/app.js | 6 ++-
3 files changed, 80 insertions(+), 4 deletions(-)
diff --git a/docs/implementation/planning-screen.md b/docs/implementation/planning-screen.md
index 529de195..306803e6 100644
--- a/docs/implementation/planning-screen.md
+++ b/docs/implementation/planning-screen.md
@@ -26,8 +26,8 @@ The guidance field is shared by the two explicit request types. **Draft next rev
After start, the screen polls only the durable request resource with bounded backoff. A lifecycle transition resets the interval. Poll updates replace only the request status and result: they never write the guidance field. The active control uses `aria-disabled` with an in-flight guard, so keyboard focus stays in place and repeat activation cannot start a second invocation. Dismiss uses the request's cancel endpoint and its own stable action ID.
-Suggestion replies render as plain text followed by one line-separated row per typed edit. Drafts render the proposed revision summary and ordered items. The browser escapes every agent-provided field. A completed, non-cancelled result remains visible as history but is marked stale whenever its captured revision or snapshot no longer matches the current review response. Dismissed output is removed, and a poll started before dismissal cannot restore it. A delayed ready poll likewise cannot present an old result as current after an import or refresh advances the plan.
+Suggestion replies render as plain text followed by one line-separated row per typed edit. Drafts render the proposed revision summary and ordered items. The browser escapes every agent-provided field. A completed, non-cancelled result remains visible as history but is marked stale whenever its lifecycle is invalidated or its captured revision or snapshot no longer matches the current review response. Dismissed output is removed, and a poll started before dismissal cannot restore it. A delayed ready poll likewise cannot present an old result as current after an import or refresh advances the plan.
-`test/browser/plans-authoring.spec.ts` covers duplicate activation, focus and disabled styling, request-time guidance preservation, exact ambiguous-start replay, a start refused after an intervening import, malformed-status ownership, rejection of mismatched bindings and unsafe operation payloads, hostile markup and attribute text in both result types, retained completed history when a later start is refused, suggestion rows, whole-draft preview without mutation, dismissal and ambiguous-cancellation reconciliation without a revision change, an older poll returning after dismissal, and the interleaving where an import advances the plan before an older ready poll returns and removes the stale dismissal action.
+`test/browser/plans-authoring.spec.ts` covers duplicate activation, focus and disabled styling, request-time guidance preservation, exact ambiguous-start replay, a start refused after an intervening import, malformed-status ownership, rejection of mismatched bindings, wrong-operation payloads and unsafe operation data, hostile markup and attribute text in both result types, retained completed history and invalidated-history labeling, suggestion rows, whole-draft preview without mutation, dismissal and ambiguous-cancellation reconciliation without a revision change, an older poll returning after dismissal, and the interleaving where an import advances the plan before an older ready poll returns and removes the stale dismissal action.
G3 adds revision-bound Apply and request-time draft/attachment preservation. G4 completes real-provider/store integration and the remaining T18 browser/adapter gate.
diff --git a/test/browser/plans-authoring.spec.ts b/test/browser/plans-authoring.spec.ts
index 59bf1dfd..5c61540c 100644
--- a/test/browser/plans-authoring.spec.ts
+++ b/test/browser/plans-authoring.spec.ts
@@ -182,6 +182,80 @@ test('keeps ownership while a malformed status response is retried', async ({ pa
await expect(page.getByRole('status').filter({ hasText: 'Suggestions ready for r1.' })).toBeVisible();
});
+test('rejects a status response for the wrong planning operation', async ({ page }) => {
+ const { deps, invocations } = planning();
+ await openPlans(page, deps);
+ const current = app.service.store.getPlan(app.service.config.identity);
+ const snapshotId = app.service.load().snapshot.id;
+ let reads = 0;
+ await page.route('**/api/plan/suggestions/*', async route => {
+ reads++;
+ if (reads === 1) {
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
+ mode: 'draft', state: 'ready', revision: 1, snapshotId, reply: suggestions(), reason: null,
+ }) });
+ return;
+ }
+ await route.continue();
+ });
+ const suggest = page.locator('#plan-suggest');
+ await suggest.click();
+ await expect.poll(() => invocations.length).toBe(1);
+ await expect(page.getByRole('status').filter({ hasText: 'Planning status response was incomplete, invalid' })).toBeVisible();
+ await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toHaveCount(0);
+ await expect(suggest).toHaveAttribute('aria-disabled', 'true');
+ invocations[0]!.resolve(JSON.stringify(suggestions()));
+ await expect(page.getByRole('status').filter({ hasText: 'Suggestions ready for r1.' })).toBeVisible();
+
+ let draftReads = 0;
+ await page.route('**/api/plan/drafts/*', async route => {
+ draftReads++;
+ if (draftReads === 1) {
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
+ mode: 'suggest', state: 'ready', revision: 1, snapshotId, plan: draft(current), reason: null,
+ }) });
+ return;
+ }
+ await route.continue();
+ });
+ await page.getByRole('button', { name: 'Draft next revision' }).click();
+ await expect.poll(() => invocations.length).toBe(2);
+ await expect(page.getByRole('status').filter({ hasText: 'Planning status response was incomplete, invalid' })).toBeVisible();
+ await expect(page.getByRole('region', { name: 'Draft Make retry limits visible to operators' })).toHaveCount(0);
+ invocations[1]!.resolve(JSON.stringify(draft(current)));
+ await expect(page.getByRole('status').filter({ hasText: 'Draft ready for r1.' })).toBeVisible();
+});
+
+test('marks invalidated output stale when it moves into history', async ({ page }) => {
+ const { deps, invocations } = planning();
+ await openPlans(page, deps);
+ const current = app.service.store.getPlan(app.service.config.identity);
+ const snapshotId = app.service.load().snapshot.id;
+ let reads = 0;
+ await page.route('**/api/plan/suggestions/*', async route => {
+ reads++;
+ if (reads === 1) {
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
+ mode: 'suggest', state: 'invalidated', revision: 1, snapshotId, reply: suggestions(),
+ reason: 'The planning checkpoint changed.',
+ }) });
+ return;
+ }
+ await route.continue();
+ });
+ await page.getByRole('button', { name: 'Suggest edits' }).click();
+ await expect.poll(() => invocations.length).toBe(1);
+ await expect(page.getByRole('status').filter({ hasText: 'Suggestions became stale.' })).toBeVisible();
+ invocations[0]!.resolve(JSON.stringify(suggestions()));
+ await page.getByRole('button', { name: 'Draft next revision' }).click();
+ await expect.poll(() => invocations.length).toBe(2);
+ const historical = page.locator('.plan-author-result-heading').filter({ hasText: 'Earlier suggested edits' });
+ await expect(historical).toContainText('! Stale');
+ await expect(historical).not.toContainText('Earlier result');
+ invocations[1]!.resolve(JSON.stringify(draft(current)));
+ await expect(page.getByRole('status').filter({ hasText: 'Draft ready for r1.' })).toBeVisible();
+});
+
test('rejects a status observation bound to another snapshot', async ({ page }) => {
const { deps, invocations } = planning();
await openPlans(page, deps);
diff --git a/web/public/app.js b/web/public/app.js
index c395413d..e75bde21 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -933,7 +933,7 @@ function planAuthorBusy() {
return ["starting", "pending", "cancelling"].includes(planAuthorRequest?.state);
}
function planAuthorStale(request = planAuthorRequest) {
- return !!request && (!data?.plan ||
+ return !!request && (request.state === "invalidated" || !data?.plan ||
request.revision !== data.plan.revision || request.snapshotId !== data.snapshot?.id);
}
function describePlanEdit(edit) {
@@ -967,7 +967,9 @@ function validPlanEditForDisplay(edit) {
}
function validPlanAuthorStatus(status, request) {
const states = ["pending", "ready", "failed", "cancelled", "invalidated", "consumed"];
- if (!status || typeof status !== "object" || !states.includes(status.state) ||
+ const objectStatus = !!status && typeof status === "object";
+ const validMode = objectStatus && (request.mode === "suggest" ? status.mode === "suggest" : !("mode" in status));
+ if (!objectStatus || !validMode || !states.includes(status.state) ||
!Number.isSafeInteger(status.revision) || status.revision < 1 ||
typeof status.snapshotId !== "string" || !status.snapshotId ||
status.revision !== request.revision || status.snapshotId !== request.snapshotId ||
From 9312e84698339dd2fb0b1dfd50bd892841980f30 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Thu, 8 Oct 2026 11:56:12 -0700
Subject: [PATCH 08/10] Validate complete G2 result payloads
---
docs/implementation/planning-screen.md | 4 +-
test/browser/plans-authoring.spec.ts | 59 ++++++++++++++++++-
web/public/app.js | 80 ++++++++++++++++++++------
3 files changed, 123 insertions(+), 20 deletions(-)
diff --git a/docs/implementation/planning-screen.md b/docs/implementation/planning-screen.md
index 306803e6..e884e41a 100644
--- a/docs/implementation/planning-screen.md
+++ b/docs/implementation/planning-screen.md
@@ -26,8 +26,8 @@ The guidance field is shared by the two explicit request types. **Draft next rev
After start, the screen polls only the durable request resource with bounded backoff. A lifecycle transition resets the interval. Poll updates replace only the request status and result: they never write the guidance field. The active control uses `aria-disabled` with an in-flight guard, so keyboard focus stays in place and repeat activation cannot start a second invocation. Dismiss uses the request's cancel endpoint and its own stable action ID.
-Suggestion replies render as plain text followed by one line-separated row per typed edit. Drafts render the proposed revision summary and ordered items. The browser escapes every agent-provided field. A completed, non-cancelled result remains visible as history but is marked stale whenever its lifecycle is invalidated or its captured revision or snapshot no longer matches the current review response. Dismissed output is removed, and a poll started before dismissal cannot restore it. A delayed ready poll likewise cannot present an old result as current after an import or refresh advances the plan.
+Suggestion replies render as plain text followed by one line-separated row per typed edit. Drafts render the proposed revision summary and ordered items. Before rendering a ready result, the browser validates the complete v1 plan or edit-reply structure, including exact object fields, bounded nested file/check/item data and each edit operation's used and null payloads. The browser escapes every agent-provided field. A completed, non-cancelled result remains visible as history but is marked stale whenever its lifecycle is invalidated or its captured revision or snapshot no longer matches the current review response. Dismissed output is removed, and a poll started before dismissal cannot restore it. A delayed ready poll likewise cannot present an old result as current after an import or refresh advances the plan.
-`test/browser/plans-authoring.spec.ts` covers duplicate activation, focus and disabled styling, request-time guidance preservation, exact ambiguous-start replay, a start refused after an intervening import, malformed-status ownership, rejection of mismatched bindings, wrong-operation payloads and unsafe operation data, hostile markup and attribute text in both result types, retained completed history and invalidated-history labeling, suggestion rows, whole-draft preview without mutation, dismissal and ambiguous-cancellation reconciliation without a revision change, an older poll returning after dismissal, and the interleaving where an import advances the plan before an older ready poll returns and removes the stale dismissal action.
+`test/browser/plans-authoring.spec.ts` covers duplicate activation, focus and disabled styling, request-time guidance preservation, exact ambiguous-start replay, a start refused after an intervening import, malformed-status ownership, rejection of mismatched bindings, wrong-operation payloads, incomplete edit cards and incomplete nested draft data, hostile markup and attribute text in both result types, retained completed history and invalidated-history labeling, suggestion rows, whole-draft preview without mutation, dismissal and ambiguous-cancellation reconciliation without a revision change, an older poll returning after dismissal, and the interleaving where an import advances the plan before an older ready poll returns and removes the stale dismissal action.
G3 adds revision-bound Apply and request-time draft/attachment preservation. G4 completes real-provider/store integration and the remaining T18 browser/adapter gate.
diff --git a/test/browser/plans-authoring.spec.ts b/test/browser/plans-authoring.spec.ts
index 5c61540c..90e600a5 100644
--- a/test/browser/plans-authoring.spec.ts
+++ b/test/browser/plans-authoring.spec.ts
@@ -96,13 +96,63 @@ test('renders suggestion cards and preserves guidance edited after submission',
expect(invocations[0]!.request.prompt).toContain('Make the plan clearer.');
});
+test('accepts complete payloads for every edit operation', async ({ page }) => {
+ const { deps } = planning();
+ await openPlans(page, deps);
+ const current = app.service.store.getPlan(app.service.config.identity);
+ const snapshotId = app.service.load().snapshot.id;
+ const card = (op: EditReply['edits'][number]['op'], payload: Record = {}, item = 'P1') => ({
+ op, item, summary: `Exercise ${op}`, reason: `The ${op} payload is complete.`,
+ field: null, value: null, file: null, check: null, check_index: null, depends_on: null, new_item: null, ...payload,
+ }) as EditReply['edits'][number];
+ const newItem = { ...structuredClone(current.items[0]!), id: 'P4', depends_on: ['P1'] };
+ const reply: EditReply = { ...suggestions(), edits: [
+ card('add_item', { new_item: newItem }, 'P4'),
+ card('set_field', { field: 'intent', value: 'Explain the bounded retry behavior.' }),
+ card('add_file', { file: { path: 'added.ts', kind: 'add', renamed_from: null, change: 'Add the helper.' } }),
+ card('update_file', { file: { path: 'retry.ts', kind: 'edit', renamed_from: null, change: 'Clarify retries.' } }),
+ card('remove_file', { value: 'retry.ts' }),
+ card('add_check', { check: { type: 'check', text: 'Retries stay bounded.' } }),
+ card('remove_check', { check_index: 0 }),
+ card('set_depends', { depends_on: [] }),
+ card('remove_item', {}, 'P3'),
+ ] };
+ const requestId = '12345678-1234-4123-8123-123456789abc';
+ await page.route('**/api/plan/suggestions', route => route.fulfill({
+ status: 200, contentType: 'application/json', body: JSON.stringify({ result: { requestId } }),
+ }));
+ await page.route(`**/api/plan/suggestions/${requestId}`, route => route.fulfill({
+ status: 200, contentType: 'application/json', body: JSON.stringify({
+ mode: 'suggest', state: 'ready', revision: 1, snapshotId, reply, reason: null,
+ }),
+ }));
+ await page.getByRole('button', { name: 'Suggest edits' }).click();
+ await expect(page.getByRole('status').filter({ hasText: 'Suggestions ready for r1.' })).toBeVisible();
+ await expect(page.locator('.suggestion-row')).toHaveCount(9);
+});
+
test('renders a whole next-revision draft without changing the current plan', async ({ page }) => {
const { deps, invocations } = planning();
await openPlans(page, deps);
const current = app.service.store.getPlan(app.service.config.identity);
+ const snapshotId = app.service.load().snapshot.id;
+ let reads = 0;
+ await page.route('**/api/plan/drafts/*', async route => {
+ reads++;
+ if (reads === 1) {
+ const malformed = { ...draft(current), items: [{ id: 'P1', title: 'Incomplete item' }] };
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
+ state: 'ready', revision: 1, snapshotId, plan: malformed, reason: null,
+ }) });
+ return;
+ }
+ await route.continue();
+ });
await page.getByLabel('Guidance').fill('Rewrite the plan around operator visibility.');
await page.getByRole('button', { name: 'Draft next revision' }).click();
await expect.poll(() => invocations.length).toBe(1);
+ await expect(page.getByRole('status').filter({ hasText: 'Planning status response was incomplete, invalid' })).toBeVisible();
+ await expect(page.getByRole('region', { name: 'Draft Make retry limits visible to operators' })).toHaveCount(0);
invocations[0]!.resolve(JSON.stringify(draft(current)));
await expect(page.getByRole('status').filter({ hasText: 'Draft ready for r1.' })).toBeVisible();
@@ -287,9 +337,12 @@ test('rejects an operation-specific payload that is unsafe to render', async ({
let reads = 0;
await page.route('**/api/plan/suggestions/*', async route => {
reads++;
- if (reads === 1) {
+ if (reads <= 2) {
const reply = suggestions();
- const malformed = { ...reply, edits: [{ ...reply.edits[0], op: 'set_depends', field: null, value: null, depends_on: 'P2' }] };
+ const malformedEdit = reads === 1
+ ? { ...reply.edits[0], op: 'add_item', field: null, value: null, new_item: null }
+ : { ...reply.edits[0], op: 'add_file', field: null, value: null, file: { path: 'partial.ts' } };
+ const malformed = { ...reply, edits: [malformedEdit] };
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
mode: 'suggest', state: 'ready', revision: 1, snapshotId, reply: malformed, reason: null,
}) });
@@ -304,6 +357,8 @@ test('rejects an operation-specific payload that is unsafe to render', async ({
await expect.poll(() => invocations.length).toBe(1);
await expect(page.getByRole('status').filter({ hasText: 'Planning status response was incomplete, invalid' })).toBeVisible();
await expect(suggest).toHaveAttribute('aria-disabled', 'true');
+ await expect.poll(() => reads).toBeGreaterThanOrEqual(2);
+ await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toHaveCount(0);
expect(errors).toEqual([]);
invocations[0]!.resolve(JSON.stringify(suggestions()));
await expect(page.getByRole('status').filter({ hasText: 'Suggestions ready for r1.' })).toBeVisible();
diff --git a/web/public/app.js b/web/public/app.js
index e75bde21..58433a05 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -950,26 +950,77 @@ function describePlanEdit(edit) {
default: return "Change the plan";
}
}
+function exactObject(value, keys) {
+ return !!value && typeof value === "object" && !Array.isArray(value) &&
+ Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
+}
+function boundedText(value, minimum, maximum) {
+ return typeof value === "string" && value.length >= minimum && value.length <= maximum;
+}
+function validPlanId(value) {
+ return typeof value === "string" && /^P[1-9][0-9]{0,2}$/.test(value);
+}
+function validPlanFileForDisplay(file) {
+ return exactObject(file, ["path", "kind", "renamed_from", "change"]) &&
+ boundedText(file.path, 1, 300) && /^[^/\\][^\\]*$/.test(file.path) &&
+ ["edit", "add", "delete", "rename"].includes(file.kind) &&
+ (file.renamed_from === null || boundedText(file.renamed_from, 0, 300)) &&
+ boundedText(file.change, 1, 1200);
+}
+function validPlanCheckForDisplay(check) {
+ return exactObject(check, ["type", "text"]) && ["cmd", "check"].includes(check.type) &&
+ boundedText(check.text, 1, 400);
+}
+function validPlanItemForDisplay(item) {
+ return exactObject(item, ["id", "title", "intent", "files", "acceptance", "depends_on"]) &&
+ validPlanId(item.id) && boundedText(item.title, 1, 120) && boundedText(item.intent, 1, 600) &&
+ Array.isArray(item.files) && item.files.length >= 1 && item.files.length <= 40 && item.files.every(validPlanFileForDisplay) &&
+ Array.isArray(item.acceptance) && item.acceptance.length >= 1 && item.acceptance.length <= 10 && item.acceptance.every(validPlanCheckForDisplay) &&
+ Array.isArray(item.depends_on) && item.depends_on.length <= 29 && item.depends_on.every(validPlanId);
+}
+function validPlanForDisplay(plan) {
+ return exactObject(plan, ["schema_version", "issue", "revision", "summary", "items", "questions"]) &&
+ plan.schema_version === 1 && Number.isSafeInteger(plan.issue) && plan.issue >= 1 &&
+ Number.isSafeInteger(plan.revision) && plan.revision >= 1 && boundedText(plan.summary, 1, 600) &&
+ Array.isArray(plan.items) && plan.items.length >= 1 && plan.items.length <= 30 && plan.items.every(validPlanItemForDisplay) &&
+ Array.isArray(plan.questions) && plan.questions.length <= 10 && plan.questions.every((question) => boundedText(question, 1, 400));
+}
function validPlanEditForDisplay(edit) {
- if (!edit || typeof edit.item !== "string" || typeof edit.summary !== "string" || typeof edit.reason !== "string") return false;
+ const payloads = ["field", "value", "file", "check", "check_index", "depends_on", "new_item"];
+ const used = {
+ add_item: ["new_item"], remove_item: [], set_field: ["field", "value"],
+ add_file: ["file"], update_file: ["file"], remove_file: ["value"],
+ add_check: ["check"], remove_check: ["check_index"], set_depends: ["depends_on"],
+ };
+ const operationPayloads = Object.hasOwn(used, edit?.op) ? used[edit.op] : null;
+ if (!exactObject(edit, ["op", "item", "summary", "reason", ...payloads]) || !operationPayloads ||
+ !validPlanId(edit.item) || !boundedText(edit.summary, 1, 160) || !boundedText(edit.reason, 1, 400) ||
+ payloads.some((key) => operationPayloads.includes(key) ? edit[key] === null : edit[key] !== null)) return false;
switch (edit.op) {
- case "add_item":
+ case "add_item": return validPlanItemForDisplay(edit.new_item) && edit.new_item.id === edit.item;
case "remove_item": return true;
- case "set_field": return ["title", "intent"].includes(edit.field) && typeof edit.value === "string";
+ case "set_field": return ["title", "intent"].includes(edit.field) && boundedText(edit.value, 0, 1200);
case "add_file":
- case "update_file": return !!edit.file && typeof edit.file === "object" && typeof edit.file.path === "string";
- case "remove_file": return typeof edit.value === "string";
- case "add_check": return !!edit.check && typeof edit.check === "object" && typeof edit.check.type === "string";
+ case "update_file": return validPlanFileForDisplay(edit.file);
+ case "remove_file": return boundedText(edit.value, 0, 1200);
+ case "add_check": return validPlanCheckForDisplay(edit.check);
case "remove_check": return Number.isSafeInteger(edit.check_index) && edit.check_index >= 0;
- case "set_depends": return Array.isArray(edit.depends_on) && edit.depends_on.every((item) => typeof item === "string");
+ case "set_depends": return Array.isArray(edit.depends_on) && edit.depends_on.length <= 29 && edit.depends_on.every(validPlanId);
default: return false;
}
}
+function validEditReplyForDisplay(reply, revision) {
+ return exactObject(reply, ["schema_version", "base_revision", "reply", "edits"]) && reply.schema_version === 1 &&
+ Number.isSafeInteger(reply.base_revision) && reply.base_revision === revision && boundedText(reply.reply, 0, 4000) &&
+ Array.isArray(reply.edits) && reply.edits.length <= 10 && reply.edits.every(validPlanEditForDisplay);
+}
function validPlanAuthorStatus(status, request) {
const states = ["pending", "ready", "failed", "cancelled", "invalidated", "consumed"];
- const objectStatus = !!status && typeof status === "object";
- const validMode = objectStatus && (request.mode === "suggest" ? status.mode === "suggest" : !("mode" in status));
- if (!objectStatus || !validMode || !states.includes(status.state) ||
+ const statusKeys = request.mode === "suggest" ? ["mode", "state", "revision", "snapshotId", "reply", "reason"]
+ : ["state", "revision", "snapshotId", "plan", "reason"];
+ const validShape = exactObject(status, statusKeys);
+ const validMode = validShape && (request.mode === "suggest" ? status.mode === "suggest" : !("mode" in status));
+ if (!validShape || !validMode || !states.includes(status.state) ||
!Number.isSafeInteger(status.revision) || status.revision < 1 ||
typeof status.snapshotId !== "string" || !status.snapshotId ||
status.revision !== request.revision || status.snapshotId !== request.snapshotId ||
@@ -977,12 +1028,8 @@ function validPlanAuthorStatus(status, request) {
const terminalReason = ["failed", "cancelled", "invalidated"].includes(status.state);
if (terminalReason ? !(typeof status.reason === "string" && status.reason.length > 0) : status.reason !== null) return false;
const result = request.mode === "draft" ? status.plan : status.reply;
- const validDraft = result && typeof result === "object" && Number.isSafeInteger(result.revision) &&
- result.revision === status.revision + 1 && typeof result.summary === "string" && Array.isArray(result.items) &&
- result.items.every((item) => item && typeof item.id === "string" && typeof item.title === "string");
- const validReply = result && typeof result === "object" && Number.isSafeInteger(result.base_revision) &&
- result.base_revision === status.revision && typeof result.reply === "string" && Array.isArray(result.edits) &&
- result.edits.every(validPlanEditForDisplay);
+ const validDraft = validPlanForDisplay(result) && result.issue === request.issue && result.revision === status.revision + 1;
+ const validReply = validEditReplyForDisplay(result, status.revision);
const validResult = request.mode === "draft" ? validDraft : validReply;
if (status.state === "pending") return result === null;
if (["ready", "consumed"].includes(status.state)) return !!validResult;
@@ -1232,6 +1279,7 @@ async function startPlanAuthor(mode) {
state: "starting",
revision: requestBody.expectedRevision,
snapshotId: requestBody.snapshotId,
+ issue: data.plan.issue,
request: requestBody,
requestId: null,
reason: null,
From 4c44e89c47a4e409a45bc7e95ee155eb19fc10c0 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Thu, 8 Oct 2026 12:00:29 -0700
Subject: [PATCH 09/10] Count planning text by code point
---
test/browser/plans-authoring.spec.ts | 4 +++-
web/public/app.js | 7 ++++++-
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/test/browser/plans-authoring.spec.ts b/test/browser/plans-authoring.spec.ts
index 90e600a5..7aecad76 100644
--- a/test/browser/plans-authoring.spec.ts
+++ b/test/browser/plans-authoring.spec.ts
@@ -106,7 +106,7 @@ test('accepts complete payloads for every edit operation', async ({ page }) => {
field: null, value: null, file: null, check: null, check_index: null, depends_on: null, new_item: null, ...payload,
}) as EditReply['edits'][number];
const newItem = { ...structuredClone(current.items[0]!), id: 'P4', depends_on: ['P1'] };
- const reply: EditReply = { ...suggestions(), edits: [
+ const reply: EditReply = { ...suggestions(), reply: '😀'.repeat(4000), edits: [
card('add_item', { new_item: newItem }, 'P4'),
card('set_field', { field: 'intent', value: 'Explain the bounded retry behavior.' }),
card('add_file', { file: { path: 'added.ts', kind: 'add', renamed_from: null, change: 'Add the helper.' } }),
@@ -128,7 +128,9 @@ test('accepts complete payloads for every edit operation', async ({ page }) => {
}));
await page.getByRole('button', { name: 'Suggest edits' }).click();
await expect(page.getByRole('status').filter({ hasText: 'Suggestions ready for r1.' })).toBeVisible();
+ await expect(page.locator('.plan-author-reply')).toHaveText(reply.reply);
await expect(page.locator('.suggestion-row')).toHaveCount(9);
+ await expect(page.getByRole('button', { name: 'Suggest edits' })).not.toHaveAttribute('aria-disabled', 'true');
});
test('renders a whole next-revision draft without changing the current plan', async ({ page }) => {
diff --git a/web/public/app.js b/web/public/app.js
index 58433a05..39e8dd03 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -955,7 +955,12 @@ function exactObject(value, keys) {
Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
}
function boundedText(value, minimum, maximum) {
- return typeof value === "string" && value.length >= minimum && value.length <= maximum;
+ if (typeof value !== "string") return false;
+ let length = 0;
+ for (const _point of value) {
+ if (++length > maximum) return false;
+ }
+ return length >= minimum;
}
function validPlanId(value) {
return typeof value === "string" && /^P[1-9][0-9]{0,2}$/.test(value);
From b35e67bfac745dd3a4cafd44b0e8df568241bc09 Mon Sep 17 00:00:00 2001
From: mchwang
Date: Thu, 8 Oct 2026 12:22:49 -0700
Subject: [PATCH 10/10] Validate terminal planning states
---
docs/implementation/planning-screen.md | 4 ++--
test/browser/plans-authoring.spec.ts | 33 ++++++++++++++++++++++++++
web/public/app.js | 4 ++--
3 files changed, 37 insertions(+), 4 deletions(-)
diff --git a/docs/implementation/planning-screen.md b/docs/implementation/planning-screen.md
index e884e41a..244826fa 100644
--- a/docs/implementation/planning-screen.md
+++ b/docs/implementation/planning-screen.md
@@ -26,8 +26,8 @@ The guidance field is shared by the two explicit request types. **Draft next rev
After start, the screen polls only the durable request resource with bounded backoff. A lifecycle transition resets the interval. Poll updates replace only the request status and result: they never write the guidance field. The active control uses `aria-disabled` with an in-flight guard, so keyboard focus stays in place and repeat activation cannot start a second invocation. Dismiss uses the request's cancel endpoint and its own stable action ID.
-Suggestion replies render as plain text followed by one line-separated row per typed edit. Drafts render the proposed revision summary and ordered items. Before rendering a ready result, the browser validates the complete v1 plan or edit-reply structure, including exact object fields, bounded nested file/check/item data and each edit operation's used and null payloads. The browser escapes every agent-provided field. A completed, non-cancelled result remains visible as history but is marked stale whenever its lifecycle is invalidated or its captured revision or snapshot no longer matches the current review response. Dismissed output is removed, and a poll started before dismissal cannot restore it. A delayed ready poll likewise cannot present an old result as current after an import or refresh advances the plan.
+Suggestion replies render as plain text followed by one line-separated row per typed edit. Drafts render the proposed revision summary and ordered items. Before rendering a ready result, the browser validates the complete v1 plan or edit-reply structure, including exact object fields, bounded nested file/check/item data and each edit operation's used and null payloads. The browser escapes every agent-provided field. A completed, non-cancelled result remains visible as history but is marked stale whenever its lifecycle is invalidated or consumed, or its captured revision or snapshot no longer matches the current review response. Dismissed output is removed, and a poll started before dismissal cannot restore it. A delayed ready poll likewise cannot present an old result as current after an import or refresh advances the plan.
-`test/browser/plans-authoring.spec.ts` covers duplicate activation, focus and disabled styling, request-time guidance preservation, exact ambiguous-start replay, a start refused after an intervening import, malformed-status ownership, rejection of mismatched bindings, wrong-operation payloads, incomplete edit cards and incomplete nested draft data, hostile markup and attribute text in both result types, retained completed history and invalidated-history labeling, suggestion rows, whole-draft preview without mutation, dismissal and ambiguous-cancellation reconciliation without a revision change, an older poll returning after dismissal, and the interleaving where an import advances the plan before an older ready poll returns and removes the stale dismissal action.
+`test/browser/plans-authoring.spec.ts` covers duplicate activation, focus and disabled styling, request-time guidance preservation, exact ambiguous-start replay, a start refused after an intervening import, malformed-status ownership, failed states with impossible output, consumed-result stale labeling, rejection of mismatched bindings, wrong-operation payloads, incomplete edit cards and incomplete nested draft data, hostile markup and attribute text in both result types, retained completed history and invalidated-history labeling, suggestion rows, whole-draft preview without mutation, dismissal and ambiguous-cancellation reconciliation without a revision change, an older poll returning after dismissal, and the interleaving where an import advances the plan before an older ready poll returns and removes the stale dismissal action.
G3 adds revision-bound Apply and request-time draft/attachment preservation. G4 completes real-provider/store integration and the remaining T18 browser/adapter gate.
diff --git a/test/browser/plans-authoring.spec.ts b/test/browser/plans-authoring.spec.ts
index 7aecad76..2eda984f 100644
--- a/test/browser/plans-authoring.spec.ts
+++ b/test/browser/plans-authoring.spec.ts
@@ -234,6 +234,39 @@ test('keeps ownership while a malformed status response is retried', async ({ pa
await expect(page.getByRole('status').filter({ hasText: 'Suggestions ready for r1.' })).toBeVisible();
});
+test('rejects failed output and marks consumed output stale', async ({ page }) => {
+ const { deps } = planning();
+ await openPlans(page, deps);
+ const snapshotId = app.service.load().snapshot.id;
+ let reads = 0;
+ let releaseConsumed!: () => void;
+ const consumedHeld = new Promise(resolve => { releaseConsumed = resolve; });
+ await page.route('**/api/plan/suggestions', route => route.fulfill({
+ status: 200, contentType: 'application/json', body: JSON.stringify({
+ result: { requestId: '12345678-1234-4123-8123-123456789abc' },
+ }),
+ }));
+ await page.route('**/api/plan/suggestions/*', async route => {
+ reads++;
+ if (reads > 1) await consumedHeld;
+ return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
+ mode: 'suggest', state: reads === 1 ? 'failed' : 'consumed', revision: 1, snapshotId,
+ reply: suggestions(), reason: reads === 1 ? 'The provider refused the request.' : null,
+ }) });
+ });
+ const suggest = page.locator('#plan-suggest');
+ await suggest.click();
+ await expect(page.getByRole('status').filter({ hasText: 'Planning status response was incomplete, invalid' })).toBeVisible();
+ await expect(suggest).toHaveAttribute('aria-disabled', 'true');
+ await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toHaveCount(0);
+ await expect.poll(() => reads).toBeGreaterThanOrEqual(2);
+ releaseConsumed();
+ await expect(page.getByRole('status').filter({ hasText: 'Stale suggestions · generated for r1' })).toBeVisible();
+ await expect(page.getByRole('article', { name: 'Name the retry ceiling' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Dismiss suggestions' })).toHaveCount(0);
+ await expect(suggest).not.toHaveAttribute('aria-disabled', 'true');
+});
+
test('rejects a status response for the wrong planning operation', async ({ page }) => {
const { deps, invocations } = planning();
await openPlans(page, deps);
diff --git a/web/public/app.js b/web/public/app.js
index 39e8dd03..a00675f7 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -933,7 +933,7 @@ function planAuthorBusy() {
return ["starting", "pending", "cancelling"].includes(planAuthorRequest?.state);
}
function planAuthorStale(request = planAuthorRequest) {
- return !!request && (request.state === "invalidated" || !data?.plan ||
+ return !!request && (["invalidated", "consumed"].includes(request.state) || !data?.plan ||
request.revision !== data.plan.revision || request.snapshotId !== data.snapshot?.id);
}
function describePlanEdit(edit) {
@@ -1036,7 +1036,7 @@ function validPlanAuthorStatus(status, request) {
const validDraft = validPlanForDisplay(result) && result.issue === request.issue && result.revision === status.revision + 1;
const validReply = validEditReplyForDisplay(result, status.revision);
const validResult = request.mode === "draft" ? validDraft : validReply;
- if (status.state === "pending") return result === null;
+ if (["pending", "failed"].includes(status.state)) return result === null;
if (["ready", "consumed"].includes(status.state)) return !!validResult;
return result === null || !!validResult;
}