promptward

Plan 003: Make the error-modal Retry path honor the review contract

Executor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving to the next step. If anything in the “STOP conditions” section occurs, stop and report — do not improvise. When done, update the status row for this plan in plans/README.md — unless a reviewer dispatched you and told you they maintain the index.

Drift check (run first): git diff --stat a6293e1..HEAD -- src/content.ts tests/content-flow.test.ts Plans 001 (new test file) and 002 (IME guard in onKeydownCapture) are expected predecessors. Any OTHER drift in protectAndMaybeSubmit / handleFailure versus the excerpts below is a STOP condition.

Status

Why this matters

PromptWard’s product contract (README: “Censor by default, never silent”) is that redacted text is only sent after the user sees a review modal. The error-modal Retry path breaks this twice:

  1. If the retry redaction succeeds with changes, the code applies the redacted text and replays the send immediately — no review modal. The user clicked “Retry”, not “Send”; their prompt leaves without review.
  2. If the retry fails again, the function returns silently: the modal is gone, the send was already blocked, and the user gets no feedback at all — a dead end that looks like the site is broken.

The fix routes the retry result through the exact same decision flow as a first-time protect result, by extracting that flow into one shared function.

Current state

Commands you will need

Purpose Command Expected on success
Typecheck npx tsc -p tsconfig.json --noEmit exit 0
Tests npm test all pass
One file npx vitest run tests/content-flow.test.ts all pass

Scope

In scope:

Out of scope:

Git workflow

Steps

Step 1: Extract the response-decision flow

In src/content.ts, extract lines 164–230 (everything from the if (!response.ok) check through the cancel branch, inclusive) into:

async function handleProtectResponse(
  response: ProtectTextResponse,
  original: string,
  trigger: HTMLElement,
  editor: EditorHandle,
  debugId: string
): Promise<void>

protectAndMaybeSubmit then becomes: read editor → guards → protectText → log → await handleProtectResponse(...), keeping the inFlight try/finally where it is. The extracted function’s !response.ok branch calls handleFailure(...) exactly as today. Behavior after this step must be identical — this is a pure extraction.

Verify: npx tsc -p tsconfig.json --noEmit → exit 0. Verify: npx vitest run tests/content-flow.test.ts → all pass unchanged (including the old-behavior retry test — it still passes because behavior hasn’t changed yet).

Step 2: Route retry through the shared flow

Rewrite handleFailure to:

async function handleFailure(error: string, original: string, trigger: HTMLElement, editor: EditorHandle, debugId: string): Promise<void> {
  const decision = await showReviewModal({ original, error });
  if (decision !== "retry") return;
  const response = await protectText(original, debugId);
  await logDebug({
    debugId,
    stage: "retry-protect-response",
    level: response.ok ? "debug" : "error",
    metadata: { ok: response.ok, changed: response.ok ? response.changed : undefined, error: response.error }
  });
  await handleProtectResponse(response, original, trigger, editor, debugId);
}

Consequences (all intended): retry success with changes now shows the review modal; retry failure now recursively shows the error modal again (bounded by requiring a user click per cycle); retry success unchanged still replays directly.

Verify: npx tsc -p tsconfig.json --noEmit → exit 0. The old-behavior retry test in tests/content-flow.test.ts now FAILS — expected; fixed next.

Step 3: Rewrite the retry tests

Replace the "error-modal retry (current behavior — see plans/003)" block with "error-modal retry" covering:

  1. Retry success with changes shows the review modal: fail → modal → click Retry with next response { ok: true, changed: true, safeText: "safe" } → assert a review modal (has [data-action='original']) appears and NO replay click has happened yet → auto-confirm → textarea is "safe", replay click observed.
  2. Retry failure re-shows the error modal: fail → Retry with next response also failing → assert a new error modal appears (no [data-action='original']) → click Cancel → no replay ever, textarea unchanged.
  3. Retry success unchanged replays without a modal (unchanged behavior): fail → Retry with { ok: true, changed: false, safeText: original } → replay click observed, no second modal.

Verify: npx vitest run tests/content-flow.test.ts → all pass.

Step 4: Full suite

Verify: npm test → exit 0.

Test plan

Step 3 is the test plan; model on the existing modal-interaction tests in tests/content-flow.test.ts (shadow-root query + fake timers).

Done criteria

STOP conditions

Stop and report back if:

Maintenance notes