diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 5936d1c7b4..f1d28444d8 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -66,6 +66,12 @@ effects, audit, retries, conflicts, redaction, continuation, and terminal decisions. The production adapter stays App-owned and invokes Paperclip's real route/service authorities; it does not copy those rules into this package. +Provider-facing `paperclip_finish` accepts an omitted or `null` continuation for +`done`, `completed`, and `needs_review`. This supports gateways that require all +declared tool properties to be present. Normalization removes only `null`; +non-yielding tool calls still reject a continuation object, and `yielded` still +requires a complete `response_wake` object. + ## Quick start Native provider debug-trace correlation uses an incremental index owned by its transport. Pending diff --git a/packages/paperclip-runner/src/contracts/completion-result.test.ts b/packages/paperclip-runner/src/contracts/completion-result.test.ts index c23a7e8563..7fd02435ba 100644 --- a/packages/paperclip-runner/src/contracts/completion-result.test.ts +++ b/packages/paperclip-runner/src/contracts/completion-result.test.ts @@ -131,6 +131,19 @@ describe("provider-neutral completion result schema", () => { expect(validate(response)).toBe(false); }); + it.each(["done", "needs_review", "completed"])("accepts explicit no-continuation at the provider boundary for %s", (disposition) => { + const providerValidate = new Ajv2020({ allErrors: true, strict: false }) + .compile(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA); + const response = { + ...structuredClone(baseResult), + reportedWorkDisposition: disposition, + continuation: null, + }; + expect(providerValidate(response)).toBe(true); + // The canonical output remains strict; normalization removes only null. + expect(validate(response)).toBe(false); + }); + it("exposes concrete completion fields while retaining response-wake validation", () => { // The live Codex code-mode renderer reduced a conditional-only root allOf // to `args: unknown`. Keep this tool object-shaped for provider discovery. @@ -154,6 +167,7 @@ describe("provider-neutral completion result schema", () => { }; expect(providerValidate(yielded)).toBe(true); expect(providerValidate({ ...yielded, continuation: undefined })).toBe(false); + expect(providerValidate({ ...yielded, continuation: null })).toBe(false); expect(providerValidate({ ...yielded, continuation: { kind: "response_wake" } })).toBe(false); expect(providerValidate({ ...yielded, continuation: { ...yielded.continuation, kind: "same_agent" }, diff --git a/packages/paperclip-runner/src/contracts/completion-result.ts b/packages/paperclip-runner/src/contracts/completion-result.ts index dec93a2f1d..6beca921f2 100644 --- a/packages/paperclip-runner/src/contracts/completion-result.ts +++ b/packages/paperclip-runner/src/contracts/completion-result.ts @@ -355,15 +355,23 @@ export const PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA = { reportedWorkDisposition: { enum: ["done", "needs_review", "yielded", "completed"] }, verification: providerVerificationCompatibilitySchema, attentionRequests: providerAttentionCompatibilitySchema, - continuation: responseWakeContinuationSchema, + // Responses-compatible gateways can require every declared property in + // tool calls. Give non-yielding results an explicit absence value instead + // of forcing callers to invent a response-wake continuation. + continuation: { + ...responseWakeContinuationSchema, + type: ["object", "null"], + description: + "Use null or omit this field for done, completed, or needs_review. Only yielded requires a response-wake object with kind, summary, and idempotencyKey.", + }, }, // Keep the provider-facing root a concrete object. Codex code-mode renders a // root allOf containing only an if/then constraint as `args: unknown`, hiding // every required field from the model. This equivalent direct conditional // preserves validation without obscuring the object-shaped tool signature. if: { properties: { reportedWorkDisposition: { const: "yielded" } }, required: ["reportedWorkDisposition"] }, - then: { required: ["continuation"] }, - else: { not: { required: ["continuation"] } }, + then: { required: ["continuation"], properties: { continuation: { type: "object" } } }, + else: { properties: { continuation: { type: "null" } } }, } as const; export const PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA = { diff --git a/packages/paperclip-runner/src/protocol/result-normalization.test.ts b/packages/paperclip-runner/src/protocol/result-normalization.test.ts index fcce070e36..7128beae7d 100644 --- a/packages/paperclip-runner/src/protocol/result-normalization.test.ts +++ b/packages/paperclip-runner/src/protocol/result-normalization.test.ts @@ -6,6 +6,36 @@ import { import { validatePrpStructuredRunResult } from "./replay-contract.js"; describe("normalizePrpResultSignals", () => { + it("normalizes an explicit null continuation without weakening completion rules", () => { + const providerResult = { + reportedWorkDisposition: "done", + summary: "Renamed the task to bobathan.", + completionClaim: { + contractRevision: "1", + objectiveSatisfied: true, + criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }], + remainingWork: [], + }, + evidence: [], + verification: [], + continuation: null, + }; + const validated = validatePrpStructuredRunResult(providerResult); + expect(validated).toMatchObject({ ok: true, result: { reportedWorkDisposition: "done" } }); + if (validated.ok) expect(validated.result).not.toHaveProperty("continuation"); + expect(providerResult).toHaveProperty("continuation", null); + expect(validatePrpStructuredRunResult({ + ...providerResult, + reportedWorkDisposition: "yielded", + })).toMatchObject({ ok: false }); + expect(normalizeLegacyPrpStructuredRunResult({ + ...providerResult, + continuation: { kind: "response_wake", summary: "Contradictory wait.", idempotencyKey: "wait-1" }, + })).toHaveProperty("continuation", { + kind: "response_wake", summary: "Contradictory wait.", idempotencyKey: "wait-1", + }); + }); + it("accepts legacy completion aliases as one canonical PRP result", () => { const legacy = { schema: "paperclip.run_result.v1", diff --git a/packages/paperclip-runner/src/protocol/result-normalization.ts b/packages/paperclip-runner/src/protocol/result-normalization.ts index 47ab63c309..ae27d13bf1 100644 --- a/packages/paperclip-runner/src/protocol/result-normalization.ts +++ b/packages/paperclip-runner/src/protocol/result-normalization.ts @@ -119,6 +119,12 @@ export function normalizeLegacyPrpStructuredRunResult(value: unknown): unknown { const source = record(value); if (Object.keys(source).length === 0) return value; const normalized: Record = { ...source }; + // Some provider transports require all tool properties to be present. Null + // explicitly means no continuation; preserve every non-null value so strict + // canonical validation still rejects contradictory or malformed waits. + if (normalized.continuation === null) { + delete normalized.continuation; + } // Provider tool-callers do not consistently echo constant discriminator // fields even when they are present in the advertised JSON schema. The // transport already selected the semantic-result tool, so adding its