Closed
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
195 changes: 146 additions & 49 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,27 +43,66 @@ describe("CodexSessionRuntimeIdentifierGenerationError", () => {
});
});

function makeThreadOpenResponse(
threadId: string,
): CodexRpc.ClientRequestResponsesByMethod["thread/start"] {
/**
* Raw `thread/start` / `thread/resume` payload as Codex puts it on the wire.
* `items` accepts arbitrary history entries so tests can replay shapes newer
* than the generated bindings.
*/
function makeThreadOpenResponse(threadId: string, items: ReadonlyArray<unknown> = []): unknown {
return {
cwd: "/tmp/project",
model: "gpt-5.3-codex",
modelProvider: "openai",
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "danger-full-access" },
sandbox: { type: "dangerFullAccess" },
thread: {
id: threadId,
createdAt: "2026-04-18T00:00:00.000Z",
source: { session: "cli" },
turns: [],
status: {
state: "idle",
activeFlags: [],
},
cliVersion: "0.150.0",
createdAt: 0,
updatedAt: 0,
cwd: "/tmp/project",
ephemeral: false,
modelProvider: "openai",
preview: "",
sessionId: "session-1",
source: "cli",
status: { type: "idle" },
turns: items.length === 0 ? [] : [{ id: "turn-1", status: "completed", items }],
},
} as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/start"];
};
}

/**
* Mirrors the real client: params keep their generated types, and the raw
* payload is decoded with whichever response schema the caller supplied,
* failing exactly as the client would.
*/
function makeThreadOpenClient(
respond: (
method: "thread/start" | "thread/resume",
) => Effect.Effect<unknown, CodexErrors.CodexAppServerError>,
) {
return {
request: <M extends "thread/start" | "thread/resume", A>(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
responseSchema: Schema.Codec<A, unknown>,
) =>
respond(method).pipe(
Effect.flatMap((raw) =>
Schema.decodeUnknownEffect(responseSchema)(raw).pipe(
Effect.mapError((cause) =>
CodexErrors.CodexAppServerRequestError.invalidPayload(
method,
"decode-payload",
cause,
),
),
),
),
),
};
}

describe("buildTurnStartParams", () => {
Expand DownExpand Up@@ -752,6 +791,20 @@ describe("isRecoverableThreadResumeError", () => {
);
});

it("matches responses this build cannot decode", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
new CodexErrors.CodexAppServerRequestError({
code: -32602,
errorMessage: "Invalid payload for method 'thread/resume' during 'decode-payload'",
method: "thread/resume",
operation: "decode-payload",
}),
),
true,
);
});

it("ignores unrelated missing-resource errors that do not mention threads", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
Expand All@@ -775,27 +828,56 @@ describe("isRecoverableThreadResumeError", () => {
});

describe("openCodexThread", () => {
it.effect("falls back to thread/start when resume fails recoverably", () =>
it.effect("resumes a thread whose history uses a newer protocol variant", () =>
Effect.gen(function* () {
const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = [];
const started = makeThreadOpenResponse("fresh-thread");
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
calls.push({ method, payload });
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]);
// Opening a session must not depend on history this build cannot name
// (#8322). The kind is deliberately fictional so the test keeps
// exercising drift after the bindings learn today's values.
const resumed = makeThreadOpenResponse("resumed-thread", [
{
id: "item-18",
type: "subAgentActivity",
agentPath: "/root/child",
agentThreadId: "child-thread",
kind: "escalated",
},
};
]);
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
return Effect.succeed(resumed);
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "resumed-thread",
});

NodeAssert.equal(opened.thread.id, "resumed-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume"]);
}),
);

it.effect("falls back to thread/start when resume fails recoverably", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
Expand All@@ -808,33 +890,48 @@ describe("openCodexThread", () => {
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(
calls.map((call) => call.method),
["thread/resume", "thread/start"],
);
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("falls back to thread/start when the resume response cannot be decoded", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.succeed({ thread: {} });
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "stale-thread",
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("propagates non-recoverable resume failures", () =>
Effect.gen(function* () {
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
if (method === "thread/resume") {
return Effect.fail(
const client = makeThreadOpenClient((method) =>
method === "thread/resume"
? Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "timed out waiting for server",
}),
);
}
return Effect.succeed(
makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M],
);
},
};
)
: Effect.succeed(makeThreadOpenResponse("fresh-thread")),
);

const error = yield* openCodexThread({
client,
Expand Down
50 changes: 40 additions & 10 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -667,25 +667,47 @@ function classifyCodexStderrLine(rawLine: string): { readonly message: string }
return { message: line };
}

const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);

export function isRecoverableThreadResumeError(error: unknown): boolean {
// A response we cannot decode means Codex resumed the thread but described it
// in a protocol shape this build does not know. Retrying will never help, so
// treat it as recoverable and open a fresh Codex thread instead of leaving
// the T3 thread permanently unusable.
if (isCodexAppServerRequestError(error) && error.operation === "decode-payload") {
return true;
}
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
if (!message.includes("thread")) {
return false;
}
return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));
}

type CodexThreadOpenResponse =
| CodexRpc.ClientRequestResponsesByMethod["thread/start"]
| CodexRpc.ClientRequestResponsesByMethod["thread/resume"];
/**
* The only parts of a `thread/start` or `thread/resume` response this runtime
* consumes. Codex replays the whole thread history in those responses, and
* decoding it against generated bindings makes opening a session fail whenever
* upstream adds a protocol variant we have not regenerated yet (see #8322).
* Session state is rebuilt from notifications anyway, so read the handful of
* fields we need and let the rest pass through undecoded.
*/
const CodexThreadOpenResponse = Schema.Struct({
thread: Schema.Struct({ id: Schema.String }),
cwd: Schema.String,
model: Schema.String,
});

type CodexThreadOpenResponse = typeof CodexThreadOpenResponse.Type;

type CodexThreadOpenMethod = "thread/start" | "thread/resume";

interface CodexThreadOpenClient {
readonly request: <M extends CodexThreadOpenMethod>(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => Effect.Effect<CodexRpc.ClientRequestResponsesByMethod[M], CodexErrors.CodexAppServerError>;
responseSchema: typeof CodexThreadOpenResponse,
) => Effect.Effect<CodexThreadOpenResponse, CodexErrors.CodexAppServerError>;
}

export const openCodexThread = (input: {
Expand All@@ -706,14 +728,18 @@ export const openCodexThread = (input: {
});

if (resumeThreadId === undefined) {
return input.client.request("thread/start", startParams);
return input.client.request("thread/start", startParams, CodexThreadOpenResponse);
}

return input.client
.request("thread/resume", {
threadId: resumeThreadId,
...startParams,
})
.request(
"thread/resume",
{
threadId: resumeThreadId,
...startParams,
},
CodexThreadOpenResponse,
)
.pipe(
Effect.catchIf(isRecoverableThreadResumeError, (error) =>
Effect.logWarning("codex app-server thread resume fell back to fresh start", {
Expand All@@ -722,7 +748,11 @@ export const openCodexThread = (input: {
resumeThreadId,
recoverable: true,
cause: error,
}).pipe(Effect.andThen(input.client.request("thread/start", startParams))),
}).pipe(
Effect.andThen(
input.client.request("thread/start", startParams, CodexThreadOpenResponse),
),
),
),
);
};
Expand Down
26 changes: 26 additions & 0 deletions docs/internals/providers.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,32 @@ empty inventory is authoritative. Existing threads keep their explicit model ide
options when catalog metadata is missing; the catalog is not permission to choose a different
model for a thread.

## Generated Codex bindings

`packages/effect-codex-app-server` holds Effect/Schema bindings generated from a pinned
`openai/codex` revision (`scripts/generate.ts`). Codex ships far more often than we regenerate, so
at any moment an installed CLI may describe itself with protocol variants those bindings do not
name — a new enum member, a new item type, a newly required field.

Two rules keep that drift from breaking sessions:

- **Decode only what you consume.** `thread/start` and `thread/resume` replay an entire thread
history, and the session runtime needs three fields from it. Pass a narrow response schema as the
third argument to `client.request` rather than accepting the generated one; anything the runtime
does not read cannot then fail the request.
- **Never treat drift as fatal.** A response we cannot decode counts as a recoverable resume error,
so the thread falls back to a fresh Codex session instead of becoming permanently unopenable.
Notifications we cannot decode are dropped with a warning, never silently.

When a live event carries a value the bindings reject, teach them that one value: the generator's
definition overrides (`Codex0150DefinitionSchemas` in `scripts/generate.ts`) widen a named
definition without moving the pin. Prefer that to a full refresh, which drags in unrelated changes
and can break older CLIs — upstream adds _required_ fields too, so pinning forward breaks anyone
who has not upgraded.

None of that replaces the two rules. Overrides fix the variants we already know about; the rules
are what keep the ones we do not know about yet from being fatal.

## Model manifest

The model picker's legacy section is driven by `apps/server/src/provider/model-manifest.json`, which
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
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
195 changes: 146 additions & 49 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,27 +43,66 @@ describe("CodexSessionRuntimeIdentifierGenerationError", () => {
});
});

function makeThreadOpenResponse(
threadId: string,
): CodexRpc.ClientRequestResponsesByMethod["thread/start"] {
/**
* Raw `thread/start` / `thread/resume` payload as Codex puts it on the wire.
* `items` accepts arbitrary history entries so tests can replay shapes newer
* than the generated bindings.
*/
function makeThreadOpenResponse(threadId: string, items: ReadonlyArray<unknown> = []): unknown {
return {
cwd: "/tmp/project",
model: "gpt-5.3-codex",
modelProvider: "openai",
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "danger-full-access" },
sandbox: { type: "dangerFullAccess" },
thread: {
id: threadId,
createdAt: "2026-04-18T00:00:00.000Z",
source: { session: "cli" },
turns: [],
status: {
state: "idle",
activeFlags: [],
},
cliVersion: "0.150.0",
createdAt: 0,
updatedAt: 0,
cwd: "/tmp/project",
ephemeral: false,
modelProvider: "openai",
preview: "",
sessionId: "session-1",
source: "cli",
status: { type: "idle" },
turns: items.length === 0 ? [] : [{ id: "turn-1", status: "completed", items }],
},
} as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/start"];
};
}

/**
* Mirrors the real client: params keep their generated types, and the raw
* payload is decoded with whichever response schema the caller supplied,
* failing exactly as the client would.
*/
function makeThreadOpenClient(
respond: (
method: "thread/start" | "thread/resume",
) => Effect.Effect<unknown, CodexErrors.CodexAppServerError>,
) {
return {
request: <M extends "thread/start" | "thread/resume", A>(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
responseSchema: Schema.Codec<A, unknown>,
) =>
respond(method).pipe(
Effect.flatMap((raw) =>
Schema.decodeUnknownEffect(responseSchema)(raw).pipe(
Effect.mapError((cause) =>
CodexErrors.CodexAppServerRequestError.invalidPayload(
method,
"decode-payload",
cause,
),
),
),
),
),
};
}

describe("buildTurnStartParams", () => {
Expand DownExpand Up@@ -752,6 +791,20 @@ describe("isRecoverableThreadResumeError", () => {
);
});

it("matches responses this build cannot decode", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
new CodexErrors.CodexAppServerRequestError({
code: -32602,
errorMessage: "Invalid payload for method 'thread/resume' during 'decode-payload'",
method: "thread/resume",
operation: "decode-payload",
}),
),
true,
);
});

it("ignores unrelated missing-resource errors that do not mention threads", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
Expand All@@ -775,27 +828,56 @@ describe("isRecoverableThreadResumeError", () => {
});

describe("openCodexThread", () => {
it.effect("falls back to thread/start when resume fails recoverably", () =>
it.effect("resumes a thread whose history uses a newer protocol variant", () =>
Effect.gen(function* () {
const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = [];
const started = makeThreadOpenResponse("fresh-thread");
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
calls.push({ method, payload });
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]);
// Opening a session must not depend on history this build cannot name
// (#8322). The kind is deliberately fictional so the test keeps
// exercising drift after the bindings learn today's values.
const resumed = makeThreadOpenResponse("resumed-thread", [
{
id: "item-18",
type: "subAgentActivity",
agentPath: "/root/child",
agentThreadId: "child-thread",
kind: "escalated",
},
};
]);
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
return Effect.succeed(resumed);
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "resumed-thread",
});

NodeAssert.equal(opened.thread.id, "resumed-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume"]);
}),
);

it.effect("falls back to thread/start when resume fails recoverably", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
Expand All@@ -808,33 +890,48 @@ describe("openCodexThread", () => {
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(
calls.map((call) => call.method),
["thread/resume", "thread/start"],
);
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("falls back to thread/start when the resume response cannot be decoded", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.succeed({ thread: {} });
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "stale-thread",
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("propagates non-recoverable resume failures", () =>
Effect.gen(function* () {
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
if (method === "thread/resume") {
return Effect.fail(
const client = makeThreadOpenClient((method) =>
method === "thread/resume"
? Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "timed out waiting for server",
}),
);
}
return Effect.succeed(
makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M],
);
},
};
)
: Effect.succeed(makeThreadOpenResponse("fresh-thread")),
);

const error = yield* openCodexThread({
client,
Expand Down
50 changes: 40 additions & 10 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -667,25 +667,47 @@ function classifyCodexStderrLine(rawLine: string): { readonly message: string }
return { message: line };
}

const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);

export function isRecoverableThreadResumeError(error: unknown): boolean {
// A response we cannot decode means Codex resumed the thread but described it
// in a protocol shape this build does not know. Retrying will never help, so
// treat it as recoverable and open a fresh Codex thread instead of leaving
// the T3 thread permanently unusable.
if (isCodexAppServerRequestError(error) && error.operation === "decode-payload") {
return true;
}
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
if (!message.includes("thread")) {
return false;
}
return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));
}

type CodexThreadOpenResponse =
| CodexRpc.ClientRequestResponsesByMethod["thread/start"]
| CodexRpc.ClientRequestResponsesByMethod["thread/resume"];
/**
* The only parts of a `thread/start` or `thread/resume` response this runtime
* consumes. Codex replays the whole thread history in those responses, and
* decoding it against generated bindings makes opening a session fail whenever
* upstream adds a protocol variant we have not regenerated yet (see #8322).
* Session state is rebuilt from notifications anyway, so read the handful of
* fields we need and let the rest pass through undecoded.
*/
const CodexThreadOpenResponse = Schema.Struct({
thread: Schema.Struct({ id: Schema.String }),
cwd: Schema.String,
model: Schema.String,
});

type CodexThreadOpenResponse = typeof CodexThreadOpenResponse.Type;

type CodexThreadOpenMethod = "thread/start" | "thread/resume";

interface CodexThreadOpenClient {
readonly request: <M extends CodexThreadOpenMethod>(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => Effect.Effect<CodexRpc.ClientRequestResponsesByMethod[M], CodexErrors.CodexAppServerError>;
responseSchema: typeof CodexThreadOpenResponse,
) => Effect.Effect<CodexThreadOpenResponse, CodexErrors.CodexAppServerError>;
}

export const openCodexThread = (input: {
Expand All@@ -706,14 +728,18 @@ export const openCodexThread = (input: {
});

if (resumeThreadId === undefined) {
return input.client.request("thread/start", startParams);
return input.client.request("thread/start", startParams, CodexThreadOpenResponse);
}

return input.client
.request("thread/resume", {
threadId: resumeThreadId,
...startParams,
})
.request(
"thread/resume",
{
threadId: resumeThreadId,
...startParams,
},
CodexThreadOpenResponse,
)
.pipe(
Effect.catchIf(isRecoverableThreadResumeError, (error) =>
Effect.logWarning("codex app-server thread resume fell back to fresh start", {
Expand All@@ -722,7 +748,11 @@ export const openCodexThread = (input: {
resumeThreadId,
recoverable: true,
cause: error,
}).pipe(Effect.andThen(input.client.request("thread/start", startParams))),
}).pipe(
Effect.andThen(
input.client.request("thread/start", startParams, CodexThreadOpenResponse),
),
),
),
);
};
Expand Down
26 changes: 26 additions & 0 deletions docs/internals/providers.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,32 @@ empty inventory is authoritative. Existing threads keep their explicit model ide
options when catalog metadata is missing; the catalog is not permission to choose a different
model for a thread.

## Generated Codex bindings

`packages/effect-codex-app-server` holds Effect/Schema bindings generated from a pinned
`openai/codex` revision (`scripts/generate.ts`). Codex ships far more often than we regenerate, so
at any moment an installed CLI may describe itself with protocol variants those bindings do not
name — a new enum member, a new item type, a newly required field.

Two rules keep that drift from breaking sessions:

- **Decode only what you consume.** `thread/start` and `thread/resume` replay an entire thread
history, and the session runtime needs three fields from it. Pass a narrow response schema as the
third argument to `client.request` rather than accepting the generated one; anything the runtime
does not read cannot then fail the request.
- **Never treat drift as fatal.** A response we cannot decode counts as a recoverable resume error,
so the thread falls back to a fresh Codex session instead of becoming permanently unopenable.
Notifications we cannot decode are dropped with a warning, never silently.

When a live event carries a value the bindings reject, teach them that one value: the generator's
definition overrides (`Codex0150DefinitionSchemas` in `scripts/generate.ts`) widen a named
definition without moving the pin. Prefer that to a full refresh, which drags in unrelated changes
and can break older CLIs — upstream adds _required_ fields too, so pinning forward breaks anyone
who has not upgraded.

None of that replaces the two rules. Overrides fix the variants we already know about; the rules
are what keep the ones we do not know about yet from being fatal.

## Model manifest

The model picker's legacy section is driven by `apps/server/src/provider/model-manifest.json`, which
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
195 changes: 146 additions & 49 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,27 +43,66 @@ describe("CodexSessionRuntimeIdentifierGenerationError", () => {
});
});

function makeThreadOpenResponse(
threadId: string,
): CodexRpc.ClientRequestResponsesByMethod["thread/start"] {
/**
* Raw `thread/start` / `thread/resume` payload as Codex puts it on the wire.
* `items` accepts arbitrary history entries so tests can replay shapes newer
* than the generated bindings.
*/
function makeThreadOpenResponse(threadId: string, items: ReadonlyArray<unknown> = []): unknown {
return {
cwd: "/tmp/project",
model: "gpt-5.3-codex",
modelProvider: "openai",
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "danger-full-access" },
sandbox: { type: "dangerFullAccess" },
thread: {
id: threadId,
createdAt: "2026-04-18T00:00:00.000Z",
source: { session: "cli" },
turns: [],
status: {
state: "idle",
activeFlags: [],
},
cliVersion: "0.150.0",
createdAt: 0,
updatedAt: 0,
cwd: "/tmp/project",
ephemeral: false,
modelProvider: "openai",
preview: "",
sessionId: "session-1",
source: "cli",
status: { type: "idle" },
turns: items.length === 0 ? [] : [{ id: "turn-1", status: "completed", items }],
},
} as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/start"];
};
}

/**
* Mirrors the real client: params keep their generated types, and the raw
* payload is decoded with whichever response schema the caller supplied,
* failing exactly as the client would.
*/
function makeThreadOpenClient(
respond: (
method: "thread/start" | "thread/resume",
) => Effect.Effect<unknown, CodexErrors.CodexAppServerError>,
) {
return {
request: <M extends "thread/start" | "thread/resume", A>(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
responseSchema: Schema.Codec<A, unknown>,
) =>
respond(method).pipe(
Effect.flatMap((raw) =>
Schema.decodeUnknownEffect(responseSchema)(raw).pipe(
Effect.mapError((cause) =>
CodexErrors.CodexAppServerRequestError.invalidPayload(
method,
"decode-payload",
cause,
),
),
),
),
),
};
}

describe("buildTurnStartParams", () => {
Expand DownExpand Up@@ -752,6 +791,20 @@ describe("isRecoverableThreadResumeError", () => {
);
});

it("matches responses this build cannot decode", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
new CodexErrors.CodexAppServerRequestError({
code: -32602,
errorMessage: "Invalid payload for method 'thread/resume' during 'decode-payload'",
method: "thread/resume",
operation: "decode-payload",
}),
),
true,
);
});

it("ignores unrelated missing-resource errors that do not mention threads", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
Expand All@@ -775,27 +828,56 @@ describe("isRecoverableThreadResumeError", () => {
});

describe("openCodexThread", () => {
it.effect("falls back to thread/start when resume fails recoverably", () =>
it.effect("resumes a thread whose history uses a newer protocol variant", () =>
Effect.gen(function* () {
const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = [];
const started = makeThreadOpenResponse("fresh-thread");
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
calls.push({ method, payload });
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]);
// Opening a session must not depend on history this build cannot name
// (#8322). The kind is deliberately fictional so the test keeps
// exercising drift after the bindings learn today's values.
const resumed = makeThreadOpenResponse("resumed-thread", [
{
id: "item-18",
type: "subAgentActivity",
agentPath: "/root/child",
agentThreadId: "child-thread",
kind: "escalated",
},
};
]);
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
return Effect.succeed(resumed);
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "resumed-thread",
});

NodeAssert.equal(opened.thread.id, "resumed-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume"]);
}),
);

it.effect("falls back to thread/start when resume fails recoverably", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
Expand All@@ -808,33 +890,48 @@ describe("openCodexThread", () => {
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(
calls.map((call) => call.method),
["thread/resume", "thread/start"],
);
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("falls back to thread/start when the resume response cannot be decoded", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.succeed({ thread: {} });
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "stale-thread",
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("propagates non-recoverable resume failures", () =>
Effect.gen(function* () {
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
if (method === "thread/resume") {
return Effect.fail(
const client = makeThreadOpenClient((method) =>
method === "thread/resume"
? Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "timed out waiting for server",
}),
);
}
return Effect.succeed(
makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M],
);
},
};
)
: Effect.succeed(makeThreadOpenResponse("fresh-thread")),
);

const error = yield* openCodexThread({
client,
Expand Down
50 changes: 40 additions & 10 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -667,25 +667,47 @@ function classifyCodexStderrLine(rawLine: string): { readonly message: string }
return { message: line };
}

const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);

export function isRecoverableThreadResumeError(error: unknown): boolean {
// A response we cannot decode means Codex resumed the thread but described it
// in a protocol shape this build does not know. Retrying will never help, so
// treat it as recoverable and open a fresh Codex thread instead of leaving
// the T3 thread permanently unusable.
if (isCodexAppServerRequestError(error) && error.operation === "decode-payload") {
return true;
}
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
if (!message.includes("thread")) {
return false;
}
return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));
}

type CodexThreadOpenResponse =
| CodexRpc.ClientRequestResponsesByMethod["thread/start"]
| CodexRpc.ClientRequestResponsesByMethod["thread/resume"];
/**
* The only parts of a `thread/start` or `thread/resume` response this runtime
* consumes. Codex replays the whole thread history in those responses, and
* decoding it against generated bindings makes opening a session fail whenever
* upstream adds a protocol variant we have not regenerated yet (see #8322).
* Session state is rebuilt from notifications anyway, so read the handful of
* fields we need and let the rest pass through undecoded.
*/
const CodexThreadOpenResponse = Schema.Struct({
thread: Schema.Struct({ id: Schema.String }),
cwd: Schema.String,
model: Schema.String,
});

type CodexThreadOpenResponse = typeof CodexThreadOpenResponse.Type;

type CodexThreadOpenMethod = "thread/start" | "thread/resume";

interface CodexThreadOpenClient {
readonly request: <M extends CodexThreadOpenMethod>(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => Effect.Effect<CodexRpc.ClientRequestResponsesByMethod[M], CodexErrors.CodexAppServerError>;
responseSchema: typeof CodexThreadOpenResponse,
) => Effect.Effect<CodexThreadOpenResponse, CodexErrors.CodexAppServerError>;
}

export const openCodexThread = (input: {
Expand All@@ -706,14 +728,18 @@ export const openCodexThread = (input: {
});

if (resumeThreadId === undefined) {
return input.client.request("thread/start", startParams);
return input.client.request("thread/start", startParams, CodexThreadOpenResponse);
}

return input.client
.request("thread/resume", {
threadId: resumeThreadId,
...startParams,
})
.request(
"thread/resume",
{
threadId: resumeThreadId,
...startParams,
},
CodexThreadOpenResponse,
)
.pipe(
Effect.catchIf(isRecoverableThreadResumeError, (error) =>
Effect.logWarning("codex app-server thread resume fell back to fresh start", {
Expand All@@ -722,7 +748,11 @@ export const openCodexThread = (input: {
resumeThreadId,
recoverable: true,
cause: error,
}).pipe(Effect.andThen(input.client.request("thread/start", startParams))),
}).pipe(
Effect.andThen(
input.client.request("thread/start", startParams, CodexThreadOpenResponse),
),
),
),
);
};
Expand Down
26 changes: 26 additions & 0 deletions docs/internals/providers.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,32 @@ empty inventory is authoritative. Existing threads keep their explicit model ide
options when catalog metadata is missing; the catalog is not permission to choose a different
model for a thread.

## Generated Codex bindings

`packages/effect-codex-app-server` holds Effect/Schema bindings generated from a pinned
`openai/codex` revision (`scripts/generate.ts`). Codex ships far more often than we regenerate, so
at any moment an installed CLI may describe itself with protocol variants those bindings do not
name — a new enum member, a new item type, a newly required field.

Two rules keep that drift from breaking sessions:

- **Decode only what you consume.** `thread/start` and `thread/resume` replay an entire thread
history, and the session runtime needs three fields from it. Pass a narrow response schema as the
third argument to `client.request` rather than accepting the generated one; anything the runtime
does not read cannot then fail the request.
- **Never treat drift as fatal.** A response we cannot decode counts as a recoverable resume error,
so the thread falls back to a fresh Codex session instead of becoming permanently unopenable.
Notifications we cannot decode are dropped with a warning, never silently.

When a live event carries a value the bindings reject, teach them that one value: the generator's
definition overrides (`Codex0150DefinitionSchemas` in `scripts/generate.ts`) widen a named
definition without moving the pin. Prefer that to a full refresh, which drags in unrelated changes
and can break older CLIs — upstream adds _required_ fields too, so pinning forward breaks anyone
who has not upgraded.

None of that replaces the two rules. Overrides fix the variants we already know about; the rules
are what keep the ones we do not know about yet from being fatal.

## Model manifest

The model picker's legacy section is driven by `apps/server/src/provider/model-manifest.json`, which
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
195 changes: 146 additions & 49 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,27 +43,66 @@ describe("CodexSessionRuntimeIdentifierGenerationError", () => {
});
});

function makeThreadOpenResponse(
threadId: string,
): CodexRpc.ClientRequestResponsesByMethod["thread/start"] {
/**
* Raw `thread/start` / `thread/resume` payload as Codex puts it on the wire.
* `items` accepts arbitrary history entries so tests can replay shapes newer
* than the generated bindings.
*/
function makeThreadOpenResponse(threadId: string, items: ReadonlyArray<unknown> = []): unknown {
return {
cwd: "/tmp/project",
model: "gpt-5.3-codex",
modelProvider: "openai",
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "danger-full-access" },
sandbox: { type: "dangerFullAccess" },
thread: {
id: threadId,
createdAt: "2026-04-18T00:00:00.000Z",
source: { session: "cli" },
turns: [],
status: {
state: "idle",
activeFlags: [],
},
cliVersion: "0.150.0",
createdAt: 0,
updatedAt: 0,
cwd: "/tmp/project",
ephemeral: false,
modelProvider: "openai",
preview: "",
sessionId: "session-1",
source: "cli",
status: { type: "idle" },
turns: items.length === 0 ? [] : [{ id: "turn-1", status: "completed", items }],
},
} as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/start"];
};
}

/**
* Mirrors the real client: params keep their generated types, and the raw
* payload is decoded with whichever response schema the caller supplied,
* failing exactly as the client would.
*/
function makeThreadOpenClient(
respond: (
method: "thread/start" | "thread/resume",
) => Effect.Effect<unknown, CodexErrors.CodexAppServerError>,
) {
return {
request: <M extends "thread/start" | "thread/resume", A>(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
responseSchema: Schema.Codec<A, unknown>,
) =>
respond(method).pipe(
Effect.flatMap((raw) =>
Schema.decodeUnknownEffect(responseSchema)(raw).pipe(
Effect.mapError((cause) =>
CodexErrors.CodexAppServerRequestError.invalidPayload(
method,
"decode-payload",
cause,
),
),
),
),
),
};
}

describe("buildTurnStartParams", () => {
Expand DownExpand Up@@ -752,6 +791,20 @@ describe("isRecoverableThreadResumeError", () => {
);
});

it("matches responses this build cannot decode", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
new CodexErrors.CodexAppServerRequestError({
code: -32602,
errorMessage: "Invalid payload for method 'thread/resume' during 'decode-payload'",
method: "thread/resume",
operation: "decode-payload",
}),
),
true,
);
});

it("ignores unrelated missing-resource errors that do not mention threads", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
Expand All@@ -775,27 +828,56 @@ describe("isRecoverableThreadResumeError", () => {
});

describe("openCodexThread", () => {
it.effect("falls back to thread/start when resume fails recoverably", () =>
it.effect("resumes a thread whose history uses a newer protocol variant", () =>
Effect.gen(function* () {
const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = [];
const started = makeThreadOpenResponse("fresh-thread");
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
calls.push({ method, payload });
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]);
// Opening a session must not depend on history this build cannot name
// (#8322). The kind is deliberately fictional so the test keeps
// exercising drift after the bindings learn today's values.
const resumed = makeThreadOpenResponse("resumed-thread", [
{
id: "item-18",
type: "subAgentActivity",
agentPath: "/root/child",
agentThreadId: "child-thread",
kind: "escalated",
},
};
]);
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
return Effect.succeed(resumed);
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "resumed-thread",
});

NodeAssert.equal(opened.thread.id, "resumed-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume"]);
}),
);

it.effect("falls back to thread/start when resume fails recoverably", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
Expand All@@ -808,33 +890,48 @@ describe("openCodexThread", () => {
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(
calls.map((call) => call.method),
["thread/resume", "thread/start"],
);
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("falls back to thread/start when the resume response cannot be decoded", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.succeed({ thread: {} });
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "stale-thread",
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("propagates non-recoverable resume failures", () =>
Effect.gen(function* () {
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
if (method === "thread/resume") {
return Effect.fail(
const client = makeThreadOpenClient((method) =>
method === "thread/resume"
? Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "timed out waiting for server",
}),
);
}
return Effect.succeed(
makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M],
);
},
};
)
: Effect.succeed(makeThreadOpenResponse("fresh-thread")),
);

const error = yield* openCodexThread({
client,
Expand Down
50 changes: 40 additions & 10 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -667,25 +667,47 @@ function classifyCodexStderrLine(rawLine: string): { readonly message: string }
return { message: line };
}

const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);

export function isRecoverableThreadResumeError(error: unknown): boolean {
// A response we cannot decode means Codex resumed the thread but described it
// in a protocol shape this build does not know. Retrying will never help, so
// treat it as recoverable and open a fresh Codex thread instead of leaving
// the T3 thread permanently unusable.
if (isCodexAppServerRequestError(error) && error.operation === "decode-payload") {
return true;
}
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
if (!message.includes("thread")) {
return false;
}
return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));
}

type CodexThreadOpenResponse =
| CodexRpc.ClientRequestResponsesByMethod["thread/start"]
| CodexRpc.ClientRequestResponsesByMethod["thread/resume"];
/**
* The only parts of a `thread/start` or `thread/resume` response this runtime
* consumes. Codex replays the whole thread history in those responses, and
* decoding it against generated bindings makes opening a session fail whenever
* upstream adds a protocol variant we have not regenerated yet (see #8322).
* Session state is rebuilt from notifications anyway, so read the handful of
* fields we need and let the rest pass through undecoded.
*/
const CodexThreadOpenResponse = Schema.Struct({
thread: Schema.Struct({ id: Schema.String }),
cwd: Schema.String,
model: Schema.String,
});

type CodexThreadOpenResponse = typeof CodexThreadOpenResponse.Type;

type CodexThreadOpenMethod = "thread/start" | "thread/resume";

interface CodexThreadOpenClient {
readonly request: <M extends CodexThreadOpenMethod>(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => Effect.Effect<CodexRpc.ClientRequestResponsesByMethod[M], CodexErrors.CodexAppServerError>;
responseSchema: typeof CodexThreadOpenResponse,
) => Effect.Effect<CodexThreadOpenResponse, CodexErrors.CodexAppServerError>;
}

export const openCodexThread = (input: {
Expand All@@ -706,14 +728,18 @@ export const openCodexThread = (input: {
});

if (resumeThreadId === undefined) {
return input.client.request("thread/start", startParams);
return input.client.request("thread/start", startParams, CodexThreadOpenResponse);
}

return input.client
.request("thread/resume", {
threadId: resumeThreadId,
...startParams,
})
.request(
"thread/resume",
{
threadId: resumeThreadId,
...startParams,
},
CodexThreadOpenResponse,
)
.pipe(
Effect.catchIf(isRecoverableThreadResumeError, (error) =>
Effect.logWarning("codex app-server thread resume fell back to fresh start", {
Expand All@@ -722,7 +748,11 @@ export const openCodexThread = (input: {
resumeThreadId,
recoverable: true,
cause: error,
}).pipe(Effect.andThen(input.client.request("thread/start", startParams))),
}).pipe(
Effect.andThen(
input.client.request("thread/start", startParams, CodexThreadOpenResponse),
),
),
),
);
};
Expand Down
26 changes: 26 additions & 0 deletions docs/internals/providers.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,32 @@ empty inventory is authoritative. Existing threads keep their explicit model ide
options when catalog metadata is missing; the catalog is not permission to choose a different
model for a thread.

## Generated Codex bindings

`packages/effect-codex-app-server` holds Effect/Schema bindings generated from a pinned
`openai/codex` revision (`scripts/generate.ts`). Codex ships far more often than we regenerate, so
at any moment an installed CLI may describe itself with protocol variants those bindings do not
name — a new enum member, a new item type, a newly required field.

Two rules keep that drift from breaking sessions:

- **Decode only what you consume.** `thread/start` and `thread/resume` replay an entire thread
history, and the session runtime needs three fields from it. Pass a narrow response schema as the
third argument to `client.request` rather than accepting the generated one; anything the runtime
does not read cannot then fail the request.
- **Never treat drift as fatal.** A response we cannot decode counts as a recoverable resume error,
so the thread falls back to a fresh Codex session instead of becoming permanently unopenable.
Notifications we cannot decode are dropped with a warning, never silently.

When a live event carries a value the bindings reject, teach them that one value: the generator's
definition overrides (`Codex0150DefinitionSchemas` in `scripts/generate.ts`) widen a named
definition without moving the pin. Prefer that to a full refresh, which drags in unrelated changes
and can break older CLIs — upstream adds _required_ fields too, so pinning forward breaks anyone
who has not upgraded.

None of that replaces the two rules. Overrides fix the variants we already know about; the rules
are what keep the ones we do not know about yet from being fatal.

## Model manifest

The model picker's legacy section is driven by `apps/server/src/provider/model-manifest.json`, which
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
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
195 changes: 146 additions & 49 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,27 +43,66 @@ describe("CodexSessionRuntimeIdentifierGenerationError", () => {
});
});

function makeThreadOpenResponse(
threadId: string,
): CodexRpc.ClientRequestResponsesByMethod["thread/start"] {
/**
* Raw `thread/start` / `thread/resume` payload as Codex puts it on the wire.
* `items` accepts arbitrary history entries so tests can replay shapes newer
* than the generated bindings.
*/
function makeThreadOpenResponse(threadId: string, items: ReadonlyArray<unknown> = []): unknown {
return {
cwd: "/tmp/project",
model: "gpt-5.3-codex",
modelProvider: "openai",
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "danger-full-access" },
sandbox: { type: "dangerFullAccess" },
thread: {
id: threadId,
createdAt: "2026-04-18T00:00:00.000Z",
source: { session: "cli" },
turns: [],
status: {
state: "idle",
activeFlags: [],
},
cliVersion: "0.150.0",
createdAt: 0,
updatedAt: 0,
cwd: "/tmp/project",
ephemeral: false,
modelProvider: "openai",
preview: "",
sessionId: "session-1",
source: "cli",
status: { type: "idle" },
turns: items.length === 0 ? [] : [{ id: "turn-1", status: "completed", items }],
},
} as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/start"];
};
}

/**
* Mirrors the real client: params keep their generated types, and the raw
* payload is decoded with whichever response schema the caller supplied,
* failing exactly as the client would.
*/
function makeThreadOpenClient(
respond: (
method: "thread/start" | "thread/resume",
) => Effect.Effect<unknown, CodexErrors.CodexAppServerError>,
) {
return {
request: <M extends "thread/start" | "thread/resume", A>(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
responseSchema: Schema.Codec<A, unknown>,
) =>
respond(method).pipe(
Effect.flatMap((raw) =>
Schema.decodeUnknownEffect(responseSchema)(raw).pipe(
Effect.mapError((cause) =>
CodexErrors.CodexAppServerRequestError.invalidPayload(
method,
"decode-payload",
cause,
),
),
),
),
),
};
}

describe("buildTurnStartParams", () => {
Expand DownExpand Up@@ -752,6 +791,20 @@ describe("isRecoverableThreadResumeError", () => {
);
});

it("matches responses this build cannot decode", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
new CodexErrors.CodexAppServerRequestError({
code: -32602,
errorMessage: "Invalid payload for method 'thread/resume' during 'decode-payload'",
method: "thread/resume",
operation: "decode-payload",
}),
),
true,
);
});

it("ignores unrelated missing-resource errors that do not mention threads", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
Expand All@@ -775,27 +828,56 @@ describe("isRecoverableThreadResumeError", () => {
});

describe("openCodexThread", () => {
it.effect("falls back to thread/start when resume fails recoverably", () =>
it.effect("resumes a thread whose history uses a newer protocol variant", () =>
Effect.gen(function* () {
const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = [];
const started = makeThreadOpenResponse("fresh-thread");
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
calls.push({ method, payload });
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]);
// Opening a session must not depend on history this build cannot name
// (#8322). The kind is deliberately fictional so the test keeps
// exercising drift after the bindings learn today's values.
const resumed = makeThreadOpenResponse("resumed-thread", [
{
id: "item-18",
type: "subAgentActivity",
agentPath: "/root/child",
agentThreadId: "child-thread",
kind: "escalated",
},
};
]);
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
return Effect.succeed(resumed);
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "resumed-thread",
});

NodeAssert.equal(opened.thread.id, "resumed-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume"]);
}),
);

it.effect("falls back to thread/start when resume fails recoverably", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
Expand All@@ -808,33 +890,48 @@ describe("openCodexThread", () => {
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(
calls.map((call) => call.method),
["thread/resume", "thread/start"],
);
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("falls back to thread/start when the resume response cannot be decoded", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.succeed({ thread: {} });
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "stale-thread",
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("propagates non-recoverable resume failures", () =>
Effect.gen(function* () {
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
if (method === "thread/resume") {
return Effect.fail(
const client = makeThreadOpenClient((method) =>
method === "thread/resume"
? Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "timed out waiting for server",
}),
);
}
return Effect.succeed(
makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M],
);
},
};
)
: Effect.succeed(makeThreadOpenResponse("fresh-thread")),
);

const error = yield* openCodexThread({
client,
Expand Down
50 changes: 40 additions & 10 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -667,25 +667,47 @@ function classifyCodexStderrLine(rawLine: string): { readonly message: string }
return { message: line };
}

const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);

export function isRecoverableThreadResumeError(error: unknown): boolean {
// A response we cannot decode means Codex resumed the thread but described it
// in a protocol shape this build does not know. Retrying will never help, so
// treat it as recoverable and open a fresh Codex thread instead of leaving
// the T3 thread permanently unusable.
if (isCodexAppServerRequestError(error) && error.operation === "decode-payload") {
return true;
}
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
if (!message.includes("thread")) {
return false;
}
return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));
}

type CodexThreadOpenResponse =
| CodexRpc.ClientRequestResponsesByMethod["thread/start"]
| CodexRpc.ClientRequestResponsesByMethod["thread/resume"];
/**
* The only parts of a `thread/start` or `thread/resume` response this runtime
* consumes. Codex replays the whole thread history in those responses, and
* decoding it against generated bindings makes opening a session fail whenever
* upstream adds a protocol variant we have not regenerated yet (see #8322).
* Session state is rebuilt from notifications anyway, so read the handful of
* fields we need and let the rest pass through undecoded.
*/
const CodexThreadOpenResponse = Schema.Struct({
thread: Schema.Struct({ id: Schema.String }),
cwd: Schema.String,
model: Schema.String,
});

type CodexThreadOpenResponse = typeof CodexThreadOpenResponse.Type;

type CodexThreadOpenMethod = "thread/start" | "thread/resume";

interface CodexThreadOpenClient {
readonly request: <M extends CodexThreadOpenMethod>(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => Effect.Effect<CodexRpc.ClientRequestResponsesByMethod[M], CodexErrors.CodexAppServerError>;
responseSchema: typeof CodexThreadOpenResponse,
) => Effect.Effect<CodexThreadOpenResponse, CodexErrors.CodexAppServerError>;
}

export const openCodexThread = (input: {
Expand All@@ -706,14 +728,18 @@ export const openCodexThread = (input: {
});

if (resumeThreadId === undefined) {
return input.client.request("thread/start", startParams);
return input.client.request("thread/start", startParams, CodexThreadOpenResponse);
}

return input.client
.request("thread/resume", {
threadId: resumeThreadId,
...startParams,
})
.request(
"thread/resume",
{
threadId: resumeThreadId,
...startParams,
},
CodexThreadOpenResponse,
)
.pipe(
Effect.catchIf(isRecoverableThreadResumeError, (error) =>
Effect.logWarning("codex app-server thread resume fell back to fresh start", {
Expand All@@ -722,7 +748,11 @@ export const openCodexThread = (input: {
resumeThreadId,
recoverable: true,
cause: error,
}).pipe(Effect.andThen(input.client.request("thread/start", startParams))),
}).pipe(
Effect.andThen(
input.client.request("thread/start", startParams, CodexThreadOpenResponse),
),
),
),
);
};
Expand Down
26 changes: 26 additions & 0 deletions docs/internals/providers.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,32 @@ empty inventory is authoritative. Existing threads keep their explicit model ide
options when catalog metadata is missing; the catalog is not permission to choose a different
model for a thread.

## Generated Codex bindings

`packages/effect-codex-app-server` holds Effect/Schema bindings generated from a pinned
`openai/codex` revision (`scripts/generate.ts`). Codex ships far more often than we regenerate, so
at any moment an installed CLI may describe itself with protocol variants those bindings do not
name — a new enum member, a new item type, a newly required field.

Two rules keep that drift from breaking sessions:

- **Decode only what you consume.** `thread/start` and `thread/resume` replay an entire thread
history, and the session runtime needs three fields from it. Pass a narrow response schema as the
third argument to `client.request` rather than accepting the generated one; anything the runtime
does not read cannot then fail the request.
- **Never treat drift as fatal.** A response we cannot decode counts as a recoverable resume error,
so the thread falls back to a fresh Codex session instead of becoming permanently unopenable.
Notifications we cannot decode are dropped with a warning, never silently.

When a live event carries a value the bindings reject, teach them that one value: the generator's
definition overrides (`Codex0150DefinitionSchemas` in `scripts/generate.ts`) widen a named
definition without moving the pin. Prefer that to a full refresh, which drags in unrelated changes
and can break older CLIs — upstream adds _required_ fields too, so pinning forward breaks anyone
who has not upgraded.

None of that replaces the two rules. Overrides fix the variants we already know about; the rules
are what keep the ones we do not know about yet from being fatal.

## Model manifest

The model picker's legacy section is driven by `apps/server/src/provider/model-manifest.json`, which
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
195 changes: 146 additions & 49 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,27 +43,66 @@ describe("CodexSessionRuntimeIdentifierGenerationError", () => {
});
});

function makeThreadOpenResponse(
threadId: string,
): CodexRpc.ClientRequestResponsesByMethod["thread/start"] {
/**
* Raw `thread/start` / `thread/resume` payload as Codex puts it on the wire.
* `items` accepts arbitrary history entries so tests can replay shapes newer
* than the generated bindings.
*/
function makeThreadOpenResponse(threadId: string, items: ReadonlyArray<unknown> = []): unknown {
return {
cwd: "/tmp/project",
model: "gpt-5.3-codex",
modelProvider: "openai",
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "danger-full-access" },
sandbox: { type: "dangerFullAccess" },
thread: {
id: threadId,
createdAt: "2026-04-18T00:00:00.000Z",
source: { session: "cli" },
turns: [],
status: {
state: "idle",
activeFlags: [],
},
cliVersion: "0.150.0",
createdAt: 0,
updatedAt: 0,
cwd: "/tmp/project",
ephemeral: false,
modelProvider: "openai",
preview: "",
sessionId: "session-1",
source: "cli",
status: { type: "idle" },
turns: items.length === 0 ? [] : [{ id: "turn-1", status: "completed", items }],
},
} as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/start"];
};
}

/**
* Mirrors the real client: params keep their generated types, and the raw
* payload is decoded with whichever response schema the caller supplied,
* failing exactly as the client would.
*/
function makeThreadOpenClient(
respond: (
method: "thread/start" | "thread/resume",
) => Effect.Effect<unknown, CodexErrors.CodexAppServerError>,
) {
return {
request: <M extends "thread/start" | "thread/resume", A>(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
responseSchema: Schema.Codec<A, unknown>,
) =>
respond(method).pipe(
Effect.flatMap((raw) =>
Schema.decodeUnknownEffect(responseSchema)(raw).pipe(
Effect.mapError((cause) =>
CodexErrors.CodexAppServerRequestError.invalidPayload(
method,
"decode-payload",
cause,
),
),
),
),
),
};
}

describe("buildTurnStartParams", () => {
Expand DownExpand Up@@ -752,6 +791,20 @@ describe("isRecoverableThreadResumeError", () => {
);
});

it("matches responses this build cannot decode", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
new CodexErrors.CodexAppServerRequestError({
code: -32602,
errorMessage: "Invalid payload for method 'thread/resume' during 'decode-payload'",
method: "thread/resume",
operation: "decode-payload",
}),
),
true,
);
});

it("ignores unrelated missing-resource errors that do not mention threads", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
Expand All@@ -775,27 +828,56 @@ describe("isRecoverableThreadResumeError", () => {
});

describe("openCodexThread", () => {
it.effect("falls back to thread/start when resume fails recoverably", () =>
it.effect("resumes a thread whose history uses a newer protocol variant", () =>
Effect.gen(function* () {
const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = [];
const started = makeThreadOpenResponse("fresh-thread");
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
calls.push({ method, payload });
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]);
// Opening a session must not depend on history this build cannot name
// (#8322). The kind is deliberately fictional so the test keeps
// exercising drift after the bindings learn today's values.
const resumed = makeThreadOpenResponse("resumed-thread", [
{
id: "item-18",
type: "subAgentActivity",
agentPath: "/root/child",
agentThreadId: "child-thread",
kind: "escalated",
},
};
]);
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
return Effect.succeed(resumed);
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "resumed-thread",
});

NodeAssert.equal(opened.thread.id, "resumed-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume"]);
}),
);

it.effect("falls back to thread/start when resume fails recoverably", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
Expand All@@ -808,33 +890,48 @@ describe("openCodexThread", () => {
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(
calls.map((call) => call.method),
["thread/resume", "thread/start"],
);
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("falls back to thread/start when the resume response cannot be decoded", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.succeed({ thread: {} });
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "stale-thread",
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("propagates non-recoverable resume failures", () =>
Effect.gen(function* () {
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
if (method === "thread/resume") {
return Effect.fail(
const client = makeThreadOpenClient((method) =>
method === "thread/resume"
? Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "timed out waiting for server",
}),
);
}
return Effect.succeed(
makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M],
);
},
};
)
: Effect.succeed(makeThreadOpenResponse("fresh-thread")),
);

const error = yield* openCodexThread({
client,
Expand Down
50 changes: 40 additions & 10 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -667,25 +667,47 @@ function classifyCodexStderrLine(rawLine: string): { readonly message: string }
return { message: line };
}

const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);

export function isRecoverableThreadResumeError(error: unknown): boolean {
// A response we cannot decode means Codex resumed the thread but described it
// in a protocol shape this build does not know. Retrying will never help, so
// treat it as recoverable and open a fresh Codex thread instead of leaving
// the T3 thread permanently unusable.
if (isCodexAppServerRequestError(error) && error.operation === "decode-payload") {
return true;
}
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
if (!message.includes("thread")) {
return false;
}
return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));
}

type CodexThreadOpenResponse =
| CodexRpc.ClientRequestResponsesByMethod["thread/start"]
| CodexRpc.ClientRequestResponsesByMethod["thread/resume"];
/**
* The only parts of a `thread/start` or `thread/resume` response this runtime
* consumes. Codex replays the whole thread history in those responses, and
* decoding it against generated bindings makes opening a session fail whenever
* upstream adds a protocol variant we have not regenerated yet (see #8322).
* Session state is rebuilt from notifications anyway, so read the handful of
* fields we need and let the rest pass through undecoded.
*/
const CodexThreadOpenResponse = Schema.Struct({
thread: Schema.Struct({ id: Schema.String }),
cwd: Schema.String,
model: Schema.String,
});

type CodexThreadOpenResponse = typeof CodexThreadOpenResponse.Type;

type CodexThreadOpenMethod = "thread/start" | "thread/resume";

interface CodexThreadOpenClient {
readonly request: <M extends CodexThreadOpenMethod>(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => Effect.Effect<CodexRpc.ClientRequestResponsesByMethod[M], CodexErrors.CodexAppServerError>;
responseSchema: typeof CodexThreadOpenResponse,
) => Effect.Effect<CodexThreadOpenResponse, CodexErrors.CodexAppServerError>;
}

export const openCodexThread = (input: {
Expand All@@ -706,14 +728,18 @@ export const openCodexThread = (input: {
});

if (resumeThreadId === undefined) {
return input.client.request("thread/start", startParams);
return input.client.request("thread/start", startParams, CodexThreadOpenResponse);
}

return input.client
.request("thread/resume", {
threadId: resumeThreadId,
...startParams,
})
.request(
"thread/resume",
{
threadId: resumeThreadId,
...startParams,
},
CodexThreadOpenResponse,
)
.pipe(
Effect.catchIf(isRecoverableThreadResumeError, (error) =>
Effect.logWarning("codex app-server thread resume fell back to fresh start", {
Expand All@@ -722,7 +748,11 @@ export const openCodexThread = (input: {
resumeThreadId,
recoverable: true,
cause: error,
}).pipe(Effect.andThen(input.client.request("thread/start", startParams))),
}).pipe(
Effect.andThen(
input.client.request("thread/start", startParams, CodexThreadOpenResponse),
),
),
),
);
};
Expand Down
26 changes: 26 additions & 0 deletions docs/internals/providers.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,32 @@ empty inventory is authoritative. Existing threads keep their explicit model ide
options when catalog metadata is missing; the catalog is not permission to choose a different
model for a thread.

## Generated Codex bindings

`packages/effect-codex-app-server` holds Effect/Schema bindings generated from a pinned
`openai/codex` revision (`scripts/generate.ts`). Codex ships far more often than we regenerate, so
at any moment an installed CLI may describe itself with protocol variants those bindings do not
name — a new enum member, a new item type, a newly required field.

Two rules keep that drift from breaking sessions:

- **Decode only what you consume.** `thread/start` and `thread/resume` replay an entire thread
history, and the session runtime needs three fields from it. Pass a narrow response schema as the
third argument to `client.request` rather than accepting the generated one; anything the runtime
does not read cannot then fail the request.
- **Never treat drift as fatal.** A response we cannot decode counts as a recoverable resume error,
so the thread falls back to a fresh Codex session instead of becoming permanently unopenable.
Notifications we cannot decode are dropped with a warning, never silently.

When a live event carries a value the bindings reject, teach them that one value: the generator's
definition overrides (`Codex0150DefinitionSchemas` in `scripts/generate.ts`) widen a named
definition without moving the pin. Prefer that to a full refresh, which drags in unrelated changes
and can break older CLIs — upstream adds _required_ fields too, so pinning forward breaks anyone
who has not upgraded.

None of that replaces the two rules. Overrides fix the variants we already know about; the rules
are what keep the ones we do not know about yet from being fatal.

## Model manifest

The model picker's legacy section is driven by `apps/server/src/provider/model-manifest.json`, which
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
195 changes: 146 additions & 49 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,27 +43,66 @@ describe("CodexSessionRuntimeIdentifierGenerationError", () => {
});
});

function makeThreadOpenResponse(
threadId: string,
): CodexRpc.ClientRequestResponsesByMethod["thread/start"] {
/**
* Raw `thread/start` / `thread/resume` payload as Codex puts it on the wire.
* `items` accepts arbitrary history entries so tests can replay shapes newer
* than the generated bindings.
*/
function makeThreadOpenResponse(threadId: string, items: ReadonlyArray<unknown> = []): unknown {
return {
cwd: "/tmp/project",
model: "gpt-5.3-codex",
modelProvider: "openai",
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "danger-full-access" },
sandbox: { type: "dangerFullAccess" },
thread: {
id: threadId,
createdAt: "2026-04-18T00:00:00.000Z",
source: { session: "cli" },
turns: [],
status: {
state: "idle",
activeFlags: [],
},
cliVersion: "0.150.0",
createdAt: 0,
updatedAt: 0,
cwd: "/tmp/project",
ephemeral: false,
modelProvider: "openai",
preview: "",
sessionId: "session-1",
source: "cli",
status: { type: "idle" },
turns: items.length === 0 ? [] : [{ id: "turn-1", status: "completed", items }],
},
} as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/start"];
};
}

/**
* Mirrors the real client: params keep their generated types, and the raw
* payload is decoded with whichever response schema the caller supplied,
* failing exactly as the client would.
*/
function makeThreadOpenClient(
respond: (
method: "thread/start" | "thread/resume",
) => Effect.Effect<unknown, CodexErrors.CodexAppServerError>,
) {
return {
request: <M extends "thread/start" | "thread/resume", A>(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
responseSchema: Schema.Codec<A, unknown>,
) =>
respond(method).pipe(
Effect.flatMap((raw) =>
Schema.decodeUnknownEffect(responseSchema)(raw).pipe(
Effect.mapError((cause) =>
CodexErrors.CodexAppServerRequestError.invalidPayload(
method,
"decode-payload",
cause,
),
),
),
),
),
};
}

describe("buildTurnStartParams", () => {
Expand DownExpand Up@@ -752,6 +791,20 @@ describe("isRecoverableThreadResumeError", () => {
);
});

it("matches responses this build cannot decode", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
new CodexErrors.CodexAppServerRequestError({
code: -32602,
errorMessage: "Invalid payload for method 'thread/resume' during 'decode-payload'",
method: "thread/resume",
operation: "decode-payload",
}),
),
true,
);
});

it("ignores unrelated missing-resource errors that do not mention threads", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
Expand All@@ -775,27 +828,56 @@ describe("isRecoverableThreadResumeError", () => {
});

describe("openCodexThread", () => {
it.effect("falls back to thread/start when resume fails recoverably", () =>
it.effect("resumes a thread whose history uses a newer protocol variant", () =>
Effect.gen(function* () {
const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = [];
const started = makeThreadOpenResponse("fresh-thread");
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
calls.push({ method, payload });
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]);
// Opening a session must not depend on history this build cannot name
// (#8322). The kind is deliberately fictional so the test keeps
// exercising drift after the bindings learn today's values.
const resumed = makeThreadOpenResponse("resumed-thread", [
{
id: "item-18",
type: "subAgentActivity",
agentPath: "/root/child",
agentThreadId: "child-thread",
kind: "escalated",
},
};
]);
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
return Effect.succeed(resumed);
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "resumed-thread",
});

NodeAssert.equal(opened.thread.id, "resumed-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume"]);
}),
);

it.effect("falls back to thread/start when resume fails recoverably", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
Expand All@@ -808,33 +890,48 @@ describe("openCodexThread", () => {
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(
calls.map((call) => call.method),
["thread/resume", "thread/start"],
);
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("falls back to thread/start when the resume response cannot be decoded", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.succeed({ thread: {} });
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "stale-thread",
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("propagates non-recoverable resume failures", () =>
Effect.gen(function* () {
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
if (method === "thread/resume") {
return Effect.fail(
const client = makeThreadOpenClient((method) =>
method === "thread/resume"
? Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "timed out waiting for server",
}),
);
}
return Effect.succeed(
makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M],
);
},
};
)
: Effect.succeed(makeThreadOpenResponse("fresh-thread")),
);

const error = yield* openCodexThread({
client,
Expand Down
50 changes: 40 additions & 10 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -667,25 +667,47 @@ function classifyCodexStderrLine(rawLine: string): { readonly message: string }
return { message: line };
}

const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);

export function isRecoverableThreadResumeError(error: unknown): boolean {
// A response we cannot decode means Codex resumed the thread but described it
// in a protocol shape this build does not know. Retrying will never help, so
// treat it as recoverable and open a fresh Codex thread instead of leaving
// the T3 thread permanently unusable.
if (isCodexAppServerRequestError(error) && error.operation === "decode-payload") {
return true;
}
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
if (!message.includes("thread")) {
return false;
}
return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));
}

type CodexThreadOpenResponse =
| CodexRpc.ClientRequestResponsesByMethod["thread/start"]
| CodexRpc.ClientRequestResponsesByMethod["thread/resume"];
/**
* The only parts of a `thread/start` or `thread/resume` response this runtime
* consumes. Codex replays the whole thread history in those responses, and
* decoding it against generated bindings makes opening a session fail whenever
* upstream adds a protocol variant we have not regenerated yet (see #8322).
* Session state is rebuilt from notifications anyway, so read the handful of
* fields we need and let the rest pass through undecoded.
*/
const CodexThreadOpenResponse = Schema.Struct({
thread: Schema.Struct({ id: Schema.String }),
cwd: Schema.String,
model: Schema.String,
});

type CodexThreadOpenResponse = typeof CodexThreadOpenResponse.Type;

type CodexThreadOpenMethod = "thread/start" | "thread/resume";

interface CodexThreadOpenClient {
readonly request: <M extends CodexThreadOpenMethod>(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => Effect.Effect<CodexRpc.ClientRequestResponsesByMethod[M], CodexErrors.CodexAppServerError>;
responseSchema: typeof CodexThreadOpenResponse,
) => Effect.Effect<CodexThreadOpenResponse, CodexErrors.CodexAppServerError>;
}

export const openCodexThread = (input: {
Expand All@@ -706,14 +728,18 @@ export const openCodexThread = (input: {
});

if (resumeThreadId === undefined) {
return input.client.request("thread/start", startParams);
return input.client.request("thread/start", startParams, CodexThreadOpenResponse);
}

return input.client
.request("thread/resume", {
threadId: resumeThreadId,
...startParams,
})
.request(
"thread/resume",
{
threadId: resumeThreadId,
...startParams,
},
CodexThreadOpenResponse,
)
.pipe(
Effect.catchIf(isRecoverableThreadResumeError, (error) =>
Effect.logWarning("codex app-server thread resume fell back to fresh start", {
Expand All@@ -722,7 +748,11 @@ export const openCodexThread = (input: {
resumeThreadId,
recoverable: true,
cause: error,
}).pipe(Effect.andThen(input.client.request("thread/start", startParams))),
}).pipe(
Effect.andThen(
input.client.request("thread/start", startParams, CodexThreadOpenResponse),
),
),
),
);
};
Expand Down
26 changes: 26 additions & 0 deletions docs/internals/providers.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,32 @@ empty inventory is authoritative. Existing threads keep their explicit model ide
options when catalog metadata is missing; the catalog is not permission to choose a different
model for a thread.

## Generated Codex bindings

`packages/effect-codex-app-server` holds Effect/Schema bindings generated from a pinned
`openai/codex` revision (`scripts/generate.ts`). Codex ships far more often than we regenerate, so
at any moment an installed CLI may describe itself with protocol variants those bindings do not
name — a new enum member, a new item type, a newly required field.

Two rules keep that drift from breaking sessions:

- **Decode only what you consume.** `thread/start` and `thread/resume` replay an entire thread
history, and the session runtime needs three fields from it. Pass a narrow response schema as the
third argument to `client.request` rather than accepting the generated one; anything the runtime
does not read cannot then fail the request.
- **Never treat drift as fatal.** A response we cannot decode counts as a recoverable resume error,
so the thread falls back to a fresh Codex session instead of becoming permanently unopenable.
Notifications we cannot decode are dropped with a warning, never silently.

When a live event carries a value the bindings reject, teach them that one value: the generator's
definition overrides (`Codex0150DefinitionSchemas` in `scripts/generate.ts`) widen a named
definition without moving the pin. Prefer that to a full refresh, which drags in unrelated changes
and can break older CLIs — upstream adds _required_ fields too, so pinning forward breaks anyone
who has not upgraded.

None of that replaces the two rules. Overrides fix the variants we already know about; the rules
are what keep the ones we do not know about yet from being fatal.

## Model manifest

The model picker's legacy section is driven by `apps/server/src/provider/model-manifest.json`, which
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
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
195 changes: 146 additions & 49 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,27 +43,66 @@ describe("CodexSessionRuntimeIdentifierGenerationError", () => {
});
});

function makeThreadOpenResponse(
threadId: string,
): CodexRpc.ClientRequestResponsesByMethod["thread/start"] {
/**
* Raw `thread/start` / `thread/resume` payload as Codex puts it on the wire.
* `items` accepts arbitrary history entries so tests can replay shapes newer
* than the generated bindings.
*/
function makeThreadOpenResponse(threadId: string, items: ReadonlyArray<unknown> = []): unknown {
return {
cwd: "/tmp/project",
model: "gpt-5.3-codex",
modelProvider: "openai",
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "danger-full-access" },
sandbox: { type: "dangerFullAccess" },
thread: {
id: threadId,
createdAt: "2026-04-18T00:00:00.000Z",
source: { session: "cli" },
turns: [],
status: {
state: "idle",
activeFlags: [],
},
cliVersion: "0.150.0",
createdAt: 0,
updatedAt: 0,
cwd: "/tmp/project",
ephemeral: false,
modelProvider: "openai",
preview: "",
sessionId: "session-1",
source: "cli",
status: { type: "idle" },
turns: items.length === 0 ? [] : [{ id: "turn-1", status: "completed", items }],
},
} as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/start"];
};
}

/**
* Mirrors the real client: params keep their generated types, and the raw
* payload is decoded with whichever response schema the caller supplied,
* failing exactly as the client would.
*/
function makeThreadOpenClient(
respond: (
method: "thread/start" | "thread/resume",
) => Effect.Effect<unknown, CodexErrors.CodexAppServerError>,
) {
return {
request: <M extends "thread/start" | "thread/resume", A>(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
responseSchema: Schema.Codec<A, unknown>,
) =>
respond(method).pipe(
Effect.flatMap((raw) =>
Schema.decodeUnknownEffect(responseSchema)(raw).pipe(
Effect.mapError((cause) =>
CodexErrors.CodexAppServerRequestError.invalidPayload(
method,
"decode-payload",
cause,
),
),
),
),
),
};
}

describe("buildTurnStartParams", () => {
Expand DownExpand Up@@ -752,6 +791,20 @@ describe("isRecoverableThreadResumeError", () => {
);
});

it("matches responses this build cannot decode", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
new CodexErrors.CodexAppServerRequestError({
code: -32602,
errorMessage: "Invalid payload for method 'thread/resume' during 'decode-payload'",
method: "thread/resume",
operation: "decode-payload",
}),
),
true,
);
});

it("ignores unrelated missing-resource errors that do not mention threads", () => {
NodeAssert.equal(
isRecoverableThreadResumeError(
Expand All@@ -775,27 +828,56 @@ describe("isRecoverableThreadResumeError", () => {
});

describe("openCodexThread", () => {
it.effect("falls back to thread/start when resume fails recoverably", () =>
it.effect("resumes a thread whose history uses a newer protocol variant", () =>
Effect.gen(function* () {
const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = [];
const started = makeThreadOpenResponse("fresh-thread");
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
calls.push({ method, payload });
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]);
// Opening a session must not depend on history this build cannot name
// (#8322). The kind is deliberately fictional so the test keeps
// exercising drift after the bindings learn today's values.
const resumed = makeThreadOpenResponse("resumed-thread", [
{
id: "item-18",
type: "subAgentActivity",
agentPath: "/root/child",
agentThreadId: "child-thread",
kind: "escalated",
},
};
]);
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
return Effect.succeed(resumed);
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "resumed-thread",
});

NodeAssert.equal(opened.thread.id, "resumed-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume"]);
}),
);

it.effect("falls back to thread/start when resume fails recoverably", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "thread not found",
}),
);
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
Expand All@@ -808,33 +890,48 @@ describe("openCodexThread", () => {
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(
calls.map((call) => call.method),
["thread/resume", "thread/start"],
);
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("falls back to thread/start when the resume response cannot be decoded", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const client = makeThreadOpenClient((method) => {
calls.push(method);
if (method === "thread/resume") {
return Effect.succeed({ thread: {} });
}
return Effect.succeed(makeThreadOpenResponse("fresh-thread"));
});

const opened = yield* openCodexThread({
client,
threadId: ThreadId.make("thread-1"),
runtimeMode: "full-access",
cwd: "/tmp/project",
requestedModel: "gpt-5.3-codex",
serviceTier: undefined,
resumeThreadId: "stale-thread",
});

NodeAssert.equal(opened.thread.id, "fresh-thread");
NodeAssert.deepStrictEqual(calls, ["thread/resume", "thread/start"]);
}),
);

it.effect("propagates non-recoverable resume failures", () =>
Effect.gen(function* () {
const client = {
request: <M extends "thread/start" | "thread/resume">(
method: M,
_payload: CodexRpc.ClientRequestParamsByMethod[M],
) => {
if (method === "thread/resume") {
return Effect.fail(
const client = makeThreadOpenClient((method) =>
method === "thread/resume"
? Effect.fail(
new CodexErrors.CodexAppServerRequestError({
code: -32603,
errorMessage: "timed out waiting for server",
}),
);
}
return Effect.succeed(
makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M],
);
},
};
)
: Effect.succeed(makeThreadOpenResponse("fresh-thread")),
);

const error = yield* openCodexThread({
client,
Expand Down
50 changes: 40 additions & 10 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -667,25 +667,47 @@ function classifyCodexStderrLine(rawLine: string): { readonly message: string }
return { message: line };
}

const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);

export function isRecoverableThreadResumeError(error: unknown): boolean {
// A response we cannot decode means Codex resumed the thread but described it
// in a protocol shape this build does not know. Retrying will never help, so
// treat it as recoverable and open a fresh Codex thread instead of leaving
// the T3 thread permanently unusable.
if (isCodexAppServerRequestError(error) && error.operation === "decode-payload") {
return true;
}
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
if (!message.includes("thread")) {
return false;
}
return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));
}

type CodexThreadOpenResponse =
| CodexRpc.ClientRequestResponsesByMethod["thread/start"]
| CodexRpc.ClientRequestResponsesByMethod["thread/resume"];
/**
* The only parts of a `thread/start` or `thread/resume` response this runtime
* consumes. Codex replays the whole thread history in those responses, and
* decoding it against generated bindings makes opening a session fail whenever
* upstream adds a protocol variant we have not regenerated yet (see #8322).
* Session state is rebuilt from notifications anyway, so read the handful of
* fields we need and let the rest pass through undecoded.
*/
const CodexThreadOpenResponse = Schema.Struct({
thread: Schema.Struct({ id: Schema.String }),
cwd: Schema.String,
model: Schema.String,
});

type CodexThreadOpenResponse = typeof CodexThreadOpenResponse.Type;

type CodexThreadOpenMethod = "thread/start" | "thread/resume";

interface CodexThreadOpenClient {
readonly request: <M extends CodexThreadOpenMethod>(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
) => Effect.Effect<CodexRpc.ClientRequestResponsesByMethod[M], CodexErrors.CodexAppServerError>;
responseSchema: typeof CodexThreadOpenResponse,
) => Effect.Effect<CodexThreadOpenResponse, CodexErrors.CodexAppServerError>;
}

export const openCodexThread = (input: {
Expand All@@ -706,14 +728,18 @@ export const openCodexThread = (input: {
});

if (resumeThreadId === undefined) {
return input.client.request("thread/start", startParams);
return input.client.request("thread/start", startParams, CodexThreadOpenResponse);
}

return input.client
.request("thread/resume", {
threadId: resumeThreadId,
...startParams,
})
.request(
"thread/resume",
{
threadId: resumeThreadId,
...startParams,
},
CodexThreadOpenResponse,
)
.pipe(
Effect.catchIf(isRecoverableThreadResumeError, (error) =>
Effect.logWarning("codex app-server thread resume fell back to fresh start", {
Expand All@@ -722,7 +748,11 @@ export const openCodexThread = (input: {
resumeThreadId,
recoverable: true,
cause: error,
}).pipe(Effect.andThen(input.client.request("thread/start", startParams))),
}).pipe(
Effect.andThen(
input.client.request("thread/start", startParams, CodexThreadOpenResponse),
),
),
),
);
};
Expand Down
26 changes: 26 additions & 0 deletions docs/internals/providers.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,32 @@ empty inventory is authoritative. Existing threads keep their explicit model ide
options when catalog metadata is missing; the catalog is not permission to choose a different
model for a thread.

## Generated Codex bindings

`packages/effect-codex-app-server` holds Effect/Schema bindings generated from a pinned
`openai/codex` revision (`scripts/generate.ts`). Codex ships far more often than we regenerate, so
at any moment an installed CLI may describe itself with protocol variants those bindings do not
name — a new enum member, a new item type, a newly required field.

Two rules keep that drift from breaking sessions:

- **Decode only what you consume.** `thread/start` and `thread/resume` replay an entire thread
history, and the session runtime needs three fields from it. Pass a narrow response schema as the
third argument to `client.request` rather than accepting the generated one; anything the runtime
does not read cannot then fail the request.
- **Never treat drift as fatal.** A response we cannot decode counts as a recoverable resume error,
so the thread falls back to a fresh Codex session instead of becoming permanently unopenable.
Notifications we cannot decode are dropped with a warning, never silently.

When a live event carries a value the bindings reject, teach them that one value: the generator's
definition overrides (`Codex0150DefinitionSchemas` in `scripts/generate.ts`) widen a named
definition without moving the pin. Prefer that to a full refresh, which drags in unrelated changes
and can break older CLIs — upstream adds _required_ fields too, so pinning forward breaks anyone
who has not upgraded.

None of that replaces the two rules. Overrides fix the variants we already know about; the rules
are what keep the ones we do not know about yet from being fatal.

## Model manifest

The model picker's legacy section is driven by `apps/server/src/provider/model-manifest.json`, which
Expand Down
Loading
Loading