Skip to content

Commit 430ece4

Browse files
[codex] Structure Codex app-server request errors (#3258)
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 4238a0e commit 430ece4

8 files changed

Lines changed: 631 additions & 158 deletions

File tree

apps/server/src/provider/Layers/CodexSessionRuntime.test.ts

Lines changed: 108 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import * as NodeAssert from "node:assert/strict";
22

3+
import { it } from "@effect/vitest";
34
import * as Effect from "effect/Effect";
45
import * as Schema from "effect/Schema";
5-
import { describe, it } from "vite-plus/test";
6+
import { describe } from "vite-plus/test";
67
import { ThreadId } from "@t3tools/contracts";
78
import * as CodexErrors from "effect-codex-app-server/errors";
89
import * as CodexRpc from "effect-codex-app-server/rpc";
@@ -19,6 +20,23 @@ import {
1920
} from "./CodexSessionRuntime.ts";
2021
const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);
2122

23+
describe("CodexSessionRuntimeIdentifierGenerationError", () => {
24+
it("retains identifier purpose and the random source failure", () => {
25+
const cause = new Error("random source unavailable");
26+
const error = new CodexErrors.CodexAppServerIdentifierGenerationError({
27+
purpose: "provider-event",
28+
cause,
29+
});
30+
31+
NodeAssert.equal(error.purpose, "provider-event");
32+
NodeAssert.strictEqual(error.cause, cause);
33+
NodeAssert.equal(
34+
error.message,
35+
"Failed to generate Codex App Server identifier for provider-event.",
36+
);
37+
});
38+
});
39+
2240
function makeThreadOpenResponse(
2341
threadId: string,
2442
): CodexRpc.ClientRequestResponsesByMethod["thread/start"] {
@@ -43,6 +61,32 @@ function makeThreadOpenResponse(
4361
}
4462

4563
describe("buildTurnStartParams", () => {
64+
it("keeps invalid turn values only in the schema cause", () => {
65+
const secret = "codex-turn-input-secret-sentinel";
66+
const error = Effect.runSync(
67+
buildTurnStartParams({
68+
threadId: "provider-thread-1",
69+
runtimeMode: "full-access",
70+
attachments: [
71+
{
72+
type: "image",
73+
url: { secret } as unknown as string,
74+
},
75+
],
76+
}).pipe(Effect.flip),
77+
);
78+
const { cause, ...directDiagnostics } = error;
79+
80+
NodeAssert.equal(error.operation, "decode-request-payload");
81+
NodeAssert.equal(error.method, "turn/start");
82+
NodeAssert.ok((error.issueCount ?? 0) > 0);
83+
NodeAssert.ok(error.issueKinds?.includes("Pointer"));
84+
NodeAssert.ok((error.maximumPathDepth ?? 0) > 0);
85+
NodeAssert.ok(Schema.isSchemaError(cause));
86+
NodeAssert.doesNotMatch(error.message, new RegExp(secret));
87+
NodeAssert.doesNotMatch(JSON.stringify(directDiagnostics), new RegExp(secret));
88+
});
89+
4690
it("includes plan collaboration mode when requested", () => {
4791
const params = Effect.runSync(
4892
buildTurnStartParams({
@@ -223,81 +267,79 @@ describe("isRecoverableThreadResumeError", () => {
223267
});
224268

225269
describe("openCodexThread", () => {
226-
it("falls back to thread/start when resume fails recoverably", async () => {
227-
const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = [];
228-
const started = makeThreadOpenResponse("fresh-thread");
229-
const client = {
230-
request: <M extends "thread/start" | "thread/resume">(
231-
method: M,
232-
payload: CodexRpc.ClientRequestParamsByMethod[M],
233-
) => {
234-
calls.push({ method, payload });
235-
if (method === "thread/resume") {
236-
return Effect.fail(
237-
new CodexErrors.CodexAppServerRequestError({
238-
code: -32603,
239-
errorMessage: "thread not found",
240-
}),
241-
);
242-
}
243-
return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]);
244-
},
245-
};
270+
it.effect("falls back to thread/start when resume fails recoverably", () =>
271+
Effect.gen(function* () {
272+
const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = [];
273+
const started = makeThreadOpenResponse("fresh-thread");
274+
const client = {
275+
request: <M extends "thread/start" | "thread/resume">(
276+
method: M,
277+
payload: CodexRpc.ClientRequestParamsByMethod[M],
278+
) => {
279+
calls.push({ method, payload });
280+
if (method === "thread/resume") {
281+
return Effect.fail(
282+
new CodexErrors.CodexAppServerRequestError({
283+
code: -32603,
284+
errorMessage: "thread not found",
285+
}),
286+
);
287+
}
288+
return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]);
289+
},
290+
};
246291

247-
const opened = await Effect.runPromise(
248-
openCodexThread({
292+
const opened = yield* openCodexThread({
249293
client,
250294
threadId: ThreadId.make("thread-1"),
251295
runtimeMode: "full-access",
252296
cwd: "/tmp/project",
253297
requestedModel: "gpt-5.3-codex",
254298
serviceTier: undefined,
255299
resumeThreadId: "stale-thread",
256-
}),
257-
);
300+
});
258301

259-
NodeAssert.equal(opened.thread.id, "fresh-thread");
260-
NodeAssert.deepStrictEqual(
261-
calls.map((call) => call.method),
262-
["thread/resume", "thread/start"],
263-
);
264-
});
302+
NodeAssert.equal(opened.thread.id, "fresh-thread");
303+
NodeAssert.deepStrictEqual(
304+
calls.map((call) => call.method),
305+
["thread/resume", "thread/start"],
306+
);
307+
}),
308+
);
265309

266-
it("propagates non-recoverable resume failures", async () => {
267-
const client = {
268-
request: <M extends "thread/start" | "thread/resume">(
269-
method: M,
270-
_payload: CodexRpc.ClientRequestParamsByMethod[M],
271-
) => {
272-
if (method === "thread/resume") {
273-
return Effect.fail(
274-
new CodexErrors.CodexAppServerRequestError({
275-
code: -32603,
276-
errorMessage: "timed out waiting for server",
277-
}),
310+
it.effect("propagates non-recoverable resume failures", () =>
311+
Effect.gen(function* () {
312+
const client = {
313+
request: <M extends "thread/start" | "thread/resume">(
314+
method: M,
315+
_payload: CodexRpc.ClientRequestParamsByMethod[M],
316+
) => {
317+
if (method === "thread/resume") {
318+
return Effect.fail(
319+
new CodexErrors.CodexAppServerRequestError({
320+
code: -32603,
321+
errorMessage: "timed out waiting for server",
322+
}),
323+
);
324+
}
325+
return Effect.succeed(
326+
makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M],
278327
);
279-
}
280-
return Effect.succeed(
281-
makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M],
282-
);
283-
},
284-
};
328+
},
329+
};
285330

286-
await NodeAssert.rejects(
287-
Effect.runPromise(
288-
openCodexThread({
289-
client,
290-
threadId: ThreadId.make("thread-1"),
291-
runtimeMode: "full-access",
292-
cwd: "/tmp/project",
293-
requestedModel: "gpt-5.3-codex",
294-
serviceTier: undefined,
295-
resumeThreadId: "stale-thread",
296-
}),
297-
),
298-
(error: unknown) =>
299-
isCodexAppServerRequestError(error) &&
300-
error.errorMessage === "timed out waiting for server",
301-
);
302-
});
331+
const error = yield* openCodexThread({
332+
client,
333+
threadId: ThreadId.make("thread-1"),
334+
runtimeMode: "full-access",
335+
cwd: "/tmp/project",
336+
requestedModel: "gpt-5.3-codex",
337+
serviceTier: undefined,
338+
resumeThreadId: "stale-thread",
339+
}).pipe(Effect.flip);
340+
341+
NodeAssert.ok(isCodexAppServerRequestError(error));
342+
NodeAssert.equal(error.errorMessage, "timed out waiting for server");
343+
}),
344+
);
303345
});

apps/server/src/provider/Layers/CodexSessionRuntime.ts

Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,9 @@ import * as Exit from "effect/Exit";
2626
import * as Layer from "effect/Layer";
2727
import * as Queue from "effect/Queue";
2828
import * as Ref from "effect/Ref";
29-
import * as Scope from "effect/Scope";
3029
import * as Schema from "effect/Schema";
30+
import * as Scope from "effect/Scope";
3131
import * as Stream from "effect/Stream";
32-
import * as SchemaIssue from "effect/SchemaIssue";
3332
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3433
import * as CodexClient from "effect-codex-app-server/client";
3534
import * as CodexErrors from "effect-codex-app-server/errors";
@@ -89,7 +88,6 @@ const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffe
8988

9089
export type CodexTurnStartParamsWithCollaborationMode =
9190
typeof CodexTurnStartParamsWithCollaborationMode.Type;
92-
const formatSchemaIssue = SchemaIssue.makeFormatterDefault();
9391

9492
export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type;
9593
type CodexServiceTier = NonNullable<EffectCodexSchema.V2ThreadStartParams["serviceTier"]>;
@@ -390,7 +388,13 @@ export function buildTurnStartParams(input: {
390388
...(input.effort ? { effort: input.effort } : {}),
391389
...(collaborationMode ? { collaborationMode } : {}),
392390
}).pipe(
393-
Effect.mapError((error) => toProtocolParseError("Invalid turn/start request payload", error)),
391+
Effect.mapError((cause) =>
392+
CodexErrors.CodexAppServerProtocolParseError.fromSchemaError(
393+
"decode-request-payload",
394+
cause,
395+
{ method: "turn/start" },
396+
),
397+
),
394398
);
395399
}
396400

@@ -468,7 +472,7 @@ export const openCodexThread = (input: {
468472
requestedRuntimeMode: input.runtimeMode,
469473
resumeThreadId,
470474
recoverable: true,
471-
cause: error.message,
475+
cause: error,
472476
}).pipe(Effect.andThen(input.client.request("thread/start", startParams))),
473477
),
474478
);
@@ -658,16 +662,6 @@ function toCodexUserInputAnswers(
658662
).pipe(Effect.map((entries) => Object.fromEntries(entries)));
659663
}
660664

661-
function toProtocolParseError(
662-
detail: string,
663-
cause: Schema.SchemaError,
664-
): CodexErrors.CodexAppServerProtocolParseError {
665-
return new CodexErrors.CodexAppServerProtocolParseError({
666-
detail: `${detail}: ${formatSchemaIssue(cause.issue)}`,
667-
cause,
668-
});
669-
}
670-
671665
function currentProviderThreadId(session: ProviderSession): string | undefined {
672666
return readResumeCursorThreadId(session.resumeCursor);
673667
}
@@ -760,15 +754,16 @@ export const makeCodexSessionRuntime = (
760754
);
761755
const serverNotifications = yield* Queue.unbounded<CodexServerNotification>();
762756
const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
763-
const randomUUIDv4 = crypto.randomUUIDv4.pipe(
764-
Effect.mapError(
765-
(cause) =>
766-
new CodexErrors.CodexAppServerTransportError({
767-
detail: "Failed to generate Codex runtime identifier.",
768-
cause,
769-
}),
770-
),
771-
);
757+
const randomUUIDv4 = (purpose: CodexErrors.CodexAppServerIdentifierPurpose) =>
758+
crypto.randomUUIDv4.pipe(
759+
Effect.mapError(
760+
(cause) =>
761+
new CodexErrors.CodexAppServerIdentifierGenerationError({
762+
purpose,
763+
cause,
764+
}),
765+
),
766+
);
772767

773768
const sessionCreatedAt = yield* nowIso;
774769
const initialSession = {
@@ -788,7 +783,7 @@ export const makeCodexSessionRuntime = (
788783

789784
const emitEvent = (event: Omit<ProviderEvent, "id" | "provider" | "createdAt">) =>
790785
Effect.gen(function* () {
791-
const id = yield* randomUUIDv4;
786+
const id = yield* randomUUIDv4("provider-event");
792787
return yield* offerEvent({
793788
id: EventId.make(id),
794789
provider: PROVIDER,
@@ -956,7 +951,7 @@ export const makeCodexSessionRuntime = (
956951

957952
yield* client.handleServerRequest("item/commandExecution/requestApproval", (payload) =>
958953
Effect.gen(function* () {
959-
const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
954+
const requestId = ApprovalRequestId.make(yield* randomUUIDv4("command-approval-request"));
960955
const turnId = TurnId.make(payload.turnId);
961956
const itemId = ProviderItemId.make(payload.itemId);
962957
const decision = yield* Deferred.make<ProviderApprovalDecision>();
@@ -1012,7 +1007,9 @@ export const makeCodexSessionRuntime = (
10121007

10131008
yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) =>
10141009
Effect.gen(function* () {
1015-
const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
1010+
const requestId = ApprovalRequestId.make(
1011+
yield* randomUUIDv4("file-change-approval-request"),
1012+
);
10161013
const turnId = TurnId.make(payload.turnId);
10171014
const itemId = ProviderItemId.make(payload.itemId);
10181015
const decision = yield* Deferred.make<ProviderApprovalDecision>();
@@ -1068,7 +1065,7 @@ export const makeCodexSessionRuntime = (
10681065

10691066
yield* client.handleServerRequest("item/tool/requestUserInput", (payload) =>
10701067
Effect.gen(function* () {
1071-
const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
1068+
const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request"));
10721069
const turnId = TurnId.make(payload.turnId);
10731070
const itemId = ProviderItemId.make(payload.itemId);
10741071
const answers = yield* Deferred.make<ProviderUserInputAnswers>();
@@ -1293,7 +1290,11 @@ export const makeCodexSessionRuntime = (
12931290
const rawResponse = yield* client.raw.request("turn/start", params);
12941291
const response = yield* decodeV2TurnStartResponse(rawResponse).pipe(
12951292
Effect.mapError((error) =>
1296-
toProtocolParseError("Invalid turn/start response payload", error),
1293+
CodexErrors.CodexAppServerProtocolParseError.fromSchemaError(
1294+
"decode-response-payload",
1295+
error,
1296+
{ method: "turn/start" },
1297+
),
12971298
),
12981299
);
12991300
const turnId = TurnId.make(response.turn.id);

0 commit comments

Comments
 (0)