import { readFileSync } from "node:fs"; import { expect, it, vi } from "vitest"; import { gradePiRestartCompletion, observePiRestartPending, runPiPendingControllerRestart } from "./pi-native-restart-flow.js"; import { validatePrpStructuredRunResult } from "../../packages/paperclip-runner/src/protocol/replay-contract.js"; type Row = Record; vi.mock("@playwright/test", () => ({ expect: (locator: { count(): number }) => ({ toHaveCount: async (expected: number) => expect(locator.count()).toBe(expected), }) })); function fixture(adapter = "acpx-runtime-sidecar") { const questionSet = { schema: "paperclip.question_set.v1", questions: [{ id: "answer", header: "Pi native restart", prompt: "Answer", answerMode: "text", required: true }] }; const request = { schema: "paperclip.runtime_request.v2", requestKind: "runtime", type: "input", status: "pending", requestId: "request", turnId: "turn", itemId: "request", input: questionSet, origin: { adapter, provider: "pi", method: "elicitation/create" } }; const wrap = (eventType: string, sourceSeq: number, payload: Row): Row => ({ companyId: "company", runId: "run", seq: sourceSeq, protocolSchemaVersion: 1, eventType, payload: { prpEvent: { schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", eventType, runId: "run", turnId: "turn", itemId: "request", normalizedSessionId: "session", sourceInstanceId: "runner", sourceSeq, sourceEventId: `runner:run:${sourceSeq}`, payload } }, }); const state = { issue: { id: "issue", companyId: "company", status: "in_progress" }, runs: [{ id: "run", companyId: "company", nativeIssueId: "issue", runtimeMode: "native", status: "running", nativeSessionId: "session", runnerInstanceId: "runner", processPid: 200, processGroupId: 200, processStartedAt: "2026-10-02T13:55:27.000Z" } as Row], interactions: [{ id: "interaction", companyId: "company", issueId: "issue", kind: "ask_user_questions", status: "pending", result: null, sourceRunId: "run", continuationPolicy: "none", resolverPolicy: "human_only", idempotencyKey: "paperclip-runner-question:run:request", payload: { runtimeRequestId: "request", questionSet } } as Row] }; const events = [wrap("runtime_request.created", 1, { request })]; const control = (eventType: string, sourceSeq: number, payload: Row): Row => { const row = wrap(eventType, sourceSeq + 4, payload), event = row.payload.prpEvent; Object.assign(event, { sourceKind: "control_plane", sourceInstanceId: "runner:control", sourceSeq, sourceEventId: `runner:control:run:${sourceSeq}` }); delete event.itemId; Object.assign(row, { sourceInstanceId: event.sourceInstanceId, sourceSeq, sourceEventId: event.sourceEventId }); return row; }; function finish(answer = "hidden") { state.issue.status = "done"; state.runs[0]!.status = "succeeded"; Object.assign(state.interactions[0]!, { status: "answered", result: { answers: [{ questionId: "answer", optionIds: [], otherText: answer }] } }); const validated = validatePrpStructuredRunResult({ summary: "Restart question answered", reportedWorkDisposition: "done", completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }], remainingWork: [] }, evidence: [], verification: [] }); if (!validated.ok) throw new Error("Fixture finish result must satisfy the actual production schema"); const result = validated.result; events.push(wrap("runtime_request.resolved", 2, { requestId: "request", turnId: "turn", itemId: "request", action: "submit", response: { schema: "paperclip.question_response.v1", answers: { answer: { text: answer } } } }), wrap("turn.completed", 4, { status: "completed" }), wrap("run.result.proposed", 3, structuredClone(result)), control("run.result.accepted", 1, { result: structuredClone(result) }), control("run.terminal", 2, { schema: "paperclip.prp.terminal.v1", turnTerminalState: "completed", runTerminalState: "succeeded", reportedWorkDisposition: "done" })); return { status: "answered", value: answer }; } return { state, events, request, wrap, control, finish }; } it.each(["acpx-runtime", "acpx-runtime-sidecar"])("accepts mixed runner/control-plane production completion via %s", adapter => { const f = fixture(adapter), pending = observePiRestartPending(f.state, f.events, "company"), proof = f.finish(); expect(gradePiRestartCompletion(f.state, f.events, pending, "hidden", proof)).toBe(true); }); function actualPendingPrefix() { return JSON.parse(readFileSync(new URL("./fixtures/pi-native-restart-actual-prefix.json", import.meta.url), "utf8")) as { state: { issue: Row; runs: Row[]; interactions: Row[] }; events: Row[]; }; } it.each(["omitted", "null"])("accepts the actual pre-start submission with %s turn ID", representation => { const f = actualPendingPrefix(); if (representation === "null") f.events[0]!.payload.prpEvent.turnId = null; expect(observePiRestartPending(f.state, f.events, "company")).toMatchObject({ runId: "run", turnId: "turn", nativeSessionId: "session", sourceInstanceId: "runner", createdSourceSeq: 195, }); }); it.each([ ["missing start", (f: ReturnType) => { f.events.splice(1, 1); }], ["start without assigned turn", (f: ReturnType) => { delete f.events[1]!.payload.prpEvent.turnId; }], ["start with foreign turn", (f: ReturnType) => { f.events[1]!.payload.prpEvent.turnId = "other"; }], ["foreign submission turn", (f: ReturnType) => { f.events[0]!.payload.prpEvent.turnId = "other"; }], ["foreign submission session", (f: ReturnType) => { f.events[0]!.payload.prpEvent.normalizedSessionId = "other"; }], ["foreign submission producer", (f: ReturnType) => { Object.assign(f.events[0]!.payload.prpEvent, { sourceInstanceId: "other", sourceEventId: "other:run:6" }); }], ["submission after start", (f: ReturnType) => { f.events[0]!.seq = 29; Object.assign(f.events[0]!.payload.prpEvent, { sourceSeq: 8, sourceEventId: "runner:run:8" }); }], ["start after request", (f: ReturnType) => { f.events[1]!.seq = 223; Object.assign(f.events[1]!.payload.prpEvent, { sourceSeq: 196, sourceEventId: "runner:run:196" }); }], ["duplicate submission", (f: ReturnType) => { const row = structuredClone(f.events[0]!); row.seq = 26; Object.assign(row.payload.prpEvent, { sourceSeq: 7, sourceEventId: "runner:run:7" }); Object.assign(f.events[1]!.payload.prpEvent, { sourceSeq: 8, sourceEventId: "runner:run:8" }); f.events.push(row); }], ["duplicate start", (f: ReturnType) => { const row = structuredClone(f.events[1]!); row.seq = 29; Object.assign(row.payload.prpEvent, { sourceSeq: 8, sourceEventId: "runner:run:8" }); f.events.push(row); }], ["later unbound turn event", (f: ReturnType) => { const row = structuredClone(f.events[1]!); row.seq = 29; row.eventType = "turn.progress"; Object.assign(row.payload.prpEvent, { eventType: "turn.progress", turnId: null, sourceSeq: 8, sourceEventId: "runner:run:8" }); f.events.push(row); }], ] as const)("rejects %s around an unbound submission", (_label, mutate) => { const f = actualPendingPrefix(); mutate(f); expect(() => observePiRestartPending(f.state, f.events, "company")).toThrow("Pi native restart"); }); it.each([ ["no native event", (f: ReturnType) => { f.events.length = 0; }], ["wrong origin", (f: ReturnType) => { f.request.origin.provider = "cursor"; }], ["semantic origin", (f: ReturnType) => { f.request.origin.adapter = "semantic"; }], ["internal kind instead of public schema", (f: ReturnType) => { f.request.requestKind = "elicitation"; }], ["missing native session", (f: ReturnType) => { delete f.state.runs[0]!.nativeSessionId; }], ["foreign request turn", (f: ReturnType) => { f.request.turnId = "other"; }], ["foreign company", (f: ReturnType) => { f.state.interactions[0]!.companyId = "other"; }], ["durable fallback", (f: ReturnType) => { f.state.interactions[0]!.continuationPolicy = "wake_assignee"; }], ["wrong idempotency identity", (f: ReturnType) => { f.state.interactions[0]!.idempotencyKey = "other"; }], ["duplicate interaction", (f: ReturnType) => { f.state.interactions.push(structuredClone(f.state.interactions[0]!)); }], ["duplicate source receipt", (f: ReturnType) => { f.events.push(structuredClone(f.events[0]!)); }], ["already answered", (f: ReturnType) => { f.finish(); }], ] as const)("rejects %s before restart", (_label, mutate) => { const f = fixture(); mutate(f); expect(() => observePiRestartPending(f.state, f.events, "company")).toThrow("Pi native restart"); }); it.each([ ["replacement run", (f: ReturnType) => { f.state.runs[0]!.id = "replacement"; }], ["additional run", (f: ReturnType) => { f.state.runs.push({ ...f.state.runs[0], id: "replayed" }); }], ["replacement native session", (f: ReturnType) => { f.state.runs[0]!.nativeSessionId = "replacement"; }], ["replacement producer", (f: ReturnType) => { f.state.runs[0]!.runnerInstanceId = "replacement"; }], ["replaced interaction", (f: ReturnType) => { f.state.interactions[0]!.id = "replacement"; }], ["missing resolution", (f: ReturnType) => { f.events.splice(1, 1); }], ["duplicate resolution", (f: ReturnType) => { f.events.push(structuredClone(f.events[1]!)); }], ["replayed creation", (f: ReturnType) => { f.events.push(f.wrap("runtime_request.created", 4, { request: f.request })); }], ["cancelled callback", (f: ReturnType) => { f.events[1]!.eventType = f.events[1]!.payload.prpEvent.eventType = "runtime_request.cancelled"; }], ["expired callback", (f: ReturnType) => { f.events[1]!.eventType = f.events[1]!.payload.prpEvent.eventType = "runtime_request.expired"; }], ["wrong delivered answer", (f: ReturnType) => { f.events[1]!.payload.prpEvent.payload.response.answers.answer.text = "guess"; }], ["wrong durable answer", (f: ReturnType) => { f.state.interactions[0]!.result.answers[0].otherText = "guess"; }], ["wrong response turn", (f: ReturnType) => { f.events[1]!.payload.prpEvent.payload.turnId = "other"; }], ["missing resolution envelope turn", (f: ReturnType) => { delete f.events[1]!.payload.prpEvent.turnId; }], ["missing resolution item", (f: ReturnType) => { delete f.events[1]!.payload.prpEvent.itemId; }], ["rewritten canonical question", (f: ReturnType) => { f.request.input.questions[0]!.prompt = "Replacement prompt"; }], ["new native turn", (f: ReturnType) => { f.events[2]!.payload.prpEvent.turnId = "other"; }], ["failed terminal payload", (f: ReturnType) => { f.events[2]!.payload.prpEvent.payload.status = "failed"; }], ["missing terminal", (f: ReturnType) => { f.events.splice(2, 1); }], ["duplicate terminal", (f: ReturnType) => { f.events.push(f.wrap("turn.completed", 4, {})); }], ["missing control-plane acceptance", (f: ReturnType) => { f.events.splice(4, 1); }], ["missing control-plane terminal", (f: ReturnType) => { f.events.pop(); }], ["foreign control-plane source", (f: ReturnType) => { f.events[4]!.payload.prpEvent.sourceInstanceId = "other:control"; }], ["foreign control-plane turn", (f: ReturnType) => { f.events[4]!.payload.prpEvent.turnId = "other"; }], ["missing control-plane turn", (f: ReturnType) => { delete f.events[4]!.payload.prpEvent.turnId; }], ["foreign control-plane session", (f: ReturnType) => { f.events[4]!.payload.prpEvent.normalizedSessionId = "other"; }], ["mismatched durable control source", (f: ReturnType) => { f.events[4]!.sourceSeq = 5; }], ["duplicate control receipt", (f: ReturnType) => { f.events.push(structuredClone(f.events[4]!)); }], ["control plane impersonates a native turn", (f: ReturnType) => { f.events.push(f.control("turn.completed", 3, { status: "completed" })); }], ["failed control terminal", (f: ReturnType) => { f.events[5]!.payload.prpEvent.payload.runTerminalState = "failed"; }], ["unaccepted native result", (f: ReturnType) => { f.events[4]!.payload.prpEvent.payload.result.summary = "Replacement result"; }], ["malformed accepted result", (f: ReturnType) => { delete f.events[4]!.payload.prpEvent.payload.result.schema; }], ["runner impersonates acceptance", (f: ReturnType) => { f.events[4]!.payload.prpEvent.sourceKind = "runner"; }], ] as const)("rejects %s despite a correct workspace answer", (_label, mutate) => { const f = fixture(), pending = observePiRestartPending(f.state, f.events, "company"), proof = f.finish(); mutate(f); expect(() => gradePiRestartCompletion(f.state, f.events, pending, "hidden", proof)).toThrow(); }); it.each([null, {}, { status: "answered", value: "guess" }, { status: "cancelled", value: "hidden" }, { status: "answered", value: "hidden", invented: true }])( "rejects missing or wrong independent file proof %j", proof => { const f = fixture(), pending = observePiRestartPending(f.state, f.events, "company"); f.finish(); expect(() => gradePiRestartCompletion(f.state, f.events, pending, "hidden", proof)).toThrow(); }, ); async function flow(failure?: "replaced" | "wrong-file" | "missing-resolution" | "replaced-runner", issueRef = "issue") { const f = fixture(), evidence = new Map(), checkpoints: string[] = []; Object.assign(f.state.issue, { identifier: "RUN-1" }); let answer = "", restarts = 0, submissions = 0, proof: unknown, resolvePost: ((value: unknown) => void) | undefined; let postPredicate: ((value: any) => boolean) | undefined; const composer: any = { count: () => 1, filter: () => composer, locator: () => composer, first: () => composer, fill: async (value: string) => { expect(restarts).toBe(1); answer = value; } }; const button: any = { count: () => 1, filter: () => button, click: async () => { expect(restarts).toBe(1); expect(answer).toMatch(/^PI-RESTART-[a-f0-9]{32}$/); submissions++; const request = { url: () => `http://fixture/api/issues/${issueRef}/interactions/interaction/respond`, method: () => "POST", postDataJSON: () => ({ answers: [{ questionId: "answer", optionIds: [], otherText: answer }] }) }; expect(postPredicate!({ ...request, url: () => "http://fixture/api/issues/OTHER-1/interactions/interaction/respond" })).toBe(false); expect(postPredicate!({ ...request, url: () => `http://fixture/api/issues/${issueRef}/interactions/other/respond` })).toBe(false); expect(postPredicate!({ ...request, method: () => "GET" })).toBe(false); expect(postPredicate!(request)).toBe(true); resolvePost!(request); proof = f.finish(answer); if (failure === "missing-resolution") f.events.splice(1, 1); } }; const page: any = { getByTestId: () => composer, getByRole: () => button, reload: async () => { checkpoints.push("reload"); }, waitForRequest: (predicate: (value: any) => boolean) => { postPredicate = predicate; return new Promise(resolve => { resolvePost = resolve; }); } }; const result = runPiPendingControllerRestart({ page, companyId: "company", deadlineAt: Date.now() + 1000, load: async () => structuredClone(f.state), events: async () => structuredClone(f.events), restart: async identity => { expect(identity).toEqual({ processPid: 200, processGroupId: 200, processStartedAt: "2026-10-02T13:55:27.000Z" }); expect(submissions).toBe(0); expect(f.state.runs[0]!.status).toBe("running"); expect(f.state.interactions[0]!.status).toBe("pending"); expect(answer).toBe(""); restarts++; checkpoints.push("restart"); if (failure === "replaced") f.state.interactions[0]!.id = "replacement"; if (failure === "replaced-runner") f.state.runs[0]!.processPid = f.state.runs[0]!.processGroupId = 201; }, settle: async () => structuredClone(f.state), readProof: async () => failure === "wrong-file" ? { status: "answered", value: "guess" } : proof, capture: async id => { checkpoints.push(id); }, evidence: async (name, data) => { evidence.set(name, structuredClone(data)); }, }); if (failure) await expect(result).rejects.toThrow("Pi native restart"); else expect((await result).every(check => check.passed)).toBe(true); expect(restarts).toBe(1); expect(submissions).toBe(["replaced", "replaced-runner"].includes(failure ?? "") ? 0 : 1); expect(checkpoints.slice(0, 3)).toEqual(["pi-restart-pending", "restart", "reload"]); expect(evidence.has("pi-native-restart-before.json")).toBe(true); expect(evidence.has("pi-native-restart-after.json")).toBe(true); expect(evidence.has("pi-native-restart-checks.json")).toBe(true); expect(JSON.stringify(evidence.get("pi-native-restart-before.json"))).not.toContain("PI-RESTART-"); if (!["replaced", "replaced-runner"].includes(failure ?? "")) expect(evidence.has("pi-native-restart-final.json")).toBe(true); } it.each(["issue", "RUN-1"])("restarts while unanswered and submits through the exact %s browser route", issueRef => flow(undefined, issueRef)); it.each(["replaced", "wrong-file", "missing-resolution", "replaced-runner"] as const)("retains evidence and fails whole flow on %s", failure => flow(failure));