From ad83416b03f3320a192f02ea8d875ec7f3851edd Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 2 Oct 2026 06:38:22 -0500 Subject: [PATCH] test(runner): recognize pre-start Pi submission identity Co-Authored-By: Paperclip --- tests/runner-e2e/FIXTURES.md | 5 + .../pi-native-restart-actual-prefix.json | 182 ++++++++++++++++++ .../runner-e2e/pi-native-restart-flow.test.ts | 51 +++++ tests/runner-e2e/pi-native-restart-flow.ts | 14 +- 4 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 tests/runner-e2e/fixtures/pi-native-restart-actual-prefix.json diff --git a/tests/runner-e2e/FIXTURES.md b/tests/runner-e2e/FIXTURES.md index 6cab7446227..4b5e075e311 100644 --- a/tests/runner-e2e/FIXTURES.md +++ b/tests/runner-e2e/FIXTURES.md @@ -314,6 +314,11 @@ resolution, one original successful turn and independently read exact workspace JSON prove delivery. A replacement run, replay, cancellation, expiry, rewritten request or merely reloaded browser cannot pass. Full states and PRP identities stay in private snapshots under the existing publication allowlist. +The local runner's unique `turn.submitted` receipt may precede assignment of the +provider turn ID. The oracle accepts that missing ID only before the single +matching `turn.started` and request creation, with the same session and producer +and increasing durable/source sequence numbers. Later missing or changed turn +identities still fail. ### Pi file editing and registered artifacts diff --git a/tests/runner-e2e/fixtures/pi-native-restart-actual-prefix.json b/tests/runner-e2e/fixtures/pi-native-restart-actual-prefix.json new file mode 100644 index 00000000000..9f5707adddb --- /dev/null +++ b/tests/runner-e2e/fixtures/pi-native-restart-actual-prefix.json @@ -0,0 +1,182 @@ +{ + "description": "Sanitized identity-only projection of Pi 1.0.0 profile13 pending-input stream; original submission omits turnId before turn.started assigns it. No restart was reached in the original failing attempt. Prompt, reasoning, timestamps and private execution metadata omitted.", + "state": { + "issue": { + "id": "issue", + "companyId": "company", + "status": "in_progress" + }, + "runs": [ + { + "id": "run", + "companyId": "company", + "nativeIssueId": "issue", + "runtimeMode": "native", + "status": "running", + "nativeSessionId": "session", + "runnerInstanceId": "runner" + } + ], + "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": { + "version": 1, + "title": "Pi native restart", + "submitLabel": "Submit answers", + "supersedeOnUserComment": false, + "questions": [ + { + "id": "answer", + "prompt": "Answer", + "helpText": "Pi native restart", + "selectionMode": "single", + "required": true, + "allowOther": true, + "options": [ + { + "id": "paperclip_text_answer", + "label": "Pi native restart", + "description": "Expected text input", + "freeText": true + } + ] + } + ], + "questionSet": { + "schema": "paperclip.question_set.v1", + "title": "Pi native restart", + "submitLabel": "Submit answers", + "questions": [ + { + "id": "answer", + "header": "Pi native restart", + "prompt": "Answer", + "required": true, + "answerMode": "text", + "textValidation": { + "inputType": "text" + } + } + ] + }, + "runtimeRequestId": "request" + } + } + ] + }, + "events": [ + { + "companyId": "company", + "runId": "run", + "seq": 25, + "protocolSchemaVersion": 1, + "eventType": "turn.submitted", + "payload": { + "prpEvent": { + "schema": "paperclip.prp.event.v1", + "schemaVersion": 1, + "sourceKind": "runner", + "eventType": "turn.submitted", + "runId": "run", + "normalizedSessionId": "session", + "sourceInstanceId": "runner", + "sourceSeq": 6, + "sourceEventId": "runner:run:6", + "payload": { + "text": "Sanitized fixture request" + } + } + } + }, + { + "companyId": "company", + "runId": "run", + "seq": 28, + "protocolSchemaVersion": 1, + "eventType": "turn.started", + "payload": { + "prpEvent": { + "schema": "paperclip.prp.event.v1", + "schemaVersion": 1, + "sourceKind": "runner", + "eventType": "turn.started", + "runId": "run", + "turnId": "turn", + "normalizedSessionId": "session", + "sourceInstanceId": "runner", + "sourceSeq": 7, + "sourceEventId": "runner:run:7", + "payload": { + "status": "inProgress" + } + } + } + }, + { + "companyId": "company", + "runId": "run", + "seq": 222, + "protocolSchemaVersion": 1, + "eventType": "runtime_request.created", + "payload": { + "prpEvent": { + "schema": "paperclip.prp.event.v1", + "schemaVersion": 1, + "sourceKind": "runner", + "eventType": "runtime_request.created", + "runId": "run", + "turnId": "turn", + "itemId": "item-run", + "normalizedSessionId": "session", + "sourceInstanceId": "runner", + "sourceSeq": 195, + "sourceEventId": "runner:run:195", + "payload": { + "request": { + "type": "input", + "input": { + "title": "Pi native restart", + "schema": "paperclip.question_set.v1", + "questions": [ + { + "id": "answer", + "header": "Pi native restart", + "prompt": "Answer", + "required": true, + "answerMode": "text", + "textValidation": { + "inputType": "text" + } + } + ], + "submitLabel": "Submit answers" + }, + "itemId": "item-run", + "origin": { + "method": "elicitation/create", + "adapter": "acpx-runtime-sidecar", + "provider": "pi" + }, + "prompt": "Pi native restart", + "schema": "paperclip.runtime_request.v2", + "status": "pending", + "turnId": "turn", + "requestId": "request", + "requestKind": "runtime" + } + } + } + } + } + ] +} diff --git a/tests/runner-e2e/pi-native-restart-flow.test.ts b/tests/runner-e2e/pi-native-restart-flow.test.ts index b6e0be51c82..aca08bbcad6 100644 --- a/tests/runner-e2e/pi-native-restart-flow.test.ts +++ b/tests/runner-e2e/pi-native-restart-flow.test.ts @@ -1,3 +1,4 @@ +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"; @@ -50,6 +51,56 @@ it.each(["acpx-runtime", "acpx-runtime-sidecar"])("accepts mixed runner/control- 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"; }], diff --git a/tests/runner-e2e/pi-native-restart-flow.ts b/tests/runner-e2e/pi-native-restart-flow.ts index ff8b2eb3c60..1605c749347 100644 --- a/tests/runner-e2e/pi-native-restart-flow.ts +++ b/tests/runner-e2e/pi-native-restart-flow.ts @@ -66,7 +66,19 @@ function inspect(state: State, events: readonly Row[], companyId: string, header && id(question.id) && question.answerMode === "text" && question.header === header && card.payload?.runtimeRequestId === request.requestId && digest(card.payload.questionSet) === digest(questionSet) && card.idempotencyKey === `paperclip-runner-question:${run.id}:${request.requestId}`, "canonical Pi input request identity missing"); - requireProof(rows.filter(x => x.event.turnId != null || x.event.eventType.startsWith("turn.") || x.event.eventType.startsWith("runtime_request.")).every(({ event }) => event.turnId === request.turnId + const submitted = rows.filter(x => x.event.eventType === "turn.submitted"); + const unboundSubmission = submitted.find(x => x.event.turnId == null); + if (unboundSubmission) { + // The local runner records submission before ACP assigns the provider turn ID. + // Only that unique, ordered pre-start receipt may omit the turn identity. + const started = rows.filter(x => x.event.eventType === "turn.started"); + requireProof(submitted.length === 1 && started.length === 1 + && started[0]!.event.turnId === request.turnId + && unboundSubmission.row.seq < started[0]!.row.seq && started[0]!.row.seq < opening.row.seq + && unboundSubmission.event.sourceSeq < started[0]!.event.sourceSeq && started[0]!.event.sourceSeq < opening.event.sourceSeq, + "unbound submission is not the unique pre-start receipt"); + } + requireProof(rows.filter(x => x.event.turnId != null || x.event.eventType.startsWith("turn.") || x.event.eventType.startsWith("runtime_request.")).every(({ row, event }) => (event.turnId === request.turnId || row === unboundSubmission?.row) && event.normalizedSessionId === run.nativeSessionId && event.sourceInstanceId === (event.sourceKind === "runner" ? run.runnerInstanceId : `${run.runnerInstanceId}:control`)), "turn, native session or producer was replaced"); requireProof(rows.filter(x => x.event.sourceKind === "control_plane").every(x => x.event.turnId === request.turnId), "control-plane result belongs to another turn");