fix(codex): keep output schema fields named like schema keywords (#5684)

The Codex output schema normalizer walked every key as if it held a
schema, so a field named "properties" (or a literal default, enum, or
const value shaped like an object schema) was rewritten with invented
required and additionalProperties entries. Codex then rejected the turn
with invalid_json_schema. Walk only the keywords that hold schemas.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-09-29 16:49:03 +02:00
committed by GitHub
parent 2838922ade
commit 23c4404b95
2 changed files with 117 additions and 4 deletions
@@ -2373,6 +2373,71 @@ describe("Codex app-server provider", () => {
});
});
test("keeps an output field named properties as a field", () => {
const input = {
type: "object",
properties: { properties: { type: "string" } },
required: ["properties"],
additionalProperties: false,
};
expect(normalizeCodexOutputSchema(input)).toEqual(input);
});
test("normalizes schemas under keywords and leaves literal values alone", () => {
const literal = { type: "object", properties: { type: "string" } };
const input = {
type: "object",
properties: {
result: { $ref: "#/$defs/result" },
choice: { anyOf: [{ type: "object", properties: { id: { type: "string" } } }] },
},
$defs: {
properties: { type: "string" },
result: {
type: "object",
properties: { value: { type: "string" } },
default: literal,
examples: [literal],
enum: [literal],
const: literal,
},
},
};
expect(normalizeCodexOutputSchema(input)).toEqual({
type: "object",
properties: {
result: { $ref: "#/$defs/result" },
choice: {
anyOf: [
{
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false,
},
],
},
},
required: ["result", "choice"],
additionalProperties: false,
$defs: {
properties: { type: "string" },
result: {
type: "object",
properties: { value: { type: "string" } },
default: literal,
examples: [literal],
enum: [literal],
const: literal,
required: ["value"],
additionalProperties: false,
},
},
});
});
test("passes a normalized output schema to turn/start", async () => {
const session = createSession();
const request = vi.fn(async (method: string) => {
@@ -365,19 +365,67 @@ function isObjectSchemaNode(schema: Record<string, unknown>): boolean {
);
}
function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): unknown {
// Keywords whose value maps names to schemas.
const SCHEMA_MAP_KEYWORDS = [
"properties",
"patternProperties",
"$defs",
"definitions",
"dependentSchemas",
] as const;
// Keywords whose value is a schema or an array of schemas.
const SUBSCHEMA_KEYWORDS = [
"items",
"prefixItems",
"additionalItems",
"contains",
"additionalProperties",
"unevaluatedProperties",
"unevaluatedItems",
"propertyNames",
"allOf",
"anyOf",
"oneOf",
"not",
"if",
"then",
"else",
] as const;
function normalizeCodexOutputSubschema(schema: unknown, schemaPath: string): unknown {
if (Array.isArray(schema)) {
return schema.map((entry, index) =>
normalizeCodexOutputSchemaNode(entry, `${schemaPath}[${index}]`),
);
}
return normalizeCodexOutputSchemaNode(schema, schemaPath);
}
function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): unknown {
if (!isSchemaRecord(schema)) {
return schema;
}
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) {
normalized[key] = normalizeCodexOutputSchemaNode(value, `${schemaPath}.${key}`);
const normalized: Record<string, unknown> = { ...schema };
for (const keyword of SCHEMA_MAP_KEYWORDS) {
const schemas = schema[keyword];
if (isSchemaRecord(schemas)) {
normalized[keyword] = Object.fromEntries(
Object.entries(schemas).map(([name, child]) => [
name,
normalizeCodexOutputSchemaNode(child, `${schemaPath}.${keyword}.${name}`),
]),
);
}
}
for (const keyword of SUBSCHEMA_KEYWORDS) {
if (keyword in schema) {
normalized[keyword] = normalizeCodexOutputSubschema(
schema[keyword],
`${schemaPath}.${keyword}`,
);
}
}
if (!isObjectSchemaNode(normalized)) {