From f44f5b1ab1c9e597f60661fb02e7cc3b700637b9 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:46:00 -0500 Subject: [PATCH] feat(runner): normalize Codex structured questions (#12365) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Existing adapters already use provider-neutral structured questions > - Codex emits several provider-specific input and elicitation forms > - Malformed native forms must fail closed instead of degrading to an opaque prompt > - This pull request adds the Codex question adapter without adding the full driver > - A later pull request will connect it to provider request handling > - The benefit is a small compatibility boundary that preserves legacy question behavior ## Linked Issues or Issue Description **Subsystem affected** `packages/paperclip-runner` Codex structured input handling. **Problem or motivation** Codex user-input and MCP elicitation requests use different shapes. The runner needs one canonical question set and must map validated answers back to the correct provider response. **Proposed solution** Normalize native forms into `paperclip.question_set.v1`. Preserve optional answers, select modes, validation bounds, and stable IDs. Map canonical responses back only after validation. **Alternatives considered** Passing provider-native forms through PRP would couple the app to Codex and would not preserve the existing provider-neutral question boundary. **Roadmap alignment** This supports the Codex-first experimental runner and existing structured-question compatibility. It does not enable the runner adapter. ## What Changed - Added request-kind detection for approval, input, and elicitation requests. - Added requestUserInput normalization. - Added JSON Schema elicitation normalization. - Added canonical runtime request payloads. - Added response mapping for Codex answers and MCP elicitation content. - Added malformed-form and multi-question tests. ## Verification - `pnpm --filter @paperclipai/paperclip-runner test:typescript` - `pnpm -r typecheck` - `pnpm build` - The focused question adapter test has 3 passing cases. ## Risks The main risk is changing question semantics. Tests verify optional answers, single and multi select modes, text validation, duplicate IDs, numeric elicitation, and canonical response mapping. ## Model Used OpenAI Codex with GPT-5.6 and repository tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .../codex/codex-question-adapter.test.ts | 543 +++++++++++++++ .../drivers/codex/codex-question-adapter.ts | 623 ++++++++++++++++++ 2 files changed, 1166 insertions(+) create mode 100644 packages/paperclip-runner/src/drivers/codex/codex-question-adapter.test.ts create mode 100644 packages/paperclip-runner/src/drivers/codex/codex-question-adapter.ts diff --git a/packages/paperclip-runner/src/drivers/codex/codex-question-adapter.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-question-adapter.test.ts new file mode 100644 index 0000000000..afdca595fa --- /dev/null +++ b/packages/paperclip-runner/src/drivers/codex/codex-question-adapter.test.ts @@ -0,0 +1,543 @@ +import { describe, expect, it } from "vitest"; + +import type { HarnessRuntimeRequest } from "../../contracts/harness-driver.js"; +import { + createCodexQuestionResponseContext, + hasCodexQuestionForm, + normalizeCodexQuestionSet as normalizeCodexQuestionSetWithContext, + runtimeRequestKind, + runtimeRequestProtocolPayload, + runtimeRequestResponse as runtimeRequestResponseWithContext, +} from "./codex-question-adapter.js"; + +function normalizeCodexQuestionSet( + method: string, + params: Record, +) { + return normalizeCodexQuestionSetWithContext( + method, + params, + createCodexQuestionResponseContext(), + ); +} + +function runtimeRequestResponse( + request: Parameters[0], + resolution: Parameters[1], +) { + return runtimeRequestResponseWithContext( + request, + resolution, + createCodexQuestionResponseContext(), + ); +} + +describe("Codex structured question adapter", () => { + it("normalizes requestUserInput without inventing required answers", () => { + const questions = normalizeCodexQuestionSet("item/tool/requestUserInput", { + title: "Deployment", + questions: [ + { + id: "environment", + header: "Target", + question: "Where should we deploy?", + options: [ + { id: "staging", label: "Staging", description: "Safe first step" }, + { id: "production", label: "Production" }, + ], + }, + { + id: "regions", + question: "Which regions?", + multiSelect: true, + options: [{ id: "us", label: "US" }, { id: "eu", label: "EU" }], + required: true, + }, + { + id: "notes", + question: "Anything else?", + minLength: 2, + maxLength: 500, + }, + ], + }); + + expect(questions).toMatchObject({ + schema: "paperclip.question_set.v1", + title: "Deployment", + questions: [ + { id: "environment", required: false, answerMode: "single_select" }, + { id: "regions", required: true, answerMode: "multi_select" }, + { + id: "notes", + required: false, + answerMode: "text", + textValidation: { minLength: 2, maxLength: 500 }, + }, + ], + }); + expect(runtimeRequestKind("item/tool/requestUserInput")).toBe("user_input"); + expect(hasCodexQuestionForm("item/tool/requestUserInput", { questions: [] })).toBe(true); + }); + + it("fails closed on a malformed native question form", () => { + expect(() => normalizeCodexQuestionSet("tool/requestUserInput", { + questions: [ + { id: "duplicate", question: "First?" }, + { id: "duplicate", question: "Second?" }, + ], + })).toThrow("must be unique"); + }); + + it("keeps explicit question ids distinct from generated question ids", () => { + const questions = normalizeCodexQuestionSet("tool/requestUserInput", { + questions: [ + { id: "question-2", question: "First?" }, + { question: "Second?" }, + { id: "question-1", question: "Third?" }, + ], + }); + + expect(questions?.questions.map((question) => question.id)).toEqual([ + "question-2", + "question-3", + "question-1", + ]); + }); + + it("keeps explicit option ids distinct from generated option ids", () => { + const responseContext = createCodexQuestionResponseContext(); + const input = normalizeCodexQuestionSetWithContext("tool/requestUserInput", { + questions: [{ + id: "target", + question: "Where?", + options: [ + { id: "option-2", label: "First" }, + { label: "Second" }, + { id: "question-1", label: "Third" }, + { id: "option-1", label: "Fourth" }, + ], + }], + }, responseContext)!; + const request: HarnessRuntimeRequest = { + requestId: "request-distinct-option-ids", + requestKind: "user_input", + method: "tool/requestUserInput", + turnId: "turn-1", + itemId: "item-1", + status: "pending", + prompt: "Codex requests user input.", + details: {}, + input, + origin: { adapter: "codex", method: "tool/requestUserInput" }, + }; + + expect(input.questions[0]?.options?.map((option) => option.id)).toEqual([ + "option-2", + "option-3", + "question-1", + "option-1", + ]); + expect(runtimeRequestResponseWithContext(request, { + action: "submit", + response: { + schema: "paperclip.question_response.v1", + answers: { target: { selectedOptionIds: ["question-1", "option-1"] } }, + }, + }, responseContext)).toEqual({ + answers: { target: { answers: ["Third", "Fourth"] } }, + }); + }); + + it("returns native option values instead of their display labels", () => { + const responseContext = createCodexQuestionResponseContext(); + const input = normalizeCodexQuestionSetWithContext("tool/requestUserInput", { + questions: [{ + id: "environment", + question: "Where?", + options: [{ + id: "production", + label: "Production (recommended)", + value: "prod-us-east-1", + }], + }], + }, responseContext)!; + const request: HarnessRuntimeRequest = { + requestId: "request-native-option-value", + requestKind: "user_input", + method: "tool/requestUserInput", + turnId: "turn-1", + itemId: "item-1", + status: "pending", + prompt: "Codex requests user input.", + details: {}, + input, + origin: { adapter: "codex", method: "tool/requestUserInput" }, + }; + + expect(input.questions[0]?.options?.[0]?.label).toBe( + "Production (recommended)", + ); + expect(runtimeRequestResponseWithContext(request, { + action: "submit", + response: { + schema: "paperclip.question_response.v1", + answers: { + environment: { selectedOptionIds: ["production"] }, + }, + }, + }, responseContext)).toEqual({ + answers: { environment: { answers: ["prod-us-east-1"] } }, + }); + }); + + it("serializes non-string native option values instead of discarding them", () => { + const responseContext = createCodexQuestionResponseContext(); + const input = normalizeCodexQuestionSetWithContext("tool/requestUserInput", { + questions: [{ + id: "settings", + question: "Which settings?", + multiSelect: true, + options: [ + { id: "retries", label: "Three retries", value: 3 }, + { id: "enabled", label: "Disabled", value: false }, + { + id: "policy", + label: "Strict policy", + value: { mode: "strict", retries: 2 }, + }, + ], + }], + }, responseContext)!; + const request: HarnessRuntimeRequest = { + requestId: "request-non-string-option-values", + requestKind: "user_input", + method: "tool/requestUserInput", + turnId: "turn-1", + itemId: "item-1", + status: "pending", + prompt: "Codex requests user input.", + details: {}, + input, + origin: { adapter: "codex", method: "tool/requestUserInput" }, + }; + + expect(runtimeRequestResponseWithContext(request, { + action: "submit", + response: { + schema: "paperclip.question_response.v1", + answers: { + settings: { + selectedOptionIds: ["retries", "enabled", "policy"], + }, + }, + }, + }, responseContext)).toEqual({ + answers: { + settings: { + answers: ["3", "false", '{"mode":"strict","retries":2}'], + }, + }, + }); + }); + + it("fails closed instead of truncating oversized native forms", () => { + expect(() => normalizeCodexQuestionSet("tool/requestUserInput", { + questions: Array.from({ length: 65 }, (_, index) => ({ + id: `question-${index}`, + question: `Question ${index}`, + })), + })).toThrow("Codex question form exceeds 64 questions"); + + expect(() => normalizeCodexQuestionSet("tool/requestUserInput", { + questions: [{ + id: "oversized-options", + question: "Choose one", + options: Array.from({ length: 129 }, (_, index) => ({ + id: `option-${index}`, + label: `Option ${index}`, + })), + }], + })).toThrow("Codex question exceeds 128 options"); + + expect(() => normalizeCodexQuestionSet("tool/requestUserInput", { + questions: [{ + id: "q".repeat(161), + question: "Choose one", + }], + })).toThrow("Codex question identifier exceeds 160 characters"); + + expect(() => normalizeCodexQuestionSet("tool/requestUserInput", { + questions: [{ + id: "oversized-label", + question: "Choose one", + options: [{ id: "option-1", label: "x".repeat(1_001) }], + }], + })).toThrow("Codex option label exceeds 1000 characters"); + + expect(() => normalizeCodexQuestionSet("tool/requestUserInput", { + description: "x".repeat(4_001), + questions: [{ id: "prompt", question: "Choose one" }], + })).toThrow("Codex form description exceeds 4000 characters"); + + expect(() => normalizeCodexQuestionSet("tool/requestUserInput", { + questions: [{ id: "prompt", question: "x".repeat(4_001) }], + })).toThrow("Codex question prompt exceeds 4000 characters"); + + expect(() => normalizeCodexQuestionSet("tool/requestUserInput", { + questions: [{ + id: "option-description", + question: "Choose one", + options: [{ + id: "option-1", + label: "One", + description: "x".repeat(4_001), + }], + }], + })).toThrow("Codex option description exceeds 4000 characters"); + + const oversizedSecret = `Bearer ${"a".repeat(4_001)}`; + for (const params of [ + { + description: oversizedSecret, + questions: [{ id: "form", question: "Choose one" }], + }, + { + questions: [{ + id: "question", + question: "Choose one", + description: oversizedSecret, + }], + }, + { + questions: [{ + id: "option", + question: "Choose one", + options: [{ id: "option-1", label: "One", description: oversizedSecret }], + }], + }, + ]) { + expect(() => normalizeCodexQuestionSet("tool/requestUserInput", params)) + .toThrow("exceeds 4000 characters"); + } + + const boundaryPrefix = "Bearer a "; + const boundaryDescription = `${boundaryPrefix}${"x".repeat(4_000 - boundaryPrefix.length)}`; + expect(() => normalizeCodexQuestionSet("tool/requestUserInput", { + description: boundaryDescription, + questions: [{ id: "form", question: "Choose one" }], + })).toThrow("Codex form description exceeds 4000 characters after redaction"); + + const preservedTail = "description-tail"; + const safeDescription = `${boundaryPrefix}${"x".repeat(3_900)}${preservedTail}`; + const safeForm = normalizeCodexQuestionSet("tool/requestUserInput", { + description: safeDescription, + questions: [{ id: "form", question: "Choose one" }], + }); + expect(safeForm?.description).toContain("Bearer [REDACTED]"); + expect(safeForm?.description.endsWith(preservedTail)).toBe(true); + + expect(() => normalizeCodexQuestionSet("mcpServer/elicitation/request", { + requestedSchema: { + type: "object", + properties: { + choice: { + type: "string", + oneOf: [{ const: "one", title: "x".repeat(1_001) }], + }, + }, + }, + })).toThrow("Codex option label exceeds 1000 characters"); + + expect(() => normalizeCodexQuestionSet("mcpServer/elicitation/request", { + requestedSchema: { + type: "object", + properties: Object.fromEntries(Array.from({ length: 65 }, (_, index) => [ + `property-${index}`, + { type: "string" }, + ])), + }, + })).toThrow("Codex question form exceeds 64 questions"); + }); + + it("maps canonical answers back to Codex user-input and elicitation shapes", () => { + const input = normalizeCodexQuestionSet("tool/requestUserInput", { + questions: [{ + id: "environment", + question: "Where?", + options: [{ id: "staging", label: "Staging" }], + }], + })!; + const request: HarnessRuntimeRequest = { + requestId: "request-1", + requestKind: "user_input", + method: "tool/requestUserInput", + turnId: "turn-1", + itemId: "item-1", + status: "pending", + prompt: "Codex requests user input.", + details: {}, + input, + origin: { adapter: "codex", method: "tool/requestUserInput" }, + }; + const response = { + schema: "paperclip.question_response.v1" as const, + answers: { environment: { selectedOptionIds: ["staging"] } }, + }; + + expect(runtimeRequestResponse(request, { action: "submit", response })).toEqual({ + answers: { environment: { answers: ["Staging"] } }, + }); + expect(runtimeRequestProtocolPayload(request)).toMatchObject({ + schema: "paperclip.runtime_request.v2", + requestKind: "runtime", + requestId: "request-1", + type: "input", + input, + }); + + const elicitationInput = normalizeCodexQuestionSet("mcpServer/elicitation/request", { + requestedSchema: { + type: "object", + properties: { + retries: { type: "integer", title: "Retries", minimum: 0, maximum: 5 }, + enabled: { type: "boolean", title: "Enabled" }, + }, + required: ["retries"], + }, + })!; + const elicitationRequest: HarnessRuntimeRequest = { + ...request, + requestId: "request-2", + requestKind: "elicitation", + method: "mcpServer/elicitation/request", + details: { + requestedSchema: { + type: "object", + properties: { + retries: { type: "integer" }, + enabled: { type: "boolean" }, + }, + }, + }, + input: elicitationInput, + }; + expect(runtimeRequestResponse(elicitationRequest, { + action: "submit", + response: { + schema: "paperclip.question_response.v1", + answers: { + retries: { text: "3" }, + enabled: { selectedOptionIds: ["true"] }, + }, + }, + })).toEqual({ + action: "accept", + content: { retries: 3, enabled: true }, + _meta: null, + }); + }); + + it("keeps native option answers private while redacting their display labels", () => { + const responseContext = createCodexQuestionResponseContext(); + const input = normalizeCodexQuestionSetWithContext("tool/requestUserInput", { + questions: [{ + id: "credential", + question: "Choose the configured credential.", + options: [{ id: "configured", label: "token=native-option-secret" }], + }], + }, responseContext)!; + const request: HarnessRuntimeRequest = { + requestId: "request-private-option", + requestKind: "user_input", + method: "tool/requestUserInput", + turnId: "turn-1", + itemId: "item-1", + status: "pending", + prompt: "Codex requests user input.", + details: {}, + input, + origin: { adapter: "codex", method: "tool/requestUserInput" }, + }; + + expect(JSON.stringify(input)).not.toContain("native-option-secret"); + expect(input.questions[0]?.options?.[0]?.label).toContain("[REDACTED]"); + const rehydratedRequest = structuredClone(request); + expect(runtimeRequestResponseWithContext(rehydratedRequest, { + action: "submit", + response: { + schema: "paperclip.question_response.v1", + answers: { credential: { selectedOptionIds: ["configured"] } }, + }, + }, responseContext)).toEqual({ + answers: { credential: { answers: ["token=native-option-secret"] } }, + }); + }); + + it("redacts credential text from every MCP elicitation display field", () => { + const responseContext = createCodexQuestionResponseContext(); + const input = normalizeCodexQuestionSetWithContext("mcpServer/elicitation/request", { + message: "Authorization: Bearer message-secret", + requestedSchema: { + type: "object", + properties: { + environment: { + type: "string", + title: "Authorization: Bearer property-title-secret", + description: "token=property-secret", + oneOf: [ + { + const: "staging", + title: "token=option-title-secret", + description: "password=option-secret", + }, + { const: "password=option-value-secret" }, + ], + }, + }, + }, + }, responseContext); + + expect(input).toMatchObject({ + description: expect.stringContaining("[REDACTED]"), + questions: [{ + header: expect.stringContaining("[REDACTED]"), + prompt: expect.stringContaining("[REDACTED]"), + helpText: expect.stringContaining("[REDACTED]"), + options: [ + { + label: expect.stringContaining("[REDACTED]"), + description: expect.stringContaining("[REDACTED]"), + }, + { label: expect.stringContaining("[REDACTED]") }, + ], + }], + }); + expect(JSON.stringify(input)).not.toMatch( + /message-secret|property-title-secret|property-secret|option-title-secret|option-value-secret|option-secret/, + ); + const request: HarnessRuntimeRequest = { + requestId: "request-private-elicitation-option", + requestKind: "elicitation", + method: "mcpServer/elicitation/request", + turnId: "turn-1", + itemId: "item-1", + status: "pending", + prompt: "A tool requests structured user input.", + details: {}, + input: input!, + origin: { adapter: "codex", method: "mcpServer/elicitation/request" }, + }; + expect(runtimeRequestResponseWithContext(structuredClone(request), { + action: "submit", + response: { + schema: "paperclip.question_response.v1", + answers: { environment: { selectedOptionIds: ["option-2"] } }, + }, + }, responseContext)).toEqual({ + action: "accept", + content: { environment: "password=option-value-secret" }, + _meta: null, + }); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-question-adapter.ts b/packages/paperclip-runner/src/drivers/codex/codex-question-adapter.ts new file mode 100644 index 0000000000..fa7b73ca92 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/codex/codex-question-adapter.ts @@ -0,0 +1,623 @@ +import type { + HarnessRuntimeRequest, + HarnessRuntimeRequestKind, + HarnessRuntimeRequestResolution, + PaperclipQuestion, + PaperclipQuestionResponse, + PaperclipQuestionSet, +} from "../../contracts/harness-driver.js"; +import { + PAPERCLIP_QUESTION_SET_SCHEMA, + PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2, + parsePaperclipQuestionSet, +} from "../../contracts/harness-driver.js"; +import { redactCodexDiagnostic } from "./app-server-transport.js"; + +export interface CodexQuestionResponseContext { + readonly kind: "codex_question_response_context"; +} + +const NATIVE_OPTION_VALUES = new WeakMap< + CodexQuestionResponseContext, + ReadonlyMap> +>(); + +/** Create one private response context for one provider runtime request. */ +export function createCodexQuestionResponseContext(): CodexQuestionResponseContext { + const context = Object.freeze({ + kind: "codex_question_response_context" as const, + }); + NATIVE_OPTION_VALUES.set(context, new Map()); + return context; +} + +interface NormalizedQuestionOptions { + options: NonNullable; + nativeValues: ReadonlyMap; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function text(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function boundedText( + value: unknown, + fallback = "unknown", + maxCharacters = 1024, +): string { + const candidate = text(value, fallback); + return candidate.length <= maxCharacters + ? candidate + : `${candidate.slice(0, maxCharacters)}...[truncated]`; +} + +export function runtimeRequestKind(method: string): HarnessRuntimeRequestKind | null { + if ( + method === "item/commandExecution/requestApproval" || + method === "execCommandApproval" + ) { + return "command_approval"; + } + if ( + method === "item/fileChange/requestApproval" || + method === "applyPatchApproval" + ) { + return "file_approval"; + } + if (method === "item/permissions/requestApproval") + return "permission_approval"; + if ( + method === "item/tool/requestUserInput" || + method === "tool/requestUserInput" + ) { + return "user_input"; + } + if (method === "mcpServer/elicitation/request") return "elicitation"; + return null; +} + +/** + * During the v1 migration, input requests that predate structured form data + * remain opaque runtime requests. Once a provider supplies a native form, + * however, a malformed/unsupported form must fail closed instead of silently + * degrading back to the legacy textarea presentation. + */ +export function hasCodexQuestionForm(method: string, params: Record): boolean { + if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") { + return "questions" in params; + } + if (method === "mcpServer/elicitation/request") { + return "requestedSchema" in params || "schema" in params; + } + return false; +} + +export function runtimeRequestPrompt( + kind: HarnessRuntimeRequestKind, + params: Record, +): string { + const reason = text(params.reason, text(params.message)); + if (reason.length > 0) return boundedText(redactCodexDiagnostic(reason)); + const labels: Record = { + command_approval: "Codex requests approval to run a command.", + file_approval: "Codex requests approval to change files.", + permission_approval: "Codex requests additional runtime permissions.", + user_input: "Codex requests user input.", + elicitation: "A tool requests structured user input.", + }; + return labels[kind]; +} + +function stableQuestionId(value: unknown, index: number): string { + const candidate = text(value).trim(); + if (candidate.length > 160) { + throw new Error("Codex question identifier exceeds 160 characters"); + } + return candidate.length > 0 ? candidate : `question-${index + 1}`; +} + +function exactOptionLabel(value: unknown, index: number): string { + const label = text(value); + if (label.length > 1_000) { + throw new Error("Codex option label exceeds 1000 characters"); + } + const redacted = redactCodexDiagnostic(label || `Option ${index + 1}`); + if (redacted.length > 1_000) { + throw new Error("Codex option label exceeds 1000 characters after redaction"); + } + return redacted; +} + +function exactQuestionText( + value: unknown, + field: string, + maxCharacters = 4_000, +): string { + const candidate = text(value); + if (candidate.length > maxCharacters) { + throw new Error( + `Codex ${field} exceeds ${maxCharacters} characters`, + ); + } + return candidate; +} + +function exactRedactedQuestionText( + value: unknown, + field: string, + maxCharacters = 4_000, +): string { + const redacted = redactCodexDiagnostic( + exactQuestionText(value, field, maxCharacters), + ); + if (redacted.length <= maxCharacters) return redacted; + throw new Error( + `Codex ${field} exceeds ${maxCharacters} characters after redaction`, + ); +} + +function codexOptions(value: unknown): NormalizedQuestionOptions | undefined { + if (!Array.isArray(value)) return undefined; + if (value.length > 128) throw new Error("Codex question exceeds 128 options"); + const explicitIds = value.map((rawOption, index) => { + const optionId = text(record(rawOption).id).trim(); + return optionId.length > 0 ? stableQuestionId(optionId, index) : null; + }); + const usedIds = new Set(); + for (const id of explicitIds) { + if (id === null) continue; + if (usedIds.has(id)) { + throw new Error("Codex option identifiers must be unique"); + } + usedIds.add(id); + } + const nativeValues = new Map(); + const options = value.map((rawOption, index) => { + const option = record(rawOption); + const nativeLabel = text( + option.label, + text(option.value, text(rawOption, `Option ${index + 1}`)), + ); + let id = explicitIds[index]; + if (id === null || id === undefined) { + let generatedIndex = index + 1; + id = `option-${generatedIndex}`; + while (usedIds.has(id)) { + generatedIndex += 1; + id = `option-${generatedIndex}`; + } + usedIds.add(id); + } + nativeValues.set( + id, + structuredClone("value" in option ? option.value : nativeLabel), + ); + return { + id, + label: exactOptionLabel(nativeLabel, index), + ...(text(option.description).length > 0 + ? { + description: exactRedactedQuestionText( + option.description, + "option description", + ), + } + : {}), + }; + }); + return { options, nativeValues }; +} + +function jsonSchemaOptions(schema: Record): NormalizedQuestionOptions { + const values = Array.isArray(schema.enum) + ? schema.enum + : Array.isArray(schema.oneOf) + ? schema.oneOf.map((entry) => record(entry).const) + : []; + if (values.length > 128) throw new Error("Codex question exceeds 128 options"); + const nativeValues = new Map(); + const options = values.map((value, index) => { + const oneOf = Array.isArray(schema.oneOf) ? record(schema.oneOf[index]) : {}; + const id = `option-${index + 1}`; + nativeValues.set(id, structuredClone(value)); + return { + id, + label: exactOptionLabel( + text( + oneOf.title, + typeof value === "string" ? value : JSON.stringify(value), + ), + index, + ), + ...(text(oneOf.description).length > 0 + ? { + description: exactRedactedQuestionText( + text(oneOf.description), + "option description", + ), + } + : {}), + }; + }); + return { options, nativeValues }; +} + +function retainNativeOptionValues( + questionSet: PaperclipQuestionSet, + nativeValues: ReadonlyMap>, + responseContext: CodexQuestionResponseContext, +): PaperclipQuestionSet { + if (!NATIVE_OPTION_VALUES.has(responseContext)) { + throw new Error("Codex question response context was not created by this adapter"); + } + NATIVE_OPTION_VALUES.set(responseContext, nativeValues); + return questionSet; +} + +/** Codex-native requests are converted once, before they enter PRP. */ +export function normalizeCodexQuestionSet( + method: string, + params: Record, + responseContext: CodexQuestionResponseContext, +): PaperclipQuestionSet | null { + if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") { + if (!Array.isArray(params.questions) || params.questions.length === 0) return null; + if (params.questions.length > 64) throw new Error("Codex question form exceeds 64 questions"); + const explicitQuestionIds = params.questions.map((rawQuestion, index) => { + const questionId = text(record(rawQuestion).id).trim(); + return questionId.length > 0 ? stableQuestionId(questionId, index) : null; + }); + const usedQuestionIds = new Set(); + for (const id of explicitQuestionIds) { + if (id === null) continue; + if (usedQuestionIds.has(id)) { + throw new Error("Codex question identifiers must be unique"); + } + usedQuestionIds.add(id); + } + const nativeValues = new Map>(); + const questions = params.questions.map((rawQuestion, index): PaperclipQuestion => { + const question = record(rawQuestion); + const normalizedOptions = codexOptions(question.options); + let questionId = explicitQuestionIds[index]; + if (questionId === null || questionId === undefined) { + let generatedIndex = index + 1; + questionId = `question-${generatedIndex}`; + while (usedQuestionIds.has(questionId)) { + generatedIndex += 1; + questionId = `question-${generatedIndex}`; + } + usedQuestionIds.add(questionId); + } + if (normalizedOptions !== undefined) { + nativeValues.set(questionId, normalizedOptions.nativeValues); + } + return { + id: questionId, + ...(text(question.header).length > 0 + ? { + header: exactRedactedQuestionText( + text(question.header), + "question header", + 1_000, + ), + } + : {}), + prompt: exactRedactedQuestionText( + text(question.question, text(question.prompt, `Question ${index + 1}`)), + "question prompt", + ), + ...(text(question.description).length > 0 + ? { + helpText: exactRedactedQuestionText( + question.description, + "question description", + ), + } + : {}), + // Codex requestUserInput questions do not normally declare requiredness. + // Do not invent a required constraint when the provider omitted one. + required: question.required === true, + answerMode: normalizedOptions && normalizedOptions.options.length > 0 + ? question.multiSelect === true || question.multiple === true ? "multi_select" : "single_select" + : "text", + ...(normalizedOptions && normalizedOptions.options.length > 0 + ? { options: normalizedOptions.options } + : {}), + ...(question.isOther === true || question.allowOther === true + ? { customAnswer: { enabled: true, label: "Other", placeholder: "Enter another answer" } } + : {}), + ...(!(normalizedOptions && normalizedOptions.options.length > 0) && (typeof question.minLength === "number" || typeof question.maxLength === "number") + ? { textValidation: { + ...(typeof question.minLength === "number" ? { minLength: question.minLength } : {}), + ...(typeof question.maxLength === "number" ? { maxLength: question.maxLength } : {}), + } } + : {}), + }; + }); + return retainNativeOptionValues(parsePaperclipQuestionSet({ + schema: PAPERCLIP_QUESTION_SET_SCHEMA, + title: exactRedactedQuestionText( + text(params.title, "Codex needs your input"), + "form title", + 1_000, + ), + ...(text(params.description).length > 0 + ? { + description: exactRedactedQuestionText( + params.description, + "form description", + ), + } + : {}), + submitLabel: exactRedactedQuestionText( + text(params.submitLabel, "Submit answers"), + "submit label", + 1_000, + ), + questions, + }), nativeValues, responseContext); + } + if (method !== "mcpServer/elicitation/request") return null; + const requestedSchema = record(params.requestedSchema ?? params.schema); + const properties = record(requestedSchema.properties); + const required = new Set(Array.isArray(requestedSchema.required) ? requestedSchema.required.filter((entry): entry is string => typeof entry === "string") : []); + const propertyEntries = Object.entries(properties); + if (propertyEntries.length > 64) throw new Error("Codex question form exceeds 64 questions"); + const nativeValues = new Map>(); + const questions = propertyEntries.map(([id, rawProperty]): PaperclipQuestion => { + const property = record(rawProperty); + const propertyType = text(property.type); + const itemSchema = record(property.items); + const selectSchema = propertyType === "array" ? itemSchema : property; + const normalizedOptions = propertyType === "boolean" + ? { + options: [{ id: "true", label: "Yes" }, { id: "false", label: "No" }], + nativeValues: new Map([["true", true], ["false", false]]), + } + : jsonSchemaOptions(selectSchema); + if (normalizedOptions.options.length > 0) { + nativeValues.set(id, normalizedOptions.nativeValues); + } + const answerMode: PaperclipQuestion["answerMode"] = propertyType === "array" && normalizedOptions.options.length > 0 + ? "multi_select" + : normalizedOptions.options.length > 0 + ? "single_select" + : "text"; + const inputType = propertyType === "integer" ? "integer" : propertyType === "number" ? "number" : "text"; + return { + id, + ...(text(property.title).length > 0 + ? { + header: exactRedactedQuestionText( + text(property.title), + "question header", + 1_000, + ), + } + : {}), + prompt: exactRedactedQuestionText( + text(property.title, id), + "question prompt", + ), + ...(text(property.description).length > 0 + ? { + helpText: exactRedactedQuestionText( + text(property.description), + "question description", + ), + } + : {}), + required: required.has(id), + answerMode, + ...(normalizedOptions.options.length > 0 ? { options: normalizedOptions.options } : {}), + ...(answerMode === "text" ? { textValidation: { + inputType, + ...(typeof property.minLength === "number" ? { minLength: property.minLength } : {}), + ...(typeof property.maxLength === "number" ? { maxLength: property.maxLength } : {}), + ...(typeof property.minimum === "number" ? { minimum: property.minimum } : {}), + ...(typeof property.maximum === "number" ? { maximum: property.maximum } : {}), + ...(typeof property.pattern === "string" ? { pattern: property.pattern } : {}), + } } : {}), + }; + }); + if (questions.length === 0) return null; + return retainNativeOptionValues(parsePaperclipQuestionSet({ + schema: PAPERCLIP_QUESTION_SET_SCHEMA, + title: "A tool needs your input", + ...(text(params.message).length > 0 + ? { + description: exactRedactedQuestionText( + text(params.message), + "form description", + ), + } + : {}), + submitLabel: "Submit", + questions, + }), nativeValues, responseContext); +} + +export function runtimeRequestProtocolPayload(request: HarnessRuntimeRequest): Record { + if (request.input !== undefined) { + return { + schema: PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2, + requestKind: "runtime", + requestId: request.requestId, + type: "input", + status: request.status, + prompt: request.prompt, + input: structuredClone(request.input), + origin: structuredClone(request.origin), + turnId: request.turnId, + itemId: request.itemId, + }; + } + return { ...request, type: request.method }; +} + +function canonicalCodexAnswers( + request: HarnessRuntimeRequest, + response: PaperclipQuestionResponse, + responseContext: CodexQuestionResponseContext, +): Record { + const result: Record = {}; + for (const question of request.input?.questions ?? []) { + const answer = response.answers[question.id]; + if (answer === undefined) continue; + const nativeValues = request.input === undefined + ? undefined + : NATIVE_OPTION_VALUES.get(responseContext)?.get(question.id); + const labels = (answer.selectedOptionIds ?? []).map((optionId) => { + const value = nativeValues?.has(optionId) + ? nativeValues.get(optionId) + : question.options?.find((option) => option.id === optionId)?.label; + if (typeof value === "string") return value; + const serialized = JSON.stringify(value); + if (serialized === undefined) { + throw new Error("Codex native option value must be JSON-serializable"); + } + return serialized; + }); + if (answer.text !== undefined) labels.push(answer.text); + if (answer.customText !== undefined) labels.push(answer.customText); + result[question.id] = { answers: labels }; + } + return result; +} + +function jsonSchemaOptionValue( + schema: Record, + optionId: string, + nativeValues: ReadonlyMap | undefined, +): unknown { + if (nativeValues?.has(optionId)) return structuredClone(nativeValues.get(optionId)); + if (optionId === "true") return true; + if (optionId === "false") return false; + const index = Number(optionId.match(/^option-(\d+)$/)?.[1] ?? "0") - 1; + if (index < 0) return optionId; + if (Array.isArray(schema.enum)) return schema.enum[index]; + if (Array.isArray(schema.oneOf)) return record(schema.oneOf[index]).const; + return optionId; +} + +function canonicalElicitationContent( + request: HarnessRuntimeRequest, + response: PaperclipQuestionResponse, + responseContext: CodexQuestionResponseContext, +): Record { + const requestedSchema = record(request.details.requestedSchema ?? request.details.schema); + const properties = record(requestedSchema.properties); + const content: Record = {}; + for (const question of request.input?.questions ?? []) { + const answer = response.answers[question.id]; + if (answer === undefined) continue; + const property = record(properties[question.id]); + const itemSchema = record(property.items); + const nativeValues = request.input === undefined + ? undefined + : NATIVE_OPTION_VALUES.get(responseContext)?.get(question.id); + if (question.answerMode === "text") { + const value = answer.text ?? ""; + content[question.id] = property.type === "integer" || property.type === "number" ? Number(value) : value; + } else if (question.answerMode === "multi_select") { + content[question.id] = (answer.selectedOptionIds ?? []).map((optionId) => + jsonSchemaOptionValue(itemSchema, optionId, nativeValues)); + } else { + const optionId = answer.selectedOptionIds?.[0]; + if (optionId !== undefined) { + content[question.id] = jsonSchemaOptionValue(property, optionId, nativeValues); + } + else if (answer.customText !== undefined) content[question.id] = answer.customText; + } + } + return content; +} + +/** + * Maps an already-validated resolution onto the provider's response shape. + * `parseHarnessRuntimeRequestResolution` is the only gate on shape, so every + * branch here answers a resolution the request kind actually accepts. + */ +export function runtimeRequestResponse( + request: HarnessRuntimeRequest, + resolution: HarnessRuntimeRequestResolution, + responseContext: CodexQuestionResponseContext, +): Record { + if ( + request.requestKind === "command_approval" || + request.requestKind === "file_approval" + ) { + if (resolution.action === "submit") { + throw new Error( + `${request.requestKind} does not accept submitted form data`, + ); + } + const decisions = { + accept: "accept", + accept_for_session: "acceptForSession", + decline: "decline", + cancel: "cancel", + } as const; + return { decision: decisions[resolution.action] }; + } + if (request.requestKind === "permission_approval") { + if (resolution.action === "submit") { + throw new Error( + "permission approval does not accept submitted form data", + ); + } + return { + permissions: {}, + scope: resolution.action === "accept_for_session" ? "session" : "turn", + }; + } + if (request.requestKind === "user_input") { + if (resolution.action === "submit" && "response" in resolution) { + return { + answers: canonicalCodexAnswers( + request, + resolution.response, + responseContext, + ), + }; + } + if (resolution.action !== "submit" || !("answers" in resolution)) { + // Declines and cancels are the only non-submit answers the validator + // lets through, and neither carries form data. + return { answers: {} }; + } + return { answers: structuredClone(resolution.answers) }; + } + if (resolution.action === "submit" && "response" in resolution) { + return { + action: "accept", + content: canonicalElicitationContent( + request, + resolution.response, + responseContext, + ), + _meta: null, + }; + } + if (resolution.action === "submit" && "content" in resolution) { + return { + action: "accept", + content: structuredClone(resolution.content), + _meta: null, + }; + } + if ( + resolution.action === "submit" || + resolution.action === "accept_for_session" + ) { + throw new Error("elicitation submissions require content"); + } + return { action: resolution.action, content: null, _meta: null }; +}