diff --git a/tests/runner-e2e/continuation-approval.spec.ts b/tests/runner-e2e/continuation-approval.spec.ts
new file mode 100644
index 00000000000..057797ea894
--- /dev/null
+++ b/tests/runner-e2e/continuation-approval.spec.ts
@@ -0,0 +1,63 @@
+import { createServer } from "node:http";
+import { expect, test, type APIRequestContext } from "@playwright/test";
+import { confirmContinuationPlan, type ContinuationApproval } from "./continuation-approval.js";
+import type { ContinuationCheckpoint } from "./continuation-scoring.js";
+
+async function fixture(request: APIRequestContext) {
+ const card: ContinuationApproval = { id: "current", issueId: "parent", kind: "request_confirmation", status: "pending",
+ resolverPolicy: "human_only", effectiveResolverPolicy: "human_only", continuationPolicy: "wake_assignee_on_accept",
+ payload: { acceptLabel: "Confirm", target: { type: "issue_document", issueId: "parent", key: "plan", revisionId: "revision-2" } } };
+ const documents = [{ key: "plan", body: "Revised plan", latestRevisionId: "revision-2" }];
+ const checkpoint: ContinuationCheckpoint = { phase: "revised", issue: { id: "parent", status: "in_review" },
+ children: [], comments: [], attachments: [], runs: [], documents: structuredClone(documents), interactions: [structuredClone(card)] };
+ const acceptedIds: string[] = [];
+ const server = createServer((req, res) => {
+ if (req.method === "GET" && req.url === "/api/issues/parent/interactions") {
+ res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify([card]));
+ } else if (req.method === "GET" && req.url === "/api/issues/parent/documents") {
+ res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(documents));
+ } else if (req.method === "POST" && req.url?.endsWith("/accept")) {
+ const id = req.url.split("/").at(-2)!;
+ acceptedIds.push(id);
+ if (id === card.id) Object.assign(card, { status: "accepted", result: { outcome: "accepted" },
+ resolvedByUserId: "fixture-user", resolvedByAgentId: null, resolvedAt: new Date().toISOString() });
+ res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(card));
+ } else {
+ res.writeHead(200, { "content-type": "text/html" });
+ res.end(`
+
Approve revised plan
+
+
`);
+ }
+ });
+ await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("Missing fixture listener");
+ const origin = `http://127.0.0.1:${address.port}`;
+ return { card, documents, checkpoint, acceptedIds, origin,
+ api: { get: async (path: string) => { const response = await request.get(`${origin}${path}`); expect(response.ok()).toBe(true); return response.json() as Promise; } },
+ close: async () => { server.closeAllConnections(); await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); } };
+}
+
+test("uses the exact revised card's real Confirm action and reads its saved human verdict", async ({ page, request }) => {
+ const f = await fixture(request);
+ try {
+ await page.goto(f.origin);
+ const saved = await confirmContinuationPlan({ page, api: f.api, checkpoint: f.checkpoint, deadlineAt: Date.now() + 5000 });
+ expect(f.acceptedIds).toEqual(["current"]);
+ expect(saved).toMatchObject({ id: "current", status: "accepted", resolverPolicy: "human_only", resolvedByUserId: "fixture-user", result: { outcome: "accepted" } });
+ expect(saved.payload.target?.revisionId).toBe("revision-2");
+ } finally { await f.close(); }
+});
+
+test("refuses a plan revised after the checkpoint without approving either card", async ({ page, request }) => {
+ const f = await fixture(request);
+ try {
+ f.documents[0]!.latestRevisionId = "revision-3";
+ await page.goto(f.origin);
+ await expect(confirmContinuationPlan({ page, api: f.api, checkpoint: f.checkpoint, deadlineAt: Date.now() + 5000 }))
+ .rejects.toThrow(/current plan revision/);
+ expect(f.acceptedIds).toEqual([]);
+ expect(f.card.status).toBe("pending");
+ } finally { await f.close(); }
+});
diff --git a/tests/runner-e2e/continuation-approval.test.ts b/tests/runner-e2e/continuation-approval.test.ts
new file mode 100644
index 00000000000..06ae216dd88
--- /dev/null
+++ b/tests/runner-e2e/continuation-approval.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+import { continuationApprovalAccepted, currentContinuationApproval, type ContinuationApproval } from "./continuation-approval.js";
+import type { ContinuationCheckpoint } from "./continuation-scoring.js";
+
+function recording() {
+ const card: ContinuationApproval = { id: "revised-card", issueId: "parent", kind: "request_confirmation", status: "pending",
+ resolverPolicy: "human_only", effectiveResolverPolicy: "human_only", continuationPolicy: "wake_assignee_on_accept",
+ payload: { acceptLabel: "Confirm", target: { type: "issue_document", issueId: "parent", key: "plan", revisionId: "revised" } } };
+ const checkpoint: ContinuationCheckpoint = { phase: "revised", issue: { id: "parent", status: "in_review" },
+ children: [], attachments: [], comments: [], runs: [],
+ documents: [{ key: "plan", body: "Use the revised word after approval.", latestRevisionId: "revised" }],
+ interactions: [{ ...card, id: "old-card", status: "expired", payload: { target: { ...card.payload.target, revisionId: "old" } } }, card] };
+ return { card, checkpoint };
+}
+
+describe("continuation plan human approval", () => {
+ it("selects the current revision while preserving the expired prior request", () => {
+ const { card, checkpoint } = recording();
+ expect(currentContinuationApproval(checkpoint)).toBe(card);
+ expect((checkpoint.interactions[0] as ContinuationApproval).status).toBe("expired");
+ });
+
+ it("rejects approval of a stale revision before a browser action", () => {
+ const { card, checkpoint } = recording();
+ card.payload.target!.revisionId = "old";
+ expect(() => currentContinuationApproval(checkpoint)).toThrow(/current plan revision/);
+ });
+
+ it("requires the exact recorded human acceptance, not dismissal or an agent claim", () => {
+ const { card } = recording();
+ const dismissed = { ...card, dismissedAt: "2026-11-04T21:41:02Z", dismissedByUserId: "user" };
+ expect(continuationApprovalAccepted(card, dismissed)).toBe(false);
+ const accepted = { ...card, status: "accepted", result: { outcome: "accepted" },
+ resolvedByUserId: "user", resolvedAt: "2026-11-04T21:41:03Z" };
+ expect(continuationApprovalAccepted(card, accepted)).toBe(true);
+ expect(continuationApprovalAccepted(card, { ...accepted, resolvedByUserId: null, resolvedByAgentId: "agent" })).toBe(false);
+ expect(continuationApprovalAccepted(card, { ...accepted, payload: { target: { ...card.payload.target!, revisionId: "old" } } })).toBe(false);
+ });
+});
diff --git a/tests/runner-e2e/continuation-approval.ts b/tests/runner-e2e/continuation-approval.ts
new file mode 100644
index 00000000000..fd52e06638b
--- /dev/null
+++ b/tests/runner-e2e/continuation-approval.ts
@@ -0,0 +1,82 @@
+import { expect, type Page } from "@playwright/test";
+import { pollUntil, type RunnerApi } from "./api.js";
+import { isContinuationPlan, type ContinuationCheckpoint } from "./continuation-scoring.js";
+
+type Target = { type: string; issueId?: string; key?: string; revisionId?: string };
+export interface ContinuationApproval {
+ id: string;
+ issueId: string;
+ kind: string;
+ status: string;
+ resolverPolicy?: string;
+ effectiveResolverPolicy?: string;
+ continuationPolicy?: string;
+ payload: { acceptLabel?: string; target?: Target };
+ resolvedByUserId?: string | null;
+ resolvedByAgentId?: string | null;
+ resolvedAt?: string | null;
+ result?: { outcome?: string } | null;
+}
+
+/** Select the exact current saved proposal before using its UI. */
+export function currentContinuationApproval(checkpoint: ContinuationCheckpoint): ContinuationApproval {
+ const pending = (checkpoint.interactions as ContinuationApproval[])
+ .filter(i => i.kind === "request_confirmation" && i.status === "pending");
+ expect(pending, "one current plan confirmation must await the user's decision").toHaveLength(1);
+ const card = pending[0]!;
+ const target = card.payload.target;
+ expect(card.issueId).toBe(checkpoint.issue.id);
+ expect(target).toMatchObject({ type: "issue_document", issueId: checkpoint.issue.id });
+ const document = checkpoint.documents.find(d => d.key === target?.key);
+ expect(document && isContinuationPlan(document, checkpoint), "approval must target the recorded plan").toBeTruthy();
+ expect(document?.latestRevisionId, "the plan must have an immutable current revision").toBeTruthy();
+ expect(target?.revisionId, "approval must target the current plan revision").toBe(document!.latestRevisionId);
+ return card;
+}
+
+function sameTarget(left: Target | undefined, right: Target | undefined) {
+ return Boolean(left && right && left.type === right.type && left.issueId === right.issueId
+ && left.key === right.key && left.revisionId === right.revisionId);
+}
+
+export function continuationApprovalAccepted(expected: ContinuationApproval, saved: ContinuationApproval | undefined) {
+ return Boolean(saved && saved.id === expected.id && saved.issueId === expected.issueId
+ && saved.kind === "request_confirmation" && saved.status === "accepted"
+ && saved.result?.outcome === "accepted" && sameTarget(expected.payload.target, saved.payload.target)
+ && saved.resolvedByUserId?.trim() && !saved.resolvedByAgentId
+ && saved.resolvedAt && Number.isFinite(Date.parse(saved.resolvedAt)));
+}
+
+/** A chat comment/dismissal is not a human-only card response. Use its real UI. */
+export async function confirmContinuationPlan(input: {
+ page: Page;
+ api: Pick;
+ checkpoint: ContinuationCheckpoint;
+ deadlineAt: number;
+}) {
+ const expected = currentContinuationApproval(input.checkpoint);
+ const path = `/api/issues/${input.checkpoint.issue.id}`;
+ const [interactions, documents] = await Promise.all([
+ input.api.get(`${path}/interactions`),
+ input.api.get(`${path}/documents`),
+ ]);
+ const current = currentContinuationApproval({ ...input.checkpoint, interactions, documents });
+ expect(current.id, "the reviewed confirmation must still be current").toBe(expected.id);
+ expect(sameTarget(current.payload.target, expected.payload.target), "the reviewed plan must not change before approval").toBe(true);
+ const card = input.page.getByTestId("task-chat-composer-takeover")
+ .locator(`[id=${JSON.stringify(`interaction-${current.id}`)}]`);
+ await expect(card, "the exact current confirmation must be visible").toBeVisible();
+ await card.getByRole("button", { name: current.payload.acceptLabel ?? "Approve", exact: true }).click();
+ const saved = await pollUntil({
+ label: "continuation plan human acceptance",
+ deadlineAt: Math.min(input.deadlineAt, Date.now() + 30_000),
+ intervalMs: 250,
+ load: async () => (await input.api.get(`${path}/interactions`)).find(i => i.id === current.id),
+ accept: result => continuationApprovalAccepted(current, result),
+ reject: result => result && (!sameTarget(current.payload.target, result.payload.target)
+ || !["pending", "accepted"].includes(result.status)
+ || result.status === "accepted" && !continuationApprovalAccepted(current, result))
+ ? "the reviewed confirmation changed or did not record human acceptance" : undefined,
+ });
+ return saved!;
+}
diff --git a/tests/runner-e2e/continuation-flow.ts b/tests/runner-e2e/continuation-flow.ts
index f93853b0c9a..df999ff2537 100644
--- a/tests/runner-e2e/continuation-flow.ts
+++ b/tests/runner-e2e/continuation-flow.ts
@@ -3,6 +3,7 @@ import { gradeLegacyWorkspaceMigration, migrationPrimaryOutputCheckpoints, isLeg
import { necessaryQuestionContract } from "./continuation-cases.js";
import { validResumeQuestionForm, validResumeReferenceForm } from "./question-resume-scoring.js";
import { answerResumeQuestionThroughUi } from "./question-resume-ui.js";
+import { confirmContinuationPlan } from "./continuation-approval.js";
import { gradeLifecycleBaseline, type LifecycleCheckpoint } from "./lifecycle-baseline.js";
import { lifecycleLiveCase, lifecycleLiveContinuation, gradeLifecycleNarrative } from "./lifecycle-live-cases.js";
import { prepareLegacyContinuationSkill, prepareContinuationBudget } from "./continuation-fixtures.js";
@@ -308,7 +309,12 @@ export async function runContinuationFlow(input: {
scenario.id === "revision-preserves-approval" ? "revised" : "answered",
);
assertWaiting();
- await reply(scenario.approval);
+ if (scenario.id === "revision-preserves-approval") {
+ const before = new Set(runs.map(r => r.id));
+ const accepted = await confirmContinuationPlan({ page, api, checkpoint: checkpoints.at(-1)!, deadlineAt: input.deadlineAt });
+ await input.evidence("continuation-plan-approval.json", accepted);
+ await settle(before);
+ } else await reply(scenario.approval);
}
if (migration) migrationAfter = await readMigrationPoint({ api, companyId: fixtures.company.id, agentId: fixtures.agent.id, issueId: issue!.id });
await snapshot("final");
diff --git a/tests/runner-e2e/playwright-support.config.ts b/tests/runner-e2e/playwright-support.config.ts
index ca73b10620d..3e2887eed4c 100644
--- a/tests/runner-e2e/playwright-support.config.ts
+++ b/tests/runner-e2e/playwright-support.config.ts
@@ -3,7 +3,7 @@ import { defineConfig } from "@playwright/test";
/** Browser-only harness regressions: no Paperclip instance or provider credentials. */
export default defineConfig({
testDir: ".",
- testMatch: ["hire-delegate-composer.spec.ts", "question-resume-ui.spec.ts", "clarification-readback.spec.ts", "project-context-readback.spec.ts", "skill-studio-flow.spec.ts", "native-task-reply.spec.ts", "screenshot-readiness.spec.ts", "service-worker-reload.spec.ts", "lost-send.spec.ts", "chat-restart.spec.ts", "settings-toggle.spec.ts", "browser-bootstrap-diagnostics.spec.ts", "service-worker-module-loading.spec.ts", "browser-bootstrap-recovery.spec.ts", "blocker-input.spec.ts", "native-blocker-visible.spec.ts", "copilot-permission-card.spec.ts", "kimi-permission-card.spec.ts", "github-review-check.spec.ts", "antigravity-journey-browser.spec.ts", "task-search-filter.spec.ts", "task-document-navigation.spec.ts"],
+ testMatch: ["hire-delegate-composer.spec.ts", "question-resume-ui.spec.ts", "clarification-readback.spec.ts", "project-context-readback.spec.ts", "skill-studio-flow.spec.ts", "native-task-reply.spec.ts", "screenshot-readiness.spec.ts", "service-worker-reload.spec.ts", "lost-send.spec.ts", "chat-restart.spec.ts", "continuation-approval.spec.ts", "settings-toggle.spec.ts", "browser-bootstrap-diagnostics.spec.ts", "service-worker-module-loading.spec.ts", "browser-bootstrap-recovery.spec.ts", "blocker-input.spec.ts", "native-blocker-visible.spec.ts", "copilot-permission-card.spec.ts", "kimi-permission-card.spec.ts", "github-review-check.spec.ts", "antigravity-journey-browser.spec.ts", "task-search-filter.spec.ts", "task-document-navigation.spec.ts"],
workers: 1,
retries: 0,
timeout: 10_000,
diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts
index 730eebfb2a2..c0c4524b1a2 100644
--- a/tests/runner-e2e/runner.spec.ts
+++ b/tests/runner-e2e/runner.spec.ts
@@ -1262,7 +1262,7 @@ for (const execution of executions) {
} else if (execution.task.flow === "continuation") {
const continuation = await runContinuationFlow({
page, api, fixtures, execution, nonce, secrets, workspacePath, deadlineAt: startedAtMs + deadlineMs - 60_000,
- restart: () => restartIsolatedPaperclipServer({ api, requestId: `continuation-${nonce}`, deadlineAt: startedAtMs + deadlineMs }),
+ restart: () => restartChatServer(page, () => restartIsolatedPaperclipServer({ api, requestId: `continuation-${nonce}`, deadlineAt: startedAtMs + deadlineMs })),
observe: (currentIssue, currentRuns, checks) => {
issue = currentIssue; selectedRuns = currentRuns;
matcherResults = checks.map(check => ({ matcher: { kind: "json_path" as const, path: `continuation.${check.id}`, expected: true }, passed: check.passed, detail: check.detail }));