Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 4 additions & 4 deletions apps/server/src/provider/model-manifest.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
{
"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.156.0", "status": "unsupported" }
]
},
{
Expand Down
16 changes: 16 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,22 @@ describe("provider compatibility", () => {
}
});

it("requires Codex 0.156 for every T3 Code build that reads the policy", () => {
const bundled = ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility;
for (const [t3CodeVersion, codexVersion, expected] of [
["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