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 }));