Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2383,6 +2383,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
method: "item/tool/requestUserInput",
requestId: ApprovalRequestId.make("req-user-input-1"),
payload: {
isBlocking: true,
itemId: "item-user-input-1",
threadId: "thread-1",
turnId: "turn-1",
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
requestAllCodexModels(client),
// Usage is an enrichment: a failure or a slow answer degrades to "no
// usage this probe" rather than costing the account and models.
client.request("account/rateLimits/read", undefined).pipe(
client.request("account/rateLimits/read", null).pipe(
Comment thread
juliusmarminge marked this conversation as resolved.
Effect.map((response): CodexRateLimitsProbe => ({
snapshot: response.rateLimits,
rateLimitsByLimitId: response.rateLimitsByLimitId,
Expand Down
30 changes: 17 additions & 13 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,25 +90,28 @@ describe("Codex thread history", () => {
);
}

it.effect("keeps the count-based rollback API for older threads", () =>
it.effect("surfaces Codex rejecting a revert of a legacy thread", () =>
Effect.gen(function* () {
const rejection = CodexErrors.CodexAppServerRequestError.invalidRequest(
"thread/revert only supports paginated threads",
);
const client: Parameters<typeof rollbackCodexThread>[0] = {
raw: { request: () => Effect.succeed({ thread: {} }) },
request: <M extends CodexRpc.ClientRequestMethod>(
method: M,
params: CodexRpc.ClientRequestParamsByMethod[M],
) => {
NodeAssert.equal(method, "thread/rollback");
NodeAssert.deepEqual(params, { threadId: "legacy-thread", numTurns: 2 });
raw: {
request: (method) => {
if (method === "thread/read") return Effect.succeed({ thread: {} });
if (method === "thread/revert") return Effect.fail(rejection);
return Effect.die(`Unexpected raw request: ${method}`);
},
},
request: <M extends CodexRpc.ClientRequestMethod>(method: M) => {
NodeAssert.equal(method, "thread/read");
return Effect.succeed({
thread: { id: "legacy-thread", turns: [] },
thread: { id: "legacy-thread", turns: [{ id: "turn-1", items: [] }] },
} as unknown as CodexRpc.ClientRequestResponsesByMethod[M]);
},
};
NodeAssert.deepEqual(yield* rollbackCodexThread(client, "legacy-thread", 2), {
threadId: "legacy-thread",
turns: [],
});
const error = yield* Effect.flip(rollbackCodexThread(client, "legacy-thread", 1));
NodeAssert.strictEqual(error, rejection);
}),
);
});
Expand Down Expand Up @@ -680,6 +683,7 @@ function makeThreadStartedNotification(
id: threadId,
modelProvider: "openai",
preview: "",
projectId: null,
sessionId: threadId,
source,
status: { type: "idle" as const },
Expand Down
12 changes: 4 additions & 8 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,7 @@ export type CodexTurnStartParamsWithCollaborationMode =
export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type;
type CodexServiceTier = NonNullable<EffectCodexSchema.V2ThreadStartParams["serviceTier"]>;
type CodexThreadItem =
| EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number]["items"][number]
| EffectCodexSchema.V2ThreadRollbackResponse["thread"]["turns"][number]["items"][number];
EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number]["items"][number];

export interface CodexSessionRuntimeOptions {
readonly threadId: ThreadId;
Expand Down Expand Up @@ -1185,7 +1184,7 @@ function updateSession(
}

function parseThreadSnapshot(
response: EffectCodexSchema.V2ThreadReadResponse | EffectCodexSchema.V2ThreadRollbackResponse,
response: EffectCodexSchema.V2ThreadReadResponse,
): CodexThreadSnapshot {
return {
threadId: response.thread.id,
Expand Down Expand Up @@ -1273,11 +1272,8 @@ export const rollbackCodexThread = Effect.fn("rollbackCodexThread")(function* (
threadId: string,
numTurns: number,
): Effect.fn.Return<CodexThreadSnapshot, CodexErrors.CodexAppServerError> {
if ((yield* readCodexHistoryMode(client, threadId)) !== "paginated") {
return parseThreadSnapshot(yield* client.request("thread/rollback", { threadId, numTurns }));
}
// Paginated threads replace history at a turn boundary instead of supporting
// the legacy count-based rollback endpoint.
// Codex replaces history at a turn boundary. It rejects threads that still
// use legacy history, which have no rollback API since Codex 0.156.
const snapshot = yield* readCodexThread(client, threadId);
Comment thread
juliusmarminge marked this conversation as resolved.
const retainedCount = Math.max(0, snapshot.turns.length - numTurns);
const firstRemoved = snapshot.turns[retainedCount];
Expand Down
9 changes: 5 additions & 4 deletions apps/server/src/provider/model-manifest.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
{
"version": 1,
"updatedAt": "2026-09-24T00:11:29Z",
"updatedAt": "2026-09-24T18:40:00Z",
"compatibility": [
{
"driver": "codex",
"t3CodeRange": ">=0.0.42",
"recommendedRange": ">=0.129.0",
"recommendedRange": ">=0.156.0",
"ranges": [
{ "range": ">=0.129.0", "status": "supported" },
{ "range": "<0.129.0", "status": "broken" }
{ "range": ">=0.156.0", "status": "supported" },
{ "range": ">=0.149.0 <0.156.0", "status": "unsupported" },
{ "range": "<0.149.0", "status": "broken" }
]
},
{
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/provider/providerCompatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ describe("provider compatibility", () => {
}
});

it("supports Codex 0.156 and marks Codex without Thread.projectId broken", () => {
const bundled = ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility;
for (const [t3CodeVersion, codexVersion, expected] of [
["0.0.42", "0.148.0", "broken"],
["0.0.42", "0.149.0", "unsupported"],
["0.0.42", "0.155.0", "unsupported"],
["0.0.42", "0.156.0", "supported"],
["0.0.43-nightly.20260924.2200", "0.153.3", "unsupported"],
["0.0.43-nightly.20260924.2200", "0.156.1", "supported"],
] as const) {
assert.strictEqual(
resolveProviderCompatibility(bundled, driver, codexVersion, t3CodeVersion)?.status,
expected,
`T3 Code ${t3CodeVersion} with Codex ${codexVersion}`,
);
}
});

it("compares Cursor build dates without treating semver prereleases as stable", () => {
const cursor = ProviderDriverKind.make("cursor");
const cursorPolicy: ProviderCompatibilityPolicy = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"forkedFromId": null,
"parentThreadId": null,
"preview": "",
"projectId": null,
"ephemeral": false,
"historyMode": "legacy",
"modelProvider": "openai",
Expand Down Expand Up @@ -389,6 +390,7 @@
"forkedFromId": null,
"parentThreadId": null,
"preview": "",
"projectId": null,
"ephemeral": false,
"historyMode": "legacy",
"modelProvider": "openai",
Expand Down
175 changes: 6 additions & 169 deletions packages/effect-codex-app-server/scripts/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from "effect/unstable/http";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

const UPSTREAM_REF = "678157acaa819d5510adfe359abb5d0392cfe461";
const UPSTREAM_REF = "fe74a774532af67b5a4a3dec03ce9469e17f89af";
const USER_AGENT = "effect-codex-app-server-generator";
const GITHUB_API_BASE =
"https://api.github.com/repos/openai/codex/contents/codex-rs/app-server-protocol";
Expand Down Expand Up @@ -145,112 +145,6 @@ const ManualSchemas: Record<string, Schema.Json> = {
},
};

// Codex 0.150 added these multi-agent values before our next full protocol
// refresh. Keep every generated response namespace compatible with them.
const Codex0150DefinitionSchemas: Record<string, Schema.Json> = {
CollabAgentTool: {
type: "string",
enum: [
"spawnAgent",
"sendInput",
"resumeAgent",
"wait",
"closeAgent",
"sendMessage",
"followupTask",
"interruptAgent",
"listAgents",
],
},
CollabAgentToolCallStatus: {
type: "string",
enum: ["inProgress", "completed", "failed", "interrupted"],
},
PlanType: {
type: "string",
enum: [
"free",
"go",
"plus",
"pro",
"prolite",
"team",
"self_serve_business_prolite",
"self_serve_business_usage_based",
"business",
"ent26",
"enterprise_cbp_automation",
"enterprise_cbp_usage_based",
"enterprise",
"edu",
"edu_plus",
"edu_pro",
"unknown",
],
},
SubAgentActivityKind: {
type: "string",
enum: ["started", "interacted", "interrupted", "completed"],
},
};

// Pinned protocol JSON omits later CodexErrorInfo variants. Keep historical
// thread payloads decodable; do not fold unknown values into "other".
const CodexErrorInfoCompatibilityValues = [
"rateLimitExceeded",
"misalignmentPolicyViolation",
] as const;

const CodexErrorInfoCompatibilityExports = new Set([
"V2ThreadReadResponse",
"V2ThreadResumeResponse",
"V2ThreadRollbackResponse",
"V2ThreadForkResponse",
"V2TurnCompletedNotification",
]);

function applyCodex0151DefinitionCompatibility(
exportName: string,
definitionName: string,
definitionSchema: Schema.Json,
): Schema.Json {
if (
!CodexErrorInfoCompatibilityExports.has(exportName) ||
definitionName !== "CodexErrorInfo" ||
typeof definitionSchema !== "object"
) {
return definitionSchema;
}

const schema = definitionSchema as {
readonly oneOf?: ReadonlyArray<{ readonly enum?: ReadonlyArray<string> }>;
};
const [firstVariant, ...remainingVariants] = schema.oneOf ?? [];
const currentEnum = firstVariant?.enum;
if (!currentEnum) {
return definitionSchema;
}

const missingValues = CodexErrorInfoCompatibilityValues.filter(
(value) => !currentEnum.includes(value),
);
if (missingValues.length === 0) {
return definitionSchema;
}

const enumValues = [...currentEnum];
const otherIndex = enumValues.indexOf("other");
const nextEnum =
otherIndex === -1
? [...enumValues, ...missingValues]
: [...enumValues.slice(0, otherIndex), ...missingValues, ...enumValues.slice(otherIndex)];

return {
...definitionSchema,
oneOf: [{ ...firstVariant, enum: nextEnum }, ...remainingVariants],
};
}

const getGeneratedPaths = Effect.fn("getGeneratedPaths")(function* () {
const path = yield* Path.Path;
const generatedDir = path.join(import.meta.dirname, "..", "src", "_generated");
Expand Down Expand Up @@ -470,61 +364,6 @@ function adaptSchemaForEffect(value: Schema.Json): Schema.Json {
};
}

// Codex 0.153 adds async questions to agent messages. Keep older protocol
// fields until the next full refresh, including every thread history namespace.
function addAsyncQuestionFields(value: Schema.Json): Schema.Json {
if (Array.isArray(value)) {
return value.map(addAsyncQuestionFields);
}
if (value === null || typeof value !== "object") {
return value;
}
const properties = "properties" in value ? value.properties : undefined;
const itemType =
properties && typeof properties === "object" && "type" in properties
? properties.type
: undefined;
if (
properties &&
typeof properties === "object" &&
itemType &&
typeof itemType === "object" &&
"enum" in itemType &&
Array.isArray(itemType.enum) &&
itemType.enum.includes("agentMessage")
) {
return {
...value,
properties: {
...Object.fromEntries(Object.entries(properties).filter(([key]) => key !== "type")),
delivery: { anyOf: [{ type: "string", enum: ["async"] }, { type: "null" }] },
questions: {
anyOf: [
{
type: "array",
items: {
type: "object",
properties: {
title: { type: "string" },
options: {
anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }],
},
},
required: ["title"],
},
},
{ type: "null" },
],
},
type: itemType,
},
};
}
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [key, addAsyncQuestionFields(child)]),
);
}

function toPascalCaseMethod(method: string) {
return method
.split("/")
Expand All @@ -536,13 +375,14 @@ function toPascalCaseMethod(method: string) {
}

function parseRequestEntries(fileContents: string): ReadonlyArray<MethodEntry> {
const entryPattern = /\{\s*"method":\s*"([^"]+)",\s*id:\s*RequestId,\s*params:\s*([^,}]+)/g;
// Optional params render as `params?: Foo | undefined`; their JSON schema is `NullableFoo`.
const entryPattern = /\{\s*"method":\s*"([^"]+)",\s*id:\s*RequestId,\s*params(\??):\s*([^,}|]+)/g;
const entries: Array<MethodEntry> = [];
let match: RegExpExecArray | null;
while ((match = entryPattern.exec(fileContents)) !== null) {
entries.push({
method: match[1]!,
paramsType: match[2]!.trim(),
paramsType: `${match[2] ? "Nullable" : ""}${match[3]!.trim()}`,
});
}
return entries;
Expand Down Expand Up @@ -800,13 +640,10 @@ const generateFiles = Effect.fn("generateFiles")(function* () {
);

for (const [definitionName, definitionSchema] of Object.entries(parsed.definitions ?? {})) {
const compatibleDefinitionSchema =
Codex0150DefinitionSchemas[definitionName] ??
applyCodex0151DefinitionCompatibility(file.exportName, definitionName, definitionSchema);
aggregateSchemas[localDefinitionNames.get(definitionName)!] = stripNullDefaults(
normalizeNullableTypes(
rewriteExternalRefs(
compatibleDefinitionSchema,
definitionSchema,
localDefinitionNames,
file.namespace,
exportNameByQualifiedName,
Expand Down Expand Up @@ -844,7 +681,7 @@ const generateFiles = Effect.fn("generateFiles")(function* () {
for (const [name, schema] of Object.entries(aggregateSchemas).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
aggregateSchemas[name] = adaptSchemaForEffect(addAsyncQuestionFields(schema));
aggregateSchemas[name] = adaptSchemaForEffect(schema);
generator.addSchema(name, aggregateSchemas[name] as never);
}

Expand Down
Loading
Loading