mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
## 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
544 lines
18 KiB
TypeScript
544 lines
18 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|