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:
parent
9d7d9ea724
commit
f44f5b1ab1
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue