diff --git a/packages/effect-codex-app-server/src/_internal/shared.test.ts b/packages/effect-codex-app-server/src/_internal/shared.test.ts
index 5e87485c85d2..ba354d568701 100644
--- a/packages/effect-codex-app-server/src/_internal/shared.test.ts
+++ b/packages/effect-codex-app-server/src/_internal/shared.test.ts
@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import * as CodexError from "../errors.ts";
+import * as CodexSchema from "../schema.ts";
import * as Shared from "./shared.ts";
const decodeNestedNumberPayload = Schema.decodeUnknownEffect(
@@ -135,6 +136,47 @@ it.effect("passes request errors through without adding a wrapper", () =>
}),
);
+it.effect("normalizes unrecognized plan types to unknown before decoding", () =>
+ Effect.gen(function* () {
+ const account = yield* Shared.decodeOptionalPayload(
+ "account/read",
+ CodexSchema.V2GetAccountResponse,
+ {
+ account: {
+ type: "chatgpt",
+ email: "edu@example.com",
+ planType: "edu_plus",
+ },
+ requiresOpenaiAuth: false,
+ },
+ );
+
+ assert.deepEqual(account.account, {
+ type: "chatgpt",
+ email: "edu@example.com",
+ planType: "unknown",
+ });
+
+ const knownPlan = yield* Shared.decodeOptionalPayload(
+ "account/read",
+ CodexSchema.V2GetAccountResponse,
+ {
+ account: {
+ type: "chatgpt",
+ email: "plus@example.com",
+ planType: "plus",
+ },
+ requiresOpenaiAuth: false,
+ },
+ );
+
+ assert.equal(knownPlan.account?.type, "chatgpt");
+ if (knownPlan.account?.type === "chatgpt") {
+ assert.equal(knownPlan.account.planType, "plus");
+ }
+ }),
+);
+
it.effect("retains the full notification payload decode cause chain", () =>
Effect.gen(function* () {
const error = yield* Shared.decodeNotificationPayload(
diff --git a/packages/effect-codex-app-server/src/_internal/shared.ts b/packages/effect-codex-app-server/src/_internal/shared.ts
index 34155348abfa..2fb916145259 100644
--- a/packages/effect-codex-app-server/src/_internal/shared.ts
+++ b/packages/effect-codex-app-server/src/_internal/shared.ts
@@ -17,6 +17,42 @@ export const JsonRpcResponseEnvelope = Schema.Struct({
error: Schema.optional(JsonRpcError),
});
+// Plan types emitted by the running codex binary can be newer than the pinned
+// protocol schema (e.g. "edu_plus"). Upstream maps unrecognized plans to
+// "unknown" via #[serde(other)]; mirror that so account payloads still decode.
+const KNOWN_PLAN_TYPES = new Set([
+ "free",
+ "go",
+ "plus",
+ "pro",
+ "prolite",
+ "team",
+ "self_serve_business_usage_based",
+ "business",
+ "enterprise_cbp_usage_based",
+ "enterprise",
+ "edu",
+ "unknown",
+]);
+
+export const normalizeUnknownPlanTypes = (value: unknown): unknown => {
+ if (Array.isArray(value)) {
+ return value.map(normalizeUnknownPlanTypes);
+ }
+ if (typeof value !== "object" || value === null) {
+ return value;
+ }
+
+ return Object.fromEntries(
+ Object.entries(value).map(([key, child]) => [
+ key,
+ key === "planType" && typeof child === "string" && !KNOWN_PLAN_TYPES.has(child)
+ ? "unknown"
+ : normalizeUnknownPlanTypes(child),
+ ]),
+ );
+};
+
export const decodeOptionalPayload = (
method: string,
schema: Schema.Codec | undefined,
@@ -31,7 +67,9 @@ export const decodeOptionalPayload = (
);
}
- return Schema.decodeUnknownEffect(schema)(raw).pipe(
+ return Schema.decodeUnknownEffect(schema)(
+ typeof raw === "object" && raw !== null ? normalizeUnknownPlanTypes(raw) : raw,
+ ).pipe(
Effect.mapError((error) =>
CodexError.CodexAppServerRequestError.invalidPayload(method, "decode-payload", error),
),
diff --git a/packages/effect-codex-app-server/src/client.test.ts b/packages/effect-codex-app-server/src/client.test.ts
index 3830c5fc5f6f..1dfb495eaf5b 100644
--- a/packages/effect-codex-app-server/src/client.test.ts
+++ b/packages/effect-codex-app-server/src/client.test.ts
@@ -123,6 +123,37 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => {
]);
}),
);
+ it.effect("decodes account plans the pinned protocol schema does not know", () =>
+ Effect.gen(function* () {
+ const handle = yield* makeHandle({
+ CODEX_APP_SERVER_TEST_ACCOUNT_PLAN_TYPE: "edu_plus",
+ });
+ const scope = yield* Scope.make();
+ const clientLayer = CodexClient.layerChildProcess(handle);
+ const context = yield* Layer.buildWithScope(clientLayer, scope);
+
+ const account = yield* Effect.gen(function* () {
+ const client = yield* CodexClient.CodexAppServerClient;
+ yield* client.request("initialize", {
+ clientInfo: {
+ name: "effect-codex-app-server-test",
+ title: "Effect Codex App Server Test",
+ version: "0.0.0",
+ },
+ capabilities: {
+ experimentalApi: true,
+ optOutNotificationMethods: null,
+ },
+ });
+ return yield* client.request("account/read", {});
+ }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
+
+ assert.equal(account.account?.type, "chatgpt");
+ if (account.account?.type === "chatgpt") {
+ assert.equal(account.account.planType, "unknown");
+ }
+ }),
+ );
it.effect("drains child stderr so large diagnostics cannot block protocol responses", () =>
Effect.gen(function* () {
const handle = yield* makeHandle({
diff --git a/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts b/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts
index 3f2a213d38c7..a6f58f4df11c 100644
--- a/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts
+++ b/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts
@@ -71,11 +71,13 @@ const handleMethod = (message: Record) => {
return;
}
case "account/read": {
+ // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone mock peer process has no Effect runtime.
+ const planType = process.env.CODEX_APP_SERVER_TEST_ACCOUNT_PLAN_TYPE ?? "plus";
respond(message.id as number | string, {
account: {
type: "chatgpt",
email: "mock@example.com",
- planType: "plus",
+ planType,
},
requiresOpenaiAuth: false,
});