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
This commit is contained in:
Dotta 2026-08-30 00:46:00 -05:00 committed by GitHub
parent 9d7d9ea724
commit f44f5b1ab1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 1166 additions and 0 deletions

View File

@ -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<string, unknown>,
) {
return normalizeCodexQuestionSetWithContext(
method,
params,
createCodexQuestionResponseContext(),
);
}
function runtimeRequestResponse(
request: Parameters<typeof runtimeRequestResponseWithContext>[0],
resolution: Parameters<typeof runtimeRequestResponseWithContext>[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,
});
});
});

View File

@ -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<string, ReadonlyMap<string, unknown>>
>();
/** 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<PaperclipQuestion["options"]>;
nativeValues: ReadonlyMap<string, unknown>;
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
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<string, unknown>): 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, unknown>,
): string {
const reason = text(params.reason, text(params.message));
if (reason.length > 0) return boundedText(redactCodexDiagnostic(reason));
const labels: Record<HarnessRuntimeRequestKind, string> = {
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<string>();
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<string, unknown>();
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<string, unknown>): 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<string, unknown>();
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<string, ReadonlyMap<string, unknown>>,
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<string, unknown>,
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<string>();
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<string, ReadonlyMap<string, unknown>>();
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<string, ReadonlyMap<string, unknown>>();
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<string, unknown>([["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<string, unknown> {
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<string, { answers: string[] }> {
const result: Record<string, { answers: string[] }> = {};
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<string, unknown>,
optionId: string,
nativeValues: ReadonlyMap<string, unknown> | 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<string, unknown> {
const requestedSchema = record(request.details.requestedSchema ?? request.details.schema);
const properties = record(requestedSchema.properties);
const content: Record<string, unknown> = {};
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<string, unknown> {
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 };
}